feat: 装配 UniDesk SSH 工具凭证
This commit is contained in:
@@ -4,6 +4,7 @@ import { AgentRunError } from "./errors.js";
|
||||
import { backendProfileSpec, backendProfiles, isBackendProfile } from "./backend-profiles.js";
|
||||
|
||||
const allowedTenants = new Set(["unidesk", "hwlab"]);
|
||||
const allowedToolCredentials = ["github", "unidesk-ssh"] as const;
|
||||
|
||||
export function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
@@ -162,7 +163,7 @@ function validateToolCredentials(value: unknown): NonNullable<ExecutionPolicy["s
|
||||
return value.map((credential, index) => {
|
||||
const item = asRecord(credential, `toolCredentials[${index}]`);
|
||||
const tool = requiredString(item, "tool");
|
||||
if (tool !== "github") throw new AgentRunError("schema-invalid", `tool credential ${tool} is not supported in v0.1`, { httpStatus: 400, details: { allowedTools: ["github"] } });
|
||||
if (!allowedToolCredentials.includes(tool as (typeof allowedToolCredentials)[number])) throw new AgentRunError("schema-invalid", `tool credential ${tool} is not supported in v0.1`, { httpStatus: 400, details: { allowedTools: [...allowedToolCredentials] } });
|
||||
const purpose = optionalString(item.purpose);
|
||||
const secretRef = validateSecretRef(asRecord(item.secretRef, `toolCredentials[${index}].secretRef`));
|
||||
const keys = secretRef.keys ?? [];
|
||||
@@ -174,6 +175,7 @@ function validateToolCredentials(value: unknown): NonNullable<ExecutionPolicy["s
|
||||
validateEnvName(envName, `toolCredentials[${index}].projection.envName`);
|
||||
const secretKey = optionalString(projection.secretKey) ?? keys[0];
|
||||
if (!secretKey || !keys.includes(secretKey)) throw new AgentRunError("schema-invalid", `tool credential ${tool} projection.secretKey must be included in secretRef.keys`, { httpStatus: 400 });
|
||||
validateToolCredentialProjection(tool, envName, secretKey, keys, index);
|
||||
const identity = `${tool}:${purpose ?? ""}:${envName}`;
|
||||
if (seen.has(identity)) throw new AgentRunError("schema-invalid", `tool credential projection ${identity} is duplicated`, { httpStatus: 400 });
|
||||
seen.add(identity);
|
||||
@@ -181,6 +183,16 @@ function validateToolCredentials(value: unknown): NonNullable<ExecutionPolicy["s
|
||||
});
|
||||
}
|
||||
|
||||
function validateToolCredentialProjection(tool: string, envName: string, secretKey: string, keys: string[], index: number): void {
|
||||
if (tool !== "unidesk-ssh") return;
|
||||
if (!keys.includes("UNIDESK_SSH_CLIENT_TOKEN")) {
|
||||
throw new AgentRunError("schema-invalid", `toolCredentials[${index}] unidesk-ssh secretRef.keys must include UNIDESK_SSH_CLIENT_TOKEN`, { httpStatus: 400 });
|
||||
}
|
||||
if (envName !== "UNIDESK_SSH_CLIENT_TOKEN" || secretKey !== "UNIDESK_SSH_CLIENT_TOKEN") {
|
||||
throw new AgentRunError("schema-invalid", `toolCredentials[${index}] unidesk-ssh must project UNIDESK_SSH_CLIENT_TOKEN from the same Secret key`, { httpStatus: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export function validateEnvName(name: string, fieldName: string): void {
|
||||
if (!/^[A-Z_][A-Z0-9_]{0,63}$/u.test(name)) throw new AgentRunError("schema-invalid", `${fieldName} must be an uppercase env name`, { httpStatus: 400 });
|
||||
}
|
||||
|
||||
@@ -8,6 +8,18 @@ import { stableHash, validateEnvName } from "../common/validation.js";
|
||||
import { renderRunnerJobManifest } from "../runner/k8s-job.js";
|
||||
import type { RunnerTransientEnv } from "../runner/k8s-job.js";
|
||||
|
||||
const reusableCredentialEnvNames = new Set([
|
||||
"AUTH_PASSWORD",
|
||||
"CODEX_API_KEY",
|
||||
"GH_TOKEN",
|
||||
"GITHUB_TOKEN",
|
||||
"OPENAI_API_KEY",
|
||||
"PROVIDER_TOKEN",
|
||||
"UNIDESK_AUTH_PASSWORD",
|
||||
"UNIDESK_PROVIDER_TOKEN",
|
||||
"UNIDESK_SSH_CLIENT_TOKEN",
|
||||
]);
|
||||
|
||||
export interface RunnerJobDefaults {
|
||||
namespace: string;
|
||||
managerUrl: string;
|
||||
@@ -165,7 +177,7 @@ function transientEnvField(value: unknown): RunnerTransientEnv[] {
|
||||
const record = entry as JsonRecord;
|
||||
const name = stringField(record, "name");
|
||||
validateEnvName(name, `transientEnv[${index}].name`);
|
||||
if (name === "GH_TOKEN" || name === "GITHUB_TOKEN" || name === "OPENAI_API_KEY" || name === "CODEX_API_KEY") throw new AgentRunError("tenant-policy-denied", `transientEnv ${name} must use tool/provider credential assembly instead`, { httpStatus: 403 });
|
||||
if (reusableCredentialEnvNames.has(name)) throw new AgentRunError("tenant-policy-denied", `transientEnv ${name} must use tool/provider credential assembly instead`, { httpStatus: 403 });
|
||||
if (seen.has(name)) throw new AgentRunError("schema-invalid", `transientEnv name ${name} is duplicated`, { httpStatus: 400 });
|
||||
seen.add(name);
|
||||
const rawValue = record.value;
|
||||
|
||||
@@ -18,7 +18,14 @@ const selfTest: SelfTestCase = async (context) => {
|
||||
secretRef: { name: "agentrun-v01-tool-github-pr", keys: ["GH_TOKEN"] },
|
||||
projection: { kind: "env", envName: "GH_TOKEN", secretKey: "GH_TOKEN" },
|
||||
}];
|
||||
const item = await createRunWithCommand(client, { ...context, toolCredentials: githubToolCredentials }, "job smoke", "selftest-job-render", 15_000);
|
||||
const unideskSshToolCredentials = [{
|
||||
tool: "unidesk-ssh",
|
||||
purpose: "ssh-passthrough",
|
||||
secretRef: { name: "agentrun-v01-tool-unidesk-ssh", keys: ["UNIDESK_SSH_CLIENT_TOKEN"] },
|
||||
projection: { kind: "env", envName: "UNIDESK_SSH_CLIENT_TOKEN", secretKey: "UNIDESK_SSH_CLIENT_TOKEN" },
|
||||
}];
|
||||
const combinedToolCredentials = [...githubToolCredentials, ...unideskSshToolCredentials];
|
||||
const item = await createRunWithCommand(client, { ...context, toolCredentials: combinedToolCredentials }, "job smoke", "selftest-job-render", 15_000);
|
||||
const rendered = renderRunnerJobDryRun({
|
||||
run: await client.get(`/api/v1/runs/${item.runId}`) as RunRecord,
|
||||
commandId: item.commandId,
|
||||
@@ -34,10 +41,24 @@ const selfTest: SelfTestCase = async (context) => {
|
||||
assert.equal((rendered.jobIdentity as { serviceAccountName?: string }).serviceAccountName, "agentrun-v01-runner");
|
||||
assertRunnerJobUsesWritableCodexHome(rendered.manifest as JsonRecord, context.codexHome, "codex-0", "/var/run/agentrun/secrets/codex-0");
|
||||
assertRunnerJobUsesToolCredential(rendered, "GH_TOKEN", "agentrun-v01-tool-github-pr", "GH_TOKEN");
|
||||
assertRunnerJobUsesToolCredential(rendered, "UNIDESK_SSH_CLIENT_TOKEN", "agentrun-v01-tool-unidesk-ssh", "UNIDESK_SSH_CLIENT_TOKEN");
|
||||
assert.equal(runnerEnvValue(rendered.manifest as JsonRecord, "HWLAB_DEVICE_POD_SESSION_TOKEN"), "REDACTED");
|
||||
assert.deepEqual((((rendered.transientEnv as JsonRecord).names) as string[]), ["HWLAB_DEVICE_POD_SESSION_TOKEN"]);
|
||||
assertNoSecretLeak(rendered);
|
||||
|
||||
await assert.rejects(
|
||||
() => createRunWithCommand(client, {
|
||||
...context,
|
||||
toolCredentials: [{
|
||||
tool: "unidesk-ssh",
|
||||
purpose: "ssh-passthrough",
|
||||
secretRef: { name: "agentrun-v01-tool-unidesk-ssh", keys: ["UNIDESK_SSH_CLIENT_TOKEN"] },
|
||||
projection: { kind: "env", envName: "UNIDESK_SSH_TOKEN", secretKey: "UNIDESK_SSH_CLIENT_TOKEN" },
|
||||
}],
|
||||
}, "bad unidesk ssh projection", "selftest-bad-unidesk-ssh-projection", 15_000),
|
||||
(error) => error instanceof Error && error.message.includes("unidesk-ssh must project UNIDESK_SSH_CLIENT_TOKEN"),
|
||||
);
|
||||
|
||||
const deepseekItem = await createRunWithCommand(client, { ...context, backendProfile: "deepseek" }, "deepseek job smoke", "selftest-deepseek-job-render", 15_000);
|
||||
const deepseekRendered = renderRunnerJobDryRun({
|
||||
run: await client.get(`/api/v1/runs/${deepseekItem.runId}`) as RunRecord,
|
||||
@@ -91,7 +112,7 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
|
||||
});
|
||||
try {
|
||||
const jobClient = new ManagerClient(serverWithKubectl.baseUrl);
|
||||
const jobItem = await createRunWithCommand(jobClient, { ...context, toolCredentials: githubToolCredentials }, "job create smoke", "selftest-job-create", 15_000);
|
||||
const jobItem = await createRunWithCommand(jobClient, { ...context, toolCredentials: combinedToolCredentials }, "job create smoke", "selftest-job-create", 15_000);
|
||||
const created = await jobClient.post(`/api/v1/runs/${jobItem.runId}/runner-jobs`, {
|
||||
commandId: jobItem.commandId,
|
||||
attemptId: "attempt_selftest_create",
|
||||
@@ -106,22 +127,33 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
|
||||
{ name: "HWLAB_RUNTIME_ENDPOINT_SOURCE", value: "runtime-namespace", sensitive: true },
|
||||
{ name: "HWLAB_RUNTIME_ENDPOINT_LOCKED", value: "1", sensitive: true },
|
||||
{ name: "HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME", value: "1", sensitive: true },
|
||||
{ name: "UNIDESK_MAIN_SERVER_IP", value: "https://unidesk.example.test", sensitive: true },
|
||||
],
|
||||
});
|
||||
assert.equal((created as { mutation?: unknown }).mutation, true);
|
||||
assert.equal(((created as JsonRecord).retention as JsonRecord).ttlSecondsAfterFinished, 86_400);
|
||||
assert.deepEqual((((created as JsonRecord).transientEnv as JsonRecord).names) as string[], ["HWLAB_DEVICE_POD_SESSION_TOKEN", "HWLAB_CLOUD_API_URL", "HWLAB_DEVICE_POD_API_URL", "HWLAB_RUNTIME_API_URL", "HWLAB_RUNTIME_WEB_URL", "HWLAB_RUNTIME_NAMESPACE", "HWLAB_RUNTIME_LANE", "HWLAB_RUNTIME_ENDPOINT_SOURCE", "HWLAB_RUNTIME_ENDPOINT_LOCKED", "HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME"]);
|
||||
assert.deepEqual((((created as JsonRecord).transientEnv as JsonRecord).names) as string[], ["HWLAB_DEVICE_POD_SESSION_TOKEN", "HWLAB_CLOUD_API_URL", "HWLAB_DEVICE_POD_API_URL", "HWLAB_RUNTIME_API_URL", "HWLAB_RUNTIME_WEB_URL", "HWLAB_RUNTIME_NAMESPACE", "HWLAB_RUNTIME_LANE", "HWLAB_RUNTIME_ENDPOINT_SOURCE", "HWLAB_RUNTIME_ENDPOINT_LOCKED", "HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME", "UNIDESK_MAIN_SERVER_IP"]);
|
||||
const manifest = JSON.parse(await readFile(createdManifest, "utf8")) as JsonRecord;
|
||||
assert.equal((manifest.spec as JsonRecord).ttlSecondsAfterFinished, 86_400);
|
||||
assert.equal(runnerEnvValue(manifest, "HWLAB_DEVICE_POD_SESSION_TOKEN"), "test-token-material");
|
||||
assert.equal(runnerEnvValue(manifest, "HWLAB_CLOUD_API_URL"), "http://cloud.test");
|
||||
assert.equal(runnerEnvValue(manifest, "HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME"), "1");
|
||||
assert.equal(runnerEnvValue(manifest, "UNIDESK_MAIN_SERVER_IP"), "https://unidesk.example.test");
|
||||
assertRunnerJobUsesToolCredential({ manifest, toolCredentials: (created as JsonRecord).toolCredentials } as JsonRecord, "GH_TOKEN", "agentrun-v01-tool-github-pr", "GH_TOKEN");
|
||||
assertRunnerJobUsesToolCredential({ manifest, toolCredentials: (created as JsonRecord).toolCredentials } as JsonRecord, "UNIDESK_SSH_CLIENT_TOKEN", "agentrun-v01-tool-unidesk-ssh", "UNIDESK_SSH_CLIENT_TOKEN");
|
||||
assertNoSecretLeak(created);
|
||||
await assert.rejects(
|
||||
() => jobClient.post(`/api/v1/runs/${jobItem.runId}/runner-jobs`, {
|
||||
commandId: jobItem.commandId,
|
||||
attemptId: "attempt_selftest_bad_unidesk_ssh_transient",
|
||||
transientEnv: [{ name: "UNIDESK_SSH_CLIENT_TOKEN", value: "test-unidesk-ssh-client-token", sensitive: true }],
|
||||
}),
|
||||
(error) => error instanceof Error && error.message.includes("must use tool/provider credential assembly instead"),
|
||||
);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => serverWithKubectl.server.close(() => resolve()));
|
||||
}
|
||||
return { name: "runner-k8s-job", tests: ["runner-k8s-job-dry-run", "runner-k8s-job-deepseek-profile-dry-run", "runner-k8s-job-minimax-m3-profile-dry-run", "runner-k8s-job-create-api", "runner-k8s-job-retention-ttl", "runner-job-transient-env", "runner-job-tool-credential-env"] };
|
||||
return { name: "runner-k8s-job", tests: ["runner-k8s-job-dry-run", "runner-k8s-job-deepseek-profile-dry-run", "runner-k8s-job-minimax-m3-profile-dry-run", "runner-k8s-job-create-api", "runner-k8s-job-retention-ttl", "runner-job-transient-env", "runner-job-tool-credential-env", "runner-job-unidesk-ssh-tool-credential-env", "runner-job-unidesk-ssh-transient-env-denied"] };
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.server.close(() => resolve()));
|
||||
}
|
||||
@@ -157,7 +189,14 @@ function assertRunnerJobUsesToolCredential(rendered: JsonRecord, envName: string
|
||||
|
||||
const summary = rendered.toolCredentials as JsonRecord;
|
||||
assert.equal(summary.valuesPrinted, false);
|
||||
assert.equal(summary.count, 1);
|
||||
assert.ok(Number(summary.count) >= 1);
|
||||
const items = summary.items as JsonRecord[];
|
||||
const summaryEntry = items.find((item) => {
|
||||
const projection = item.projection as JsonRecord;
|
||||
return item.name === secretName && projection.envName === envName && projection.secretKey === secretKey;
|
||||
});
|
||||
assert.ok(summaryEntry, `${envName} tool credential summary should include its SecretRef and projection`);
|
||||
assert.equal(summaryEntry.valuesPrinted, false);
|
||||
}
|
||||
|
||||
function assertRunnerJobUsesWritableCodexHome(manifest: JsonRecord, expectedCodexHome: string, volumeName: string, projectionPath: string): void {
|
||||
|
||||
Reference in New Issue
Block a user