feat: add aipod spec Artificer assembly
This commit is contained in:
@@ -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% 真实。
|
||||
|
||||
@@ -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
|
||||
@@ -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/render,render 结果包含 `backendProfile=sub2api`、`model=gpt-5.5`、`reasoningEffort=xhigh`、provider SecretRef、UniDesk SSH env projection、GitHub SSH volume projection、AgentRun runner tools gitbundle 和 gitbundle requiredSkills。
|
||||
- A2:`queue submit --aipod Artificer --dry-run` 输出标准 `queue-submit-plan`,且 `idempotencyKey`、prompt 与 metadata 被保留。
|
||||
- A3:GitHub URL 在启用 `gitMirror` 后改写到 mirror base URL;非 GitHub URL 不改写。
|
||||
- A4:runner Job dry-run 支持 tool credential volume mount,并且 response/manifest/event 不泄漏 Secret 明文。
|
||||
- A5:`bun run check` 和 `bun run self-test` 必须覆盖 A1-A4。
|
||||
@@ -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 task;CLI 不得在本地复制一套 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` 的“测试规格”小节。
|
||||
|
||||
@@ -53,7 +53,7 @@ P0 最小 JSON 形态:
|
||||
| Tool credential | `executionPolicy.secretScope.toolCredentials[]` | 由 runner 按 tool scope 投影为文件或 env,并只暴露给当前 run/command 允许的工具 | 用于 GitHub PR、issue、UniDesk SSH passthrough、artifact registry 等 agent shell 工具能力;不等同于 AgentRun integration,不触发 GitHub sink/OA/Event 之类外部动作记录。 |
|
||||
| Short-lived execution context | runner-job `transientEnv` | 单次 Job env,response/dry-run/event 只显示 name/hash | 只用于业务 dispatcher 生成的短期或 owner-scoped runtime context,以及 manager 受控默认值补齐的非敏感服务地址;不得承载 provider credential、GitHub token、UniDesk SSH client token 或长期 SSH key。 |
|
||||
|
||||
`toolCredentials` 是装配 SPEC 中的受控扩展槽位,用于把 agent 运行时需要的外部工具授权从“临时 env”收敛为 SecretRef。`v0.1` 支持 GitHub PR/issue 与 UniDesk SSH passthrough 所需的最小 env projection,例如:
|
||||
`toolCredentials` 是装配 SPEC 中的受控扩展槽位,用于把 agent 运行时需要的外部工具授权从“临时 env”收敛为 SecretRef。`v0.1` 支持 env projection 与只读 volume projection:env 用于 `GH_TOKEN`、`UNIDESK_SSH_CLIENT_TOKEN` 这类单 key 工具变量,volume 用于 GitHub SSH 目录等文件型凭据。例如:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -77,6 +77,16 @@ P0 最小 JSON 形态:
|
||||
"keys": ["UNIDESK_SSH_CLIENT_TOKEN"]
|
||||
},
|
||||
"projection": { "kind": "env", "envName": "UNIDESK_SSH_CLIENT_TOKEN" }
|
||||
},
|
||||
{
|
||||
"tool": "github",
|
||||
"purpose": "github-ssh",
|
||||
"secretRef": {
|
||||
"namespace": "agentrun-v01",
|
||||
"name": "agentrun-v01-tool-github-ssh",
|
||||
"keys": ["id_ed25519", "known_hosts", "config"]
|
||||
},
|
||||
"projection": { "kind": "volume", "mountPath": "/home/agentrun/.ssh" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -90,6 +100,8 @@ P0 最小 JSON 形态:
|
||||
- dry-run manifest、runner job record、event、trace、日志和 CLI 输出只能显示 tool、purpose、SecretRef 名称/key、projection kind 和 `valuesPrinted=false`。
|
||||
- GitHub PR 能力属于 agent shell/tool 运行能力,不是 AgentRun Queue integration,也不要求新增 GitHub sink、OA sink、notification 或 Event Flow。
|
||||
- `tool=unidesk-ssh` 只允许投影 Secret key `UNIDESK_SSH_CLIENT_TOKEN` 到同名 env。该 token 是 UniDesk frontend `/ws/ssh` 的 scoped client token,route allowlist 由 UniDesk frontend 配置约束;它不得携带 provider token、主 server SSH key 或完整 frontend 登录态。
|
||||
- `projection.kind="volume"` 只能挂载到 `/home/agentrun/` 下的路径,不能包含 `..`;runner Job 必须用只读 Secret volume mount,不得把文件型凭据复制进 event、logs 或 Queue task。
|
||||
- GitHub SSH 能力应使用文件型 SecretRef,例如 `id_ed25519`、`known_hosts` 和 `config` 投影到 `/home/agentrun/.ssh`;它仍只属于当前 run/command 的工具能力,不是 manager 自身 GitHub 写入权限。
|
||||
- `tool=unidesk-ssh` 所需的 UniDesk endpoint 不属于 SecretRef。runner-job env 必须具备 `UNIDESK_MAIN_SERVER_IP`、`UNIDESK_MAIN_SERVER_HOST` 或 `UNIDESK_FRONTEND_URL` 三者之一;调用方可以通过 `transientEnv` 显式传入,若未传入,manager 可以从受控默认配置自动补齐非敏感 endpoint env。
|
||||
- manager 自动补齐 endpoint 时只追加 env name/value 到本次 runner Job,不把 value 写入 run、command、event 或 result 明文;trace/response 只能显示 env name、count、hash 和 `valuesPrinted=false`。若 run 请求了 `tool=unidesk-ssh`,但调用方和 manager 默认都没有 endpoint,runner-job 创建必须在装配阶段返回结构化 `schema-invalid`,不能让 agent 进入 turn 后靠 prompt 猜 endpoint。
|
||||
- 发现 agent shell 缺少 `gh`、`curl`、UniDesk SSH passthrough token 或其他工具凭证时,只能记录为装配能力缺口;不得用 `transientEnv` 或 issue 评论里的明文 token 绕过。
|
||||
@@ -162,6 +174,7 @@ HWLAB Workbench 的 project/workspace 不属于 RuntimeAssembly 四要素,也
|
||||
- P0 固定 `kind="gitbundle"`,输入只依赖 Git repo/ref 与 `bundles[]`;runner 从 `resourceBundleRef.ref`、run 的 `workspaceRef.branch` 或 `HEAD` 解析实际 commit,再用该 materialized commit 作为内容身份。
|
||||
- `commitId` 是可选 pin 或历史请求 hint;提供时必须是 full commit sha,但 HWLAB gitbundle 默认不得依赖 cloud-api/CI/CD rollout 注入的 commitId。
|
||||
- `bundles[]` 每一项只允许 `{ name?, repoUrl?, ref?, commitId?, subpath, target_path }`;缺省 repo/ref/commit 继承顶层解析结果。
|
||||
- `gitMirror` 可把 GitHub repo URL 改写到受控 mirror read URL;event/result 必须同时记录原始 `repoUrl`、实际 `fetchRepoUrl`、`mirrorUsed` 和 `mirrorBaseUrl`,不能只显示理论 repo。
|
||||
- `subpath` 必须留在 checkout 内,`target_path` 必须留在 runner workspace 内;runner 按 `subpath -> target_path` 复制文件或目录。
|
||||
- `credentialRef` 只用于拉取私有 Git repo,不等同于 backend API KEY。
|
||||
- 不支持上传文件、对象存储 artifact、任意 ConfigMap 文件袋、inline seed 或旧字段;旧 `toolAliases`、`skillRefs`、`workspaceFiles`、`subdir`、`sparsePaths` 输入必须直接 schema-invalid。
|
||||
@@ -172,7 +185,7 @@ HWLAB Workbench 的 project/workspace 不属于 RuntimeAssembly 四要素,也
|
||||
|
||||
runner 对 workspace `tools/` 做统一装配:顶层带 shebang 的脚本会被 `chmod +x`,`tools/` 会暴露到 `PATH`。如果 runtime 配置了单独的 `AGENTRUN_RESOURCE_BIN_PATH`,runner 只能在该目录写入执行原始 workspace tool 的 shim,不能复制 tool 文件导致 `dirname "$0"`、相对 import 或辅助源码解析到 bin 目录。非 shebang 文件是随 bundle 复制的源码、测试或辅助文件,不作为可执行工具发现,也不触发 schema-invalid。短命令名称来自 repo 内真实文件,例如 `tools/hwpod`,repo tool 本身承担业务 wrapper 语义。
|
||||
|
||||
AgentRun 自身仓库必须提供 `tools/tran` 与 `tools/trans`,用于承接 UniDesk frontend `/ws/ssh` 的 scoped client-token 透传。runner 只通过 `executionPolicy.secretScope.toolCredentials[]` 投影 `UNIDESK_SSH_CLIENT_TOKEN`,并通过调用方 `transientEnv` 或 manager 受控默认值注入非敏感 `UNIDESK_MAIN_SERVER_IP`、`UNIDESK_MAIN_SERVER_HOST` 或 `UNIDESK_FRONTEND_URL`;工具不得读取 provider token、主 server SSH key 或完整 frontend 登录态。`tran --help` 必须输出 JSON,并列出当前支持的最小开发面:host/host workspace `script`、`argv`、普通 ssh-like 命令、`k3s kubectl`、`k3s script` 和 k3s workload `argv/script`。未实现的 `apply-patch`、`upload`、`download` 和 Windows route 必须显式 `unsupported-operation`,不能静默改走不受控 shell 拼接或 token fallback。
|
||||
AgentRun 自身仓库必须提供 `tools/tran`、`tools/trans` 与 `tools/apply_patch`,用于承接 UniDesk frontend `/ws/ssh` 的 scoped client-token 透传。runner 只通过 `executionPolicy.secretScope.toolCredentials[]` 投影 `UNIDESK_SSH_CLIENT_TOKEN`,并通过调用方 `transientEnv` 或 manager 受控默认值注入非敏感 `UNIDESK_MAIN_SERVER_IP`、`UNIDESK_MAIN_SERVER_HOST` 或 `UNIDESK_FRONTEND_URL`;工具不得读取 provider token、主 server SSH key 或完整 frontend 登录态。`tran --help` 必须输出 JSON,并列出当前支持的最小开发面:host/host workspace `script`、`argv`、普通 ssh-like 命令、host workspace `apply-patch`、`k3s kubectl`、`k3s script`、k3s workload `argv/script` 和 k3s workload `apply-patch`。runner 侧 `apply-patch` 只把 stdin patch 和相邻 `tools/apply_patch` helper 传到目标 route 执行,不接触或回显 Secret 值;`upload`、`download` 和 Windows route 未实现时必须显式 `unsupported-operation`,不能静默改走不受控 shell 拼接或 token fallback。
|
||||
|
||||
#### promptRefs
|
||||
|
||||
@@ -243,6 +256,7 @@ skill 只来自 gitbundle 复制进 workspace 的 `.agents/skills/<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 kind;volume projection 必须显示只读 mount path。
|
||||
- 缺少 tool credential 时,run/command 必须返回可判定的 `secret-unavailable`、`tenant-policy-denied` 或明确 blocker,不能伪装成 agent 业务失败。
|
||||
- `transientEnv` 不得用于 GitHub token、UniDesk SSH client token、长期 SSH key、provider API key 或其他 provider/tool 可复用 credential。HWLAB dispatcher 生成并限定 owner/HWPOD/runtime scope 的 `HWLAB_API_KEY` 可以作为单次 runner Job runtime context 进入 `transientEnv`,但正式 Kubernetes Job 必须通过 per-job Secret 和 `valueFrom.secretKeyRef` 投影,不能作为 pod spec plain env value;AgentRun 不保存、不解释、不回显其明文。
|
||||
|
||||
@@ -259,6 +273,7 @@ skill 只来自 gitbundle 复制进 workspace 的 `.agents/skills/<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 创建会解析既有 session,runner 按 threadId resume;session 不保存 credential 文件,TTL/GC 后续细化。 |
|
||||
| `SessionRef` | v0.1.1 已实现/已通过 HWLAB v0.2 原入口复测 | manager 持久化 `sessionId/conversationId/threadId` + 每个 session 绑 RWO PVC(`agentrun-v01-session-<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 checkout,workspace 受 `AGENTRUN_WORKSPACE_ROOT` 限制,event/result 记录 requested ref/commit、actual commit、tree/workspace/bundles 摘要;`tools/` PATH、`promptRefs` thread-start 注入、`.agents/skills` 目录发现和 required skill 校验已实现。 |
|
||||
| `toolCredentials` | 已实现最小 env projection | GitHub PR 和 UniDesk SSH passthrough 等 agent shell/tool 授权通过装配 SPEC 的 SecretRef 进入 runner;v0.1 支持 `tool=github` 与 `tool=unidesk-ssh`、`projection.kind=env`,runner Job 使用 `valueFrom.secretKeyRef` 注入,不用 `transientEnv` 绕过。 |
|
||||
| `ResourceBundleRef` | 已实现 `kind="gitbundle"` materialization/promptRefs/tools/skillDirs/requiredSkills 装配 | `repoUrl + ref/materialized commit + bundles[]` 已进入 run schema 和 runner checkout,workspace 受 `AGENTRUN_WORKSPACE_ROOT` 限制,event/result 记录 requested ref/commit、actual commit、tree/workspace/bundles 摘要;`gitMirror` 会把 GitHub URL 改写到受控 mirror 并记录 `fetchRepoUrl/mirrorUsed`;`tools/` PATH、`promptRefs` thread-start 注入、`.agents/skills` 目录发现和 required skill 校验已实现。 |
|
||||
| `toolCredentials` | 已实现 env 与 volume projection | GitHub PR、GitHub SSH 和 UniDesk SSH passthrough 等 agent shell/tool 授权通过装配 SPEC 的 SecretRef 进入 runner;v0.1 支持 `tool=github` 与 `tool=unidesk-ssh`、`projection.kind=env|volume`,runner Job 使用 `valueFrom.secretKeyRef` 或只读 Secret volume 注入,不用 `transientEnv` 绕过。 |
|
||||
| `transientEnv` | 已实现 per-job SecretRef 投影 | Queue dispatch 和 runner-job API 支持短期 runtime env;正式 Kubernetes Job 先创建本次 Job 专属 Secret,再用 `valueFrom.secretKeyRef` 注入 runner env,response/event/trace 只显示 env names、Secret metadata 和 `valuesPrinted=false`。 |
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
| Provider config | 非敏感 base URL/model 可以来自 `config.toml` 或 ConfigMap;credential value 不得放入 ConfigMap。 |
|
||||
| Tekton Git SSH Secret | `agentrun-ci/agentrun-git-ssh` |
|
||||
| Argo Git SSH Secret | `argocd/agentrun-git-ssh` |
|
||||
| GitHub Tool Secret | `agentrun-v01-tool-github-pr` key `GH_TOKEN` |
|
||||
| GitHub PR Tool Secret | `agentrun-v01-tool-github-pr` key `GH_TOKEN` |
|
||||
| GitHub SSH Tool Secret | `agentrun-v01-tool-github-ssh` keys `id_ed25519`、`known_hosts`、`config` |
|
||||
| UniDesk SSH Tool Secret | `agentrun-v01-tool-unidesk-ssh` key `UNIDESK_SSH_CLIENT_TOKEN` |
|
||||
| Runtime ServiceAccount | `agentrun-v01-mgr`、`agentrun-v01-runner` |
|
||||
|
||||
@@ -187,6 +188,25 @@ Secret 创建和轮换不由 source branch 自动生成;source branch 只声
|
||||
|
||||
失败必须结构化返回 `failureKind`:缺文件、不可读文件或空 credential 归类为 `secret-unavailable`;非法 JSON/TOML 归类为 `schema-invalid`。
|
||||
|
||||
## GitHub SSH Tool Secret bootstrap
|
||||
|
||||
`Artificer` 需要 GitHub SSH 权限时,长期 credential 不进入 Queue payload、`transientEnv`、Git source 或 GitOps Secret data。v0.1 提供受控 manager/CLI 入口把 operator 已有 SSH 文件写入 runtime namespace Secret:
|
||||
|
||||
```bash
|
||||
./scripts/agentrun tool-credentials set-github-ssh \
|
||||
--private-key-file ~/.ssh/id_ed25519_github \
|
||||
--known-hosts-file ~/.ssh/known_hosts \
|
||||
--config-file ~/.ssh/config
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 写入对象固定为 `agentrun-v01/agentrun-v01-tool-github-ssh`,keys 固定为 `id_ed25519`、`known_hosts`、`config`。
|
||||
- CLI dry-run 只显示输入文件 bytes、SecretRef、key 列表和确认命令,不输出文件内容。
|
||||
- manager upsert Secret 只返回 resourceVersion、hash suffix、SecretRef 和 redaction 状态,不输出 `data`、`stringData`、private key、known_hosts 或 config 明文。
|
||||
- `config` 缺省为只允许 `github.com` 走 `ssh.github.com:443`、`IdentityFile ~/.ssh/id_ed25519`、`StrictHostKeyChecking yes` 和 `UserKnownHostsFile ~/.ssh/known_hosts` 的最小配置;若传入自定义 config,必须包含 `Host` 与 `IdentityFile`。
|
||||
- runtime 消费仍必须通过 `executionPolicy.secretScope.toolCredentials[]` 的 volume projection 挂载到 `/home/agentrun/.ssh`;不得把同一 SSH private key 复制到镜像、ConfigMap、payload 或 transient env。
|
||||
|
||||
## 日志与事件 Redaction
|
||||
|
||||
- event、trace、日志、CLI 输出、health 和 diagnostics 不得打印 Secret 值。
|
||||
@@ -223,6 +243,7 @@ Secret 创建和轮换不由 source branch 自动生成;source branch 只声
|
||||
| DeepSeek profile SecretRef | 已实现/已通过主闭环 | 已新增 `agentrun-v01-provider-deepseek` render、GitOps/RBAC 引用、Job projection、profile 选择和负向 missing-secret 自测试;真实 Secret 创建与 Kubernetes Job projection 已通过主闭环,轮换仍由 Kubernetes 密钥管理流程完成。 |
|
||||
| MiniMax-M3 profile SecretRef | 已实现/待真实主闭环 | 已新增 `agentrun-v01-provider-minimax-m3` render、GitOps/RBAC 引用、Job projection、profile 选择和负向 missing-secret 自测试;真实 Secret 创建使用 HWLAB Code Queue 现有 MiniMax API key,轮换仍由 Kubernetes 密钥管理流程完成。 |
|
||||
| dsflash-go profile SecretRef | 已实现/待真实主闭环 | 已新增 `agentrun-v01-provider-dsflash-go` 的 `model-catalog.json` required key、Secret render、Job projection、writable `CODEX_HOME` 复制和负向 readiness;真实 profile turn 仍需按 provider 管理 canary 和 HWLAB 原入口复测。 |
|
||||
| Tool credential SecretRef | 已实现最小 env projection | `executionPolicy.secretScope.toolCredentials[]` 已支持 `tool=github`、`tool=unidesk-ssh` 与 `projection.kind=env`,runner Job 通过 Kubernetes `secretKeyRef` 注入 env;CLI、event、runner job response 和 dry-run 只显示 SecretRef/projection 元数据,不输出值。 |
|
||||
| Tool credential SecretRef | 已实现 env + volume projection | `executionPolicy.secretScope.toolCredentials[]` 已支持 `tool=github`、`tool=unidesk-ssh` 与 `projection.kind=env|volume`,runner Job 通过 Kubernetes `secretKeyRef` 注入 env,或通过只读 Secret volume 挂载到 `/home/agentrun/*`;CLI、event、runner job response 和 dry-run 只显示 SecretRef/projection 元数据,不输出值。 |
|
||||
| GitHub SSH Tool Secret bootstrap | 已实现 | `tool-credentials set-github-ssh` 通过 manager 受控 upsert runtime Secret,list/show 只展示 SecretRef、key presence 和 hash suffix;Secret value 不进入输出。 |
|
||||
| redaction 最小规则 | 已实现主路径 | Secret dry-run 工具、event、Job dry-run 输出、self-test 和真实主闭环均不打印 Secret value;复杂审计按 [spec-v01-validation.md](spec-v01-validation.md) 人工抽查。 |
|
||||
| 外部 secret manager | 未采用 | 如需 Vault/ExternalSecrets/SOPS,后续单独更新规格。 |
|
||||
|
||||
+194
-2
@@ -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>",
|
||||
|
||||
@@ -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
@@ -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
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user