diff --git a/config/agentrun.yaml b/config/agentrun.yaml index d8d93b84..fcb52745 100644 --- a/config/agentrun.yaml +++ b/config/agentrun.yaml @@ -33,6 +33,21 @@ auth: client: role: render-only transport: direct-http + sessionPolicy: + tenantId: unidesk + projectId: default + providerId: G14 + backendProfile: codex + workspaceRef: + kind: opaque + path: . + executionPolicy: + sandbox: workspace-write + approval: never + timeoutMs: 900000 + network: enabled + secretScope: + allowCredentialEcho: false controlPlane: default: @@ -178,7 +193,25 @@ controlPlane: name: agentrun-v01-mgr-db key: DATABASE_URL localPostgresExpectedAbsent: false - secrets: [] + secrets: + - id: provider-codex-auth-json + sourceMode: file + sourceRef: /root/.codex/auth.json + targetRef: + namespace: agentrun-v01 + name: agentrun-v01-provider-codex + key: auth.json + providerCredential: + profile: codex + - id: provider-codex-config + sourceMode: file + sourceRef: /root/.codex/config.toml + targetRef: + namespace: agentrun-v01 + name: agentrun-v01-provider-codex + key: config.toml + providerCredential: + profile: codex v02: node: D601 @@ -334,6 +367,8 @@ controlPlane: namespace: agentrun-v02 name: agentrun-v01-provider-codex key: auth.json + providerCredential: + profile: codex - id: provider-codex-config sourceMode: file sourceRef: /root/.codex/config.toml @@ -341,6 +376,8 @@ controlPlane: namespace: agentrun-v02 name: agentrun-v01-provider-codex key: config.toml + providerCredential: + profile: codex - id: provider-deepseek-auth-json sourceMode: file sourceRef: /root/.codex-deepseek-v4-pro/auth.json @@ -348,6 +385,8 @@ controlPlane: namespace: agentrun-v02 name: agentrun-v01-provider-deepseek key: auth.json + providerCredential: + profile: deepseek - id: provider-deepseek-config sourceMode: file sourceRef: agentrun/d601-v02-provider-deepseek-config.toml @@ -355,6 +394,8 @@ controlPlane: namespace: agentrun-v02 name: agentrun-v01-provider-deepseek key: config.toml + providerCredential: + profile: deepseek - id: tool-github-pr-token sourceRef: /root/.config/unidesk/github.env sourceKey: GH_TOKEN diff --git a/scripts/src/agentrun-lanes.ts b/scripts/src/agentrun-lanes.ts index a295b74b..f931eb2c 100644 --- a/scripts/src/agentrun-lanes.ts +++ b/scripts/src/agentrun-lanes.ts @@ -22,6 +22,16 @@ export interface AgentRunLaneSecretSpec { readonly sourceMode: "env" | "file"; readonly sourceKey: string | null; readonly targetRef: AgentRunSecretRef; + readonly providerCredentialProfile: string | null; +} + +export interface AgentRunProviderCredentialRef { + readonly profile: string; + readonly secretRef: { + readonly namespace: string; + readonly name: string; + readonly keys: readonly string[]; + }; } export interface AgentRunLaneSpec { @@ -193,10 +203,6 @@ export function resolveAgentRunLaneTarget(options: { node?: string | null; lane? return { configPath: config.sourcePath, spec }; } -export function agentRunDefaultProviderId(env: NodeJS.ProcessEnv = process.env): string { - return resolveAgentRunLaneTarget({}, env).spec.nodeId; -} - export function agentRunLaneSummary(spec: AgentRunLaneSpec): Record { return { node: { @@ -306,11 +312,43 @@ export function agentRunLaneSummary(spec: AgentRunLaneSpec): Record ({ + profile: credential.profile, + secretRef: credential.secretRef, valuesPrinted: false, })), }; } +export function agentRunProviderCredentialRefs(spec: AgentRunLaneSpec, profile?: string | null): readonly AgentRunProviderCredentialRef[] { + const groups = new Map(); + for (const secret of spec.secrets) { + const credentialProfile = secret.providerCredentialProfile; + if (credentialProfile === null) continue; + if (profile !== undefined && profile !== null && credentialProfile !== profile) continue; + const groupKey = `${credentialProfile}\0${secret.targetRef.namespace}\0${secret.targetRef.name}`; + const group = groups.get(groupKey) ?? { + profile: credentialProfile, + namespace: secret.targetRef.namespace, + name: secret.targetRef.name, + keys: [], + }; + if (!group.keys.includes(secret.targetRef.key)) group.keys.push(secret.targetRef.key); + groups.set(groupKey, group); + } + return Array.from(groups.values()).map((group) => ({ + profile: group.profile, + secretRef: { + namespace: group.namespace, + name: group.name, + keys: group.keys, + }, + })); +} + export function agentRunPipelineRunName(spec: AgentRunLaneSpec, sourceCommit: string): string { return `${spec.ci.pipelineRunPrefix}-${sourceCommit.slice(0, 12)}`; } @@ -531,15 +569,26 @@ function parseLaneSecret(input: Record, path: string): AgentRun if (sourceMode !== "env" && sourceMode !== "file") throw new Error(`${path}.sourceMode must be env or file`); const sourceKey = optionalStringField(input, "sourceKey", path) ?? null; if (sourceMode === "env" && sourceKey === null) throw new Error(`${path}.sourceKey is required when sourceMode is env`); + const providerCredential = parseProviderCredentialBinding(input, `${path}.providerCredential`); return { id: stringField(input, "id", path), sourceRef: secretSourceRefField(input, "sourceRef", path), sourceMode, sourceKey, targetRef: parseNamespacedSecretRef(recordField(input, "targetRef", path), `${path}.targetRef`), + providerCredentialProfile: providerCredential, }; } +function parseProviderCredentialBinding(input: Record, path: string): string | null { + const value = input.providerCredential; + if (value === undefined || value === null) return null; + const record = asRecord(value, path); + const profile = stringField(record, "profile", path); + validateSimpleId(profile, path); + return profile; +} + function parseGitMirrorRepository(input: Record, path: string): AgentRunGitMirrorRepositorySpec { const gitopsBranch = optionalStringField(input, "gitopsBranch", path); return { diff --git a/scripts/src/agentrun.ts b/scripts/src/agentrun.ts index 35cf1c5b..79bd8392 100644 --- a/scripts/src/agentrun.ts +++ b/scripts/src/agentrun.ts @@ -10,9 +10,9 @@ import { runRemoteSshCommandCapture } from "./remote"; import { startJob } from "./jobs"; import { AGENTRUN_CONFIG_PATH, - agentRunDefaultProviderId, agentRunLaneSummary, agentRunPipelineRunName, + agentRunProviderCredentialRefs, resolveAgentRunLaneTarget, type AgentRunLaneSpec, } from "./agentrun-lanes"; @@ -45,6 +45,7 @@ export function agentRunHelp(): unknown { "bun scripts/cli.ts agentrun apply -f - --dry-run", "bun scripts/cli.ts agentrun send session/ --aipod Artificer --prompt-stdin", "bun scripts/cli.ts agentrun explain task", + "bun scripts/cli.ts agentrun explain session-policy", "bun scripts/cli.ts agentrun control-plane plan --node D601 --lane v02", "bun scripts/cli.ts agentrun control-plane apply --node D601 --lane v02 --dry-run", "bun scripts/cli.ts agentrun control-plane status --node D601 --lane v02", @@ -1536,6 +1537,7 @@ function nextPagedResourceCommand(command: string, nextAfterSeq: string, limit: } function agentRunExplain(kindRaw: string): string { + if (kindRaw === "session-policy" || kindRaw === "provider-policy") return renderAgentRunSessionPolicyExplanation(); const kind = parseResourceKind(kindRaw); if (kind === "task") { return [ @@ -4599,21 +4601,21 @@ function isExplicitSessionSendBody(input: Record): boolean { async function sessionRunBodyFromArgs(sessionId: string, args: string[], input: Record): Promise> { const existing = await fetchAgentRunSessionOrNull(sessionId, args); - const defaultProviderId = agentRunDefaultProviderId(); + const sessionPolicy = readAgentRunClientConfig().client.sessionPolicy; const profile = agentRunOption(args, "profile") ?? agentRunOption(args, "backend-profile") ?? stringOrNull(input.backendProfile) ?? stringOrNull(existing?.backendProfile) - ?? "codex"; + ?? sessionPolicy.backendProfile; const body: Record = { ...input }; - body.tenantId = agentRunOption(args, "tenant-id") ?? stringOrNull(body.tenantId) ?? stringOrNull(existing?.tenantId) ?? "unidesk"; - body.projectId = agentRunOption(args, "project-id") ?? stringOrNull(body.projectId) ?? stringOrNull(existing?.projectId) ?? "default"; - body.providerId = agentRunOption(args, "provider-id") ?? stringOrNull(body.providerId) ?? stringOrNull(existing?.providerId) ?? defaultProviderId; + body.tenantId = agentRunOption(args, "tenant-id") ?? stringOrNull(body.tenantId) ?? stringOrNull(existing?.tenantId) ?? sessionPolicy.tenantId; + body.projectId = agentRunOption(args, "project-id") ?? stringOrNull(body.projectId) ?? stringOrNull(existing?.projectId) ?? sessionPolicy.projectId; + body.providerId = agentRunOption(args, "provider-id") ?? stringOrNull(body.providerId) ?? stringOrNull(existing?.providerId) ?? sessionPolicy.providerId; body.backendProfile = profile; body.workspaceRef = jsonObjectOption(args, "workspace-json") ?? record(body.workspaceRef); - if (Object.keys(record(body.workspaceRef)).length === 0) body.workspaceRef = { kind: "opaque", path: "." }; + if (Object.keys(record(body.workspaceRef)).length === 0) body.workspaceRef = cloneJsonRecord(sessionPolicy.workspaceRef); body.executionPolicy = jsonObjectOption(args, "execution-policy-json") ?? record(body.executionPolicy); - if (Object.keys(record(body.executionPolicy)).length === 0) body.executionPolicy = defaultAgentRunExecutionPolicy(profile); + if (Object.keys(record(body.executionPolicy)).length === 0) body.executionPolicy = defaultAgentRunExecutionPolicy(profile, sessionPolicy); const inheritedSessionRef = existing === null ? {} : { conversationId: existing.conversationId, threadId: existing.threadId, @@ -4636,15 +4638,17 @@ async function sessionSendWithAipodRest(sessionId: string, aipod: string, args: const metadata = record(sessionRef.metadata); const title = agentRunOption(args, "title") ?? stringOrNull(task.title); if (title) metadata.title = title; - const defaultProviderId = agentRunDefaultProviderId(); + const sessionPolicy = readAgentRunClientConfig().client.sessionPolicy; + const backendProfile = stringOrNull(task.backendProfile) ?? sessionPolicy.backendProfile; + const executionPolicy = record(task.executionPolicy); const runBody: Record = { - tenantId: task.tenantId, - projectId: task.projectId, - providerId: task.providerId ?? defaultProviderId, - backendProfile: task.backendProfile, - workspaceRef: task.workspaceRef ?? { kind: "opaque", path: "." }, + tenantId: stringOrNull(task.tenantId) ?? sessionPolicy.tenantId, + projectId: stringOrNull(task.projectId) ?? sessionPolicy.projectId, + providerId: stringOrNull(task.providerId) ?? sessionPolicy.providerId, + backendProfile, + workspaceRef: task.workspaceRef ?? cloneJsonRecord(sessionPolicy.workspaceRef), sessionRef: { ...sessionRef, sessionId, metadata }, - executionPolicy: task.executionPolicy, + executionPolicy: Object.keys(executionPolicy).length === 0 ? defaultAgentRunExecutionPolicy(backendProfile, sessionPolicy) : executionPolicy, resourceBundleRef: task.resourceBundleRef, traceSink: { kind: "aipod-session", aipod, sessionId, valuesPrinted: false }, }; @@ -4808,6 +4812,7 @@ export function parseAgentRunClientConfigYaml(raw: string, sourcePath = "config/ const transport = stringFieldFromRecord(client, "transport", "client"); if (role !== "render-only") throw new Error(`${sourcePath}: client.role must be render-only`); if (transport !== "direct-http") throw new Error(`${sourcePath}: client.transport must be direct-http`); + const sessionPolicy = readAgentRunSessionPolicyConfig(client, sourcePath); const publicExposure = readAgentRunPublicExposureConfig(input.publicExposure, baseUrl.replace(/\/+$/u, "/"), sourcePath); return { sourcePath, @@ -4824,11 +4829,35 @@ export function parseAgentRunClientConfigYaml(raw: string, sourcePath = "config/ client: { role, transport, + sessionPolicy, }, publicExposure, }; } +function readAgentRunSessionPolicyConfig(client: Record, sourcePath: string): AgentRunSessionPolicyConfig { + const pathValue = "client.sessionPolicy"; + const sessionPolicy = record(client.sessionPolicy); + const workspaceRef = record(sessionPolicy.workspaceRef); + const executionPolicy = record(sessionPolicy.executionPolicy); + const secretScope = record(executionPolicy.secretScope); + stringFieldFromRecord(workspaceRef, "kind", `${pathValue}.workspaceRef`); + stringFieldFromRecord(executionPolicy, "sandbox", `${pathValue}.executionPolicy`); + stringFieldFromRecord(executionPolicy, "approval", `${pathValue}.executionPolicy`); + positiveIntegerFieldFromRecord(executionPolicy, "timeoutMs", `${pathValue}.executionPolicy`); + stringFieldFromRecord(executionPolicy, "network", `${pathValue}.executionPolicy`); + booleanFieldFromRecord(secretScope, "allowCredentialEcho", `${pathValue}.executionPolicy.secretScope`); + return { + sourcePath, + tenantId: stringFieldFromRecord(sessionPolicy, "tenantId", pathValue), + projectId: stringFieldFromRecord(sessionPolicy, "projectId", pathValue), + providerId: stringFieldFromRecord(sessionPolicy, "providerId", pathValue), + backendProfile: stringFieldFromRecord(sessionPolicy, "backendProfile", pathValue), + workspaceRef: cloneJsonRecord(workspaceRef), + executionPolicy: cloneJsonRecord(executionPolicy), + }; +} + function validateAgentRunConfigEnvelope(input: Record, sourcePath: string): void { if (input.version !== 1) throw new Error(`${sourcePath}: version must be 1`); if (input.kind !== "AgentRunConfig") throw new Error(`${sourcePath}: kind must be AgentRunConfig`); @@ -5115,28 +5144,58 @@ function jsonInputDisclosureFromArgs(args: string[]): Record { }; } -function defaultAgentRunExecutionPolicy(profile: string): Record { - const keys = profile === "sub2api" || profile === "codex" ? ["auth.json", "config.toml"] : ["auth.json", "config.toml"]; +function defaultAgentRunExecutionPolicy(profile: string, sessionPolicy: AgentRunSessionPolicyConfig = readAgentRunClientConfig().client.sessionPolicy): Record { + const { spec } = resolveAgentRunLaneTarget({}); + const credentials = agentRunProviderCredentialRefs(spec, profile); + if (credentials.length === 0) { + throw new AgentRunRestError("validation-failed", `config/agentrun.yaml has no providerCredential Secret binding for backendProfile=${profile} on default lane ${spec.nodeId}/${spec.lane}`); + } + const providerCredentials = credentials.map((credential) => { + if (credential.secretRef.namespace !== spec.runtime.namespace) { + throw new AgentRunRestError("validation-failed", `providerCredential ${profile} Secret ${credential.secretRef.name} is in namespace ${credential.secretRef.namespace}, expected default lane namespace ${spec.runtime.namespace}`); + } + return { + profile: credential.profile, + secretRef: { + name: credential.secretRef.name, + keys: credential.secretRef.keys, + }, + }; + }); + const basePolicy = cloneJsonRecord(sessionPolicy.executionPolicy); + const secretScope = record(basePolicy.secretScope); return { - sandbox: "workspace-write", - approval: "never", - timeoutMs: 900000, - network: "enabled", + ...basePolicy, secretScope: { - allowCredentialEcho: false, - providerCredentials: [ - { - profile, - secretRef: { - name: `agentrun-v01-provider-${profile}`, - keys, - }, - }, - ], + ...secretScope, + providerCredentials, }, }; } +function renderAgentRunSessionPolicyExplanation(): string { + const config = readAgentRunClientConfig(); + const { spec } = resolveAgentRunLaneTarget({}); + const sessionPolicy = config.client.sessionPolicy; + const credentials = agentRunProviderCredentialRefs(spec, sessionPolicy.backendProfile); + const execution = defaultAgentRunExecutionPolicy(sessionPolicy.backendProfile, sessionPolicy); + const credentialSources = credentials.map((credential) => ({ + profile: credential.profile, + secretRef: credential.secretRef, + valuesPrinted: false, + })); + return [ + "KIND: session-policy", + `CONFIG: ${config.sourcePath}`, + `DEFAULT LANE: ${spec.nodeId}/${spec.lane}`, + `DEFAULTS: tenantId=${sessionPolicy.tenantId} projectId=${sessionPolicy.projectId} providerId=${sessionPolicy.providerId} backendProfile=${sessionPolicy.backendProfile}`, + `WORKSPACE: ${JSON.stringify(sessionPolicy.workspaceRef)}`, + `EXECUTION: ${JSON.stringify(execution)}`, + `PROVIDER CREDENTIAL SOURCES: ${JSON.stringify(credentialSources)}`, + "VALUES: secret payloads are not printed", + ].join("\n"); +} + function safeAgentRunEnvelope(envelope: Record): Record { return pickCompact(envelope, ["ok", "failureKind", "message", "code", "details", "valuesPrinted"]); } @@ -5160,6 +5219,22 @@ function numberFieldFromRecord(obj: Record, key: string, pathVa return value; } +function positiveIntegerFieldFromRecord(obj: Record, key: string, pathValue: string): number { + const value = obj[key]; + if (!Number.isInteger(value) || Number(value) <= 0) throw new Error(`${pathValue}.${key} must be a positive integer`); + return Number(value); +} + +function booleanFieldFromRecord(obj: Record, key: string, pathValue: string): boolean { + const value = obj[key]; + if (typeof value !== "boolean") throw new Error(`${pathValue}.${key} must be a boolean`); + return value; +} + +function cloneJsonRecord(value: Record): Record { + return JSON.parse(JSON.stringify(value)) as Record; +} + interface AgentRunClientConfig { sourcePath: string; manager: { @@ -5175,10 +5250,21 @@ interface AgentRunClientConfig { client: { role: string; transport: string; + sessionPolicy: AgentRunSessionPolicyConfig; }; publicExposure: AgentRunPublicExposure | null; } +interface AgentRunSessionPolicyConfig { + sourcePath: string; + tenantId: string; + projectId: string; + providerId: string; + backendProfile: string; + workspaceRef: Record; + executionPolicy: Record; +} + interface AgentRunPublicExposure { enabled: true; proxyName: string;