fix: 保真渲染 AgentRun 多键工具凭据
This commit is contained in:
@@ -306,6 +306,10 @@ controlPlane:
|
||||
name: agentrun-v02-mgr-db
|
||||
key: DATABASE_URL
|
||||
localPostgresExpectedAbsent: true
|
||||
toolCredentials:
|
||||
- extends: controlPlane.templates.toolCredentials.githubPr
|
||||
- extends: controlPlane.templates.toolCredentials.unideskSsh
|
||||
- extends: controlPlane.templates.toolCredentials.githubSsh
|
||||
secrets:
|
||||
- id: manager-api-key
|
||||
sourceRef: hwlab/nc01-v03-admin.env
|
||||
@@ -478,6 +482,8 @@ controlPlane:
|
||||
profile: dsflash-go
|
||||
- extends: controlPlane.templates.secrets.githubPrRawToken
|
||||
- extends: controlPlane.templates.secrets.unideskSshToken
|
||||
- extends: controlPlane.templates.secrets.githubSshPrivateKey
|
||||
- extends: controlPlane.templates.secrets.githubSshKnownHosts
|
||||
extends: controlPlane.templates.agentrunV02Lane
|
||||
variables:
|
||||
NODE: NC01
|
||||
@@ -533,6 +539,46 @@ controlPlane:
|
||||
argoNamespace: argocd
|
||||
argoApplication: "${argoApplication}"
|
||||
repoURL: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/agentrun.git
|
||||
toolCredentials:
|
||||
githubPr:
|
||||
id: github-pr
|
||||
tool: github
|
||||
purpose: github-pr
|
||||
secretRef:
|
||||
namespace: agentrun-v02
|
||||
name: agentrun-v01-tool-github-pr
|
||||
keys:
|
||||
- GH_TOKEN
|
||||
projection:
|
||||
kind: env
|
||||
envName: GH_TOKEN
|
||||
secretKey: GH_TOKEN
|
||||
unideskSsh:
|
||||
id: unidesk-ssh
|
||||
tool: unidesk-ssh
|
||||
purpose: ssh-passthrough
|
||||
secretRef:
|
||||
namespace: agentrun-v02
|
||||
name: agentrun-v01-tool-unidesk-ssh
|
||||
keys:
|
||||
- UNIDESK_SSH_CLIENT_TOKEN
|
||||
projection:
|
||||
kind: env
|
||||
envName: UNIDESK_SSH_CLIENT_TOKEN
|
||||
secretKey: UNIDESK_SSH_CLIENT_TOKEN
|
||||
githubSsh:
|
||||
id: github-ssh
|
||||
tool: github
|
||||
purpose: github-ssh
|
||||
secretRef:
|
||||
namespace: agentrun-v02
|
||||
name: agentrun-v01-tool-github-ssh
|
||||
keys:
|
||||
- id_ed25519
|
||||
- known_hosts
|
||||
projection:
|
||||
kind: volume
|
||||
mountPath: /home/agentrun/.ssh
|
||||
secrets:
|
||||
githubPrRawToken:
|
||||
id: tool-github-pr-token
|
||||
@@ -551,3 +597,19 @@ controlPlane:
|
||||
namespace: agentrun-v02
|
||||
name: agentrun-v01-tool-unidesk-ssh
|
||||
key: UNIDESK_SSH_CLIENT_TOKEN
|
||||
githubSshPrivateKey:
|
||||
id: tool-github-ssh-private-key
|
||||
sourceMode: file
|
||||
sourceRef: deploy-ssh/github.com/id_ed25519
|
||||
targetRef:
|
||||
namespace: agentrun-v02
|
||||
name: agentrun-v01-tool-github-ssh
|
||||
key: id_ed25519
|
||||
githubSshKnownHosts:
|
||||
id: tool-github-ssh-known-hosts
|
||||
sourceMode: file
|
||||
sourceRef: deploy-ssh/github.com/known_hosts
|
||||
targetRef:
|
||||
namespace: agentrun-v02
|
||||
name: agentrun-v01-tool-github-ssh
|
||||
key: known_hosts
|
||||
|
||||
@@ -110,6 +110,20 @@ export interface AgentRunLaneSecretSpec {
|
||||
readonly providerCredentialProfile: string | null;
|
||||
}
|
||||
|
||||
export interface AgentRunToolCredentialSpec {
|
||||
readonly id: string;
|
||||
readonly tool: string;
|
||||
readonly purpose: string;
|
||||
readonly secretRef: {
|
||||
readonly namespace: string;
|
||||
readonly name: string;
|
||||
readonly keys: readonly string[];
|
||||
};
|
||||
readonly projection:
|
||||
| { readonly kind: "env"; readonly envName: string; readonly secretKey: string }
|
||||
| { readonly kind: "volume"; readonly mountPath: string };
|
||||
}
|
||||
|
||||
export interface AgentRunCodexConfigSpec {
|
||||
readonly modelProvider: string;
|
||||
readonly model: string;
|
||||
@@ -273,6 +287,7 @@ export interface AgentRunLaneSpec {
|
||||
readonly localPostgresExpectedAbsent: boolean;
|
||||
};
|
||||
readonly secrets: readonly AgentRunLaneSecretSpec[];
|
||||
readonly toolCredentials: readonly AgentRunToolCredentialSpec[];
|
||||
}
|
||||
|
||||
export interface AgentRunContainerResources {
|
||||
@@ -543,6 +558,14 @@ export function agentRunLaneSummary(spec: AgentRunLaneSpec): Record<string, unkn
|
||||
},
|
||||
valuesPrinted: false,
|
||||
})),
|
||||
toolCredentials: spec.toolCredentials.map((credential) => ({
|
||||
id: credential.id,
|
||||
tool: credential.tool,
|
||||
purpose: credential.purpose,
|
||||
secretRef: credential.secretRef,
|
||||
projection: credential.projection,
|
||||
valuesPrinted: false,
|
||||
})),
|
||||
providerCredentials: agentRunProviderCredentialRefs(spec).map((credential) => ({
|
||||
profile: credential.profile,
|
||||
secretRef: credential.secretRef,
|
||||
@@ -660,6 +683,7 @@ function parseLane(lane: string, node: AgentRunNodeSpec, input: Record<string, u
|
||||
const database = recordField(input, "database", path);
|
||||
const version = stringField(input, "version", path);
|
||||
const statusMode = sourceStatusModeField(optionalStringField(source, "statusMode", `${path}.source`) ?? (version === "v0.2" ? "k3s-git-mirror" : "host-worktree"), `${path}.source.statusMode`);
|
||||
const secrets = parseLaneSecrets(input, path);
|
||||
const spec: AgentRunLaneSpec = {
|
||||
lane,
|
||||
nodeId: node.id,
|
||||
@@ -723,9 +747,11 @@ function parseLane(lane: string, node: AgentRunNodeSpec, input: Record<string, u
|
||||
repositories: parseGitMirrorRepositories(arrayField(gitMirror, "repositories", `${path}.gitMirror`), `${path}.gitMirror.repositories`),
|
||||
},
|
||||
database: parseDatabase(database, `${path}.database`),
|
||||
secrets: parseLaneSecrets(input, path),
|
||||
secrets,
|
||||
toolCredentials: parseToolCredentials(input, path),
|
||||
};
|
||||
validateAgentRunLaneSourceAuthority(spec, path);
|
||||
validateToolCredentialSources(spec, path);
|
||||
return spec;
|
||||
}
|
||||
|
||||
@@ -1022,6 +1048,82 @@ function parseLaneSecrets(input: Record<string, unknown>, path: string): AgentRu
|
||||
return secrets;
|
||||
}
|
||||
|
||||
function parseToolCredentials(input: Record<string, unknown>, path: string): AgentRunToolCredentialSpec[] {
|
||||
const credentials = arrayField(input, "toolCredentials", path).map((value, index) => {
|
||||
const credentialPath = `${path}.toolCredentials[${index}]`;
|
||||
const secretRef = recordField(value, "secretRef", credentialPath);
|
||||
const projection = recordField(value, "projection", credentialPath);
|
||||
const kind = stringField(projection, "kind", `${credentialPath}.projection`);
|
||||
const keys = stringArrayField(secretRef, "keys", `${credentialPath}.secretRef`);
|
||||
if (keys.length === 0) throw new Error(`${credentialPath}.secretRef.keys must not be empty`);
|
||||
if (new Set(keys).size !== keys.length) throw new Error(`${credentialPath}.secretRef.keys must not contain duplicates`);
|
||||
const normalizedProjection = kind === "env"
|
||||
? parseToolCredentialEnvProjection(projection, keys, `${credentialPath}.projection`)
|
||||
: kind === "volume"
|
||||
? parseToolCredentialVolumeProjection(projection, `${credentialPath}.projection`)
|
||||
: null;
|
||||
if (normalizedProjection === null) throw new Error(`${credentialPath}.projection.kind must be env or volume`);
|
||||
return {
|
||||
id: stringField(value, "id", credentialPath),
|
||||
tool: credentialSlugField(value, "tool", credentialPath),
|
||||
purpose: credentialSlugField(value, "purpose", credentialPath),
|
||||
secretRef: {
|
||||
namespace: stringField(secretRef, "namespace", `${credentialPath}.secretRef`),
|
||||
name: stringField(secretRef, "name", `${credentialPath}.secretRef`),
|
||||
keys,
|
||||
},
|
||||
projection: normalizedProjection,
|
||||
} satisfies AgentRunToolCredentialSpec;
|
||||
});
|
||||
const ids = new Map<string, string>();
|
||||
const identities = new Map<string, string>();
|
||||
for (const [index, credential] of credentials.entries()) {
|
||||
const credentialPath = `${path}.toolCredentials[${index}]`;
|
||||
const priorId = ids.get(credential.id);
|
||||
if (priorId !== undefined) throw new Error(`${credentialPath}.id duplicates ${priorId}.id (${credential.id})`);
|
||||
ids.set(credential.id, credentialPath);
|
||||
const identity = `${credential.tool}/${credential.purpose}`;
|
||||
const priorIdentity = identities.get(identity);
|
||||
if (priorIdentity !== undefined) throw new Error(`${credentialPath} duplicates ${priorIdentity} tool/purpose (${identity})`);
|
||||
identities.set(identity, credentialPath);
|
||||
}
|
||||
return credentials;
|
||||
}
|
||||
|
||||
function parseToolCredentialEnvProjection(input: Record<string, unknown>, keys: readonly string[], path: string): AgentRunToolCredentialSpec["projection"] {
|
||||
const envName = stringField(input, "envName", path);
|
||||
if (!/^[A-Z_][A-Z0-9_]{0,63}$/u.test(envName)) throw new Error(`${path}.envName must be an uppercase environment variable name`);
|
||||
const secretKey = stringField(input, "secretKey", path);
|
||||
if (!keys.includes(secretKey)) throw new Error(`${path}.secretKey must be present in secretRef.keys`);
|
||||
return { kind: "env", envName, secretKey };
|
||||
}
|
||||
|
||||
function parseToolCredentialVolumeProjection(input: Record<string, unknown>, path: string): AgentRunToolCredentialSpec["projection"] {
|
||||
const mountPath = absolutePathField(input, "mountPath", path);
|
||||
if (!mountPath.startsWith("/home/agentrun/") || mountPath.includes("..")) throw new Error(`${path}.mountPath must stay under /home/agentrun`);
|
||||
return { kind: "volume", mountPath };
|
||||
}
|
||||
|
||||
function credentialSlugField(input: Record<string, unknown>, key: string, path: string): string {
|
||||
const value = stringField(input, key, path);
|
||||
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/u.test(value)) throw new Error(`${path}.${key} must be a lowercase credential slug`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateToolCredentialSources(spec: AgentRunLaneSpec, path: string): void {
|
||||
const declaredTargets = new Set(spec.secrets.map((secret) => `${secret.targetRef.namespace}\0${secret.targetRef.name}\0${secret.targetRef.key}`));
|
||||
for (const [index, credential] of spec.toolCredentials.entries()) {
|
||||
const credentialPath = `${path}.toolCredentials[${index}]`;
|
||||
if (credential.secretRef.namespace !== spec.runtime.namespace) {
|
||||
throw new Error(`${credentialPath}.secretRef.namespace must match ${path}.runtime.namespace (${spec.runtime.namespace})`);
|
||||
}
|
||||
const missingKeys = credential.secretRef.keys.filter((key) => !declaredTargets.has(`${credential.secretRef.namespace}\0${credential.secretRef.name}\0${key}`));
|
||||
if (missingKeys.length > 0) {
|
||||
throw new Error(`${credentialPath}.secretRef.keys have no matching YAML Secret source: ${missingKeys.join(", ")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseCodexConfig(input: Record<string, unknown>, path: string): AgentRunCodexConfigSpec {
|
||||
const providersRaw = recordField(input, "modelProviders", path);
|
||||
const modelProviders = Object.entries(providersRaw).map(([id, raw]) => {
|
||||
|
||||
@@ -2,7 +2,9 @@ import { readFileSync, rmSync } from "node:fs";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseAgentRunClientConfigYaml, resolveAgentRunAuth, runAgentRunCommand, stripAgentRunResourceWrapperArgs } from "./agentrun";
|
||||
import { resolveAgentRunLaneTarget } from "./agentrun-lanes";
|
||||
import { agentRunToolCredentialRefs } from "./agentrun/config";
|
||||
import { placeholderAgentRunImage, renderAgentRunGitopsFiles } from "./agentrun-manifests";
|
||||
import { collectLaneSecretSources, secretSyncScript } from "./agentrun/secrets";
|
||||
|
||||
function renderedTextOf(value: unknown): string {
|
||||
if (typeof value !== "object" || value === null) throw new Error("expected rendered CLI result");
|
||||
@@ -808,6 +810,155 @@ describe("AgentRun default transport contract", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentRun YAML tool credential binding", () => {
|
||||
test("aggregates managed GitHub SSH source keys into one volume credential and one sync target", () => {
|
||||
const { spec } = resolveAgentRunLaneTarget({ node: "NC01", lane: "nc01-v02" });
|
||||
const githubSsh = agentRunToolCredentialRefs(spec).filter((credential) => credential.tool === "github" && credential.purpose === "github-ssh");
|
||||
expect(githubSsh).toEqual([{
|
||||
sourceId: "github-ssh",
|
||||
tool: "github",
|
||||
purpose: "github-ssh",
|
||||
secretRef: {
|
||||
namespace: "agentrun-v02",
|
||||
name: "agentrun-v01-tool-github-ssh",
|
||||
keys: ["id_ed25519", "known_hosts"],
|
||||
},
|
||||
projection: { kind: "volume", mountPath: "/home/agentrun/.ssh" },
|
||||
valuesPrinted: false,
|
||||
}]);
|
||||
|
||||
const sources = collectLaneSecretSources(spec).filter((source) => source.targetRef.name === "agentrun-v01-tool-github-ssh");
|
||||
expect(sources.map((source) => ({ id: source.id, sourceMode: source.sourceMode, sourceRef: source.sourceRef, targetRef: source.targetRef }))).toEqual([
|
||||
{
|
||||
id: "tool-github-ssh-private-key",
|
||||
sourceMode: "file",
|
||||
sourceRef: "deploy-ssh/github.com/id_ed25519",
|
||||
targetRef: { namespace: "agentrun-v02", name: "agentrun-v01-tool-github-ssh", key: "id_ed25519" },
|
||||
},
|
||||
{
|
||||
id: "tool-github-ssh-known-hosts",
|
||||
sourceMode: "file",
|
||||
sourceRef: "deploy-ssh/github.com/known_hosts",
|
||||
targetRef: { namespace: "agentrun-v02", name: "agentrun-v01-tool-github-ssh", key: "known_hosts" },
|
||||
},
|
||||
]);
|
||||
const syncScript = secretSyncScript(spec, sources.map((source, index) => ({ targetRef: source.targetRef, value: `fixture-${index}` })));
|
||||
const payloadMatch = syncScript.match(/^payload_b64='([^']+)'$/mu);
|
||||
expect(payloadMatch).not.toBeNull();
|
||||
const syncManifest = JSON.parse(Buffer.from(payloadMatch?.[1] ?? "", "base64").toString("utf8")) as Array<{ targetRef: { namespace: string; name: string; key: string } }>;
|
||||
expect(syncManifest.map((item) => item.targetRef)).toEqual(sources.map((source) => source.targetRef));
|
||||
expect(new Set(syncManifest.map((item) => `${item.targetRef.namespace}/${item.targetRef.name}`))).toEqual(new Set(["agentrun-v02/agentrun-v01-tool-github-ssh"]));
|
||||
expect(syncScript).toContain("groups.setdefault(key");
|
||||
expect(JSON.stringify({ githubSsh, sources })).not.toContain("/root/.ssh");
|
||||
});
|
||||
|
||||
test("preserves multi-key credentials across Aipod render and queue admission and rejects conflicts", async () => {
|
||||
const renderBodies: Record<string, any>[] = [];
|
||||
const submittedBodies: Record<string, any>[] = [];
|
||||
let conflictingKeys = false;
|
||||
const renderedTask = () => ({
|
||||
tenantId: "unidesk",
|
||||
projectId: "pikasTech/unidesk",
|
||||
queue: "commander",
|
||||
lane: "v0.1",
|
||||
backendProfile: "codex",
|
||||
providerId: "NC01",
|
||||
workspaceRef: { kind: "opaque", path: "." },
|
||||
executionPolicy: {
|
||||
sandbox: "workspace-write",
|
||||
approval: "never",
|
||||
timeoutMs: 1_800_000,
|
||||
network: "enabled",
|
||||
secretScope: {
|
||||
allowCredentialEcho: false,
|
||||
providerCredentials: [{
|
||||
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" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
payload: { prompt: "fixture" },
|
||||
});
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url);
|
||||
expect(request.headers.get("authorization")).toBe("Bearer secret-value");
|
||||
if (url.pathname === "/api/v1/aipod-specs/Artificer/render") {
|
||||
renderBodies.push(await request.json() as Record<string, any>);
|
||||
return Response.json({ ok: true, data: { queueTask: renderedTask() } });
|
||||
}
|
||||
if (url.pathname === "/api/v1/queue/tasks") {
|
||||
submittedBodies.push(await request.json() as Record<string, any>);
|
||||
return Response.json({ ok: true, data: { id: "qt_fixture", state: "queued" } });
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
},
|
||||
});
|
||||
const previousConfig = process.env.AGENTRUN_CLIENT_CONFIG;
|
||||
const previousKey = process.env.HWLAB_API_KEY;
|
||||
process.env.HWLAB_API_KEY = "secret-value";
|
||||
const tempConfigPath = `/tmp/unidesk-agentrun-tool-credential-test-${process.pid}-${Date.now()}.yaml`;
|
||||
try {
|
||||
process.env.AGENTRUN_CLIENT_CONFIG = tempConfigPath;
|
||||
await Bun.write(tempConfigPath, agentRunMinimalClientYaml.replace("http://agentrun.example.local:8080", server.url.href.replace(/\/$/u, "")));
|
||||
|
||||
const accepted = await runAgentRunCommand(null, ["create", "task", "--aipod", "Artificer", "--prompt", "fixture", "-o", "json"]);
|
||||
expect(accepted.ok).toBe(true);
|
||||
expect(renderBodies).toHaveLength(1);
|
||||
expect(submittedBodies).toHaveLength(1);
|
||||
for (const body of [renderBodies[0], submittedBodies[0]]) {
|
||||
const githubSsh = body?.executionPolicy?.secretScope?.toolCredentials?.filter((credential: Record<string, unknown>) => credential.tool === "github" && credential.purpose === "github-ssh");
|
||||
expect(githubSsh).toEqual([{
|
||||
tool: "github",
|
||||
purpose: "github-ssh",
|
||||
secretRef: { name: "agentrun-v01-tool-github-ssh", keys: ["id_ed25519", "known_hosts"] },
|
||||
projection: { kind: "volume", mountPath: "/home/agentrun/.ssh" },
|
||||
}]);
|
||||
}
|
||||
|
||||
conflictingKeys = true;
|
||||
const rejected = await runAgentRunCommand(null, ["create", "task", "--aipod", "Artificer", "--prompt", "fixture", "-o", "json"]);
|
||||
expect(rejected.ok).toBe(false);
|
||||
expect(submittedBodies).toHaveLength(1);
|
||||
const rejectedText = renderedTextOf(rejected);
|
||||
expect(rejectedText).toContain("validation-failed");
|
||||
expect(rejectedText).toContain("github/github-ssh keys conflict");
|
||||
expect(Buffer.byteLength(rejectedText, "utf8")).toBeLessThan(2400);
|
||||
} finally {
|
||||
server.stop(true);
|
||||
if (previousConfig === undefined) delete process.env.AGENTRUN_CLIENT_CONFIG;
|
||||
else process.env.AGENTRUN_CLIENT_CONFIG = previousConfig;
|
||||
if (previousKey === undefined) delete process.env.HWLAB_API_KEY;
|
||||
else process.env.HWLAB_API_KEY = previousKey;
|
||||
rmSync(tempConfigPath, { force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentRun runner API key SecretRef", () => {
|
||||
test("renders manager runtime authority from the YAML runner SecretRef", () => {
|
||||
const { spec } = resolveAgentRunLaneTarget({ node: "NC01", lane: "nc01-v02" });
|
||||
|
||||
@@ -579,7 +579,9 @@ export function agentRunExecutionPolicyWithLaneCredentials(basePolicy: Record<st
|
||||
},
|
||||
};
|
||||
});
|
||||
const toolCredentials = includeToolCredentials ? agentRunToolCredentialRefs(spec).map((credential) => {
|
||||
const toolCredentialRefs = includeToolCredentials ? agentRunToolCredentialRefs(spec) : [];
|
||||
if (includeToolCredentials) validateBasePolicyToolCredentials(basePolicy, toolCredentialRefs, target);
|
||||
const toolCredentials = toolCredentialRefs.map((credential) => {
|
||||
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}`);
|
||||
}
|
||||
@@ -588,11 +590,11 @@ export function agentRunExecutionPolicyWithLaneCredentials(basePolicy: Record<st
|
||||
purpose: credential.purpose,
|
||||
secretRef: {
|
||||
name: credential.secretRef.name,
|
||||
keys: [credential.secretRef.key],
|
||||
keys: credential.secretRef.keys,
|
||||
},
|
||||
projection: credential.projection,
|
||||
};
|
||||
}) : [];
|
||||
});
|
||||
const secretScope = record(basePolicy.secretScope);
|
||||
return {
|
||||
...basePolicy,
|
||||
@@ -604,45 +606,80 @@ export function agentRunExecutionPolicyWithLaneCredentials(basePolicy: Record<st
|
||||
};
|
||||
}
|
||||
|
||||
export const AGENTRUN_DEFAULT_TOOL_CREDENTIAL_IDS = new Set(["tool-github-pr-token", "tool-unidesk-ssh-token"]);
|
||||
|
||||
export function agentRunToolCredentialRefs(spec: AgentRunLaneSpec, options: { includeUnsupported?: boolean } = {}): Array<{ tool: string; purpose: string; projection: Record<string, unknown>; sourceId: string; secretRef: { namespace: string; name: string; key: string }; valuesPrinted: false }> {
|
||||
return spec.secrets
|
||||
.filter((secret) => secret.providerCredentialProfile === null && secret.id.startsWith("tool-"))
|
||||
.map((secret) => {
|
||||
const binding = agentRunToolCredentialBinding(secret.id, secret.targetRef.key);
|
||||
return {
|
||||
tool: binding.tool,
|
||||
purpose: binding.purpose,
|
||||
projection: binding.projection,
|
||||
sourceId: secret.id,
|
||||
secretRef: secret.targetRef,
|
||||
valuesPrinted: false as const,
|
||||
};
|
||||
})
|
||||
.filter((credential) => options.includeUnsupported === true || AGENTRUN_DEFAULT_TOOL_CREDENTIAL_IDS.has(credential.sourceId));
|
||||
export interface AgentRunToolCredentialRef {
|
||||
readonly tool: string;
|
||||
readonly purpose: string;
|
||||
readonly projection: Record<string, unknown>;
|
||||
readonly sourceId: string;
|
||||
readonly secretRef: { readonly namespace: string; readonly name: string; readonly keys: readonly string[] };
|
||||
readonly valuesPrinted: false;
|
||||
}
|
||||
|
||||
export function agentRunToolCredentialBinding(sourceId: string, key: string): { tool: string; purpose: string; projection: Record<string, unknown> } {
|
||||
if (sourceId === "tool-github-pr-token") {
|
||||
return {
|
||||
tool: "github",
|
||||
purpose: "github-pr",
|
||||
projection: { kind: "env", envName: key, secretKey: key },
|
||||
};
|
||||
export function agentRunToolCredentialRefs(spec: AgentRunLaneSpec): AgentRunToolCredentialRef[] {
|
||||
return spec.toolCredentials.map((credential) => ({
|
||||
tool: credential.tool,
|
||||
purpose: credential.purpose,
|
||||
projection: credential.projection,
|
||||
sourceId: credential.id,
|
||||
secretRef: credential.secretRef,
|
||||
valuesPrinted: false,
|
||||
}));
|
||||
}
|
||||
|
||||
function validateBasePolicyToolCredentials(basePolicy: Record<string, unknown>, laneCredentials: readonly AgentRunToolCredentialRef[], target: AgentRunSessionPolicyTarget): void {
|
||||
const rawCredentials = record(basePolicy.secretScope).toolCredentials;
|
||||
if (rawCredentials === undefined || rawCredentials === null) return;
|
||||
if (!Array.isArray(rawCredentials)) {
|
||||
throw new AgentRunRestError("validation-failed", "executionPolicy.secretScope.toolCredentials must be an array when lane tool credentials are enabled");
|
||||
}
|
||||
if (sourceId === "tool-unidesk-ssh-token") {
|
||||
return {
|
||||
tool: "unidesk-ssh",
|
||||
purpose: "ssh-passthrough",
|
||||
projection: { kind: "env", envName: key, secretKey: key },
|
||||
};
|
||||
const laneByIdentity = new Map(laneCredentials.map((credential) => [`${credential.tool}\0${credential.purpose}`, credential]));
|
||||
const seen = new Set<string>();
|
||||
for (const [index, rawCredential] of rawCredentials.entries()) {
|
||||
const credential = record(rawCredential);
|
||||
const tool = stringOrNull(credential.tool);
|
||||
const purpose = stringOrNull(credential.purpose);
|
||||
if (tool === null || purpose === null) {
|
||||
throw new AgentRunRestError("validation-failed", `executionPolicy.secretScope.toolCredentials[${index}] must declare tool and purpose`);
|
||||
}
|
||||
const identity = `${tool}\0${purpose}`;
|
||||
if (seen.has(identity)) {
|
||||
throw new AgentRunRestError("validation-failed", `executionPolicy.secretScope.toolCredentials contains duplicate ${tool}/${purpose} identity`);
|
||||
}
|
||||
seen.add(identity);
|
||||
const laneCredential = laneByIdentity.get(identity);
|
||||
if (laneCredential === undefined) {
|
||||
throw new AgentRunRestError("validation-failed", `executionPolicy toolCredential ${tool}/${purpose} is not declared by YAML lane ${target.spec.nodeId}/${target.spec.lane}`);
|
||||
}
|
||||
const secretRef = record(credential.secretRef);
|
||||
const secretName = stringOrNull(secretRef.name);
|
||||
const namespace = stringOrNull(secretRef.namespace) ?? target.spec.runtime.namespace;
|
||||
if (secretName !== laneCredential.secretRef.name || namespace !== laneCredential.secretRef.namespace) {
|
||||
throw new AgentRunRestError("validation-failed", `executionPolicy toolCredential ${tool}/${purpose} SecretRef conflicts with YAML lane ${target.spec.nodeId}/${target.spec.lane}`);
|
||||
}
|
||||
const keys = normalizedCredentialKeys(secretRef.keys);
|
||||
const laneKeys = normalizedCredentialKeys(laneCredential.secretRef.keys);
|
||||
if (keys === null || laneKeys === null || keys.length !== laneKeys.length || keys.some((key, keyIndex) => key !== laneKeys[keyIndex])) {
|
||||
throw new AgentRunRestError("validation-failed", `executionPolicy toolCredential ${tool}/${purpose} keys conflict with YAML lane ${target.spec.nodeId}/${target.spec.lane}`);
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
tool: sourceId.replace(/^tool-/u, "").replace(/-token$/u, ""),
|
||||
purpose: sourceId.replace(/^tool-/u, ""),
|
||||
projection: {},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedCredentialKeys(value: unknown): string[] | null {
|
||||
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim().length === 0)) return null;
|
||||
const keys = value.map((item) => String(item)).sort();
|
||||
return new Set(keys).size === keys.length ? keys : null;
|
||||
}
|
||||
|
||||
function toolCredentialProjectionMatches(actual: Record<string, unknown>, expected: Record<string, unknown>): boolean {
|
||||
if (actual.kind !== expected.kind) return false;
|
||||
if (expected.kind === "env") {
|
||||
return actual.envName === expected.envName && actual.secretKey === expected.secretKey;
|
||||
}
|
||||
if (expected.kind === "volume") return actual.mountPath === expected.mountPath;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function renderAgentRunSessionPolicyExplanation(args: string[] = [], options: AgentRunRestTargetOptions = { node: null, lane: null }): string {
|
||||
@@ -660,7 +697,7 @@ export function renderAgentRunSessionPolicyExplanation(args: string[] = [], opti
|
||||
secretRef: credential.secretRef,
|
||||
valuesPrinted: false,
|
||||
}));
|
||||
const toolCredentialSources = agentRunToolCredentialRefs(spec, { includeUnsupported: true });
|
||||
const toolCredentialSources = agentRunToolCredentialRefs(spec);
|
||||
return [
|
||||
"KIND: session-policy",
|
||||
`CONFIG: ${config.sourcePath}`,
|
||||
|
||||
Reference in New Issue
Block a user