Merge pull request #147 from pikasTech/feat/aipod-artificer-146

feat: 增加 AipodSpec 与 Artificer 装配
This commit is contained in:
Lyon
2026-06-10 17:47:56 +08:00
committed by GitHub
22 changed files with 2103 additions and 56 deletions
+1
View File
@@ -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% 真实。
+119
View File
@@ -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
+125
View File
@@ -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 <name>``sessions turn --aipod <name>` 使用规格。
- `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 <key>
./scripts/agentrun sessions turn --aipod Artificer --prompt-stdin
./scripts/agentrun tool-credentials set-github-ssh --private-key-file <id_ed25519> --known-hosts-file <known_hosts> [--config-file <ssh_config>]
```
所有 mutating CLI 必须短返回 JSON`--dry-run` 只输出 mutation plan 和确认命令,不写 manager。
## 测试规格
- A1`config/aipods/artificer.yaml` 能被 manager list/show/renderrender 结果包含 `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 被保留。
- A3GitHub URL 在启用 `gitMirror` 后改写到 mirror base URL;非 GitHub URL 不改写。
- A4runner Job dry-run 支持 tool credential volume mount,并且 response/manifest/event 不泄漏 Secret 明文。
- A5`bun run check``bun run self-test` 必须覆盖 A1-A4。
+15
View File
@@ -54,12 +54,16 @@ CLI 官方 TypeScript 入口固定为 `scripts/agentrun-cli.ts`。在 G14 非交
./scripts/agentrun provider-profiles remove <profile>
./scripts/agentrun provider-profiles set-key <profile> --key-stdin
./scripts/agentrun provider-profiles validate <profile> [--wait] [--timeout-ms <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 <id_ed25519> --known-hosts-file <known_hosts> [--config-file <ssh_config>] [--dry-run]
./scripts/agentrun backends list
./scripts/agentrun server start [--port <port>] [--host <host>] [--foreground]
./scripts/agentrun server status [--port <port>]
./scripts/agentrun server logs [--port <port>] [--tail-bytes <bytes>] [--log-file <path>]
./scripts/agentrun server stop [--port <port>]
./scripts/agentrun queue submit --json-stdin|--json-file <task.json> [--dry-run]
./scripts/agentrun queue submit --aipod <name> [--prompt-stdin|--prompt-file <file>|--prompt <text>] [--idempotency-key <key>] [--dry-run]
./scripts/agentrun queue list [--queue <queue>] [--state <state>] [--cursor <cursor>] [--limit <limit>] [--full|--raw]
./scripts/agentrun queue show <taskId> [--full|--raw]
./scripts/agentrun queue stats [--queue <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 <reader>]
./scripts/agentrun sessions show <sessionId> [--reader-id <reader>]
./scripts/agentrun sessions turn [sessionId] [--json-stdin|--json-file <run-base.json>] [--prompt-stdin|--prompt-file <file>|--prompt <text>] [--profile codex|deepseek|minimax-m3|dsflash-go|M3] [--runner-json-stdin|--runner-json-file <job.json>] [--no-runner-job]
./scripts/agentrun sessions turn [sessionId] --aipod <name> [--prompt-stdin|--prompt-file <file>|--prompt <text>] [--runner-json-stdin|--runner-json-file <job.json>] [--no-runner-job]
./scripts/agentrun sessions steer <sessionId> [--prompt-stdin|--prompt-file <file>|--prompt <text>]
./scripts/agentrun sessions cancel <sessionId> [--reason <text>]
./scripts/agentrun sessions trace <sessionId> [--after-seq <n>] [--limit <limit>] [--run-id <runId>] [--include-output] [--seq <n>|--event-id <id>|--item-id <id>] [--detail-scan-pages <n>] [--full|--raw]
./scripts/agentrun sessions output <sessionId> [--after-seq <n>] [--limit <limit>] [--run-id <runId>] [--include-output] [--seq <n>|--event-id <id>|--item-id <id>] [--detail-scan-pages <n>] [--full|--raw]
./scripts/agentrun sessions read <sessionId> [--reader-id <reader>]
./scripts/agentrun aipod-specs list
./scripts/agentrun aipod-specs show <name>
./scripts/agentrun aipod-specs render <name> [--json-stdin|--json-file <input.json>] [--prompt-stdin|--prompt-file <file>|--prompt <text>]
./scripts/agentrun aipod-specs apply [name] --yaml-stdin|--yaml-file <spec.yaml> [--dry-run]
./scripts/agentrun aipod-specs delete <name>
```
具体参数可以在实现时按代码结构微调,但行为必须保持:
@@ -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 taskCLI 不得在本地复制一套 render 逻辑。
- `queue submit --aipod <name>` 只接受本次任务输入(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`。 |
@@ -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` 的“测试规格”小节。
+19 -4
View File
@@ -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 envresponse/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 projectionenv 用于 `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 tokenroute 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 默认都没有 endpointrunner-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 URLevent/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/<name>/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 kindvolume 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 valueAgentRun 不保存、不解释、不回显其明文。
@@ -259,6 +273,7 @@ skill 只来自 gitbundle 复制进 workspace 的 `.agents/skills/<name>/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/<name>/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 创建会解析既有 sessionrunner 按 threadId resumesession 不保存 credential 文件,TTL/GC 后续细化。 |
| `SessionRef` | v0.1.1 已实现/已通过 HWLAB v0.2 原入口复测 | manager 持久化 `sessionId/conversationId/threadId` + 每个 session 绑 RWO PVC`agentrun-v01-session-<sessionId>`),runner Job 把 PVC 直接挂到 `${CODEX_HOME}/<codex_rollout_subdir>`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 checkoutworkspace 受 `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 进入 runnerv0.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 checkoutworkspace 受 `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 进入 runnerv0.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 envresponse/event/trace 只显示 env names、Secret metadata 和 `valuesPrinted=false`。 |
+23 -2
View File
@@ -36,7 +36,8 @@
| Provider config | 非敏感 base URL/model 可以来自 `config.toml` 或 ConfigMapcredential 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` 注入 envCLI、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 Secretlist/show 只展示 SecretRef、key presence 和 hash suffixSecret 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,后续单独更新规格。 |
+194 -2
View File
@@ -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<CliResult> {
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<CliResult> {
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 <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<JsonRecord> {
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<JsonVal
return summarizeSessionMutationResult("session-read", sessionId, result, { read: true });
}
async function renderAipodSpecCli(args: ParsedArgs, name: string): Promise<JsonValue> {
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<RenderedAipodQueueTask> {
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<RenderAipodInput> {
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<JsonValue> {
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<string> {
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 <file>", { httpStatus: 2 });
return readFile(file, "utf8");
}
async function submitQueueTaskWithAipod(args: ParsedArgs, aipod: string): Promise<JsonValue> {
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<JsonRecord> {
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 <file>`, cancel: `./scripts/agentrun sessions cancel ${sessionId}` } };
}
async function submitQueueTask(args: ParsedArgs): Promise<JsonValue> {
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<JsonRecord> {
return renderCodexProviderSecretPlan(options);
}
async function setGithubSshToolCredentialCli(args: ParsedArgs): Promise<JsonRecord> {
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 <key> --known-hosts-file <known_hosts> [--config-file <ssh_config>]",
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<string> {
const file = optionalFlag(args, flagName);
if (!file) throw new AgentRunError("schema-invalid", `tool-credentials set-github-ssh requires --${flagName} <file> 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<JsonRecord> {
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<string> {
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<string | undefined> {
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<string> {
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 <sessionId>",
"sessions storage <sessionId> --delete",
"sessions show <sessionId> [--reader-id <reader>]",
"sessions turn [sessionId] [--json-stdin|--json-file <run-base.json>] [--prompt-stdin|--prompt-file <file>|--prompt <text>] [--profile codex|deepseek|minimax-m3|dsflash-go|<dynamic-profile>|M3] [--runner-json-stdin|--runner-json-file <job.json>]",
"sessions turn [sessionId] [--aipod <name>|--json-stdin|--json-file <run-base.json>] [--prompt-stdin|--prompt-file <file>|--prompt <text>] [--profile codex|deepseek|minimax-m3|dsflash-go|<dynamic-profile>|M3] [--runner-json-stdin|--runner-json-file <job.json>] [--no-runner-job]",
"sessions steer <sessionId> [--prompt-stdin|--prompt-file <file>|--prompt <text>]",
"sessions cancel <sessionId> [--reason <text>] [--full|--raw]",
"sessions trace <sessionId> [--after-seq <n>] [--limit <n>] [--run-id <runId>] [--summary-chars <n>] [--include-output] [--seq <n>|--event-id <id>|--item-id <id>] [--detail-scan-pages <n>] [--full|--raw]",
@@ -1703,6 +1886,7 @@ function help(args: ParsedArgs, group?: string): JsonRecord {
"runner jobs --run-id <runId> [--command-id <commandId>]",
"runner job-status [runnerJobId] --run-id <runId>",
"queue submit --json-stdin|--json-file <task.json> [--idempotency-key <key>] [--dry-run]",
"queue submit --aipod <name> [--prompt-stdin|--prompt-file <file>|--prompt <text>] [--idempotency-key <key>] [--dry-run]",
"queue list [--queue <queue>] [--state <state>] [--cursor <cursor>] [--limit <limit>] [--updated-after <version>] [--full|--raw]",
"queue show <taskId> [--full|--raw]",
"queue stats [--queue <queue>]",
@@ -1711,7 +1895,15 @@ function help(args: ParsedArgs, group?: string): JsonRecord {
"queue cancel <taskId> [--reason <text>] [--dry-run] [--full|--raw]",
"queue dispatch <taskId> [--json-stdin|--json-file <dispatch.json>] [--idempotency-key <key>] [--image <image>] [--namespace <namespace>] [--dry-run] [--full|--raw]",
"queue refresh <taskId> [--dry-run] [--full|--raw]",
"aipod-specs list",
"aipod-specs show <name>",
"aipod-specs render <name> [--json-stdin|--json-file <input.json>] [--prompt-stdin|--prompt-file <file>|--prompt <text>]",
"aipod-specs apply [name] --yaml-stdin|--yaml-file <spec.yaml> [--dry-run]",
"aipod-specs delete <name>",
"secrets codex render --dry-run [--profile codex|deepseek|minimax-m3|dsflash-go|<dynamic-profile>] [--codex-home <dir>] [--model-catalog-file <file>] [--namespace agentrun-v01] [--secret-name <name>]",
"tool-credentials list",
"tool-credentials show github-ssh|unidesk-ssh",
"tool-credentials set-github-ssh --private-key-file <id_ed25519> --known-hosts-file <known_hosts> [--config-file <ssh_config>] [--dry-run]",
"provider-profiles list",
"provider-profiles show <profile>",
"provider-profiles config <profile>",
+300
View File
@@ -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<JsonRecord> {
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<JsonRecord> {
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<RenderedAipodQueueTask> {
const record = await getAipodSpecRecord(name, dir);
return renderAipodSpec(record, input);
}
export async function applyAipodSpec(input: unknown, dir = aipodSpecDirectory()): Promise<JsonRecord> {
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<JsonRecord> {
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<AipodSpecRecord[]> {
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<AipodSpecRecord> {
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<AipodSpecRecord> {
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 | undefined>): 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";
}
+75 -2
View File
@@ -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;
};
+49 -10
View File
@@ -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<ResourceBundleRef["gitMirror"]> | 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<ExecutionPolicy["s
if (keys.length === 0) throw new AgentRunError("schema-invalid", `tool credential ${tool} secretRef.keys must not be empty`, { httpStatus: 400 });
const projection = asRecord(item.projection, `toolCredentials[${index}].projection`);
const kind = requiredString(projection, "kind");
if (kind !== "env") throw new AgentRunError("schema-invalid", "toolCredentials[].projection.kind must be env in v0.1", { httpStatus: 400 });
const normalizedProjection = validateToolCredentialProjection(tool, projection, keys, index);
const identity = `${tool}:${purpose ?? ""}:${kind}:${normalizedProjection.kind === "env" ? normalizedProjection.envName : normalizedProjection.mountPath}`;
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: normalizedProjection };
});
}
function validateToolCredentialProjection(tool: string, projection: JsonRecord, keys: string[], index: number): NonNullable<ExecutionPolicy["secretScope"]["toolCredentials"]>[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 });
}
+2 -2
View File
@@ -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,
+29 -2
View File
@@ -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<ManagerServerOptions["runnerJobDefaults"]>; sessionPvcDefaults?: NonNullable<ManagerServerOptions["sessionPvcOptions"]>; providerProfileDefaults?: NonNullable<ManagerServerOptions["providerProfileOptions"]> }): Promise<JsonValue> {
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<ManagerServerOptions["runnerJobDefaults"]>; sessionPvcDefaults?: NonNullable<ManagerServerOptions["sessionPvcOptions"]>; providerProfileDefaults?: NonNullable<ManagerServerOptions["providerProfileOptions"]>; toolCredentialDefaults?: NonNullable<ManagerServerOptions["toolCredentialOptions"]>; aipodSpecDir?: string }): Promise<JsonValue> {
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<JsonValue> {
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;
+309
View File
@@ -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<JsonRecord> {
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<JsonRecord> {
return toolCredentialStatus(requiredSpec(name), options);
}
export async function setGithubSshToolCredential(body: unknown, options: ToolCredentialOptions = {}): Promise<JsonRecord> {
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<JsonRecord> {
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<JsonRecord | null> {
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<JsonRecord> {
const name = stringPath(manifest, ["metadata", "name"]) ?? "<unknown>";
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;
}
+31 -10
View File
@@ -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 {
+76 -14
View File
@@ -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<string, Promise<GitCheckout>>();
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<GitCheckout> {
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<GitCheckout>): Promise<MaterializedGitBundle[]> {
@@ -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,
+39 -2
View File
@@ -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<void>((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;
+147
View File
@@ -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<void>((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<JsonRecord> {
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<number | null>((resolve) => proc.on("close", resolve))]);
assert.equal(code, 0, stderr || stdout);
return JSON.parse(stdout) as JsonRecord;
}
async function readStream(stream: NodeJS.ReadableStream): Promise<string> {
const chunks: Buffer[] = [];
stream.on("data", (chunk: Buffer | string) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
await new Promise<void>((resolve, reject) => {
stream.on("end", resolve);
stream.on("error", reject);
});
return Buffer.concat(chunks).toString("utf8");
}
export default selfTest;
+94
View File
@@ -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 <name>")), true);
assert.equal(commands.some((item) => item.includes("queue submit --aipod <name>")), 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<void>((resolve) => server.server.close(() => resolve()));
}
};
export default selfTest;
async function runCliJson(context: { root: string }, managerUrl: string, args: string[]): Promise<JsonRecord> {
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<number | null>((resolve) => proc.on("close", resolve))]);
assert.equal(code, 0, stderr || stdout);
return JSON.parse(stdout) as JsonRecord;
}
async function readStream(stream: NodeJS.ReadableStream): Promise<string> {
const chunks: Buffer[] = [];
stream.on("data", (chunk: Buffer | string) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
await new Promise<void>((resolve, reject) => {
stream.on("end", resolve);
stream.on("error", reject);
});
return Buffer.concat(chunks).toString("utf8");
}
+9 -2
View File
@@ -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<string> {
+385
View File
@@ -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 "$@"
+59 -3
View File
@@ -14,12 +14,14 @@ function jsonHelp() {
"tran <provider> argv <command...>",
"tran <provider> script -- '<shell script>'",
"tran <provider>:/absolute/workspace script -- '<shell script>'",
"tran <provider>:/absolute/workspace apply-patch < patch.diff",
"tran <provider>:k3s kubectl <args...>",
"tran <provider>:k3s script -- '<shell script>'",
"tran <provider>:k3s:<namespace>:<workload>[:container] argv <command...>",
"tran <provider>:k3s:<namespace>:<workload>[:container] script -- '<shell script>'",
"tran <provider>:k3s:<namespace>:<workload>[: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 };