fix: 对齐 Artificer gpt-pika 模型合同
This commit is contained in:
@@ -107,6 +107,7 @@ export interface AgentRunLaneSecretSpec {
|
||||
readonly codexConfig: AgentRunCodexConfigSpec | null;
|
||||
readonly jsonValue: unknown;
|
||||
readonly targetRef: AgentRunSecretRef;
|
||||
readonly providerCredentialSourceProfile: string | null;
|
||||
readonly providerCredentialProfile: string | null;
|
||||
}
|
||||
|
||||
@@ -158,6 +159,7 @@ export interface AgentRunCodexProjectSpec {
|
||||
}
|
||||
|
||||
export interface AgentRunProviderCredentialRef {
|
||||
readonly sourceProfile: string;
|
||||
readonly profile: string;
|
||||
readonly secretRef: {
|
||||
readonly namespace: string;
|
||||
@@ -540,7 +542,10 @@ export function agentRunLaneSummary(spec: AgentRunLaneSpec): Record<string, unkn
|
||||
sourceFormat: secret.sourceFormat,
|
||||
transform: secret.transform,
|
||||
targetRef: secret.targetRef,
|
||||
providerCredential: secret.providerCredentialProfile === null ? null : { profile: secret.providerCredentialProfile },
|
||||
providerCredential: secret.providerCredentialProfile === null ? null : {
|
||||
sourceProfile: secret.providerCredentialSourceProfile,
|
||||
profile: secret.providerCredentialProfile,
|
||||
},
|
||||
codexConfig: secret.codexConfig === null ? null : {
|
||||
modelProvider: secret.codexConfig.modelProvider,
|
||||
model: secret.codexConfig.model,
|
||||
@@ -567,6 +572,7 @@ export function agentRunLaneSummary(spec: AgentRunLaneSpec): Record<string, unkn
|
||||
valuesPrinted: false,
|
||||
})),
|
||||
providerCredentials: agentRunProviderCredentialRefs(spec).map((credential) => ({
|
||||
sourceProfile: credential.sourceProfile,
|
||||
profile: credential.profile,
|
||||
secretRef: credential.secretRef,
|
||||
valuesPrinted: false,
|
||||
@@ -575,13 +581,15 @@ export function agentRunLaneSummary(spec: AgentRunLaneSpec): Record<string, unkn
|
||||
}
|
||||
|
||||
export function agentRunProviderCredentialRefs(spec: AgentRunLaneSpec, profile?: string | null): readonly AgentRunProviderCredentialRef[] {
|
||||
const groups = new Map<string, { profile: string; namespace: string; name: string; keys: string[] }>();
|
||||
for (const secret of spec.secrets) {
|
||||
const groups = new Map<string, { sourceProfile: string; profile: string; namespace: string; name: string; keys: string[] }>();
|
||||
for (const secret of agentRunProviderCredentialSecrets(spec)) {
|
||||
const sourceProfile = secret.providerCredentialSourceProfile;
|
||||
const credentialProfile = secret.providerCredentialProfile;
|
||||
if (credentialProfile === null) continue;
|
||||
if (sourceProfile === null || credentialProfile === null) continue;
|
||||
if (profile !== undefined && profile !== null && credentialProfile !== profile) continue;
|
||||
const groupKey = `${credentialProfile}\0${secret.targetRef.namespace}\0${secret.targetRef.name}`;
|
||||
const groupKey = `${sourceProfile}\0${credentialProfile}\0${secret.targetRef.namespace}\0${secret.targetRef.name}`;
|
||||
const group = groups.get(groupKey) ?? {
|
||||
sourceProfile,
|
||||
profile: credentialProfile,
|
||||
namespace: secret.targetRef.namespace,
|
||||
name: secret.targetRef.name,
|
||||
@@ -591,6 +599,7 @@ export function agentRunProviderCredentialRefs(spec: AgentRunLaneSpec, profile?:
|
||||
groups.set(groupKey, group);
|
||||
}
|
||||
return Array.from(groups.values()).map((group) => ({
|
||||
sourceProfile: group.sourceProfile,
|
||||
profile: group.profile,
|
||||
secretRef: {
|
||||
namespace: group.namespace,
|
||||
@@ -600,6 +609,25 @@ export function agentRunProviderCredentialRefs(spec: AgentRunLaneSpec, profile?:
|
||||
}));
|
||||
}
|
||||
|
||||
export function agentRunProviderCredentialRefsForSelection(spec: AgentRunLaneSpec, profile: string): readonly AgentRunProviderCredentialRef[] {
|
||||
const effectiveProfiles = new Set(
|
||||
agentRunProviderCredentialSecrets(spec, profile)
|
||||
.map((secret) => secret.providerCredentialProfile)
|
||||
.filter((value): value is string => value !== null),
|
||||
);
|
||||
if (effectiveProfiles.size !== 1) return [];
|
||||
return agentRunProviderCredentialRefs(spec, Array.from(effectiveProfiles)[0]);
|
||||
}
|
||||
|
||||
export function agentRunProviderCredentialSecrets(spec: AgentRunLaneSpec, profile?: string | null): readonly AgentRunLaneSecretSpec[] {
|
||||
return spec.secrets.filter((secret) => {
|
||||
const sourceProfile = secret.providerCredentialSourceProfile;
|
||||
const effectiveProfile = secret.providerCredentialProfile;
|
||||
if (sourceProfile === null || effectiveProfile === null) return false;
|
||||
return profile === undefined || profile === null || profile === sourceProfile || profile === effectiveProfile;
|
||||
});
|
||||
}
|
||||
|
||||
export function agentRunPipelineRunName(spec: AgentRunLaneSpec, sourceCommit: string): string {
|
||||
return `${spec.ci.pipelineRunPrefix}-${sourceCommit.slice(0, 12)}`;
|
||||
}
|
||||
@@ -1023,7 +1051,8 @@ function parseLaneSecret(input: Record<string, unknown>, path: string): AgentRun
|
||||
codexConfig: sourceMode === "codex-config" ? parseCodexConfig(recordField(input, "codexConfig", path), `${path}.codexConfig`) : null,
|
||||
jsonValue,
|
||||
targetRef: parseNamespacedSecretRef(recordField(input, "targetRef", path), `${path}.targetRef`),
|
||||
providerCredentialProfile: providerCredential,
|
||||
providerCredentialSourceProfile: providerCredential?.sourceProfile ?? null,
|
||||
providerCredentialProfile: providerCredential?.profile ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1031,6 +1060,8 @@ function parseLaneSecrets(input: Record<string, unknown>, path: string): AgentRu
|
||||
const secrets = arrayField(input, "secrets", path).map((secret, index) => parseLaneSecret(secret, `${path}.secrets[${index}]`));
|
||||
const idPaths = new Map<string, string>();
|
||||
const targetRefPaths = new Map<string, string>();
|
||||
const sourceProfileMappings = new Map<string, { profile: string; path: string }>();
|
||||
const effectiveProfileMappings = new Map<string, { sourceProfile: string; path: string }>();
|
||||
for (const [index, secret] of secrets.entries()) {
|
||||
const secretPath = `${path}.secrets[${index}]`;
|
||||
const previousIdPath = idPaths.get(secret.id);
|
||||
@@ -1044,6 +1075,20 @@ function parseLaneSecrets(input: Record<string, unknown>, path: string): AgentRu
|
||||
throw new Error(`${secretPath}.targetRef duplicates ${previousTargetRefPath}.targetRef (${targetRef})`);
|
||||
}
|
||||
targetRefPaths.set(targetRef, secretPath);
|
||||
if (secret.providerCredentialSourceProfile !== null && secret.providerCredentialProfile !== null) {
|
||||
const sourceProfile = secret.providerCredentialSourceProfile;
|
||||
const effectiveProfile = secret.providerCredentialProfile;
|
||||
const priorEffective = sourceProfileMappings.get(sourceProfile);
|
||||
if (priorEffective !== undefined && priorEffective.profile !== effectiveProfile) {
|
||||
throw new Error(`${secretPath}.providerCredential maps sourceProfile ${sourceProfile} to ${effectiveProfile}, conflicting with ${priorEffective.path}.providerCredential.profile=${priorEffective.profile}`);
|
||||
}
|
||||
sourceProfileMappings.set(sourceProfile, { profile: effectiveProfile, path: secretPath });
|
||||
const priorSource = effectiveProfileMappings.get(effectiveProfile);
|
||||
if (priorSource !== undefined && priorSource.sourceProfile !== sourceProfile) {
|
||||
throw new Error(`${secretPath}.providerCredential maps effective profile ${effectiveProfile} from ${sourceProfile}, conflicting with ${priorSource.path}.providerCredential.sourceProfile=${priorSource.sourceProfile}`);
|
||||
}
|
||||
effectiveProfileMappings.set(effectiveProfile, { sourceProfile, path: secretPath });
|
||||
}
|
||||
}
|
||||
return secrets;
|
||||
}
|
||||
@@ -1207,13 +1252,15 @@ function integerRecord(value: unknown, path: string): Readonly<Record<string, nu
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseProviderCredentialBinding(input: Record<string, unknown>, path: string): string | null {
|
||||
function parseProviderCredentialBinding(input: Record<string, unknown>, path: string): { sourceProfile: string; profile: 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;
|
||||
const sourceProfile = optionalStringField(record, "sourceProfile", path) ?? profile;
|
||||
validateSimpleId(sourceProfile, `${path}.sourceProfile`);
|
||||
validateAgentRunBackendProfile(profile, `${path}.profile`);
|
||||
return { sourceProfile, profile };
|
||||
}
|
||||
|
||||
function parseGitMirrorRepositories(input: readonly Record<string, unknown>[], path: string): readonly AgentRunGitMirrorRepositorySpec[] {
|
||||
@@ -1518,6 +1565,12 @@ function validateSimpleId(value: string, path: string): void {
|
||||
if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${path}.${value} must use a simple id`);
|
||||
}
|
||||
|
||||
function validateAgentRunBackendProfile(value: string, path: string): void {
|
||||
if (!/^[a-z0-9][a-z0-9-]{0,63}$/u.test(value)) {
|
||||
throw new Error(`${path} must match the canonical AgentRun backend profile slug /^[a-z0-9][a-z0-9-]{0,63}$/`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateKubernetesNamePrefix(value: string, path: string): void {
|
||||
if (!/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/u.test(value)) throw new Error(`${path} must be a Kubernetes name prefix`);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ function blockedFixture(): Record<string, unknown> {
|
||||
secret: index >= 11 ? "agentrun-v01-tool-github-ssh" : `fixture-target-${index}`,
|
||||
key: index === 11 ? "id_ed25519" : index === 12 ? "known_hosts" : `key-${index}`,
|
||||
sourceMode: "file",
|
||||
sourceProfile: index === 0 ? "gpt.pika" : null,
|
||||
effectiveBackendProfile: index === 0 ? "gpt-pika" : null,
|
||||
sourcePath: "/root/.unidesk/.state/secrets/<redacted>",
|
||||
present: true,
|
||||
sourceFilePresent: true,
|
||||
@@ -162,6 +164,7 @@ describe("AgentRun secret-sync bounded output", () => {
|
||||
expect(json.plan.omittedCount).toBe(0);
|
||||
expect(json.plan.items.some((item: Record<string, unknown>) => item.id === "tool-github-ssh-private-key")).toBe(true);
|
||||
expect(json.plan.items.some((item: Record<string, unknown>) => item.id === "tool-github-ssh-known-hosts")).toBe(true);
|
||||
expect(json.plan.items[0].profile).toEqual({ sourceProfile: "gpt.pika", effectiveBackendProfile: "gpt-pika" });
|
||||
expect(json.activationFence).toMatchObject({
|
||||
kind: "ProviderSecretActivationFence",
|
||||
identity: "providersecretactivationfence/NC01/nc01-v02",
|
||||
@@ -186,6 +189,8 @@ describe("AgentRun secret-sync bounded output", () => {
|
||||
expect(human).toContain("ACTIVATION FENCE");
|
||||
expect(human).toContain("runnerjob/rjob_fixture");
|
||||
expect(human).toContain("tool-github-ssh-private-key");
|
||||
expect(human).toContain("gpt.pika");
|
||||
expect(human).toContain("gpt-pika");
|
||||
expect(human).toContain("VALUES PRINTED false");
|
||||
for (const output of [human, jsonText, yamlText]) {
|
||||
expect(Buffer.byteLength(output, "utf8")).toBeLessThan(10_240);
|
||||
|
||||
+383
-13
@@ -1,7 +1,7 @@
|
||||
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 { agentRunProviderCredentialRefs, agentRunProviderCredentialRefsForSelection, resolveAgentRunLaneTarget } from "./agentrun-lanes";
|
||||
import { agentRunToolCredentialRefs } from "./agentrun/config";
|
||||
import { placeholderAgentRunImage, renderAgentRunGitopsFiles } from "./agentrun-manifests";
|
||||
import { collectLaneSecretSources, secretSyncScript } from "./agentrun/secrets";
|
||||
@@ -979,6 +979,60 @@ describe("AgentRun default transport contract", () => {
|
||||
});
|
||||
|
||||
describe("AgentRun YAML tool credential binding", () => {
|
||||
test("maps the YAML source profile to one canonical AgentRun backend profile", async () => {
|
||||
const { spec } = resolveAgentRunLaneTarget({ node: "NC01", lane: "nc01-v02" });
|
||||
const pikaSecrets = spec.secrets.filter((secret) => secret.providerCredentialSourceProfile === "gpt.pika");
|
||||
expect(pikaSecrets.map((secret) => ({
|
||||
id: secret.id,
|
||||
sourceMode: secret.sourceMode,
|
||||
sourceRef: secret.sourceRef,
|
||||
sourceProfile: secret.providerCredentialSourceProfile,
|
||||
effectiveBackendProfile: secret.providerCredentialProfile,
|
||||
targetRef: secret.targetRef,
|
||||
}))).toEqual([
|
||||
{
|
||||
id: "provider-gpt-pika-auth-json",
|
||||
sourceMode: "file",
|
||||
sourceRef: "/root/.codex/auth.json.pika",
|
||||
sourceProfile: "gpt.pika",
|
||||
effectiveBackendProfile: "gpt-pika",
|
||||
targetRef: { namespace: "agentrun-v02", name: "agentrun-v02-provider-gpt-pika", key: "auth.json" },
|
||||
},
|
||||
{
|
||||
id: "provider-gpt-pika-config",
|
||||
sourceMode: "file",
|
||||
sourceRef: "/root/.codex/config.toml.pika",
|
||||
sourceProfile: "gpt.pika",
|
||||
effectiveBackendProfile: "gpt-pika",
|
||||
targetRef: { namespace: "agentrun-v02", name: "agentrun-v02-provider-gpt-pika", key: "config.toml" },
|
||||
},
|
||||
]);
|
||||
const expected = [{
|
||||
sourceProfile: "gpt.pika",
|
||||
profile: "gpt-pika",
|
||||
secretRef: {
|
||||
namespace: "agentrun-v02",
|
||||
name: "agentrun-v02-provider-gpt-pika",
|
||||
keys: ["auth.json", "config.toml"],
|
||||
},
|
||||
}];
|
||||
expect(agentRunProviderCredentialRefsForSelection(spec, "gpt.pika")).toEqual(expected);
|
||||
expect(agentRunProviderCredentialRefs(spec, "gpt-pika")).toEqual(expected);
|
||||
expect(agentRunProviderCredentialRefs(spec, "gpt.pika")).toEqual([]);
|
||||
|
||||
const invalidPath = `/tmp/unidesk-agentrun-invalid-profile-${process.pid}-${Date.now()}.yaml`;
|
||||
try {
|
||||
const invalid = readFileSync("config/agentrun.yaml", "utf8").replaceAll(" profile: gpt-pika", " profile: gpt.pika");
|
||||
await Bun.write(invalidPath, invalid);
|
||||
expect(() => resolveAgentRunLaneTarget(
|
||||
{ node: "NC01", lane: "nc01-v02" },
|
||||
{ ...process.env, AGENTRUN_CONTROL_PLANE_CONFIG: invalidPath },
|
||||
)).toThrow("canonical AgentRun backend profile slug");
|
||||
} finally {
|
||||
rmSync(invalidPath, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
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");
|
||||
@@ -1020,6 +1074,290 @@ describe("AgentRun YAML tool credential binding", () => {
|
||||
expect(JSON.stringify({ githubSsh, sources })).not.toContain("/root/.ssh");
|
||||
});
|
||||
|
||||
test("shares model overrides across Aipod render, create, and send without overriding provider authority", async () => {
|
||||
const renderBodies: Record<string, any>[] = [];
|
||||
const submittedBodies: Record<string, any>[] = [];
|
||||
const sendBodies: Record<string, any>[] = [];
|
||||
let renderedBackendProfile = "gpt-pika";
|
||||
let renderedCredentialProfile = "gpt-pika";
|
||||
let renderedSecretName = "agentrun-v02-provider-gpt-pika";
|
||||
let resolutionModelOverride: string | null = null;
|
||||
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") {
|
||||
const input = await request.json() as Record<string, any>;
|
||||
renderBodies.push(input);
|
||||
const model = input.model ?? "gpt-5.6-terra";
|
||||
const reasoningEffort = input.reasoningEffort ?? "xhigh";
|
||||
const modelSource = input.model === undefined ? "aipod-spec-default" : "render-input-override";
|
||||
const reasoningEffortSource = input.reasoningEffort === undefined ? "aipod-spec-default" : "render-input-override";
|
||||
return Response.json({
|
||||
ok: true,
|
||||
data: {
|
||||
queueTask: {
|
||||
tenantId: "unidesk",
|
||||
projectId: "pikasTech/unidesk",
|
||||
queue: "commander",
|
||||
lane: "v0.2",
|
||||
providerId: "NC01",
|
||||
backendProfile: renderedBackendProfile,
|
||||
workspaceRef: input.workspaceRef ?? { kind: "opaque", path: "." },
|
||||
executionPolicy: {
|
||||
sandbox: "workspace-write",
|
||||
approval: "never",
|
||||
timeoutMs: 1_800_000,
|
||||
network: "enabled",
|
||||
secretScope: {
|
||||
allowCredentialEcho: false,
|
||||
providerCredentials: [{
|
||||
profile: renderedCredentialProfile,
|
||||
secretRef: {
|
||||
namespace: "agentrun-v02",
|
||||
name: renderedSecretName,
|
||||
keys: ["auth.json", "config.toml"],
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
payload: {
|
||||
prompt: input.prompt ?? "fixture",
|
||||
model,
|
||||
reasoningEffort,
|
||||
modelConfig: { model, reasoningEffort, modelSource, reasoningEffortSource, valuesPrinted: false },
|
||||
},
|
||||
},
|
||||
dispatchDefaults: {
|
||||
runnerJob: { namespace: "agentrun-v02" },
|
||||
},
|
||||
modelResolution: {
|
||||
model: resolutionModelOverride ?? model,
|
||||
reasoningEffort,
|
||||
modelSource,
|
||||
reasoningEffortSource,
|
||||
modelOverridden: input.model !== undefined,
|
||||
reasoningEffortOverridden: input.reasoningEffort !== undefined,
|
||||
backendProfile: "gpt-pika",
|
||||
backendProfileSource: "aipod-spec",
|
||||
providerCredential: {
|
||||
profile: "gpt-pika",
|
||||
secretRef: {
|
||||
namespace: "agentrun-v02",
|
||||
name: "agentrun-v02-provider-gpt-pika",
|
||||
keys: ["auth.json", "config.toml"],
|
||||
valuesPrinted: false,
|
||||
},
|
||||
valuesPrinted: false,
|
||||
},
|
||||
valuesPrinted: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/api/v1/queue/tasks") {
|
||||
submittedBodies.push(await request.json() as Record<string, any>);
|
||||
return Response.json({ ok: true, data: { id: "qt_gpt_pika", state: "pending" } });
|
||||
}
|
||||
if (url.pathname === "/api/v1/sessions/ses_gpt_pika/send") {
|
||||
const body = await request.json() as Record<string, any>;
|
||||
sendBodies.push(body);
|
||||
return Response.json({
|
||||
ok: true,
|
||||
data: {
|
||||
action: "session-send-plan",
|
||||
dryRun: body.dryRun,
|
||||
mutation: false,
|
||||
sessionId: "ses_gpt_pika",
|
||||
decision: "turn",
|
||||
internalCommandType: "turn",
|
||||
request: { method: "POST", path: url.pathname, createRunnerJob: true, valuesPrinted: false },
|
||||
valuesPrinted: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
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-gpt-pika-${process.pid}-${Date.now()}.yaml`;
|
||||
const tempInputPath = `/tmp/unidesk-agentrun-gpt-pika-input-${process.pid}-${Date.now()}.json`;
|
||||
try {
|
||||
process.env.AGENTRUN_CLIENT_CONFIG = tempConfigPath;
|
||||
await Bun.write(tempConfigPath, agentRunMinimalClientYaml.replace("http://agentrun.example.local:8080", server.url.href.replace(/\/$/u, "")));
|
||||
|
||||
const defaultPlan = JSON.parse(renderedTextOf(await runAgentRunCommand(null, [
|
||||
"create", "task", "--aipod", "Artificer", "--prompt", "default fixture", "--dry-run", "-o", "json",
|
||||
]))) as Record<string, any>;
|
||||
expect(defaultPlan.aipodBinding).toMatchObject({
|
||||
sourceProfile: "gpt.pika",
|
||||
effectiveBackendProfile: "gpt-pika",
|
||||
backendProfileSource: "aipod-spec",
|
||||
model: "gpt-5.6-terra",
|
||||
reasoningEffort: "xhigh",
|
||||
modelSource: "aipod-spec-default",
|
||||
reasoningEffortSource: "aipod-spec-default",
|
||||
modelOverridden: false,
|
||||
reasoningEffortOverridden: false,
|
||||
valuesPrinted: false,
|
||||
});
|
||||
expect(defaultPlan.aipodBinding.providerCredentials).toEqual([{
|
||||
profile: "gpt-pika",
|
||||
secretRef: { name: "agentrun-v02-provider-gpt-pika", keys: ["auth.json", "config.toml"] },
|
||||
valuesPrinted: false,
|
||||
}]);
|
||||
expect(renderBodies[0]).toEqual({ prompt: "default fixture" });
|
||||
|
||||
const override = await runAgentRunCommand(null, [
|
||||
"create", "task", "--aipod", "Artificer", "--prompt", "override fixture",
|
||||
"--model", "gpt-5.6-terra-alt", "--reasoning-effort", "xhigh", "-o", "json",
|
||||
]);
|
||||
expect(override.ok).toBe(true);
|
||||
expect(renderBodies[1]).toMatchObject({
|
||||
prompt: "override fixture",
|
||||
model: "gpt-5.6-terra-alt",
|
||||
reasoningEffort: "xhigh",
|
||||
});
|
||||
expect(renderBodies[1]?.backendProfile).toBeUndefined();
|
||||
expect(renderBodies[1]?.executionPolicy).toBeUndefined();
|
||||
expect(submittedBodies[0]).toMatchObject({
|
||||
backendProfile: "gpt-pika",
|
||||
payload: { model: "gpt-5.6-terra-alt", reasoningEffort: "xhigh" },
|
||||
executionPolicy: {
|
||||
secretScope: {
|
||||
providerCredentials: [{
|
||||
profile: "gpt-pika",
|
||||
secretRef: { name: "agentrun-v02-provider-gpt-pika", keys: ["auth.json", "config.toml"] },
|
||||
}],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const send = await runAgentRunCommand(null, [
|
||||
"send", "session/ses_gpt_pika", "--aipod", "Artificer", "--prompt", "send fixture",
|
||||
"--model", "gpt-5.6-terra-send", "--reasoning-effort", "medium", "--dry-run", "-o", "json",
|
||||
]);
|
||||
expect(send.ok).toBe(true);
|
||||
const sendProjection = JSON.parse(renderedTextOf(send)) as Record<string, any>;
|
||||
expect(sendProjection.aipodBinding).toMatchObject({
|
||||
sourceProfile: "gpt.pika",
|
||||
effectiveBackendProfile: "gpt-pika",
|
||||
model: "gpt-5.6-terra-send",
|
||||
reasoningEffort: "medium",
|
||||
modelSource: "render-input-override",
|
||||
reasoningEffortSource: "render-input-override",
|
||||
modelOverridden: true,
|
||||
reasoningEffortOverridden: true,
|
||||
valuesPrinted: false,
|
||||
});
|
||||
expect(renderBodies[2]).toMatchObject({ model: "gpt-5.6-terra-send", reasoningEffort: "medium" });
|
||||
expect(sendBodies[0]).toMatchObject({
|
||||
dryRun: true,
|
||||
run: { backendProfile: "gpt-pika" },
|
||||
payload: { model: "gpt-5.6-terra-send", reasoningEffort: "medium" },
|
||||
});
|
||||
|
||||
const render = await runAgentRunCommand(null, [
|
||||
"aipod-specs", "render", "Artificer", "--model", "gpt-5.6-terra-render", "--reasoning-effort", "low", "-o", "json",
|
||||
]);
|
||||
expect(render.ok).toBe(true);
|
||||
expect(renderBodies[3]).toEqual({ model: "gpt-5.6-terra-render", reasoningEffort: "low" });
|
||||
|
||||
const createHuman = renderedTextOf(await runAgentRunCommand(null, [
|
||||
"create", "task", "--aipod", "Artificer", "--prompt", "human create", "--dry-run",
|
||||
]));
|
||||
const sendHuman = renderedTextOf(await runAgentRunCommand(null, [
|
||||
"send", "session/ses_gpt_pika", "--aipod", "Artificer", "--prompt", "human send",
|
||||
"--model", "gpt-5.6-terra-human", "--reasoning-effort", "medium", "--dry-run",
|
||||
]));
|
||||
const renderHuman = renderedTextOf(await runAgentRunCommand(null, [
|
||||
"aipod-specs", "render", "Artificer", "--model", "gpt-5.6-terra-human", "--reasoning-effort", "medium",
|
||||
]));
|
||||
for (const human of [createHuman, sendHuman, renderHuman]) {
|
||||
expect(human).toContain("sourceProfile=gpt.pika");
|
||||
expect(human).toContain("effectiveBackendProfile=gpt-pika");
|
||||
expect(human).toContain("model=");
|
||||
expect(human).toContain("reasoningEffort=");
|
||||
expect(human).toContain("valuesPrinted=false");
|
||||
expect(human).not.toContain("secret-value");
|
||||
}
|
||||
expect(createHuman).toContain("modelSource=aipod-spec-default");
|
||||
expect(sendHuman).toContain("modelSource=render-input-override");
|
||||
expect(renderHuman).toContain("modelSource=render-input-override");
|
||||
|
||||
for (const helpArgs of [["create", "--help"], ["send", "--help"], ["aipod-specs", "render", "--help"]]) {
|
||||
const help = renderedTextOf(await runAgentRunCommand(null, helpArgs));
|
||||
expect(help).toContain("--model <name>");
|
||||
expect(help).toContain("--reasoning-effort <level>");
|
||||
}
|
||||
|
||||
resolutionModelOverride = "gpt-5.6-terra-conflict";
|
||||
const resolutionRejected = await runAgentRunCommand(null, [
|
||||
"create", "task", "--aipod", "Artificer", "--prompt", "resolution conflict", "-o", "json",
|
||||
]);
|
||||
expect(resolutionRejected.ok).toBe(false);
|
||||
expect(renderedTextOf(resolutionRejected)).toContain("queueTask.payload.model must match modelResolution.model");
|
||||
expect(submittedBodies).toHaveLength(1);
|
||||
resolutionModelOverride = null;
|
||||
|
||||
const requestCountBeforeRejections = renderBodies.length;
|
||||
for (const forbidden of [
|
||||
["--backend-profile", "gpt-pika"],
|
||||
["--profile", "gpt-pika"],
|
||||
["--execution-policy-json", "{}"],
|
||||
]) {
|
||||
const rejected = await runAgentRunCommand(null, [
|
||||
"create", "task", "--aipod", "Artificer", "--prompt", "rejected", ...forbidden, "-o", "json",
|
||||
]);
|
||||
expect(rejected.ok).toBe(false);
|
||||
expect(renderedTextOf(rejected)).toContain("AipodSpec owns backendProfile");
|
||||
}
|
||||
await Bun.write(tempInputPath, JSON.stringify({ payload: { prompt: "nested", model: "gpt-5.6-terra-nested" } }));
|
||||
const nestedRejected = await runAgentRunCommand(null, [
|
||||
"create", "task", "--aipod", "Artificer", "--json-file", tempInputPath, "-o", "json",
|
||||
]);
|
||||
expect(nestedRejected.ok).toBe(false);
|
||||
expect(renderedTextOf(nestedRejected)).toContain("payload.model");
|
||||
await Bun.write(tempInputPath, JSON.stringify({ modelConfig: { model: "gpt-5.6-terra-nested" } }));
|
||||
const modelConfigRejected = await runAgentRunCommand(null, [
|
||||
"create", "task", "--aipod", "Artificer", "--json-file", tempInputPath, "-o", "json",
|
||||
]);
|
||||
expect(modelConfigRejected.ok).toBe(false);
|
||||
expect(renderedTextOf(modelConfigRejected)).toContain("modelConfig");
|
||||
expect(renderBodies).toHaveLength(requestCountBeforeRejections);
|
||||
|
||||
renderedBackendProfile = "gpt.pika";
|
||||
renderedCredentialProfile = "gpt.pika";
|
||||
const dottedRejected = await runAgentRunCommand(null, [
|
||||
"create", "task", "--aipod", "Artificer", "--prompt", "dotted", "-o", "json",
|
||||
]);
|
||||
expect(dottedRejected.ok).toBe(false);
|
||||
expect(renderedTextOf(dottedRejected)).toContain("backendProfile=gpt.pika");
|
||||
expect(submittedBodies).toHaveLength(1);
|
||||
|
||||
renderedBackendProfile = "gpt-pika";
|
||||
renderedCredentialProfile = "gpt-pika";
|
||||
renderedSecretName = "agentrun-v01-provider-codex";
|
||||
const secretRejected = await runAgentRunCommand(null, [
|
||||
"create", "task", "--aipod", "Artificer", "--prompt", "wrong secret", "-o", "json",
|
||||
]);
|
||||
expect(secretRejected.ok).toBe(false);
|
||||
expect(renderedTextOf(secretRejected)).toContain("is not the unique YAML binding");
|
||||
expect(submittedBodies).toHaveLength(1);
|
||||
} 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 });
|
||||
rmSync(tempInputPath, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves explicit credential subsets across Aipod admission and rejects unknown identities or conflicts", async () => {
|
||||
const renderBodies: Record<string, any>[] = [];
|
||||
const submittedBodies: Record<string, any>[] = [];
|
||||
@@ -1081,8 +1419,40 @@ describe("AgentRun YAML tool credential binding", () => {
|
||||
toolCredentials: renderedToolCredentials(),
|
||||
},
|
||||
},
|
||||
payload: { prompt: "fixture" },
|
||||
payload: {
|
||||
prompt: "fixture",
|
||||
model: "gpt-5.5",
|
||||
reasoningEffort: "high",
|
||||
modelConfig: {
|
||||
model: "gpt-5.5",
|
||||
reasoningEffort: "high",
|
||||
modelSource: "aipod-spec-default",
|
||||
reasoningEffortSource: "aipod-spec-default",
|
||||
valuesPrinted: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
const modelResolution = {
|
||||
model: "gpt-5.5",
|
||||
reasoningEffort: "high",
|
||||
modelSource: "aipod-spec-default",
|
||||
reasoningEffortSource: "aipod-spec-default",
|
||||
modelOverridden: false,
|
||||
reasoningEffortOverridden: false,
|
||||
backendProfile: "codex",
|
||||
backendProfileSource: "aipod-spec",
|
||||
providerCredential: {
|
||||
profile: "codex",
|
||||
secretRef: {
|
||||
namespace: "agentrun-v02",
|
||||
name: "agentrun-v01-provider-codex",
|
||||
keys: ["auth.json", "config.toml"],
|
||||
valuesPrinted: false,
|
||||
},
|
||||
valuesPrinted: false,
|
||||
},
|
||||
valuesPrinted: false,
|
||||
};
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
@@ -1090,7 +1460,7 @@ describe("AgentRun YAML tool credential binding", () => {
|
||||
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() } });
|
||||
return Response.json({ ok: true, data: { queueTask: renderedTask(), modelResolution } });
|
||||
}
|
||||
if (url.pathname === "/api/v1/queue/tasks") {
|
||||
submittedBodies.push(await request.json() as Record<string, any>);
|
||||
@@ -1111,20 +1481,20 @@ 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<string, unknown>) => `${credential.tool}/${credential.purpose}`)).toEqual([
|
||||
expect(renderBodies[0]?.backendProfile).toBeUndefined();
|
||||
expect(renderBodies[0]?.executionPolicy).toBeUndefined();
|
||||
expect(submittedBodies[0]?.executionPolicy?.secretScope?.toolCredentials?.map((credential: Record<string, unknown>) => `${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<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" },
|
||||
}]);
|
||||
}
|
||||
const githubSsh = submittedBodies[0]?.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" },
|
||||
}]);
|
||||
|
||||
credentialMode = "subset";
|
||||
const subsetAccepted = await runAgentRunCommand(null, ["create", "task", "--aipod", "Artificer", "--prompt", "fixture", "-o", "json"]);
|
||||
|
||||
@@ -427,28 +427,45 @@ export function readYamlInputFromArgs(args: string[]): string {
|
||||
|
||||
export async function aipodRenderInputFromArgs(args: string[], trailingPromptStart: number, overrides: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
|
||||
const input = await optionalJsonBody(args);
|
||||
const prompt = optionalPromptFromArgs(args, trailingPromptStart);
|
||||
assertAipodRenderProviderAuthority(args, input);
|
||||
const prompt = optionalPromptFromArgs(args, args.length);
|
||||
if (prompt !== null) input.prompt = prompt;
|
||||
copyAgentRunOptions(args, input, ["tenant-id", "project-id", "queue", "node", "lane", "title", "provider-id", "idempotency-key", "session-id"]);
|
||||
const sessionPolicy = readAgentRunClientConfig().client.sessionPolicy;
|
||||
const policyTarget = resolveAgentRunSessionPolicyTarget({ node: stringOrNull(input.node), lane: stringOrNull(input.lane) });
|
||||
if (input.node === undefined) input.node = policyTarget.spec.nodeId;
|
||||
if (input.lane === undefined) input.lane = policyTarget.spec.lane;
|
||||
if (input.providerId === undefined) input.providerId = defaultAgentRunProviderId(sessionPolicy, policyTarget);
|
||||
const profile = agentRunOption(args, "profile") ?? agentRunOption(args, "backend-profile");
|
||||
const backendProfile = profile ?? stringOrNull(input.backendProfile) ?? sessionPolicy.backendProfile;
|
||||
input.backendProfile = backendProfile;
|
||||
if (!isRecord(input.workspaceRef)) input.workspaceRef = cloneJsonRecord(sessionPolicy.workspaceRef);
|
||||
if (!isRecord(input.executionPolicy)) {
|
||||
input.executionPolicy = defaultAgentRunExecutionPolicy(backendProfile, sessionPolicy, policyTarget, { includeToolCredentials: true });
|
||||
}
|
||||
copyAgentRunOptions(args, input, ["tenant-id", "project-id", "queue", "node", "lane", "title", "provider-id", "idempotency-key", "session-id", "model", "reasoning-effort"]);
|
||||
const priority = agentRunOption(args, "priority");
|
||||
if (priority) input.priority = Number(priority);
|
||||
const workspaceRef = jsonObjectOption(args, "workspace-json");
|
||||
if (workspaceRef !== null) input.workspaceRef = workspaceRef;
|
||||
void trailingPromptStart;
|
||||
return { ...input, ...overrides };
|
||||
}
|
||||
|
||||
function assertAipodRenderProviderAuthority(args: string[], input: Record<string, unknown>): void {
|
||||
const forbiddenFlags = ["profile", "backend-profile", "execution-policy-json"]
|
||||
.filter((flag) => agentRunHasFlag(args, flag))
|
||||
.map((flag) => `--${flag}`);
|
||||
if (forbiddenFlags.length > 0) {
|
||||
throw new AgentRunRestError(
|
||||
"validation-failed",
|
||||
`AipodSpec owns backendProfile, provider credential SecretRef, and executionPolicy; remove ${forbiddenFlags.join(", ")} and use only --model/--reasoning-effort for task-level model selection`,
|
||||
);
|
||||
}
|
||||
const forbiddenTopLevel = ["profile", "backendProfile", "modelConfig", "executionPolicy", "providerCredentials", "providerCredential", "secretRef"]
|
||||
.filter((key) => Object.hasOwn(input, key));
|
||||
const payload = record(input.payload);
|
||||
const forbiddenPayload = ["model", "reasoningEffort", "modelConfig", "backendProfile", "executionPolicy", "providerCredentials", "providerCredential", "secretRef"]
|
||||
.filter((key) => Object.hasOwn(payload, key));
|
||||
if (forbiddenTopLevel.length > 0 || forbiddenPayload.length > 0) {
|
||||
const fields = [
|
||||
...forbiddenTopLevel,
|
||||
...forbiddenPayload.map((key) => `payload.${key}`),
|
||||
];
|
||||
throw new AgentRunRestError(
|
||||
"validation-failed",
|
||||
`Aipod render input cannot override provider authority or place model overrides in payload: ${fields.join(", ")}; use top-level model/reasoningEffort`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function readPromptFromArgs(args: string[], trailingStart: number): string {
|
||||
const prompt = optionalPromptFromArgs(args, trailingStart);
|
||||
if (prompt === null) throw new AgentRunRestError("validation-failed", "prompt is required; use --prompt-stdin, --prompt-file, --prompt, or a trailing prompt");
|
||||
|
||||
@@ -68,9 +68,9 @@ export function agentRunHelp(): unknown {
|
||||
"bun scripts/cli.ts agentrun retry task/<taskId> --idempotency-key <key> --reason <text> --dry-run",
|
||||
"bun scripts/cli.ts agentrun get attempts --task <taskId> --limit 20",
|
||||
"bun scripts/cli.ts agentrun dispatch task/<taskId>",
|
||||
"bun scripts/cli.ts agentrun create task --aipod Artificer --prompt-stdin --idempotency-key <key>",
|
||||
"bun scripts/cli.ts agentrun create task --aipod Artificer --prompt-stdin --model <name> --reasoning-effort <level> --idempotency-key <key>",
|
||||
"bun scripts/cli.ts agentrun apply -f - --dry-run",
|
||||
"bun scripts/cli.ts agentrun send session/<sessionId> --aipod Artificer --prompt-stdin",
|
||||
"bun scripts/cli.ts agentrun send session/<sessionId> --aipod Artificer --prompt-stdin --model <name> --reasoning-effort <level>",
|
||||
"bun scripts/cli.ts agentrun explain task",
|
||||
"bun scripts/cli.ts agentrun explain session-policy",
|
||||
"bun scripts/cli.ts agentrun control-plane plan --node NC01 --lane nc01-v02",
|
||||
@@ -316,13 +316,21 @@ export function agentRunHelpText(args: string[]): string {
|
||||
return "Usage: bun scripts/cli.ts agentrun dispatch task/<taskId>";
|
||||
}
|
||||
if (verb === "create") {
|
||||
return "Usage: bun scripts/cli.ts agentrun create task --aipod Artificer --prompt-stdin [--idempotency-key <key>] [--dry-run]";
|
||||
return [
|
||||
"Usage: bun scripts/cli.ts agentrun create task --aipod Artificer --prompt-stdin [--model <name>] [--reasoning-effort <level>] [--idempotency-key <key>] [--dry-run]",
|
||||
"",
|
||||
"AipodSpec owns provider credentials and execution policy. Task-level model/reasoning overrides keep the YAML sourceProfile and canonical effectiveBackendProfile fixed.",
|
||||
].join("\n");
|
||||
}
|
||||
if (verb === "apply") {
|
||||
return "Usage: bun scripts/cli.ts agentrun apply -f task.yaml|json|- [--dry-run]\nTask manifests use kind: Task and spec: <AgentRun task payload>.";
|
||||
}
|
||||
if (verb === "send") {
|
||||
return "Usage: bun scripts/cli.ts agentrun send session/<sessionId> --aipod Artificer --prompt-stdin\nThe server decides whether this becomes an internal steer or a new turn from session state.";
|
||||
return [
|
||||
"Usage: bun scripts/cli.ts agentrun send session/<sessionId> --aipod Artificer --prompt-stdin [--model <name>] [--reasoning-effort <level>] [--dry-run]",
|
||||
"",
|
||||
"AipodSpec owns provider credentials and execution policy. The server decides whether this becomes an internal steer or a new turn from session state.",
|
||||
].join("\n");
|
||||
}
|
||||
if (verb === "explain") return agentRunExplain(kind ?? "task");
|
||||
if (verb === "control-plane") {
|
||||
@@ -382,6 +390,13 @@ export function agentRunHelpText(args: string[]): string {
|
||||
].join("\n");
|
||||
}
|
||||
if (verb !== undefined && isAgentRunRestCompatGroup(verb)) {
|
||||
if ((verb === "aipod-specs" || verb === "aipods") && kind === "render") {
|
||||
return [
|
||||
`Usage: bun scripts/cli.ts agentrun ${verb} render <aipod> [--model <name>] [--reasoning-effort <level>] [-o json|yaml] [--full|--raw]`,
|
||||
"",
|
||||
"Human output shows sourceProfile, effectiveBackendProfile, model/reasoning resolution, and SecretRef-only provider binding with valuesPrinted=false.",
|
||||
].join("\n");
|
||||
}
|
||||
return [
|
||||
`Compatibility group: agentrun ${verb} ...`,
|
||||
"",
|
||||
@@ -411,7 +426,7 @@ export function agentRunHelpText(args: string[]): string {
|
||||
" bun scripts/cli.ts agentrun logs session/<sessionId> --tail 100",
|
||||
" bun scripts/cli.ts agentrun retry task/<taskId> --idempotency-key <key> --reason <text> --dry-run",
|
||||
" bun scripts/cli.ts agentrun get attempts --task <taskId> --limit 20",
|
||||
" bun scripts/cli.ts agentrun create task --aipod Artificer --prompt-stdin",
|
||||
" bun scripts/cli.ts agentrun create task --aipod Artificer --prompt-stdin --model <name> --reasoning-effort <level>",
|
||||
"",
|
||||
"Machine/debug output:",
|
||||
" -o json|yaml emits a stable UniDesk resource object without the global JSON envelope.",
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
import {
|
||||
agentRunLaneSummary,
|
||||
agentRunProviderCredentialRefs,
|
||||
agentRunProviderCredentialRefsForSelection,
|
||||
agentRunProviderCredentialSecrets,
|
||||
resolveAgentRunLaneTarget,
|
||||
type AgentRunLaneSecretSpec,
|
||||
type AgentRunLaneSpec,
|
||||
@@ -106,6 +108,9 @@ export function providerProfileHelp(): string {
|
||||
" bun scripts/cli.ts job status <jobId> --tail-bytes 12000",
|
||||
" bun scripts/cli.ts agentrun provider-profile validate --node NC01 --lane nc01-v02 --profile gpt.pika --wait",
|
||||
"",
|
||||
"Profile identity:",
|
||||
" --profile accepts the YAML source selector or canonical AgentRun backend slug. Output always discloses sourceProfile and effectiveBackendProfile separately.",
|
||||
"",
|
||||
"Boundary:",
|
||||
" confirmed apply returns an async job only after the provider Secret gate reports an exact data no-op; the worker skips the provider Secret write, publishes runtime GitOps using the current manager image, refreshes Argo and restarts the manager without rebuilding images or creating a PipelineRun.",
|
||||
].join("\n");
|
||||
@@ -128,7 +133,7 @@ async function providerProfileApplyAsync(config: UniDeskConfig, options: Provide
|
||||
const plan = providerProfilePlan({ ...options, confirm: false, dryRun: true });
|
||||
if (plan.ok !== true) return plan;
|
||||
const { spec, profile } = resolveProviderProfile(options);
|
||||
const secrets = spec.secrets.filter((secret) => secret.providerCredentialProfile === profile);
|
||||
const secrets = [...agentRunProviderCredentialSecrets(spec, profile)];
|
||||
const activationTargets = providerProfileActivationTargets(inspectProviderProfileSources(spec, secrets));
|
||||
const activationPreflight = await providerSecretActivationPreflight(config, spec, activationTargets, { full: options.full || options.raw });
|
||||
if (activationPreflight.ok !== true) {
|
||||
@@ -201,9 +206,9 @@ async function providerProfileRuntimeStatus(config: UniDeskConfig, options: Prov
|
||||
function resolveProviderProfile(options: ProviderProfileOptions): { configPath: string; spec: AgentRunLaneSpec; profile: string; secrets: AgentRunLaneSecretSpec[] } {
|
||||
if (options.profile === null) throw new Error("provider-profile requires --profile");
|
||||
const target = resolveAgentRunLaneTarget(options);
|
||||
const secrets = target.spec.secrets.filter((secret) => secret.providerCredentialProfile === options.profile);
|
||||
const secrets = [...agentRunProviderCredentialSecrets(target.spec, options.profile)];
|
||||
if (secrets.length === 0) {
|
||||
const declared = agentRunProviderCredentialRefs(target.spec).map((item) => item.profile);
|
||||
const declared = agentRunProviderCredentialRefs(target.spec).map((item) => `${item.sourceProfile}->${item.profile}`);
|
||||
throw new Error(`provider profile ${options.profile} is not declared in ${target.configPath}; declared profiles: ${declared.join(", ") || "(none)"}`);
|
||||
}
|
||||
return { ...target, profile: options.profile, secrets };
|
||||
@@ -759,11 +764,11 @@ async function publishProfileGitops(config: UniDeskConfig, spec: AgentRunLaneSpe
|
||||
}
|
||||
|
||||
function providerProfileSummary(spec: AgentRunLaneSpec, profile: string, secrets: readonly AgentRunLaneSecretSpec[]): Record<string, unknown> {
|
||||
const credential = agentRunProviderCredentialRefs(spec, profile)[0] ?? null;
|
||||
const credential = agentRunProviderCredentialRefsForSelection(spec, profile)[0] ?? null;
|
||||
return {
|
||||
profile,
|
||||
sourceProfile: credential?.sourceProfile ?? profile,
|
||||
effectiveBackendProfile: credential?.profile ?? null,
|
||||
declared: true,
|
||||
backendProfile: profile === "gpt.pika" ? "gpt-pika" : profile,
|
||||
secretIds: secrets.map((secret) => secret.id),
|
||||
secretRef: credential?.secretRef ?? null,
|
||||
configuredKeys: credential?.secretRef.keys ?? secrets.map((secret) => secret.targetRef.key),
|
||||
|
||||
@@ -278,8 +278,9 @@ export function renderAgentRunControlPlaneActionSummary(result: Record<string, u
|
||||
lines.push(
|
||||
"",
|
||||
"PROVIDER CREDENTIALS",
|
||||
renderTable(["PROFILE", "NAMESPACE", "SECRET", "KEYS", "VALUES"], providerCredentials.slice(0, 8).map((credential) => [
|
||||
displayValue(credential.profile),
|
||||
renderTable(["SOURCE PROFILE", "BACKEND PROFILE", "NAMESPACE", "SECRET", "KEYS", "VALUES"], providerCredentials.slice(0, 8).map((credential) => [
|
||||
displayValue(credential.sourceProfile),
|
||||
displayValue(credential.effectiveBackendProfile),
|
||||
displayValue(credential.namespace),
|
||||
displayValue(credential.name),
|
||||
Array.isArray(credential.keys) ? credential.keys.map(String).join(",") : "-",
|
||||
|
||||
@@ -38,7 +38,7 @@ import { agentRunDryRunPlan, readAgentRunClientConfig } from "./config";
|
||||
import { status } from "./control-plane";
|
||||
import { arrayRecords, displayValue, isRecord, nextPagedResourceCommand, parseTaskManifest, pickCompact, relativeAge, renderFailureLines, renderResourceNextLines, renderTable, resourceName, shortId, stringOrDash, truncateMultiline, truncateOneLine } from "./options";
|
||||
import { activeAgentRunRestTarget, agentRunRestRequest, runAgentRunRestCommand } from "./rest-bridge";
|
||||
import { renderSessionSendResult } from "./session-send-render";
|
||||
import { renderAipodBindingHumanLines, renderSessionSendResult } from "./session-send-render";
|
||||
import { record, stringOrNull } from "./utils";
|
||||
|
||||
export function resolveAgentRunCancelPolicyTarget(config: UniDeskConfig | null, options: AgentRunResourceOptions): { configPath: string; spec: AgentRunLaneSpec; source: "selected-lane" | "default-lane" } | null {
|
||||
@@ -495,6 +495,7 @@ export function renderMutationSummary(command: string, raw: Record<string, unkno
|
||||
if (mutation !== undefined) lines.push(`Mutation: ${String(mutation)}`);
|
||||
if (decision !== null) lines.push(`Decision: ${decision}`);
|
||||
if (internalCommandType !== null) lines.push(`InternalCommandType: ${internalCommandType}`);
|
||||
lines.push(...renderAipodBindingHumanLines(record(data.aipodBinding ?? raw.aipodBinding)));
|
||||
lines.push(...renderCancelLifecycleMutationLines(record(data.cancelLifecycle ?? raw.cancelLifecycle)));
|
||||
const next = record(raw.next ?? data.next);
|
||||
const nextLines = (overrideNextLines ?? Object.values(next).map(String)).filter((line) => line.length > 0).slice(0, 5);
|
||||
|
||||
@@ -43,6 +43,7 @@ import { arrayRecords, displayValue, isRecord, pickCompact, renderTable, truncat
|
||||
import { innerData, pathValue, renderMachine, renderedCliResult } from "./render";
|
||||
import { parseResourceOptions, stripAgentRunResourceWrapperArgs } from "./resource-actions";
|
||||
import { AGENTRUN_GIT_MIRROR_RETRY_MAX_ATTEMPTS, agentRunGitMirrorRetryDelayMs, agentRunGitMirrorRetrySummary, agentRunGitMirrorRetryableFailure, createYamlLaneJobScript, yamlLaneGitMirrorJobManifest, yamlLaneGitMirrorStatusScript, yamlLaneJobProbeScript } from "./secrets";
|
||||
import { renderAipodBindingHumanLines } from "./session-send-render";
|
||||
import { capture, captureJsonPayload, compactCapture, nonNegativeIntegerOrNull, progressEvent, record, shQuote, sleep, stringOrNull } from "./utils";
|
||||
|
||||
export async function readGitMirrorStatus(config: UniDeskConfig, target: { configPath: string; spec: AgentRunLaneSpec }): Promise<Record<string, unknown> & { result: SshCaptureResult; raw: string; summary: Record<string, unknown> }> {
|
||||
@@ -285,10 +286,10 @@ export async function runAgentRunRestCompatCommand(config: UniDeskConfig | null,
|
||||
const options = parseAgentRunRestCompatOptions(args);
|
||||
const command = `agentrun ${canonicalArgs.join(" ")}`.trim();
|
||||
try {
|
||||
const raw = await withAgentRunRestTarget(resolveAgentRunRestTarget(config, options), async () => {
|
||||
return await runAgentRunRestCommand(config, group, stripAgentRunResourceWrapperArgs(args));
|
||||
return await withAgentRunRestTarget(resolveAgentRunRestTarget(config, options), async () => {
|
||||
const raw = await runAgentRunRestCommand(config, group, stripAgentRunResourceWrapperArgs(args));
|
||||
return renderAgentRunRestCompatResult(command, group, stripAgentRunResourceWrapperArgs(args), raw, options);
|
||||
});
|
||||
return renderAgentRunRestCompatResult(command, group, stripAgentRunResourceWrapperArgs(args), raw, options);
|
||||
} catch (error) {
|
||||
if (error instanceof AgentRunRestError) {
|
||||
if (options.raw || options.full || options.output === "json" || options.output === "yaml") {
|
||||
@@ -335,6 +336,7 @@ export function renderAgentRunAipodSpecRenderSummary(command: string, raw: Recor
|
||||
const namespace = stringOrNull(bridge.namespace) ?? stringOrNull(runnerJobDefaults.namespace) ?? "-";
|
||||
const providerCredentials = agentRunRenderedProviderCredentials(secretScope, aipod);
|
||||
const toolCredentials = agentRunRenderedToolCredentials(secretScope, aipod);
|
||||
const aipodBinding = agentRunAipodBindingDisclosure(task, requestedAipod, record(data.modelResolution));
|
||||
const bundleNames = agentRunResourceBundleNames(resourceBundle.bundles, "name");
|
||||
const skillNames = agentRunResourceBundleNames(resourceBundle.requiredSkills, "name");
|
||||
const targetArgs = agentRunCompatTargetCliArgs(node, lane);
|
||||
@@ -343,6 +345,7 @@ export function renderAgentRunAipodSpecRenderSummary(command: string, raw: Recor
|
||||
` AipodSpec: ${displayValue(aipod.name ?? requestedAipod)} hash=${displayValue(aipod.specHash)}`,
|
||||
` Target: node=${displayValue(node)} lane=${displayValue(lane)} namespace=${displayValue(namespace)} bridge=${displayValue(bridge.mode)}`,
|
||||
` TaskPolicy: backendProfile=${displayValue(task.backendProfile)} providerId=${displayValue(task.providerId)} queue=${displayValue(task.queue)} lane=${displayValue(task.lane)}`,
|
||||
...renderAipodBindingHumanLines(aipodBinding),
|
||||
` WorkspaceRef: ${agentRunCompactJson(task.workspaceRef, 140)}`,
|
||||
` Execution: ${agentRunCompactJson(pickCompact(executionPolicy, ["sandbox", "approval", "timeoutMs", "network"]), 160)}`,
|
||||
" Values: secret payloads are not printed; valuesPrinted=false",
|
||||
@@ -565,7 +568,16 @@ export async function runAgentRunRunnerRest(action: string | undefined, id: stri
|
||||
export async function runAgentRunAipodSpecsRest(action: string | undefined, id: string | undefined, args: string[]): Promise<Record<string, unknown>> {
|
||||
if (action === "list") return await agentRunRestRequest("agentrun aipod-specs list", "GET", "/api/v1/aipod-specs");
|
||||
if (action === "show" && id) return await agentRunRestRequest("agentrun aipod-specs show", "GET", `/api/v1/aipod-specs/${encodeURIComponent(id)}`);
|
||||
if (action === "render" && id) return await agentRunRestRequest("agentrun aipod-specs render", "POST", `/api/v1/aipod-specs/${encodeURIComponent(id)}/render`, await aipodRenderInputFromArgs(args, 2));
|
||||
if (action === "render" && id) {
|
||||
const renderInput = await aipodRenderInputFromArgs(args, 2);
|
||||
const rendered = await agentRunRestRequest("agentrun aipod-specs render", "POST", `/api/v1/aipod-specs/${encodeURIComponent(id)}/render`, renderInput);
|
||||
const renderedData = record(innerData(rendered));
|
||||
const task = record(renderedData.queueTask);
|
||||
if (Object.keys(task).length === 0) throw new AgentRunRestError("schema-mismatch", `aipod-spec ${id} render did not return queueTask`);
|
||||
normalizeAipodRenderedQueueTask(task, args, id);
|
||||
agentRunAipodBindingDisclosure(task, id, record(renderedData.modelResolution), renderInput);
|
||||
return rendered;
|
||||
}
|
||||
if (action === "apply" || action === "set") {
|
||||
const yaml = readYamlInputFromArgs(args);
|
||||
const pathValue = id ? `/api/v1/aipod-specs/${encodeURIComponent(id)}` : "/api/v1/aipod-specs";
|
||||
@@ -581,13 +593,16 @@ export async function runAgentRunAipodSpecsRest(action: string | undefined, id:
|
||||
export async function submitQueueTaskRest(args: string[]): Promise<Record<string, unknown>> {
|
||||
const aipod = agentRunOption(args, "aipod") ?? agentRunOption(args, "aipod-spec");
|
||||
if (aipod) {
|
||||
const rendered = await agentRunRestRequest("agentrun aipod-specs render", "POST", `/api/v1/aipod-specs/${encodeURIComponent(aipod)}/render`, await aipodRenderInputFromArgs(args, 2));
|
||||
const body = normalizeAipodRenderedQueueTask(record(record(innerData(rendered)).queueTask), args, aipod);
|
||||
const renderInput = await aipodRenderInputFromArgs(args, 2);
|
||||
const rendered = await agentRunRestRequest("agentrun aipod-specs render", "POST", `/api/v1/aipod-specs/${encodeURIComponent(aipod)}/render`, renderInput);
|
||||
const renderedData = record(innerData(rendered));
|
||||
const body = normalizeAipodRenderedQueueTask(record(renderedData.queueTask), args, aipod);
|
||||
if (Object.keys(body).length === 0) throw new AgentRunRestError("schema-mismatch", `aipod-spec ${aipod} render did not return queueTask`);
|
||||
const aipodBinding = agentRunAipodBindingDisclosure(body, aipod, record(renderedData.modelResolution), renderInput);
|
||||
if (agentRunHasFlag(args, "dry-run")) {
|
||||
return agentRunDryRunPlan("queue-submit", "/api/v1/queue/tasks", body, queueSubmitConfirmCommand(args, aipod), "POST", {
|
||||
jsonInput: { source: "aipod-spec", aipod, valuesPrinted: false },
|
||||
aipodBinding: agentRunAipodBindingDisclosure(body, aipod),
|
||||
aipodBinding,
|
||||
});
|
||||
}
|
||||
return await agentRunRestRequest("agentrun queue submit", "POST", "/api/v1/queue/tasks", body);
|
||||
@@ -706,34 +721,80 @@ export function normalizeAipodRenderedQueueTask(task: Record<string, unknown>, a
|
||||
const sessionPolicy = readAgentRunClientConfig().client.sessionPolicy;
|
||||
const policyTarget = resolveAgentRunSessionPolicyTarget();
|
||||
const explicitProfile = agentRunOption(args, "profile") ?? agentRunOption(args, "backend-profile");
|
||||
const renderedProfile = stringOrNull(task.backendProfile);
|
||||
const fallbackProfile = sessionPolicy.backendProfile;
|
||||
let backendProfile = explicitProfile ?? renderedProfile ?? fallbackProfile;
|
||||
if (agentRunProviderCredentialRefs(policyTarget.spec, backendProfile).length === 0) {
|
||||
if (explicitProfile !== null) {
|
||||
throw new AgentRunRestError("validation-failed", `${policyTarget.configPath} has no providerCredential Secret binding for explicit --backend-profile ${explicitProfile} on ${policyTarget.spec.nodeId}/${policyTarget.spec.lane}`);
|
||||
}
|
||||
backendProfile = fallbackProfile;
|
||||
if (explicitProfile !== null) {
|
||||
throw new AgentRunRestError("validation-failed", `AipodSpec ${aipod} owns backendProfile; explicit profile override ${explicitProfile} is not allowed`);
|
||||
}
|
||||
if (agentRunProviderCredentialRefs(policyTarget.spec, backendProfile).length === 0) {
|
||||
throw new AgentRunRestError("validation-failed", `${policyTarget.configPath} has no providerCredential Secret binding for AipodSpec ${aipod} backendProfile=${backendProfile} on ${policyTarget.spec.nodeId}/${policyTarget.spec.lane}`);
|
||||
const renderedProfile = stringOrNull(task.backendProfile);
|
||||
if (renderedProfile === null) {
|
||||
throw new AgentRunRestError("schema-mismatch", `aipod-spec ${aipod} render did not return queueTask.backendProfile`);
|
||||
}
|
||||
const providerCredentials = agentRunProviderCredentialRefs(policyTarget.spec, renderedProfile);
|
||||
if (providerCredentials.length === 0) {
|
||||
throw new AgentRunRestError("validation-failed", `${policyTarget.configPath} has no providerCredential Secret binding for AipodSpec ${aipod} backendProfile=${renderedProfile} on ${policyTarget.spec.nodeId}/${policyTarget.spec.lane}`);
|
||||
}
|
||||
const basePolicy = record(task.executionPolicy);
|
||||
if (Object.keys(basePolicy).length === 0) {
|
||||
throw new AgentRunRestError("schema-mismatch", `aipod-spec ${aipod} render did not return queueTask.executionPolicy`);
|
||||
}
|
||||
validateAipodRenderedProviderCredentials(basePolicy, renderedProfile, providerCredentials, policyTarget, aipod);
|
||||
const normalized: Record<string, unknown> = { ...task };
|
||||
const explicitProviderId = agentRunOption(args, "provider-id");
|
||||
normalized.tenantId = stringOrNull(normalized.tenantId) ?? sessionPolicy.tenantId;
|
||||
normalized.projectId = stringOrNull(normalized.projectId) ?? sessionPolicy.projectId;
|
||||
normalized.providerId = explicitProviderId ?? defaultAgentRunProviderId(sessionPolicy, policyTarget);
|
||||
normalized.backendProfile = backendProfile;
|
||||
normalized.providerId = stringOrNull(normalized.providerId) ?? defaultAgentRunProviderId(sessionPolicy, policyTarget);
|
||||
normalized.backendProfile = renderedProfile;
|
||||
if (!isRecord(normalized.workspaceRef)) normalized.workspaceRef = cloneJsonRecord(sessionPolicy.workspaceRef);
|
||||
normalized.executionPolicy = defaultAgentRunExecutionPolicy(backendProfile, sessionPolicy, policyTarget, {
|
||||
normalized.executionPolicy = defaultAgentRunExecutionPolicy(renderedProfile, sessionPolicy, policyTarget, {
|
||||
includeToolCredentials: true,
|
||||
basePolicy: Object.keys(basePolicy).length === 0 ? sessionPolicy.executionPolicy : basePolicy,
|
||||
basePolicy,
|
||||
});
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function agentRunAipodBindingDisclosure(task: Record<string, unknown>, aipod: string): Record<string, unknown> {
|
||||
function validateAipodRenderedProviderCredentials(
|
||||
executionPolicy: Record<string, unknown>,
|
||||
backendProfile: string,
|
||||
expectedCredentials: readonly ReturnType<typeof agentRunProviderCredentialRefs>[number][],
|
||||
policyTarget: ReturnType<typeof resolveAgentRunSessionPolicyTarget>,
|
||||
aipod: string,
|
||||
): void {
|
||||
const rendered = arrayRecords(record(executionPolicy.secretScope).providerCredentials);
|
||||
if (rendered.length === 0) {
|
||||
throw new AgentRunRestError("schema-mismatch", `aipod-spec ${aipod} render did not return executionPolicy.secretScope.providerCredentials`);
|
||||
}
|
||||
const expectedByName = new Map(expectedCredentials.map((credential) => [credential.secretRef.name, credential]));
|
||||
const seen = new Set<string>();
|
||||
for (const [index, credential] of rendered.entries()) {
|
||||
const renderedProfile = stringOrNull(credential.profile);
|
||||
if (renderedProfile !== backendProfile) {
|
||||
throw new AgentRunRestError("validation-failed", `aipod-spec ${aipod} providerCredentials[${index}].profile=${renderedProfile ?? "(missing)"} must exactly match queueTask.backendProfile=${backendProfile}`);
|
||||
}
|
||||
const secretRef = record(credential.secretRef);
|
||||
const name = stringOrNull(secretRef.name);
|
||||
const expected = name === null ? undefined : expectedByName.get(name);
|
||||
if (name === null || expected === undefined || seen.has(name)) {
|
||||
throw new AgentRunRestError("validation-failed", `aipod-spec ${aipod} provider credential SecretRef ${name ?? "(missing)"} is not the unique YAML binding for backendProfile=${backendProfile}`);
|
||||
}
|
||||
seen.add(name);
|
||||
const namespace = stringOrNull(secretRef.namespace);
|
||||
if (namespace !== null && namespace !== expected.secretRef.namespace) {
|
||||
throw new AgentRunRestError("validation-failed", `aipod-spec ${aipod} provider credential ${name} namespace conflicts with YAML lane ${policyTarget.spec.nodeId}/${policyTarget.spec.lane}`);
|
||||
}
|
||||
const keys = Array.isArray(secretRef.keys) ? secretRef.keys.map(String) : [];
|
||||
if (JSON.stringify([...keys].sort()) !== JSON.stringify([...expected.secretRef.keys].sort())) {
|
||||
throw new AgentRunRestError("validation-failed", `aipod-spec ${aipod} provider credential ${name} keys conflict with YAML lane ${policyTarget.spec.nodeId}/${policyTarget.spec.lane}`);
|
||||
}
|
||||
}
|
||||
if (seen.size !== expectedCredentials.length) {
|
||||
throw new AgentRunRestError("validation-failed", `aipod-spec ${aipod} provider credential bindings are incomplete for backendProfile=${backendProfile}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function agentRunAipodBindingDisclosure(
|
||||
task: Record<string, unknown>,
|
||||
aipod: string,
|
||||
modelResolution: Record<string, unknown>,
|
||||
renderInput: Record<string, unknown> | null = null,
|
||||
): Record<string, unknown> {
|
||||
const policyTarget = resolveAgentRunSessionPolicyTarget();
|
||||
const executionPolicy = record(task.executionPolicy);
|
||||
const secretScope = record(executionPolicy.secretScope);
|
||||
@@ -755,6 +816,42 @@ export function agentRunAipodBindingDisclosure(task: Record<string, unknown>, ai
|
||||
projection: record(credential.projection),
|
||||
valuesPrinted: false,
|
||||
}));
|
||||
const payload = record(task.payload);
|
||||
const modelConfig = record(payload.modelConfig);
|
||||
const model = requiredAipodResolutionString(modelResolution.model, aipod, "modelResolution.model");
|
||||
const reasoningEffort = requiredAipodResolutionString(modelResolution.reasoningEffort, aipod, "modelResolution.reasoningEffort");
|
||||
const modelSource = requiredAipodResolutionSource(modelResolution.modelSource, aipod, "modelResolution.modelSource");
|
||||
const reasoningEffortSource = requiredAipodResolutionSource(modelResolution.reasoningEffortSource, aipod, "modelResolution.reasoningEffortSource");
|
||||
const modelOverridden = requiredAipodResolutionBoolean(modelResolution.modelOverridden, aipod, "modelResolution.modelOverridden");
|
||||
const reasoningEffortOverridden = requiredAipodResolutionBoolean(modelResolution.reasoningEffortOverridden, aipod, "modelResolution.reasoningEffortOverridden");
|
||||
const backendProfile = requiredAipodResolutionString(task.backendProfile, aipod, "queueTask.backendProfile");
|
||||
const resolvedBackendProfile = requiredAipodResolutionString(modelResolution.backendProfile, aipod, "modelResolution.backendProfile");
|
||||
const backendProfileSource = requiredAipodResolutionString(modelResolution.backendProfileSource, aipod, "modelResolution.backendProfileSource");
|
||||
assertAipodResolutionEqual(payload.model, model, aipod, "queueTask.payload.model", "modelResolution.model");
|
||||
assertAipodResolutionEqual(payload.reasoningEffort, reasoningEffort, aipod, "queueTask.payload.reasoningEffort", "modelResolution.reasoningEffort");
|
||||
assertAipodResolutionEqual(modelConfig.model, model, aipod, "queueTask.payload.modelConfig.model", "modelResolution.model");
|
||||
assertAipodResolutionEqual(modelConfig.reasoningEffort, reasoningEffort, aipod, "queueTask.payload.modelConfig.reasoningEffort", "modelResolution.reasoningEffort");
|
||||
assertAipodResolutionEqual(modelConfig.modelSource, modelSource, aipod, "queueTask.payload.modelConfig.modelSource", "modelResolution.modelSource");
|
||||
assertAipodResolutionEqual(modelConfig.reasoningEffortSource, reasoningEffortSource, aipod, "queueTask.payload.modelConfig.reasoningEffortSource", "modelResolution.reasoningEffortSource");
|
||||
if (modelConfig.valuesPrinted !== false || modelResolution.valuesPrinted !== false) {
|
||||
throw new AgentRunRestError("schema-mismatch", `aipod-spec ${aipod} model resolution must disclose valuesPrinted=false`);
|
||||
}
|
||||
if (modelOverridden !== (modelSource === "render-input-override") || reasoningEffortOverridden !== (reasoningEffortSource === "render-input-override")) {
|
||||
throw new AgentRunRestError("schema-mismatch", `aipod-spec ${aipod} model resolution override booleans conflict with source fields`);
|
||||
}
|
||||
if (renderInput !== null) {
|
||||
validateAipodRenderOverride(renderInput, "model", model, modelSource, modelOverridden, aipod);
|
||||
validateAipodRenderOverride(renderInput, "reasoningEffort", reasoningEffort, reasoningEffortSource, reasoningEffortOverridden, aipod);
|
||||
}
|
||||
if (resolvedBackendProfile !== backendProfile || backendProfileSource !== "aipod-spec") {
|
||||
throw new AgentRunRestError("validation-failed", `aipod-spec ${aipod} modelResolution backendProfile must match queueTask.backendProfile and remain aipod-spec-owned`);
|
||||
}
|
||||
const credentialBindings = agentRunProviderCredentialRefs(policyTarget.spec, backendProfile);
|
||||
if (credentialBindings.length !== 1) {
|
||||
throw new AgentRunRestError("validation-failed", `${policyTarget.configPath} must declare one unique providerCredential Secret binding for AipodSpec ${aipod} backendProfile=${backendProfile}`);
|
||||
}
|
||||
const credentialBinding = credentialBindings[0]!;
|
||||
validateAipodModelResolutionCredential(modelResolution, credentialBinding, aipod);
|
||||
return {
|
||||
aipod,
|
||||
node: policyTarget.spec.nodeId,
|
||||
@@ -762,7 +859,15 @@ export function agentRunAipodBindingDisclosure(task: Record<string, unknown>, ai
|
||||
namespace: policyTarget.spec.runtime.namespace,
|
||||
policySource: policyTarget.source,
|
||||
providerId: task.providerId ?? null,
|
||||
backendProfile: task.backendProfile ?? null,
|
||||
sourceProfile: credentialBinding.sourceProfile,
|
||||
effectiveBackendProfile: backendProfile,
|
||||
backendProfileSource,
|
||||
model,
|
||||
reasoningEffort,
|
||||
modelSource,
|
||||
reasoningEffortSource,
|
||||
modelOverridden,
|
||||
reasoningEffortOverridden,
|
||||
workspaceRef: task.workspaceRef ?? null,
|
||||
executionPolicy: pickCompact(executionPolicy, ["sandbox", "approval", "timeoutMs", "network"]),
|
||||
providerCredentials,
|
||||
@@ -771,6 +876,70 @@ export function agentRunAipodBindingDisclosure(task: Record<string, unknown>, ai
|
||||
};
|
||||
}
|
||||
|
||||
function requiredAipodResolutionString(value: unknown, aipod: string, pathValue: string): string {
|
||||
const parsed = stringOrNull(value);
|
||||
if (parsed === null) throw new AgentRunRestError("schema-mismatch", `aipod-spec ${aipod} render did not return non-empty ${pathValue}`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function requiredAipodResolutionSource(value: unknown, aipod: string, pathValue: string): "aipod-spec-default" | "render-input-override" {
|
||||
const parsed = requiredAipodResolutionString(value, aipod, pathValue);
|
||||
if (parsed !== "aipod-spec-default" && parsed !== "render-input-override") {
|
||||
throw new AgentRunRestError("schema-mismatch", `aipod-spec ${aipod} ${pathValue} must be aipod-spec-default or render-input-override`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function requiredAipodResolutionBoolean(value: unknown, aipod: string, pathValue: string): boolean {
|
||||
if (typeof value !== "boolean") throw new AgentRunRestError("schema-mismatch", `aipod-spec ${aipod} render did not return boolean ${pathValue}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertAipodResolutionEqual(actual: unknown, expected: string, aipod: string, actualPath: string, expectedPath: string): void {
|
||||
if (actual !== expected) throw new AgentRunRestError("schema-mismatch", `aipod-spec ${aipod} ${actualPath} must match ${expectedPath}`);
|
||||
}
|
||||
|
||||
function validateAipodRenderOverride(
|
||||
renderInput: Record<string, unknown>,
|
||||
key: "model" | "reasoningEffort",
|
||||
effectiveValue: string,
|
||||
source: "aipod-spec-default" | "render-input-override",
|
||||
overridden: boolean,
|
||||
aipod: string,
|
||||
): void {
|
||||
const requested = Object.hasOwn(renderInput, key);
|
||||
const expectedSource = requested ? "render-input-override" : "aipod-spec-default";
|
||||
if (source !== expectedSource || overridden !== requested) {
|
||||
throw new AgentRunRestError("schema-mismatch", `aipod-spec ${aipod} ${key} override disclosure does not match render input`);
|
||||
}
|
||||
if (requested && renderInput[key] !== effectiveValue) {
|
||||
throw new AgentRunRestError("schema-mismatch", `aipod-spec ${aipod} effective ${key} does not match the explicit render input`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateAipodModelResolutionCredential(
|
||||
modelResolution: Record<string, unknown>,
|
||||
expected: ReturnType<typeof agentRunProviderCredentialRefs>[number],
|
||||
aipod: string,
|
||||
): void {
|
||||
const credential = record(modelResolution.providerCredential);
|
||||
const secretRef = record(credential.secretRef);
|
||||
const keys = Array.isArray(secretRef.keys) && secretRef.keys.every((key) => typeof key === "string")
|
||||
? secretRef.keys.map(String).sort()
|
||||
: null;
|
||||
if (
|
||||
credential.profile !== expected.profile
|
||||
|| secretRef.namespace !== expected.secretRef.namespace
|
||||
|| secretRef.name !== expected.secretRef.name
|
||||
|| keys === null
|
||||
|| JSON.stringify(keys) !== JSON.stringify([...expected.secretRef.keys].sort())
|
||||
|| credential.valuesPrinted !== false
|
||||
|| secretRef.valuesPrinted !== false
|
||||
) {
|
||||
throw new AgentRunRestError("validation-failed", `aipod-spec ${aipod} modelResolution.providerCredential must match the unique YAML SecretRef for backendProfile=${expected.profile}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function agentRunSessionRunPolicyDisclosure(runBody: Record<string, unknown>): Record<string, unknown> {
|
||||
const policyTarget = resolveAgentRunSessionPolicyTarget();
|
||||
const executionPolicy = record(runBody.executionPolicy);
|
||||
@@ -798,17 +967,20 @@ export function agentRunSessionRunPolicyDisclosure(runBody: Record<string, unkno
|
||||
}
|
||||
|
||||
export async function sessionSendWithAipodRest(sessionId: string, aipod: string, args: string[]): Promise<Record<string, unknown>> {
|
||||
const rendered = await agentRunRestRequest("agentrun aipod-specs render", "POST", `/api/v1/aipod-specs/${encodeURIComponent(aipod)}/render`, await aipodRenderInputFromArgs(args, 2, { sessionId }));
|
||||
const renderInput = await aipodRenderInputFromArgs(args, 2, { sessionId });
|
||||
const rendered = await agentRunRestRequest("agentrun aipod-specs render", "POST", `/api/v1/aipod-specs/${encodeURIComponent(aipod)}/render`, renderInput);
|
||||
const renderedData = record(innerData(rendered));
|
||||
const task = normalizeAipodRenderedQueueTask(record(renderedData.queueTask), args, aipod);
|
||||
if (Object.keys(task).length === 0) throw new AgentRunRestError("schema-mismatch", `aipod-spec ${aipod} render did not return queueTask`);
|
||||
const aipodBinding = agentRunAipodBindingDisclosure(task, aipod, record(renderedData.modelResolution), renderInput);
|
||||
const sessionRef = record(task.sessionRef);
|
||||
const metadata = record(sessionRef.metadata);
|
||||
const title = agentRunOption(args, "title") ?? stringOrNull(task.title);
|
||||
if (title) metadata.title = title;
|
||||
const sessionPolicy = readAgentRunClientConfig().client.sessionPolicy;
|
||||
const policyTarget = resolveAgentRunSessionPolicyTarget();
|
||||
const backendProfile = stringOrNull(task.backendProfile) ?? sessionPolicy.backendProfile;
|
||||
const backendProfile = stringOrNull(task.backendProfile);
|
||||
if (backendProfile === null) throw new AgentRunRestError("schema-mismatch", `aipod-spec ${aipod} normalized queueTask has no backendProfile`);
|
||||
const executionPolicy = record(task.executionPolicy);
|
||||
const runBody: Record<string, unknown> = {
|
||||
tenantId: stringOrNull(task.tenantId) ?? sessionPolicy.tenantId,
|
||||
@@ -843,7 +1015,7 @@ export async function sessionSendWithAipodRest(sessionId: string, aipod: string,
|
||||
aipod,
|
||||
profile: String(task.backendProfile ?? ""),
|
||||
sessionPolicy: agentRunSessionRunPolicyDisclosure(runBody),
|
||||
aipodBinding: agentRunAipodBindingDisclosure(task, aipod),
|
||||
aipodBinding,
|
||||
valuesPrinted: false,
|
||||
},
|
||||
bridge: response.bridge,
|
||||
|
||||
@@ -116,14 +116,17 @@ export function renderAgentRunSecretSyncText(payload: Record<string, unknown>):
|
||||
`SECRET PLAN count=${displayValue(plan.secretCount)} omitted=${displayValue(plan.omittedCount)}`,
|
||||
planItems.length === 0
|
||||
? "-"
|
||||
: renderTable(["ID", "TARGET", "KEY", "SOURCE", "PRESENT", "FINGERPRINT", "STATUS"], planItems.map((item) => {
|
||||
: renderTable(["ID", "TARGET", "KEY", "SOURCE", "SOURCE PROFILE", "BACKEND PROFILE", "PRESENT", "FINGERPRINT", "STATUS"], planItems.map((item) => {
|
||||
const source = record(item.source);
|
||||
const target = record(item.target);
|
||||
const profile = record(item.profile);
|
||||
return [
|
||||
displayValue(item.id),
|
||||
displayValue(target.identity),
|
||||
displayValue(target.key),
|
||||
displayValue(source.mode),
|
||||
displayValue(profile.sourceProfile),
|
||||
displayValue(profile.effectiveBackendProfile),
|
||||
displayValue(source.present),
|
||||
shortFingerprint(source.fingerprint),
|
||||
source.present === true ? "ready" : "unavailable",
|
||||
@@ -201,6 +204,10 @@ function compactSecretPlanItem(item: Record<string, unknown>, identity: Record<s
|
||||
fingerprint: boundedText(item.fingerprint, 96),
|
||||
...(boundedText(item.degradedReason) === null ? {} : { reason: boundedText(item.degradedReason) }),
|
||||
},
|
||||
profile: {
|
||||
sourceProfile: boundedText(item.sourceProfile),
|
||||
effectiveBackendProfile: boundedText(item.effectiveBackendProfile),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -875,6 +875,8 @@ export type LaneSecretSource = {
|
||||
sourceFormat?: "env" | "raw-token" | null;
|
||||
codexConfig?: AgentRunCodexConfigSpec | null;
|
||||
jsonValue?: unknown;
|
||||
providerCredentialSourceProfile?: string | null;
|
||||
providerCredentialProfile?: string | null;
|
||||
targetRef: { namespace: string; name: string; key: string };
|
||||
transform?: "codex-auth-json-openai-api-key" | "local-postgres-database" | "local-postgres-user" | "local-postgres-database-url" | null;
|
||||
};
|
||||
@@ -1156,6 +1158,8 @@ export function collectLaneSecretSources(spec: AgentRunLaneSpec): LaneSecretSour
|
||||
transform: secret.transform,
|
||||
codexConfig: secret.codexConfig,
|
||||
jsonValue: secret.jsonValue,
|
||||
providerCredentialSourceProfile: secret.providerCredentialSourceProfile,
|
||||
providerCredentialProfile: secret.providerCredentialProfile,
|
||||
targetRef: secret.targetRef,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ type SessionSendProjection = {
|
||||
resources: Record<string, unknown>;
|
||||
runnerAdmission: Record<string, unknown> | null;
|
||||
request: Record<string, unknown> | null;
|
||||
aipodBinding: Record<string, unknown> | null;
|
||||
error: Record<string, unknown> | null;
|
||||
next: {
|
||||
actions: Array<{ command: string; mutation: boolean; reason: string }>;
|
||||
@@ -51,6 +52,7 @@ export function sessionSendSuccessProjection(command: string, raw: Record<string
|
||||
const rawRunnerAdmission = record(data.runnerAdmission);
|
||||
const runnerAdmission = compactRunnerAdmission(rawRunnerAdmission, dispatchIntent);
|
||||
const request = compactRequest(record(data.request));
|
||||
const aipodBinding = compactAipodBinding(record(data.aipodBinding));
|
||||
const decision = stringOrNull(data.decision);
|
||||
const dryRun = data.dryRun === true;
|
||||
const reuseRun = decision === null
|
||||
@@ -100,6 +102,7 @@ export function sessionSendSuccessProjection(command: string, raw: Record<string
|
||||
}),
|
||||
runnerAdmission,
|
||||
request,
|
||||
aipodBinding,
|
||||
error: null,
|
||||
next: {
|
||||
actions: nextActions,
|
||||
@@ -199,6 +202,7 @@ export function sessionSendErrorProjection(raw: Record<string, unknown>): Sessio
|
||||
}),
|
||||
runnerAdmission,
|
||||
request: null,
|
||||
aipodBinding: null,
|
||||
error: errorProjection,
|
||||
next: {
|
||||
actions: nextActions,
|
||||
@@ -237,6 +241,7 @@ function renderSessionSendHuman(command: string, projection: SessionSendProjecti
|
||||
lines.push(`CreateRunner: ${display(projection.createRunner)}`);
|
||||
lines.push(`Mutation: ${display(projection.mutation)}`);
|
||||
lines.push(`PartialWrite: ${display(projection.partialWrite)}`);
|
||||
if (projection.aipodBinding !== null) lines.push(...renderAipodBindingHumanLines(projection.aipodBinding));
|
||||
const resources = Object.values(projection.resources).filter((value) => value !== null).map(record);
|
||||
if (resources.length > 0) {
|
||||
lines.push("", "Resources:");
|
||||
@@ -270,6 +275,59 @@ function renderSessionSendHuman(command: string, projection: SessionSendProjecti
|
||||
return { ok: projection.ok, command, renderedText: lines.join("\n"), contentType: "text/plain" };
|
||||
}
|
||||
|
||||
export function compactAipodBinding(value: Record<string, unknown>): Record<string, unknown> | null {
|
||||
if (Object.keys(value).length === 0) return null;
|
||||
const providerCredentials = Array.isArray(value.providerCredentials)
|
||||
? value.providerCredentials.slice(0, 4).map(record).map((credential) => {
|
||||
const secretRef = record(credential.secretRef);
|
||||
return {
|
||||
profile: stringOrNull(credential.profile),
|
||||
secretRef: {
|
||||
name: stringOrNull(secretRef.name),
|
||||
keys: Array.isArray(secretRef.keys) ? secretRef.keys.filter((key): key is string => typeof key === "string").slice(0, 8) : [],
|
||||
},
|
||||
valuesPrinted: false,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
aipod: stringOrNull(value.aipod),
|
||||
node: stringOrNull(value.node),
|
||||
lane: stringOrNull(value.lane),
|
||||
namespace: stringOrNull(value.namespace),
|
||||
sourceProfile: stringOrNull(value.sourceProfile),
|
||||
effectiveBackendProfile: stringOrNull(value.effectiveBackendProfile),
|
||||
backendProfileSource: stringOrNull(value.backendProfileSource),
|
||||
model: stringOrNull(value.model),
|
||||
reasoningEffort: stringOrNull(value.reasoningEffort),
|
||||
modelSource: stringOrNull(value.modelSource),
|
||||
reasoningEffortSource: stringOrNull(value.reasoningEffortSource),
|
||||
modelOverridden: typeof value.modelOverridden === "boolean" ? value.modelOverridden : null,
|
||||
reasoningEffortOverridden: typeof value.reasoningEffortOverridden === "boolean" ? value.reasoningEffortOverridden : null,
|
||||
providerCredentials,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderAipodBindingHumanLines(value: Record<string, unknown>): string[] {
|
||||
const binding = compactAipodBinding(value);
|
||||
if (binding === null) return [];
|
||||
const credentials = Array.isArray(binding.providerCredentials) ? binding.providerCredentials.map(record) : [];
|
||||
const lines = [
|
||||
"",
|
||||
"AIPOD BINDING",
|
||||
` Provider: sourceProfile=${display(binding.sourceProfile)} effectiveBackendProfile=${display(binding.effectiveBackendProfile)} source=${display(binding.backendProfileSource)}`,
|
||||
` Model: model=${display(binding.model)} reasoningEffort=${display(binding.reasoningEffort)}`,
|
||||
` Resolution: modelSource=${display(binding.modelSource)} modelOverridden=${display(binding.modelOverridden)} reasoningEffortSource=${display(binding.reasoningEffortSource)} reasoningEffortOverridden=${display(binding.reasoningEffortOverridden)}`,
|
||||
];
|
||||
for (const credential of credentials) {
|
||||
const secretRef = record(credential.secretRef);
|
||||
const keys = Array.isArray(secretRef.keys) ? secretRef.keys.map(String).join(",") : "";
|
||||
lines.push(` SecretRef: profile=${display(credential.profile)} ref=${display(binding.namespace)}/${display(secretRef.name)} keys=${keys || "-"} valuesPrinted=false`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function compactResources(resources: Record<string, Record<string, unknown> | null>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {
|
||||
session: resources.session,
|
||||
|
||||
@@ -149,13 +149,15 @@ export async function restartYamlLane(config: UniDeskConfig, options: LaneConfir
|
||||
namespace: secret.targetRef.namespace,
|
||||
name: secret.targetRef.name,
|
||||
key: secret.targetRef.key,
|
||||
providerCredentialProfile: secret.providerCredentialProfile,
|
||||
sourceProfile: secret.providerCredentialSourceProfile,
|
||||
effectiveBackendProfile: secret.providerCredentialProfile,
|
||||
valuesPrinted: false,
|
||||
})),
|
||||
valuesPrinted: false,
|
||||
},
|
||||
providerCredentials: agentRunProviderCredentialRefs(spec).map((credential) => ({
|
||||
profile: credential.profile,
|
||||
sourceProfile: credential.sourceProfile,
|
||||
effectiveBackendProfile: credential.profile,
|
||||
namespace: credential.secretRef.namespace,
|
||||
name: credential.secretRef.name,
|
||||
keys: credential.secretRef.keys,
|
||||
@@ -228,6 +230,8 @@ export async function secretSync(config: UniDeskConfig, options: SecretSyncOptio
|
||||
sourceMode: item.sourceMode,
|
||||
sourceKey: item.sourceKey,
|
||||
transform: item.transform ?? null,
|
||||
sourceProfile: item.providerCredentialSourceProfile ?? null,
|
||||
effectiveBackendProfile: item.providerCredentialProfile ?? null,
|
||||
...inspection.summary,
|
||||
valuesPrinted: false,
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user