diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index 37e70282..0658f7dc 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -85,6 +85,20 @@ const AGENTRUN_PROVIDER_TRACE_WIRE_API = "agentrun-v01-command-result"; const THREAD_CONTINUITY_POLICY = "hwlab-agentrun-v01-reuse-runner-thread"; const SESSION_POLICY_RUN_LOCAL = "hwlab-agentrun-v01-session-runner-reuse"; const TERMINAL_RUN_STATUSES = new Set(["completed", "failed", "blocked", "cancelled", "canceled"]); +const AGENTRUN_DURABLE_TRANSIENT_ENV_RESERVED = new Set([ + "AGENTRUN_API_KEY", + "AUTH_PASSWORD", + "CODEX_API_KEY", + "CODEX_HOME", + "GH_TOKEN", + "GITHUB_TOKEN", + "HOME", + "OPENAI_API_KEY", + "PROVIDER_TOKEN", + "UNIDESK_AUTH_PASSWORD", + "UNIDESK_PROVIDER_TOKEN", + "UNIDESK_SSH_CLIENT_TOKEN" +]); const HWLAB_RESOURCE_GIT_BUNDLES = Object.freeze([ Object.freeze({ name: "hwlab-tools", subpath: "tools", target_path: "tools" }), Object.freeze({ name: "hwlab-agent-skills", subpath: "skills", target_path: ".agents/skills" }) @@ -1599,22 +1613,47 @@ function buildAgentRunDurableDispatchInput({ env, traceId, backendProfile }) { const runtimeAuthority = requireAgentRunRuntimeAuthority(env); const baseTransient = buildAgentRunTransientEnv(env, { providerProfile: backendProfile, parentTraceId: traceId }); assertAgentRunTransientRuntimeAuthority(baseTransient, runtimeAuthority); - if (baseTransient.some((entry) => entry?.sensitive === true)) { - throw adapterError("agentrun_durable_dispatch_secret_forbidden", "AgentRun durable dispatch input must not persist sensitive transient environment values."); - } + assertAgentRunDurableTransientEnv(baseTransient); return { idempotencyKey: traceId, - namespace: firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_RUNNER_NAMESPACE, env.AGENTRUN_RUNTIME_NAMESPACE, DEFAULT_RUNNER_NAMESPACE), - ...(firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_RUNNER_IMAGE, env.AGENTRUN_RUNNER_IMAGE) - ? { image: firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_RUNNER_IMAGE, env.AGENTRUN_RUNNER_IMAGE) } - : {}), - ...(firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_RUNNER_SERVICE_ACCOUNT, env.AGENTRUN_RUNNER_SERVICE_ACCOUNT) - ? { serviceAccountName: firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_RUNNER_SERVICE_ACCOUNT, env.AGENTRUN_RUNNER_SERVICE_ACCOUNT) } - : {}), ...(baseTransient.length > 0 ? { transientEnv: baseTransient } : {}) }; } +function assertAgentRunDurableTransientEnv(entries) { + const seen = new Set(); + for (const entry of Array.isArray(entries) ? entries : []) { + const name = firstNonEmpty(entry?.name); + const value = firstNonEmpty(entry?.value); + if (!/^[A-Z_][A-Z0-9_]*$/u.test(name) || !value || seen.has(name)) { + throw adapterError("agentrun_durable_dispatch_transient_env_invalid", "AgentRun durable dispatch transient environment must contain unique non-empty environment entries."); + } + if (entry?.sensitive === true) { + throw adapterError("agentrun_durable_dispatch_secret_forbidden", "AgentRun durable dispatch input must not persist sensitive transient environment values."); + } + if (agentRunReservedTransientEnvName(name)) { + throw adapterError("agentrun_durable_dispatch_transient_env_reserved", `AgentRun durable dispatch transient environment cannot override runtime-owned ${name}.`); + } + seen.add(name); + } +} + +function agentRunReservedTransientEnvName(name) { + return name.startsWith("AGENTRUN_") + || name.startsWith("KUBERNETES_") + || AGENTRUN_DURABLE_TRANSIENT_ENV_RESERVED.has(name) + || new Set([ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "GIT_SSH_COMMAND", + "OTEL_SERVICE_NAME", + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT" + ]).has(name); +} + function requireAgentRunDurableDispatchIntent(value) { const intent = value && typeof value === "object" && !Array.isArray(value) ? value : null; const admittedStates = new Set(["pending", "dispatching", "retry", "dispatched"]); diff --git a/internal/cloud/code-agent-agentrun-durable-admission.test.ts b/internal/cloud/code-agent-agentrun-durable-admission.test.ts index 890825a9..017fbd16 100644 --- a/internal/cloud/code-agent-agentrun-durable-admission.test.ts +++ b/internal/cloud/code-agent-agentrun-durable-admission.test.ts @@ -64,7 +64,11 @@ test("fresh gpt.pika turn returns running only after durable dispatch intent is assert.equal(commandCall.body.dispatch.kind, "kubernetes-job"); assert.equal(Object.hasOwn(commandCall.body.dispatch.input, "commandId"), false); assert.equal(commandCall.body.dispatch.input.idempotencyKey, traceId); + assert.equal(Object.hasOwn(commandCall.body.dispatch.input, "namespace"), false); + assert.equal(Object.hasOwn(commandCall.body.dispatch.input, "image"), false); + assert.equal(Object.hasOwn(commandCall.body.dispatch.input, "serviceAccountName"), false); assert.equal(commandCall.body.dispatch.input.transientEnv.every((entry) => entry.sensitive === false), true); + assert.equal(commandCall.body.dispatch.input.transientEnv.every((entry) => !entry.name.startsWith("AGENTRUN_") && !entry.name.startsWith("KUBERNETES_")), true); assert.equal(JSON.stringify(commandCall.body).includes("hwl_live_must_not_be_persisted"), false); assert.equal(calls.some((call) => call.path.endsWith("/runner-jobs")), false); assert.ok(traceStore.snapshot(traceId).events.some((event) => event.label === "agentrun:dispatch-intent:admitted")); @@ -222,6 +226,9 @@ function agentRunTestEnv() { HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID: "NC01", HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT: sourceCommit, HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "gpt.pika", + HWLAB_CODE_AGENT_AGENTRUN_RUNNER_NAMESPACE: "caller-controlled-namespace-must-be-ignored", + HWLAB_CODE_AGENT_AGENTRUN_RUNNER_IMAGE: "caller-controlled-image-must-be-ignored", + HWLAB_CODE_AGENT_AGENTRUN_RUNNER_SERVICE_ACCOUNT: "caller-controlled-service-account-must-be-ignored", HWLAB_RUNTIME_API_URL: "http://hwlab-cloud-api.hwlab-v03.svc.cluster.local:6667", HWLAB_RUNTIME_WEB_URL: "http://hwlab-cloud-web.hwlab-v03.svc.cluster.local:8080", HWLAB_RUNTIME_NAMESPACE: "hwlab-v03",