diff --git a/scripts/src/agentrun.test.ts b/scripts/src/agentrun.test.ts index 1452c477..25024ba1 100644 --- a/scripts/src/agentrun.test.ts +++ b/scripts/src/agentrun.test.ts @@ -852,10 +852,45 @@ describe("AgentRun YAML tool credential binding", () => { expect(JSON.stringify({ githubSsh, sources })).not.toContain("/root/.ssh"); }); - test("preserves multi-key credentials across Aipod render and queue admission and rejects conflicts", async () => { + test("preserves explicit credential subsets across Aipod admission and rejects unknown identities or conflicts", async () => { const renderBodies: Record[] = []; const submittedBodies: Record[] = []; - let conflictingKeys = false; + let credentialMode: "all" | "subset" | "conflict" | "unknown" = "all"; + const renderedToolCredentials = (): Record[] => { + const credentials = [ + { + tool: "github", + purpose: "github-pr", + secretRef: { name: "agentrun-v01-tool-github-pr", keys: ["GH_TOKEN"] }, + projection: { kind: "env", envName: "GH_TOKEN", secretKey: "GH_TOKEN" }, + }, + { + tool: "unidesk-ssh", + purpose: "ssh-passthrough", + secretRef: { name: "agentrun-v01-tool-unidesk-ssh", keys: ["UNIDESK_SSH_CLIENT_TOKEN"] }, + projection: { kind: "env", envName: "UNIDESK_SSH_CLIENT_TOKEN", secretKey: "UNIDESK_SSH_CLIENT_TOKEN" }, + }, + { + tool: "github", + purpose: "github-ssh", + secretRef: { + name: "agentrun-v01-tool-github-ssh", + keys: credentialMode === "conflict" ? ["id_ed25519", "known_hosts", "config"] : ["id_ed25519", "known_hosts"], + }, + projection: { kind: "volume", mountPath: "/home/agentrun/.ssh" }, + }, + ]; + if (credentialMode === "subset") return credentials.slice(0, 1); + if (credentialMode === "unknown") { + return [{ + tool: "artifact-store", + purpose: "publish", + secretRef: { name: "agentrun-artifact-store", keys: ["TOKEN"] }, + projection: { kind: "env", envName: "ARTIFACT_TOKEN", secretKey: "TOKEN" }, + }]; + } + return credentials; + }; const renderedTask = () => ({ tenantId: "unidesk", projectId: "pikasTech/unidesk", @@ -875,29 +910,7 @@ describe("AgentRun YAML tool credential binding", () => { profile: "codex", secretRef: { name: "agentrun-v01-provider-codex", keys: ["auth.json", "config.toml"] }, }], - toolCredentials: [ - { - tool: "github", - purpose: "github-pr", - secretRef: { name: "agentrun-v01-tool-github-pr", keys: ["GH_TOKEN"] }, - projection: { kind: "env", envName: "GH_TOKEN", secretKey: "GH_TOKEN" }, - }, - { - tool: "unidesk-ssh", - purpose: "ssh-passthrough", - secretRef: { name: "agentrun-v01-tool-unidesk-ssh", keys: ["UNIDESK_SSH_CLIENT_TOKEN"] }, - projection: { kind: "env", envName: "UNIDESK_SSH_CLIENT_TOKEN", secretKey: "UNIDESK_SSH_CLIENT_TOKEN" }, - }, - { - tool: "github", - purpose: "github-ssh", - secretRef: { - name: "agentrun-v01-tool-github-ssh", - keys: conflictingKeys ? ["id_ed25519", "known_hosts", "config"] : ["id_ed25519", "known_hosts"], - }, - projection: { kind: "volume", mountPath: "/home/agentrun/.ssh" }, - }, - ], + toolCredentials: renderedToolCredentials(), }, }, payload: { prompt: "fixture" }, @@ -930,6 +943,11 @@ describe("AgentRun YAML tool credential binding", () => { expect(accepted.ok).toBe(true); expect(renderBodies).toHaveLength(1); expect(submittedBodies).toHaveLength(1); + expect(renderBodies[0]?.executionPolicy?.secretScope?.toolCredentials?.map((credential: Record) => `${credential.tool}/${credential.purpose}`)).toEqual([ + "github/github-pr", + "unidesk-ssh/ssh-passthrough", + "github/github-ssh", + ]); for (const body of [renderBodies[0], submittedBodies[0]]) { const githubSsh = body?.executionPolicy?.secretScope?.toolCredentials?.filter((credential: Record) => credential.tool === "github" && credential.purpose === "github-ssh"); expect(githubSsh).toEqual([{ @@ -940,14 +958,35 @@ describe("AgentRun YAML tool credential binding", () => { }]); } - conflictingKeys = true; + credentialMode = "subset"; + const subsetAccepted = await runAgentRunCommand(null, ["create", "task", "--aipod", "Artificer", "--prompt", "fixture", "-o", "json"]); + expect(subsetAccepted.ok).toBe(true); + expect(renderBodies).toHaveLength(2); + expect(submittedBodies).toHaveLength(2); + expect(submittedBodies[1]?.executionPolicy?.secretScope?.toolCredentials).toEqual([{ + tool: "github", + purpose: "github-pr", + secretRef: { name: "agentrun-v01-tool-github-pr", keys: ["GH_TOKEN"] }, + projection: { kind: "env", envName: "GH_TOKEN", secretKey: "GH_TOKEN" }, + }]); + + credentialMode = "conflict"; const rejected = await runAgentRunCommand(null, ["create", "task", "--aipod", "Artificer", "--prompt", "fixture", "-o", "json"]); expect(rejected.ok).toBe(false); - expect(submittedBodies).toHaveLength(1); + expect(submittedBodies).toHaveLength(2); const rejectedText = renderedTextOf(rejected); expect(rejectedText).toContain("validation-failed"); expect(rejectedText).toContain("github/github-ssh keys conflict"); expect(Buffer.byteLength(rejectedText, "utf8")).toBeLessThan(2400); + + credentialMode = "unknown"; + const unknownRejected = await runAgentRunCommand(null, ["create", "task", "--aipod", "Artificer", "--prompt", "fixture", "-o", "json"]); + expect(unknownRejected.ok).toBe(false); + expect(submittedBodies).toHaveLength(2); + const unknownText = renderedTextOf(unknownRejected); + expect(unknownText).toContain("validation-failed"); + expect(unknownText).toContain("artifact-store/publish is not declared by YAML lane"); + expect(Buffer.byteLength(unknownText, "utf8")).toBeLessThan(2400); } finally { server.stop(true); if (previousConfig === undefined) delete process.env.AGENTRUN_CLIENT_CONFIG; diff --git a/scripts/src/agentrun/config.ts b/scripts/src/agentrun/config.ts index c92603da..3ca35f63 100644 --- a/scripts/src/agentrun/config.ts +++ b/scripts/src/agentrun/config.ts @@ -579,8 +579,10 @@ export function agentRunExecutionPolicyWithLaneCredentials(basePolicy: Record { if (credential.secretRef.namespace !== spec.runtime.namespace) { throw new AgentRunRestError("validation-failed", `toolCredential ${credential.tool} Secret ${credential.secretRef.name} is in namespace ${credential.secretRef.namespace}, expected target lane namespace ${spec.runtime.namespace}`); @@ -626,14 +628,16 @@ export function agentRunToolCredentialRefs(spec: AgentRunLaneSpec): AgentRunTool })); } -function validateBasePolicyToolCredentials(basePolicy: Record, laneCredentials: readonly AgentRunToolCredentialRef[], target: AgentRunSessionPolicyTarget): void { - const rawCredentials = record(basePolicy.secretScope).toolCredentials; - if (rawCredentials === undefined || rawCredentials === null) return; +function selectBasePolicyToolCredentials(basePolicy: Record, laneCredentials: readonly AgentRunToolCredentialRef[], target: AgentRunSessionPolicyTarget): AgentRunToolCredentialRef[] { + const secretScope = record(basePolicy.secretScope); + if (!Object.hasOwn(secretScope, "toolCredentials")) return [...laneCredentials]; + const rawCredentials = secretScope.toolCredentials; if (!Array.isArray(rawCredentials)) { throw new AgentRunRestError("validation-failed", "executionPolicy.secretScope.toolCredentials must be an array when lane tool credentials are enabled"); } const laneByIdentity = new Map(laneCredentials.map((credential) => [`${credential.tool}\0${credential.purpose}`, credential])); const seen = new Set(); + const selected: AgentRunToolCredentialRef[] = []; for (const [index, rawCredential] of rawCredentials.entries()) { const credential = record(rawCredential); const tool = stringOrNull(credential.tool); @@ -664,7 +668,9 @@ function validateBasePolicyToolCredentials(basePolicy: Record, if (!toolCredentialProjectionMatches(record(credential.projection), laneCredential.projection)) { throw new AgentRunRestError("validation-failed", `executionPolicy toolCredential ${tool}/${purpose} projection conflicts with YAML lane ${target.spec.nodeId}/${target.spec.lane}`); } + selected.push(laneCredential); } + return selected; } function normalizedCredentialKeys(value: unknown): string[] | null {