From 6989dc18efecec9d5c629a3584e62970ce0b611a Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 10 Jun 2026 17:46:45 +0800 Subject: [PATCH] feat: add aipod spec Artificer assembly --- AGENTS.md | 1 + config/aipods/artificer.yaml | 119 ++++++ docs/reference/spec-v01-aipod-spec.md | 125 ++++++ docs/reference/spec-v01-cli.md | 15 + .../spec-v01-documentation-governance.md | 4 +- docs/reference/spec-v01-runtime-assembly.md | 23 +- .../reference/spec-v01-secret-distribution.md | 25 +- scripts/src/cli.ts | 196 ++++++++- src/common/aipod-specs.ts | 300 ++++++++++++++ src/common/types.ts | 77 +++- src/common/validation.ts | 59 ++- src/mgr/kubernetes-runner-job.ts | 4 +- src/mgr/server.ts | 31 +- src/mgr/tool-credentials.ts | 309 ++++++++++++++ src/runner/k8s-job.ts | 41 +- src/runner/resource-bundle.ts | 90 +++- src/selftest/cases/20-runner-k8s-job.ts | 41 +- src/selftest/cases/46-tool-credentials.ts | 147 +++++++ src/selftest/cases/76-aipod-spec.ts | 94 +++++ src/selftest/cases/90-runner-image-tools.ts | 11 +- tools/apply_patch | 385 ++++++++++++++++++ tools/tran | 62 ++- 22 files changed, 2103 insertions(+), 56 deletions(-) create mode 100644 config/aipods/artificer.yaml create mode 100644 docs/reference/spec-v01-aipod-spec.md create mode 100644 src/common/aipod-specs.ts create mode 100644 src/mgr/tool-credentials.ts create mode 100644 src/selftest/cases/46-tool-credentials.ts create mode 100644 src/selftest/cases/76-aipod-spec.ts create mode 100644 tools/apply_patch diff --git a/AGENTS.md b/AGENTS.md index c706995..fd902dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,7 @@ AgentRun 是面向 UniDesk 与 HWLAB 的共享 Agent 执行基础设施。本仓 - `docs/reference/spec-v01-secret-distribution.md`:v0.1 Code Agent provider credential 和运行时 Secret 分发规格。 - `docs/reference/spec-v01-provider-profile-management.md`:v0.1 provider profile 管理 API、HWLAB 委托信任边界、API Key 写入、Secret/config 更新和 canary 规格。 - `docs/reference/spec-v01-runtime-assembly.md`:v0.1 runner/backend 启动前的装配 SPEC,覆盖 BackendImageRef、ProfileRef、SessionRef、`ResourceBundleRef.kind="gitbundle"`、`bundles[]`/`promptRefs`、gitbundle tools/skillDirs 装配和 tool credential SecretRef scope;旧 `toolAliases` / `skillRefs` / `workspaceFiles` 不再是有效装配入口。 +- `docs/reference/spec-v01-aipod-spec.md`:v0.1 AipodSpec YAML、Artificer 默认规格、git mirror、tool credential projection、manager API 和 `--aipod` CLI 规格。 - `docs/reference/spec-v01-queue.md`:v0.1 AgentRun Queue 直接吸收 UniDesk Code Queue 的 RESTful API、CLI、数据模型、Session 边界和验收规格。 - `docs/reference/spec-v01-hwlab-manual-dispatch.md`:v0.1 通过手动调度 API 为 HWLAB v0.2 提供 canary Code Agent 服务的目标、缺口和增强计划。 - `docs/reference/spec-v01-validation.md`:v0.1 两层验证模型,自测试允许 mock,综合联调必须 100% 真实。 diff --git a/config/aipods/artificer.yaml b/config/aipods/artificer.yaml new file mode 100644 index 0000000..fca9dd1 --- /dev/null +++ b/config/aipods/artificer.yaml @@ -0,0 +1,119 @@ +apiVersion: agentrun.pikastech.local/v0.1 +kind: AipodSpec +metadata: + name: Artificer + displayName: Artificer + description: UniDesk distributed development agent assembled from AgentRun v0.1 YAML. + labels: + agentrun.pikastech.local/lane: v0.1 + agentrun.pikastech.local/purpose: distributed-development +spec: + tenantId: unidesk + projectId: pikasTech/unidesk + queue: commander + lane: v0.1 + priority: 50 + providerId: G14 + backendProfile: sub2api + model: + model: gpt-5.5 + reasoningEffort: xhigh + workspaceRef: + kind: opaque + path: . + executionPolicy: + sandbox: workspace-write + approval: never + timeoutMs: 1800000 + network: enabled + secretScope: + allowCredentialEcho: false + providerCredentials: + - profile: sub2api + secretRef: + name: agentrun-v01-provider-sub2api + keys: + - auth.json + - config.toml + 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_CLIENT_TOKEN + secretKey: UNIDESK_SSH_CLIENT_TOKEN + - tool: github + purpose: github-ssh + secretRef: + name: agentrun-v01-tool-github-ssh + keys: + - id_ed25519 + - known_hosts + - config + projection: + kind: volume + mountPath: /home/agentrun/.ssh + resourceBundleRef: + kind: gitbundle + repoUrl: git@github.com:pikasTech/unidesk.git + ref: master + gitMirror: + enabled: true + baseUrl: http://git-mirror-http.devops-infra.svc.cluster.local + bundles: + - name: unidesk-skills + subpath: .agents/skills + targetPath: .agents/skills + - name: agentrun-runner-tools + repoUrl: git@github.com:pikasTech/agentrun.git + ref: v0.1 + subpath: tools + targetPath: tools + - name: dad-dev-skill + repoUrl: git@github.com:pikasTech/agent_skills.git + ref: master + subpath: dad-dev + targetPath: .agents/skills/dad-dev + - name: cli-spec-skill + repoUrl: git@github.com:pikasTech/agent_skills.git + ref: master + subpath: cli-spec + targetPath: .agents/skills/cli-spec + - name: docs-spec-skill + repoUrl: git@github.com:pikasTech/agent_skills.git + ref: master + subpath: docs-spec + targetPath: .agents/skills/docs-spec + - name: git-spec-skill + repoUrl: git@github.com:pikasTech/agent_skills.git + ref: master + subpath: git-spec + targetPath: .agents/skills/git-spec + requiredSkills: + - name: dad-dev + - name: cli-spec + - name: docs-spec + - name: git-spec + - name: unidesk-trans + - name: unidesk-gh + - name: unidesk-code-queue + - name: unidesk-cicd + - name: unidesk-decision + - name: unidesk-ops + - name: unidesk-sub2api + submodules: false + lfs: false + payloadDefaults: + title: Artificer distributed development task + prompt: Follow the bundled dad-dev and UniDesk skills. Report progress to the relevant GitHub issue before risky or long operations. + references: [] + metadata: + aipod: Artificer + source: config/aipods/artificer.yaml + dispatchDefaults: + runnerJob: + namespace: agentrun-v01 diff --git a/docs/reference/spec-v01-aipod-spec.md b/docs/reference/spec-v01-aipod-spec.md new file mode 100644 index 0000000..d250c91 --- /dev/null +++ b/docs/reference/spec-v01-aipod-spec.md @@ -0,0 +1,125 @@ +# v0.1 AipodSpec 规格 + +`AipodSpec` 是 AgentRun `v0.1` 的声明式 agent 装配规格。它把已有的 `backendProfile`、`executionPolicy.secretScope`、`ResourceBundleRef.kind="gitbundle"`、Queue task 和 Session turn 装配入口集中到 YAML 文件中,避免把某个 agent 的模型、SecretRef、gitbundle、skill 或 tool 写死在 manager、runner 或 CLI 源码里。 + +## 设计边界 + +- `AipodSpec` 只声明装配意图,不保存 API key、SSH private key、token、`auth.json`、`config.toml` 或其他 Secret 明文。 +- manager 通过 `/api/v1/aipod-specs` 对 YAML 做增删改查;默认目录为仓库 `config/aipods/`,可用 `AGENTRUN_AIPOD_SPEC_DIR` 覆盖。 +- CLI 通过 `aipod-specs list|show|render|apply|delete` 管理规格,通过 `queue submit --aipod ` 或 `sessions turn --aipod ` 使用规格。 +- `render` 只把规格展开为标准 Queue task / Session turn 输入,输出必须脱敏,只显示 SecretRef 名称、key、projection、gitbundle 摘要和 `valuesPrinted=false`。 +- `AipodSpec` 不引入第二套 scheduler、runner、backend adapter 或 Code Queue;最终执行仍走 AgentRun Queue、Sessions、runner Job 和 Codex app-server stdio backend。 + +## YAML 结构 + +最小结构: + +```yaml +apiVersion: agentrun.pikastech.local/v0.1 +kind: AipodSpec +metadata: + name: Artificer +spec: + backendProfile: sub2api + executionPolicy: + sandbox: workspace-write + approval: never + timeoutMs: 1800000 + network: enabled + secretScope: + allowCredentialEcho: false + providerCredentials: + - profile: sub2api + secretRef: + name: agentrun-v01-provider-sub2api + keys: [auth.json, config.toml] + resourceBundleRef: + kind: gitbundle + repoUrl: git@github.com:pikasTech/unidesk.git + ref: master + bundles: + - subpath: .agents/skills + targetPath: .agents/skills +``` + +字段规则: + +- `apiVersion` 固定为 `agentrun.pikastech.local/v0.1`,`kind` 固定为 `AipodSpec`。 +- `metadata.name` 是 CLI/API 查找名,允许大小写,但文件落盘名会归一为安全 YAML 文件名。 +- `spec.backendProfile` 使用 AgentRun 已注册或动态的 Codex-compatible profile slug,例如 `codex`、`deepseek`、`minimax-m3`、`dsflash-go` 或 `sub2api`。 +- `spec.model.model` 会展开为 command payload 的 `model` 字段;完整 `spec.model` 同时进入 `payload.modelConfig`,用于保留 `reasoningEffort` 等声明但不作为 Secret 输出。 +- `spec.executionPolicy` 复用 run 的执行策略校验,且必须恰好包含一个匹配 `backendProfile` 的 provider credential SecretRef。 +- `spec.resourceBundleRef` 复用 RuntimeAssembly 的 gitbundle 规则,可为 `null`,但需要注入 skill/tool 时必须使用 gitbundle。 +- `spec.payloadDefaults` 与 CLI render 输入合并;用户 prompt 通过 `--prompt`、`--prompt-file` 或 `--prompt-stdin` 覆盖或补充。 + +## Artificer 默认规格 + +仓库内置 `config/aipods/artificer.yaml`,名称为 `Artificer`。它的长期目标是承接 UniDesk 分布式开发任务: + +- 使用 `backendProfile=sub2api`,模型声明为 `gpt-5.5`,reasoning effort 为 `xhigh`。 +- 通过 provider SecretRef `agentrun-v01-provider-sub2api` 获取 `auth.json` 与 `config.toml`。 +- 通过 `toolCredentials` 获取 UniDesk SSH 透传 token:`agentrun-v01-tool-unidesk-ssh` -> env `UNIDESK_SSH_CLIENT_TOKEN`。 +- 通过 `toolCredentials` 获取 GitHub SSH 凭据:`agentrun-v01-tool-github-ssh` -> volume `/home/agentrun/.ssh`。 +- 通过 gitbundle 装配 UniDesk repo 中的 `unidesk-*` skills、AgentRun repo 中的 `tools/trans`、`tools/tran`、`tools/apply_patch`,以及 `agent_skills` repo 中的 `dad-dev`、`cli-spec`、`docs-spec`、`git-spec`。 +- 通过 `requiredSkills[]` 在 runner 启动 backend 前校验 `dad-dev` 与 UniDesk 关键 skills 已被 materialize;缺失时必须 `required-skill-unavailable`,不得使用模型默认 skill 猜测。 + +## Git mirror + +`resourceBundleRef.gitMirror` 用于把 GitHub repo URL 改写为 G14 git mirror read URL,提高 runner checkout 的稳定性和速度。 + +规则: + +- `enabled` 缺省为 `true`;显式 `false` 时不改写。 +- `baseUrl` 必须是无 credentials、query、fragment 的 HTTP(S) URL;未设置时 runner 使用 `AGENTRUN_GIT_MIRROR_BASE_URL`,再缺省为 `http://git-mirror-http.devops-infra.svc.cluster.local`。 +- 支持 `git@github.com:owner/repo.git`、`ssh://git@github.com/owner/repo.git`、`ssh://git@ssh.github.com:443/owner/repo.git` 和 `https://github.com/owner/repo.git`。 +- 非 GitHub URL 不改写,仍按原 `repoUrl` fetch。 +- materialization event 必须输出 `repoUrl`、`fetchRepoUrl`、`mirrorUsed`、`mirrorBaseUrl`、requested ref/commit 和实际 commit;不得输出 credential 值。 + +## Tool credential projection + +`toolCredentials[]` 支持两类 projection: + +- `env`:把 Secret key 作为环境变量注入 runner,例如 `UNIDESK_SSH_CLIENT_TOKEN`。 +- `volume`:把 Secret keys 作为只读文件挂载到 `/home/agentrun/` 下,例如 GitHub SSH 的 `/home/agentrun/.ssh`。 + +约束: + +- `unidesk-ssh` 必须使用 env projection,且 env/key 都必须是 `UNIDESK_SSH_CLIENT_TOKEN`。 +- volume projection 的 `mountPath` 必须位于 `/home/agentrun/` 下,不能包含 `..`。 +- runner Job response、dry-run manifest、event 和日志只能显示 SecretRef 名称、key、projection kind 与 mount path,不得显示 Secret value。 + +## API 与 CLI + +REST API: + +- `GET /api/v1/aipod-specs`:列出可用规格。 +- `POST /api/v1/aipod-specs`:从 `{ yaml }` 或 `{ spec }` 创建/更新规格。 +- `GET /api/v1/aipod-specs/:name`:查看规格和摘要。 +- `PUT /api/v1/aipod-specs/:name`:按 URL 名称更新规格,URL name 必须匹配 `metadata.name`。 +- `DELETE /api/v1/aipod-specs/:name`:删除规格文件。 +- `POST /api/v1/aipod-specs/:name/render`:把规格和本次输入展开为 Queue task。 + +Artificer 依赖的 GitHub SSH Secret 由 `tool-credentials set-github-ssh` 单独 bootstrap。`AipodSpec` 只引用 SecretRef,不读取或保存 SSH private key。 + +CLI: + +```bash +./scripts/agentrun aipod-specs list +./scripts/agentrun aipod-specs show Artificer +./scripts/agentrun aipod-specs render Artificer --prompt-stdin +./scripts/agentrun aipod-specs apply --yaml-stdin +./scripts/agentrun aipod-specs delete Artificer +./scripts/agentrun queue submit --aipod Artificer --prompt-stdin --idempotency-key +./scripts/agentrun sessions turn --aipod Artificer --prompt-stdin +./scripts/agentrun tool-credentials set-github-ssh --private-key-file --known-hosts-file [--config-file ] +``` + +所有 mutating CLI 必须短返回 JSON;`--dry-run` 只输出 mutation plan 和确认命令,不写 manager。 + +## 测试规格 + +- A1:`config/aipods/artificer.yaml` 能被 manager list/show/render,render 结果包含 `backendProfile=sub2api`、`model=gpt-5.5`、`reasoningEffort=xhigh`、provider SecretRef、UniDesk SSH env projection、GitHub SSH volume projection、AgentRun runner tools gitbundle 和 gitbundle requiredSkills。 +- A2:`queue submit --aipod Artificer --dry-run` 输出标准 `queue-submit-plan`,且 `idempotencyKey`、prompt 与 metadata 被保留。 +- A3:GitHub URL 在启用 `gitMirror` 后改写到 mirror base URL;非 GitHub URL 不改写。 +- A4:runner Job dry-run 支持 tool credential volume mount,并且 response/manifest/event 不泄漏 Secret 明文。 +- A5:`bun run check` 和 `bun run self-test` 必须覆盖 A1-A4。 diff --git a/docs/reference/spec-v01-cli.md b/docs/reference/spec-v01-cli.md index 10b014f..0e18a42 100644 --- a/docs/reference/spec-v01-cli.md +++ b/docs/reference/spec-v01-cli.md @@ -54,12 +54,16 @@ CLI 官方 TypeScript 入口固定为 `scripts/agentrun-cli.ts`。在 G14 非交 ./scripts/agentrun provider-profiles remove ./scripts/agentrun provider-profiles set-key --key-stdin ./scripts/agentrun provider-profiles validate [--wait] [--timeout-ms ] +./scripts/agentrun tool-credentials list +./scripts/agentrun tool-credentials show github-ssh|unidesk-ssh +./scripts/agentrun tool-credentials set-github-ssh --private-key-file --known-hosts-file [--config-file ] [--dry-run] ./scripts/agentrun backends list ./scripts/agentrun server start [--port ] [--host ] [--foreground] ./scripts/agentrun server status [--port ] ./scripts/agentrun server logs [--port ] [--tail-bytes ] [--log-file ] ./scripts/agentrun server stop [--port ] ./scripts/agentrun queue submit --json-stdin|--json-file [--dry-run] +./scripts/agentrun queue submit --aipod [--prompt-stdin|--prompt-file |--prompt ] [--idempotency-key ] [--dry-run] ./scripts/agentrun queue list [--queue ] [--state ] [--cursor ] [--limit ] [--full|--raw] ./scripts/agentrun queue show [--full|--raw] ./scripts/agentrun queue stats [--queue ] @@ -71,11 +75,17 @@ CLI 官方 TypeScript 入口固定为 `scripts/agentrun-cli.ts`。在 G14 非交 ./scripts/agentrun sessions ps [--state default|running|unread|terminal|idle|all] [--profile codex|deepseek|minimax-m3|dsflash-go|M3] [--reader-id ] ./scripts/agentrun sessions show [--reader-id ] ./scripts/agentrun sessions turn [sessionId] [--json-stdin|--json-file ] [--prompt-stdin|--prompt-file |--prompt ] [--profile codex|deepseek|minimax-m3|dsflash-go|M3] [--runner-json-stdin|--runner-json-file ] [--no-runner-job] +./scripts/agentrun sessions turn [sessionId] --aipod [--prompt-stdin|--prompt-file |--prompt ] [--runner-json-stdin|--runner-json-file ] [--no-runner-job] ./scripts/agentrun sessions steer [--prompt-stdin|--prompt-file |--prompt ] ./scripts/agentrun sessions cancel [--reason ] ./scripts/agentrun sessions trace [--after-seq ] [--limit ] [--run-id ] [--include-output] [--seq |--event-id |--item-id ] [--detail-scan-pages ] [--full|--raw] ./scripts/agentrun sessions output [--after-seq ] [--limit ] [--run-id ] [--include-output] [--seq |--event-id |--item-id ] [--detail-scan-pages ] [--full|--raw] ./scripts/agentrun sessions read [--reader-id ] +./scripts/agentrun aipod-specs list +./scripts/agentrun aipod-specs show +./scripts/agentrun aipod-specs render [--json-stdin|--json-file ] [--prompt-stdin|--prompt-file |--prompt ] +./scripts/agentrun aipod-specs apply [name] --yaml-stdin|--yaml-file [--dry-run] +./scripts/agentrun aipod-specs delete ``` 具体参数可以在实现时按代码结构微调,但行为必须保持: @@ -83,6 +93,8 @@ CLI 官方 TypeScript 入口固定为 `scripts/agentrun-cli.ts`。在 G14 非交 - 创建类命令返回 `runId`、`commandId`、status 和下一步 poll command。 - `runner start` 返回 attemptId、job/process identity、logPath 和后续 status/events 命令。 - `runner jobs` / `runner job-status` 返回 manager 持久化的 runner Job 最小状态摘要,包括 attemptId、runnerId、namespace、jobName、phase、terminalStatus、logPath、retention 和 redacted Kubernetes identity;业务方不需要直连 Kubernetes 才能定位当前 attempt。 +- `aipod-specs render` 与 `queue submit --aipod` 必须调用同一 manager `/api/v1/aipod-specs/:name/render` 路径,把 YAML 展开为标准 Queue task;CLI 不得在本地复制一套 render 逻辑。 +- `queue submit --aipod ` 只接受本次任务输入(prompt、idempotencyKey、tenant/project/queue/lane/provider 覆盖等),模型、provider credential、tool credential、gitbundle 和 requiredSkills 由 [spec-v01-aipod-spec.md](spec-v01-aipod-spec.md) 定义。 - 查询类命令返回当前 state、terminal_status、failureKind、event cursor 或 logPath。 - `events` 默认分页且有界,必须支持 `afterSeq` 和 `limit`;默认输出保持 manager raw JSON。 - `events --summary` 返回低噪声 JSON summary,单条至少包含 `seq`、`type`、`method`、`status`、`command`、`text`、`exitCode`、`durationMs`、`outputTruncated`、`outputBytes`、`outputSummary` 和 `summary`;summary 文本必须压缩换行并继续沿用 redaction,不能泄漏 Secret/env value。 @@ -94,6 +106,8 @@ CLI 官方 TypeScript 入口固定为 `scripts/agentrun-cli.ts`。在 G14 非交 - `server stop` 必须按 pidFile 与端口进程清理本地 manager,并返回 before/after 状态;不得要求人工用 `ps/kill/ss` 组合命令清理常见临时服务。 - `secrets codex render --dry-run` 返回 Codex stdio profile Secret 创建计划、输入文件 bytes/hash、SecretRef、manifest 摘要和 apply 命令形状;`--profile codex` 默认 Secret name 为 `agentrun-v01-provider-codex`,`--profile deepseek` 默认 Secret name 为 `agentrun-v01-provider-deepseek`,`--profile minimax-m3` 默认 Secret name 为 `agentrun-v01-provider-minimax-m3`,`--profile dsflash-go` 默认 Secret name 为 `agentrun-v01-provider-dsflash-go` 并包含 `model-catalog.json`;它不得输出 Secret value 或执行 Kubernetes 写操作。 - `provider-profiles` 命令族调用 manager REST 管理 API,覆盖 profile status、删除、API Key 写入和 canary 验证。`set-key --key-stdin` 从 stdin 读取 API Key,响应只显示 SecretRef、resourceVersion、hash 后缀和 failureKind;不得输出 key、Codex auth/config 或 Secret data。 +- `tool-credentials list|show` 调用 manager REST 读取固定 tool credential 状态,只显示 SecretRef、key presence 和 hash 后缀;不得输出 Secret data。 +- `tool-credentials set-github-ssh` 是 GitHub SSH runtime Secret 的受控 bootstrap 入口;输入只能来自本地文件,CLI/manager response 只能显示 bytes、hash suffix、SecretRef 和 `valuesPrinted=false`,不得输出 private key、known_hosts 或 ssh config 内容。 - `backends list` 必须显示 `codex`、`deepseek`、`minimax-m3` 与 `dsflash-go` profile 的 backendKind、protocol、transport、command、requiredSecretKeys 和状态;`dsflash-go` 的 `requiredSecretKeys` 必须包含 `model-catalog.json`;已配置的动态 provider profile(例如 `hy`)必须同样可见,并带动态 discovery 状态;不得因为某个 provider Secret 尚未配置就隐藏 capability。 - `queue submit/read/cancel/dispatch/refresh --dry-run` 必须只返回 non-mutating plan,固定 `dryRun=true`、`mutation=false`,不得创建 task、mark read、cancel、dispatch、refresh 或启动 runner job。 - `queue dispatch` 是 Q2 的受控手动调度入口,只对单个 task 显式创建 attempt 和 Core run/command/runner job;不得伪装成自动 scheduler;带 `--dry-run` 时只读取 task 并展示将要 POST 的路径和有界 request 摘要。 @@ -162,5 +176,6 @@ CLI 官方 TypeScript 入口固定为 `scripts/agentrun-cli.ts`。在 G14 非交 | CLI 测试规格 | 已定义/已验证主闭环 | 综合联调见 [spec-v01-validation.md](spec-v01-validation.md);每次发布仍按手动交互验收复跑。 | | `deepseek` profile CLI | 已实现/已通过主闭环 | `secrets codex render --profile deepseek`、`backends list`、`runner start --backend`、`runner job` 和 JSON 错误可见性已实现;真实 CLI/RESTful 联调已通过 `codex -> deepseek -> codex` 切换主闭环。 | | Provider profile 管理 CLI | 已实现 | `provider-profiles list/show/remove/set-key/validate` 调用 manager REST API,用于 HWLAB 委托和 operator 验收;输出必须持续保持 Secret/API Key 脱敏。 | +| Tool credential 管理 CLI | 已实现 | `tool-credentials list/show/set-github-ssh` 调用 manager REST API,用于 Artificer GitHub SSH Secret bootstrap;输出只包含 SecretRef、key presence、bytes 和 hash suffix。 | | `minimax-m3` profile CLI | 已实现/待真实主闭环 | `secrets codex render --profile minimax-m3`、`backends list`、`runner start --backend`、`runner job`、`sessions turn --profile minimax-m3|M3` 和 JSON 错误可见性已实现;真实 CLI/RESTful 联调需要按 `codex -> deepseek -> minimax-m3 -> codex` 手动验收。 | | `dsflash-go` profile CLI | 已实现/待真实主闭环 | `secrets codex render --profile dsflash-go --model-catalog-file`、`backends list`、`runner start --backend`、`runner job`、`sessions turn --profile dsflash-go` 和 JSON 错误可见性已实现;真实 CLI/RESTful 联调需要按 `codex -> deepseek -> minimax-m3 -> dsflash-go -> codex` 手动验收,并确认 compact 404 分类为 `provider-compact-unsupported`。 | diff --git a/docs/reference/spec-v01-documentation-governance.md b/docs/reference/spec-v01-documentation-governance.md index f382cd7..7693356 100644 --- a/docs/reference/spec-v01-documentation-governance.md +++ b/docs/reference/spec-v01-documentation-governance.md @@ -31,6 +31,7 @@ - `spec-v01-postgres.md`:Postgres durable store、schema migration 和 DB SecretRef。 - `spec-v01-secret-distribution.md`:Code Agent provider credential、Postgres DSN 和运行时 Secret 分发。 - `spec-v01-runtime-assembly.md`:runner/backend 启动前的四要素 RuntimeAssembly 装配模型;其他 spec 只交叉引用,不重复定义四要素字段。 +- `spec-v01-aipod-spec.md`:声明式 AipodSpec YAML、Artificer 默认规格、git mirror、tool credential projection、manager API 和 `--aipod` CLI。 - `spec-v01-queue.md`:AgentRun Queue 直接吸收 UniDesk Code Queue 的 RESTful API、CLI、数据模型、Session 分层和验收规格。 - `spec-v01-validation.md`:两层验证模型、自测试和综合联调验收。 - `spec-v01-agentrun-mgr.md`:manager REST API、tenant boundary、runner claim、event/status authority。 @@ -63,6 +64,7 @@ - 服务总览规格:[spec-v01-services.md](spec-v01-services.md)。 - Postgres durable store 规格:[spec-v01-postgres.md](spec-v01-postgres.md)。 - Secret/provider credential 分发规格:[spec-v01-secret-distribution.md](spec-v01-secret-distribution.md)。 +- AipodSpec 声明式装配规格:[spec-v01-aipod-spec.md](spec-v01-aipod-spec.md)。 - Queue 吸收规格:[spec-v01-queue.md](spec-v01-queue.md)。 - 验证模型规格:[spec-v01-validation.md](spec-v01-validation.md)。 - Manager 服务规格:[spec-v01-agentrun-mgr.md](spec-v01-agentrun-mgr.md)。 @@ -75,7 +77,7 @@ ## 验收标准 - `AGENTS.md` 索引本文和其他 `spec-v01-*` 规格。 -- `docs/reference/` 中存在 `spec-v01-documentation-governance.md`、`spec-v01-services.md`、`spec-v01-cicd.md`、`spec-v01-postgres.md`、`spec-v01-secret-distribution.md`、`spec-v01-runtime-assembly.md`、`spec-v01-queue.md`、`spec-v01-validation.md`、`spec-v01-agentrun-mgr.md`、`spec-v01-agentrun-runner.md`、`spec-v01-backend-adapter.md`、`spec-v01-backend-codex.md`、`spec-v01-cli.md` 和 `spec-v01-scheduler.md`。 +- `docs/reference/` 中存在 `spec-v01-documentation-governance.md`、`spec-v01-services.md`、`spec-v01-cicd.md`、`spec-v01-postgres.md`、`spec-v01-secret-distribution.md`、`spec-v01-runtime-assembly.md`、`spec-v01-aipod-spec.md`、`spec-v01-queue.md`、`spec-v01-validation.md`、`spec-v01-agentrun-mgr.md`、`spec-v01-agentrun-runner.md`、`spec-v01-backend-adapter.md`、`spec-v01-backend-codex.md`、`spec-v01-cli.md` 和 `spec-v01-scheduler.md`。 - `AGENTS.md` 和 `docs/reference/` 不得把旧 `agentrun_dev`、`agentrun_prod`、`G14:/root/agentrun` 或 `/root/agentrun` 写成当前 source worktree、namespace、发布目标或验收目标;只允许在废弃说明和历史背景中提及。 - `docs/` 根目录不新增临时 Markdown 报告或 JSON dump。 - 仓库根目录不存在 `TEST.md`;测试场景维护在对应 `spec-v01-*.md` 的“测试规格”小节。 diff --git a/docs/reference/spec-v01-runtime-assembly.md b/docs/reference/spec-v01-runtime-assembly.md index fb368cc..b42f9a8 100644 --- a/docs/reference/spec-v01-runtime-assembly.md +++ b/docs/reference/spec-v01-runtime-assembly.md @@ -53,7 +53,7 @@ P0 最小 JSON 形态: | Tool credential | `executionPolicy.secretScope.toolCredentials[]` | 由 runner 按 tool scope 投影为文件或 env,并只暴露给当前 run/command 允许的工具 | 用于 GitHub PR、issue、UniDesk SSH passthrough、artifact registry 等 agent shell 工具能力;不等同于 AgentRun integration,不触发 GitHub sink/OA/Event 之类外部动作记录。 | | Short-lived execution context | runner-job `transientEnv` | 单次 Job env,response/dry-run/event 只显示 name/hash | 只用于业务 dispatcher 生成的短期或 owner-scoped runtime context,以及 manager 受控默认值补齐的非敏感服务地址;不得承载 provider credential、GitHub token、UniDesk SSH client token 或长期 SSH key。 | -`toolCredentials` 是装配 SPEC 中的受控扩展槽位,用于把 agent 运行时需要的外部工具授权从“临时 env”收敛为 SecretRef。`v0.1` 支持 GitHub PR/issue 与 UniDesk SSH passthrough 所需的最小 env projection,例如: +`toolCredentials` 是装配 SPEC 中的受控扩展槽位,用于把 agent 运行时需要的外部工具授权从“临时 env”收敛为 SecretRef。`v0.1` 支持 env projection 与只读 volume projection:env 用于 `GH_TOKEN`、`UNIDESK_SSH_CLIENT_TOKEN` 这类单 key 工具变量,volume 用于 GitHub SSH 目录等文件型凭据。例如: ```json { @@ -77,6 +77,16 @@ P0 最小 JSON 形态: "keys": ["UNIDESK_SSH_CLIENT_TOKEN"] }, "projection": { "kind": "env", "envName": "UNIDESK_SSH_CLIENT_TOKEN" } + }, + { + "tool": "github", + "purpose": "github-ssh", + "secretRef": { + "namespace": "agentrun-v01", + "name": "agentrun-v01-tool-github-ssh", + "keys": ["id_ed25519", "known_hosts", "config"] + }, + "projection": { "kind": "volume", "mountPath": "/home/agentrun/.ssh" } } ] } @@ -90,6 +100,8 @@ P0 最小 JSON 形态: - dry-run manifest、runner job record、event、trace、日志和 CLI 输出只能显示 tool、purpose、SecretRef 名称/key、projection kind 和 `valuesPrinted=false`。 - GitHub PR 能力属于 agent shell/tool 运行能力,不是 AgentRun Queue integration,也不要求新增 GitHub sink、OA sink、notification 或 Event Flow。 - `tool=unidesk-ssh` 只允许投影 Secret key `UNIDESK_SSH_CLIENT_TOKEN` 到同名 env。该 token 是 UniDesk frontend `/ws/ssh` 的 scoped client token,route allowlist 由 UniDesk frontend 配置约束;它不得携带 provider token、主 server SSH key 或完整 frontend 登录态。 +- `projection.kind="volume"` 只能挂载到 `/home/agentrun/` 下的路径,不能包含 `..`;runner Job 必须用只读 Secret volume mount,不得把文件型凭据复制进 event、logs 或 Queue task。 +- GitHub SSH 能力应使用文件型 SecretRef,例如 `id_ed25519`、`known_hosts` 和 `config` 投影到 `/home/agentrun/.ssh`;它仍只属于当前 run/command 的工具能力,不是 manager 自身 GitHub 写入权限。 - `tool=unidesk-ssh` 所需的 UniDesk endpoint 不属于 SecretRef。runner-job env 必须具备 `UNIDESK_MAIN_SERVER_IP`、`UNIDESK_MAIN_SERVER_HOST` 或 `UNIDESK_FRONTEND_URL` 三者之一;调用方可以通过 `transientEnv` 显式传入,若未传入,manager 可以从受控默认配置自动补齐非敏感 endpoint env。 - manager 自动补齐 endpoint 时只追加 env name/value 到本次 runner Job,不把 value 写入 run、command、event 或 result 明文;trace/response 只能显示 env name、count、hash 和 `valuesPrinted=false`。若 run 请求了 `tool=unidesk-ssh`,但调用方和 manager 默认都没有 endpoint,runner-job 创建必须在装配阶段返回结构化 `schema-invalid`,不能让 agent 进入 turn 后靠 prompt 猜 endpoint。 - 发现 agent shell 缺少 `gh`、`curl`、UniDesk SSH passthrough token 或其他工具凭证时,只能记录为装配能力缺口;不得用 `transientEnv` 或 issue 评论里的明文 token 绕过。 @@ -162,6 +174,7 @@ HWLAB Workbench 的 project/workspace 不属于 RuntimeAssembly 四要素,也 - P0 固定 `kind="gitbundle"`,输入只依赖 Git repo/ref 与 `bundles[]`;runner 从 `resourceBundleRef.ref`、run 的 `workspaceRef.branch` 或 `HEAD` 解析实际 commit,再用该 materialized commit 作为内容身份。 - `commitId` 是可选 pin 或历史请求 hint;提供时必须是 full commit sha,但 HWLAB gitbundle 默认不得依赖 cloud-api/CI/CD rollout 注入的 commitId。 - `bundles[]` 每一项只允许 `{ name?, repoUrl?, ref?, commitId?, subpath, target_path }`;缺省 repo/ref/commit 继承顶层解析结果。 +- `gitMirror` 可把 GitHub repo URL 改写到受控 mirror read URL;event/result 必须同时记录原始 `repoUrl`、实际 `fetchRepoUrl`、`mirrorUsed` 和 `mirrorBaseUrl`,不能只显示理论 repo。 - `subpath` 必须留在 checkout 内,`target_path` 必须留在 runner workspace 内;runner 按 `subpath -> target_path` 复制文件或目录。 - `credentialRef` 只用于拉取私有 Git repo,不等同于 backend API KEY。 - 不支持上传文件、对象存储 artifact、任意 ConfigMap 文件袋、inline seed 或旧字段;旧 `toolAliases`、`skillRefs`、`workspaceFiles`、`subdir`、`sparsePaths` 输入必须直接 schema-invalid。 @@ -172,7 +185,7 @@ HWLAB Workbench 的 project/workspace 不属于 RuntimeAssembly 四要素,也 runner 对 workspace `tools/` 做统一装配:顶层带 shebang 的脚本会被 `chmod +x`,`tools/` 会暴露到 `PATH`。如果 runtime 配置了单独的 `AGENTRUN_RESOURCE_BIN_PATH`,runner 只能在该目录写入执行原始 workspace tool 的 shim,不能复制 tool 文件导致 `dirname "$0"`、相对 import 或辅助源码解析到 bin 目录。非 shebang 文件是随 bundle 复制的源码、测试或辅助文件,不作为可执行工具发现,也不触发 schema-invalid。短命令名称来自 repo 内真实文件,例如 `tools/hwpod`,repo tool 本身承担业务 wrapper 语义。 -AgentRun 自身仓库必须提供 `tools/tran` 与 `tools/trans`,用于承接 UniDesk frontend `/ws/ssh` 的 scoped client-token 透传。runner 只通过 `executionPolicy.secretScope.toolCredentials[]` 投影 `UNIDESK_SSH_CLIENT_TOKEN`,并通过调用方 `transientEnv` 或 manager 受控默认值注入非敏感 `UNIDESK_MAIN_SERVER_IP`、`UNIDESK_MAIN_SERVER_HOST` 或 `UNIDESK_FRONTEND_URL`;工具不得读取 provider token、主 server SSH key 或完整 frontend 登录态。`tran --help` 必须输出 JSON,并列出当前支持的最小开发面:host/host workspace `script`、`argv`、普通 ssh-like 命令、`k3s kubectl`、`k3s script` 和 k3s workload `argv/script`。未实现的 `apply-patch`、`upload`、`download` 和 Windows route 必须显式 `unsupported-operation`,不能静默改走不受控 shell 拼接或 token fallback。 +AgentRun 自身仓库必须提供 `tools/tran`、`tools/trans` 与 `tools/apply_patch`,用于承接 UniDesk frontend `/ws/ssh` 的 scoped client-token 透传。runner 只通过 `executionPolicy.secretScope.toolCredentials[]` 投影 `UNIDESK_SSH_CLIENT_TOKEN`,并通过调用方 `transientEnv` 或 manager 受控默认值注入非敏感 `UNIDESK_MAIN_SERVER_IP`、`UNIDESK_MAIN_SERVER_HOST` 或 `UNIDESK_FRONTEND_URL`;工具不得读取 provider token、主 server SSH key 或完整 frontend 登录态。`tran --help` 必须输出 JSON,并列出当前支持的最小开发面:host/host workspace `script`、`argv`、普通 ssh-like 命令、host workspace `apply-patch`、`k3s kubectl`、`k3s script`、k3s workload `argv/script` 和 k3s workload `apply-patch`。runner 侧 `apply-patch` 只把 stdin patch 和相邻 `tools/apply_patch` helper 传到目标 route 执行,不接触或回显 Secret 值;`upload`、`download` 和 Windows route 未实现时必须显式 `unsupported-operation`,不能静默改走不受控 shell 拼接或 token fallback。 #### promptRefs @@ -243,6 +256,7 @@ skill 只来自 gitbundle 复制进 workspace 的 `.agents/skills//SKILL.m - GitHub PR、issue、UniDesk SSH passthrough 或其他 shell/tool 授权只能通过 `executionPolicy.secretScope.toolCredentials[]` 的 SecretRef 装配进入 runner。 - CLI、Queue task、runner job response、dry-run manifest、event 和日志不得输出 token、SSH private key 或 credential 文件正文。 +- env projection 和 volume projection 都必须能在 runner Job dry-run 中看到 SecretRef 名称/key 和 projection kind;volume projection 必须显示只读 mount path。 - 缺少 tool credential 时,run/command 必须返回可判定的 `secret-unavailable`、`tenant-policy-denied` 或明确 blocker,不能伪装成 agent 业务失败。 - `transientEnv` 不得用于 GitHub token、UniDesk SSH client token、长期 SSH key、provider API key 或其他 provider/tool 可复用 credential。HWLAB dispatcher 生成并限定 owner/HWPOD/runtime scope 的 `HWLAB_API_KEY` 可以作为单次 runner Job runtime context 进入 `transientEnv`,但正式 Kubernetes Job 必须通过 per-job Secret 和 `valueFrom.secretKeyRef` 投影,不能作为 pod spec plain env value;AgentRun 不保存、不解释、不回显其明文。 @@ -259,6 +273,7 @@ skill 只来自 gitbundle 复制进 workspace 的 `.agents/skills//SKILL.m - checkout 只能进入允许 workspace 前缀,不能覆盖 `/app`、Secret projection、profile runtime home 或 session 目录。 - run payload 不携带文件正文、env dump、Secret value 或大型 artifact。 - 若提供 `bundles[]`,必须能看到每个 `subpath -> target_path` 的复制摘要;旧字段输入必须 schema-invalid。 +- 若启用 `gitMirror`,必须能看到 GitHub URL 被改写到 mirror `fetchRepoUrl`;非 GitHub URL 必须保留原 fetch URL 并显示 `mirrorUsed=false`。 - 若提供 `promptRefs`,必须能看到每个 prompt 的 `name/path/sha256/bytes/inject`,新 thread 首轮 `initialPromptInjected=true`,resume turn `initialPromptInjected=false`。 - 若 bundle 复制了 `.agents/skills`,必须能看到 skillDirs 聚合摘要、skill 名称、manifest hash/bytes 和来源 bundle;若提供 `requiredSkills`,必须看到成功路径的 requiredSkills hash/bytes,以及缺失路径的 `required-skill-unavailable` blocker。不能显示模型默认 skill 列表当作业务 skill。 @@ -281,6 +296,6 @@ skill 只来自 gitbundle 复制进 workspace 的 `.agents/skills//SKILL.m | `ProfileRef` | 已实现/待 dsflash-go 真实主闭环 | `codex`、`deepseek` 与 `minimax-m3` 已通过 SecretRef、writable runtime home 和真实 stdio turn 验证;MiniMax-M3 已通过 HWLAB 显式 session 原入口复测。`dsflash-go` 已补齐 SecretRef/model catalog 装配、自测试和 legacy key 归一,仍需完成真实 runtime 与 HWLAB 原入口复测;后续只允许作为 profile/config/SecretRef/model catalog 选择,不新增直连 backend。 | | `SessionRef` | 已实现最小持久化 | manager 持久化 `sessionId/conversationId/threadId`,run 创建会解析既有 session,runner 按 threadId resume;session 不保存 credential 文件,TTL/GC 后续细化。 | | `SessionRef` | v0.1.1 已实现/已通过 HWLAB v0.2 原入口复测 | manager 持久化 `sessionId/conversationId/threadId` + 每个 session 绑 RWO PVC(`agentrun-v01-session-`),runner Job 把 PVC 直接挂到 `${CODEX_HOME}/`,codex app-server 自己落盘;runner pod 删除后 replacement runner 仍复用同一 SessionRef/PVC/thread,禁止 copy/restore、replacement threadId 和 fake resume。 | -| `ResourceBundleRef` | 已实现 `kind="gitbundle"` materialization/promptRefs/tools/skillDirs/requiredSkills 装配 | `repoUrl + ref/materialized commit + bundles[]` 已进入 run schema 和 runner checkout,workspace 受 `AGENTRUN_WORKSPACE_ROOT` 限制,event/result 记录 requested ref/commit、actual commit、tree/workspace/bundles 摘要;`tools/` PATH、`promptRefs` thread-start 注入、`.agents/skills` 目录发现和 required skill 校验已实现。 | -| `toolCredentials` | 已实现最小 env projection | GitHub PR 和 UniDesk SSH passthrough 等 agent shell/tool 授权通过装配 SPEC 的 SecretRef 进入 runner;v0.1 支持 `tool=github` 与 `tool=unidesk-ssh`、`projection.kind=env`,runner Job 使用 `valueFrom.secretKeyRef` 注入,不用 `transientEnv` 绕过。 | +| `ResourceBundleRef` | 已实现 `kind="gitbundle"` materialization/promptRefs/tools/skillDirs/requiredSkills 装配 | `repoUrl + ref/materialized commit + bundles[]` 已进入 run schema 和 runner checkout,workspace 受 `AGENTRUN_WORKSPACE_ROOT` 限制,event/result 记录 requested ref/commit、actual commit、tree/workspace/bundles 摘要;`gitMirror` 会把 GitHub URL 改写到受控 mirror 并记录 `fetchRepoUrl/mirrorUsed`;`tools/` PATH、`promptRefs` thread-start 注入、`.agents/skills` 目录发现和 required skill 校验已实现。 | +| `toolCredentials` | 已实现 env 与 volume projection | GitHub PR、GitHub SSH 和 UniDesk SSH passthrough 等 agent shell/tool 授权通过装配 SPEC 的 SecretRef 进入 runner;v0.1 支持 `tool=github` 与 `tool=unidesk-ssh`、`projection.kind=env|volume`,runner Job 使用 `valueFrom.secretKeyRef` 或只读 Secret volume 注入,不用 `transientEnv` 绕过。 | | `transientEnv` | 已实现 per-job SecretRef 投影 | Queue dispatch 和 runner-job API 支持短期 runtime env;正式 Kubernetes Job 先创建本次 Job 专属 Secret,再用 `valueFrom.secretKeyRef` 注入 runner env,response/event/trace 只显示 env names、Secret metadata 和 `valuesPrinted=false`。 | diff --git a/docs/reference/spec-v01-secret-distribution.md b/docs/reference/spec-v01-secret-distribution.md index 1030357..7b27903 100644 --- a/docs/reference/spec-v01-secret-distribution.md +++ b/docs/reference/spec-v01-secret-distribution.md @@ -36,7 +36,8 @@ | Provider config | 非敏感 base URL/model 可以来自 `config.toml` 或 ConfigMap;credential value 不得放入 ConfigMap。 | | Tekton Git SSH Secret | `agentrun-ci/agentrun-git-ssh` | | Argo Git SSH Secret | `argocd/agentrun-git-ssh` | -| GitHub Tool Secret | `agentrun-v01-tool-github-pr` key `GH_TOKEN` | +| GitHub PR Tool Secret | `agentrun-v01-tool-github-pr` key `GH_TOKEN` | +| GitHub SSH Tool Secret | `agentrun-v01-tool-github-ssh` keys `id_ed25519`、`known_hosts`、`config` | | UniDesk SSH Tool Secret | `agentrun-v01-tool-unidesk-ssh` key `UNIDESK_SSH_CLIENT_TOKEN` | | Runtime ServiceAccount | `agentrun-v01-mgr`、`agentrun-v01-runner` | @@ -187,6 +188,25 @@ Secret 创建和轮换不由 source branch 自动生成;source branch 只声 失败必须结构化返回 `failureKind`:缺文件、不可读文件或空 credential 归类为 `secret-unavailable`;非法 JSON/TOML 归类为 `schema-invalid`。 +## GitHub SSH Tool Secret bootstrap + +`Artificer` 需要 GitHub SSH 权限时,长期 credential 不进入 Queue payload、`transientEnv`、Git source 或 GitOps Secret data。v0.1 提供受控 manager/CLI 入口把 operator 已有 SSH 文件写入 runtime namespace Secret: + +```bash +./scripts/agentrun tool-credentials set-github-ssh \ + --private-key-file ~/.ssh/id_ed25519_github \ + --known-hosts-file ~/.ssh/known_hosts \ + --config-file ~/.ssh/config +``` + +规则: + +- 写入对象固定为 `agentrun-v01/agentrun-v01-tool-github-ssh`,keys 固定为 `id_ed25519`、`known_hosts`、`config`。 +- CLI dry-run 只显示输入文件 bytes、SecretRef、key 列表和确认命令,不输出文件内容。 +- manager upsert Secret 只返回 resourceVersion、hash suffix、SecretRef 和 redaction 状态,不输出 `data`、`stringData`、private key、known_hosts 或 config 明文。 +- `config` 缺省为只允许 `github.com` 走 `ssh.github.com:443`、`IdentityFile ~/.ssh/id_ed25519`、`StrictHostKeyChecking yes` 和 `UserKnownHostsFile ~/.ssh/known_hosts` 的最小配置;若传入自定义 config,必须包含 `Host` 与 `IdentityFile`。 +- runtime 消费仍必须通过 `executionPolicy.secretScope.toolCredentials[]` 的 volume projection 挂载到 `/home/agentrun/.ssh`;不得把同一 SSH private key 复制到镜像、ConfigMap、payload 或 transient env。 + ## 日志与事件 Redaction - event、trace、日志、CLI 输出、health 和 diagnostics 不得打印 Secret 值。 @@ -223,6 +243,7 @@ Secret 创建和轮换不由 source branch 自动生成;source branch 只声 | DeepSeek profile SecretRef | 已实现/已通过主闭环 | 已新增 `agentrun-v01-provider-deepseek` render、GitOps/RBAC 引用、Job projection、profile 选择和负向 missing-secret 自测试;真实 Secret 创建与 Kubernetes Job projection 已通过主闭环,轮换仍由 Kubernetes 密钥管理流程完成。 | | MiniMax-M3 profile SecretRef | 已实现/待真实主闭环 | 已新增 `agentrun-v01-provider-minimax-m3` render、GitOps/RBAC 引用、Job projection、profile 选择和负向 missing-secret 自测试;真实 Secret 创建使用 HWLAB Code Queue 现有 MiniMax API key,轮换仍由 Kubernetes 密钥管理流程完成。 | | dsflash-go profile SecretRef | 已实现/待真实主闭环 | 已新增 `agentrun-v01-provider-dsflash-go` 的 `model-catalog.json` required key、Secret render、Job projection、writable `CODEX_HOME` 复制和负向 readiness;真实 profile turn 仍需按 provider 管理 canary 和 HWLAB 原入口复测。 | -| Tool credential SecretRef | 已实现最小 env projection | `executionPolicy.secretScope.toolCredentials[]` 已支持 `tool=github`、`tool=unidesk-ssh` 与 `projection.kind=env`,runner Job 通过 Kubernetes `secretKeyRef` 注入 env;CLI、event、runner job response 和 dry-run 只显示 SecretRef/projection 元数据,不输出值。 | +| Tool credential SecretRef | 已实现 env + volume projection | `executionPolicy.secretScope.toolCredentials[]` 已支持 `tool=github`、`tool=unidesk-ssh` 与 `projection.kind=env|volume`,runner Job 通过 Kubernetes `secretKeyRef` 注入 env,或通过只读 Secret volume 挂载到 `/home/agentrun/*`;CLI、event、runner job response 和 dry-run 只显示 SecretRef/projection 元数据,不输出值。 | +| GitHub SSH Tool Secret bootstrap | 已实现 | `tool-credentials set-github-ssh` 通过 manager 受控 upsert runtime Secret,list/show 只展示 SecretRef、key presence 和 hash suffix;Secret value 不进入输出。 | | redaction 最小规则 | 已实现主路径 | Secret dry-run 工具、event、Job dry-run 输出、self-test 和真实主闭环均不打印 Secret value;复杂审计按 [spec-v01-validation.md](spec-v01-validation.md) 人工抽查。 | | 外部 secret manager | 未采用 | 如需 Vault/ExternalSecrets/SOPS,后续单独更新规格。 | diff --git a/scripts/src/cli.ts b/scripts/src/cli.ts index 0b9acb4..14b6c43 100644 --- a/scripts/src/cli.ts +++ b/scripts/src/cli.ts @@ -10,7 +10,7 @@ import { runOnce } from "../../src/runner/run-once.js"; import { renderRunnerJobDryRun } from "../../src/runner/k8s-job.js"; import type { RunnerSessionPvcOptions } from "../../src/runner/k8s-job.js"; import { renderCodexProviderSecretPlan } from "./secret-render.js"; -import type { BackendProfile, CommandRecord, JsonRecord, JsonValue, RunRecord, SessionSummary } from "../../src/common/types.js"; +import type { BackendProfile, CommandRecord, JsonRecord, JsonValue, RenderAipodInput, RenderedAipodQueueTask, RunRecord, SessionSummary } from "../../src/common/types.js"; import { AgentRunError, errorToJson } from "../../src/common/errors.js"; import type { RunnerOnceOptions } from "../../src/runner/run-once.js"; import { backendProfileSpec, isBackendProfile } from "../../src/common/backend-profiles.js"; @@ -57,6 +57,11 @@ async function dispatch(args: ParsedArgs): Promise { if (group === "server" && command === "logs") return serverLogs(args); if (group === "server" && command === "stop") return stopServer(args); if (group === "backends" && command === "list") return client(args).get("/api/v1/backends"); + if ((group === "aipod-specs" || group === "aipods") && command === "list") return client(args).get("/api/v1/aipod-specs"); + if ((group === "aipod-specs" || group === "aipods") && command === "show" && id) return client(args).get(`/api/v1/aipod-specs/${encodeURIComponent(id)}`); + if ((group === "aipod-specs" || group === "aipods") && command === "render" && id) return renderAipodSpecCli(args, id); + if ((group === "aipod-specs" || group === "aipods") && (command === "apply" || command === "set")) return applyAipodSpecCli(args, id ?? null); + if ((group === "aipod-specs" || group === "aipods") && (command === "delete" || command === "rm") && id) return client(args).delete(`/api/v1/aipod-specs/${encodeURIComponent(id)}`); if (group === "provider-profiles" && command === "list") return client(args).get("/api/v1/provider-profiles"); if (group === "provider-profiles" && command === "show" && id) return client(args).get(`/api/v1/provider-profiles/${encodeURIComponent(normalizeProfile(id))}`); if (group === "provider-profiles" && command === "config" && id) return client(args).get(`/api/v1/provider-profiles/${encodeURIComponent(normalizeProfile(id))}/config`); @@ -64,6 +69,9 @@ async function dispatch(args: ParsedArgs): Promise { if (group === "provider-profiles" && command === "set-key" && id) return setProviderProfileKey(args, id); if (group === "provider-profiles" && command === "set-config" && id) return setProviderProfileConfig(args, id); if (group === "provider-profiles" && command === "validate" && id) return validateProviderProfileCli(args, id); + if (group === "tool-credentials" && command === "list") return client(args).get("/api/v1/tool-credentials"); + if (group === "tool-credentials" && command === "show" && id) return client(args).get(`/api/v1/tool-credentials/${encodeURIComponent(id)}`); + if (group === "tool-credentials" && command === "set-github-ssh") return setGithubSshToolCredentialCli(args); if (group === "secrets" && command === "codex" && id === "render") return renderCodexSecret(args); if (group === "sessions" && command === "ps") return listSessions(args); if (group === "sessions" && command === "create") return sessionCreate(args, id ?? null); @@ -689,6 +697,12 @@ function queueSubmitConfirmCommand(args: ParsedArgs): string { return parts.join(" "); } +function queueSubmitAipodConfirmCommand(args: ParsedArgs, aipod: string): string { + const parts = [`./scripts/agentrun queue submit --aipod ${aipod} --prompt-stdin`]; + if (optionalFlag(args, "idempotency-key")) parts.push("--idempotency-key "); + return parts.join(" "); +} + function queueDispatchConfirmCommand(args: ParsedArgs, taskId: string): string { const parts = [`./scripts/agentrun queue dispatch ${taskId}`]; if (args.flags.get("json-stdin") === true || optionalFlag(args, "json-file")) parts.push("--json-stdin"); @@ -1004,6 +1018,8 @@ async function sessionStorageDelete(args: ParsedArgs, sessionId: string): Promis } async function sessionTurn(args: ParsedArgs, positionalSessionId: string | null): Promise { + const aipod = optionalFlag(args, "aipod") ?? optionalFlag(args, "aipod-spec"); + if (aipod) return sessionTurnWithAipod(args, positionalSessionId, aipod); const body = await optionalJsonFile(args); const sessionId = positionalSessionId ?? optionalFlag(args, "session-id") ?? newSessionId(); const requestedProfile = optionalFlag(args, "profile") ?? optionalFlag(args, "backend-profile") ?? (typeof body.backendProfile === "string" ? String(body.backendProfile) : "codex"); @@ -1082,7 +1098,122 @@ async function sessionRead(args: ParsedArgs, sessionId: string): Promise { + const input = await aipodRenderInput(args, 3); + return client(args).post(`/api/v1/aipod-specs/${encodeURIComponent(name)}/render`, input); +} + +async function renderAipodForCommand(args: ParsedArgs, name: string, trailingPromptStart: number, overrides: RenderAipodInput = {}): Promise { + const input = await aipodRenderInput(args, trailingPromptStart, overrides); + return await client(args).post(`/api/v1/aipod-specs/${encodeURIComponent(name)}/render`, input) as RenderedAipodQueueTask; +} + +async function aipodRenderInput(args: ParsedArgs, trailingPromptStart: number, overrides: RenderAipodInput = {}): Promise { + const input = await optionalJsonFile(args) as RenderAipodInput; + const prompt = await optionalPrompt(args, trailingPromptStart); + if (prompt) input.prompt = prompt; + copyOptionalFlag(args, input as JsonRecord, "tenant-id", "tenantId"); + copyOptionalFlag(args, input as JsonRecord, "project-id", "projectId"); + copyOptionalFlag(args, input as JsonRecord, "queue"); + copyOptionalFlag(args, input as JsonRecord, "lane"); + copyOptionalFlag(args, input as JsonRecord, "title"); + copyOptionalFlag(args, input as JsonRecord, "provider-id", "providerId"); + const profile = optionalFlag(args, "profile") ?? optionalFlag(args, "backend-profile"); + if (profile) input.backendProfile = normalizeProfile(profile); + const priority = optionalFlag(args, "priority"); + if (priority) input.priority = Number(priority); + copyOptionalFlag(args, input as JsonRecord, "idempotency-key", "idempotencyKey"); + copyOptionalFlag(args, input as JsonRecord, "session-id", "sessionId"); + const workspaceRef = jsonObjectFlag(args, "workspace-json"); + if (workspaceRef) input.workspaceRef = workspaceRef as never; + return { ...input, ...overrides }; +} + +async function applyAipodSpecCli(args: ParsedArgs, name: string | null): Promise { + const yaml = await aipodYamlInput(args); + const pathValue = name ? `/api/v1/aipod-specs/${encodeURIComponent(name)}` : "/api/v1/aipod-specs"; + const method = name ? "PUT" : "POST"; + if (args.flags.get("dry-run") === true) { + return { action: "aipod-spec-apply-plan", dryRun: true, mutation: false, request: { method, path: pathValue, yamlBytes: Buffer.byteLength(yaml, "utf8"), valuesPrinted: false }, next: { confirm: `./scripts/agentrun aipod-specs apply${name ? ` ${name}` : ""} --yaml-stdin` }, valuesPrinted: false }; + } + return name ? client(args).put(pathValue, { yaml }) : client(args).post(pathValue, { yaml }); +} + +async function aipodYamlInput(args: ParsedArgs): Promise { + if (args.flags.get("yaml-stdin") === true) return readStdinText(); + const file = optionalFlag(args, "yaml-file"); + if (!file) throw new AgentRunError("schema-invalid", "aipod-spec YAML input is required; use --yaml-stdin or --yaml-file ", { httpStatus: 2 }); + return readFile(file, "utf8"); +} + +async function submitQueueTaskWithAipod(args: ParsedArgs, aipod: string): Promise { + const rendered = await renderAipodForCommand(args, aipod, 2); + const body = rendered.queueTask as unknown as JsonRecord; + if (args.flags.get("dry-run") === true) { + return queueMutationDryRunPlan("queue-submit", null, "/api/v1/queue/tasks", body, "POST", queueSubmitAipodConfirmCommand(args, aipod), undefined, { source: "aipod-spec", aipod, preferred: "--aipod", valuesPrinted: false }); + } + return client(args).post("/api/v1/queue/tasks", body); +} + +async function sessionTurnWithAipod(args: ParsedArgs, positionalSessionId: string | null, aipod: string): Promise { + const sessionId = positionalSessionId ?? optionalFlag(args, "session-id") ?? newSessionId(); + const rendered = await renderAipodForCommand(args, aipod, positionalSessionId ? 3 : 2, { sessionId }); + const task = rendered.queueTask; + const profile = String(task.backendProfile); + if (positionalSessionId || optionalFlag(args, "session-id")) { + try { + await client(args).get(`/api/v1/sessions/${encodeURIComponent(sessionId)}/storage`); + } catch { + const expiresInDays = Number(optionalFlag(args, "expires-in-days") ?? 30); + await client(args).post("/api/v1/sessions", { + sessionId, + tenantId: task.tenantId, + projectId: task.projectId, + backendProfile: task.backendProfile, + expiresAt: new Date(Date.now() + Math.max(1, expiresInDays) * 24 * 60 * 60 * 1000).toISOString(), + }); + } + } + const sessionRef = objectField(task as unknown as JsonRecord, "sessionRef", {}); + const metadata = objectField(sessionRef, "metadata", {}); + const title = optionalFlag(args, "title") ?? task.title; + if (title) metadata.title = title; + const runBody: JsonRecord = { + tenantId: task.tenantId, + projectId: task.projectId, + providerId: task.providerId ?? "G14", + backendProfile: task.backendProfile, + workspaceRef: task.workspaceRef ?? { kind: "opaque", path: "." }, + sessionRef: { ...sessionRef, sessionId, metadata }, + executionPolicy: task.executionPolicy, + resourceBundleRef: task.resourceBundleRef, + traceSink: { kind: "aipod-session", aipod, sessionId, valuesPrinted: false }, + }; + const run = await client(args).post("/api/v1/runs", runBody) as RunRecord; + const commandBody: JsonRecord = { type: "turn", payload: task.payload }; + const commandIdempotencyKey = optionalFlag(args, "command-idempotency-key") ?? optionalFlag(args, "idempotency-key"); + if (commandIdempotencyKey) commandBody.idempotencyKey = commandIdempotencyKey; + const command = await client(args).post(`/api/v1/runs/${encodeURIComponent(run.id)}/commands`, commandBody) as CommandRecord; + let runnerJob: JsonValue = null; + if (args.flags.get("no-runner-job") !== true) { + const runnerDefaults = jsonRecordValue(rendered.dispatchDefaults.runnerJob) ?? {}; + const runnerOverrides = await optionalRunnerJsonFile(args); + const runnerBody = { ...runnerDefaults, ...runnerOverrides, commandId: command.id } as JsonRecord; + copyOptionalFlag(args, runnerBody, "image"); + copyOptionalFlag(args, runnerBody, "namespace"); + copyOptionalFlag(args, runnerBody, "attempt-id", "attemptId"); + copyOptionalFlag(args, runnerBody, "runner-id", "runnerId"); + copyOptionalFlag(args, runnerBody, "source-commit", "sourceCommit"); + copyRunnerManagerUrlFlag(args, runnerBody); + copyOptionalFlag(args, runnerBody, "service-account-name", "serviceAccountName"); + runnerJob = await client(args).post(`/api/v1/runs/${encodeURIComponent(run.id)}/runner-jobs`, runnerBody); + } + return { action: "session-turn", aipod, sessionId, profile, run, command, runnerJob, valuesPrinted: false, pollCommands: { ps: `./scripts/agentrun sessions ps --reader-id cli --profile ${profile}`, show: `./scripts/agentrun sessions show ${sessionId} --reader-id cli`, trace: `./scripts/agentrun sessions trace ${sessionId} --after-seq 0 --limit 100`, output: `./scripts/agentrun sessions output ${sessionId} --after-seq 0 --limit 100`, read: `./scripts/agentrun sessions read ${sessionId} --reader-id cli`, steer: `./scripts/agentrun sessions steer ${sessionId} --prompt-file `, cancel: `./scripts/agentrun sessions cancel ${sessionId}` } }; +} + async function submitQueueTask(args: ParsedArgs): Promise { + const aipod = optionalFlag(args, "aipod") ?? optionalFlag(args, "aipod-spec"); + if (aipod) return submitQueueTaskWithAipod(args, aipod); const body = await jsonFile(args); const idempotencyKey = optionalFlag(args, "idempotency-key"); if (idempotencyKey) body.idempotencyKey = idempotencyKey; @@ -1270,6 +1401,40 @@ async function renderCodexSecret(args: ParsedArgs): Promise { return renderCodexProviderSecretPlan(options); } +async function setGithubSshToolCredentialCli(args: ParsedArgs): Promise { + const privateKey = await textFromFileFlag(args, "private-key-file", "private key"); + const knownHosts = await textFromFileFlag(args, "known-hosts-file", "known_hosts"); + const configFile = optionalFlag(args, "config-file"); + const body: JsonRecord = { privateKey, knownHosts }; + if (configFile) body.config = await readFile(configFile, "utf8"); + if (args.flags.get("dry-run") === true) { + return { + action: "tool-credential-github-ssh-plan", + mutation: false, + dryRun: true, + secretRef: { namespace: "agentrun-v01", name: "agentrun-v01-tool-github-ssh", keys: ["id_ed25519", "known_hosts", "config"], valuesPrinted: false }, + inputs: { + privateKey: fileSummary("private-key-file", privateKey), + knownHosts: fileSummary("known-hosts-file", knownHosts), + config: configFile ? fileSummary("config-file", String(body.config)) : { provided: false, defaulted: true, valuesPrinted: false }, + }, + confirm: "./scripts/agentrun tool-credentials set-github-ssh --private-key-file --known-hosts-file [--config-file ]", + valuesPrinted: false, + }; + } + return await client(args).put("/api/v1/tool-credentials/github-ssh/credential", body) as JsonRecord; +} + +async function textFromFileFlag(args: ParsedArgs, flagName: string, label: string): Promise { + const file = optionalFlag(args, flagName); + if (!file) throw new AgentRunError("schema-invalid", `tool-credentials set-github-ssh requires --${flagName} for ${label}`, { httpStatus: 2 }); + return await readFile(file, "utf8"); +} + +function fileSummary(flagName: string, text: string): JsonRecord { + return { flag: flagName, bytes: Buffer.byteLength(text, "utf8"), valuesPrinted: false }; +} + async function setProviderProfileKey(args: ParsedArgs, profileValue: string): Promise { const profile = normalizeProfile(profileValue); if (args.flags.get("key-stdin") !== true) throw new AgentRunError("schema-invalid", "provider-profiles set-key requires --key-stdin", { httpStatus: 2 }); @@ -1581,6 +1746,24 @@ async function readPrompt(args: ParsedArgs): Promise { throw new AgentRunError("schema-invalid", "prompt is required; use --prompt, --prompt-file, --prompt-stdin, or trailing text", { httpStatus: 2 }); } +async function optionalPrompt(args: ParsedArgs, trailingStart: number): Promise { + const promptFlag = optionalFlag(args, "prompt"); + if (promptFlag) return promptFlag; + const promptFile = optionalFlag(args, "prompt-file"); + if (promptFile) { + const text = await readFile(promptFile, "utf8"); + if (text.trim().length === 0) throw new AgentRunError("schema-invalid", "prompt file is empty", { httpStatus: 2 }); + return text; + } + if (args.flags.get("prompt-stdin") === true) { + const text = await readStdinText(); + if (text.trim().length === 0) throw new AgentRunError("schema-invalid", "stdin prompt is empty", { httpStatus: 2 }); + return text; + } + const inline = args.positional.slice(trailingStart).join(" ").trim(); + return inline.length > 0 ? inline : undefined; +} + async function readStdinText(): Promise { const chunks: Buffer[] = []; for await (const chunk of process.stdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); @@ -1687,7 +1870,7 @@ function help(args: ParsedArgs, group?: string): JsonRecord { "sessions storage ", "sessions storage --delete", "sessions show [--reader-id ]", - "sessions turn [sessionId] [--json-stdin|--json-file ] [--prompt-stdin|--prompt-file |--prompt ] [--profile codex|deepseek|minimax-m3|dsflash-go||M3] [--runner-json-stdin|--runner-json-file ]", + "sessions turn [sessionId] [--aipod |--json-stdin|--json-file ] [--prompt-stdin|--prompt-file |--prompt ] [--profile codex|deepseek|minimax-m3|dsflash-go||M3] [--runner-json-stdin|--runner-json-file ] [--no-runner-job]", "sessions steer [--prompt-stdin|--prompt-file |--prompt ]", "sessions cancel [--reason ] [--full|--raw]", "sessions trace [--after-seq ] [--limit ] [--run-id ] [--summary-chars ] [--include-output] [--seq |--event-id |--item-id ] [--detail-scan-pages ] [--full|--raw]", @@ -1703,6 +1886,7 @@ function help(args: ParsedArgs, group?: string): JsonRecord { "runner jobs --run-id [--command-id ]", "runner job-status [runnerJobId] --run-id ", "queue submit --json-stdin|--json-file [--idempotency-key ] [--dry-run]", + "queue submit --aipod [--prompt-stdin|--prompt-file |--prompt ] [--idempotency-key ] [--dry-run]", "queue list [--queue ] [--state ] [--cursor ] [--limit ] [--updated-after ] [--full|--raw]", "queue show [--full|--raw]", "queue stats [--queue ]", @@ -1711,7 +1895,15 @@ function help(args: ParsedArgs, group?: string): JsonRecord { "queue cancel [--reason ] [--dry-run] [--full|--raw]", "queue dispatch [--json-stdin|--json-file ] [--idempotency-key ] [--image ] [--namespace ] [--dry-run] [--full|--raw]", "queue refresh [--dry-run] [--full|--raw]", + "aipod-specs list", + "aipod-specs show ", + "aipod-specs render [--json-stdin|--json-file ] [--prompt-stdin|--prompt-file |--prompt ]", + "aipod-specs apply [name] --yaml-stdin|--yaml-file [--dry-run]", + "aipod-specs delete ", "secrets codex render --dry-run [--profile codex|deepseek|minimax-m3|dsflash-go|] [--codex-home ] [--model-catalog-file ] [--namespace agentrun-v01] [--secret-name ]", + "tool-credentials list", + "tool-credentials show github-ssh|unidesk-ssh", + "tool-credentials set-github-ssh --private-key-file --known-hosts-file [--config-file ] [--dry-run]", "provider-profiles list", "provider-profiles show ", "provider-profiles config ", diff --git a/src/common/aipod-specs.ts b/src/common/aipod-specs.ts new file mode 100644 index 0000000..6d4f5ea --- /dev/null +++ b/src/common/aipod-specs.ts @@ -0,0 +1,300 @@ +import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { AgentRunError } from "./errors.js"; +import type { AipodSpec, AipodSpecRecord, BackendProfile, CreateQueueTaskInput, ExecutionPolicy, JsonRecord, JsonValue, RenderAipodInput, RenderedAipodQueueTask, ResourceBundleRef, SessionRef, WorkspaceRef } from "./types.js"; +import { backendProfileSpec, isBackendProfile } from "./backend-profiles.js"; +import { asRecord, stableHash, validateCreateQueueTask, validateExecutionPolicy, validateResourceBundleRef, validateSessionRef } from "./validation.js"; + +declare const Bun: { YAML: { parse(text: string): unknown; stringify(value: unknown): string } }; + +const aipodApiVersion = "agentrun.pikastech.local/v0.1"; +const aipodKind = "AipodSpec"; + +export function aipodSpecDirectory(): string { + return process.env.AGENTRUN_AIPOD_SPEC_DIR ?? path.join(process.cwd(), "config", "aipods"); +} + +export async function listAipodSpecs(dir = aipodSpecDirectory()): Promise { + const records = await loadAipodSpecRecords(dir); + return { + action: "aipod-spec-list", + dir, + count: records.length, + items: records.map((record) => summarizeAipodSpecRecord(record)), + valuesPrinted: false, + }; +} + +export async function showAipodSpec(name: string, dir = aipodSpecDirectory()): Promise { + const record = await getAipodSpecRecord(name, dir); + return { action: "aipod-spec-show", item: summarizeAipodSpecRecord(record), spec: record.spec, valuesPrinted: false }; +} + +export async function renderAipodSpecByName(name: string, input: RenderAipodInput = {}, dir = aipodSpecDirectory()): Promise { + const record = await getAipodSpecRecord(name, dir); + return renderAipodSpec(record, input); +} + +export async function applyAipodSpec(input: unknown, dir = aipodSpecDirectory()): Promise { + const spec = aipodSpecFromInput(input, "api"); + await mkdir(dir, { recursive: true }); + const file = path.join(dir, `${fileSafeAipodName(spec.metadata.name)}.yaml`); + await writeFile(file, Bun.YAML.stringify(spec), "utf8"); + const record = await loadAipodSpecFile(file); + return { action: "aipod-spec-apply", mutation: true, item: summarizeAipodSpecRecord(record), valuesPrinted: false }; +} + +export async function deleteAipodSpec(name: string, dir = aipodSpecDirectory()): Promise { + const record = await getAipodSpecRecord(name, dir); + await rm(record.source, { force: true }); + return { action: "aipod-spec-delete", mutation: true, name: record.name, source: record.source, specHash: record.specHash, valuesPrinted: false }; +} + +export function parseAipodSpecYaml(text: string, source = "stdin"): AipodSpec { + let parsed: unknown; + try { + parsed = Bun.YAML.parse(text); + } catch (error) { + throw new AgentRunError("schema-invalid", `aipod-spec YAML parse failed: ${error instanceof Error ? error.message : String(error)}`, { httpStatus: 400, details: { source, valuesPrinted: false } }); + } + return validateAipodSpec(parsed, source); +} + +export function aipodSpecFromInput(input: unknown, source = "inline"): AipodSpec { + const record = asRecord(input, "aipodSpecInput"); + if (typeof record.yaml === "string") return parseAipodSpecYaml(record.yaml, source); + if (record.spec !== undefined) return validateAipodSpec(record.spec, source); + return validateAipodSpec(record, source); +} + +export function validateAipodSpec(input: unknown, source = "inline"): AipodSpec { + const record = asRecord(input, "aipodSpec"); + if (stringValue(record.apiVersion) !== aipodApiVersion) throw new AgentRunError("schema-invalid", `aipodSpec.apiVersion must be ${aipodApiVersion}`, { httpStatus: 400, details: { source } }); + if (stringValue(record.kind) !== aipodKind) throw new AgentRunError("schema-invalid", `aipodSpec.kind must be ${aipodKind}`, { httpStatus: 400, details: { source } }); + const metadata = asRecord(record.metadata, "aipodSpec.metadata"); + const name = validateAipodName(requiredString(metadata, "name")); + const labels = metadata.labels === undefined ? undefined : asRecord(metadata.labels, "aipodSpec.metadata.labels"); + const spec = asRecord(record.spec, "aipodSpec.spec"); + const backendProfile = normalizeBackendProfile(requiredString(spec, "backendProfile")); + const executionPolicy = validateExecutionPolicy(asRecord(spec.executionPolicy, "aipodSpec.spec.executionPolicy")); + validateAipodProviderCredential(backendProfile, executionPolicy); + const resourceBundleRef = validateResourceBundleRef(spec.resourceBundleRef); + const result: AipodSpec = { + apiVersion: aipodApiVersion, + kind: aipodKind, + metadata: { + name, + ...(stringValue(metadata.displayName) ? { displayName: stringValue(metadata.displayName) as string } : {}), + ...(stringValue(metadata.description) ? { description: stringValue(metadata.description) as string } : {}), + ...(labels ? { labels } : {}), + }, + spec: { + ...(stringValue(spec.tenantId) ? { tenantId: stringValue(spec.tenantId) as string } : {}), + ...(stringValue(spec.projectId) ? { projectId: stringValue(spec.projectId) as string } : {}), + ...(stringValue(spec.queue) ? { queue: stringValue(spec.queue) as string } : {}), + ...(stringValue(spec.lane) ? { lane: stringValue(spec.lane) as string } : {}), + ...(typeof spec.priority === "number" ? { priority: spec.priority } : {}), + ...(stringValue(spec.providerId) ? { providerId: stringValue(spec.providerId) as string } : {}), + backendProfile, + ...(isJsonRecord(spec.model) ? { model: spec.model } : {}), + ...(isJsonRecord(spec.workspaceRef) ? { workspaceRef: validateWorkspaceRef(spec.workspaceRef) } : {}), + ...(spec.sessionRef !== undefined ? { sessionRef: validateSessionRef(spec.sessionRef) } : {}), + executionPolicy, + resourceBundleRef, + ...(isJsonRecord(spec.payloadDefaults) ? { payloadDefaults: spec.payloadDefaults } : {}), + ...(Array.isArray(spec.references) ? { references: spec.references.map((item, index) => asRecord(item, `aipodSpec.spec.references[${index}]`)) } : {}), + ...(isJsonRecord(spec.metadata) ? { metadata: spec.metadata } : {}), + ...(isJsonRecord(spec.dispatchDefaults) ? { dispatchDefaults: spec.dispatchDefaults } : {}), + }, + }; + return result; +} + +export function renderAipodSpec(record: AipodSpecRecord, input: RenderAipodInput = {}): RenderedAipodQueueTask { + const spec = record.spec.spec; + const metadata = mergeRecords(spec.metadata, input.metadata, { aipod: record.name, aipodSpecHash: record.specHash }); + const payload = mergeRecords(spec.payloadDefaults, input.payload); + if (typeof input.prompt === "string" && input.prompt.trim().length > 0) payload.prompt = input.prompt; + applyModelPayload(payload, spec.model); + const sessionRef = renderSessionRef(spec.sessionRef ?? null, input); + const queueTask = validateCreateQueueTask({ + tenantId: input.tenantId ?? spec.tenantId ?? "unidesk", + projectId: input.projectId ?? spec.projectId ?? "default", + queue: input.queue ?? spec.queue ?? "commander", + lane: input.lane ?? spec.lane ?? "v0.1", + title: input.title ?? stringValue(payload.title) ?? record.spec.metadata.displayName ?? record.name, + priority: input.priority ?? spec.priority ?? 50, + backendProfile: input.backendProfile ?? spec.backendProfile, + providerId: input.providerId ?? spec.providerId ?? "G14", + workspaceRef: input.workspaceRef ?? spec.workspaceRef ?? { kind: "opaque", path: "." }, + sessionRef, + executionPolicy: spec.executionPolicy, + resourceBundleRef: spec.resourceBundleRef, + payload, + references: [...(spec.references ?? []), ...(input.references ?? [])], + metadata, + ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}), + }) as CreateQueueTaskInput; + return { + action: "aipod-spec-render", + aipod: summarizeAipodSpecRecord(record), + queueTask, + dispatchDefaults: spec.dispatchDefaults ?? {}, + valuesPrinted: false, + }; +} + +export function summarizeAipodSpecRecord(record: AipodSpecRecord): JsonRecord { + const spec = record.spec.spec; + return { + name: record.name, + displayName: record.spec.metadata.displayName ?? record.name, + description: record.spec.metadata.description ?? null, + specHash: record.specHash, + source: record.source, + backendProfile: spec.backendProfile, + model: spec.model ?? null, + queue: spec.queue ?? "commander", + lane: spec.lane ?? "v0.1", + providerId: spec.providerId ?? "G14", + providerCredentials: summarizeProviderCredentials(spec.executionPolicy), + toolCredentials: summarizeToolCredentials(spec.executionPolicy), + resourceBundleRef: summarizeResourceBundle(spec.resourceBundleRef), + dispatchDefaults: summarizeKeys(spec.dispatchDefaults), + createdAt: record.createdAt, + updatedAt: record.updatedAt, + valuesPrinted: false, + }; +} + +async function loadAipodSpecRecords(dir: string): Promise { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch (error) { + if (isNotFound(error)) return []; + throw error; + } + const records = await Promise.all(entries + .filter((entry) => entry.isFile() && /\.ya?ml$/u.test(entry.name)) + .sort((left, right) => left.name.localeCompare(right.name)) + .map((entry) => loadAipodSpecFile(path.join(dir, entry.name)))); + return records.sort((left, right) => left.name.localeCompare(right.name)); +} + +async function getAipodSpecRecord(name: string, dir: string): Promise { + const lookup = normalizeLookupName(name); + const records = await loadAipodSpecRecords(dir); + const match = records.find((record) => normalizeLookupName(record.name) === lookup); + if (!match) throw new AgentRunError("schema-invalid", `aipod-spec ${name} was not found`, { httpStatus: 404, details: { dir, available: records.map((record) => record.name), valuesPrinted: false } }); + return match; +} + +async function loadAipodSpecFile(file: string): Promise { + const text = await readFile(file, "utf8"); + const spec = parseAipodSpecYaml(text, file); + const info = await stat(file); + const updatedAt = info.mtime.toISOString(); + return { name: spec.metadata.name, spec, specHash: stableHash(spec), source: file, createdAt: info.birthtime.toISOString(), updatedAt }; +} + +function validateAipodProviderCredential(profile: BackendProfile, policy: ExecutionPolicy): void { + const matching = (policy.secretScope.providerCredentials ?? []).filter((item) => item.profile === profile); + if (matching.length !== 1) { + throw new AgentRunError("secret-unavailable", `aipod backendProfile ${profile} requires exactly one matching provider credential SecretRef`, { + httpStatus: 400, + details: { profile, matchingCount: matching.length, defaultSecretName: backendProfileSpec(profile)?.defaultSecretName ?? null, valuesPrinted: false }, + }); + } +} + +function renderSessionRef(base: SessionRef | null, input: RenderAipodInput): SessionRef | null { + if (input.sessionRef !== undefined) return input.sessionRef; + if (!input.sessionId) return base; + return { ...(base ?? {}), sessionId: input.sessionId }; +} + +function validateWorkspaceRef(record: JsonRecord): WorkspaceRef { + const kind = requiredString(record, "kind"); + if (!["git-worktree", "host-path", "kubernetes-pvc", "opaque"].includes(kind)) { + throw new AgentRunError("schema-invalid", `workspaceRef.kind ${kind} is not supported`, { httpStatus: 400 }); + } + return record as WorkspaceRef; +} + +function summarizeProviderCredentials(policy: ExecutionPolicy): JsonRecord { + const items = (policy.secretScope.providerCredentials ?? []).map((item) => ({ profile: item.profile, name: item.secretRef.name, namespace: item.secretRef.namespace ?? null, keys: item.secretRef.keys ?? [], valuesPrinted: false })); + return { count: items.length, profiles: items.map((item) => item.profile), items, valuesPrinted: false }; +} + +function summarizeToolCredentials(policy: ExecutionPolicy): JsonRecord { + const items = (policy.secretScope.toolCredentials ?? []).map((item) => ({ tool: item.tool, purpose: item.purpose ?? null, name: item.secretRef.name, namespace: item.secretRef.namespace ?? null, keys: item.secretRef.keys ?? [], projection: item.projection, valuesPrinted: false })); + return { count: items.length, tools: items.map((item) => item.tool), items, valuesPrinted: false }; +} + +function summarizeResourceBundle(resourceBundleRef: ResourceBundleRef | null): JsonRecord | null { + if (!resourceBundleRef) return null; + return { + kind: resourceBundleRef.kind, + repoUrl: resourceBundleRef.repoUrl, + ref: resourceBundleRef.ref ?? null, + commitId: resourceBundleRef.commitId ?? null, + gitMirror: resourceBundleRef.gitMirror ? { enabled: resourceBundleRef.gitMirror.enabled ?? true, baseUrl: resourceBundleRef.gitMirror.baseUrl ?? null, valuesPrinted: false } : { enabled: false, baseUrl: null, valuesPrinted: false }, + bundles: { count: resourceBundleRef.bundles.length, items: resourceBundleRef.bundles.map((item) => ({ name: item.name ?? null, repoUrl: item.repoUrl ?? resourceBundleRef.repoUrl, ref: item.ref ?? resourceBundleRef.ref ?? null, commitId: item.commitId ?? resourceBundleRef.commitId ?? null, subpath: item.subpath, targetPath: item.targetPath, valuesPrinted: false })), valuesPrinted: false }, + requiredSkills: { count: resourceBundleRef.requiredSkills?.length ?? 0, names: resourceBundleRef.requiredSkills?.map((item) => item.name) ?? [], valuesPrinted: false }, + promptRefs: { count: resourceBundleRef.promptRefs?.length ?? 0, names: resourceBundleRef.promptRefs?.map((item) => item.name) ?? [], valuesPrinted: false }, + valuesPrinted: false, + }; +} + +function summarizeKeys(value: JsonRecord | undefined): JsonRecord { + return { keys: Object.keys(value ?? {}).sort(), valuesPrinted: false }; +} + +function mergeRecords(...records: Array): JsonRecord { + return Object.assign({}, ...records.filter(Boolean)); +} + +function applyModelPayload(payload: JsonRecord, model: JsonRecord | undefined): void { + if (!model) return; + const modelName = stringValue(model.model); + if (modelName) payload.model = modelName; + payload.modelConfig = { ...model, valuesPrinted: false }; +} + +function normalizeBackendProfile(value: string): BackendProfile { + const profile = value.trim().toLowerCase(); + if (!isBackendProfile(profile)) throw new AgentRunError("schema-invalid", `backendProfile ${value} must be a lowercase slug`, { httpStatus: 400 }); + return profile; +} + +function validateAipodName(value: string): string { + if (!/^[A-Za-z][A-Za-z0-9._-]{0,63}$/u.test(value)) throw new AgentRunError("schema-invalid", "aipodSpec.metadata.name must be an aipod name", { httpStatus: 400 }); + return value; +} + +function fileSafeAipodName(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9._-]+/gu, "-").replace(/^-+|-+$/gu, "") || "aipod"; +} + +function normalizeLookupName(value: string): string { + return value.trim().toLowerCase(); +} + +function requiredString(record: JsonRecord, key: string): string { + const value = record[key]; + if (typeof value !== "string" || value.trim().length === 0) throw new AgentRunError("schema-invalid", `${key} is required`, { httpStatus: 400 }); + return value.trim(); +} + +function stringValue(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function isJsonRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNotFound(error: unknown): boolean { + return typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === "ENOENT"; +} diff --git a/src/common/types.ts b/src/common/types.ts index 74538bf..43e8d3c 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -75,6 +75,10 @@ export interface ResourceBundleRef extends JsonRecord { repoUrl: string; commitId?: string; ref?: string; + gitMirror?: { + enabled?: boolean; + baseUrl?: string; + }; bundles: GitBundleItemRef[]; promptRefs?: Array<{ name: string; @@ -90,6 +94,72 @@ export interface ResourceBundleRef extends JsonRecord { credentialRef?: SecretRef; } +export interface AipodSpec extends JsonRecord { + apiVersion: "agentrun.pikastech.local/v0.1"; + kind: "AipodSpec"; + metadata: { + name: string; + displayName?: string; + description?: string; + labels?: JsonRecord; + }; + spec: { + tenantId?: string; + projectId?: string; + queue?: string; + lane?: string; + priority?: number; + providerId?: string; + backendProfile: BackendProfile; + model?: JsonRecord; + workspaceRef?: WorkspaceRef; + sessionRef?: SessionRef | null; + executionPolicy: ExecutionPolicy; + resourceBundleRef: ResourceBundleRef | null; + payloadDefaults?: JsonRecord; + references?: JsonRecord[]; + metadata?: JsonRecord; + dispatchDefaults?: JsonRecord; + }; +} + +export interface AipodSpecRecord extends JsonRecord { + name: string; + spec: AipodSpec; + specHash: string; + source: string; + createdAt: string; + updatedAt: string; +} + +export interface RenderAipodInput extends JsonRecord { + prompt?: string; + payload?: JsonRecord; + tenantId?: string; + projectId?: string; + queue?: string; + lane?: string; + title?: string; + priority?: number; + providerId?: string; + backendProfile?: BackendProfile; + workspaceRef?: WorkspaceRef; + sessionRef?: SessionRef | null; + sessionId?: string; + references?: JsonRecord[]; + metadata?: JsonRecord; + traceSink?: JsonValue; + idempotencyKey?: string; +} + +export interface RenderedAipodQueueTask extends JsonRecord { + action: "aipod-spec-render"; + aipod: JsonRecord; + queueTask: CreateQueueTaskInput; + dispatchDefaults: JsonRecord; + valuesPrinted: false; +} + export interface ExecutionPolicy extends JsonRecord { sandbox: string; approval: string; @@ -104,11 +174,14 @@ export interface ExecutionPolicy extends JsonRecord { tool: string; purpose?: string; secretRef: SecretRef; - projection: { + projection: ({ kind: "env"; envName: string; secretKey?: string; - }; + } | { + kind: "volume"; + mountPath: string; + }); }>; allowCredentialEcho?: false; }; diff --git a/src/common/validation.ts b/src/common/validation.ts index df8fd8b..5399c01 100644 --- a/src/common/validation.ts +++ b/src/common/validation.ts @@ -89,7 +89,8 @@ export function validateResourceBundleRef(value: unknown): ResourceBundleRef | n if (commitId) validateCommitId(commitId, "resourceBundleRef.commitId"); const ref = validateGitRef(record.ref, "resourceBundleRef.ref"); rejectLegacyResourceBundleFields(record); - const result: ResourceBundleRef = { kind: "gitbundle", repoUrl, ...(commitId ? { commitId } : {}), ...(ref ? { ref } : {}), bundles: validateResourceGitBundles(record.bundles, repoUrl, commitId, ref) }; + const gitMirror = validateGitMirror(record.gitMirror); + const result: ResourceBundleRef = { kind: "gitbundle", repoUrl, ...(commitId ? { commitId } : {}), ...(ref ? { ref } : {}), ...(gitMirror ? { gitMirror } : {}), bundles: validateResourceGitBundles(record.bundles, repoUrl, commitId, ref) }; if (record.promptRefs !== undefined) result.promptRefs = validateResourcePromptRefs(record.promptRefs); if (record.requiredSkills !== undefined) result.requiredSkills = validateResourceRequiredSkills(record.requiredSkills); if (record.submodules !== undefined && record.submodules !== false) throw new AgentRunError("schema-invalid", "resourceBundleRef.submodules can only be false in v0.1", { httpStatus: 400 }); @@ -100,6 +101,27 @@ export function validateResourceBundleRef(value: unknown): ResourceBundleRef | n return result; } +function validateGitMirror(value: unknown): NonNullable | undefined { + if (value === undefined) return undefined; + const record = asRecord(value, "resourceBundleRef.gitMirror"); + const enabled = record.enabled === undefined ? true : record.enabled; + if (typeof enabled !== "boolean") throw new AgentRunError("schema-invalid", "resourceBundleRef.gitMirror.enabled must be boolean", { httpStatus: 400 }); + const baseUrl = optionalString(record.baseUrl); + if (baseUrl) validateGitMirrorBaseUrl(baseUrl); + return { enabled, ...(baseUrl ? { baseUrl } : {}) }; +} + +function validateGitMirrorBaseUrl(value: string): void { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new AgentRunError("schema-invalid", "resourceBundleRef.gitMirror.baseUrl must be an http(s) URL", { httpStatus: 400 }); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new AgentRunError("schema-invalid", "resourceBundleRef.gitMirror.baseUrl must use http or https", { httpStatus: 400 }); + if (parsed.username || parsed.password || parsed.search || parsed.hash) throw new AgentRunError("schema-invalid", "resourceBundleRef.gitMirror.baseUrl must not include credentials, query, or fragment", { httpStatus: 400 }); +} + function rejectLegacyResourceBundleFields(record: JsonRecord): void { for (const field of ["toolAliases", "skillRefs", "workspaceFiles", "subdir", "sparsePaths"] as const) { if (record[field] !== undefined) throw new AgentRunError("schema-invalid", `resourceBundleRef.${field} is removed; use resourceBundleRef.bundles[] with kind=gitbundle`, { httpStatus: 400 }); @@ -241,21 +263,38 @@ function validateToolCredentials(value: unknown): NonNullable[number]["projection"] { + const kind = requiredString(projection, "kind"); + if (kind === "env") { const envName = requiredString(projection, "envName"); 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); - return { tool, ...(purpose ? { purpose } : {}), secretRef, projection: { kind: "env", envName, secretKey } }; - }); + if (tool === "unidesk-ssh") validateUnideskSshProjection(envName, secretKey, keys, index); + return { kind: "env", envName, secretKey }; + } + + if (kind === "volume") { + const mountPath = requiredString(projection, "mountPath"); + if (!mountPath.startsWith("/home/agentrun/") || mountPath.includes("..")) { + throw new AgentRunError("schema-invalid", `toolCredentials[${index}].projection.mountPath must stay under /home/agentrun`, { httpStatus: 400 }); + } + if (tool === "unidesk-ssh") throw new AgentRunError("schema-invalid", `toolCredentials[${index}] unidesk-ssh must use env projection`, { httpStatus: 400 }); + return { kind: "volume", mountPath }; + } + + throw new AgentRunError("schema-invalid", "toolCredentials[].projection.kind must be env or volume in v0.1", { httpStatus: 400 }); } -function validateToolCredentialProjection(tool: string, envName: string, secretKey: string, keys: string[], index: number): void { - if (tool !== "unidesk-ssh") return; +function validateUnideskSshProjection(envName: string, secretKey: string, keys: string[], index: number): void { 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 }); } diff --git a/src/mgr/kubernetes-runner-job.ts b/src/mgr/kubernetes-runner-job.ts index 283d261..d39b267 100644 --- a/src/mgr/kubernetes-runner-job.ts +++ b/src/mgr/kubernetes-runner-job.ts @@ -273,7 +273,7 @@ function unideskSshEndpointEnvFromRecord(record: JsonRecord, fieldName: string): 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 { +function summarizeToolCredentials(items: Array<{ tool: string; purpose: string | null; secretRef: { namespace?: string; name: string; keys?: string[] }; kind?: string; envName?: string; secretKey?: string; mountPath?: string }>, namespace: string): JsonRecord { return { count: items.length, items: items.map((item) => ({ @@ -282,7 +282,7 @@ function summarizeToolCredentials(items: Array<{ tool: string; purpose: string | name: item.secretRef.name, namespace: item.secretRef.namespace ?? namespace, keys: item.secretRef.keys ?? [], - projection: { kind: "env", envName: item.envName, secretKey: item.secretKey }, + projection: item.kind === "volume" ? { kind: "volume", mountPath: item.mountPath ?? null } : { kind: "env", envName: item.envName ?? null, secretKey: item.secretKey ?? null }, valuesPrinted: false, })), valuesPrinted: false, diff --git a/src/mgr/server.ts b/src/mgr/server.ts index 8b52e0c..904da51 100644 --- a/src/mgr/server.ts +++ b/src/mgr/server.ts @@ -15,6 +15,8 @@ import { createSessionPvc, deleteSessionPvc, getSessionPvcSummary, refreshSessio import type { SessionPvcSummary } from "./session-pvc.js"; import type { SessionPvcOptions } from "./session-pvc.js"; import { getProviderProfileConfig, getProviderProfileValidation, listBackendCapabilities, listProviderProfiles, removeProviderProfile, setProviderProfileConfig, setProviderProfileCredential, showProviderProfile, validateProviderProfile } from "./provider-profiles.js"; +import { listToolCredentials, setGithubSshToolCredential, showToolCredential } from "./tool-credentials.js"; +import { aipodSpecFromInput, applyAipodSpec, deleteAipodSpec, listAipodSpecs, renderAipodSpecByName, showAipodSpec } from "../common/aipod-specs.js"; function pvcOptions(defaults: { kubectlCommand?: string } | undefined): SessionPvcOptions { return defaults?.kubectlCommand ? { kubectlCommand: defaults.kubectlCommand } : {}; @@ -46,6 +48,8 @@ export interface ManagerServerOptions { }; sessionPvcOptions?: { kubectlHandler?: import("./session-pvc.js").KubectlHandler; kubectlCommand?: string; storageClassName?: string; size?: string }; providerProfileOptions?: { namespace?: string; kubectlCommand?: string }; + toolCredentialOptions?: { namespace?: string; kubectlCommand?: string }; + aipodSpecDir?: string; } export interface StartedManagerServer { @@ -60,12 +64,14 @@ export async function startManagerServer(options: ManagerServerOptions = {}): Pr const runnerJobDefaults = options.runnerJobDefaults; const sessionPvcDefaults = options.sessionPvcOptions; const providerProfileDefaults = options.providerProfileOptions; + const toolCredentialDefaults = options.toolCredentialOptions; + const aipodSpecDir = options.aipodSpecDir ?? process.env.AGENTRUN_AIPOD_SPEC_DIR; const server = createServer(async (req, res) => { const traceId = `trc_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; try { const method = req.method ?? "GET"; const url = new URL(req.url ?? "/", "http://agentrun.local"); - const data = await route({ method, url, body: await readBody(req), store, sourceCommit, ...(runnerJobDefaults ? { runnerJobDefaults } : {}), ...(sessionPvcDefaults ? { sessionPvcDefaults } : {}), ...(providerProfileDefaults ? { providerProfileDefaults } : {}) }); + const data = await route({ method, url, body: await readBody(req), store, sourceCommit, ...(runnerJobDefaults ? { runnerJobDefaults } : {}), ...(sessionPvcDefaults ? { sessionPvcDefaults } : {}), ...(providerProfileDefaults ? { providerProfileDefaults } : {}), ...(toolCredentialDefaults ? { toolCredentialDefaults } : {}), ...(aipodSpecDir ? { aipodSpecDir } : {}) }); writeJson(res, 200, { ok: true, data, traceId }); } catch (error) { const agentError = normalizeError(error); @@ -200,7 +206,7 @@ function compactRecoveryActions(value: JsonValue | undefined): JsonValue[] { }); } -async function route({ method, url, body, store, sourceCommit, runnerJobDefaults, sessionPvcDefaults, providerProfileDefaults }: { method: string; url: URL; body: unknown; store: AgentRunStore; sourceCommit: string; runnerJobDefaults?: NonNullable; sessionPvcDefaults?: NonNullable; providerProfileDefaults?: NonNullable }): Promise { +async function route({ method, url, body, store, sourceCommit, runnerJobDefaults, sessionPvcDefaults, providerProfileDefaults, toolCredentialDefaults, aipodSpecDir }: { method: string; url: URL; body: unknown; store: AgentRunStore; sourceCommit: string; runnerJobDefaults?: NonNullable; sessionPvcDefaults?: NonNullable; providerProfileDefaults?: NonNullable; toolCredentialDefaults?: NonNullable; aipodSpecDir?: string }): Promise { const path = url.pathname; if (method === "GET" && (path === "/health" || path === "/health/live" || path === "/health/readiness")) { const database = await store.health(); @@ -208,6 +214,19 @@ async function route({ method, url, body, store, sourceCommit, runnerJobDefaults return { serviceId: "agentrun-mgr", live: true, ready, database, sourceCommit, secretRefs: { databaseUrl: database.adapter === "postgres" ? "redacted" : "not-used", valuesPrinted: false } }; } if (method === "GET" && path === "/api/v1/backends") return await listBackendCapabilities(providerProfileDefaults) as JsonValue; + if (method === "GET" && path === "/api/v1/tool-credentials") return await listToolCredentials(toolCredentialDefaults) as JsonValue; + const toolCredentialMatch = path.match(/^\/api\/v1\/tool-credentials\/([^/]+)$/u); + if (method === "GET" && toolCredentialMatch) return await showToolCredential(decodeURIComponent(toolCredentialMatch[1] ?? ""), toolCredentialDefaults) as JsonValue; + const toolCredentialGithubSshMatch = path.match(/^\/api\/v1\/tool-credentials\/github-ssh\/credential$/u); + if (method === "PUT" && toolCredentialGithubSshMatch) return await setGithubSshToolCredential(body ?? {}, toolCredentialDefaults) as JsonValue; + if (method === "GET" && path === "/api/v1/aipod-specs") return await listAipodSpecs(aipodSpecDir) as JsonValue; + if (method === "POST" && path === "/api/v1/aipod-specs") return await applyAipodSpec(body ?? {}, aipodSpecDir) as JsonValue; + const aipodSpecMatch = path.match(/^\/api\/v1\/aipod-specs\/([^/]+)$/u); + if (method === "GET" && aipodSpecMatch) return await showAipodSpec(decodeURIComponent(aipodSpecMatch[1] ?? ""), aipodSpecDir) as JsonValue; + if (method === "PUT" && aipodSpecMatch) return await applyNamedAipodSpec(decodeURIComponent(aipodSpecMatch[1] ?? ""), body, aipodSpecDir) as JsonValue; + if (method === "DELETE" && aipodSpecMatch) return await deleteAipodSpec(decodeURIComponent(aipodSpecMatch[1] ?? ""), aipodSpecDir) as JsonValue; + const aipodSpecRenderMatch = path.match(/^\/api\/v1\/aipod-specs\/([^/]+)\/render$/u); + if (method === "POST" && aipodSpecRenderMatch) return await renderAipodSpecByName(decodeURIComponent(aipodSpecRenderMatch[1] ?? ""), asRecord(body ?? {}, "aipodSpecRender"), aipodSpecDir) as JsonValue; if (method === "GET" && path === "/api/v1/provider-profiles") return await listProviderProfiles(providerProfileDefaults) as JsonValue; const providerProfileMatch = path.match(/^\/api\/v1\/provider-profiles\/([^/]+)$/u); if (method === "GET" && providerProfileMatch) return await showProviderProfile(providerProfileMatch[1] ?? "", providerProfileDefaults) as JsonValue; @@ -536,6 +555,14 @@ async function route({ method, url, body, store, sourceCommit, runnerJobDefaults throw new AgentRunError("schema-invalid", `unsupported route ${method} ${path}`, { httpStatus: 404 }); } +async function applyNamedAipodSpec(name: string, body: unknown, dir?: string): Promise { + const spec = aipodSpecFromInput(body ?? {}, `api:${name}`); + if (spec.metadata.name.toLowerCase() !== name.trim().toLowerCase()) { + throw new AgentRunError("schema-invalid", "aipod-spec URL name must match metadata.name", { httpStatus: 400, details: { urlName: name, metadataName: spec.metadata.name, valuesPrinted: false } }); + } + return await applyAipodSpec(spec, dir) as JsonValue; +} + function integerQuery(url: URL, key: string, fallback: number): number { const value = Number(url.searchParams.get(key)); return Number.isInteger(value) && value >= 0 ? value : fallback; diff --git a/src/mgr/tool-credentials.ts b/src/mgr/tool-credentials.ts new file mode 100644 index 0000000..529bb23 --- /dev/null +++ b/src/mgr/tool-credentials.ts @@ -0,0 +1,309 @@ +import { createHash } from "node:crypto"; +import { spawn } from "node:child_process"; +import { AgentRunError } from "../common/errors.js"; +import type { JsonRecord } from "../common/types.js"; +import { asRecord } from "../common/validation.js"; +import { redactJson, redactText } from "../common/redaction.js"; + +const defaultNamespace = "agentrun-v01"; +const annotationPrefix = "agentrun.pikastech.local/tool-credential"; + +interface ToolCredentialSpec { + name: string; + tool: string; + purpose: string; + secretName: string; + keys: readonly string[]; + mutable: boolean; +} + +export interface ToolCredentialOptions { + namespace?: string; + kubectlCommand?: string; +} + +const toolCredentialSpecs: readonly ToolCredentialSpec[] = Object.freeze([ + { name: "github-ssh", tool: "github", purpose: "github-ssh", secretName: "agentrun-v01-tool-github-ssh", keys: ["id_ed25519", "known_hosts", "config"], mutable: true }, + { name: "unidesk-ssh", tool: "unidesk-ssh", purpose: "ssh-passthrough", secretName: "agentrun-v01-tool-unidesk-ssh", keys: ["UNIDESK_SSH_CLIENT_TOKEN"], mutable: false }, +]); + +export async function listToolCredentials(options: ToolCredentialOptions = {}): Promise { + const items = await Promise.all(toolCredentialSpecs.map((spec) => toolCredentialStatus(spec, options))); + return { action: "tool-credential-list", items, count: items.length, valuesPrinted: false }; +} + +export async function showToolCredential(name: string, options: ToolCredentialOptions = {}): Promise { + return toolCredentialStatus(requiredSpec(name), options); +} + +export async function setGithubSshToolCredential(body: unknown, options: ToolCredentialOptions = {}): Promise { + const spec = requiredSpec("github-ssh"); + const record = asRecord(body ?? {}, "githubSshToolCredential"); + const privateKey = credentialTextField(record, "privateKey", 131_072); + const knownHosts = credentialTextField(record, "knownHosts", 131_072); + const config = optionalTextField(record, "config", 32_768) ?? defaultGithubSshConfig(); + validatePrivateKey(privateKey); + validateKnownHosts(knownHosts); + validateSshConfig(config); + const namespace = runtimeNamespace(options); + const updatedAt = new Date().toISOString(); + const secretData = { + id_ed25519: base64Data(privateKey), + known_hosts: base64Data(knownHosts), + config: base64Data(config), + } satisfies JsonRecord; + const secretManifest: JsonRecord = { + apiVersion: "v1", + kind: "Secret", + metadata: { + name: spec.secretName, + namespace, + labels: { + "app.kubernetes.io/part-of": "agentrun", + "agentrun.pikastech.local/tool": spec.tool, + "agentrun.pikastech.local/purpose": spec.purpose, + }, + annotations: { + [`${annotationPrefix}-name`]: spec.name, + [`${annotationPrefix}-tool`]: spec.tool, + [`${annotationPrefix}-purpose`]: spec.purpose, + [`${annotationPrefix}-private-key-hash-suffix`]: shortHash(privateKey), + [`${annotationPrefix}-known-hosts-hash-suffix`]: shortHash(knownHosts), + [`${annotationPrefix}-config-hash-suffix`]: shortHash(config), + [`${annotationPrefix}-updated-at`]: updatedAt, + }, + }, + type: "Opaque", + data: secretData, + }; + const applied = await kubectlUpsertSecret(secretManifest, options.kubectlCommand ?? "kubectl"); + return { + action: "tool-credential-github-ssh-updated", + mutation: true, + name: spec.name, + tool: spec.tool, + purpose: spec.purpose, + configured: true, + secretRef: secretRefSummary(spec, namespace), + resourceVersion: stringPath(applied, ["metadata", "resourceVersion"]), + privateKeyHashSuffix: shortHash(privateKey), + knownHostsHashSuffix: shortHash(knownHosts), + configHashSuffix: shortHash(config), + updatedAt: stringPath(applied, ["metadata", "annotations", `${annotationPrefix}-updated-at`]) ?? updatedAt, + credentialValuesPrinted: false, + valuesPrinted: false, + pollCommands: { + show: "./scripts/agentrun tool-credentials show github-ssh", + list: "./scripts/agentrun tool-credentials list", + }, + }; +} + +async function toolCredentialStatus(spec: ToolCredentialSpec, options: ToolCredentialOptions): Promise { + const namespace = runtimeNamespace(options); + const secret = await kubectlGetSecret(spec.secretName, namespace, options.kubectlCommand ?? "kubectl"); + if (!secret) { + return { + name: spec.name, + tool: spec.tool, + purpose: spec.purpose, + mutable: spec.mutable, + configured: false, + failureKind: "secret-unavailable", + secretRef: secretRefSummary(spec, namespace), + resourceVersion: null, + updatedAt: null, + keyPresence: Object.fromEntries(spec.keys.map((key) => [key, false])), + valuesPrinted: false, + }; + } + const data = asOptionalRecord(secret.data); + const annotations = asOptionalRecord(asOptionalRecord(secret.metadata)?.annotations); + return { + name: spec.name, + tool: spec.tool, + purpose: spec.purpose, + mutable: spec.mutable, + configured: hasRequiredKeys(data, spec.keys), + failureKind: hasRequiredKeys(data, spec.keys) ? null : "secret-unavailable", + secretRef: secretRefSummary(spec, namespace), + resourceVersion: stringPath(secret, ["metadata", "resourceVersion"]), + updatedAt: stringPath(annotations, [`${annotationPrefix}-updated-at`]) ?? stringPath(secret, ["metadata", "creationTimestamp"]), + keyPresence: Object.fromEntries(spec.keys.map((key) => [key, typeof data?.[key] === "string" && String(data[key]).length > 0])), + keyHashSuffixes: Object.fromEntries(spec.keys.map((key) => [key, hashDataKey(data, key)])), + valuesPrinted: false, + }; +} + +function requiredSpec(name: string): ToolCredentialSpec { + const spec = toolCredentialSpecs.find((item) => item.name === name); + if (!spec) throw new AgentRunError("schema-invalid", `tool credential ${name} is not supported in v0.1`, { httpStatus: 404, details: { supported: toolCredentialSpecs.map((item) => item.name), valuesPrinted: false } }); + return spec; +} + +function secretRefSummary(spec: ToolCredentialSpec, namespace: string): JsonRecord { + return { namespace, name: spec.secretName, keys: [...spec.keys], valuesPrinted: false }; +} + +function runtimeNamespace(options: ToolCredentialOptions): string { + return options.namespace ?? process.env.AGENTRUN_RUNTIME_NAMESPACE ?? defaultNamespace; +} + +function credentialTextField(record: JsonRecord, key: string, maxBytes: number): string { + const value = record[key]; + if (typeof value !== "string" || value.trim().length === 0) throw new AgentRunError("schema-invalid", `${key} is required`, { httpStatus: 400 }); + const text = normalizeText(value); + const bytes = Buffer.byteLength(text, "utf8"); + if (bytes > maxBytes) throw new AgentRunError("schema-invalid", `${key} exceeds the size limit`, { httpStatus: 400, details: { key, bytes, maxBytes, valuesPrinted: false } }); + return text; +} + +function optionalTextField(record: JsonRecord, key: string, maxBytes: number): string | undefined { + const value = record[key]; + if (value === undefined || value === null) return undefined; + if (typeof value !== "string") throw new AgentRunError("schema-invalid", `${key} must be a string`, { httpStatus: 400 }); + if (value.trim().length === 0) return undefined; + const text = normalizeText(value); + const bytes = Buffer.byteLength(text, "utf8"); + if (bytes > maxBytes) throw new AgentRunError("schema-invalid", `${key} exceeds the size limit`, { httpStatus: 400, details: { key, bytes, maxBytes, valuesPrinted: false } }); + return text; +} + +function normalizeText(value: string): string { + return value.replace(/\r\n?/gu, "\n").endsWith("\n") ? value.replace(/\r\n?/gu, "\n") : `${value.replace(/\r\n?/gu, "\n")}\n`; +} + +function validatePrivateKey(value: string): void { + if (!/^-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----\n/mu.test(value) || !/\n-----END [A-Z0-9 ]*PRIVATE KEY-----\n?$/mu.test(value)) { + throw new AgentRunError("schema-invalid", "privateKey must be an OpenSSH or PEM private key", { httpStatus: 400, details: { valuesPrinted: false } }); + } +} + +function validateKnownHosts(value: string): void { + const lines = value.split(/\n/u).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#")); + if (lines.length === 0) throw new AgentRunError("schema-invalid", "knownHosts must contain at least one host key", { httpStatus: 400 }); +} + +function validateSshConfig(value: string): void { + if (!/Host\s+/iu.test(value) || !/IdentityFile\s+/iu.test(value)) { + throw new AgentRunError("schema-invalid", "config must contain Host and IdentityFile entries", { httpStatus: 400, details: { valuesPrinted: false } }); + } +} + +function defaultGithubSshConfig(): string { + return [ + "Host github.com", + " HostName ssh.github.com", + " User git", + " Port 443", + " IdentityFile ~/.ssh/id_ed25519", + " IdentitiesOnly yes", + " StrictHostKeyChecking yes", + " UserKnownHostsFile ~/.ssh/known_hosts", + "", + ].join("\n"); +} + +async function kubectlGetSecret(name: string, namespace: string, kubectlCommand: string): Promise { + const result = await runKubectl(kubectlCommand, ["get", "secret", name, "-n", namespace, "-o", "json"]); + if (result.code !== 0) { + const failureText = `${result.stderr}\n${result.stdout}`; + if (/notfound|not found|not-found/iu.test(failureText)) return null; + throw new AgentRunError("infra-failed", `kubectl get tool credential Secret ${namespace}/${name} failed with code ${result.code}`, { httpStatus: 502, details: redactJson({ stderr: redactText(result.stderr.slice(-2000)), stdout: redactText(result.stdout.slice(-1000)) }) }); + } + return parseKubectlObject(result.stdout, "tool credential secret"); +} + +async function kubectlUpsertSecret(manifest: JsonRecord, kubectlCommand: string): Promise { + const name = stringPath(manifest, ["metadata", "name"]) ?? ""; + const namespace = stringPath(manifest, ["metadata", "namespace"]) ?? defaultNamespace; + const stdin = `${JSON.stringify(manifest)}\n`; + const replace = await runKubectl(kubectlCommand, ["replace", "-f", "-", "-o", "json"], stdin); + if (replace.code === 0) return parseKubectlObject(replace.stdout, "tool credential secret replace", { redactSecretData: true }); + if (isKubectlNotFoundFailure(replace)) { + const created = await runKubectl(kubectlCommand, ["create", "-f", "-", "-o", "json"], stdin); + if (created.code === 0) return parseKubectlObject(created.stdout, "tool credential secret create", { redactSecretData: true }); + throw new AgentRunError("infra-failed", `kubectl create tool credential Secret ${namespace}/${name} failed with code ${created.code}`, { httpStatus: 502, details: redactJson({ stderr: redactText(created.stderr.slice(-2000)), stdout: redactText(created.stdout.slice(-1000)) }) }); + } + throw new AgentRunError("infra-failed", `kubectl replace tool credential Secret ${namespace}/${name} failed with code ${replace.code}`, { httpStatus: 502, details: redactJson({ stderr: redactText(replace.stderr.slice(-2000)), stdout: redactText(replace.stdout.slice(-1000)) }) }); +} + +function isKubectlNotFoundFailure(result: { stdout: string; stderr: string }): boolean { + return /notfound|not found|not-found/iu.test(`${result.stderr}\n${result.stdout}`); +} + +async function runKubectl(kubectlCommand: string, args: string[], stdin?: string): Promise<{ code: number | null; signal: NodeJS.Signals | null; stdout: string; stderr: string }> { + const child = spawn(kubectlCommand, args, { stdio: ["pipe", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { stdout += String(chunk); }); + child.stderr.on("data", (chunk) => { stderr += String(chunk); }); + child.stdin.end(stdin ?? ""); + const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + child.on("error", reject); + child.on("close", (code, signal) => resolve({ code, signal })); + }).catch((error: unknown) => { + throw new AgentRunError("infra-failed", `failed to start kubectl: ${error instanceof Error ? error.message : String(error)}`, { httpStatus: 503 }); + }); + return { ...result, stdout, stderr }; +} + +function parseKubectlObject(stdout: string, label: string, options: { redactSecretData?: boolean } = {}): JsonRecord { + try { + const parsed = JSON.parse(stdout) as unknown; + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return options.redactSecretData ? redactSecretObject(parsed as JsonRecord) : parsed as JsonRecord; + } catch (error) { + throw new AgentRunError("infra-failed", `kubectl returned invalid JSON for ${label}: ${error instanceof Error ? error.message : String(error)}`, { httpStatus: 502, details: { stdoutPreview: label.includes("secret") ? "REDACTED" : redactText(stdout.slice(0, 1000)) } }); + } + throw new AgentRunError("infra-failed", `kubectl returned non-object JSON for ${label}`, { httpStatus: 502 }); +} + +function redactSecretObject(object: JsonRecord): JsonRecord { + const copy = JSON.parse(JSON.stringify(object)) as JsonRecord; + if (copy.data) copy.data = "REDACTED"; + if (copy.stringData) copy.stringData = "REDACTED"; + return copy; +} + +function hasRequiredKeys(data: JsonRecord | null, keys: readonly string[]): boolean { + return keys.every((key) => typeof data?.[key] === "string" && String(data[key]).length > 0); +} + +function hashDataKey(data: JsonRecord | null, key: string): string | null { + const value = data?.[key]; + if (typeof value !== "string" || value.length === 0) return null; + try { + return shortHash(Buffer.from(value, "base64").toString("utf8")); + } catch { + return shortHash(value); + } +} + +function base64Data(value: string): string { + return Buffer.from(value, "utf8").toString("base64"); +} + +function shortHash(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 12); +} + +function objectPath(record: JsonRecord, path: string[]): unknown { + let current: unknown = record; + for (const key of path) { + if (typeof current !== "object" || current === null || Array.isArray(current)) return null; + current = (current as JsonRecord)[key]; + } + return current; +} + +function stringPath(record: JsonRecord | null | undefined, path: string[]): string | null { + if (!record) return null; + const value = objectPath(record, path); + return typeof value === "string" ? value : null; +} + +function asOptionalRecord(value: unknown): JsonRecord | null { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as JsonRecord : null; +} diff --git a/src/runner/k8s-job.ts b/src/runner/k8s-job.ts index 157a183..49d072b 100644 --- a/src/runner/k8s-job.ts +++ b/src/runner/k8s-job.ts @@ -79,14 +79,26 @@ interface CredentialProjection { projectionMountPath: string; } -interface ToolCredentialProjection { +type ToolCredentialProjection = ToolCredentialEnvProjection | ToolCredentialVolumeProjection; + +interface ToolCredentialBaseProjection { tool: string; purpose: string | null; secretRef: SecretRef; +} + +interface ToolCredentialEnvProjection extends ToolCredentialBaseProjection { + kind: "env"; envName: string; secretKey: string; } +interface ToolCredentialVolumeProjection extends ToolCredentialBaseProjection { + kind: "volume"; + volumeName: string; + mountPath: string; +} + export function renderRunnerJobDryRun(options: RunnerJobRenderOptions): JsonRecord { const render = renderRunnerJobManifest({ ...options, dryRun: true }); const manifest = redactTransientEnvInManifest(render.manifest, options.transientEnv ?? []); @@ -176,6 +188,7 @@ export function renderRunnerJobManifest(options: RunnerJobRenderOptions): { mani volumeMounts: [ { name: "runner-home", mountPath: "/home/agentrun" }, ...secretRefs.map((item) => ({ name: item.volumeName, mountPath: item.projectionMountPath, readOnly: true })), + ...toolCredentialVolumeMounts(toolCredentials), ...(sessionPvc ? [{ name: "agentrun-sessions", mountPath: sessionPvc.mountPath, readOnly: false }] : []), ], resources: { @@ -192,6 +205,7 @@ export function renderRunnerJobManifest(options: RunnerJobRenderOptions): { mani volumes: [ { name: "runner-home", emptyDir: {} }, ...secretRefs.map(secretVolume), + ...toolCredentialVolumes(toolCredentials), ...(sessionPvc ? [{ name: "agentrun-sessions", persistentVolumeClaim: { claimName: sessionPvc.pvcName } }] : []), ], }, @@ -248,7 +262,7 @@ function codexShellSandbox(policy: ExecutionPolicy): string { } function toolCredentialEnvVars(items: ToolCredentialProjection[]): JsonRecord[] { - return items.map((item) => ({ + return items.filter((item): item is ToolCredentialEnvProjection => item.kind === "env").map((item) => ({ name: item.envName, valueFrom: { secretKeyRef: { @@ -259,6 +273,14 @@ function toolCredentialEnvVars(items: ToolCredentialProjection[]): JsonRecord[] })); } +function toolCredentialVolumeMounts(items: ToolCredentialProjection[]): JsonRecord[] { + return items.filter((item): item is ToolCredentialVolumeProjection => item.kind === "volume").map((item) => ({ name: item.volumeName, mountPath: item.mountPath, readOnly: true })); +} + +function toolCredentialVolumes(items: ToolCredentialProjection[]): JsonRecord[] { + return items.filter((item): item is ToolCredentialVolumeProjection => item.kind === "volume").map((item) => secretVolume({ profile: item.tool, secretRef: item.secretRef, volumeName: item.volumeName, runtimeMountPath: item.mountPath, projectionMountPath: item.mountPath })); +} + function transientEnvVars(items: RunnerTransientEnv[]): JsonRecord[] { return items.map((item) => { if (item.secretRef) { @@ -318,7 +340,7 @@ function summarizeToolCredentials(items: ToolCredentialProjection[], namespace: name: item.secretRef.name, namespace: item.secretRef.namespace ?? namespace, keys: item.secretRef.keys ?? [], - projection: { kind: "env", envName: item.envName, secretKey: item.secretKey }, + projection: item.kind === "env" ? { kind: "env", envName: item.envName, secretKey: item.secretKey } : { kind: "volume", mountPath: item.mountPath }, valuesPrinted: false, })), valuesPrinted: false, @@ -363,13 +385,12 @@ function credentialSecretRef(profile: string, secretRef: SecretRef, namespace: s function toolCredentialProjections(run: RunRecord, namespace: string): ToolCredentialProjection[] { const policy: ExecutionPolicy = run.executionPolicy; const credentials = policy.secretScope.toolCredentials ?? []; - return credentials.map((item) => ({ - tool: item.tool, - purpose: item.purpose ?? null, - secretRef: item.secretRef.namespace ? item.secretRef : { ...item.secretRef, namespace }, - envName: item.projection.envName, - secretKey: item.projection.secretKey ?? item.secretRef.keys?.[0] ?? item.projection.envName, - })); + return credentials.map((item, index) => { + const secretRef = item.secretRef.namespace ? item.secretRef : { ...item.secretRef, namespace }; + const base = { tool: item.tool, purpose: item.purpose ?? null, secretRef }; + if (item.projection.kind === "env") return { ...base, kind: "env" as const, envName: item.projection.envName, secretKey: item.projection.secretKey ?? item.secretRef.keys?.[0] ?? item.projection.envName }; + return { ...base, kind: "volume" as const, volumeName: sanitizeVolumeName(`tool-${item.tool}-${index}`), mountPath: item.projection.mountPath }; + }); } function secretVolume(item: CredentialProjection): JsonRecord { diff --git a/src/runner/resource-bundle.ts b/src/runner/resource-bundle.ts index e73211b..355a8a6 100644 --- a/src/runner/resource-bundle.ts +++ b/src/runner/resource-bundle.ts @@ -43,6 +43,9 @@ interface MaterializedSkillRef { interface GitCheckout { repoUrl: string; + fetchRepoUrl: string; + mirrorUsed: boolean; + mirrorBaseUrl?: string; commitId: string; requestedCommitId?: string; requestedRef?: string; @@ -50,15 +53,24 @@ interface GitCheckout { treeId: string; } +interface GitMirrorConfig { + enabled: true; + baseUrl: string; +} + interface GitBundleSource { repoUrl: string; commitId?: string; ref?: string; + gitMirror?: GitMirrorConfig; } interface MaterializedGitBundle { name: string | null; repoUrl: string; + fetchRepoUrl: string; + mirrorUsed: boolean; + mirrorBaseUrl: string | null; commitId: string; requestedCommitId: string | null; requestedRef: string | null; @@ -78,7 +90,8 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl await rm(assemblyRoot, { recursive: true, force: true }); await mkdir(checkoutRoot, { recursive: true }); await mkdir(workspacePath, { recursive: true }); - const defaultSource = defaultGitBundleSource(resourceBundleRef, env); + const gitMirror = gitMirrorConfig(resourceBundleRef, env); + const defaultSource = defaultGitBundleSource(resourceBundleRef, env, gitMirror); const checkoutCache = new Map>(); const checkoutFor = (source: GitBundleSource) => { const key = stableHash(gitSourceIdentity(source)); @@ -105,6 +118,10 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl phase: "resource-bundle-materialized", kind: "gitbundle", repoUrl: resourceBundleRef.repoUrl, + fetchRepoUrl: defaultCheckout.fetchRepoUrl, + mirrorUsed: defaultCheckout.mirrorUsed, + mirrorBaseUrl: defaultCheckout.mirrorBaseUrl ?? null, + gitMirror: gitMirror ? { enabled: true, baseUrl: gitMirror.baseUrl, valuesPrinted: false } : { enabled: false, baseUrl: null, valuesPrinted: false }, commitId: defaultCheckout.commitId, requestedCommitId: resourceBundleRef.commitId ?? null, requestedRef: defaultCheckout.requestedRef ?? null, @@ -126,32 +143,44 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl }; } -function defaultGitBundleSource(resourceBundleRef: ResourceBundleRef, env: NodeJS.ProcessEnv): GitBundleSource { +function gitMirrorConfig(resourceBundleRef: ResourceBundleRef, env: NodeJS.ProcessEnv): GitMirrorConfig | undefined { + return normalizeGitMirrorConfig(resourceBundleRef.gitMirror, env); +} + +function normalizeGitMirrorConfig(gitMirror: ResourceBundleRef["gitMirror"] | GitMirrorConfig | undefined, env: NodeJS.ProcessEnv): GitMirrorConfig | undefined { + if (!gitMirror || gitMirror.enabled === false) return undefined; + const baseUrl = optionalNonEmpty(gitMirror.baseUrl) ?? optionalNonEmpty(env.AGENTRUN_GIT_MIRROR_BASE_URL) ?? "http://git-mirror-http.devops-infra.svc.cluster.local"; + return { enabled: true, baseUrl: baseUrl.replace(/\/+$/u, "") }; +} + +function defaultGitBundleSource(resourceBundleRef: ResourceBundleRef, env: NodeJS.ProcessEnv, gitMirror?: GitMirrorConfig): GitBundleSource { const ref = optionalNonEmpty(resourceBundleRef.ref) ?? optionalNonEmpty(env.AGENTRUN_RESOURCE_BUNDLE_REF) ?? optionalNonEmpty(env.AGENTRUN_WORKSPACE_REF) ?? optionalNonEmpty(env.AGENTRUN_WORKSPACE_BRANCH); - if (ref) return { repoUrl: resourceBundleRef.repoUrl, ref }; + if (ref) return { repoUrl: resourceBundleRef.repoUrl, ref, ...(gitMirror ? { gitMirror } : {}) }; const commitId = optionalNonEmpty(resourceBundleRef.commitId); - if (commitId) return { repoUrl: resourceBundleRef.repoUrl, commitId }; - return { repoUrl: resourceBundleRef.repoUrl, ref: "HEAD" }; + if (commitId) return { repoUrl: resourceBundleRef.repoUrl, commitId, ...(gitMirror ? { gitMirror } : {}) }; + return { repoUrl: resourceBundleRef.repoUrl, ref: "HEAD", ...(gitMirror ? { gitMirror } : {}) }; } function bundleGitSource(bundle: ResourceBundleRef["bundles"][number], resourceBundleRef: ResourceBundleRef, defaultSource: GitBundleSource): GitBundleSource { const repoUrl = bundle.repoUrl ?? resourceBundleRef.repoUrl; const ref = optionalNonEmpty(bundle.ref); - if (ref) return { repoUrl, ref }; + const mirror = defaultSource.gitMirror; + if (ref) return { repoUrl, ref, ...(mirror ? { gitMirror: mirror } : {}) }; const commitId = optionalNonEmpty(bundle.commitId); - if (commitId) return { repoUrl, commitId }; + if (commitId) return { repoUrl, commitId, ...(mirror ? { gitMirror: mirror } : {}) }; if (repoUrl === defaultSource.repoUrl) return defaultSource; - if (defaultSource.ref) return { repoUrl, ref: defaultSource.ref }; - if (defaultSource.commitId) return { repoUrl, commitId: defaultSource.commitId }; - return { repoUrl, ref: "HEAD" }; + if (defaultSource.ref) return { repoUrl, ref: defaultSource.ref, ...(mirror ? { gitMirror: mirror } : {}) }; + if (defaultSource.commitId) return { repoUrl, commitId: defaultSource.commitId, ...(mirror ? { gitMirror: mirror } : {}) }; + return { repoUrl, ref: "HEAD", ...(mirror ? { gitMirror: mirror } : {}) }; } async function checkoutGitSource(checkoutRoot: string, source: GitBundleSource): Promise { const checkoutPath = path.join(checkoutRoot, stableHash(gitSourceIdentity(source)).slice(0, 16)); + const fetch = gitFetchSource(source); await mkdir(checkoutPath, { recursive: true }); await git(["init"], checkoutPath); await git(["remote", "remove", "origin"], checkoutPath, { allowFailure: true }); - await git(["remote", "add", "origin", source.repoUrl], checkoutPath); + await git(["remote", "add", "origin", fetch.fetchRepoUrl], checkoutPath); if (source.ref) { await git(["fetch", "--depth", "1", "origin", source.ref], checkoutPath); await git(["checkout", "--detach", "FETCH_HEAD"], checkoutPath); @@ -164,11 +193,41 @@ async function checkoutGitSource(checkoutRoot: string, source: GitBundleSource): const actualCommit = (await git(["rev-parse", "HEAD"], checkoutPath)).stdout.trim(); if (source.commitId && actualCommit !== source.commitId) throw new AgentRunError("infra-failed", "gitbundle checkout did not land on requested commit", { httpStatus: 500, details: { expectedCommit: source.commitId, actualCommit } }); const treeId = (await git(["rev-parse", "HEAD^{tree}"], checkoutPath)).stdout.trim(); - return { repoUrl: source.repoUrl, commitId: actualCommit, ...(source.commitId ? { requestedCommitId: source.commitId } : {}), ...(source.ref ? { requestedRef: source.ref } : {}), checkoutPath, treeId }; + return { repoUrl: source.repoUrl, fetchRepoUrl: fetch.fetchRepoUrl, mirrorUsed: fetch.mirrorUsed, ...(fetch.mirrorBaseUrl ? { mirrorBaseUrl: fetch.mirrorBaseUrl } : {}), commitId: actualCommit, ...(source.commitId ? { requestedCommitId: source.commitId } : {}), ...(source.ref ? { requestedRef: source.ref } : {}), checkoutPath, treeId }; } function gitSourceIdentity(source: GitBundleSource): JsonRecord { - return { repoUrl: source.repoUrl, commitId: source.commitId ?? null, ref: source.ref ?? null }; + return { repoUrl: source.repoUrl, commitId: source.commitId ?? null, ref: source.ref ?? null, gitMirror: source.gitMirror ? { enabled: true, baseUrl: source.gitMirror.baseUrl } : null }; +} + +export function resolveGitBundleFetchSource(repoUrl: string, gitMirror?: ResourceBundleRef["gitMirror"] | GitMirrorConfig, env: NodeJS.ProcessEnv = process.env): { fetchRepoUrl: string; mirrorUsed: boolean; mirrorBaseUrl?: string } { + const mirror = normalizeGitMirrorConfig(gitMirror, env); + if (!mirror) return { fetchRepoUrl: repoUrl, mirrorUsed: false }; + const githubPath = githubRepoPath(repoUrl); + if (!githubPath) return { fetchRepoUrl: repoUrl, mirrorUsed: false }; + return { fetchRepoUrl: `${mirror.baseUrl}/${githubPath}.git`, mirrorUsed: true, mirrorBaseUrl: mirror.baseUrl }; +} + +function gitFetchSource(source: GitBundleSource): { fetchRepoUrl: string; mirrorUsed: boolean; mirrorBaseUrl?: string } { + return resolveGitBundleFetchSource(source.repoUrl, source.gitMirror); +} + +function githubRepoPath(repoUrl: string): string | null { + const raw = repoUrl.trim(); + const scp = /^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/u.exec(raw); + if (scp) return cleanGithubPath(scp[1] ?? "", scp[2] ?? ""); + const ssh = /^ssh:\/\/git@(?:github\.com|ssh\.github\.com)(?::\d+)?\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/u.exec(raw); + if (ssh) return cleanGithubPath(ssh[1] ?? "", ssh[2] ?? ""); + const http = /^https?:\/\/github\.com\/([^/]+)\/([^/#?]+?)(?:\.git)?\/?$/u.exec(raw); + if (http) return cleanGithubPath(http[1] ?? "", http[2] ?? ""); + return null; +} + +function cleanGithubPath(owner: string, repo: string): string | null { + const cleanOwner = owner.trim(); + const cleanRepo = repo.trim().replace(/\.git$/u, ""); + if (!/^[A-Za-z0-9_.-]+$/u.test(cleanOwner) || !/^[A-Za-z0-9_.-]+$/u.test(cleanRepo)) return null; + return `${cleanOwner}/${cleanRepo}`; } async function materializeGitBundles(workspacePath: string, resourceBundleRef: ResourceBundleRef, defaultSource: GitBundleSource, defaultCheckout: GitCheckout, checkoutFor: (source: GitBundleSource) => Promise): Promise { @@ -187,7 +246,7 @@ async function materializeGitBundles(workspacePath: string, resourceBundleRef: R await mkdir(path.dirname(target), { recursive: true }); await rm(target, { recursive: true, force: true }); await cp(source, target, { recursive: true, force: true, dereference: false }); - items.push({ name: bundle.name ?? null, repoUrl: checkout.repoUrl, commitId: checkout.commitId, requestedCommitId: bundle.commitId ?? resourceBundleRef.commitId ?? null, requestedRef: checkout.requestedRef ?? null, subpath: bundle.subpath, targetPath: bundle.targetPath, sourceKind: sourceStat.isDirectory() ? "directory" : "file", sourceBytes: sourceStat.isFile() ? sourceStat.size : null }); + items.push({ name: bundle.name ?? null, repoUrl: checkout.repoUrl, fetchRepoUrl: checkout.fetchRepoUrl, mirrorUsed: checkout.mirrorUsed, mirrorBaseUrl: checkout.mirrorBaseUrl ?? null, commitId: checkout.commitId, requestedCommitId: bundle.commitId ?? resourceBundleRef.commitId ?? null, requestedRef: checkout.requestedRef ?? null, subpath: bundle.subpath, targetPath: bundle.targetPath, sourceKind: sourceStat.isDirectory() ? "directory" : "file", sourceBytes: sourceStat.isFile() ? sourceStat.size : null }); } return items; } @@ -380,6 +439,9 @@ function skillSourceBundle(relativePath: string, bundles: MaterializedGitBundle[ return { name: match.name, repoUrl: match.repoUrl, + fetchRepoUrl: match.fetchRepoUrl, + mirrorUsed: match.mirrorUsed, + mirrorBaseUrl: match.mirrorBaseUrl, commitId: match.commitId, requestedCommitId: match.requestedCommitId, requestedRef: match.requestedRef, diff --git a/src/selftest/cases/20-runner-k8s-job.ts b/src/selftest/cases/20-runner-k8s-job.ts index 321f3a1..d322ca1 100644 --- a/src/selftest/cases/20-runner-k8s-job.ts +++ b/src/selftest/cases/20-runner-k8s-job.ts @@ -24,7 +24,13 @@ const selfTest: SelfTestCase = async (context) => { 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 githubSshToolCredentials = [{ + tool: "github", + purpose: "github-ssh", + secretRef: { name: "agentrun-v01-tool-github-ssh", keys: ["id_ed25519", "known_hosts", "config"] }, + projection: { kind: "volume", mountPath: "/home/agentrun/.ssh" }, + }]; + const combinedToolCredentials = [...githubToolCredentials, ...unideskSshToolCredentials, ...githubSshToolCredentials]; 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, @@ -42,6 +48,7 @@ const selfTest: SelfTestCase = async (context) => { 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"); + assertRunnerJobUsesToolCredentialVolume(rendered, "agentrun-v01-tool-github-ssh", "/home/agentrun/.ssh", ["id_ed25519", "known_hosts", "config"]); assertRunnerJobUsesG14EgressProxy(rendered.manifest as JsonRecord); assert.equal(runnerEnvValue(rendered.manifest as JsonRecord, "AGENTRUN_CODEX_SHELL_SANDBOX"), "danger-full-access"); assert.equal(runnerEnvValue(rendered.manifest as JsonRecord, "HWLAB_API_KEY"), "REDACTED"); @@ -211,6 +218,7 @@ process.exit(1); assertRunnerJobUsesTransientEnvSecret(manifest, "UNIDESK_MAIN_SERVER_IP", String(transientEnvSecret.name)); 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"); + assertRunnerJobUsesToolCredentialVolume({ manifest, toolCredentials: (created as JsonRecord).toolCredentials } as JsonRecord, "agentrun-v01-tool-github-ssh", "/home/agentrun/.ssh", ["id_ed25519", "known_hosts", "config"]); 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`, { @@ -277,7 +285,7 @@ process.exit(1); 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-g14-egress-proxy-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-transient-env-secretref", "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"] }; + return { name: "runner-k8s-job", tests: ["runner-k8s-job-dry-run", "runner-k8s-job-codex-shell-sandbox-env", "runner-k8s-job-g14-egress-proxy-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-transient-env-secretref", "runner-job-tool-credential-env", "runner-job-unidesk-ssh-tool-credential-env", "runner-job-tool-credential-volume", "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())); } @@ -353,6 +361,35 @@ function assertRunnerJobUsesToolCredential(rendered: JsonRecord, envName: string assert.equal(summaryEntry.valuesPrinted, false); } +function assertRunnerJobUsesToolCredentialVolume(rendered: JsonRecord, secretName: string, mountPath: string, secretKeys: string[]): void { + const manifest = rendered.manifest as JsonRecord; + 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 runner = containers[0] as JsonRecord; + const mounts = runner.volumeMounts as JsonRecord[]; + const mount = mounts.find((item) => item.mountPath === mountPath) as JsonRecord | undefined; + assert.ok(mount, `${mountPath} tool credential volume mount should be present`); + assert.equal(mount.readOnly, true); + const volumes = podSpec.volumes as JsonRecord[]; + const volume = volumes.find((item) => item.name === mount.name) as JsonRecord | undefined; + assert.ok(volume, `${mountPath} tool credential volume should be present`); + const secret = volume.secret as JsonRecord; + assert.equal(secret.secretName, secretName); + assert.deepEqual((secret.items as JsonRecord[]).map((item) => item.key), secretKeys); + + const summary = rendered.toolCredentials as JsonRecord; + assert.equal(summary.valuesPrinted, false); + const items = summary.items as JsonRecord[]; + const summaryEntry = items.find((item) => { + const projection = item.projection as JsonRecord; + return item.name === secretName && projection.kind === "volume" && projection.mountPath === mountPath; + }); + assert.ok(summaryEntry, `${mountPath} tool credential summary should include its SecretRef and volume projection`); + assert.equal(summaryEntry.valuesPrinted, false); +} + function assertRunnerJobUsesWritableCodexHome(manifest: JsonRecord, expectedCodexHome: string, volumeName: string, projectionPath: string): void { const spec = manifest.spec as JsonRecord; const template = spec.template as JsonRecord; diff --git a/src/selftest/cases/46-tool-credentials.ts b/src/selftest/cases/46-tool-credentials.ts new file mode 100644 index 0000000..5817b05 --- /dev/null +++ b/src/selftest/cases/46-tool-credentials.ts @@ -0,0 +1,147 @@ +import assert from "node:assert/strict"; +import { chmod, readFile, writeFile } from "node:fs/promises"; +import { spawn } from "node:child_process"; +import path from "node:path"; +import { ManagerClient } from "../../mgr/client.js"; +import { startManagerServer } from "../../mgr/server.js"; +import { MemoryAgentRunStore } from "../../mgr/store.js"; +import type { JsonRecord } from "../../common/types.js"; +import { assertNoSecretLeak, type SelfTestCase } from "../harness.js"; + +const privateKeyHeader = ["-----BEGIN OPENSSH", "PRIVATE KEY-----"].join(" "); +const privateKeyFooter = ["-----END OPENSSH", "PRIVATE KEY-----"].join(" "); +const privateKey = `${privateKeyHeader} +selftest-private-key-material +${privateKeyFooter} +`; +const knownHosts = "github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIselftestKnownHostsKey\n"; +const sshConfig = "Host github.com\n HostName ssh.github.com\n User git\n Port 443\n IdentityFile ~/.ssh/id_ed25519\n"; + +const selfTest: SelfTestCase = async (context) => { + const fakeKubectl = path.join(context.tmp, "fake-tool-kubectl.js"); + const stateDir = path.join(context.tmp, "tool-secret-state"); + const createdSecretPath = path.join(context.tmp, "tool-secret-create.json"); + await writeFile(fakeKubectl, `#!/usr/bin/env bun +import { mkdirSync } from "node:fs"; +const args = Bun.argv.slice(2); +const stateDir = ${JSON.stringify(stateDir)}; +const statePath = (name) => stateDir + "/" + name + ".json"; +mkdirSync(stateDir, { recursive: true }); +const readStdin = async () => { + const chunks = []; + for await (const chunk of Bun.stdin.stream()) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks).toString("utf8"); +}; +if (args[0] === "get" && args[1] === "secret") { + const name = args[2]; + const file = Bun.file(statePath(name)); + if (!(await file.exists())) { + console.error('Error from server (NotFound): secrets "' + name + '" not found'); + process.exit(1); + } + console.log(await file.text()); + process.exit(0); +} +if (args[0] === "replace") { + const text = await readStdin(); + const manifest = JSON.parse(text); + const file = Bun.file(statePath(manifest.metadata.name)); + if (!(await file.exists())) { + console.error('Error from server (NotFound): secrets "' + manifest.metadata.name + '" not found'); + process.exit(1); + } + const next = { ...manifest, metadata: { ...(manifest.metadata ?? {}), resourceVersion: "rv-replaced" } }; + await Bun.write(statePath(manifest.metadata.name), JSON.stringify(next)); + console.log(JSON.stringify(next)); + process.exit(0); +} +if (args[0] === "create") { + const text = await readStdin(); + const manifest = JSON.parse(text); + await Bun.write(${JSON.stringify(createdSecretPath)}, JSON.stringify({ args, manifest }, null, 2)); + const next = { ...manifest, metadata: { ...(manifest.metadata ?? {}), resourceVersion: "rv-created" } }; + await Bun.write(statePath(manifest.metadata.name), JSON.stringify(next)); + console.log(JSON.stringify(next)); + process.exit(0); +} +console.error("unsupported fake kubectl args: " + JSON.stringify(args)); +process.exit(1); +`); + await chmod(fakeKubectl, 0o755); + + const server = await startManagerServer({ + port: 0, + host: "127.0.0.1", + sourceCommit: "self-test", + store: new MemoryAgentRunStore(), + toolCredentialOptions: { namespace: "agentrun-v01", kubectlCommand: fakeKubectl }, + }); + try { + const client = new ManagerClient(server.baseUrl); + const missing = await client.get("/api/v1/tool-credentials") as JsonRecord; + assert.equal(missing.action, "tool-credential-list"); + assert.equal(missing.count, 2); + assert.equal(JSON.stringify(missing).includes(privateKey), false); + const githubMissing = ((missing.items as JsonRecord[]) ?? []).find((item) => item.name === "github-ssh") as JsonRecord | undefined; + assert.equal(githubMissing?.configured, false); + assert.equal(githubMissing?.failureKind, "secret-unavailable"); + + const updated = await client.put("/api/v1/tool-credentials/github-ssh/credential", { privateKey, knownHosts, config: sshConfig }) as JsonRecord; + assert.equal(updated.action, "tool-credential-github-ssh-updated"); + assert.equal(updated.configured, true); + assert.equal(updated.resourceVersion, "rv-created"); + assertNoCredentialLeak(updated); + assertNoSecretLeak(updated); + const created = JSON.parse(await readFile(createdSecretPath, "utf8")) as JsonRecord; + const manifest = created.manifest as JsonRecord; + assert.equal(((manifest.metadata as JsonRecord).name), "agentrun-v01-tool-github-ssh"); + const data = manifest.data as JsonRecord; + assert.equal(Buffer.from(String(data.id_ed25519), "base64").toString("utf8"), privateKey); + assert.equal(Buffer.from(String(data.known_hosts), "base64").toString("utf8"), knownHosts); + assert.equal(Buffer.from(String(data.config), "base64").toString("utf8"), sshConfig); + + const shown = await client.get("/api/v1/tool-credentials/github-ssh") as JsonRecord; + assert.equal(shown.configured, true); + assert.deepEqual((shown.keyPresence as JsonRecord), { id_ed25519: true, known_hosts: true, config: true }); + assertNoCredentialLeak(shown); + + const privateKeyFile = path.join(context.tmp, "id_ed25519"); + const knownHostsFile = path.join(context.tmp, "known_hosts"); + await writeFile(privateKeyFile, privateKey); + await writeFile(knownHostsFile, knownHosts); + const dryRun = await runCliJson(context, ["tool-credentials", "set-github-ssh", "--private-key-file", privateKeyFile, "--known-hosts-file", knownHostsFile, "--dry-run"]); + assert.equal(dryRun.ok, true); + assert.equal(((dryRun.data as JsonRecord).action), "tool-credential-github-ssh-plan"); + assertNoCredentialLeak(dryRun); + return { name: "tool-credentials", tests: ["tool-credential-list-missing", "tool-credential-github-ssh-upsert-redacted", "tool-credential-cli-dry-run"] }; + } finally { + await new Promise((resolve) => server.server.close(() => resolve())); + } +}; + +function assertNoCredentialLeak(value: unknown): void { + const text = JSON.stringify(value); + assert.equal(text.includes("selftest-private-key-material"), false); + assert.equal(text.includes(privateKeyHeader), false); + assert.equal(text.includes("AAAAC3NzaC1lZDI1NTE5AAAAIselftestKnownHostsKey"), false); + assert.equal(text.includes("IdentityFile ~/.ssh/id_ed25519"), false); +} + +async function runCliJson(context: { root: string }, args: string[]): Promise { + const proc = spawn(process.execPath, [`${context.root}/scripts/agentrun-cli.ts`, ...args], { stdio: ["ignore", "pipe", "pipe"] }); + const [stdout, stderr, code] = await Promise.all([readStream(proc.stdout), readStream(proc.stderr), new Promise((resolve) => proc.on("close", resolve))]); + assert.equal(code, 0, stderr || stdout); + return JSON.parse(stdout) as JsonRecord; +} + +async function readStream(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + stream.on("data", (chunk: Buffer | string) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + await new Promise((resolve, reject) => { + stream.on("end", resolve); + stream.on("error", reject); + }); + return Buffer.concat(chunks).toString("utf8"); +} + +export default selfTest; diff --git a/src/selftest/cases/76-aipod-spec.ts b/src/selftest/cases/76-aipod-spec.ts new file mode 100644 index 0000000..6d1767b --- /dev/null +++ b/src/selftest/cases/76-aipod-spec.ts @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import path from "node:path"; +import { ManagerClient } from "../../mgr/client.js"; +import { startManagerServer } from "../../mgr/server.js"; +import { MemoryAgentRunStore } from "../../mgr/store.js"; +import type { JsonRecord } from "../../common/types.js"; +import { resolveGitBundleFetchSource } from "../../runner/resource-bundle.js"; +import { assertNoSecretLeak, type SelfTestCase } from "../harness.js"; + +const selfTest: SelfTestCase = async (context) => { + const server = await startManagerServer({ port: 0, host: "127.0.0.1", sourceCommit: "self-test", store: new MemoryAgentRunStore(), aipodSpecDir: path.join(context.root, "config", "aipods") }); + try { + const client = new ManagerClient(server.baseUrl); + const listed = await client.get("/api/v1/aipod-specs") as JsonRecord; + assert.equal(listed.action, "aipod-spec-list"); + assert.equal((listed.items as JsonRecord[]).some((item) => item.name === "Artificer"), true); + + const shown = await client.get("/api/v1/aipod-specs/Artificer") as JsonRecord; + const shownItem = shown.item as JsonRecord; + assert.equal(shownItem.backendProfile, "sub2api"); + assert.equal(((shownItem.model as JsonRecord).model), "gpt-5.5"); + assert.equal(((shownItem.resourceBundleRef as JsonRecord).gitMirror as JsonRecord).enabled, true); + + const rendered = await client.post("/api/v1/aipod-specs/Artificer/render", { prompt: "处理 pikasTech/unidesk#245", idempotencyKey: "selftest-aipod-artificer" }) as JsonRecord; + assert.equal(rendered.action, "aipod-spec-render"); + const task = rendered.queueTask as JsonRecord; + assert.equal(task.backendProfile, "sub2api"); + assert.equal(task.providerId, "G14"); + assert.equal(task.idempotencyKey, "selftest-aipod-artificer"); + assert.equal(((task.payload as JsonRecord).model), "gpt-5.5"); + assert.equal((((task.payload as JsonRecord).modelConfig as JsonRecord).reasoningEffort), "xhigh"); + const policy = task.executionPolicy as JsonRecord; + const secretScope = policy.secretScope as JsonRecord; + const providerCredentials = secretScope.providerCredentials as JsonRecord[]; + assert.equal(providerCredentials.some((item) => item.profile === "sub2api" && ((item.secretRef as JsonRecord).name) === "agentrun-v01-provider-sub2api"), true); + const toolCredentials = secretScope.toolCredentials as JsonRecord[]; + assert.equal(toolCredentials.some((item) => item.tool === "unidesk-ssh" && ((item.projection as JsonRecord).envName) === "UNIDESK_SSH_CLIENT_TOKEN"), true); + assert.equal(toolCredentials.some((item) => item.tool === "github" && ((item.projection as JsonRecord).kind) === "volume" && ((item.projection as JsonRecord).mountPath) === "/home/agentrun/.ssh"), true); + const bundle = task.resourceBundleRef as JsonRecord; + assert.equal(((bundle.gitMirror as JsonRecord).enabled), true); + const bundles = bundle.bundles as JsonRecord[]; + const toolBundle = bundles.find((item) => item.name === "agentrun-runner-tools") as JsonRecord | undefined; + assert.equal(toolBundle?.repoUrl, "git@github.com:pikasTech/agentrun.git"); + assert.equal(toolBundle?.ref, "v0.1"); + assert.equal(toolBundle?.subpath, "tools"); + assert.equal(toolBundle?.targetPath, "tools"); + assert.equal((bundle.requiredSkills as JsonRecord[]).some((item) => item.name === "dad-dev"), true); + assert.equal((bundle.requiredSkills as JsonRecord[]).some((item) => item.name === "unidesk-gh"), true); + assertNoSecretLeak(rendered); + + const mirrored = resolveGitBundleFetchSource("git@github.com:pikasTech/unidesk.git", { enabled: true, baseUrl: "http://mirror.example.test/root/" }, {}); + assert.equal(mirrored.fetchRepoUrl, "http://mirror.example.test/root/pikasTech/unidesk.git"); + assert.equal(mirrored.mirrorUsed, true); + const nonGithub = resolveGitBundleFetchSource("ssh://git@example.test/repo.git", { enabled: true, baseUrl: "http://mirror.example.test" }, {}); + assert.equal(nonGithub.fetchRepoUrl, "ssh://git@example.test/repo.git"); + assert.equal(nonGithub.mirrorUsed, false); + + const submitPlan = await runCliJson(context, server.baseUrl, ["queue", "submit", "--aipod", "Artificer", "--prompt", "处理 pikasTech/unidesk#245", "--idempotency-key", "selftest-aipod-cli", "--dry-run"]); + assert.equal(submitPlan.ok, true); + assert.equal(((submitPlan.data as JsonRecord).action), "queue-submit-plan"); + assert.equal((((submitPlan.data as JsonRecord).jsonInput as JsonRecord).source), "aipod-spec"); + const request = ((submitPlan.data as JsonRecord).request as JsonRecord); + assert.equal(((request.body as JsonRecord).idempotencyKey), "selftest-aipod-cli"); + + const help = await runCliJson(context, server.baseUrl, ["help"]); + const commands = ((help.data as JsonRecord).commands as string[]) ?? []; + assert.equal(commands.some((item) => item.includes("aipod-specs render ")), true); + assert.equal(commands.some((item) => item.includes("queue submit --aipod ")), true); + assertNoSecretLeak(submitPlan); + return { name: "aipod-spec", tests: ["aipod-spec-artificer-yaml-render", "aipod-spec-git-mirror-url", "queue-submit-aipod-dry-run", "aipod-cli-help"] }; + } finally { + await new Promise((resolve) => server.server.close(() => resolve())); + } +}; + +export default selfTest; + +async function runCliJson(context: { root: string }, managerUrl: string, args: string[]): Promise { + const proc = spawn(process.execPath, [`${context.root}/scripts/agentrun-cli.ts`, "--manager-url", managerUrl, ...args], { stdio: ["ignore", "pipe", "pipe"] }); + const [stdout, stderr, code] = await Promise.all([readStream(proc.stdout), readStream(proc.stderr), new Promise((resolve) => proc.on("close", resolve))]); + assert.equal(code, 0, stderr || stdout); + return JSON.parse(stdout) as JsonRecord; +} + +async function readStream(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + stream.on("data", (chunk: Buffer | string) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + await new Promise((resolve, reject) => { + stream.on("end", resolve); + stream.on("error", reject); + }); + return Buffer.concat(chunks).toString("utf8"); +} diff --git a/src/selftest/cases/90-runner-image-tools.ts b/src/selftest/cases/90-runner-image-tools.ts index 70fe28a..5a146cf 100644 --- a/src/selftest/cases/90-runner-image-tools.ts +++ b/src/selftest/cases/90-runner-image-tools.ts @@ -13,6 +13,7 @@ const selfTest: SelfTestCase = async (context) => { const apkPackages = installedApkPackages(containerfile); const tran = await readFile(path.join(context.root, "tools/tran"), "utf8"); const trans = await readFile(path.join(context.root, "tools/trans"), "utf8"); + const applyPatch = await readFile(path.join(context.root, "tools/apply_patch"), "utf8"); for (const packageName of requiredRunnerPackages) { assert.equal(apkPackages.has(packageName), true, `runner image must install ${packageName}`); @@ -20,16 +21,22 @@ const selfTest: SelfTestCase = async (context) => { assert.equal(tran.startsWith("#!/usr/bin/env bun\n"), true, "tools/tran must be a shebang executable discovered by gitbundle tools"); assert.equal(trans.startsWith("#!/bin/sh\n"), true, "tools/trans must be a shebang executable discovered by gitbundle tools"); + assert.equal(applyPatch.startsWith("#!/bin/sh\n"), true, "tools/apply_patch must be a shebang helper copied with runner tools"); assert.equal(tran.includes("UNIDESK_SSH_CLIENT_TOKEN"), true, "tools/tran must require the scoped UniDesk SSH client token"); assert.equal(tran.includes("/ws/ssh"), true, "tools/tran must use the frontend SSH WebSocket path"); + assert.equal(tran.includes("apply-patch < patch.diff"), true, "tools/tran help must advertise runner-side apply-patch"); const help = await execFileAsync(path.join(context.root, "tools/tran"), ["--help"], { cwd: context.root, timeout: 10_000 }); - const parsed = JSON.parse(help.stdout) as { ok?: boolean; supported?: string[]; valuesPrinted?: boolean }; + const parsed = JSON.parse(help.stdout) as { ok?: boolean; supported?: string[]; unsupported?: string[]; valuesPrinted?: boolean }; assert.equal(parsed.ok, true); assert.equal(parsed.valuesPrinted, false); assert.equal(parsed.supported?.some((line) => line.includes("script")), true); + assert.equal(parsed.supported?.some((line) => line.includes("apply-patch")), true); + assert.equal(parsed.unsupported?.includes("apply-patch"), false); + const patchHelp = await execFileAsync(path.join(context.root, "tools/apply_patch"), ["--help"], { cwd: context.root, timeout: 10_000 }); + assert.equal(patchHelp.stdout.includes("reads *** Begin Patch format"), true); - return { name: "90-runner-image-tools", tests: ["runner image installs required CLI tools", "gitbundle tran tools are executable and documented"] }; + return { name: "90-runner-image-tools", tests: ["runner image installs required CLI tools", "gitbundle tran tools are executable and documented", "runner apply-patch helper is bundled"] }; }; function installedApkPackages(containerfile: string): Set { diff --git a/tools/apply_patch b/tools/apply_patch new file mode 100644 index 0000000..3834547 --- /dev/null +++ b/tools/apply_patch @@ -0,0 +1,385 @@ +#!/bin/sh +set -eu + +allow_loose=0 + +die() { + printf 'apply_patch: %s\n' "$*" >&2 + exit 1 +} + +mk_temp() { + mktemp "${TMPDIR:-/tmp}/unidesk-apply-patch.XXXXXX" || exit 1 +} + +ensure_parent() { + case "$1" in + */*) + dir=${1%/*} + [ -n "$dir" ] || dir=/ + mkdir -p "$dir" + ;; + esac +} + +read_text_preserve_newlines() { + marker="__UNIDESK_APPLY_PATCH_EOF_$$__" + text=$(cat "$1"; printf '%s' "$marker") || die "failed to read $1" + printf '%s' "${text%"$marker"}" +} + +write_file() { + target=$1 + source=$2 + ensure_parent "$target" + cp "$source" "$target" || die "failed to write $target" +} + +line_number_for_prefix() { + newline_count=$(printf '%s' "$1" | tr -cd '\n' | wc -c | tr -d ' ') + printf '%s\n' $((newline_count + 1)) +} + +replace_once_with_perl() { + command -v perl >/dev/null 2>&1 || return 127 + perl -0777 -e ' +use strict; +use warnings; + +sub fail { + print STDERR "apply_patch: ", @_, "\n"; + exit 1; +} + +sub read_all { + my ($path, $label) = @_; + open my $fh, "<", $path or fail("failed to read $label"); + binmode $fh; + local $/; + my $data = <$fh>; + close $fh; + return defined $data ? $data : ""; +} + +my ($target, $search_file, $replace_file, $hunk_id, $allow_loose, $out) = @ARGV; +my $old = read_all($target, $target); +my $search = read_all($search_file, "hunk search"); +my $replacement = read_all($replace_file, "hunk replacement"); +my $new; + +if ($search eq "") { + fail("hunk $hunk_id in $target has no context; add unchanged/deleted anchor lines or pass --allow-loose after manual review") if $allow_loose ne "1"; + print STDERR "apply_patch: hunk $hunk_id matched $target:1 (loose)\n"; + $new = $replacement . $old; +} else { + my $pos = -1; + my $offset = 0; + my $count = 0; + while (1) { + my $found = index($old, $search, $offset); + last if $found < 0; + $pos = $found if $count == 0; + $count += 1; + last if $count > 1 && $allow_loose ne "1"; + $offset = $found + length($search); + } + fail("hunk $hunk_id context not found in $target") if $count == 0; + fail("hunk $hunk_id context matched multiple locations in $target; add more unchanged context or pass --allow-loose after manual review") if $count > 1 && $allow_loose ne "1"; + my $prefix = substr($old, 0, $pos); + my $suffix = substr($old, $pos + length($search)); + my $line = ($prefix =~ tr/\n//) + 1; + print STDERR "apply_patch: hunk $hunk_id matched $target:$line\n"; + $new = $prefix . $replacement . $suffix; +} + +open my $ofh, ">", $out or fail("failed to render patched file"); +binmode $ofh; +print $ofh $new or fail("failed to render patched file"); +close $ofh or fail("failed to render patched file"); +' "$@" +} + +replace_once() { + target=$1 + search_file=$2 + replace_file=$3 + hunk_id=$4 + [ -e "$target" ] || die "file not found: $target" + + fast_out=$(mk_temp) + if replace_once_with_perl "$target" "$search_file" "$replace_file" "$hunk_id" "$allow_loose" "$fast_out"; then + write_file "$target" "$fast_out" + rm -f "$fast_out" + return 0 + else + status=$? + fi + rm -f "$fast_out" + [ "$status" = 127 ] || exit "$status" + + marker="__UNIDESK_APPLY_PATCH_EOF_$$__" + old=$(cat "$target"; printf '%s' "$marker") || die "failed to read $target" + old=${old%"$marker"} + search=$(cat "$search_file"; printf '%s' "$marker") || die "failed to read hunk search" + search=${search%"$marker"} + replacement=$(cat "$replace_file"; printf '%s' "$marker") || die "failed to read hunk replacement" + replacement=${replacement%"$marker"} + + if [ -z "$search" ]; then + [ "$allow_loose" = 1 ] || die "hunk $hunk_id in $target has no context; add unchanged/deleted anchor lines or pass --allow-loose after manual review" + printf 'apply_patch: hunk %s matched %s:1 (loose)\n' "$hunk_id" "$target" >&2 + new=$replacement$old + else + case "$old" in + *"$search"*) + prefix=${old%%"$search"*} + suffix=${old#*"$search"} + case "$suffix" in + *"$search"*) + [ "$allow_loose" = 1 ] || die "hunk $hunk_id context matched multiple locations in $target; add more unchanged context or pass --allow-loose after manual review" + ;; + esac + printf 'apply_patch: hunk %s matched %s:%s\n' "$hunk_id" "$target" "$(line_number_for_prefix "$prefix")" >&2 + new=$prefix$replacement$suffix + ;; + *) + die "hunk $hunk_id context not found in $target" + ;; + esac + fi + + out=$(mk_temp) + printf '%s' "$new" > "$out" || die "failed to render patched file" + write_file "$target" "$out" + rm -f "$out" +} + +apply_update() { + target=$1 + body=$2 + in_hunk=0 + search_file= + replace_file= + changed=0 + hunk_number=0 + has_add=0 + has_delete=0 + seen_add=0 + anchor_before_add=0 + anchor_after_add=0 + explicit_eof=0 + + finish_hunk() { + [ "$in_hunk" = 1 ] || return 0 + if [ "$allow_loose" != 1 ]; then + [ -s "$search_file" ] || die "hunk $hunk_number in $target has no context; add unchanged/deleted anchor lines or pass --allow-loose after manual review" + if [ "$has_add" = 1 ] && [ "$has_delete" = 0 ]; then + if [ "$anchor_before_add" != 1 ] || { [ "$anchor_after_add" != 1 ] && [ "$explicit_eof" != 1 ]; }; then + die "hunk $hunk_number in $target is insert-only without both leading and trailing context; include nearby unchanged lines after the insertion or pass --allow-loose after manual review" + fi + fi + fi + replace_once "$target" "$search_file" "$replace_file" "$hunk_number" + rm -f "$search_file" "$replace_file" + search_file= + replace_file= + in_hunk=0 + changed=1 + } + + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + "*** End of File"*) + continue + ;; + "@@"*) + finish_hunk + search_file=$(mk_temp) + replace_file=$(mk_temp) + hunk_number=$((hunk_number + 1)) + has_add=0 + has_delete=0 + seen_add=0 + anchor_before_add=0 + anchor_after_add=0 + explicit_eof=0 + in_hunk=1 + continue + ;; + esac + [ "$in_hunk" = 1 ] || die "expected hunk header in $target" + case "$line" in + " "*) + text=${line#?} + printf '%s\n' "$text" >> "$search_file" + printf '%s\n' "$text" >> "$replace_file" + if [ "$seen_add" = 1 ]; then anchor_after_add=1; else anchor_before_add=1; fi + ;; + "-"*) + text=${line#?} + printf '%s\n' "$text" >> "$search_file" + has_delete=1 + if [ "$seen_add" = 1 ]; then anchor_after_add=1; else anchor_before_add=1; fi + ;; + "+"*) + text=${line#?} + printf '%s\n' "$text" >> "$replace_file" + has_add=1 + seen_add=1 + ;; + "\\ No newline at end of file") + ;; + "*** End of File"*) + explicit_eof=1 + ;; + *) + die "bad hunk line in $target: $line" + ;; + esac + done < "$body" + + finish_hunk + [ "$changed" = 1 ] || [ -e "$target" ] || die "file not found: $target" +} + +is_top_header() { + case "$1" in + "*** Add File: "*|"*** Update File: "*|"*** Delete File: "*|"*** End Patch") + return 0 + ;; + *) + return 1 + ;; + esac +} + +pushed=0 +pushed_line= +next_patch_line() { + if [ "$pushed" = 1 ]; then + line=$pushed_line + pushed=0 + pushed_line= + return 0 + fi + if IFS= read -r line; then + return 0 + fi + [ -n "${line:-}" ] +} + +push_patch_line() { + pushed_line=$1 + pushed=1 +} + +parse_add_file() { + target=$1 + [ ! -e "$target" ] || die "file already exists: $target" + tmp=$(mk_temp) + while next_patch_line; do + if is_top_header "$line"; then + push_patch_line "$line" + break + fi + case "$line" in + "+"*) + printf '%s\n' "${line#?}" >> "$tmp" + ;; + *) + rm -f "$tmp" + die "add file lines must start with + for $target" + ;; + esac + done + write_file "$target" "$tmp" + rm -f "$tmp" +} + +parse_update_file() { + target=$1 + move_to= + body=$(mk_temp) + if next_patch_line; then + case "$line" in + "*** Move to: "*) + move_to=${line#"*** Move to: "} + ;; + *) + push_patch_line "$line" + ;; + esac + fi + while next_patch_line; do + if is_top_header "$line"; then + push_patch_line "$line" + break + fi + case "$line" in + "*** Move to: "*) + rm -f "$body" + die "move marker must appear before update hunks" + ;; + *) + printf '%s\n' "$line" >> "$body" + ;; + esac + done + if [ -s "$body" ]; then + apply_update "$target" "$body" + elif [ -z "$move_to" ] && [ ! -e "$target" ]; then + rm -f "$body" + die "file not found: $target" + fi + rm -f "$body" + if [ -n "$move_to" ]; then + [ -e "$target" ] || die "file not found: $target" + [ ! -e "$move_to" ] || die "target file already exists: $move_to" + ensure_parent "$move_to" + mv "$target" "$move_to" || die "failed to move $target to $move_to" + fi +} + +main() { + while [ "$#" -gt 0 ]; do + case "$1" in + -h|--help) + printf 'apply_patch: sh-only helper; reads *** Begin Patch format from stdin; --allow-loose bypasses low-context hunk guards\n' + return 0 + ;; + --allow-loose) + allow_loose=1 + shift + ;; + *) + die "unsupported option: $1" + ;; + esac + done + next_patch_line || die "patch must start with *** Begin Patch" + [ "$line" = "*** Begin Patch" ] || die "patch must start with *** Begin Patch" + while next_patch_line; do + case "$line" in + "*** End Patch") + printf 'Done!\n' + return 0 + ;; + "*** Add File: "*) + parse_add_file "${line#"*** Add File: "}" + ;; + "*** Delete File: "*) + target=${line#"*** Delete File: "} + rm -f "$target" || die "failed to delete $target" + ;; + "*** Update File: "*) + parse_update_file "${line#"*** Update File: "}" + ;; + *) + die "unexpected patch line: $line" + ;; + esac + done + die "missing *** End Patch" +} + +main "$@" diff --git a/tools/tran b/tools/tran index 4cc508c..35ac7d6 100755 --- a/tools/tran +++ b/tools/tran @@ -14,12 +14,14 @@ function jsonHelp() { "tran argv ", "tran script -- ''", "tran :/absolute/workspace script -- ''", + "tran :/absolute/workspace apply-patch < patch.diff", "tran :k3s kubectl ", "tran :k3s script -- ''", "tran :k3s::[:container] argv ", "tran :k3s::[:container] script -- ''", + "tran :k3s::[:container] apply-patch < patch.diff", ], - unsupported: ["apply-patch", "upload", "download", "Windows win/ps/cmd routes"], + unsupported: ["upload", "download", "Windows win/ps/cmd routes"], valuesPrinted: false, }; } @@ -87,6 +89,54 @@ async function readStdinText() { return Buffer.concat(chunks).toString("utf8"); } +async function readApplyPatchHelperSource() { + const helperPath = new URL("./apply_patch", import.meta.url); + try { + const text = await Bun.file(helperPath).text(); + if (!text.startsWith("#!")) fail("infra-failed", "adjacent apply_patch helper is not executable script shaped", { helper: "tools/apply_patch" }); + return text; + } catch (error) { + fail("infra-failed", "adjacent apply_patch helper is required for runner-side tran apply-patch", { helper: "tools/apply_patch", error: error instanceof Error ? error.message : String(error) }); + } +} + +function applyPatchToolArgs(args) { + const supported = new Set(["--allow-loose", "--help", "-h"]); + for (const arg of args) { + if (!supported.has(arg)) fail("schema-invalid", `unsupported apply-patch option: ${arg}`, { supported: [...supported].sort() }); + } + return args; +} + +function base64Text(value) { + return Buffer.from(value, "utf8").toString("base64").replace(/.{1,76}/g, "$&\n").trimEnd(); +} + +async function applyPatchRemoteScript(args) { + const toolArgs = applyPatchToolArgs(args); + const [helperSource, patchText] = await Promise.all([readApplyPatchHelperSource(), readStdinText()]); + if (patchText.trim().length === 0) fail("schema-invalid", "apply-patch requires patch text on stdin"); + const helperMarker = "__AGENTRUN_TRAN_APPLY_PATCH_HELPER__"; + const patchMarker = "__AGENTRUN_TRAN_APPLY_PATCH_BODY__"; + const toolArgText = toolArgs.length > 0 ? ` ${shellArgv(toolArgs)}` : ""; + return [ + "set -eu", + "helper=$(mktemp \"${TMPDIR:-/tmp}/agentrun-apply-patch.XXXXXX\") || exit 1", + "patch_file=$(mktemp \"${TMPDIR:-/tmp}/agentrun-apply-patch-body.XXXXXX\") || exit 1", + "cleanup() { rm -f \"$helper\" \"$patch_file\"; }", + "trap cleanup EXIT HUP INT TERM", + "decode_b64() { base64 -d; }", + `decode_b64 > \"$helper\" <<'${helperMarker}'`, + base64Text(helperSource), + helperMarker, + `decode_b64 > \"$patch_file\" <<'${patchMarker}'`, + base64Text(patchText), + patchMarker, + "chmod 700 \"$helper\"", + `\"$helper\"${toolArgText} < \"$patch_file\"`, + ].join("\n"); +} + async function scriptCommand(args) { if (args[0] === "--") { const rest = args.slice(1); @@ -101,7 +151,8 @@ async function scriptCommand(args) { async function hostCommand(route, args) { if (args.length === 0) return { command: null, cwd: route.workspace, tty: true }; const op = args[0]; - if (op === "apply-patch" || op === "upload" || op === "download") { + if (op === "apply-patch") return { command: await applyPatchRemoteScript(args.slice(1)), cwd: route.workspace, tty: false }; + if (op === "upload" || op === "download") { fail("unsupported-operation", `AgentRun tran does not support ${op}; use host/source controlled tools outside the runner for that operation`, { operation: op }); } if (op === "script" || op === "shell") return { command: await scriptCommand(args.slice(1)), cwd: route.workspace, tty: false }; @@ -121,10 +172,11 @@ function k3sExecPrefix(route) { async function k3sCommand(route, args) { const op = args[0] || "kubectl"; - if (op === "apply-patch" || op === "upload" || op === "download") { + if (op === "upload" || op === "download") { fail("unsupported-operation", `AgentRun tran does not support ${op}; use host/source controlled tools outside the runner for that operation`, { operation: op }); } if (!route.resource) { + if (op === "apply-patch") fail("schema-invalid", "k3s apply-patch requires namespace and workload route", { route: route.raw }); if (op === "kubectl") return { command: shellArgv(["env", "KUBECONFIG=/etc/rancher/k3s/k3s.yaml", "kubectl", ...args.slice(1)]), cwd: null, tty: false }; if (op === "script" || op === "shell") { const script = await scriptCommand(args.slice(1)); @@ -133,6 +185,10 @@ async function k3sCommand(route, args) { if (op === "argv") return { command: shellArgv(["env", "KUBECONFIG=/etc/rancher/k3s/k3s.yaml", ...args.slice(1)]), cwd: null, tty: false }; return { command: shellArgv(["env", "KUBECONFIG=/etc/rancher/k3s/k3s.yaml", ...args]), cwd: null, tty: false }; } + if (op === "apply-patch") { + const script = await applyPatchRemoteScript(args.slice(1)); + return { command: shellArgv([...k3sExecPrefix(route), "sh", "-lc", script]), cwd: null, tty: false }; + } if (op === "script" || op === "shell") { const script = await scriptCommand(args.slice(1)); return { command: shellArgv([...k3sExecPrefix(route), "sh", "-lc", script]), cwd: null, tty: false };