From 729d90a8ddb00d8bef43a0f31d165c39280cf64c Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Tue, 14 Jul 2026 02:59:19 +0200 Subject: [PATCH 1/2] fix(runner): narrow the runner environment and drop subscription auto-upload The runner discovered its own Pi login (auth.json + settings) and uploaded it into a third-party Daytona sandbox for any self-managed run. A personal subscription credential must never leave the runner container, and a local harness that shares the runner container must not be able to read a static platform API key from the runner's process environment. Phases 2+3 of runner-selfhosting-cleanup: - Delete shouldUploadOwnLogin + uploadPiAuthToSandbox and their tests. Daytona never receives a personal login; managed Daytona runs authenticate from the vault keys instead. - buildRunPlan rejects Daytona + runtime_provided, and rejects a local runtime_provided run whose subscription mount (PI_CODING_AGENT_DIR / CLAUDE_CONFIG_DIR) is unset, before any sandbox side effect. - Local runtime_provided Pi and Claude always copy the read-only mounted login into a per-run dir so harness writes never touch the operator's source. - Remove the static AGENTA_API_KEY OTLP exporter fallback; export auth rides the per-run caller credential. - One structured "mount degraded kind= cause=" warning at each durable-null site (session cwd, agent mount, harness transcript). Failure behavior unchanged. - Helm: wire AGENTA_RUNNER_TOKEN via agentRunner.auth.tokenSecretRef on both the runner (verifier) and Services (sender), guarded so it renders only when set, plus a rendered-chart secret-absence test wired into CI. - Compose: commented opt-in read-only subscription mount examples (OSS + EE). Deferred (post-release): the credentialMode strictness flip is NOT free on this base. A model-less agent template in the SDK handler threads no resolved_connection and omits the field (see status.md). This slice keeps the existing inference branch; the flip lands after the caller fix. AGENTA_RUNNER_INHERIT_ALL_PROVIDER_KEYS, commonEnv, and AGENTA_SESSION_HARNESS_MOUNTS are already no-ops on this base. Claude-Session: https://claude.ai/code/session_01XhENr63WL9npkKrJGnzDc1 --- .github/workflows/12-check-unit-tests.yml | 43 +++++ .../docker-compose/ee/docker-compose.gh.yml | 12 ++ .../docker-compose/oss/docker-compose.gh.yml | 12 ++ .../kubernetes/helm/templates/_helpers.tpl | 10 ++ .../helm/templates/runner-deployment.yaml | 13 ++ .../helm/tests/test_runner_secret_absence.py | 161 ++++++++++++++++++ hosting/kubernetes/helm/values.schema.json | 17 ++ hosting/kubernetes/helm/values.yaml | 4 + services/runner/src/engines/sandbox_agent.ts | 56 ++++-- .../src/engines/sandbox_agent/daytona.ts | 46 +---- .../runner/src/engines/sandbox_agent/mount.ts | 18 +- .../src/engines/sandbox_agent/pi-assets.ts | 35 +++- .../src/engines/sandbox_agent/run-plan.ts | 76 +++++---- services/runner/src/tracing/otel.ts | 13 +- .../tests/unit/sandbox-agent-daytona.test.ts | 35 +--- .../unit/sandbox-agent-orchestration.test.ts | 37 ++-- .../tests/unit/sandbox-agent-run-plan.test.ts | 138 ++++++++++----- 17 files changed, 543 insertions(+), 183 deletions(-) create mode 100644 hosting/kubernetes/helm/tests/test_runner_secret_absence.py diff --git a/.github/workflows/12-check-unit-tests.yml b/.github/workflows/12-check-unit-tests.yml index 0e61bd2aed..473e02f638 100644 --- a/.github/workflows/12-check-unit-tests.yml +++ b/.github/workflows/12-check-unit-tests.yml @@ -7,6 +7,7 @@ on: - 'sdks/python/**' - 'api/**' - 'services/**' + - 'hosting/kubernetes/helm/**' - '.github/workflows/12-check-unit-tests.yml' workflow_dispatch: inputs: @@ -388,6 +389,48 @@ jobs: check_name: Agent Runner Unit Test Results comment_mode: off + run-helm-render-tests: + # Rendered-chart regression guard: the agent runner Deployment must render with a narrow + # environment (no platform DB/auth/crypt/license/Redis/store secrets, no unrelated provider + # keys, no static AGENTA_API_KEY). A self-hosted local harness shares the runner container, so + # a widened env is a credential-exposure regression. Renders with `helm template` and asserts. + if: | + github.event_name == 'workflow_dispatch' || + !github.event.pull_request.draft + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Skip when package selection excludes services + if: github.event_name == 'workflow_dispatch' && !contains(fromJSON('["all","services-only"]'), inputs.packages) + run: exit 0 + + - name: Set up Helm + if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["all","services-only"]'), inputs.packages) + uses: azure/setup-helm@v4 + with: + version: v3.18.4 + + - name: Set up Python + if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["all","services-only"]'), inputs.packages) + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install uv + if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["all","services-only"]'), inputs.packages) + run: python -m pip install "uv==${UV_VERSION}" + + - name: Lint the Helm chart + if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["all","services-only"]'), inputs.packages) + run: helm lint hosting/kubernetes/helm + + - name: Assert the runner Deployment renders a narrow environment + if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["all","services-only"]'), inputs.packages) + run: uv run hosting/kubernetes/helm/tests/test_runner_secret_absence.py + run-runner-integration-tests: if: | github.event_name == 'workflow_dispatch' || diff --git a/hosting/docker-compose/ee/docker-compose.gh.yml b/hosting/docker-compose/ee/docker-compose.gh.yml index 7da1ee30b4..e1e10e2c71 100644 --- a/hosting/docker-compose/ee/docker-compose.gh.yml +++ b/hosting/docker-compose/ee/docker-compose.gh.yml @@ -283,6 +283,18 @@ services: AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS: ${AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS:-} AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM: ${AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM:-} AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} + # === SUBSCRIPTION MOUNTS (opt-in) ========================= # + # Use your own harness subscription for LOCAL runs instead of a managed API + # key. Mount the login read-only; the runner copies it into a per-run dir, so + # the harness's own writes never touch your source. Single-operator convenience + # only — this state is never shipped to a Daytona sandbox. A runtime_provided + # local run without the matching mount fails with a clear error. + # volumes: + # - ~/.pi/agent:/agenta/harness/pi:ro # Pi / Codex login + # - ~/.claude:/agenta/harness/claude:ro # Claude login + # Then point the harness config var at the mount (override the defaults above): + # PI_CODING_AGENT_DIR: /agenta/harness/pi + # CLAUDE_CONFIG_DIR: /agenta/harness/claude # === NETWORK ============================================== # networks: - agenta-ee-gh-network diff --git a/hosting/docker-compose/oss/docker-compose.gh.yml b/hosting/docker-compose/oss/docker-compose.gh.yml index 05097862fd..c58f98b57f 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.yml @@ -300,6 +300,18 @@ services: AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS: ${AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS:-} AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM: ${AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM:-} AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} + # === SUBSCRIPTION MOUNTS (opt-in) ========================= # + # Use your own harness subscription for LOCAL runs instead of a managed API + # key. Mount the login read-only; the runner copies it into a per-run dir, so + # the harness's own writes never touch your source. Single-operator convenience + # only — this state is never shipped to a Daytona sandbox. A runtime_provided + # local run without the matching mount fails with a clear error. + # volumes: + # - ~/.pi/agent:/agenta/harness/pi:ro # Pi / Codex login + # - ~/.claude:/agenta/harness/claude:ro # Claude login + # Then point the harness config var at the mount (override the defaults above): + # PI_CODING_AGENT_DIR: /agenta/harness/pi + # CLAUDE_CONFIG_DIR: /agenta/harness/claude # === NETWORK ============================================== # networks: - agenta-oss-gh-network diff --git a/hosting/kubernetes/helm/templates/_helpers.tpl b/hosting/kubernetes/helm/templates/_helpers.tpl index f6fd01dc8f..6f082bce0a 100644 --- a/hosting/kubernetes/helm/templates/_helpers.tpl +++ b/hosting/kubernetes/helm/templates/_helpers.tpl @@ -322,11 +322,21 @@ http://{{ include "agenta.agentRunner.serviceName" . }}:{{ include "agenta.agent {{- define "agenta.agentRunner.servicesEnv" -}} {{- $runner := default dict .Values.agentRunner -}} +{{- $auth := default dict $runner.auth -}} {{- $url := include "agenta.agentRunner.url" . -}} {{- if $url }} - name: AGENTA_RUNNER_INTERNAL_URL value: {{ $url | quote }} {{- end }} +{{- /* Services sends the shared runner protocol credential the runner verifies (interface.md + section 2). Same secret ref as runner-deployment.yaml, from the caller side. */ -}} +{{- if $auth.tokenSecretRef }} +- name: AGENTA_RUNNER_TOKEN + valueFrom: + secretKeyRef: + name: {{ $auth.tokenSecretRef.name | quote }} + key: {{ $auth.tokenSecretRef.key | quote }} +{{- end }} {{- end }} {{/* ================================================================ diff --git a/hosting/kubernetes/helm/templates/runner-deployment.yaml b/hosting/kubernetes/helm/templates/runner-deployment.yaml index eade3b47b1..cfc267e184 100644 --- a/hosting/kubernetes/helm/templates/runner-deployment.yaml +++ b/hosting/kubernetes/helm/templates/runner-deployment.yaml @@ -2,6 +2,7 @@ {{- $providers := default dict $runner.providers -}} {{- $enabledProviders := default (list "local") $providers.enabled -}} {{- $daytona := default dict $providers.daytona -}} +{{- $auth := default dict $runner.auth -}} {{- if eq (include "agenta.agentRunner.enabled" .) "true" }} apiVersion: apps/v1 kind: Deployment @@ -102,6 +103,18 @@ spec: key: {{ $daytona.apiKeySecretRef.key | quote }} optional: true {{- end }} + {{- /* Shared runner protocol credential (interface.md section 2): the runner verifies + it and Services sends the same value (see agentRunner.servicesEnv). Rendered only + when a secret ref is set, so an unauthenticated single-operator deployment stays + valid. */ -}} + {{- if $auth.tokenSecretRef }} + - name: AGENTA_RUNNER_TOKEN + valueFrom: + secretKeyRef: + name: {{ $auth.tokenSecretRef.name | quote }} + key: {{ $auth.tokenSecretRef.key | quote }} + optional: true + {{- end }} {{- range $key, $val := $runner.env }} - name: {{ $key }} value: {{ $val | quote }} diff --git a/hosting/kubernetes/helm/tests/test_runner_secret_absence.py b/hosting/kubernetes/helm/tests/test_runner_secret_absence.py new file mode 100644 index 0000000000..df6457b1a3 --- /dev/null +++ b/hosting/kubernetes/helm/tests/test_runner_secret_absence.py @@ -0,0 +1,161 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = ["PyYAML>=6"] +# /// +"""Rendered-chart regression guard for the runner's narrow environment. + +The agent runner must run with a deliberately narrow environment (interface.md sections 2, 9, +and the runner-selfhosting-cleanup design): a local harness process shares the runner container, +so anything on the runner's process environment is readable from /proc by user code. This test +renders the Helm chart and asserts the runner Deployment's container env contains ONLY runner and +provider-registry variables — never the platform's database, auth, crypt, license, Redis, object +store, or unrelated provider secrets, and never a static AGENTA_API_KEY. It is the guard that keeps +a future `agenta.commonEnv` include (or any broad env block) from silently re-widening the runner. + +Run: uv run hosting/kubernetes/helm/tests/test_runner_secret_absence.py +Requires the `helm` binary on PATH. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import yaml + +CHART_DIR = Path(__file__).resolve().parents[1] + +# Minimum values needed for the chart to render (URLs are required by the chart's own guard). +BASE_ARGS = [ + "--set", + "agenta.webUrl=https://agenta.example.com", + "--set", + "agenta.apiUrl=https://agenta.example.com/api", + "--set", + "agenta.servicesUrl=https://agenta.example.com/services", + "--set", + "agentRunner.enabled=true", +] + +TOKEN_ARGS = [ + "--set", + "agentRunner.auth.tokenSecretRef.name=agenta-runner", + "--set", + "agentRunner.auth.tokenSecretRef.key=token", +] + +# Exact names the runner container env must NEVER contain. +FORBIDDEN_EXACT = { + "AGENTA_AUTH_KEY", + "AGENTA_CRYPT_KEY", + "AGENTA_LICENSE", + "AGENTA_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "COHERE_API_KEY", + "MISTRAL_API_KEY", +} + +# Prefixes the runner container env must NEVER contain (databases, cache, object store). +FORBIDDEN_PREFIXES = ( + "POSTGRES_", + "REDIS_", + "AGENTA_REDIS_", + "AGENTA_STORE_", +) + +# Names the runner container env MUST contain (its own configuration). +REQUIRED = { + "AGENTA_RUNNER_PORT", + "AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS", +} + + +def render(extra_args: list[str]) -> list[dict]: + result = subprocess.run( + [ + "helm", + "template", + "runner-env-test", + str(CHART_DIR), + *BASE_ARGS, + *extra_args, + ], + capture_output=True, + text=True, + check=True, + ) + return [doc for doc in yaml.safe_load_all(result.stdout) if doc] + + +def runner_container_env_names(docs: list[dict]) -> list[str]: + """The env var NAMES on the runner Deployment's `runner` container.""" + for doc in docs: + if doc.get("kind") != "Deployment": + continue + labels = doc.get("metadata", {}).get("labels", {}) + if labels.get("app.kubernetes.io/component") != "runner": + continue + containers = doc["spec"]["template"]["spec"]["containers"] + runner = next(c for c in containers if c["name"] == "runner") + return [entry["name"] for entry in runner.get("env", [])] + raise AssertionError("no runner Deployment found in the rendered chart") + + +def check(names: list[str], *, expect_token: bool) -> list[str]: + failures: list[str] = [] + present = set(names) + + for forbidden in sorted(FORBIDDEN_EXACT): + if forbidden in present: + failures.append(f"runner env must not contain {forbidden}") + + for name in names: + for prefix in FORBIDDEN_PREFIXES: + if name.startswith(prefix): + failures.append(f"runner env must not contain {name} (prefix {prefix})") + + for required in sorted(REQUIRED): + if required not in present: + failures.append(f"runner env must contain {required}") + + if expect_token and "AGENTA_RUNNER_TOKEN" not in present: + failures.append( + "runner env must contain AGENTA_RUNNER_TOKEN when auth.tokenSecretRef is set" + ) + if not expect_token and "AGENTA_RUNNER_TOKEN" in present: + failures.append( + "runner env must not contain AGENTA_RUNNER_TOKEN when auth.tokenSecretRef is unset" + ) + + return failures + + +def main() -> int: + failures: list[str] = [] + + # Default deployment (no token ref): the narrow env, no runner token. + names = runner_container_env_names(render([])) + failures += check(names, expect_token=False) + + # Token configured: AGENTA_RUNNER_TOKEN appears, everything else stays narrow. + names_with_token = runner_container_env_names(render(TOKEN_ARGS)) + failures += check(names_with_token, expect_token=True) + + if failures: + print("FAIL: runner environment is not narrow:", file=sys.stderr) + for line in failures: + print(f" - {line}", file=sys.stderr) + return 1 + + print( + "OK: runner Deployment env is narrow (no platform secrets, provider keys, or API key)." + ) + print(f" default env: {sorted(names)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/hosting/kubernetes/helm/values.schema.json b/hosting/kubernetes/helm/values.schema.json index 3661103f7f..ca9eaf6ca5 100644 --- a/hosting/kubernetes/helm/values.schema.json +++ b/hosting/kubernetes/helm/values.schema.json @@ -342,6 +342,23 @@ } } } + }, + "auth": { + "type": "object", + "additionalProperties": false, + "description": "Runner protocol credential. Rendered as AGENTA_RUNNER_TOKEN on both the runner (verifier) and the Services API (sender).", + "properties": { + "tokenSecretRef": { + "type": "object", + "additionalProperties": false, + "description": "Secret holding the shared runner token, injected as AGENTA_RUNNER_TOKEN.", + "properties": { + "name": { "type": "string" }, + "key": { "type": "string" } + }, + "required": ["name", "key"] + } + } } } }, diff --git a/hosting/kubernetes/helm/values.yaml b/hosting/kubernetes/helm/values.yaml index 9a6a276d76..118c49603e 100644 --- a/hosting/kubernetes/helm/values.yaml +++ b/hosting/kubernetes/helm/values.yaml @@ -150,6 +150,10 @@ redisDurable: # autodeleteMinutes: 30 # sessionIdleTtlMs: "" # sessionMaxWarm: "" +# auth: +# tokenSecretRef: # AGENTA_RUNNER_TOKEN; Services sends it, the runner verifies it +# name: agenta-runner +# key: token # ================================================================== # # workers — topology A (workers-sprawl); see docs/designs/workers-sprawl/specs.md diff --git a/services/runner/src/engines/sandbox_agent.ts b/services/runner/src/engines/sandbox_agent.ts index 053e1e1d85..8fe4f2f4eb 100644 --- a/services/runner/src/engines/sandbox_agent.ts +++ b/services/runner/src/engines/sandbox_agent.ts @@ -90,6 +90,7 @@ import { buildPiExtensionEnv, configurePiSessionWorkspace, configurePiSkillSnapshot, + prepareLocalClaudeConfigDir, prepareLocalPiAssets, resolvePiSkillSnapshot, uploadSystemPromptToSandbox, @@ -253,8 +254,7 @@ function shouldSuppressPausedToolCallUpdate( pause: PendingApprovalPauseController, ): boolean { const frame = update as - | { sessionUpdate?: unknown; toolCallId?: unknown } - | undefined; + { sessionUpdate?: unknown; toolCallId?: unknown } | undefined; const kind = frame?.sessionUpdate; if (kind !== "tool_call" && kind !== "tool_call_update") return false; const toolCallId = @@ -597,8 +597,7 @@ export interface SessionEnvironment { } export type AcquireEnvironmentResult = - | { ok: true; env: SessionEnvironment } - | { ok: false; error: string }; + { ok: true; env: SessionEnvironment } | { ok: false; error: string }; /** * Sign the session's durable mount up front so keep-alive can build a pool key (the mount's @@ -697,6 +696,14 @@ export async function acquireEnvironment( log: logger, }) : null; + // A session-owned run expects a durable session cwd mount. When signing returns nothing the run + // still proceeds on an ephemeral cwd (behavior unchanged, RSH-11); emit one structured warning + // keyed by mount kind so durable-to-ephemeral degradation is measurable, not silent. + if (sessionForMount && !mountCreds) { + logger( + `mount degraded kind=session_cwd cause=sign_returned_no_mount session=${sessionForMount}`, + ); + } const artifactId = request.runContext?.workflow?.artifact?.id?.trim(); const signAgentMount = @@ -709,6 +716,12 @@ export async function acquireEnvironment( log: logger, }) : null; + // A workflow-artifact run expects an agent mount; same structured degrade signal when unsigned. + if (artifactId && !agentMountCreds) { + logger( + `mount degraded kind=agent_mount cause=sign_returned_no_mount artifact=${artifactId}`, + ); + } // 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. @@ -796,6 +809,22 @@ export async function acquireEnvironment( const binaryPath = (deps.resolveDaemonBinary ?? resolveDaemonBinary)(); let runAgentDir = prepareLocalPiAssets({ plan, env, log: logger }); + // Local Claude subscription run: copy the mounted CLAUDE_CONFIG_DIR into a per-run dir and point + // the daemon at the copy, so the harness's own writes stay off the read-only source mount + // (interface.md section 6). buildRunPlan already rejected a runtime_provided Claude run with no + // configured CLAUDE_CONFIG_DIR, so the source here is the mount. + if ( + !plan.isDaytona && + plan.acpAgent === "claude" && + plan.credentialMode === "runtime_provided" + ) { + const runClaudeConfigDir = prepareLocalClaudeConfigDir( + process.env.CLAUDE_CONFIG_DIR, + logger, + ); + if (runClaudeConfigDir) env.CLAUDE_CONFIG_DIR = runClaudeConfigDir; + } + logger(`harness=${plan.harness} sandbox=${plan.sandboxId} cwd=${plan.cwd}`); // The resolved model ref as it reaches the runner (key NAMES only, never values) — the one @@ -920,13 +949,13 @@ export async function acquireEnvironment( if (!parked && !plan.isDaytona && environment.agentMountedPath) { const agentMountSafeToDelete = await ( environment.deps.unmountStorage ?? unmountStorage - )( - environment.agentMountedPath, - { log }, - ).catch(() => false); + )(environment.agentMountedPath, { log }).catch(() => false); if (agentMountSafeToDelete) { try { - rmSync(environment.agentMountedPath, { recursive: true, force: true }); + rmSync(environment.agentMountedPath, { + recursive: true, + force: true, + }); } catch (err) { logger( `agent mountpoint cleanup failed path=${environment.agentMountedPath}: ${conciseError(err, plan.harness)}`, @@ -1356,12 +1385,9 @@ export async function acquireEnvironment( await seedAgentReadmeRemote(environment.sandbox, mountPath, { log: logger, }); - await linkAgentFilesRemote( - environment.sandbox, - plan.cwd, - mountPath, - { log: logger }, - ); + await linkAgentFilesRemote(environment.sandbox, plan.cwd, mountPath, { + log: logger, + }); await activateAgentMountGuidance(); logger(`remote agent mount active for artifact=${artifactId}`); } diff --git a/services/runner/src/engines/sandbox_agent/daytona.ts b/services/runner/src/engines/sandbox_agent/daytona.ts index 287fd405cd..33bae19514 100644 --- a/services/runner/src/engines/sandbox_agent/daytona.ts +++ b/services/runner/src/engines/sandbox_agent/daytona.ts @@ -1,4 +1,3 @@ -import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { createAcpFetch } from "./acp-fetch.ts"; @@ -7,7 +6,7 @@ import { uploadSkillsToSandbox, uploadSystemPromptToSandbox, } from "./pi-assets.ts"; -import { shouldUploadOwnLogin, type RunPlan } from "./run-plan.ts"; +import { type RunPlan } from "./run-plan.ts"; type Log = (message: string) => void; @@ -140,45 +139,11 @@ export async function ensurePiInSandbox( } } -/** - * Upload the local Pi login into a Daytona sandbox so the remote Pi authenticates with - * the dev's ChatGPT/Codex OAuth. Best-effort: with no local login the remote run falls - * back to any provider key in the sandbox env. - */ -export async function uploadPiAuthToSandbox( - sandbox: any, - log: Log = () => {}, -): Promise { - const localDir = - process.env.PI_CODING_AGENT_DIR || - join(process.env.HOME ?? "", ".pi/agent"); - const authPath = join(localDir, "auth.json"); - if (!existsSync(authPath)) return; - try { - await sandbox.mkdirFs({ path: DAYTONA_PI_DIR }); - await sandbox.writeFsFile( - { path: `${DAYTONA_PI_DIR}/auth.json` }, - readFileSync(authPath, "utf-8"), - ); - const settingsPath = join(localDir, "settings.json"); - if (existsSync(settingsPath)) { - await sandbox.writeFsFile( - { path: `${DAYTONA_PI_DIR}/settings.json` }, - readFileSync(settingsPath, "utf-8"), - ); - } - } catch (err) { - log(`pi auth upload skipped: ${(err as Error).message}`); - } -} - export interface PrepareDaytonaPiAssetsInput { sandbox: any; plan: Pick< RunPlan, | "isPi" - | "hasApiKey" - | "credentialMode" | "skillDirs" | "hasSystemPrompt" | "systemPrompt" @@ -198,11 +163,10 @@ export async function prepareDaytonaPiAssets({ }: PrepareDaytonaPiAssetsInput): Promise { if (!plan.isPi) return; - // Upload Pi's fallback `auth.json` only when the harness owns its login (Security rule 6): - // runtime_provided, or an un-migrated caller with no api key. A resolved key (credentialMode - // "env") NEVER triggers the fallback. The decision lives in `shouldUploadOwnLogin` so the rule - // is in one place and testable. - if (shouldUploadOwnLogin(plan)) await uploadPiAuthToSandbox(sandbox, log); + // A Daytona run never receives the runner's own Pi login: subscription (runtime_provided) auth + // is rejected for Daytona in buildRunPlan, and a managed run authenticates from the vault keys + // in `daytonaEnvVars`. The runner therefore uploads only the inert Agenta extension, forced + // skills, and system prompts — never a personal `auth.json` (interface.md section 6). await uploadPiExtensionToSandbox(sandbox, DAYTONA_PI_DIR, log); if (plan.skillDirs.length > 0) { await uploadSkillsToSandbox(sandbox, DAYTONA_PI_DIR, plan.skillDirs, log); diff --git a/services/runner/src/engines/sandbox_agent/mount.ts b/services/runner/src/engines/sandbox_agent/mount.ts index 890b32deca..f2ecbb7cdc 100644 --- a/services/runner/src/engines/sandbox_agent/mount.ts +++ b/services/runner/src/engines/sandbox_agent/mount.ts @@ -312,10 +312,14 @@ 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. - const staleMountDetached = await unmountStorage(cwd, { ...deps.unmountDeps, log }); + const staleMountDetached = await unmountStorage(cwd, { + ...deps.unmountDeps, + log, + }); if (!staleMountDetached) { throw new Error( - "pre-mount detach could not be confirmed for " + cwd + + "pre-mount detach could not be confirmed for " + + cwd + "; refusing to start geesefs", ); } @@ -676,7 +680,9 @@ export async function mountStorageRemote( await unmountRemoteDeadMount(sandbox, cwd, log); return false; } - log(`remote mounted ${creds.bucket}:${creds.prefix} -> ${cwd} (verified alive)`); + log( + `remote mounted ${creds.bucket}:${creds.prefix} -> ${cwd} (verified alive)`, + ); return true; } catch (err) { log( @@ -732,7 +738,11 @@ export async function mountHarnessSessionDirs( dir.name, ); if (!creds) { - log(`harness session mount '${dir.name}' not signed — skipping ${dir.path}`); + // A resumable harness session expects its transcript dir mounted. Same structured degrade + // signal as the session/agent mounts (behavior unchanged; the run proceeds without it). + log( + `mount degraded kind=harness_transcript cause=sign_returned_no_mount session=${sessionId} dir=${dir.name}`, + ); continue; } await mountRemote(sandbox, dir.path, creds, { diff --git a/services/runner/src/engines/sandbox_agent/pi-assets.ts b/services/runner/src/engines/sandbox_agent/pi-assets.ts index 9711467e5f..855d41d947 100644 --- a/services/runner/src/engines/sandbox_agent/pi-assets.ts +++ b/services/runner/src/engines/sandbox_agent/pi-assets.ts @@ -444,11 +444,34 @@ export function prepareLocalAgentDir( return dir; } +/** + * Seed a throwaway local Claude config dir from a read-only source (`CLAUDE_CONFIG_DIR`) and + * return its path. A local `runtime_provided` Claude run authenticates from the operator's mounted + * subscription; copying it into a per-run dir keeps the harness's own writes (session state, + * refreshed tokens) off the read-only source mount (interface.md section 6). Returns `undefined` + * when the source is unset or absent, so the caller leaves the daemon env untouched. + */ +export function prepareLocalClaudeConfigDir( + sourceConfigDir: string | undefined, + log: Log = () => {}, +): string | undefined { + if (!sourceConfigDir || !existsSync(sourceConfigDir)) return undefined; + const dir = mkdtempSync(join(tmpdir(), "agenta-claude-config-")); + try { + cpSync(sourceConfigDir, dir, { recursive: true }); + } catch (err) { + log(`claude config copy skipped: ${(err as Error).message}`); + return undefined; + } + return dir; +} + export interface PrepareLocalPiAssetsInput { plan: Pick< RunPlan, | "isPi" | "isDaytona" + | "credentialMode" | "skillDirs" | "hasSystemPrompt" | "systemPrompt" @@ -461,8 +484,9 @@ export interface PrepareLocalPiAssetsInput { /** * Prepare local Pi's agent dir assets and return the throwaway per-run dir when one was - * created. Skills and system prompts always use an isolated dir; plain Pi runs only install - * the inert Agenta extension into the configured shared dir. + * created. Skills, system prompts, and subscription (`runtime_provided`) runs always use an + * isolated per-run copy so harness writes never touch the operator's source login; a plain + * managed/none Pi run only installs the inert Agenta extension into the configured shared dir. */ export function prepareLocalPiAssets({ plan, @@ -471,7 +495,12 @@ export function prepareLocalPiAssets({ }: PrepareLocalPiAssetsInput): string | undefined { if (!plan.isPi || plan.isDaytona) return undefined; - if (plan.skillDirs.length > 0 || plan.hasSystemPrompt) { + // A `runtime_provided` run reads the operator's mounted subscription (read-only). Copy it into + // an isolated per-run dir even with no skills/prompt, so the harness's own writes stay off the + // read-only source mount (interface.md section 6). buildRunPlan already rejected the case where + // the source mount is unconfigured, so `sourcePiAgentDir` here is the mount. + const isSubscriptionRun = plan.credentialMode === "runtime_provided"; + if (plan.skillDirs.length > 0 || plan.hasSystemPrompt || isSubscriptionRun) { const runAgentDir = prepareLocalAgentDir(plan.sourcePiAgentDir, log); if (plan.hasSystemPrompt) { writeSystemPromptLocal( diff --git a/services/runner/src/engines/sandbox_agent/run-plan.ts b/services/runner/src/engines/sandbox_agent/run-plan.ts index d6dae9ace7..64aaa55cb0 100644 --- a/services/runner/src/engines/sandbox_agent/run-plan.ts +++ b/services/runner/src/engines/sandbox_agent/run-plan.ts @@ -11,9 +11,7 @@ import { } from "../../protocol.ts"; import { executableToolSpecs } from "../../tools/public-spec.ts"; import { CODE_TOOL_UNSUPPORTED_MESSAGE } from "../../tools/code.ts"; -import { - PI_USER_MCP_UNSUPPORTED_MESSAGE, -} from "../../tools/mcp-bridge.ts"; +import { PI_USER_MCP_UNSUPPORTED_MESSAGE } from "../../tools/mcp-bridge.ts"; import { INTERNAL_TOOL_MCP_SERVER_NAME, RESERVED_MCP_SERVER_NAME_MESSAGE, @@ -72,6 +70,26 @@ export const REMOTE_TOOLS_UNSUPPORTED_MESSAGE = "sandbox, use the Pi harness, or remove the tools. Tracked in " + "docs/design/agent-workflows/projects/in-sandbox-tool-mcp/."; +/** + * `runtime_provided` (subscription) auth means the harness authenticates from explicitly prepared + * local runtime state (a mounted Pi/Claude login). That state lives only in the runner container + * and is never shipped to a third-party sandbox (interface.md sections 5-6), so the combination is + * unsupported on Daytona in version 1 rather than silently falling back to an unauthenticated run. + */ +export const DAYTONA_SUBSCRIPTION_UNSUPPORTED_MESSAGE = + "Daytona sandboxes do not support runtime-provided (subscription) authentication. " + + "Use a managed API key (credentialMode 'env'), or run this harness on the local sandbox."; + +/** + * A local `runtime_provided` run reads the operator's subscription state from a read-only mount + * named by the harness config var. With no mount configured there is nothing to authenticate + * with, so the run fails up front (interface.md section 6) instead of silently proceeding and + * having the harness discover the runner's own home directory. + */ +export const LOCAL_SUBSCRIPTION_MOUNT_MISSING_MESSAGE = + "runtime_provided local run requires a mounted subscription: set PI_CODING_AGENT_DIR " + + "(Pi) or CLAUDE_CONFIG_DIR (Claude) to a read-only mount of your harness login."; + export interface RunPlan { harness: string; acpAgent: string; @@ -83,18 +101,18 @@ export interface RunPlan { agentsMd?: string; secrets: Record; /** - * Back-compat inputs to the OAuth-upload decision (see `shouldUploadOwnLogin`). `legacyHarnessApiKeyVar` - * does not choose the provider; it only feeds the fallback `hasApiKey` heuristic for an un-migrated caller that sends no - * `credentialMode`. + * The provider api-key env var name the harness would read by default (`ANTHROPIC_API_KEY` for + * Claude, `OPENAI_API_KEY` otherwise). It does not choose the provider; it only names the key + * whose presence sets `hasApiKey`. */ legacyHarnessApiKeyVar: string; + /** Whether the resolved `secrets` already carry `legacyHarnessApiKeyVar`. */ hasApiKey: boolean; /** * How the credential is delivered: "env" (managed, resolved key) | "runtime_provided" (the * harness owns its login) | "none". From the resolved connection (provider-model-auth design, - * Concern 3). `undefined` when an un-migrated caller sends no credentialMode; the run then - * falls back to the `hasApiKey` heuristic. Drives clear-then-apply env (Security rule 5) and - * the OAuth-upload gate (rule 6). + * Concern 3). `undefined` when an un-migrated caller sends no credentialMode. Drives + * clear-then-apply env (Security rule 5). */ credentialMode?: string; cwd: string; @@ -300,6 +318,26 @@ export function buildRunPlan( // ships one, and "unknown" must not silently behave like "reachable loopback". const isRemoteSandbox = sandboxId !== "local"; + // Subscription (runtime_provided) auth is a LOCAL-only capability: the harness reads its login + // from a read-only mount that lives in the runner container and is never shipped to a + // third-party sandbox. Reject Daytona + runtime_provided here, before any sandbox is created, + // rather than silently falling back to an unauthenticated remote run (interface.md sections 5-6). + if (isDaytona && request.credentialMode === "runtime_provided") { + return { ok: false, error: DAYTONA_SUBSCRIPTION_UNSUPPORTED_MESSAGE }; + } + + // A local runtime_provided run authenticates from an explicitly mounted subscription. If the + // harness config var is unset there is no mount to read, so fail up front with an actionable + // message rather than letting the harness fall back to discovering the runner's own home dir + // (interface.md section 6). Managed ("env") / "none" runs are unaffected. + if (!isDaytona && request.credentialMode === "runtime_provided") { + const subscriptionEnvVar = + acpAgent === "claude" ? "CLAUDE_CONFIG_DIR" : "PI_CODING_AGENT_DIR"; + if (!process.env[subscriptionEnvVar]) { + return { ok: false, error: LOCAL_SUBSCRIPTION_MOUNT_MISSING_MESSAGE }; + } + } + const secrets = request.secrets ?? {}; const legacyHarnessApiKeyVar = acpAgent === "claude" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY"; @@ -499,23 +537,3 @@ export function buildRunPlan( }, }; } - -/** - * Whether to upload Pi's fallback `auth.json` (the harness's own OAuth login) into the run. - * - * The provider-model-auth design (Security rule 6) gates this on the harness owning its login, - * NOT on a provider guessed from the harness name: - * - `credentialMode === "env"` (a resolved key): NEVER upload the fallback (the resolved key is - * the credential). - * - `credentialMode === "runtime_provided"`: upload (the harness authenticates with its login). - * - `credentialMode === "none"`: do not upload (no credential asserted). - * - no `credentialMode` on the wire (un-migrated caller): fall back to today's heuristic — - * upload only when no api key was supplied (`!hasApiKey`). - */ -export function shouldUploadOwnLogin( - plan: Pick, -): boolean { - if (plan.credentialMode === "runtime_provided") return true; - if (plan.credentialMode) return false; // "env" / "none": a resolved decision, never upload - return !plan.hasApiKey; // back-compat: un-migrated caller, no credentialMode -} diff --git a/services/runner/src/tracing/otel.ts b/services/runner/src/tracing/otel.ts index 9184f64d26..94ff4d0744 100644 --- a/services/runner/src/tracing/otel.ts +++ b/services/runner/src/tracing/otel.ts @@ -28,7 +28,8 @@ * execute_tool (TOOL) — each tool the turn ran * * Config (read lazily from the environment for the fallback target): - * AGENTA_API_URL, AGENTA_API_KEY — fallback exporter endpoint + auth + * AGENTA_API_INTERNAL_URL, AGENTA_API_URL — fallback exporter endpoint + * AGENTA_CREDENTIALS — per-run caller credential (no static API key) * OTEL_SERVICE_NAME — resource service.name (default "pi-agent") */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; @@ -105,13 +106,15 @@ function defaultTarget(): ExportTarget { ( process.env.AGENTA_API_INTERNAL_URL ?? process.env.AGENTA_API_URL )?.replace(/\/+$/, "") || "https://cloud.agenta.ai/api"; - // Prefer the bare API key; else fall back to the full (scheme-tagged) ephemeral - // credential, used verbatim — `/check` hands back a `Secret ...`, not an API key. - const apiKey = process.env.AGENTA_API_KEY || ""; + // The per-run caller credential rides the request (each explicit trace target carries its own + // authorization; local Pi's OTLP bearer is written to a 0600 file). The runner holds no static + // platform key: it must not carry an `AGENTA_API_KEY` a local harness could read from /proc and + // reuse (interface.md section 2). The scheme-tagged ephemeral `AGENTA_CREDENTIALS` (a + // `Secret ...` from `/check`, used verbatim) is the only fallback; absent it, export unauthed. const credentials = process.env.AGENTA_CREDENTIALS || ""; return { endpoint: `${base}/otlp/v1/traces`, - authorization: apiKey ? `ApiKey ${apiKey}` : credentials || undefined, + authorization: credentials || undefined, }; } diff --git a/services/runner/tests/unit/sandbox-agent-daytona.test.ts b/services/runner/tests/unit/sandbox-agent-daytona.test.ts index a40b6a1493..d40dbae805 100644 --- a/services/runner/tests/unit/sandbox-agent-daytona.test.ts +++ b/services/runner/tests/unit/sandbox-agent-daytona.test.ts @@ -5,9 +5,7 @@ */ import { afterEach, describe, it } from "vitest"; import assert from "node:assert/strict"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { rmSync } from "node:fs"; import { DAYTONA_PI_COMMAND, @@ -17,7 +15,6 @@ import { createCookieFetch, daytonaEnvVars, ensurePiInSandbox, - uploadPiAuthToSandbox, } from "../../src/engines/sandbox_agent/daytona.ts"; const envKeys = ["PI_CODING_AGENT_DIR"]; @@ -32,7 +29,8 @@ afterEach(() => { if (value === undefined) delete process.env[key]; else process.env[key] = value; } - for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + for (const dir of dirs.splice(0)) + rmSync(dir, { recursive: true, force: true }); globalThis.fetch = originalFetch; }); @@ -149,35 +147,14 @@ describe("ensurePiInSandbox (probe and pinned-install repair)", () => { }); }); -describe("uploadPiAuthToSandbox", () => { - it("uploads local Pi auth and settings when present", async () => { - const agentDir = mkdtempSync(join(tmpdir(), "agenta-pi-auth-test-")); - dirs.push(agentDir); - process.env.PI_CODING_AGENT_DIR = agentDir; - writeFileSync(join(agentDir, "auth.json"), "{\"token\":\"x\"}", "utf-8"); - writeFileSync(join(agentDir, "settings.json"), "{\"approval\":\"never\"}", "utf-8"); - const calls: Array<{ path: string; body?: string }> = []; - const sandbox = { - mkdirFs: async ({ path }: { path: string }) => calls.push({ path }), - writeFsFile: async ({ path }: { path: string }, body: string) => calls.push({ path, body }), - }; - - await uploadPiAuthToSandbox(sandbox); - - assert.deepEqual(calls, [ - { path: DAYTONA_PI_DIR }, - { path: `${DAYTONA_PI_DIR}/auth.json`, body: "{\"token\":\"x\"}" }, - { path: `${DAYTONA_PI_DIR}/settings.json`, body: "{\"approval\":\"never\"}" }, - ]); - }); -}); - describe("createCookieFetch", () => { it("persists Daytona preview cookies per host", async () => { const seenCookies: Array = []; const innerFetch = (async (_input: any, init?: any) => { seenCookies.push(new Headers(init?.headers).get("cookie")); - return new Response("ok", { headers: { "set-cookie": "session=abc; Path=/" } }); + return new Response("ok", { + headers: { "set-cookie": "session=abc; Path=/" }, + }); }) as typeof fetch; const cookieFetch = createCookieFetch(innerFetch); diff --git a/services/runner/tests/unit/sandbox-agent-orchestration.test.ts b/services/runner/tests/unit/sandbox-agent-orchestration.test.ts index ebd0e583a2..ab46800e8c 100644 --- a/services/runner/tests/unit/sandbox-agent-orchestration.test.ts +++ b/services/runner/tests/unit/sandbox-agent-orchestration.test.ts @@ -536,8 +536,7 @@ describe("runSandboxAgent orchestration", () => { assert.equal(result.ok, true); assert.equal( - (calls.providerArgs[1] as Record) - .AGENTA_AGENT_MOUNT_DIR, + (calls.providerArgs[1] as Record).AGENTA_AGENT_MOUNT_DIR, `${cwd}-agent`, ); assert.match( @@ -580,8 +579,7 @@ describe("runSandboxAgent orchestration", () => { assert.equal(result.ok, true); assert.equal( - (calls.providerArgs[1] as Record) - .AGENTA_AGENT_MOUNT_DIR, + (calls.providerArgs[1] as Record).AGENTA_AGENT_MOUNT_DIR, undefined, ); assert.equal(calls.workspacePlan.appendSystemPrompt, undefined); @@ -1519,16 +1517,27 @@ describe("runSandboxAgent orchestration", () => { it("does not clear provider env or set a base url on a runtime_provided run", async () => { const { calls, deps } = fakeHarness(); - const result = await runSandboxAgent( - { - harness: "claude", - messages: [{ role: "user", content: "hello" }], - credentialMode: "runtime_provided", - } as AgentRunRequest, - undefined, - undefined, - deps, - ); + // A local runtime_provided run authenticates from a mounted subscription, so buildRunPlan + // requires the harness config var (here CLAUDE_CONFIG_DIR) to name a mount. + const previousClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = "/agenta/harness/claude"; + let result; + try { + result = await runSandboxAgent( + { + harness: "claude", + messages: [{ role: "user", content: "hello" }], + credentialMode: "runtime_provided", + } as AgentRunRequest, + undefined, + undefined, + deps, + ); + } finally { + if (previousClaudeConfigDir === undefined) + delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = previousClaudeConfigDir; + } assert.equal(result.ok, true); // runtime_provided -> keep the harness's own inherited env (do not clear). diff --git a/services/runner/tests/unit/sandbox-agent-run-plan.test.ts b/services/runner/tests/unit/sandbox-agent-run-plan.test.ts index a8aa67263d..b4a59a3ad9 100644 --- a/services/runner/tests/unit/sandbox-agent-run-plan.test.ts +++ b/services/runner/tests/unit/sandbox-agent-run-plan.test.ts @@ -9,7 +9,8 @@ import assert from "node:assert/strict"; import type { AgentRunRequest } from "../../src/protocol.ts"; import { buildRunPlan, - shouldUploadOwnLogin, + DAYTONA_SUBSCRIPTION_UNSUPPORTED_MESSAGE, + LOCAL_SUBSCRIPTION_MOUNT_MISSING_MESSAGE, } from "../../src/engines/sandbox_agent/run-plan.ts"; import { RESERVED_MCP_SERVER_NAME_MESSAGE } from "../../src/engines/sandbox_agent/mcp.ts"; import { resetRunnerConfigCache } from "../../src/config/runner-config.ts"; @@ -602,7 +603,10 @@ describe("buildRunPlan", () => { assert.equal(result.ok, false); if (result.ok) return; - assert.match(result.error, /non-Pi harness on this remote sandbox provider/); + assert.match( + result.error, + /non-Pi harness on this remote sandbox provider/, + ); assert.match(result.error, /proven for Daytona only/); assert.match( result.error, @@ -953,8 +957,7 @@ describe("buildRunPlan", () => { assert.equal(result.plan.usageOutPath, undefined); assert.equal(result.plan.legacyHarnessApiKeyVar, "ANTHROPIC_API_KEY"); assert.equal(result.plan.hasApiKey, true); - // The resolved credentialMode is carried onto the plan (drives clear-then-apply + the - // OAuth-upload gate). + // The resolved credentialMode is carried onto the plan (drives clear-then-apply). assert.equal(result.plan.credentialMode, "env"); assert.equal(result.plan.systemPrompt, undefined); assert.equal(result.plan.hasSystemPrompt, false); @@ -1072,53 +1075,102 @@ describe("buildRunPlan durableCwd (prefix-derived cwd)", () => { }); }); -describe("shouldUploadOwnLogin", () => { - it("never uploads when the connection resolved a real key (credentialMode 'env')", () => { - // A resolved key is the credential (Security rule 6); the fallback auth.json must not load, - // even if hasApiKey somehow disagrees. - assert.equal( - shouldUploadOwnLogin({ credentialMode: "env", hasApiKey: true }), - false, - ); - assert.equal( - shouldUploadOwnLogin({ credentialMode: "env", hasApiKey: false }), - false, +describe("buildRunPlan runtime_provided (subscription) gates", () => { + const withEnv = ( + vars: Record, + fn: () => void, + ): void => { + const saved: Record = {}; + for (const [key, value] of Object.entries(vars)) { + saved[key] = process.env[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + try { + fn(); + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + }; + + it("rejects a Daytona runtime_provided run before any sandbox side effect", () => { + let created = false; + const result = buildRunPlan( + { + harness: "pi_core", + sandbox: "daytona", + messages: [{ role: "user", content: "hello" }], + credentialMode: "runtime_provided", + }, + { + createDaytonaCwd: () => { + created = true; + return "/home/sandbox/should-not-happen"; + }, + }, ); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.error, DAYTONA_SUBSCRIPTION_UNSUPPORTED_MESSAGE); + assert.equal(created, false); }); - it("uploads for runtime_provided (the harness authenticates with its own login)", () => { - assert.equal( - shouldUploadOwnLogin({ + it("rejects a local Pi runtime_provided run when PI_CODING_AGENT_DIR is unset", () => { + withEnv({ PI_CODING_AGENT_DIR: undefined }, () => { + const result = buildRunPlan({ + harness: "pi_core", + sandbox: "local", + messages: [{ role: "user", content: "hello" }], credentialMode: "runtime_provided", - hasApiKey: false, - }), - true, - ); - assert.equal( - shouldUploadOwnLogin({ + }); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.error, LOCAL_SUBSCRIPTION_MOUNT_MISSING_MESSAGE); + }); + }); + + it("rejects a local Claude runtime_provided run when CLAUDE_CONFIG_DIR is unset", () => { + withEnv({ CLAUDE_CONFIG_DIR: undefined }, () => { + const result = buildRunPlan({ + harness: "claude", + sandbox: "local", + messages: [{ role: "user", content: "hello" }], credentialMode: "runtime_provided", - hasApiKey: true, - }), - true, - ); + }); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.error, LOCAL_SUBSCRIPTION_MOUNT_MISSING_MESSAGE); + }); }); - it("never uploads for credentialMode 'none' (no credential asserted)", () => { - assert.equal( - shouldUploadOwnLogin({ credentialMode: "none", hasApiKey: false }), - false, - ); + it("accepts a local Pi runtime_provided run when PI_CODING_AGENT_DIR names a mount", () => { + withEnv({ PI_CODING_AGENT_DIR: "/agenta/harness/pi" }, () => { + const result = buildRunPlan({ + harness: "pi_core", + sandbox: "local", + messages: [{ role: "user", content: "hello" }], + credentialMode: "runtime_provided", + }); + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(result.plan.credentialMode, "runtime_provided"); + assert.equal(result.plan.sourcePiAgentDir, "/agenta/harness/pi"); + }); }); - it("falls back to the hasApiKey heuristic for an un-migrated caller (no credentialMode)", () => { - // No credentialMode on the wire: upload only when no api key was supplied (today's behavior). - assert.equal( - shouldUploadOwnLogin({ credentialMode: undefined, hasApiKey: false }), - true, - ); - assert.equal( - shouldUploadOwnLogin({ credentialMode: undefined, hasApiKey: true }), - false, - ); + it("does not gate a managed (env) local run on a subscription mount", () => { + withEnv({ PI_CODING_AGENT_DIR: undefined }, () => { + const result = buildRunPlan({ + harness: "pi_core", + sandbox: "local", + messages: [{ role: "user", content: "hello" }], + secrets: { OPENAI_API_KEY: "sk-test" }, + credentialMode: "env", + }); + assert.equal(result.ok, true); + }); }); }); From 07cffc7199492edb84e101c53352d1871e446128 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Tue, 14 Jul 2026 12:48:33 +0200 Subject: [PATCH 2/2] fix(runner): run local subscriptions out of the read-write mount, not a per-run copy A local runtime_provided run authenticated from the operator's mounted harness login, but the runner copied that login into a throwaway per-run directory and pointed the harness at the copy. Subscription logins are OAuth logins: the harness refreshes its access token mid-run and writes the new one back to its config dir. The copy discarded that refresh, so as soon as the provider rotated the refresh token the next run failed and the operator had to log in by hand. Mount the login read-write and let the harness run directly out of it, exactly as it does on a normal local install, so a refreshed token persists to the source. Drops prepareLocalClaudeConfigDir and the Pi credential copy; the dev Compose stacks stop cp -a'ing the login into a container-local path. The subscription path deliberately reports NO throwaway dir, so teardown's rmSync can never delete the operator's real login. Pinned by a test. Tradeoff, now documented: concurrent local subscription runs share the harness config dir, the same way two local harness sessions do. This path is single-trusted-operator only. The missing-mount guard is unchanged: a local runtime_provided run with no configured PI_CODING_AGENT_DIR / CLAUDE_CONFIG_DIR still fails up front. --- .../documentation/running-the-agent.md | 12 +-- .../docker-compose/ee/docker-compose.dev.yml | 14 ++-- .../docker-compose/ee/docker-compose.gh.yml | 14 ++-- .../docker-compose/oss/docker-compose.dev.yml | 10 ++- .../docker-compose/oss/docker-compose.gh.yml | 14 ++-- services/runner/src/engines/sandbox_agent.ts | 27 +++--- .../src/engines/sandbox_agent/pi-assets.ts | 68 +++++++-------- .../src/engines/sandbox_agent/run-plan.ts | 8 +- .../unit/sandbox-agent-pi-assets.test.ts | 82 +++++++++++++++++++ 9 files changed, 170 insertions(+), 79 deletions(-) diff --git a/docs/design/agent-workflows/documentation/running-the-agent.md b/docs/design/agent-workflows/documentation/running-the-agent.md index c2a085f662..a7ab69d8a1 100644 --- a/docs/design/agent-workflows/documentation/running-the-agent.md +++ b/docs/design/agent-workflows/documentation/running-the-agent.md @@ -131,15 +131,17 @@ instead of building from source. The dev compose overrides the image CMD with a shell command (around line 600): ```sh -mkdir -p /pi-agent && cp -a /pi-agent-ro/. /pi-agent/ 2>/dev/null || true; node scripts/build-extension.mjs && exec node_modules/.bin/tsx src/server.ts ``` -It does three things. It copies the read-only mounted Pi login into a writable path so OAuth -refresh stays in the container. It rebuilds the Pi extension from the mounted `src`, because -`dist/` is not bind-mounted and a restart would otherwise keep a stale bundle and silently -drop custom tools. It then starts the server with `tsx`. +It does two things. It rebuilds the Pi extension from the mounted `src`, because `dist/` is not +bind-mounted and a restart would otherwise keep a stale bundle and silently drop custom tools. It +then starts the server with `tsx`. + +The Pi login itself is bind-mounted read-write at `/pi-agent` and the harness runs directly out of +it, so an OAuth token the harness refreshes mid-run persists back to the host login instead of +dying with the container. ## Ports diff --git a/hosting/docker-compose/ee/docker-compose.dev.yml b/hosting/docker-compose/ee/docker-compose.dev.yml index a0010c6cb1..726792c5fa 100644 --- a/hosting/docker-compose/ee/docker-compose.dev.yml +++ b/hosting/docker-compose/ee/docker-compose.dev.yml @@ -377,16 +377,16 @@ services: # === IMAGE ================================================ # image: agenta-ee-dev-runner:latest # === EXECUTION ============================================ # - # No file watcher (the box's inotify limit is shared across stacks). Copy the - # read-only mounted Pi login into a writable path so OAuth refresh stays - # in-container. This command replaces the image CMD, so the Pi extension rebuild + # No file watcher (the box's inotify limit is shared across stacks). The Pi login is + # bind-mounted read-write at /pi-agent and the harness runs directly out of it, so a + # refreshed OAuth token persists to the host login instead of dying with the container. + # This command replaces the image CMD, so the Pi extension rebuild # has to live here too: dist/ is not bind-mounted and src/extensions/agenta.ts is, # so without this a restart keeps a stale bundle and custom tools silently stop # being delivered on the runner path. Rebuild from the mounted # src on start; fail loud if it cannot build rather than run a stale bundle. command: > - sh -c "mkdir -p /pi-agent && cp -a /pi-agent-ro/. /pi-agent/ 2>/dev/null || true; - node scripts/build-extension.mjs && + sh -c "node scripts/build-extension.mjs && exec node_modules/.bin/tsx src/server.ts" # === CONFIGURATION ======================================== # # Deliberately no env_file: the harness sandbox must not inherit the stack's @@ -425,7 +425,9 @@ services: # sandbox per run (resolved from /app/skills). Bind-mounted like src so edits are # live; the prod image bakes them with `COPY skills ./skills`. - ../../../services/runner/skills:/app/skills - - ${HOME}/.pi/agent:/pi-agent-ro:ro + # Read-write on purpose: the harness refreshes its own OAuth token here and the new + # token must persist back to the host login (a copy would discard it). + - ${HOME}/.pi/agent:/pi-agent:rw # === NETWORK ============================================== # networks: - agenta-network diff --git a/hosting/docker-compose/ee/docker-compose.gh.yml b/hosting/docker-compose/ee/docker-compose.gh.yml index e1e10e2c71..ee380ff3ef 100644 --- a/hosting/docker-compose/ee/docker-compose.gh.yml +++ b/hosting/docker-compose/ee/docker-compose.gh.yml @@ -285,13 +285,15 @@ services: AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} # === SUBSCRIPTION MOUNTS (opt-in) ========================= # # Use your own harness subscription for LOCAL runs instead of a managed API - # key. Mount the login read-only; the runner copies it into a per-run dir, so - # the harness's own writes never touch your source. Single-operator convenience - # only — this state is never shipped to a Daytona sandbox. A runtime_provided - # local run without the matching mount fails with a clear error. + # key. Mount the login READ-WRITE: the harness runs directly out of the mount and + # refreshes its own OAuth token there, so a rotated token persists and you never + # have to log in again by hand. Single-operator convenience only — this state is + # never shipped to a Daytona sandbox, and concurrent local subscription runs share + # the dir (same as two local harness sessions). A runtime_provided local run + # without the matching mount fails with a clear error. # volumes: - # - ~/.pi/agent:/agenta/harness/pi:ro # Pi / Codex login - # - ~/.claude:/agenta/harness/claude:ro # Claude login + # - ~/.pi/agent:/agenta/harness/pi:rw # Pi / Codex login + # - ~/.claude:/agenta/harness/claude:rw # Claude login # Then point the harness config var at the mount (override the defaults above): # PI_CODING_AGENT_DIR: /agenta/harness/pi # CLAUDE_CONFIG_DIR: /agenta/harness/claude diff --git a/hosting/docker-compose/oss/docker-compose.dev.yml b/hosting/docker-compose/oss/docker-compose.dev.yml index bfa6934620..5c7b0a3970 100644 --- a/hosting/docker-compose/oss/docker-compose.dev.yml +++ b/hosting/docker-compose/oss/docker-compose.dev.yml @@ -370,9 +370,11 @@ services: # === IMAGE ================================================ # image: agenta-oss-dev-runner:latest # === EXECUTION ============================================ # + # The Pi login is bind-mounted read-write at /pi-agent and the harness runs directly out + # of it, so a refreshed OAuth token persists to the host login instead of dying with the + # container. command: > - sh -c "mkdir -p /pi-agent && cp -a /pi-agent-ro/. /pi-agent/ 2>/dev/null || true; - node scripts/build-extension.mjs && + sh -c "node scripts/build-extension.mjs && exec node_modules/.bin/tsx watch src/server.ts" # === CONFIGURATION ======================================== # # Deliberately no env_file: harness processes must not inherit the full stack @@ -406,7 +408,9 @@ services: volumes: - ../../../services/runner/src:/app/src - ../../../services/runner/skills:/app/skills - - ${HOME}/.pi/agent:/pi-agent-ro:ro + # Read-write on purpose: the harness refreshes its own OAuth token here and the new + # token must persist back to the host login (a copy would discard it). + - ${HOME}/.pi/agent:/pi-agent:rw # === NETWORK ============================================== # networks: - agenta-network diff --git a/hosting/docker-compose/oss/docker-compose.gh.yml b/hosting/docker-compose/oss/docker-compose.gh.yml index c58f98b57f..c72bfd56b0 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.yml @@ -302,13 +302,15 @@ services: AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} # === SUBSCRIPTION MOUNTS (opt-in) ========================= # # Use your own harness subscription for LOCAL runs instead of a managed API - # key. Mount the login read-only; the runner copies it into a per-run dir, so - # the harness's own writes never touch your source. Single-operator convenience - # only — this state is never shipped to a Daytona sandbox. A runtime_provided - # local run without the matching mount fails with a clear error. + # key. Mount the login READ-WRITE: the harness runs directly out of the mount and + # refreshes its own OAuth token there, so a rotated token persists and you never + # have to log in again by hand. Single-operator convenience only — this state is + # never shipped to a Daytona sandbox, and concurrent local subscription runs share + # the dir (same as two local harness sessions). A runtime_provided local run + # without the matching mount fails with a clear error. # volumes: - # - ~/.pi/agent:/agenta/harness/pi:ro # Pi / Codex login - # - ~/.claude:/agenta/harness/claude:ro # Claude login + # - ~/.pi/agent:/agenta/harness/pi:rw # Pi / Codex login + # - ~/.claude:/agenta/harness/claude:rw # Claude login # Then point the harness config var at the mount (override the defaults above): # PI_CODING_AGENT_DIR: /agenta/harness/pi # CLAUDE_CONFIG_DIR: /agenta/harness/claude diff --git a/services/runner/src/engines/sandbox_agent.ts b/services/runner/src/engines/sandbox_agent.ts index 8fe4f2f4eb..7a69f3cabf 100644 --- a/services/runner/src/engines/sandbox_agent.ts +++ b/services/runner/src/engines/sandbox_agent.ts @@ -90,7 +90,6 @@ import { buildPiExtensionEnv, configurePiSessionWorkspace, configurePiSkillSnapshot, - prepareLocalClaudeConfigDir, prepareLocalPiAssets, resolvePiSkillSnapshot, uploadSystemPromptToSandbox, @@ -809,21 +808,13 @@ export async function acquireEnvironment( const binaryPath = (deps.resolveDaemonBinary ?? resolveDaemonBinary)(); let runAgentDir = prepareLocalPiAssets({ plan, env, log: logger }); - // Local Claude subscription run: copy the mounted CLAUDE_CONFIG_DIR into a per-run dir and point - // the daemon at the copy, so the harness's own writes stay off the read-only source mount - // (interface.md section 6). buildRunPlan already rejected a runtime_provided Claude run with no - // configured CLAUDE_CONFIG_DIR, so the source here is the mount. - if ( - !plan.isDaytona && - plan.acpAgent === "claude" && - plan.credentialMode === "runtime_provided" - ) { - const runClaudeConfigDir = prepareLocalClaudeConfigDir( - process.env.CLAUDE_CONFIG_DIR, - logger, - ); - if (runClaudeConfigDir) env.CLAUDE_CONFIG_DIR = runClaudeConfigDir; - } + // A local Claude subscription run reads and writes the operator's read-write mounted login + // DIRECTLY: `buildDaemonEnv` already carried `CLAUDE_CONFIG_DIR` (the mount) into the daemon env, + // and there is deliberately no per-run copy. Claude refreshes its OAuth token mid-run and writes + // it back to its config dir; copying that dir per run would discard the refresh, so the next run + // would fail as soon as the provider rotated the refresh token. The harness owns its own token + // lifecycle, exactly like a normal local install (interface.md section 6). buildRunPlan already + // rejected a runtime_provided Claude run with no configured CLAUDE_CONFIG_DIR. logger(`harness=${plan.harness} sandbox=${plan.sandboxId} cwd=${plan.cwd}`); @@ -970,7 +961,9 @@ export async function acquireEnvironment( } else { await environment.workspace?.cleanup().catch(() => {}); } - // The per-run Agenta agent dir (skills isolation) is throwaway; remove it too. + // The per-run Agenta agent dir (skills isolation) is throwaway; remove it too. This is only + // ever a temp dir: a subscription run leaves `runAgentDir` undefined precisely so that the + // operator's mounted login (which the harness runs out of directly) is never deleted here. if (environment.runAgentDir) rmSync(environment.runAgentDir, { recursive: true, force: true }); // Backstop: the extension deletes this on read; remove it here too in case the harness never diff --git a/services/runner/src/engines/sandbox_agent/pi-assets.ts b/services/runner/src/engines/sandbox_agent/pi-assets.ts index 855d41d947..11dbb7297b 100644 --- a/services/runner/src/engines/sandbox_agent/pi-assets.ts +++ b/services/runner/src/engines/sandbox_agent/pi-assets.ts @@ -444,28 +444,6 @@ export function prepareLocalAgentDir( return dir; } -/** - * Seed a throwaway local Claude config dir from a read-only source (`CLAUDE_CONFIG_DIR`) and - * return its path. A local `runtime_provided` Claude run authenticates from the operator's mounted - * subscription; copying it into a per-run dir keeps the harness's own writes (session state, - * refreshed tokens) off the read-only source mount (interface.md section 6). Returns `undefined` - * when the source is unset or absent, so the caller leaves the daemon env untouched. - */ -export function prepareLocalClaudeConfigDir( - sourceConfigDir: string | undefined, - log: Log = () => {}, -): string | undefined { - if (!sourceConfigDir || !existsSync(sourceConfigDir)) return undefined; - const dir = mkdtempSync(join(tmpdir(), "agenta-claude-config-")); - try { - cpSync(sourceConfigDir, dir, { recursive: true }); - } catch (err) { - log(`claude config copy skipped: ${(err as Error).message}`); - return undefined; - } - return dir; -} - export interface PrepareLocalPiAssetsInput { plan: Pick< RunPlan, @@ -483,10 +461,23 @@ export interface PrepareLocalPiAssetsInput { } /** - * Prepare local Pi's agent dir assets and return the throwaway per-run dir when one was - * created. Skills, system prompts, and subscription (`runtime_provided`) runs always use an - * isolated per-run copy so harness writes never touch the operator's source login; a plain - * managed/none Pi run only installs the inert Agenta extension into the configured shared dir. + * Prepare local Pi's agent dir assets and return the THROWAWAY per-run dir when one was created — + * `undefined` means "nothing here for the caller to delete" (the caller `rmSync`s whatever it gets + * back at teardown, so the operator's own login must never be returned). + * + * Two shapes: + * + * - Subscription (`runtime_provided`): the harness runs directly out of the operator's read-write + * mounted login, exactly like a normal local Pi install. Pi refreshes its OAuth token mid-run and + * writes the new one back into its agent dir; a per-run copy would throw that refresh away, so + * once the provider rotated the refresh token the next run would fail and the operator would have + * to log in by hand. Returns `undefined` so teardown cannot delete the mount. + * - Managed / none: no credential to preserve, so skills and system prompts still get an isolated + * per-run copy (returned, and deleted at teardown). A plain run with neither only installs the + * inert Agenta extension into the configured shared dir. + * + * Tradeoff (interface.md section 6): concurrent local subscription runs share the one agent dir, + * the same way two local `pi` sessions do. This path is single-trusted-operator only. */ export function prepareLocalPiAssets({ plan, @@ -495,12 +486,25 @@ export function prepareLocalPiAssets({ }: PrepareLocalPiAssetsInput): string | undefined { if (!plan.isPi || plan.isDaytona) return undefined; - // A `runtime_provided` run reads the operator's mounted subscription (read-only). Copy it into - // an isolated per-run dir even with no skills/prompt, so the harness's own writes stay off the - // read-only source mount (interface.md section 6). buildRunPlan already rejected the case where - // the source mount is unconfigured, so `sourcePiAgentDir` here is the mount. - const isSubscriptionRun = plan.credentialMode === "runtime_provided"; - if (plan.skillDirs.length > 0 || plan.hasSystemPrompt || isSubscriptionRun) { + // buildRunPlan already rejected a local runtime_provided run with no configured + // PI_CODING_AGENT_DIR, so `sourcePiAgentDir` here IS the operator's mount. + if (plan.credentialMode === "runtime_provided") { + const agentDir = plan.sourcePiAgentDir; + installPiExtensionLocal(agentDir, log); + if (plan.hasSystemPrompt) { + writeSystemPromptLocal( + agentDir, + plan.systemPrompt, + plan.appendSystemPrompt, + log, + ); + } + env.PI_CODING_AGENT_DIR = agentDir; + // Deliberately NOT returned as a throwaway: this is the operator's login, not a temp dir. + return undefined; + } + + if (plan.skillDirs.length > 0 || plan.hasSystemPrompt) { const runAgentDir = prepareLocalAgentDir(plan.sourcePiAgentDir, log); if (plan.hasSystemPrompt) { writeSystemPromptLocal( diff --git a/services/runner/src/engines/sandbox_agent/run-plan.ts b/services/runner/src/engines/sandbox_agent/run-plan.ts index 64aaa55cb0..488240a9e5 100644 --- a/services/runner/src/engines/sandbox_agent/run-plan.ts +++ b/services/runner/src/engines/sandbox_agent/run-plan.ts @@ -81,14 +81,14 @@ export const DAYTONA_SUBSCRIPTION_UNSUPPORTED_MESSAGE = "Use a managed API key (credentialMode 'env'), or run this harness on the local sandbox."; /** - * A local `runtime_provided` run reads the operator's subscription state from a read-only mount + * A local `runtime_provided` run reads the operator's subscription state from a read-write mount * named by the harness config var. With no mount configured there is nothing to authenticate * with, so the run fails up front (interface.md section 6) instead of silently proceeding and * having the harness discover the runner's own home directory. */ export const LOCAL_SUBSCRIPTION_MOUNT_MISSING_MESSAGE = "runtime_provided local run requires a mounted subscription: set PI_CODING_AGENT_DIR " + - "(Pi) or CLAUDE_CONFIG_DIR (Claude) to a read-only mount of your harness login."; + "(Pi) or CLAUDE_CONFIG_DIR (Claude) to a read-write mount of your harness login."; export interface RunPlan { harness: string; @@ -318,8 +318,8 @@ export function buildRunPlan( // ships one, and "unknown" must not silently behave like "reachable loopback". const isRemoteSandbox = sandboxId !== "local"; - // Subscription (runtime_provided) auth is a LOCAL-only capability: the harness reads its login - // from a read-only mount that lives in the runner container and is never shipped to a + // Subscription (runtime_provided) auth is a LOCAL-only capability: the harness reads and refreshes + // its login on a read-write mount that lives in the runner container and is never shipped to a // third-party sandbox. Reject Daytona + runtime_provided here, before any sandbox is created, // rather than silently falling back to an unauthenticated remote run (interface.md sections 5-6). if (isDaytona && request.credentialMode === "runtime_provided") { diff --git a/services/runner/tests/unit/sandbox-agent-pi-assets.test.ts b/services/runner/tests/unit/sandbox-agent-pi-assets.test.ts index 5a083681d1..952c15406c 100644 --- a/services/runner/tests/unit/sandbox-agent-pi-assets.test.ts +++ b/services/runner/tests/unit/sandbox-agent-pi-assets.test.ts @@ -511,6 +511,88 @@ describe("Pi skill snapshots", () => { }); }); +/** + * A local subscription (`runtime_provided`) run authenticates from the operator's READ-WRITE + * mounted login, and the harness runs directly out of that mount: Pi refreshes its OAuth token + * mid-run and writes the new one back, so a per-run copy would discard the refresh and the next + * run would fail once the provider rotated the refresh token. + */ +describe("prepareLocalPiAssets (runtime_provided runs out of the mount, read-write)", () => { + const subscriptionPlan = ( + mount: string, + over: Record = {}, + ) => ({ + isPi: true, + isDaytona: false, + credentialMode: "runtime_provided", + skillDirs: [], + hasSystemPrompt: false, + systemPrompt: undefined, + appendSystemPrompt: undefined, + sourcePiAgentDir: mount, + ...over, + }); + + it("points PI_CODING_AGENT_DIR at the mount itself, not at a per-run copy", () => { + const mount = tempDir("agenta-pi-subscription-mount-"); + writeFileSync(join(mount, "auth.json"), '{"token":"live"}', "utf-8"); + const env: Record = {}; + + prepareLocalPiAssets({ plan: subscriptionPlan(mount) as never, env }); + + assert.equal( + env.PI_CODING_AGENT_DIR, + mount, + "a subscription run must run out of the operator's mount so a refreshed token persists", + ); + }); + + /** + * The caller `rmSync`s whatever this returns at teardown. Returning the mount would delete the + * operator's actual login, so the contract is: a subscription run reports NO throwaway dir. + */ + it("returns undefined so teardown can never delete the operator's login", () => { + const mount = tempDir("agenta-pi-subscription-mount-"); + writeFileSync(join(mount, "auth.json"), '{"token":"live"}', "utf-8"); + + const runDir = prepareLocalPiAssets({ + plan: subscriptionPlan(mount, { + skillDirs: [], + hasSystemPrompt: true, + appendSystemPrompt: "extra", + }) as never, + env: {}, + }); + + assert.equal(runDir, undefined); + // The login itself survives: nothing moved it, and the harness still has its token to refresh. + assert.ok(existsSync(join(mount, "auth.json"))); + }); + + it("still isolates a MANAGED run's skills in a throwaway copy (no credential at stake)", () => { + const source = tempDir("agenta-pi-managed-source-"); + writeFileSync(join(source, "auth.json"), '{"token":"managed"}', "utf-8"); + const env: Record = {}; + + const runDir = prepareLocalPiAssets({ + plan: subscriptionPlan(source, { + credentialMode: "env", + hasSystemPrompt: true, + appendSystemPrompt: "extra", + }) as never, + env, + }); + + assert.ok( + runDir, + "a managed run with a system prompt still gets a per-run dir", + ); + assert.notEqual(runDir, source); + assert.equal(env.PI_CODING_AGENT_DIR, runDir); + dirs.push(runDir as string); + }); +}); + describe("sandbox uploads", () => { it("recursively uploads files into sandbox fs", async () => { const root = tempDir("agenta-pi-upload-test-");