From beb9caa2645a79a0698bc25443cc713fa0979b12 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 12 Jul 2026 12:19:28 +0200 Subject: [PATCH 1/9] feat(api): agent mount sign + query endpoints Claude-Session: https://claude.ai/code/session_01UogudBC9VRCRkpT5bZVxex --- api/oss/src/apis/fastapi/mounts/models.py | 5 + api/oss/src/apis/fastapi/mounts/router.py | 68 +++++++ api/oss/src/core/mounts/interfaces.py | 9 + api/oss/src/core/mounts/service.py | 50 +++++ api/oss/src/core/mounts/types.py | 6 + api/oss/src/dbs/postgres/mounts/dao.py | 23 +++ .../mounts/test_agent_mounts_basics.py | 69 +++++++ .../pytest/unit/mounts/test_agent_mounts.py | 181 ++++++++++++++++++ 8 files changed, 411 insertions(+) create mode 100644 api/oss/tests/pytest/acceptance/mounts/test_agent_mounts_basics.py create mode 100644 api/oss/tests/pytest/unit/mounts/test_agent_mounts.py diff --git a/api/oss/src/apis/fastapi/mounts/models.py b/api/oss/src/apis/fastapi/mounts/models.py index b3b7c0a05b..84af63abd6 100644 --- a/api/oss/src/apis/fastapi/mounts/models.py +++ b/api/oss/src/apis/fastapi/mounts/models.py @@ -31,6 +31,11 @@ class MountQueryRequest(BaseModel): windowing: Optional[Windowing] = None +class AgentMountQueryRequest(BaseModel): + artifact_id: str + name: str = "default" + + # --------------------------------------------------------------------------- # Response models # --------------------------------------------------------------------------- diff --git a/api/oss/src/apis/fastapi/mounts/router.py b/api/oss/src/apis/fastapi/mounts/router.py index e58da5e249..c90715127f 100644 --- a/api/oss/src/apis/fastapi/mounts/router.py +++ b/api/oss/src/apis/fastapi/mounts/router.py @@ -12,6 +12,7 @@ from oss.src.core.mounts.service import MountsService from oss.src.core.mounts.types import ( + MountArtifactIdInvalid, MountDataInvalid, MountFileNotFound, MountImmutableField, @@ -24,6 +25,7 @@ ) from oss.src.apis.fastapi.mounts.models import ( + AgentMountQueryRequest, MountCreateRequest, MountCredentialsResponse, MountEditRequest, @@ -65,6 +67,11 @@ async def wrapper(*args, **kwargs): status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=e.message, ) from e + except MountArtifactIdInvalid as e: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=e.message, + ) from e except MountSlugConflict as e: raise HTTPException( status_code=status.HTTP_409_CONFLICT, @@ -130,6 +137,25 @@ def __init__( response_model_exclude_none=True, status_code=status.HTTP_200_OK, ) + # Fixed agent sub-paths must be registered before "/{mount_id}" so they win. + self.router.add_api_route( + "/agents/sign", + self.sign_agent_mount_credentials, + methods=["POST"], + operation_id="sign_agent_mount_credentials", + response_model=MountCredentialsResponse, + response_model_exclude_none=True, + status_code=status.HTTP_200_OK, + ) + self.router.add_api_route( + "/agents/query", + self.query_agent_mount, + methods=["POST"], + operation_id="query_agent_mount", + response_model=MountsResponse, + response_model_exclude_none=True, + status_code=status.HTTP_200_OK, + ) self.router.add_api_route( "/{mount_id}", self.fetch_mount, @@ -284,6 +310,48 @@ async def query_mounts( return MountsResponse(count=len(mounts), mounts=mounts) + @intercept_exceptions() + @handle_mount_exceptions() + async def sign_agent_mount_credentials( + self, + request: Request, + *, + artifact_id: str = Query(...), + name: str = Query(default="default"), + ) -> MountCredentialsResponse: + await self._check(request, Permission.RUN_SESSIONS) + + mount = await self.mounts_service.get_or_create_agent_mount( + project_id=UUID(request.state.project_id), + user_id=UUID(str(request.state.user_id)), + artifact_id=artifact_id, + name=name, + ) + credentials = await sign_mount_credentials( + mounts_service=self.mounts_service, + project_id=UUID(request.state.project_id), + mount_id=mount.id, + ) + return MountCredentialsResponse(count=1, mount=mount, credentials=credentials) + + @intercept_exceptions() + @handle_mount_exceptions() + async def query_agent_mount( + self, + request: Request, + *, + body: AgentMountQueryRequest, + ) -> MountsResponse: + await self._check(request, Permission.VIEW_SESSIONS) + + mount = await self.mounts_service.fetch_agent_mount( + project_id=UUID(request.state.project_id), + artifact_id=body.artifact_id, + name=body.name, + ) + mounts = [mount] if mount else [] + return MountsResponse(count=len(mounts), mounts=mounts) + @intercept_exceptions() async def fetch_mount( self, diff --git a/api/oss/src/core/mounts/interfaces.py b/api/oss/src/core/mounts/interfaces.py index 5f46a0ce73..fa26dc530b 100644 --- a/api/oss/src/core/mounts/interfaces.py +++ b/api/oss/src/core/mounts/interfaces.py @@ -36,6 +36,15 @@ async def fetch_mount( mount_id: UUID, ) -> Optional[Mount]: ... + @abstractmethod + async def fetch_mount_by_slug( + self, + *, + project_id: UUID, + # + slug: str, + ) -> Optional[Mount]: ... + @abstractmethod async def edit_mount( self, diff --git a/api/oss/src/core/mounts/service.py b/api/oss/src/core/mounts/service.py index 0e0ef597ef..50f688ce3d 100644 --- a/api/oss/src/core/mounts/service.py +++ b/api/oss/src/core/mounts/service.py @@ -18,6 +18,7 @@ from oss.src.core.mounts.interfaces import MountsDAOInterface from oss.src.core.store.storage import ObjectStore from oss.src.core.mounts.types import ( + MountArtifactIdInvalid, MountFileNotFound, MountNameInvalid, MountNotFound, @@ -73,6 +74,21 @@ def mint_session_slug(*, session_id: str, name: str) -> str: return f"{_RESERVED_SLUG_PREFIX}{uuid5(_MOUNTS_NAMESPACE, session_id)}__{slugify_mount_name(name)}" +def mint_agent_slug(*, artifact_id: str, name: str) -> str: + """Mint the deterministic reserved slug for an artifact mount. + + Artifact IDs are UUID-parsed and rendered lowercase. Sign and query must use + this same derivation byte-identically so they address the same mount. + """ + try: + canonical_artifact_id = UUID(str(artifact_id)) + except (ValueError, TypeError, AttributeError) as e: + raise MountArtifactIdInvalid(str(artifact_id)) from e + + slug_name = slugify_mount_name(name) + return f"{_RESERVED_SLUG_PREFIX}agent__{canonical_artifact_id}__{slug_name}" + + def reject_reserved_slug(slug: str) -> None: """A caller may not author a slug in the reserved namespace (the service mints those).""" if slug.startswith(_RESERVED_SLUG_PREFIX): @@ -188,6 +204,26 @@ async def get_or_create_session_mount( mount_create=mount_create, ) + async def get_or_create_agent_mount( + self, + *, + project_id: UUID, + user_id: UUID, + artifact_id: str, + name: str = "default", + ) -> Mount: + """Bind idempotently one durable mount for an artifact, keyed by name.""" + slug_name = slugify_mount_name(name) + mount_create = MountCreate( + slug=mint_agent_slug(artifact_id=artifact_id, name=name), + name=slug_name, + ) + return await self.mounts_dao.upsert_mount( + project_id=project_id, + user_id=user_id, + mount_create=mount_create, + ) + async def get_or_create_session_cwd( self, *, @@ -216,6 +252,20 @@ async def fetch_mount( mount_id=mount_id, ) + async def fetch_agent_mount( + self, + *, + project_id: UUID, + artifact_id: str, + name: str = "default", + ) -> Optional[Mount]: + """Fetch the active artifact mount keyed by name without creating it.""" + slug = mint_agent_slug(artifact_id=artifact_id, name=name) + return await self.mounts_dao.fetch_mount_by_slug( + project_id=project_id, + slug=slug, + ) + async def edit_mount( self, *, diff --git a/api/oss/src/core/mounts/types.py b/api/oss/src/core/mounts/types.py index 05c3a5c1a1..d896030c6c 100644 --- a/api/oss/src/core/mounts/types.py +++ b/api/oss/src/core/mounts/types.py @@ -33,6 +33,12 @@ def __init__(self, name: str = "name"): self.name = name +class MountArtifactIdInvalid(MountError): + def __init__(self, artifact_id: str = "artifact_id"): + super().__init__(f"Artifact id '{artifact_id}' must be a valid UUID.") + self.artifact_id = artifact_id + + class MountImmutableField(MountError): def __init__(self, field: str = "field"): super().__init__(f"Mount field '{field}' is immutable after creation.") diff --git a/api/oss/src/dbs/postgres/mounts/dao.py b/api/oss/src/dbs/postgres/mounts/dao.py index c72821c25f..d43bc9bd63 100644 --- a/api/oss/src/dbs/postgres/mounts/dao.py +++ b/api/oss/src/dbs/postgres/mounts/dao.py @@ -115,6 +115,29 @@ async def fetch_mount( return map_mount_dbe_to_dto(mount_dbe=mount_dbe) + async def fetch_mount_by_slug( + self, + *, + project_id: UUID, + # + slug: str, + ) -> Optional[Mount]: + """Fetch by slug, excluding archived rows for agent-mount reads.""" + async with self.engine.session() as session: + stmt = select(MountDBE).where( + MountDBE.project_id == project_id, + MountDBE.slug == slug, + MountDBE.deleted_at.is_(None), + ) + + result = await session.execute(stmt) + mount_dbe = result.scalar_one_or_none() + + if not mount_dbe: + return None + + return map_mount_dbe_to_dto(mount_dbe=mount_dbe) + async def edit_mount( self, *, diff --git a/api/oss/tests/pytest/acceptance/mounts/test_agent_mounts_basics.py b/api/oss/tests/pytest/acceptance/mounts/test_agent_mounts_basics.py new file mode 100644 index 0000000000..c70598be53 --- /dev/null +++ b/api/oss/tests/pytest/acceptance/mounts/test_agent_mounts_basics.py @@ -0,0 +1,69 @@ +"""Acceptance tests for artifact-scoped agent mounts. + +Query-only tests run without an object store. Credential-signing tests skip when +the running API has no mount storage backend configured. +""" + +from uuid import uuid4 + +import pytest + + +def _query_agent_mount(authed_api, artifact_id, *, name="default"): + return authed_api( + "POST", + "/mounts/agents/query", + json={"artifact_id": artifact_id, "name": name}, + ) + + +def _sign_agent_mount(authed_api, artifact_id, *, name="default"): + response = authed_api( + "POST", + "/mounts/agents/sign", + params={"artifact_id": artifact_id, "name": name}, + ) + if response.status_code == 503: + pytest.skip("Mount storage backend not configured in this environment") + return response + + +class TestAgentMountReads: + def test_query_rejects_non_uuid_artifact_id(self, authed_api): + response = _query_agent_mount(authed_api, "not-a-uuid") + assert response.status_code == 422, response.text + + def test_query_unknown_uuid_stays_empty(self, authed_api): + artifact_id = str(uuid4()) + + first = _query_agent_mount(authed_api, artifact_id) + assert first.status_code == 200, first.text + assert first.json()["count"] == 0 + assert first.json()["mounts"] == [] + + second = _query_agent_mount(authed_api, artifact_id) + assert second.status_code == 200, second.text + assert second.json()["count"] == 0 + assert second.json()["mounts"] == [] + + +class TestAgentMountSign: + def test_sign_then_query_returns_same_mount(self, authed_api): + artifact_id = str(uuid4()) + signed = _sign_agent_mount(authed_api, artifact_id) + assert signed.status_code == 200, signed.text + mount_id = signed.json()["mount"]["id"] + + queried = _query_agent_mount(authed_api, artifact_id) + assert queried.status_code == 200, queried.text + assert queried.json()["count"] == 1 + assert queried.json()["mounts"][0]["id"] == mount_id + + def test_sign_twice_returns_same_mount(self, authed_api): + artifact_id = str(uuid4()) + first = _sign_agent_mount(authed_api, artifact_id) + assert first.status_code == 200, first.text + + second = _sign_agent_mount(authed_api, artifact_id) + assert second.status_code == 200, second.text + assert second.json()["mount"]["id"] == first.json()["mount"]["id"] diff --git a/api/oss/tests/pytest/unit/mounts/test_agent_mounts.py b/api/oss/tests/pytest/unit/mounts/test_agent_mounts.py new file mode 100644 index 0000000000..1e4cc65516 --- /dev/null +++ b/api/oss/tests/pytest/unit/mounts/test_agent_mounts.py @@ -0,0 +1,181 @@ +from datetime import datetime, timezone +from uuid import UUID, uuid4 + +import pytest + +from oss.src.core.mounts.dtos import Mount, MountCreate +from oss.src.core.mounts.service import MountsService, mint_agent_slug +from oss.src.core.mounts.types import MountArtifactIdInvalid, MountSlugReserved + + +class InMemoryMountsDAO: + def __init__(self): + self.mounts = {} + self.upsert_calls = 0 + self.fetch_by_slug_calls = 0 + + async def upsert_mount(self, *, project_id, user_id, mount_create): + self.upsert_calls += 1 + key = (project_id, mount_create.slug) + if key not in self.mounts: + self.mounts[key] = self._mount(project_id, mount_create) + else: + self.mounts[key] = self.mounts[key].model_copy( + update={"deleted_at": None, "deleted_by_id": None} + ) + return self.mounts[key] + + async def fetch_mount_by_slug(self, *, project_id, slug): + self.fetch_by_slug_calls += 1 + mount = self.mounts.get((project_id, slug)) + return mount if mount and mount.deleted_at is None else None + + async def archive_mount(self, *, project_id, user_id, mount_id): + for key, mount in self.mounts.items(): + if mount.project_id == project_id and mount.id == mount_id: + archived = mount.model_copy( + update={ + "deleted_at": datetime.now(timezone.utc), + "deleted_by_id": user_id, + } + ) + self.mounts[key] = archived + return archived + return None + + @staticmethod + def _mount(project_id, mount_create): + return Mount( + id=uuid4(), + project_id=project_id, + slug=mount_create.slug, + name=mount_create.name, + session_id=mount_create.session_id, + ) + + +@pytest.fixture +def mount_context(): + dao = InMemoryMountsDAO() + return MountsService(mounts_dao=dao), dao, uuid4(), uuid4() + + +def test_agent_slug_is_canonical_and_slugifies_name(): + artifact_id = "A0B1C2D3-E4F5-4678-9ABC-DEF012345678" + slug = mint_agent_slug(artifact_id=artifact_id, name="My Files") + assert slug == "__ag__agent__a0b1c2d3-e4f5-4678-9abc-def012345678__my-files" + assert UUID(slug.removeprefix("__ag__agent__").split("__", 1)[0]) == UUID( + artifact_id + ) + + +def test_non_uuid_artifact_id_raises_typed_exception(): + with pytest.raises(MountArtifactIdInvalid): + mint_agent_slug(artifact_id="not-a-uuid", name="default") + + +@pytest.mark.asyncio +async def test_agent_mount_upsert_is_idempotent(mount_context): + service, dao, project_id, user_id = mount_context + artifact_id = str(uuid4()) + first = await service.get_or_create_agent_mount( + project_id=project_id, user_id=user_id, artifact_id=artifact_id + ) + second = await service.get_or_create_agent_mount( + project_id=project_id, user_id=user_id, artifact_id=artifact_id + ) + assert first.id == second.id + assert first.session_id is None + assert dao.upsert_calls == 2 + assert len(dao.mounts) == 1 + + +@pytest.mark.asyncio +async def test_sign_and_query_derivations_are_byte_identical(mount_context): + service, _, project_id, user_id = mount_context + artifact_id = "A0B1C2D3-E4F5-4678-9ABC-DEF012345678" + signed = await service.get_or_create_agent_mount( + project_id=project_id, user_id=user_id, artifact_id=artifact_id + ) + queried = await service.fetch_agent_mount( + project_id=project_id, artifact_id=artifact_id.lower() + ) + assert queried is not None + assert queried.id == signed.id + assert queried.slug == signed.slug + + +@pytest.mark.asyncio +async def test_agent_mount_name_is_symmetric_between_sign_and_query(mount_context): + service, _, project_id, user_id = mount_context + artifact_id = str(uuid4()) + signed = await service.get_or_create_agent_mount( + project_id=project_id, + user_id=user_id, + artifact_id=artifact_id, + name="notes", + ) + + notes = await service.fetch_agent_mount( + project_id=project_id, artifact_id=artifact_id, name="notes" + ) + default = await service.fetch_agent_mount( + project_id=project_id, artifact_id=artifact_id + ) + + assert notes is not None + assert notes.id == signed.id + assert default is None + + +@pytest.mark.asyncio +async def test_archived_agent_mount_is_absent_until_signed_again(mount_context): + service, _, project_id, user_id = mount_context + artifact_id = str(uuid4()) + signed = await service.get_or_create_agent_mount( + project_id=project_id, user_id=user_id, artifact_id=artifact_id + ) + + await service.archive_mount( + project_id=project_id, user_id=user_id, mount_id=signed.id + ) + assert ( + await service.fetch_agent_mount(project_id=project_id, artifact_id=artifact_id) + is None + ) + + resigned = await service.get_or_create_agent_mount( + project_id=project_id, user_id=user_id, artifact_id=artifact_id + ) + queried = await service.fetch_agent_mount( + project_id=project_id, artifact_id=artifact_id + ) + + assert resigned.id == signed.id + assert queried is not None + assert queried.id == signed.id + + +@pytest.mark.asyncio +async def test_agent_query_returns_empty_without_creating(mount_context): + service, dao, project_id, _ = mount_context + mount = await service.fetch_agent_mount( + project_id=project_id, artifact_id=str(uuid4()) + ) + assert mount is None + assert dao.fetch_by_slug_calls == 1 + assert dao.upsert_calls == 0 + assert dao.mounts == {} + + +@pytest.mark.asyncio +async def test_create_rejects_forged_agent_slug(mount_context): + service, _, project_id, user_id = mount_context + with pytest.raises(MountSlugReserved): + await service.create_mount( + project_id=project_id, + user_id=user_id, + mount_create=MountCreate( + slug=f"__ag__agent__{uuid4()}__default", name="forged" + ), + ) From 9b77f2e199ed960ebc8aa233ff760b0f77b45f04 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 12 Jul 2026 12:56:41 +0200 Subject: [PATCH 2/9] feat(runner): mount the agent folder and expose it for discovery Claude-Session: https://claude.ai/code/session_01UogudBC9VRCRkpT5bZVxex --- services/runner/src/engines/sandbox_agent.ts | 135 ++++++- .../src/engines/sandbox_agent/agent-mount.ts | 240 ++++++++++++ .../runner/tests/unit/agent-mount.test.ts | 358 ++++++++++++++++++ 3 files changed, 729 insertions(+), 4 deletions(-) create mode 100644 services/runner/src/engines/sandbox_agent/agent-mount.ts create mode 100644 services/runner/tests/unit/agent-mount.test.ts diff --git a/services/runner/src/engines/sandbox_agent.ts b/services/runner/src/engines/sandbox_agent.ts index 06843645ea..d4fbe56a89 100644 --- a/services/runner/src/engines/sandbox_agent.ts +++ b/services/runner/src/engines/sandbox_agent.ts @@ -30,7 +30,7 @@ * so it is uniform across every harness and always nests under the caller's /invoke * span. stdout is reserved for the JSON result (see cli.ts); logs go to stderr. */ -import { rmSync } from "node:fs"; +import { mkdirSync, rmSync } from "node:fs"; import { apiBase } from "../apiBase.ts"; import { Redactor, seedFromEnv } from "../redaction.ts"; @@ -152,6 +152,15 @@ import { storeReachableFromSandbox, type MountCredentials, } from "./sandbox_agent/mount.ts"; +import { + AGENT_MOUNT_ENV_VAR, + agentMountPath, + linkAgentFiles, + linkAgentFilesRemote, + seedAgentReadme, + seedAgentReadmeRemote, + signAgentMountCredentials, +} from "./sandbox_agent/agent-mount.ts"; import { hydrateHarnessSessionFromDurable, syncHarnessSessionDurable, @@ -334,6 +343,7 @@ export interface SandboxAgentDeps extends BuildRunPlanDeps { localRelayHost?: typeof localRelayHost; sandboxRelayHost?: typeof sandboxRelayHost; signSessionMountCredentials?: typeof signSessionMountCredentials; + signAgentMountCredentials?: typeof signAgentMountCredentials; mountStorage?: typeof mountStorage; mountStorageRemote?: typeof mountStorageRemote; unmountStorage?: typeof unmountStorage; @@ -539,6 +549,7 @@ export interface SessionEnvironment { runAgentDir: string | undefined; otlpAuthFilePath: string | undefined; mountCreds: MountCredentials | null; + agentMountCreds?: MountCredentials | null; /** The mount's owning project id (keep-alive pool key FALLBACK scope, preferred is * `runContext.project.id`); undefined when there is no mount. */ mountProjectId?: string; @@ -551,6 +562,7 @@ export interface SessionEnvironment { // Mutable teardown/turn state shared across acquire, runTurn, and destroy. sessionDestroyRequested: boolean; mountedCwd: string | undefined; + agentMountedPath?: string; durableCwdSafeToDelete: boolean; workspace: { cleanup: () => Promise } | undefined; runtimeRemount: Promise | undefined; @@ -703,6 +715,17 @@ export async function acquireEnvironment( }) : null; + const artifactId = request.runContext?.workflow?.artifact?.id?.trim(); + const signAgentMount = + deps.signAgentMountCredentials ?? signAgentMountCredentials; + const agentMountCreds: MountCredentials | null = + artifactId && runCred + ? await signAgentMount(artifactId, { + apiBase: apiBase(), + authorization: runCred, + log: logger, + }) + : null; // Derive the durable cwd from the sign prefix (one source of truth, both providers). // local: /tmp/agenta/ — daytona: /home/sandbox/agenta/ // is already "mounts//", so no extra slug is needed. @@ -726,6 +749,7 @@ export async function acquireEnvironment( }); if (!planResult.ok) return { ok: false, error: planResult.error }; const plan = planResult.plan; + const agentMountDir = agentMountCreds ? agentMountPath(plan.cwd) : undefined; // Clear-then-apply (Security rule 5): on a managed run (credentialMode "env") the daemon // inherits NONE of the sidecar's own provider keys, so only the resolved `plan.modelEnvironment` are @@ -763,6 +787,11 @@ export async function acquireEnvironment( skills: plan.skillDirs.map((s) => s.name), }) : {}; + if (agentMountDir) { + // Daytona fixes daemon env at sandbox-create, before the best-effort remote mount. Exposing + // the signed mount's stable path is safe; discovery files are seeded only after mount success. + piExtEnv[AGENT_MOUNT_ENV_VAR] = agentMountDir; + } Object.assign(env, piExtEnv); // local daemon inherits it; daytona gets it via envVars logger( `tools=${plan.toolSpecs.length} executableTools=${plan.executableToolSpecs.length} ` + @@ -818,12 +847,14 @@ export async function acquireEnvironment( runAgentDir, otlpAuthFilePath, mountCreds, + agentMountCreds, mountProjectId: mountCreds?.projectId, loadedFromContinuity: false, resumable: false, continuityTurnIndex: undefined, sessionDestroyRequested: false, mountedCwd: undefined, + agentMountedPath: undefined, durableCwdSafeToDelete: true, // Local runs get a plain rmSync cleanup for the throwaway cwd; Daytona has none on this host. workspace: plan.isDaytona @@ -893,6 +924,23 @@ export async function acquireEnvironment( environment.deps.unmountStorage ?? unmountStorage )(environment.mountedCwd, { log }).catch(() => false); } + if (!parked && !plan.isDaytona && environment.agentMountedPath) { + const agentMountSafeToDelete = await ( + environment.deps.unmountStorage ?? unmountStorage + )( + environment.agentMountedPath, + { log }, + ).catch(() => false); + if (agentMountSafeToDelete) { + try { + rmSync(environment.agentMountedPath, { recursive: true, force: true }); + } catch (err) { + logger( + `agent mountpoint cleanup failed path=${environment.agentMountedPath}: ${conciseError(err, plan.harness)}`, + ); + } + } + } if (!environment.durableCwdSafeToDelete) { logger( `durable cwd unmount not confirmed, skipping workspace cleanup cwd=${plan.cwd}`, @@ -931,6 +979,33 @@ export async function acquireEnvironment( } return false; }; + const mountLocalAgentCwd = async (): Promise => { + if (!environment.agentMountCreds || plan.isDaytona) return false; + const mountPath = agentMountPath(plan.cwd); + if (environment.agentMountedPath === mountPath) return true; + try { + mkdirSync(mountPath, { recursive: true }); + if ( + !(await (deps.mountStorage ?? mountStorage)( + mountPath, + environment.agentMountCreds, + { log: logger }, + )) + ) { + return false; + } + environment.agentMountedPath = mountPath; + await seedAgentReadme(mountPath, { log: logger }); + await linkAgentFiles(plan.cwd, mountPath, { log: logger }); + env[AGENT_MOUNT_ENV_VAR] = mountPath; + return true; + } catch (err) { + logger( + `local agent mount failed artifact=${artifactId}: ${conciseError(err, plan.harness)}`, + ); + return false; + } + }; let localDurableCwdEnotconnRemounts = 0; const reSignAndRemountLocalCwd = async (): Promise => { if (!sessionForMount || !runCred || plan.isDaytona) return false; @@ -981,6 +1056,16 @@ export async function acquireEnvironment( }; try { + // Local mounts must be active before the provider starts the daemon so its inherited env + // includes AGENTA_AGENT_MOUNT_DIR. Daytona needs the live sandbox handle and mounts below, + // before createSession starts the harness. + if (environment.mountCreds && !plan.isDaytona) { + await mountLocalDurableCwd("initial"); + } + if (environment.agentMountCreds && !plan.isDaytona) { + await mountLocalAgentCwd(); + } + // Validate user MCP routes and credential bindings before provider construction or sandbox // reconnect/create. `buildSessionMcpServers` repeats this at final materialization as defense. await validateUserMcpServers(request.mcpServers); @@ -1144,9 +1229,6 @@ export async function acquireEnvironment( // Durable cwd: mount BEFORE createSession (so the session opens inside it) and BEFORE // workspace materialization (so AGENTS.md, harness files, and skills land in the durable // prefix instead of being hidden under the FUSE mount). - if (environment.mountCreds && !plan.isDaytona) { - await mountLocalDurableCwd("initial"); - } if (environment.mountCreds && plan.isDaytona) { const mountsStartedAt = Date.now(); try { @@ -1204,6 +1286,51 @@ export async function acquireEnvironment( timingLog("mounts", mountsStartedAt); } } + if ( + environment.agentMountCreds && + agentMountDir && + plan.isDaytona && + !environment.agentMountedPath + ) { + const agentMountStartedAt = Date.now(); + try { + const storeEndpoint = environment.agentMountCreds.endpoint; + const endpoint = storeReachableFromSandbox(storeEndpoint) + ? undefined + : ((await (deps.discoverTunnelEndpoint ?? discoverTunnelEndpoint)({ + log: logger, + })) ?? undefined); + const canMount = storeReachableFromSandbox(storeEndpoint) || !!endpoint; + const mountPath = agentMountDir; + if ( + canMount && + (await (deps.mountStorageRemote ?? mountStorageRemote)( + environment.sandbox, + mountPath, + environment.agentMountCreds, + { endpoint, log: logger }, + )) + ) { + environment.agentMountedPath = mountPath; + await seedAgentReadmeRemote(environment.sandbox, mountPath, { + log: logger, + }); + await linkAgentFilesRemote( + environment.sandbox, + plan.cwd, + mountPath, + { log: logger }, + ); + logger(`remote agent mount active for artifact=${artifactId}`); + } + } catch (err) { + logger( + `remote agent mount failed artifact=${artifactId}: ${conciseError(err, plan.harness)}`, + ); + } finally { + timingLog("agent_mount", agentMountStartedAt); + } + } const prepareWorkspaceStartedAt = Date.now(); try { diff --git a/services/runner/src/engines/sandbox_agent/agent-mount.ts b/services/runner/src/engines/sandbox_agent/agent-mount.ts new file mode 100644 index 0000000000..a6daaca44c --- /dev/null +++ b/services/runner/src/engines/sandbox_agent/agent-mount.ts @@ -0,0 +1,240 @@ +/** + * Durable agent-scoped files mounted beside each session cwd and linked into it. + * See `docs/design/agent-workflows/projects/agent-mounts/plan.md`, decision D3. + */ + +import { lstat, readlink, symlink, unlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import type { + MountCredentials, + SandboxExec, + SignMountDeps, +} from "./mount.ts"; + +export const AGENT_MOUNT_ENV_VAR = "AGENTA_AGENT_MOUNT_DIR"; +export const AGENT_README_NAME = "README.md"; +export const AGENT_FILES_LINK_NAME = "agent-files"; +export const AGENT_README_CONTENT = `This folder belongs to your agent. +Files here persist across all sessions and runs of this agent. +Your working directory persists only for the current session. +Without a session, the working directory does not persist. +Concurrent runs share this folder, so the last writer wins for each file. +`; + +function defaultLog(msg: string): void { + process.stderr.write(`[sandbox_agent/agent-mount] ${msg}\n`); +} + +export function agentMountPath(cwd: string): string { + return `${cwd}-agent`; +} + +/** + * Bind-and-sign the agent's durable mount. Returns null when signing fails so a missing agent + * mount never aborts the turn. Keep this failure contract in sync with + * `signSessionMountCredentials` in `mount.ts`. + */ +export async function signAgentMountCredentials( + artifactId: string, + deps: SignMountDeps, + name: string = "default", +): Promise { + const log = deps.log ?? defaultLog; + const doFetch = deps.fetchImpl ?? fetch; + const url = `${deps.apiBase}/mounts/agents/sign?artifact_id=${encodeURIComponent(artifactId)}&name=${encodeURIComponent(name)}`; + try { + const res = await doFetch(url, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: deps.authorization, + }, + // Bound the sign so a hung endpoint fails open (null mount) instead of + // stalling environment acquisition on the agent mount forever. + signal: AbortSignal.timeout(10_000), + }); + if (!res.ok) { + log( + `sign HTTP ${res.status} artifact=${artifactId} name=${name} — running without this mount`, + ); + return null; + } + const body = (await res.json()) as { + mount?: { project_id?: string }; + credentials?: { + endpoint?: string; + region?: string; + bucket?: string; + prefix?: string; + access_key?: string; + secret_key?: string; + session_token?: string; + expires_at?: string; + }; + }; + const c = body.credentials; + if (!c?.bucket || !c.prefix || !c.access_key || !c.secret_key) { + log(`sign returned no usable credentials artifact=${artifactId}`); + return null; + } + return { + endpoint: c.endpoint, + region: c.region ?? "us-east-1", + bucket: c.bucket, + prefix: c.prefix, + accessKey: c.access_key, + secretKey: c.secret_key, + sessionToken: c.session_token, + expiresAt: c.expires_at, + projectId: + typeof body.mount?.project_id === "string" + ? body.mount.project_id + : undefined, + }; + } catch (err) { + log( + `sign failed artifact=${artifactId}: ${String(err instanceof Error ? err.message : err).slice(0, 160)}`, + ); + return null; + } +} + +export interface SeedAgentReadmeDeps { + writeFile?: typeof writeFile; + log?: (msg: string) => void; +} + +export async function seedAgentReadme( + mountPath: string, + deps: SeedAgentReadmeDeps = {}, +): Promise { + const log = deps.log ?? defaultLog; + const write = deps.writeFile ?? writeFile; + const readmePath = join(mountPath, AGENT_README_NAME); + try { + // `wx` makes concurrent first-run seeds atomic without overwriting an agent-edited README. + await write(readmePath, AGENT_README_CONTENT, { flag: "wx" }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "EEXIST") { + log( + `README seed failed ${readmePath}: ${String(err instanceof Error ? err.message : err).slice(0, 200)}`, + ); + } + } +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +export async function seedAgentReadmeRemote( + sandbox: SandboxExec, + mountPath: string, + deps: { log?: (msg: string) => void } = {}, +): Promise { + const log = deps.log ?? defaultLog; + const readmePath = join(mountPath, AGENT_README_NAME); + try { + const res = await sandbox.runProcess({ + command: "sh", + args: [ + "-c", + `[ -e ${shellQuote(readmePath)} ] || printf %s ${shellQuote(AGENT_README_CONTENT)} > ${shellQuote(readmePath)}`, + ], + timeoutMs: 30_000, + }); + if (res?.exitCode !== 0) { + log(`remote README seed failed ${readmePath}: exit ${res?.exitCode}`); + } + } catch (err) { + log( + `remote README seed failed ${readmePath}: ${String(err instanceof Error ? err.message : err).slice(0, 200)}`, + ); + } +} + +export interface LinkAgentFilesDeps { + lstat?: typeof lstat; + readlink?: typeof readlink; + symlink?: typeof symlink; + unlink?: typeof unlink; + log?: (msg: string) => void; +} + +export async function linkAgentFiles( + cwd: string, + mountPath: string, + deps: LinkAgentFilesDeps = {}, +): Promise { + const log = deps.log ?? defaultLog; + const inspect = deps.lstat ?? lstat; + const readLink = deps.readlink ?? readlink; + const createLink = deps.symlink ?? symlink; + const removeLink = deps.unlink ?? unlink; + const linkPath = join(cwd, AGENT_FILES_LINK_NAME); + let replaceExisting = false; + try { + // Keep a correct-target symlink even when dangling. Replace a non-symlink or wrong-target + // link because geesefs silently degrades symlinks to empty files across remounts. + const stats = await inspect(linkPath); + if (stats.isSymbolicLink() && (await readLink(linkPath)) === mountPath) { + return; + } + replaceExisting = true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + log( + `agent-files check failed ${linkPath}: ${String(err instanceof Error ? err.message : err).slice(0, 200)}`, + ); + return; + } + } + if (replaceExisting) { + try { + await removeLink(linkPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + log( + `agent-files unlink failed ${linkPath}: ${String(err instanceof Error ? err.message : err).slice(0, 200)}`, + ); + } + } + } + try { + await createLink(mountPath, linkPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "EEXIST") { + log( + `agent-files link failed ${linkPath}: ${String(err instanceof Error ? err.message : err).slice(0, 200)}`, + ); + } + } +} + +export async function linkAgentFilesRemote( + sandbox: SandboxExec, + cwd: string, + mountPath: string, + deps: { log?: (msg: string) => void } = {}, +): Promise { + const log = deps.log ?? defaultLog; + const linkPath = join(cwd, AGENT_FILES_LINK_NAME); + try { + const res = await sandbox.runProcess({ + command: "sh", + args: [ + "-c", + `[ "$(readlink ${shellQuote(linkPath)} 2>/dev/null)" = ${shellQuote(mountPath)} ] || { rm -f ${shellQuote(linkPath)} && ln -s ${shellQuote(mountPath)} ${shellQuote(linkPath)}; }`, + ], + timeoutMs: 30_000, + }); + if (res?.exitCode !== 0) { + log(`remote agent-files link failed ${linkPath}: exit ${res?.exitCode}`); + } + } catch (err) { + log( + `remote agent-files link failed ${linkPath}: ${String(err instanceof Error ? err.message : err).slice(0, 200)}`, + ); + } +} diff --git a/services/runner/tests/unit/agent-mount.test.ts b/services/runner/tests/unit/agent-mount.test.ts new file mode 100644 index 0000000000..361255748b --- /dev/null +++ b/services/runner/tests/unit/agent-mount.test.ts @@ -0,0 +1,358 @@ +import assert from "node:assert/strict"; +import { describe, it } from "vitest"; + +import { + AGENT_FILES_LINK_NAME, + AGENT_MOUNT_ENV_VAR, + AGENT_README_CONTENT, + agentMountPath, + linkAgentFiles, + linkAgentFilesRemote, + seedAgentReadme, + seedAgentReadmeRemote, + signAgentMountCredentials, +} from "../../src/engines/sandbox_agent/agent-mount.ts"; + +const SILENT = () => {}; + +function response(ok: boolean, body: unknown, status = 200): Response { + return { ok, status, json: async () => body } as unknown as Response; +} + +const SIGNED_BODY = { + mount: { project_id: "project-1" }, + credentials: { + endpoint: "http://seaweedfs:8333", + region: "eu-central-1", + bucket: "agenta-store", + prefix: "mounts/project-1/mount-1", + access_key: "AK", + secret_key: "SK", + session_token: "TOKEN", + expires_at: "2026-07-11T12:00:00Z", + }, +}; + +describe("agent mount constants", () => { + it("derives a sibling mount path", () => { + assert.equal(agentMountPath("/tmp/agenta/run-1"), "/tmp/agenta/run-1-agent"); + assert.equal(AGENT_MOUNT_ENV_VAR, "AGENTA_AGENT_MOUNT_DIR"); + assert.equal(AGENT_FILES_LINK_NAME, "agent-files"); + }); +}); + +describe("signAgentMountCredentials", () => { + it("posts the artifact id and default name and maps credentials", async () => { + let calledUrl = ""; + let calledInit: RequestInit | undefined; + const credentials = await signAgentMountCredentials("artifact/id", { + apiBase: "http://api:8000", + authorization: "ApiKey abc", + fetchImpl: (async (url: string, init: RequestInit) => { + calledUrl = url; + calledInit = init; + return response(true, SIGNED_BODY); + }) as unknown as typeof fetch, + log: SILENT, + }); + + assert.equal( + calledUrl, + "http://api:8000/mounts/agents/sign?artifact_id=artifact%2Fid&name=default", + ); + assert.equal(calledInit?.method, "POST"); + assert.deepEqual(calledInit?.headers, { + "content-type": "application/json", + authorization: "ApiKey abc", + }); + assert.deepEqual(credentials, { + endpoint: "http://seaweedfs:8333", + region: "eu-central-1", + bucket: "agenta-store", + prefix: "mounts/project-1/mount-1", + accessKey: "AK", + secretKey: "SK", + sessionToken: "TOKEN", + expiresAt: "2026-07-11T12:00:00Z", + projectId: "project-1", + }); + }); + + it("passes a custom name", async () => { + let calledUrl = ""; + await signAgentMountCredentials( + "artifact-1", + { + apiBase: "http://api:8000", + authorization: "ApiKey abc", + fetchImpl: (async (url: string) => { + calledUrl = url; + return response(true, SIGNED_BODY); + }) as unknown as typeof fetch, + log: SILENT, + }, + "skills and notes", + ); + assert.match(calledUrl, /artifact_id=artifact-1&name=skills%20and%20notes$/); + }); + + it("returns null on non-2xx, network errors, and missing fields", async () => { + const base = { apiBase: "http://api:8000", authorization: "ApiKey abc", log: SILENT }; + assert.equal( + await signAgentMountCredentials("artifact-1", { + ...base, + fetchImpl: (async () => response(false, {}, 503)) as unknown as typeof fetch, + }), + null, + ); + assert.equal( + await signAgentMountCredentials("artifact-1", { + ...base, + fetchImpl: (async () => { + throw new Error("network down"); + }) as unknown as typeof fetch, + }), + null, + ); + assert.equal( + await signAgentMountCredentials("artifact-1", { + ...base, + fetchImpl: (async () => + response(true, { credentials: { bucket: "only-one-field" } })) as unknown as typeof fetch, + }), + null, + ); + }); +}); + +describe("seedAgentReadme", () => { + it("writes the README with an atomic absent-only guard", async () => { + const calls: unknown[][] = []; + await seedAgentReadme("/tmp/run-agent", { + writeFile: (async (...args: unknown[]) => { + calls.push(args); + }) as unknown as typeof import("node:fs/promises").writeFile, + log: SILENT, + }); + assert.deepEqual(calls, [ + ["/tmp/run-agent/README.md", AGENT_README_CONTENT, { flag: "wx" }], + ]); + }); + + it("leaves an existing README untouched", async () => { + const logs: string[] = []; + await seedAgentReadme("/tmp/run-agent", { + writeFile: (async () => { + throw Object.assign(new Error("exists"), { code: "EEXIST" }); + }) as unknown as typeof import("node:fs/promises").writeFile, + log: (message) => logs.push(message), + }); + assert.deepEqual(logs, []); + }); +}); + +describe("linkAgentFiles", () => { + it("creates the link when the path is missing", async () => { + const links: string[][] = []; + await linkAgentFiles("/tmp/run", "/tmp/run-agent", { + lstat: (async () => { + throw Object.assign(new Error("missing"), { code: "ENOENT" }); + }) as unknown as typeof import("node:fs/promises").lstat, + readlink: (async () => { + throw new Error("should not read a missing path"); + }) as unknown as typeof import("node:fs/promises").readlink, + unlink: (async () => { + throw new Error("should not unlink a missing path"); + }) as unknown as typeof import("node:fs/promises").unlink, + symlink: (async (target: string, path: string) => { + links.push([target, path]); + }) as unknown as typeof import("node:fs/promises").symlink, + log: SILENT, + }); + assert.deepEqual(links, [["/tmp/run-agent", "/tmp/run/agent-files"]]); + }); + + it("keeps a valid symlink to the mount path", async () => { + let linked = false; + let unlinked = false; + await linkAgentFiles("/tmp/run", "/tmp/run-agent", { + lstat: (async () => ({ + isSymbolicLink: () => true, + })) as unknown as typeof import("node:fs/promises").lstat, + readlink: (async () => + "/tmp/run-agent") as unknown as typeof import("node:fs/promises").readlink, + unlink: (async () => { + unlinked = true; + }) as unknown as typeof import("node:fs/promises").unlink, + symlink: (async () => { + linked = true; + }) as unknown as typeof import("node:fs/promises").symlink, + log: SILENT, + }); + assert.equal(unlinked, false); + assert.equal(linked, false); + }); + + it("replaces a degraded regular file with the mount symlink", async () => { + const calls: string[][] = []; + await linkAgentFiles("/tmp/run", "/tmp/run-agent", { + lstat: (async () => ({ + isSymbolicLink: () => false, + })) as unknown as typeof import("node:fs/promises").lstat, + readlink: (async () => { + throw new Error("should not read a regular file"); + }) as unknown as typeof import("node:fs/promises").readlink, + unlink: (async (path: string) => { + calls.push(["unlink", path]); + }) as unknown as typeof import("node:fs/promises").unlink, + symlink: (async (target: string, path: string) => { + calls.push(["symlink", target, path]); + }) as unknown as typeof import("node:fs/promises").symlink, + log: SILENT, + }); + assert.deepEqual(calls, [ + ["unlink", "/tmp/run/agent-files"], + ["symlink", "/tmp/run-agent", "/tmp/run/agent-files"], + ]); + }); + + it("replaces a symlink to the wrong target", async () => { + const calls: string[][] = []; + await linkAgentFiles("/tmp/run", "/tmp/run-agent", { + lstat: (async () => ({ + isSymbolicLink: () => true, + })) as unknown as typeof import("node:fs/promises").lstat, + readlink: (async () => + "/tmp/old-agent") as unknown as typeof import("node:fs/promises").readlink, + unlink: (async (path: string) => { + calls.push(["unlink", path]); + }) as unknown as typeof import("node:fs/promises").unlink, + symlink: (async (target: string, path: string) => { + calls.push(["symlink", target, path]); + }) as unknown as typeof import("node:fs/promises").symlink, + log: SILENT, + }); + assert.deepEqual(calls, [ + ["unlink", "/tmp/run/agent-files"], + ["symlink", "/tmp/run-agent", "/tmp/run/agent-files"], + ]); + }); + + it("logs a non-ENOENT lstat failure without throwing", async () => { + const logs: string[] = []; + let linked = false; + await linkAgentFiles("/tmp/run", "/tmp/run-agent", { + lstat: (async () => { + throw Object.assign(new Error("permission denied"), { code: "EACCES" }); + }) as unknown as typeof import("node:fs/promises").lstat, + readlink: (async () => { + throw new Error("should not read after lstat fails"); + }) as unknown as typeof import("node:fs/promises").readlink, + unlink: (async () => { + throw new Error("should not unlink after lstat fails"); + }) as unknown as typeof import("node:fs/promises").unlink, + symlink: (async () => { + linked = true; + }) as unknown as typeof import("node:fs/promises").symlink, + log: (message) => logs.push(message), + }); + assert.equal(linked, false); + assert.equal(logs.length, 1); + assert.match(logs[0], /permission denied/); + }); + + it("treats a concurrent symlink EEXIST as success", async () => { + const logs: string[] = []; + await linkAgentFiles("/tmp/run", "/tmp/run-agent", { + lstat: (async () => { + throw Object.assign(new Error("missing"), { code: "ENOENT" }); + }) as unknown as typeof import("node:fs/promises").lstat, + symlink: (async () => { + throw Object.assign(new Error("exists"), { code: "EEXIST" }); + }) as unknown as typeof import("node:fs/promises").symlink, + log: (message) => logs.push(message), + }); + assert.deepEqual(logs, []); + }); + + it("logs a symlink failure without throwing", async () => { + const logs: string[] = []; + await linkAgentFiles("/tmp/run", "/tmp/run-agent", { + lstat: (async () => { + throw Object.assign(new Error("missing"), { code: "ENOENT" }); + }) as unknown as typeof import("node:fs/promises").lstat, + symlink: (async () => { + throw new Error("not supported"); + }) as unknown as typeof import("node:fs/promises").symlink, + log: (message) => logs.push(message), + }); + assert.equal(logs.length, 1); + assert.match(logs[0], /not supported/); + }); +}); + +describe("remote discovery helpers", () => { + it("emits one guarded README command", async () => { + const calls: unknown[] = []; + await seedAgentReadmeRemote( + { runProcess: async (options) => (calls.push(options), { exitCode: 0 }) }, + "/home/sandbox/run-agent", + ); + assert.deepEqual(calls, [ + { + command: "sh", + args: [ + "-c", + `[ -e '/home/sandbox/run-agent/README.md' ] || printf %s '${AGENT_README_CONTENT}' > '/home/sandbox/run-agent/README.md'`, + ], + timeoutMs: 30_000, + }, + ]); + }); + + it("emits one self-healing symlink command", async () => { + const calls: unknown[] = []; + await linkAgentFilesRemote( + { runProcess: async (options) => (calls.push(options), { exitCode: 0 }) }, + "/home/sandbox/run", + "/home/sandbox/run-agent", + ); + assert.deepEqual(calls, [ + { + command: "sh", + args: [ + "-c", + `[ "$(readlink '/home/sandbox/run/agent-files' 2>/dev/null)" = '/home/sandbox/run-agent' ] || { rm -f '/home/sandbox/run/agent-files' && ln -s '/home/sandbox/run-agent' '/home/sandbox/run/agent-files'; }`, + ], + timeoutMs: 30_000, + }, + ]); + }); + + it("swallows a thrown remote process error and logs it", async () => { + const logs: string[] = []; + await seedAgentReadmeRemote( + { + runProcess: async () => { + throw new Error("exec timed out"); + }, + }, + "/home/sandbox/run-agent", + { log: (message) => logs.push(message) }, + ); + assert.equal(logs.length, 1); + assert.match(logs[0], /exec timed out/); + }); + + it("logs a non-zero remote process exit", async () => { + const logs: string[] = []; + await linkAgentFilesRemote( + { runProcess: async () => ({ exitCode: 17 }) }, + "/home/sandbox/run", + "/home/sandbox/run-agent", + { log: (message) => logs.push(message) }, + ); + assert.equal(logs.length, 1); + assert.match(logs[0], /exit 17/); + }); +}); From fc0e8575e80478f6f04ecb3f48c9c82bb557a746 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 12 Jul 2026 12:59:38 +0200 Subject: [PATCH 3/9] feat(web): agent files panel in the session inspector Claude-Session: https://claude.ai/code/session_01UogudBC9VRCRkpT5bZVxex --- .../AgentChatSlice/AgentChatPanel.tsx | 4 + .../AgentChatSlice/components/SessionRail.tsx | 13 ++- .../PanelSessionInspectorButton.tsx | 4 +- .../SessionInspectorButton.tsx | 10 ++- .../SessionInspectorDrawer.tsx | 15 +++- .../components/SessionInspector/api.test.ts | 51 +++++++++++ .../src/components/SessionInspector/api.ts | 10 +++ .../src/components/SessionInspector/store.ts | 12 ++- .../SessionInspector/tabs/MountsTab.tsx | 89 ++++++++++++++----- .../src/workflow/state/molecule.ts | 9 ++ 10 files changed, 181 insertions(+), 36 deletions(-) create mode 100644 web/oss/src/components/SessionInspector/api.test.ts diff --git a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx index bf2b0f9a5d..044cba9a1e 100644 --- a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx @@ -1,5 +1,6 @@ import {lazy, Suspense, useEffect, useRef, useState} from "react" +import {workflowMolecule} from "@agenta/entities/workflow" import {simulatedAgentRunAtomFamily} from "@agenta/shared/state" import {Splitter, Tabs} from "antd" import clsx from "clsx" @@ -52,6 +53,7 @@ const RAIL_MAX_WIDTH = 480 * `buildAgentRequest` against the current `entityId` (so the run always uses the live draft config). */ const AgentChatPanel = ({entityId}: {entityId: string}) => { + const artifactId = useAtomValue(workflowMolecule.selectors.workflowId(entityId)) const scope = useChatScopeKey() // Pre-commit onboarding: one ephemeral session, no multi-session UX — hide the whole session bar // (tabs / new / search / history). Stays hidden through the commit + first send, then eases in a beat @@ -144,6 +146,7 @@ const AgentChatPanel = ({entityId}: {entityId: string}) => { @@ -186,6 +189,7 @@ const AgentChatPanel = ({entityId}: {entityId: string}) => { <> diff --git a/web/oss/src/components/AgentChatSlice/components/SessionRail.tsx b/web/oss/src/components/AgentChatSlice/components/SessionRail.tsx index df2f4ebe0e..d3ee358b2b 100644 --- a/web/oss/src/components/AgentChatSlice/components/SessionRail.tsx +++ b/web/oss/src/components/AgentChatSlice/components/SessionRail.tsx @@ -28,6 +28,7 @@ import {SessionStatusDot} from "./SessionTagBar" interface SessionRailRowProps { session: AgentChatSession + artifactId?: string | null label: string active: boolean open: boolean @@ -45,6 +46,7 @@ interface SessionRailRowProps { * it eases in on add and out on remove (siblings reflow smoothly). */ const SessionRailRow = ({ session, + artifactId, label, active, open, @@ -110,7 +112,12 @@ const SessionRailRow = ({ open )} - {active && } + {active && ( + + )}