diff --git a/deploy/deploy.json b/deploy/deploy.json index 1c58116..923ff86 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -3,6 +3,7 @@ "runtimeNamespace": "agentrun-v01", "gitopsBranch": "v0.1-gitops", "runtimePath": "deploy/gitops/g14/runtime-v01", + "unideskSshEndpointEnv": { "name": "UNIDESK_MAIN_SERVER_IP", "value": "74.48.78.17" }, "services": [ { "id": "agentrun-mgr", diff --git a/scripts/src/gitops-render.ts b/scripts/src/gitops-render.ts index e846a93..5bb6686 100644 --- a/scripts/src/gitops-render.ts +++ b/scripts/src/gitops-render.ts @@ -39,7 +39,13 @@ interface CatalogService { provenance?: JsonRecord; } +interface EnvVarValue { + name: string; + value: string; +} + const defaultBootRepoUrl = "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/agentrun.git"; +const unideskSshEndpointEnvNames = new Set(["UNIDESK_MAIN_SERVER_IP", "UNIDESK_MAIN_SERVER_HOST", "UNIDESK_FRONTEND_URL"]); export async function runGitopsRenderCli(argv: string[]): Promise { try { @@ -57,6 +63,7 @@ export async function renderGitops(options: RenderOptions): Promise const runtimeNamespace = stringField(deploy, "runtimeNamespace", "agentrun-v01"); const gitopsBranch = stringField(deploy, "gitopsBranch", "v0.1-gitops"); const runtimePath = stringField(deploy, "runtimePath", "deploy/gitops/g14/runtime-v01"); + const unideskSshEndpointEnv = optionalUnideskSshEndpointEnv(deploy); const catalog = await loadCatalog(options, gitopsBranch); const image = imageForService(catalog, "agentrun-mgr", options); @@ -70,9 +77,9 @@ export async function renderGitops(options: RenderOptions): Promise await writeFile(path.join(options.outDir, "runtime-v01", "kustomization.yaml"), kustomizationYaml()); await writeFile(path.join(options.outDir, "runtime-v01", "namespace.yaml"), namespaceYaml(runtimeNamespace)); await writeFile(path.join(options.outDir, "runtime-v01", "postgres.yaml"), postgresYaml(runtimeNamespace)); - await writeFile(path.join(options.outDir, "runtime-v01", "mgr.yaml"), managerYaml(runtimeNamespace, image, options.sourceCommit)); + await writeFile(path.join(options.outDir, "runtime-v01", "mgr.yaml"), managerYaml(runtimeNamespace, image, options.sourceCommit, unideskSshEndpointEnv)); await writeFile(path.join(options.outDir, "runtime-v01", "runner-rbac.yaml"), runnerRbacYaml(runtimeNamespace)); - return { outDir: options.outDir, runtimeNamespace, gitopsBranch, runtimePath, image: repositoryDigestForService(image), sourceCommit: options.sourceCommit, envIdentity: image.envIdentity ?? null, artifactStatus: image.status ?? null }; + return { outDir: options.outDir, runtimeNamespace, gitopsBranch, runtimePath, image: repositoryDigestForService(image), sourceCommit: options.sourceCommit, envIdentity: image.envIdentity ?? null, artifactStatus: image.status ?? null, unideskSshEndpointEnv: unideskSshEndpointEnv ? { name: unideskSshEndpointEnv.name, valuesPrinted: false } : null }; } async function loadCatalog(options: RenderOptions, gitopsBranch: string): Promise { @@ -239,9 +246,10 @@ spec: `; } -function managerYaml(namespace: string, image: CatalogService, sourceCommit: string): string { +function managerYaml(namespace: string, image: CatalogService, sourceCommit: string, unideskSshEndpointEnv: EnvVarValue | null): string { const imageRef = repositoryDigestForService(image); const envIdentity = image.envIdentity ?? image.imageTag ?? "unknown"; + const unideskSshEndpointEnvYaml = unideskSshEndpointEnv ? ` - name: ${unideskSshEndpointEnv.name}\n value: ${JSON.stringify(unideskSshEndpointEnv.value)}\n` : ""; return `apiVersion: v1 kind: ServiceAccount metadata: @@ -314,6 +322,7 @@ spec: value: ${JSON.stringify(imageRef)} - name: AGENTRUN_RUNNER_SERVICE_ACCOUNT value: "agentrun-v01-runner" +${unideskSshEndpointEnvYaml} readinessProbe: httpGet: path: /health/readiness @@ -456,3 +465,15 @@ function stringField(record: JsonRecord, key: string, fallback: string): string const value = record[key]; return typeof value === "string" && value.length > 0 ? value : fallback; } + +function optionalUnideskSshEndpointEnv(deploy: JsonRecord): EnvVarValue | null { + const value = deploy.unideskSshEndpointEnv; + if (value === undefined) return null; + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new AgentRunError("schema-invalid", "unideskSshEndpointEnv must be an object", { httpStatus: 2 }); + const record = value as JsonRecord; + const name = record.name; + const envValue = record.value; + if (typeof name !== "string" || !unideskSshEndpointEnvNames.has(name)) throw new AgentRunError("schema-invalid", "unideskSshEndpointEnv.name must be UNIDESK_MAIN_SERVER_IP, UNIDESK_MAIN_SERVER_HOST, or UNIDESK_FRONTEND_URL", { httpStatus: 2 }); + if (typeof envValue !== "string" || envValue.length === 0) throw new AgentRunError("schema-invalid", "unideskSshEndpointEnv.value must be a non-empty string", { httpStatus: 2 }); + return { name, value: envValue }; +} diff --git a/src/mgr/kubernetes-runner-job.ts b/src/mgr/kubernetes-runner-job.ts index 85004dc..8670176 100644 --- a/src/mgr/kubernetes-runner-job.ts +++ b/src/mgr/kubernetes-runner-job.ts @@ -3,7 +3,7 @@ import { AgentRunError } from "../common/errors.js"; import { redactJson, redactText } from "../common/redaction.js"; import { isTerminalCommandState, isTerminalRunStatus, summarizeResourceBundleRef, summarizeSessionRef } from "./store.js"; import type { AgentRunStore } from "./store.js"; -import type { JsonRecord } from "../common/types.js"; +import type { ExecutionPolicy, JsonRecord } from "../common/types.js"; import { stableHash, validateEnvName } from "../common/validation.js"; import { renderRunnerJobManifest } from "../runner/k8s-job.js"; import type { RunnerSessionPvcOptions, RunnerTransientEnv } from "../runner/k8s-job.js"; @@ -20,6 +20,17 @@ const reusableCredentialEnvNames = new Set([ "UNIDESK_SSH_CLIENT_TOKEN", ]); +const unideskSshEndpointEnvNames = ["UNIDESK_MAIN_SERVER_IP", "UNIDESK_MAIN_SERVER_HOST", "UNIDESK_FRONTEND_URL"] as const; +const unideskSshEndpointEnvNameSet = new Set(unideskSshEndpointEnvNames); +const unideskSshEndpointConfigEnvNames: Array<{ configName: string; targetName: (typeof unideskSshEndpointEnvNames)[number] }> = [ + { configName: "AGENTRUN_UNIDESK_MAIN_SERVER_IP", targetName: "UNIDESK_MAIN_SERVER_IP" }, + { configName: "AGENTRUN_UNIDESK_MAIN_SERVER_HOST", targetName: "UNIDESK_MAIN_SERVER_HOST" }, + { configName: "AGENTRUN_UNIDESK_FRONTEND_URL", targetName: "UNIDESK_FRONTEND_URL" }, + { configName: "UNIDESK_MAIN_SERVER_IP", targetName: "UNIDESK_MAIN_SERVER_IP" }, + { configName: "UNIDESK_MAIN_SERVER_HOST", targetName: "UNIDESK_MAIN_SERVER_HOST" }, + { configName: "UNIDESK_FRONTEND_URL", targetName: "UNIDESK_FRONTEND_URL" }, +]; + export interface RunnerJobDefaults { namespace: string; managerUrl: string; @@ -27,6 +38,7 @@ export interface RunnerJobDefaults { sourceCommit: string; serviceAccountName?: string; kubectlCommand?: string; + unideskSshEndpointEnv?: JsonRecord; } export interface CreateRunnerJobInput extends JsonRecord { @@ -56,7 +68,7 @@ export async function createKubernetesRunnerJob(options: { store: AgentRunStore; const sourceCommit = optionalString(options.input.sourceCommit) ?? options.defaults.sourceCommit; const serviceAccountName = optionalString(options.input.serviceAccountName) ?? options.defaults.serviceAccountName; const idempotencyKey = optionalString(options.input.idempotencyKey); - const transientEnv = transientEnvField(options.input.transientEnv); + const transientEnv = assembleToolContextTransientEnv(run.executionPolicy, transientEnvField(options.input.transientEnv), options.defaults); const normalizedPayload = { commandId, image, @@ -198,6 +210,45 @@ function transientEnvField(value: unknown): RunnerTransientEnv[] { }); } +function assembleToolContextTransientEnv(policy: ExecutionPolicy, provided: RunnerTransientEnv[], defaults: RunnerJobDefaults): RunnerTransientEnv[] { + if (!usesUnideskSsh(policy)) return provided; + if (provided.some((item) => unideskSshEndpointEnvNameSet.has(item.name))) return provided; + const endpoint = defaultUnideskSshEndpointEnv(defaults); + if (!endpoint) { + throw new AgentRunError("schema-invalid", "unidesk-ssh tool credential requires runner-job transientEnv UNIDESK_MAIN_SERVER_IP, UNIDESK_MAIN_SERVER_HOST, or UNIDESK_FRONTEND_URL", { + httpStatus: 400, + details: { tool: "unidesk-ssh", requiredEnv: [...unideskSshEndpointEnvNames], valuesPrinted: false }, + }); + } + return [...provided, endpoint]; +} + +function usesUnideskSsh(policy: ExecutionPolicy): boolean { + return (policy.secretScope.toolCredentials ?? []).some((item) => item.tool === "unidesk-ssh"); +} + +function defaultUnideskSshEndpointEnv(defaults: RunnerJobDefaults): RunnerTransientEnv | null { + const fromDefaults = defaults.unideskSshEndpointEnv; + if (fromDefaults !== undefined) return unideskSshEndpointEnvFromRecord(fromDefaults, "runnerJobDefaults.unideskSshEndpointEnv"); + for (const item of unideskSshEndpointConfigEnvNames) { + const value = optionalString(process.env[item.configName]); + if (value) return { name: item.targetName, value, sensitive: true }; + } + return null; +} + +function unideskSshEndpointEnvFromRecord(record: JsonRecord, fieldName: string): RunnerTransientEnv { + const name = stringField(record, "name"); + validateEnvName(name, `${fieldName}.name`); + if (!unideskSshEndpointEnvNameSet.has(name)) { + throw new AgentRunError("schema-invalid", `${fieldName}.name must be one of ${unideskSshEndpointEnvNames.join(", ")}`, { httpStatus: 400, details: { requiredEnv: [...unideskSshEndpointEnvNames], valuesPrinted: false } }); + } + const rawValue = record.value; + if (typeof rawValue !== "string" || rawValue.length === 0) throw new AgentRunError("schema-invalid", `${fieldName}.value must be a non-empty string`, { httpStatus: 400 }); + if (Buffer.byteLength(rawValue, "utf8") > 8192) throw new AgentRunError("schema-invalid", `${fieldName}.value is too large`, { httpStatus: 400 }); + return { name, value: rawValue, sensitive: true }; +} + function summarizeToolCredentials(items: Array<{ tool: string; purpose: string | null; secretRef: { namespace?: string; name: string; keys?: string[] }; envName: string; secretKey: string }>, namespace: string): JsonRecord { return { count: items.length, diff --git a/src/mgr/server.ts b/src/mgr/server.ts index 081a9ce..f6f975c 100644 --- a/src/mgr/server.ts +++ b/src/mgr/server.ts @@ -42,6 +42,7 @@ export interface ManagerServerOptions { image?: string; serviceAccountName?: string; kubectlCommand?: string; + unideskSshEndpointEnv?: JsonRecord; }; sessionPvcOptions?: { kubectlHandler?: import("./session-pvc.js").KubectlHandler; kubectlCommand?: string; storageClassName?: string; size?: string }; providerProfileOptions?: { namespace?: string; kubectlCommand?: string }; @@ -244,6 +245,7 @@ async function route({ method, url, body, store, sourceCommit, runnerJobDefaults sourceCommit, serviceAccountName: runnerJobDefaults?.serviceAccountName ?? process.env.AGENTRUN_RUNNER_SERVICE_ACCOUNT ?? "agentrun-v01-runner", ...(runnerJobDefaults?.kubectlCommand ? { kubectlCommand: runnerJobDefaults.kubectlCommand } : {}), + ...(runnerJobDefaults?.unideskSshEndpointEnv ? { unideskSshEndpointEnv: runnerJobDefaults.unideskSshEndpointEnv } : {}), }, }) as unknown as JsonValue; } @@ -297,6 +299,7 @@ async function route({ method, url, body, store, sourceCommit, runnerJobDefaults sourceCommit, serviceAccountName: runnerJobDefaults?.serviceAccountName ?? process.env.AGENTRUN_RUNNER_SERVICE_ACCOUNT ?? "agentrun-v01-runner", ...(runnerJobDefaults?.kubectlCommand ? { kubectlCommand: runnerJobDefaults.kubectlCommand } : {}), + ...(runnerJobDefaults?.unideskSshEndpointEnv ? { unideskSshEndpointEnv: runnerJobDefaults.unideskSshEndpointEnv } : {}), }, }) as unknown as JsonValue; } diff --git a/src/selftest/cases/20-runner-k8s-job.ts b/src/selftest/cases/20-runner-k8s-job.ts index c700eae..5cffcfa 100644 --- a/src/selftest/cases/20-runner-k8s-job.ts +++ b/src/selftest/cases/20-runner-k8s-job.ts @@ -143,6 +143,7 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin managerUrl: "http://agentrun-mgr.agentrun-v01.svc.cluster.local:8080", image: "127.0.0.1:5000/agentrun/agentrun-mgr@sha256:1111111111111111111111111111111111111111111111111111111111111111", kubectlCommand: fakeKubectl, + unideskSshEndpointEnv: { name: "UNIDESK_MAIN_SERVER_IP", value: "https://unidesk.default.example.test" }, }, }); try { @@ -175,6 +176,16 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin 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); + const defaultEndpointJobItem = await createRunWithCommand(jobClient, { ...context, toolCredentials: unideskSshToolCredentials }, "job create unidesk ssh default endpoint", "selftest-job-create-unidesk-ssh-default-endpoint", 15_000); + const defaultEndpointCreated = await jobClient.post(`/api/v1/runs/${defaultEndpointJobItem.runId}/runner-jobs`, { + commandId: defaultEndpointJobItem.commandId, + attemptId: "attempt_selftest_unidesk_ssh_default_endpoint", + }) as JsonRecord; + assert.deepEqual(((defaultEndpointCreated.transientEnv as JsonRecord).names) as string[], ["UNIDESK_MAIN_SERVER_IP"]); + const defaultEndpointManifest = JSON.parse(await readFile(createdManifest, "utf8")) as JsonRecord; + assert.equal(runnerEnvValue(defaultEndpointManifest, "UNIDESK_MAIN_SERVER_IP"), "https://unidesk.default.example.test"); + assertRunnerJobUsesToolCredential({ manifest: defaultEndpointManifest, toolCredentials: defaultEndpointCreated.toolCredentials } as JsonRecord, "UNIDESK_SSH_CLIENT_TOKEN", "agentrun-v01-tool-unidesk-ssh", "UNIDESK_SSH_CLIENT_TOKEN"); + assertNoSecretLeak(defaultEndpointCreated); await assert.rejects( () => jobClient.post(`/api/v1/runs/${jobItem.runId}/runner-jobs`, { commandId: jobItem.commandId, @@ -228,7 +239,7 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin assert.equal(envMap.get("AGENTRUN_SESSION_PVC_NAMESPACE"), "agentrun-v01"); assert.equal(envMap.get("AGENTRUN_SESSION_PVC_MOUNT_PATH"), "/home/agentrun/.codex-codex/sessions"); assert.equal(envMap.get("AGENTRUN_CODEX_ROLLOUT_SUBDIR"), "sessions"); - return { name: "runner-k8s-job", tests: ["runner-k8s-job-dry-run", "runner-k8s-job-codex-shell-sandbox-env", "runner-k8s-job-deepseek-profile-dry-run", "runner-k8s-job-minimax-m3-profile-dry-run", "runner-k8s-job-dsflash-go-profile-dry-run", "runner-k8s-job-dsflash-go-legacy-secretref-normalized", "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", "runner-k8s-job-session-pvc-volume-and-env"] }; + return { name: "runner-k8s-job", tests: ["runner-k8s-job-dry-run", "runner-k8s-job-codex-shell-sandbox-env", "runner-k8s-job-deepseek-profile-dry-run", "runner-k8s-job-minimax-m3-profile-dry-run", "runner-k8s-job-dsflash-go-profile-dry-run", "runner-k8s-job-dsflash-go-legacy-secretref-normalized", "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-endpoint-auto-env", "runner-job-unidesk-ssh-transient-env-denied", "runner-k8s-job-session-pvc-volume-and-env"] }; } finally { await new Promise((resolve) => server.server.close(() => resolve())); } diff --git a/src/selftest/cases/75-queue-q2-dispatch.ts b/src/selftest/cases/75-queue-q2-dispatch.ts index ad18eec..b290678 100644 --- a/src/selftest/cases/75-queue-q2-dispatch.ts +++ b/src/selftest/cases/75-queue-q2-dispatch.ts @@ -30,6 +30,7 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin managerUrl: "http://agentrun-mgr.agentrun-v01.svc.cluster.local:8080", image: "127.0.0.1:5000/agentrun/agentrun-mgr@sha256:1111111111111111111111111111111111111111111111111111111111111111", kubectlCommand: fakeKubectl, + unideskSshEndpointEnv: { name: "UNIDESK_MAIN_SERVER_IP", value: "https://unidesk.default.example.test" }, }, }); try { @@ -96,6 +97,46 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin const dispatchManifest = JSON.parse(await readFile(createdManifest, "utf8")) as JsonRecord; assert.ok(JSON.stringify(dispatchManifest).includes(dispatched.run.id)); + const unideskCreated = await client.post("/api/v1/queue/tasks", { + tenantId: "unidesk", + projectId: "pikasTech/unidesk", + queue: "dev", + lane: "q2", + title: "Q2 queue unidesk ssh dispatch task", + priority: 22, + backendProfile: "codex", + providerId: "G14", + workspaceRef: { kind: "host-path", path: context.workspace }, + sessionRef: { sessionId: "sess_queue_q2_unidesk_ssh_selftest", metadata: { source: "queue-q2-unidesk-ssh-self-test" } }, + executionPolicy: { + sandbox: "workspace-write", + approval: "never", + timeoutMs: 15_000, + network: "default", + secretScope: { + allowCredentialEcho: false, + providerCredentials: [{ profile: "codex", secretRef: { name: "agentrun-v01-provider-codex", keys: ["auth.json", "config.toml"], mountPath: context.codexHome } }], + toolCredentials: [{ + tool: "unidesk-ssh", + purpose: "ssh-passthrough-readonly", + 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" }, + }], + }, + }, + resourceBundleRef: null, + payload: { prompt: "queue unidesk ssh dispatch hello" }, + references: [{ kind: "issue", url: "https://github.com/pikasTech/agentrun/issues/112" }], + metadata: { source: "queue-q2-unidesk-ssh-self-test" }, + idempotencyKey: "queue-q2-unidesk-ssh-self-test", + }) as QueueTaskRecord; + const unideskDispatched = await client.post(`/api/v1/queue/tasks/${unideskCreated.id}/dispatch`, { attemptId: "attempt_queue_q2_unidesk_ssh_selftest" }) as QueueDispatchResult; + assert.deepEqual((((unideskDispatched.runnerJob as JsonRecord).transientEnv as JsonRecord).names) as string[], ["UNIDESK_MAIN_SERVER_IP"]); + const unideskManifest = JSON.parse(await readFile(createdManifest, "utf8")) as JsonRecord; + assert.equal(runnerEnvValue(unideskManifest, "UNIDESK_MAIN_SERVER_IP"), "https://unidesk.default.example.test"); + assert.equal(runnerEnvValue(unideskManifest, "UNIDESK_SSH_CLIENT_TOKEN"), "secretRef"); + assertNoSecretLeak(unideskDispatched); + const cancelCreated = await client.post("/api/v1/queue/tasks", { tenantId: "unidesk", projectId: "pikasTech/unidesk", @@ -142,10 +183,22 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin assert.ok(JSON.stringify(cancelManifest).includes(cancelDispatched.run.id)); assertNoSecretLeak(dispatched); assertNoSecretLeak(cancelled); - return { name: "queue-q2-dispatch", tests: ["queue-dispatch-run-command-runner-job", "queue-refresh-from-core-status", "queue-dispatch-no-repeat", "queue-cancel-propagates-to-run-command-session"] }; + return { name: "queue-q2-dispatch", tests: ["queue-dispatch-run-command-runner-job", "queue-refresh-from-core-status", "queue-dispatch-no-repeat", "queue-unidesk-ssh-endpoint-auto-env", "queue-cancel-propagates-to-run-command-session"] }; } finally { await new Promise((resolve) => server.server.close(() => resolve())); } }; export default selfTest; + +function runnerEnvValue(manifest: JsonRecord, name: string): unknown { + const spec = manifest.spec as JsonRecord; + const template = spec.template as JsonRecord; + const podSpec = template.spec as JsonRecord; + const containers = podSpec.containers as JsonRecord[]; + const env = containers[0]?.env as JsonRecord[]; + const item = env.find((entry) => entry.name === name); + if (!item) return undefined; + if (item.valueFrom) return "secretRef"; + return item.value; +}