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" + ), + ) diff --git a/services/runner/src/engines/sandbox_agent.ts b/services/runner/src/engines/sandbox_agent.ts index 06843645ea..077b48fdf7 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,21 @@ 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 { + AGENT_MOUNT_SYSTEM_PROMPT_SEGMENT, + claudeMountSystemPromptMeta, + combineAppendSystemPrompt, + type ClaudeSystemPromptMeta, +} from "./sandbox_agent/agent-mount-guidance.ts"; import { hydrateHarnessSessionFromDurable, syncHarnessSessionDurable, @@ -334,6 +349,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 +555,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 +568,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 +721,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 +755,23 @@ export async function acquireEnvironment( }); if (!planResult.ok) return { ok: false, error: planResult.error }; const plan = planResult.plan; + const agentMountDir = agentMountCreds ? agentMountPath(plan.cwd) : undefined; + + // Best-effort discovery fix: when this run has a durable agent mount, steer the harness to + // it via the SYSTEM PROMPT (never the author's CLAUDE.md/AGENTS.md) so a natural "remember + // this" prompt lands in `agent-files/` instead of the harness's own throwaway session + // memory. No mount, no segment. See `agent-mount-guidance.ts` for the per-harness wiring. + if (agentMountDir && plan.isPi) { + plan.appendSystemPrompt = combineAppendSystemPrompt( + plan.appendSystemPrompt, + AGENT_MOUNT_SYSTEM_PROMPT_SEGMENT, + ); + plan.hasSystemPrompt = true; + } + const claudeSystemPromptMeta: ClaudeSystemPromptMeta | undefined = + agentMountDir && plan.acpAgent === "claude" + ? claudeMountSystemPromptMeta(AGENT_MOUNT_SYSTEM_PROMPT_SEGMENT) + : 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 +809,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 +869,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 +946,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}`, @@ -917,20 +987,49 @@ export async function acquireEnvironment( logger( `local durable cwd mount (${reason}) session=${sessionForMount} cwd=${plan.cwd}`, ); - if ( - await (deps.mountStorage ?? mountStorage)( - plan.cwd, - environment.mountCreds, - { - log: logger, - }, - ) - ) { + environment.durableCwdSafeToDelete = false; + const mounted = await (deps.mountStorage ?? mountStorage)( + plan.cwd, + environment.mountCreds, + { + log: logger, + }, + ); + if (mounted) { environment.mountedCwd = plan.cwd; return true; } + // A false result means mountStorage stopped the attempt and confirmed the path detached. + environment.durableCwdSafeToDelete = true; 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 +1080,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 +1253,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 +1310,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 { @@ -1303,6 +1454,18 @@ export async function acquireEnvironment( // Close the internal gateway-tool MCP server (if one started) when the session is destroyed. environment.closeToolMcp = sessionMcp.close; + // Shared session-init payload for both the createSession and continuity-resume paths below. + // Built as a plain variable (not an inline object literal at the call site) so the extra + // `_meta` key survives the daemon SDK's narrow `Omit` types — + // the daemon's own runtime forwards `_meta` unconditionally (`normalizeSessionInit` / + // `buildLoadSessionParams` in the vendored `sandbox-agent` patch), only the published types + // are stricter than the wire protocol they describe. + const sessionInit = { + cwd: plan.cwd, + mcpServers: sessionMcp.servers, + ...(claudeSystemPromptMeta ? { _meta: claudeSystemPromptMeta } : {}), + }; + // If this harness authored the conversation's most recent turn (staleness-guarded) and we // still remember its native `agentSessionId`, seed the fresh persist driver with a synthetic // record and resume-by-id so the patched `resumeSession` reaches `session/load` instead of @@ -1366,7 +1529,7 @@ export async function acquireEnvironment( agentSessionId: priorAgentSessionId, lastConnectionId: "", createdAt: Date.now(), - sessionInit: { cwd: plan.cwd, mcpServers: sessionMcp.servers }, + sessionInit, }); const createSessionStartedAt = Date.now(); try { @@ -1395,7 +1558,7 @@ export async function acquireEnvironment( ...(localSessionId ? { id: localSessionId } : {}), agent: plan.acpAgent, cwd: plan.cwd, - sessionInit: { cwd: plan.cwd, mcpServers: sessionMcp.servers }, + sessionInit, }); } finally { timingLog("create_session", createSessionStartedAt, " mode=create"); diff --git a/services/runner/src/engines/sandbox_agent/agent-mount-guidance.ts b/services/runner/src/engines/sandbox_agent/agent-mount-guidance.ts new file mode 100644 index 0000000000..48da94c37f --- /dev/null +++ b/services/runner/src/engines/sandbox_agent/agent-mount-guidance.ts @@ -0,0 +1,56 @@ +/** + * Platform system-prompt segment steering an agent to its durable agent mount. + * + * Discovery problem: on a natural "remember this for next time" prompt, an agent has no way + * to know its cwd is throwaway and `agent-files/` (the agent mount linked into the cwd by + * `agent-mount.ts`) is durable, so it writes to its own session-scoped memory and the note is + * lost next session. This segment is appended to the harness's SYSTEM PROMPT — never to the + * author's `CLAUDE.md`/`AGENTS.md` — and only when an agent mount exists for the run. + * + * Wired per harness in `sandbox_agent.ts`: + * - Pi: combined into `plan.appendSystemPrompt`, rendered via the existing + * `APPEND_SYSTEM.md` file channel (`pi-assets.ts`). + * - Claude: passed as the ACP session's `_meta.systemPrompt.append`, which + * `@agentclientprotocol/claude-agent-acp` forwards into the claude-agent-sdk's + * `{ type: "preset", preset: "claude_code", append }` system-prompt option — additive to + * the default Claude Code system prompt, so `CLAUDE.md` loading is unaffected. + */ + +export const AGENT_MOUNT_SYSTEM_PROMPT_SEGMENT = + "You have two storage areas. Your current working directory is scratch for this " + + "conversation only. Your durable agent folder — `agent-files/` in your working directory " + + "(also `$AGENTA_AGENT_MOUNT_DIR`) — persists across all of your sessions. Put throwaway or " + + "session-specific work in the working directory; put anything you want to keep, reuse " + + "later, or share across conversations in the durable agent folder. This includes anything " + + "you would normally save to your own memory tool or notes file: when someone asks you to " + + "remember something for next time, or to save a note, write a plain file into the durable " + + "agent folder — do not use your built-in memory system (files there do not persist between " + + "sessions for this agent), and do not edit your own agent configuration or instructions to " + + "store the information. The reverse matters just as much. This conversation started with no " + + "memory of any previous session, but your durable agent folder might already hold notes " + + "from one. Before you answer any question shaped like a recall (\"what's our X\", \"what " + + "did we decide\", \"did I tell you Y\", \"do you remember Z\") — including one where your " + + "first instinct is that you have no such information — list and check the durable agent " + + "folder for a relevant file first. Only answer that you don't know after actually checking; " + + "never assume nothing was saved just because this conversation is new. Example: when you " + + "research a go-to-market plan, keep your intermediate notes and drafts in the working " + + "directory, and write the final report into the durable agent folder."; + +/** Combine a request-supplied append-system-prompt with the platform segment (Pi). */ +export function combineAppendSystemPrompt( + existing: string | undefined, + segment: string, +): string { + return existing ? `${existing}\n\n${segment}` : segment; +} + +/** The `_meta.systemPrompt` shape `claude-agent-acp` forwards into the Claude SDK's preset. */ +export interface ClaudeSystemPromptMeta { + systemPrompt: { append: string }; +} + +export function claudeMountSystemPromptMeta( + segment: string, +): ClaudeSystemPromptMeta { + return { systemPrompt: { append: segment } }; +} 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/src/engines/sandbox_agent/mount.ts b/services/runner/src/engines/sandbox_agent/mount.ts index 428c8ad493..890b32deca 100644 --- a/services/runner/src/engines/sandbox_agent/mount.ts +++ b/services/runner/src/engines/sandbox_agent/mount.ts @@ -271,9 +271,14 @@ async function isMounted( } export interface MountStorageDeps { - /** Injectable command runner for tests; defaults to execFile(geesefs ...). */ - runGeesefs?: (args: string[], env: Record) => Promise; + /** Injectable mount attempt for tests; the returned stop handle prevents a late FUSE attach. */ + runGeesefs?: ( + args: string[], + env: Record, + ) => Promise<{ stop: () => Promise } | void>; checkMounted?: (cwd: string) => Promise; + /** Injectable command/probe seams while retaining production unmountStorage behavior. */ + unmountDeps?: UnmountStorageDeps; log?: (msg: string) => void; } @@ -282,8 +287,9 @@ export interface MountStorageDeps { * * Idempotent: a no-op when `cwd` is already a mountpoint. The scoped credentials ride the * child env (AWS_*), never the argv, so they do not leak to the process table. Returns true - * when the mount is live, false when it could not be established — the caller proceeds on the - * plain (ephemeral) cwd rather than aborting (best-effort). + * when the mount is live, false when it could not be established AND detach was confirmed — the + * caller then proceeds on the plain (ephemeral) cwd. Throws when detach is mounted/inconclusive, + * because recursive ephemeral cleanup must never target a possibly-live FUSE mount. */ export async function mountStorage( cwd: string, @@ -306,7 +312,13 @@ export async function mountStorage( // Not alive: a prior stale node may still occupy the mountpoint. Force-detach before // remounting, else geesefs fails "mountpoint is not empty" / re-stacks on the dead node. - await unmountStorage(cwd, { log }); + const staleMountDetached = await unmountStorage(cwd, { ...deps.unmountDeps, log }); + if (!staleMountDetached) { + throw new Error( + "pre-mount detach could not be confirmed for " + cwd + + "; refusing to start geesefs", + ); + } const args = geesefsArgs(creds, cwd); const env = credEnv(creds); @@ -322,6 +334,13 @@ export async function mountStorage( detached: true, stdio: ["ignore", "ignore", "pipe"], }); + let processExited = false; + const exited = new Promise((resolve) => { + child.once("exit", () => { + processExited = true; + resolve(); + }); + }); child.stderr?.on("data", (d) => { const s = String(d).trim(); if (s) log(`geesefs stderr: ${s.slice(0, 400)}`); @@ -330,31 +349,69 @@ export async function mountStorage( // Poll up to ~15s for the mountpoint to serve I/O; geesefs logs "successfully mounted" // within ~1s normally. Resolve as soon as it's alive; the caller re-verifies after. for (let i = 0; i < 30; i++) { - if (await isMounted(cwd, () => {})) return; + if (await isMounted(cwd, () => {})) break; await new Promise((r) => setTimeout(r, 500)); } + return { + stop: async () => { + const waitForExit = async (): Promise => { + if (processExited) return true; + await Promise.race([ + exited, + new Promise((resolve) => setTimeout(resolve, 2_000)), + ]); + return processExited; + }; + if (processExited) return; + child.kill("SIGTERM"); + if (await waitForExit()) return; + child.kill("SIGKILL"); + if (await waitForExit()) return; + throw new Error( + `geesefs process did not exit after SIGTERM and SIGKILL cwd=${cwd}`, + ); + }, + }; }); + let attempt: { stop: () => Promise } | undefined; + let failure: unknown; try { log(`geesefs mount argv: ${args.join(" ")}`); - await run(args, env); + const started = await run(args, env); + attempt = started || undefined; // Confirm the new mount actually serves I/O — a still-not-alive cwd means geesefs failed // to mount (invalid STS creds, store unreachable) or did not come up within the poll window. if (!(await checkMounted(cwd))) { - log( + failure = new Error( `mount reported success but cwd is NOT alive ${creds.bucket}:${creds.prefix} -> ${cwd} ` + `— likely expired/invalid STS creds or store unreachable`, ); - return false; + } else { + log(`mounted ${creds.bucket}:${creds.prefix} -> ${cwd} (verified alive)`); + return true; } - log(`mounted ${creds.bucket}:${creds.prefix} -> ${cwd} (verified alive)`); - return true; } catch (err) { - log( - `mount failed ${creds.bucket}:${creds.prefix} -> ${cwd}: ${String(err instanceof Error ? err.message : err).slice(0, 300)}`, + failure = err; + } + + // Never detach/fallback while a failed geesefs attempt may still attach later. + await attempt?.stop(); + const detached = await unmountStorage(cwd, { ...deps.unmountDeps, log }); + const detail = String( + failure instanceof Error ? failure.message : failure, + ).slice(0, 300); + if (!detached) { + throw new Error( + `mount failed and detach could not be confirmed for ${cwd}; refusing ephemeral cwd fallback ` + + `to avoid recursively deleting a live or indeterminate FUSE mount: ${detail}`, ); - return false; } + log( + `mount failed ${creds.bucket}:${creds.prefix} -> ${cwd}; detach confirmed, ` + + `running on ephemeral cwd: ${detail}`, + ); + return false; } export interface UnmountStorageDeps { @@ -392,7 +449,6 @@ export async function unmountStorage( log( `unmount failed ${cwd}: ${String(err instanceof Error ? err.message : err).slice(0, 200)}`, ); - return false; } // Lazy unmount can leave the node attached until the dead daemon's last ref drops; that // residual node serves ENOTCONN and poisons the next session. Verify it's actually gone. @@ -406,9 +462,18 @@ export async function unmountStorage( return false; } -// `mountpoint -q` exits 0 = still mounted, 1 = not a mountpoint (confirmed gone). Any other -// failure (missing binary, unexpected error) is NOT confirmation, so the caller never deletes -// through a possibly-live mount. +// util-linux `mountpoint -q` exits 0 = mounted and 32 = not a mountpoint. Some implementations +// use 1 for the latter. Node may surface child-process exit codes as numbers or strings. Every +// other failure remains inconclusive, so callers never delete through a possibly-live mount. +export function mountpointFailureState( + error: unknown, +): "gone" | "inconclusive" { + const code = (error as { code?: unknown } | null | undefined)?.code; + return code === 1 || code === 32 || code === "1" || code === "32" + ? "gone" + : "inconclusive"; +} + async function defaultCheckMountpoint( cwd: string, ): Promise<"gone" | "mounted" | "inconclusive"> { @@ -416,7 +481,7 @@ async function defaultCheckMountpoint( await pExecFile("mountpoint", ["-q", cwd]); return "mounted"; } catch (err) { - return (err as { code?: number }).code === 1 ? "gone" : "inconclusive"; + return mountpointFailureState(err); } } diff --git a/services/runner/src/engines/sandbox_agent/session-pool.ts b/services/runner/src/engines/sandbox_agent/session-pool.ts index 2020474639..0ec9951879 100644 --- a/services/runner/src/engines/sandbox_agent/session-pool.ts +++ b/services/runner/src/engines/sandbox_agent/session-pool.ts @@ -1,16 +1,16 @@ /** - * Session keep-alive pool: normal turn boundaries, local sandbox, flag-gated off by default. + * Session keep-alive pool: normal turn boundaries, local sandbox, enabled by default. * - * Background: today the runner destroys the sandbox and harness session at the end of every - * turn (`sandbox_agent.ts` teardown). Keep-alive parks a live session for a short TTL so the - * next message in the same conversation continues the same live harness process, keeping its - * native memory. If the window expires or anything mismatches, the dispatch falls back to - * today's cold path. Nothing gets worse than today; with the flag off nothing here runs. + * At a normal turn boundary, keep-alive parks the live session for a short TTL so the next + * message in the same conversation continues the same harness process and native memory. If the + * window expires, the operator disables keep-alive, or any fingerprint mismatches, dispatch falls + * back to the cold path. * * This module is engine-agnostic: it holds opaque `environment` handles plus the metadata the * dispatch needs to decide continue-versus-cold (two fingerprints, a credential epoch, an LRU * timestamp, a state) and a complete idempotent `teardown(reason)` closure the engine supplies. It - * never imports the engine, so it stays a pure map + timer + policy unit. + * never imports the engine, so it stays a pure map + timer + policy unit. Operators can disable + * it explicitly with `AGENTA_RUNNER_SESSION_KEEPALIVE=off`. */ import { createHash } from "node:crypto"; @@ -71,10 +71,12 @@ function nonNegativeIntEnv(name: string, fallback: number): number { return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : fallback; } -/** The runner treats only a few explicit truthy spellings as on; default OFF. */ -function boolEnv(name: string): boolean { +function boolEnv(name: string, fallback: boolean): boolean { const raw = (process.env[name] ?? "").trim().toLowerCase(); - return raw === "1" || raw === "true" || raw === "yes" || raw === "on"; + if (!raw) return fallback; + if (raw === "1" || raw === "true" || raw === "yes" || raw === "on") return true; + if (raw === "0" || raw === "false" || raw === "no" || raw === "off") return false; + return fallback; } /** Read one provider's keep-alive config from the environment. */ @@ -98,7 +100,7 @@ export function readKeepaliveConfig( }; } return { - enabled: boolEnv(KEEPALIVE_ENV), + enabled: boolEnv(KEEPALIVE_ENV, true), ttlMs: positiveIntEnv(TTL_ENV, DEFAULT_TTL_MS), approvalTtlMs: positiveIntEnv(APPROVAL_TTL_ENV, DEFAULT_APPROVAL_TTL_MS), poolMax: positiveIntEnv(POOL_MAX_ENV, DEFAULT_POOL_MAX), diff --git a/services/runner/src/engines/sandbox_agent/workspace.ts b/services/runner/src/engines/sandbox_agent/workspace.ts index 3a1f8b04fe..9974bdb966 100644 --- a/services/runner/src/engines/sandbox_agent/workspace.ts +++ b/services/runner/src/engines/sandbox_agent/workspace.ts @@ -102,6 +102,11 @@ export async function prepareWorkspace({ return { cleanup: async () => {} }; } + // A durable local cwd mount is best-effort. When geesefs cannot mount (for example, the + // runner has no /dev/fuse), acquisition deliberately falls back to an ephemeral cwd. Ensure + // that fallback exists before writing CLAUDE.md/AGENTS.md or any harness files into it. + mkdirSync(plan.cwd, { recursive: true }); + if (plan.useToolRelay) { // Clear stale .req.json from a prior turn: relayDir is keyed on the durable cwd and // is never otherwise cleared, so an old request would be re-picked-up by the fresh `seen` set. 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/); + }); +}); diff --git a/services/runner/tests/unit/sandbox-agent-mount.test.ts b/services/runner/tests/unit/sandbox-agent-mount.test.ts index 5310c3b1c7..c87feacaf2 100644 --- a/services/runner/tests/unit/sandbox-agent-mount.test.ts +++ b/services/runner/tests/unit/sandbox-agent-mount.test.ts @@ -15,6 +15,7 @@ import { signSessionMountCredentials, mountStorage, unmountStorage, + mountpointFailureState, discoverTunnelEndpoint, mountStorageRemote, harnessSessionMounts, @@ -304,16 +305,85 @@ describe("mountStorage", () => { assert.equal(ran, false); }); - it("returns false (does not throw) when geesefs fails", async () => { + it("falls back after stop when fusermount errors but the production probe confirms gone", async () => { + const calls: string[] = []; const ok = await mountStorage("/work/cwd", CREDS, { checkMounted: async () => false, runGeesefs: async () => { - throw new Error("fuse: device not found"); + calls.push("mount"); + return { + stop: async () => { + calls.push("stop"); + }, + }; + }, + unmountDeps: { + runUnmount: async () => { + calls.push("fusermount-error"); + throw new Error("not mounted"); + }, + checkMountpoint: async () => (calls.push("probe-gone"), "gone"), }, log: SILENT, }); assert.equal(ok, false); + assert.deepEqual(calls, [ + "fusermount-error", + "probe-gone", + "mount", + "stop", + "fusermount-error", + "probe-gone", + ]); }); + + it("refuses fallback when the failed geesefs process does not stop", async () => { + const calls: string[] = []; + await assert.rejects( + mountStorage("/work/cwd", CREDS, { + checkMounted: async () => false, + runGeesefs: async () => ({ + stop: async () => { + calls.push("stop-unconfirmed"); + throw new Error("geesefs process did not exit after SIGTERM and SIGKILL"); + }, + }), + unmountDeps: { + runUnmount: async () => { + calls.push("unmount"); + }, + checkMountpoint: async () => (calls.push("probe"), "gone"), + }, + log: SILENT, + }), + /geesefs process did not exit after SIGTERM and SIGKILL/, + ); + assert.deepEqual(calls, ["unmount", "probe", "stop-unconfirmed"]); + }); + + for (const detachState of ["mounted", "inconclusive"] as const) { + it("fails clearly when the failed mount remains " + detachState, async () => { + let probes = 0; + await assert.rejects( + mountStorage("/work/cwd", CREDS, { + checkMounted: async () => false, + runGeesefs: async () => { + throw new Error("fuse: device not found"); + }, + unmountDeps: { + runUnmount: async () => {}, + checkMountpoint: async () => { + probes += 1; + return probes === 1 ? "gone" : detachState; + }, + }, + log: SILENT, + }), + /detach could not be confirmed.*refusing ephemeral cwd fallback.*fuse: device not found/, + ); + assert.equal(probes, 2); + }); + } }); describe("unmountStorage", () => { @@ -333,6 +403,7 @@ describe("unmountStorage", () => { runUnmount: async () => { throw new Error("not mounted"); }, + checkMountpoint: async () => "inconclusive", log: SILENT, }); // No throw == pass, but callers must not treat this as safe to delete the cwd. @@ -340,6 +411,20 @@ describe("unmountStorage", () => { }); }); +describe("mountpointFailureState", () => { + for (const code of [1, 32, "1", "32"]) { + it("treats not-a-mountpoint exit code " + code + " as gone", () => { + assert.equal(mountpointFailureState({ code }), "gone"); + }); + } + + for (const code of [0, 2, 127, "ENOENT", undefined]) { + it("keeps unexpected exit code " + String(code) + " inconclusive", () => { + assert.equal(mountpointFailureState({ code }), "inconclusive"); + }); + } +}); + describe("unmountStorage confirmation", () => { // A caller (workspace cleanup) must gate rmSync on this return value, not merely on // fusermount not throwing — a lazy unmount (-uz) can leave the node attached. diff --git a/services/runner/tests/unit/sandbox-agent-orchestration.test.ts b/services/runner/tests/unit/sandbox-agent-orchestration.test.ts index 4f3879c077..6d03bac316 100644 --- a/services/runner/tests/unit/sandbox-agent-orchestration.test.ts +++ b/services/runner/tests/unit/sandbox-agent-orchestration.test.ts @@ -6,6 +6,7 @@ import { describe, it } from "vitest"; import assert from "node:assert/strict"; import { tmpdir } from "node:os"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import type { AgentEvent, AgentRunRequest } from "../../src/protocol.ts"; @@ -14,6 +15,7 @@ import { TOOL_NOT_EXECUTED_PAUSED, } from "../../src/tracing/otel.ts"; import { PendingApprovalPauseController } from "../../src/engines/sandbox_agent/pause.ts"; +import { mountStorage } from "../../src/engines/sandbox_agent/mount.ts"; import { buildPiGateEnvelope } from "../../src/engines/sandbox_agent/pi-gate-envelope.ts"; import { USER_MCP_UNSUPPORTED_MESSAGE } from "../../src/tools/mcp-bridge.ts"; import type { PermissionDecision } from "../../src/responder.ts"; @@ -408,6 +410,76 @@ describe("runSandboxAgent orchestration", () => { assert.equal(calls.workspaceCleanup, 1); }); + it("preserves the durable cwd when failed mount detach is unsafe", async () => { + const prefix = "unsafe-mount-" + process.pid + "-" + Date.now(); + const cwd = join(tmpdir(), "agenta", prefix); + const sentinel = join(cwd, "must-not-delete.txt"); + mkdirSync(cwd, { recursive: true }); + writeFileSync(sentinel, "durable data"); + const { calls, deps } = fakeHarness(); + deps.signSessionMountCredentials = (async () => ({ + endpoint: "http://seaweedfs:8333", + region: "us-east-1", + bucket: "agenta-store", + prefix, + accessKey: "AK-1", + secretKey: "SK-1", + })) as any; + let mountpointProbes = 0; + deps.mountStorage = ((cwd: string, creds: any, options: any) => + mountStorage(cwd, creds, { + ...options, + checkMounted: async () => false, + runGeesefs: async () => { + throw new Error("fuse: device not found"); + }, + unmountDeps: { + runUnmount: async () => {}, + checkMountpoint: async () => { + mountpointProbes += 1; + return mountpointProbes === 1 ? "gone" : "mounted"; + }, + }, + })) as any; + + const result = await runSandboxAgent( + { + harness: "claude", + sessionId: "sess-1", + telemetry: { + exporters: { otlp: { headers: { authorization: "ApiKey run" } } }, + }, + messages: [{ role: "user", content: "hello" }], + } as AgentRunRequest, + undefined, + undefined, + deps, + ); + + assert.equal(result.ok, false); + if (result.ok) return; + assert.match( + result.error ?? "", + /detach could not be confirmed.*refusing ephemeral cwd fallback/, + ); + assert.equal( + calls.workspacePlan, + undefined, + "unsafe detach fails before workspace preparation", + ); + assert.equal( + calls.workspaceCleanup, + 0, + "prepareWorkspace cleanup never starts after unsafe mount acquisition", + ); + assert.equal( + existsSync(sentinel), + true, + "environment teardown must preserve the possibly-live durable cwd", + ); + rmSync(cwd, { recursive: true, force: true }); + }); + it("re-signs, remounts, and retries local workspace prep on durable cwd ENOTCONN", async () => { const { calls, deps } = fakeHarness(); const seenMountAccessKeys: string[] = []; diff --git a/services/runner/tests/unit/sandbox-agent-workspace.test.ts b/services/runner/tests/unit/sandbox-agent-workspace.test.ts index 86f934b310..8caac377ed 100644 --- a/services/runner/tests/unit/sandbox-agent-workspace.test.ts +++ b/services/runner/tests/unit/sandbox-agent-workspace.test.ts @@ -32,6 +32,35 @@ afterEach(() => { }); describe("prepareWorkspace", () => { + it("creates a missing ephemeral cwd after a local mount fallback", async () => { + // mountStorage intentionally returns false when FUSE is unavailable. Model that fail-open + // path by entering workspace preparation with no mountpoint directory at all. + const root = tempDir(); + const cwd = join(root, "mounts", "session", "run"); + + const workspace = await prepareWorkspace({ + sandbox: {}, + plan: { + isDaytona: false, + cwd, + relayDir: join(cwd, ".agenta-tools"), + useToolRelay: false, + agentsMd: "agent instructions", + acpAgent: "claude", + isPi: false, + skillDirs: [], + }, + }); + + assert.equal( + readFileSync(join(cwd, "CLAUDE.md"), "utf-8"), + "agent instructions", + ); + + await workspace.cleanup(); + assert.equal(existsSync(cwd), false); + }); + it("prepares and cleans a local cwd", async () => { const cwd = tempDir(); diff --git a/services/runner/tests/unit/session-pool.test.ts b/services/runner/tests/unit/session-pool.test.ts index 3322f5dd0e..194d38b4b7 100644 --- a/services/runner/tests/unit/session-pool.test.ts +++ b/services/runner/tests/unit/session-pool.test.ts @@ -170,9 +170,9 @@ describe("readKeepaliveConfig", () => { } }); - it("defaults: off, 60s idle, 5m approval, cap 8", () => { + it("defaults: on, 60s idle, 5m approval, cap 8", () => { assert.deepEqual(readKeepaliveConfig("local"), { - enabled: false, + enabled: true, ttlMs: 60_000, approvalTtlMs: 300_000, poolMax: 8, @@ -190,6 +190,8 @@ describe("readKeepaliveConfig", () => { process.env.AGENTA_RUNNER_SESSION_KEEPALIVE = "off"; assert.equal(readKeepaliveConfig("local").enabled, false); + process.env.AGENTA_RUNNER_SESSION_KEEPALIVE = "not-a-boolean"; + assert.equal(readKeepaliveConfig("local").enabled, true); process.env.AGENTA_RUNNER_SESSION_TTL_MS = "-1"; assert.equal( readKeepaliveConfig("local").ttlMs, 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 && ( + + )}