diff --git a/.agents/skills/unidesk-code-queue/SKILL.md b/.agents/skills/unidesk-code-queue/SKILL.md index 7fc123bd..f6977563 100644 --- a/.agents/skills/unidesk-code-queue/SKILL.md +++ b/.agents/skills/unidesk-code-queue/SKILL.md @@ -5,7 +5,7 @@ description: UniDesk AgentRun-backed Code Queue CLI — Skill(cli-spec)。legacy # UniDesk Code Queue / AgentRun CLI -旧 Code Queue 已冻结新任务和写入口。`bun scripts/cli.ts codex ...` 现在只作为历史归档、只读排障、残留任务停止和 prompt-lint 入口;新的指挥官派单、Aipod/Artificer 执行、events/logs/result、ack/cancel、dispatch、steer/send 必须走 AgentRun 资源原语,并按 cli-spec 渐进披露。默认输出是低噪声 human 表格/摘要;脚本读取显式使用 `-o json|yaml`,原始官方 bridge 调试显式使用 `--raw`。 +旧 Code Queue 已冻结新任务和写入口。`bun scripts/cli.ts codex ...` 现在只作为历史归档、只读排障、残留任务停止和 prompt-lint 入口;新的指挥官派单、Aipod/Artificer 执行、events/logs/result、ack/cancel、dispatch、steer/send 必须走 AgentRun 资源原语,并按 cli-spec 渐进披露。UniDesk 是 render-only client:默认输出是低噪声 human 表格/摘要,脚本读取显式使用 `-o json|yaml` 的稳定客户端 schema,`--raw` 只用于查看直连 AgentRun REST envelope。 **固定入口前缀**: `cd /root/unidesk && bun scripts/cli.ts agentrun ...` @@ -31,7 +31,7 @@ bun scripts/cli.ts agentrun get aipodspecs bun scripts/cli.ts agentrun describe aipodspec/Artificer bun scripts/cli.ts agentrun aipod-specs render Artificer --prompt-stdin -# 旧 bridge 管理入口仍用于 AipodSpec apply/delete +# AipodSpec apply/delete 仍由客户端资源原语发起,服务端只返回 REST 业务事实 bun scripts/cli.ts agentrun aipod-specs apply --yaml-stdin --dry-run # 提交 AgentRun task manifest @@ -59,7 +59,7 @@ bun scripts/cli.ts agentrun steer session/ --prompt-stdin bun scripts/cli.ts agentrun cancel session/ --reason --dry-run ``` -日常 task manifest 优先使用 YAML heredoc:`agentrun apply -f -`;单 prompt 派单优先 `agentrun create task --aipod Artificer --prompt-stdin`。UniDesk bridge 会把 stdin 直通 G14 `/root/agentrun-v01` 官方 `./scripts/agentrun --manager-url auto` CLI,不先落 dump 文件;`--json-file`、`--prompt-file` 和 `--runner-json-file` 只作为旧 bridge 兼容入口用于已审阅且可复用的受控文件。它不是旧 Code Queue adapter,不双写,也不迁移旧历史。 +日常 task manifest 优先使用 YAML heredoc:`agentrun apply -f -`;单 prompt 派单优先 `agentrun create task --aipod Artificer --prompt-stdin`。UniDesk 客户端按 `config/agentrun.yaml` 直连 AgentRun REST API,不经过 HWLAB runtime、SSH official CLI 或旧 bridge wrapper;`--json-file`、`--prompt-file` 和 `--runner-json-file` 只是客户端输入来源,用于已审阅且可复用的受控文件。它不是旧 Code Queue adapter,不双写,也不迁移旧历史。 `AipodSpec` 是 AgentRun v0.1 的声明式 agent 装配:模型 profile、gitbundle、skills/tools、SecretRef 和 tool credential 都从 YAML 规格渲染。`Artificer` 默认用于 UniDesk 分布式开发任务,使用 `sub2api` provider、`gpt-5.5`、`reasoningEffort=xhigh`,并通过 SecretRef 注入 GitHub PR token、GitHub SSH 和 UniDesk SSH 透传能力。更新规格时使用 `agentrun aipod-specs apply --yaml-stdin --dry-run` 先看计划,确认后再去掉 `--dry-run`;不得把 API key、SSH key 或 token 写入 prompt、payload、YAML 或 issue。 @@ -77,7 +77,7 @@ AgentRun queue 生命周期不是一个单独的 `queue lifecycle` 命令,而 6. Session trace/output 只在 `describe task` 或 result 里有实际 `sessionId` 时使用 `logs|ack|steer|send|cancel session/`;`sessionRef=null` 时不要猜 session 命令。 7. 已创建但尚未运行的 task 使用 `dispatch task/` 派发,不再退回旧 bridge `queue dispatch`。 -默认视图必须低噪声且不是 JSON envelope,`-o json|yaml` 才输出稳定机器结构,`--raw` 才保留官方 AgentRun bridge 原始响应;命令返回里的下一步应优先是 `bun scripts/cli.ts agentrun ...` 资源原语,不得把人工 k8s 查询作为日常下一步。 +默认视图必须低噪声且不是 JSON envelope,`-o json|yaml` 才输出稳定机器结构,`--raw` 才保留直连 AgentRun REST envelope;命令返回里的下一步应优先是 `bun scripts/cli.ts agentrun ...` 资源原语,不得把人工 k8s 查询作为日常下一步。 ## HWLAB Code Agent 入口整合 diff --git a/AGENTS.md b/AGENTS.md index d4f095e8..a4ab474a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -163,9 +163,9 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - UniDesk 同时存在 main server、D601 `~/cq-deploy` 和其他 provider worktree 等多个开发/部署实例;Git remote 是长期 source of truth,本地部署实例只能视为运行副本或缓存。 - 任何开发、文档或部署配置变更开始前,必须先在当前 worktree 执行 `git status` 并从主线拉取最新源码:`git pull --ff-only origin master`;若本地并行变更或远端推进导致不能快进,必须当即分清来源并解决冲突后再继续。 - 任何需要保留的代码、文档或配置变更,在完成必要自测/部署验证后必须立刻按 `git-spec` 提交并 push 到 remote;禁止让未推送的本地修改成为部署真相或后续任务依赖。 -- 提交前必须用 `git status` 和 `git diff` 确认工作区状态;遇到非本次任务的未推送提交一律可以直接推,遇到未提交修改一律可以一起提交并直接推;所有 UniDesk agent 变更只允许在 `master` 上开发并 `git push origin master`,禁止新建、切换到或推送其他分支;长期规则见 `docs/reference/arch.md`。 -- P0: 单纯文档或 UniDesk CLI/trans/tran/helper 变更默认直接提交并推送到 `origin master`,不开 PR、不建临时分支;若涉及外部仓库、发布线、运行面部署或服务高风险行为,按对应 reference 的显式规则执行。 -- `release/v1` 是规划中的稳定维护线,不是普通 feature/fix 分支;创建、更新或启用必须作为显式 release operation,先满足 `docs/reference/release-governance.md` 和 GitHub issue #6 的 CLI/CI/CD/文档条件。当前常规 agent 任务仍按 master-only 规则执行。 +- 提交前必须用 `git status` 和 `git diff` 确认工作区状态;UniDesk 默认集成线是 `master`,但 agent 开发必须优先在从最新 `origin/master` 创建的独立 `.worktree/` 和任务分支中完成,避免污染固定主 repo;长期规则见 `docs/reference/arch.md`。 +- P0: 单纯文档或 UniDesk CLI/trans/tran/helper 变更属于轻量交付,默认仍以 `master` 为合入目标;可按任务风险直接合入/push,也可走短生命周期 PR,禁止在固定主 repo 根目录直接当 scratch 区修改。 +- `release/v1` 是规划中的稳定维护线,不是普通 feature/fix 分支;创建、更新或启用必须作为显式 release operation,先满足 `docs/reference/release-governance.md` 和 GitHub issue #6 的 CLI/CI/CD/文档条件。当前常规 agent 任务默认以 `master` 为集成目标,但不再禁止任务分支或 PR。 - `frontend`、`scripts/cli.ts`、`trans`/`tran` 和分布式 SSH 透传能力跟随 `master`;`release/v1` 仅用于明确批准的 backend-core / Code Queue 稳定维护,不作为 frontend 或 CLI/trans/tran 修复的 backport 目标。 ## Critical Master Server Build Ban diff --git a/config/agentrun.yaml b/config/agentrun.yaml new file mode 100644 index 00000000..a1990499 --- /dev/null +++ b/config/agentrun.yaml @@ -0,0 +1,30 @@ +manager: + baseUrl: https://agentrun.74-48-78-17.nip.io/ + timeoutMs: 15000 + +publicExposure: + enabled: true + proxyName: agentrun-v01-frpc + remotePort: 22880 + publicBaseUrl: https://agentrun.74-48-78-17.nip.io/ + masterBaseUrl: http://127.0.0.1:22880 + masterFrps: + configPath: /opt/hwlab-frp/frps.dev.toml + containerName: hwlab-frps-dev + masterCaddy: + enabled: true + domain: agentrun.74-48-78-17.nip.io + configPath: /etc/caddy/Caddyfile + serviceName: caddy + upstreamBaseUrl: http://127.0.0.1:22880 + responseHeaderTimeoutSeconds: 60 + +auth: + env: HWLAB_API_KEY + file: /root/.config/hwlab-v02/master-server-admin-api-key.env + header: Authorization + scheme: Bearer + +client: + role: render-only + transport: direct-http diff --git a/docs/reference/agentrun.md b/docs/reference/agentrun.md index 1166cd85..968cd599 100644 --- a/docs/reference/agentrun.md +++ b/docs/reference/agentrun.md @@ -111,9 +111,11 @@ UniDesk 不能作为以下内容的事实来源: AgentRun `v0.1` 的指挥官任务面已经按 AgentRun issue #105 完成真实运行面验收,可作为新任务派发、commander queue 观察、events/logs/result、steer/send、ack 和 cancel 的 AgentRun 侧标准路径。长期使用时仍以 AgentRun 仓库自身 SPEC 为能力事实来源;UniDesk 只记录该路径已经通过 G14 `agentrun-v01` 运行面和 `hy` profile + `gpt-5.5` 验证。 -UniDesk 指挥官新任务入口固定使用 `bun scripts/cli.ts agentrun get|describe|events|logs|result|ack|cancel|dispatch|create|apply|steer|send` 资源原语。该入口是 G14 `/root/agentrun-v01` 中官方 `./scripts/agentrun --manager-url auto` CLI 的低噪声包装;默认 human 输出只显示表格、生命周期摘要和下一步命令,脚本读取显式使用 `-o json|yaml`,官方 bridge 原始响应显式使用 `--raw`。日常派单优先用 `agentrun create task --aipod Artificer --prompt-stdin` 或 `agentrun apply -f -` 的 quoted YAML/JSON heredoc/stdin 形式;已创建未运行任务用 `agentrun dispatch task/` 派发;旧 bridge 的 `--json-file`、`--prompt-file` 和 `--runner-json-file` 只用于已审阅且可复用的兼容调试。UniDesk 不实现 AgentRun queue 协议,也不把任务 double-write 回旧 Code Queue。 +UniDesk 指挥官新任务入口固定使用 `bun scripts/cli.ts agentrun get|describe|events|logs|result|ack|cancel|dispatch|create|apply|steer|send` 资源原语。该入口是 render-only client:UniDesk 客户端保留 k8s 风格命令解析、human 表格、生命周期摘要、下一步命令、分页、`-o json|yaml` 稳定客户端 schema 和错误展示;AgentRun 服务端只提供稳定 RESTful API、鉴权和业务事实,不承载 UniDesk CLI 渲染。日常派单优先用 `agentrun create task --aipod Artificer --prompt-stdin` 或 `agentrun apply -f -` 的 quoted YAML/JSON heredoc/stdin 形式;已创建未运行任务用 `agentrun dispatch task/` 派发;`--json-file`、`--prompt-file` 和 `--runner-json-file` 只是客户端输入来源,用于已审阅且可复用的受控文件。UniDesk 不实现 AgentRun queue 协议,也不把任务 double-write 回旧 Code Queue。 -`agentrun control-plane ...`、资源原语和兼容 bridge 组共用同一 UniDesk SSH capture bridge。主 server 本机可继续使用本地 backend-core broker;AgentRun runner、Artificer 或其他没有本地 Docker / `unidesk-backend-core` 容器的环境会自动改走既有 frontend `/ws/ssh` WebSocket backend,并在输出的 `bridge.capture.backend`、`reason` 和 `localBackendCore` 中披露选择依据。本地 `unidesk-backend-core` 容器不是 runner 环境使用这些 AgentRun CLI 入口的隐式前置条件。若所有 capture backend 都不可用,CLI 必须返回 `failureKind=bridge-execution-environment` 与 `capture-backend-unavailable` 或 `bridge-execution-environment-unavailable`,并给出受控恢复入口;AgentRun 官方 CLI 自身返回的 run/command/schema 错误不得被改写成 bridge 失败。 +资源原语和旧兼容 group 的默认 transport 是直连 AgentRun REST API,配置来源是 UniDesk 自有 YAML `config/agentrun.yaml`。鉴权可以复用 `HWLAB_API_KEY` 的环境变量/固定文件发现风格,但不得依赖 HWLAB runtime、HWLAB backend-core、HWLAB frontend 代理或 SSH official CLI;多一层转发会增加故障面,不能作为正式路径。`--raw` 只披露直连 AgentRun REST envelope 和必要的 `transport=direct-http`、`clientRole=render-only`、`configPath`、`baseUrl`、auth source/redacted metadata,不打印 token value。`agentrun control-plane ...` 和 `git-mirror ...` 仍属于 G14 source/runtime 运维控制路径,可以继续使用 UniDesk SSH capture bridge;这些控制面路径不得反向成为 queue/session 资源原语的默认 transport。 + +AgentRun 公网 HTTPS 入口按 Sub2API 的 FRP+Caddy 模式维护:`agentrun-v01` runtime 仍保持 ClusterIP,AgentRun source branch 的 `deploy/deploy.json` 声明 G14 frpc,把 `agentrun-mgr.agentrun-v01.svc.cluster.local:8080` 暴露到 master `127.0.0.1:22880`;UniDesk `config/agentrun.yaml` 声明 `https://agentrun.74-48-78-17.nip.io/`、master frps allow port、master Caddy vhost 和 direct REST 鉴权。`bun scripts/cli.ts agentrun control-plane expose --confirm` 只负责补 master `frps` allow port 与 Caddy site,不在 AgentRun k3s 中创建 Ingress、NodePort、LoadBalancer、hostPort 或 HWLAB 转发层。 AgentRun Queue 任务如果需要调用 UniDesk 维护桥,例如 `trans` / `unidesk-ssh`,长期契约以 AgentRun 仓库 `docs/reference/spec-v01-runtime-assembly.md` 和 `docs/reference/spec-v01-secret-distribution.md` 为准:调用方通过 `executionPolicy.secretScope.toolCredentials[].tool=unidesk-ssh` 请求 `UNIDESK_SSH_CLIENT_TOKEN` SecretRef;非敏感 endpoint 由 runner-job `transientEnv` 显式提供,或由 manager 受控默认值自动补齐。UniDesk bridge 提交 Queue payload 时不得在 prompt、payload 或 `transientEnv` 中携带 token,也不得使用 HWLAB runtime Web 入口冒充 UniDesk frontend。若 dispatcher 已正确请求 `unidesk-ssh` 但 trace 的 `runner-job-created.transientEnv.names` 没有 `UNIDESK_MAIN_SERVER_IP`、`UNIDESK_MAIN_SERVER_HOST` 或 `UNIDESK_FRONTEND_URL`,归为 AgentRun assembly 问题;若 endpoint env 已存在但 route denied/timeout,再按 UniDesk frontend/token scope 或 provider session 排查。 diff --git a/docs/reference/arch.md b/docs/reference/arch.md index 6730170a..f7517b64 100644 --- a/docs/reference/arch.md +++ b/docs/reference/arch.md @@ -84,10 +84,10 @@ - Multi-Repo Deployment Sync - The main server repository, D601 deployment tree, provider-local worktrees, and other live copies are working or deployment instances; the Git remote is the long-term project source of truth. - Before any development, documentation, or deployment manifest change, an agent must inspect the current worktree with `git status` and pull the latest source from the only accepted integration branch with `git pull --ff-only origin master`. - - If a pull, rebase, commit, or push is blocked by concurrent work, the conflict must be handled immediately in the current worktree by separating the current task's edits from unrelated parallel changes. Do not create a feature branch to postpone the conflict. + - If a pull, rebase, commit, or push is blocked by concurrent work, separate the current task's edits from unrelated parallel changes immediately. Use a task-scoped worktree/branch for isolation; do not use a hidden long-lived branch to postpone conflict resolution. - Any source, document, or persistent configuration change intended to survive the current task must be committed and pushed to the remote promptly after required self-tests or deployment validation, following `git-spec`. - - All UniDesk agent changes must be developed on `master` and pushed to `origin master`. Agents must not create, switch to, or push feature/fix branches for UniDesk work. - - Pure documentation changes and UniDesk CLI/trans/tran/helper changes use the same direct `master` path: commit the scoped change and push `origin master` without opening a PR or creating a temporary branch. External repositories, release-line work, runtime deployments, and high-risk service behavior follow their own explicit reference rules. + - `master` is the default UniDesk integration branch, but fixed root worktrees are anchors, not scratch space. Agent changes should be developed in task-scoped `.worktree/` checkouts and may use short-lived task branches or PRs before merging to `master`. + - Pure documentation changes and UniDesk CLI/trans/tran/helper changes remain lightweight: target `master`, keep the diff scoped, and either merge/push directly after validation or use a short-lived PR when review/risk warrants it. External repositories, release-line work, runtime deployments, and high-risk service behavior follow their own explicit reference rules. - Live deployment should run from a known commit or from a change set that is immediately committed and pushed; local-only hotfixes must not become the implicit dependency for later tasks. - Secrets, tokens, generated runtime state, and node-local env files stay outside Git, but their required contract, storage location, and recovery path must be documented so pushing source changes is not blocked by runtime-only data. - Release And CI/CD Governance diff --git a/docs/reference/cli.md b/docs/reference/cli.md index c3bc52de..51eefe43 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -93,8 +93,9 @@ CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 runtime lane 滚动 - `ci install|status|run|publish-backend-core|publish-user-service|run-dev-e2e|logs` 管理 D601 原生 k3s 上的 Tekton CI。`run` 手动创建每 commit 检查和 Code Queue 只读性能门禁;`publish-backend-core` 与 `publish-user-service` 从 pushed Git commit 构建并发布 `127.0.0.1:5000/unidesk/:` commit-pinned artifacts,输出 `artifactSummary`(含 `serviceId`、`sourceCommit`、`sourceRepo`、`dockerfile`、`imageRef`、`tag`、`digest`、`digestRef`),但不部署生产;`run-dev-e2e` 的 Git 控制 runner、短 launcher、host fetch 边界、临时 smoke namespace 和 no-CD 规则只在 `docs/reference/dev-ci-runner.md` 定义;Tekton CI 通用规则见 `docs/reference/ci.md`。 - `schedule list|get|runs|run|retry-run|delete|upsert-pgdata-backup` 管理 backend-core 定时任务和运行历史。`schedule list`、`schedule get`、`schedule runs --limit N` 和 `schedule runs --limit N` 是只读观察入口;`schedule run`、`schedule retry-run`、`schedule delete` 和 `schedule upsert-pgdata-backup` 会触发运行或写入配置,生产恢复时必须有明确授权。`schedule runs --limit N` 是全局历史视图,返回 `scope=global` 和 `scheduleId=null`;`schedule runs --limit N` 是指定 schedule 历史视图,返回 `scope=schedule` 和对应 `scheduleId`。CLI 必须拒绝 `schedule runs 50` 这类纯数字位置参数,并提示使用 `schedule runs --limit 50`,避免把空数组误判成“没有历史 run”。`schedule run --wait-ms N` 触发同一 schedule,并且即使 wait 超时也必须返回 `newRunId` 和 `observeCommand`;`schedule retry-run ` 只接受 failed run,从原 run 反查 `scheduleId` 后重触发同一 schedule,并输出 `originalRunId`、`scheduleId`、`newRunId` 和 `observeCommand`。当 backend-core 目标容器缺失或只观察到 verify-only 容器时,schedule/microservice 命令必须以非零退出并返回 `failureKind=target-stack-not-running`、`runnerDisposition=infra-blocked`、`readOnlyCommands` 和 `authorizationRequiredForRecovery`,不得把 Docker 的 `No such container` 当成成功的空历史。 - `codex deploy ` 是旧 Code Queue 兼容部署入口,已禁用以防止维护通道直连 D601 部署 Code Queue;当前 dev 自动化只做 `ci run-dev-e2e` smoke,不提供 Code Queue CD,详细规则见 `docs/reference/codex-deploy.md`。 -- `agentrun get|describe|events|logs|result|ack|cancel|dispatch|create|apply|steer|send` 是当前指挥官新任务和 AgentRun session 控制入口。UniDesk CLI 通过 G14 `/root/agentrun-v01` 中官方 `./scripts/agentrun --manager-url auto` 执行,默认 human 输出只显示表格、生命周期摘要和下一步命令;脚本读取显式使用 `-o json|yaml`,官方 bridge 原始响应显式使用 `--raw`。日常查看用 `get tasks --queue commander`、`describe task/`、`events run/`、`logs session/`、`result run/ --command `;日常写入用 `create task --aipod Artificer --prompt-stdin`、`apply -f -`、`dispatch task/`、`steer/send session/`、`ack/cancel task|session/`。兼容 bridge 组 `queue|runs|commands|runner|sessions|aipod-specs` 只保留为 raw/debug 和尚未包装的低频能力入口。 -- `agentrun control-plane ...`、资源原语和兼容 bridge 组共用 UniDesk SSH capture bridge。主 server 本机可使用本地 backend-core broker;Artificer/AgentRun runner 等没有本地 Docker 或 `unidesk-backend-core` 容器的环境会自动使用 frontend `/ws/ssh` WebSocket backend,并在 `bridge.capture.backend`、`reason`、`localBackendCore` 中披露选择依据。本地 `unidesk-backend-core` 不是 runner 环境的隐式前置条件;若 capture backend 不可用,错误必须归类为 `failureKind=bridge-execution-environment` 并给出受控恢复入口。 +- `agentrun get|describe|events|logs|result|ack|cancel|dispatch|create|apply|steer|send` 是当前指挥官新任务和 AgentRun session 控制入口。UniDesk CLI 是 render-only client:客户端保留 k8s 风格命令解析、human 表格、生命周期摘要、下一步命令、分页、`-o json|yaml` 稳定客户端 schema 和错误展示;AgentRun 服务端只提供稳定 RESTful API、鉴权和业务事实,不承载 UniDesk CLI 渲染。日常查看用 `get tasks --queue commander`、`describe task/`、`events run/`、`logs session/`、`result run/ --command `;日常写入用 `create task --aipod Artificer --prompt-stdin`、`apply -f -`、`dispatch task/`、`steer/send session/`、`ack/cancel task|session/`。兼容 group `queue|runs|commands|runner|sessions|aipod-specs` 也走同一 direct HTTP transport,`--raw` 只披露直连 AgentRun REST envelope。 +- `agentrun` 资源原语的默认 transport 是直连 AgentRun REST API,配置来源是 UniDesk 自有 YAML `config/agentrun.yaml`。鉴权可以复用 `HWLAB_API_KEY` 的环境变量/固定文件发现风格,但不得依赖 HWLAB runtime、HWLAB backend-core、HWLAB frontend 代理或 SSH official CLI;多一层转发会增加故障面,不能作为正式路径。`agentrun control-plane ...` 和 `git-mirror ...` 仍属于 G14 source/runtime 运维控制路径,可以继续使用 UniDesk SSH capture bridge;这些控制面路径不得反向成为 queue/session 资源原语的默认 transport。 +- `agentrun control-plane expose --dry-run|--confirm` 按 `config/agentrun.yaml` 维护 AgentRun 公网 HTTPS 入口,模式与 Sub2API 暴露一致:G14 AgentRun runtime 通过 frpc 出到 master `127.0.0.1:`,master Caddy 提供 `https://agentrun.74-48-78-17.nip.io/`。该命令只补 master `frps` allow port 和 Caddy vhost;G14 frpc Deployment/ConfigMap 必须由 AgentRun `deploy/deploy.json` + GitOps render 管理,不能在 UniDesk 侧手写 Kubernetes manifest。 - `codex submit/enqueue`、`codex steer`、`codex resume`、`codex queue create`、`codex queue merge`、`codex move`、旧 Web 提交表单、旧队列管理和旧 workdir 管理是冻结的 legacy Code Queue 写入口。CLI 必须返回 `ok=false`、`frozen=true`、`degradedReason=legacy-code-queue-frozen` 和 AgentRun 替代命令;服务端旧 API 写入口必须返回 410。新任务、steer/send、events/logs/result、ack 和 cancel 走 AgentRun 资源原语。 - 旧 Code Queue 只保留历史归档、只读排障和残留任务停止。`codex task/tasks/output/read/unread/queues` 继续通过 backend-core 私有代理读取旧 PostgreSQL 历史;`codex interrupt|cancel ` 只用于停止旧运行面残留任务。旧 `steer-confirm` 只作为历史 trace confirmation 查询,不是新任务控制入口。 - `codex pr-preflight [--remote] [--push-dry-run --push-dry-run-ref refs/heads/probe/] [--pr-create-dry-run --pr-create-dry-run-head ] [--issue N] [--full|--raw]` 通过稳定 `code-queue` proxy 请求 D601 scheduler `/api/runtime-preflight`,用于 PR 型派单 admission。默认输出是紧凑 commander 视图,显式分出 `schedulerPreflight` 与 `activeRunnerPrCapability`,并附带 `commands` 和 `disclosure`,方便先看 scheduler auth 缺口、再看当前 runner/dev container 的 `gh auth status` 与 `gh pr create --dry-run` 能力;`--full` 或 `--raw` 才展开完整 `preflight`、工具、agent port、Git worktree、GitHub egress、repo/issue/PR 只读探测和观测原文。只报告 `GH_TOKEN`/`GITHUB_TOKEN` 是否存在和来源 key,不打印值。当 auth-broker 配置存在时,`tokenCoverage.source="auth-broker"`、`credentialSource="broker-issued-token"` 且 runner env token 不是成功前提;当仅 env token 存在时,`credentialSource="env-token"` 且 `authBroker.nextAction="use-env-token-until-auth-broker-live"`;两者都缺失时顶层 `ok=false`、`runnerDisposition=infra-blocked`、`degradedReason=auth-broker-needed`,`tokenCoverage.missing` 同时列出 `GH_TOKEN` 与 `GITHUB_TOKEN`,并输出 `authBroker.source="broker/auth-broker-needed"`、`capability.source="missing-token"`。该 `auth-missing` 的 scope 是 `scheduler-runner-env`,不能简化成“当前 active runner/dev container 不能创建 PR”;默认视图必须带 `scopeBoundary` 和 `activeRunnerPrCapability`。GitHub DNS/API 连接失败应归类为 `failureKind=github-transient`、`degradedReason=github-dns-api-transient`,并带 `retryable=true`、`commanderAction=retry-backoff-or-keep-running-if-heartbeat-fresh` 和有界 `githubTransient.failedProbes`;调用方应重试/退避,且在任务 heartbeat/trace 新鲜时继续监督,不把它当成 auth 缺失或 PR 语义失败。`prCapability` 是 runner-facing 合同摘要,必须包含目标分支、token/auth 来源、`systemGhBinaryRequiredForWrites=false`、UniDesk REST `bun scripts/cli.ts gh` 可用性、push dry-run/PR create dry-run 的 `writesRemote=false`、expected PR handoff、真实 PR 创建需要 commander 授权,以及 guarded `gh pr merge --dry-run` 预检路径;系统 `gh` binary 缺失只进入 `tools.systemGhBinary`,不得误判为 UniDesk REST `gh` CLI 不可用。`--remote` 在 runner-like 环境里不再依赖本地 `unidesk-backend-core`、`unidesk-database`、`baidu-netdisk-backend` 容器存在;这些缺失只作为本地观测证据。若远程控制面可达,则继续走远程控制面结果;若远程控制面不可达,则结构化返回 `failureKind=control-plane-missing` / `degradedReason=remote-control-plane-unreachable`,而不是把本地 `backend-core-container-missing` 当作最终阻塞。`--pr-create-dry-run` 不 POST GitHub,只证明 runner 内 PR body 生成、`scripts/cli.ts gh pr create --dry-run` 和 branch 参数形态可用;服务端创建权限仍以 token/auth broker、repo/issue/PR read、push dry-run 和最终授权后的真实 PR 创建结果为准。 diff --git a/docs/reference/code-queue-supervision.md b/docs/reference/code-queue-supervision.md index 33dfc805..6a7522ce 100644 --- a/docs/reference/code-queue-supervision.md +++ b/docs/reference/code-queue-supervision.md @@ -93,7 +93,7 @@ HWLAB M3 口径使用同一分级:只读报告、fixture、LOCAL/DRY-RUN 和 d AgentRun 新派单和历史 Code Queue 审阅都按成本、可信度和 blast radius 分层:GPT-5.5/Codex 处理高风险和复杂任务,DeepSeek/OpenCode 处理中等复杂度且边界清晰的任务,MiniMax/OpenCode 处理简单、低权限、可复核任务,生产重启、密钥、数据库手工写入和运行中任务控制保留给指挥官或人工。 -当前新任务派发合同由 `bun scripts/cli.ts agentrun get|describe|events|logs|result|ack|cancel|dispatch|create|apply|steer|send` 资源原语暴露:`get tasks --queue commander` 查看指挥官队列,`create task --aipod Artificer --prompt-stdin` 或 `apply -f -` 创建任务,`dispatch task/` 派发,`events/logs/result/ack/cancel/steer/send` 读取和控制 AgentRun task、run 与 session。日常一次性 YAML/JSON 和 prompt 输入优先用 quoted heredoc/stdin;`--json-file`、`--prompt-file` 和旧 bridge 参数只用于已审阅且可复用的兼容调试。本地 UniDesk bridge 会把 stdin 直通官方 G14 `/root/agentrun-v01` CLI,不先落 dump 文件;它不是旧 Code Queue adapter,不做双写,也不迁移旧历史。 +当前新任务派发合同由 `bun scripts/cli.ts agentrun get|describe|events|logs|result|ack|cancel|dispatch|create|apply|steer|send` 资源原语暴露:`get tasks --queue commander` 查看指挥官队列,`create task --aipod Artificer --prompt-stdin` 或 `apply -f -` 创建任务,`dispatch task/` 派发,`events/logs/result/ack/cancel/steer/send` 读取和控制 AgentRun task、run 与 session。UniDesk 是 render-only client:日常一次性 YAML/JSON 和 prompt 输入优先用 quoted heredoc/stdin,客户端按 `config/agentrun.yaml` 直连 AgentRun REST API 并保留 k8s 风格渲染;`--json-file`、`--prompt-file` 和 `--runner-json-file` 只是客户端输入来源。该路径不经过 HWLAB runtime、SSH official CLI 或旧 bridge wrapper,不做旧 Code Queue 双写,也不迁移旧历史。 旧 `codex submit/enqueue`、`codex steer`、`codex resume`、旧 queue mutation、task move 和旧 workdir mutation 已冻结。CLI 必须返回 `ok=false`、`frozen=true`、`degradedReason=legacy-code-queue-frozen` 和 AgentRun 替代命令;服务端旧 API 写入口必须返回 410。旧 `codex task/tasks/output/read/unread/queues` 继续作为历史归档和只读排障入口,`codex interrupt|cancel` 只用于停止残留旧任务。 新任务模型由 AgentRun task payload 和 AgentRun runtime 配置决定;旧 Code Queue 的 `CODE_QUEUE_MODELS` 只作为历史任务审阅和残留运行面配置参考,长期合同至少包含 GPT-5.5、GPT-5.4、GPT-5.4 Mini、DeepSeek Chat、MiniMax M3 和 MiniMax M2.7 两路并行配置;`deepseek`/`deepseek-chat`、`minimax-m3` 与 `minimax-m2.7` 会走 OpenCode port,其余模型走 Codex port。PROD 集群把 `MINIMAX_MODEL` 切到 `MiniMax-M3`(M3 是新任务的默认 provider model),judge 与 opencode 跟随;M2.7 仍然作为并行配置存在,切换只需把 `MINIMAX_MODEL` 改成 `MiniMax-M2.7` 后 rollout restart。两者不存在自动 fallback 关系:M3 任务失败不会自动改派 M2.7,task 要用 M2.7 必须显式 `--model minimax-m2.7`。只有当执行面 `/health` 或等价配置已经显示 DeepSeek 模型可用、并完成轻量 runner smoke 后,才允许真实提交 `--model deepseek-chat`。 @@ -189,7 +189,7 @@ CLI 是短 shout 的需求原语,不是长驻服务器进程。CLI 功能不 所有 GitHub Markdown 正文写入优先使用 `--body-stdin` 或 `--body-file `。不要使用 `gh issue comment --body`、`gh api -f body=...` 或把多行正文直接拼进 shell 参数;这些路径容易把真实换行、反引号和 Markdown 表格污染成字面量 `\n` 或 shell escape。从 shell 生成正文文件时使用 quoted heredoc,例如 `cat <<'EOF' > /tmp/body.md`,保证反引号和反斜杠不被展开;JSON 请求体场景优先使用对应 CLI 的 `--body-file` 或 `--body-stdin`,不要把长 JSON 塞进命令行参数。`gh issue comment create|update|edit` 和 `gh pr comment create|update|edit` 都支持 `--body-stdin` 作为多行 Markdown 的第一等入口,`--body` 仅适合短单行文本。`gh issue` 正文更新主入口仍是 `update --mode replace|append --body-stdin|--body-file`,`edit` 只是兼容别名;`append` 会先读取当前正文再追加文件字节,保留真实换行、反引号和 Markdown 表格,不走 shell 拼接。`gh issue update --body-file` 默认拒绝 `null`、空白和过短正文;#20 自动要求 `## 看板(OPEN)`,指挥简报 profile 自动要求 `## 常驻观察与长期建议`,并允许 #24 legacy 或每日滚动简报 issue。更新 body-only issue 前优先跑 `--dry-run`,查看旧/新正文长度、body SHA、关键标题、字面量 `\n` 和 shell 污染信号;正式写入长期正文时优先带上 `--expect-updated-at` 或 `--expect-body-sha`,避免旧缓存覆盖新正文。指挥简报更新正文时默认只写 GitHub issue,不自动向 ClaudeQQ 推送;#24 legacy 可用 `--notify-claudeqq-brief-diff` 通知 helper,如确需提醒用户,按本文的 ClaudeQQ 通知门槛单独发送。提交前或巡检时可用 `gh issue scan-escape --limit N --dry-run` 或 `gh issue cleanup-plan --limit N` 只读扫描污染并生成建议,不自动修复。 -PR 是审查型交付入口,不是所有 Code Queue 任务的默认出口。默认 master-only 交付仍按项目 Git 规则执行;当变更风险高、跨模块、需要人工审查、或任务目标明确要求 PR 交付时,worker 可以创建 PR。PR 型任务必须报告源分支、目标分支、PR URL、关联 issue、测试证据和未完成风险。禁止把 PR 当成隐藏分支仓库;PR 分支必须来自最新目标线,保持小而可审查,并在合并后确认目标分支远端 commit 可 fetch。 +PR 是审查型交付入口,不是所有 Code Queue 任务的默认出口。UniDesk 默认集成目标仍是 `master`,但不再禁止任务分支;当变更风险高、跨模块、需要人工审查、或任务目标明确要求 PR 交付时,worker 可以创建 PR。PR 型任务必须报告源分支、目标分支、PR URL、关联 issue、测试证据和未完成风险。禁止把 PR 当成隐藏分支仓库;PR 分支必须来自最新目标线,保持小而可审查,并在合并后确认目标分支远端 commit 可 fetch。 PR handoff 的职责默认分开:runner 实现、测试、提交、push head branch 并创建 PR;指挥官监督并发、steer、审阅、确认 checks 和合并裁决。短期内 GPT-5.5 runner 如果收到明确 PR 收口授权,并且 PR 是普通 UniDesk source 变更、checks 满足任务要求、无冲突且不涉及 prod/runtime/release/security/database/破坏性回滚,可以自行用 repo-owned GitHub merge/close 路径完成收口并报告 SHA。高风险、边界不清、checks 失败或用户/指挥官保留 final action 的 PR 仍必须交回 commander 审查。host commander 也不把直接编辑业务代码当成常规 PR 替代路径。 diff --git a/docs/reference/codex-deploy.md b/docs/reference/codex-deploy.md index b88008d5..2347115b 100644 --- a/docs/reference/codex-deploy.md +++ b/docs/reference/codex-deploy.md @@ -70,7 +70,7 @@ D601 原生 k3s 的人工诊断必须显式使用 host kubeconfig:`KUBECONFIG= ## Pull Request Delivery -Code Queue worker 可以在任务明确要求审查型交付时创建 Pull Request。PR 交付不是默认出口;默认集成仍遵循项目当前 master-only 规则,直到具体任务或指挥官要求改为 PR。PR 型任务必须从最新目标线创建短生命周期分支,报告源分支、目标分支、PR URL、关联 issue、验证证据和未完成风险;分支命名应使用 `probe/`、`code-queue/` 或其他明确任务前缀,禁止把隐藏分支当成长期交付状态。 +Code Queue worker 可以在任务明确要求审查型交付时创建 Pull Request。PR 交付不是唯一出口;默认集成目标仍是 `master`,轻量改动可直接合入,风险较高或需要审查的改动使用短生命周期 PR。PR 型任务必须从最新目标线创建短生命周期分支,报告源分支、目标分支、PR URL、关联 issue、验证证据和未完成风险;分支命名应使用 `probe/`、`code-queue/` 或其他明确任务前缀,禁止把隐藏分支当成长期交付状态。 Code Queue runtime 提供 `/api/runtime-preflight` 作为 PR 能力探测入口;CLI 稳定入口是 `bun scripts/cli.ts codex pr-preflight [--remote] [--push-dry-run --push-dry-run-ref refs/heads/probe/] [--pr-create-dry-run --pr-create-dry-run-head ] [--issue N]`。默认请求只检查本地工具、凭证可见性、Git worktree、HOME、known_hosts、agent port 和 proxy DNS;`--remote` 会增加 GitHub 网络、issue API、SSH/HTTPS `git ls-remote`、GitHub SSH、`gh auth status`、`gh repo view`、`gh issue read/view` 和只读 `gh pr list` 探测;`--push-dry-run` 会额外执行 `git push --dry-run`,验证远端写权限但不创建分支;`--pr-create-dry-run` 会在 runner 内生成受控 PR body 并执行 `scripts/cli.ts gh pr create --dry-run`,只证明 PR body guard 和命令形态可用,不 POST GitHub。探测输出只报告 `GH_TOKEN`/`GITHUB_TOKEN` 是否存在,不得输出 token 内容,并通过 `prCapabilityContract` 明确 token source、target branch、expected PR handoff、dry-run 不写远端和 merge unsupported 边界。缺少 runner env token 时,`authBroker.source="broker/auth-broker-needed"` 是标准结构化证据;系统 `gh` binary 缺失必须与 UniDesk REST `bun scripts/cli.ts gh` 可用性分开报告。backend-core 的稳定 `code-queue` proxy 必须把 `/api/runtime-preflight` 路由到 D601 scheduler,而不是主 server `code-queue-mgr`,因为 token 和 PR runner 能力属于执行面环境。 diff --git a/docs/reference/dev-environment.md b/docs/reference/dev-environment.md index 27ad9195..3fa46f9f 100644 --- a/docs/reference/dev-environment.md +++ b/docs/reference/dev-environment.md @@ -25,7 +25,7 @@ trans D601:/home/ubuntu/workspace/unidesk-dev git remote -v 若路径、分支或 remote 不符合预期,先修正 fixed workspace,再继续。`/home/ubuntu/cq-deploy`、Code Queue pod 内 `/root/unidesk`/`/app`、D601 上的 `/root/unidesk` 和 `/tmp/unidesk-*` 只作为部署副本、运行副本或临时实验面;运行面热修可以直接作用在 pod/容器,但必须随后把持久化修复提交到 Git remote,并在 fixed workspace 中复验。 -固定 workspace 只作为 source truth 预检、fetch、worktree 管理和最终同步入口。实际开发、文档修改、测试补丁和 PR 准备应在固定 repo 下的独立 worktree 中完成,例如 `/home/ubuntu/workspace/unidesk-dev/.worktree/`;该 worktree 必须从最新 `origin/master` 创建,使用任务专属分支或按当前 master-only 规则完成提交,结束前用 `git status` 确认只包含本任务文件。不要把 `/home/ubuntu/workspace/unidesk-dev` 根目录当作并行任务 scratch 区,也不要复用其他任务遗留 worktree。 +固定 workspace 只作为 source truth 预检、fetch、worktree 管理和最终同步入口。实际开发、文档修改、测试补丁和 PR 准备应在固定 repo 下的独立 worktree 中完成,例如 `/home/ubuntu/workspace/unidesk-dev/.worktree/`;该 worktree 必须从最新 `origin/master` 创建,使用任务专属分支或 detached worktree 隔离当前改动,结束前用 `git status` 确认只包含本任务文件。不要把 `/home/ubuntu/workspace/unidesk-dev` 根目录当作并行任务 scratch 区,也不要复用其他任务遗留 worktree。 Master server 不作为 UniDesk 重型验证机。仓库级 check、Playwright/browser smoke、镜像构建、Rust/Go 编译和 Code Queue runner 实测必须放到 D601、CI runner 或其他获批执行面;master server 只做轻量源码编辑、Git 操作、状态观察和受控调度。唯一例外是 backend-core 主 server 上线:当用户或 issue 明确要求把当前 backend-core 修复上线到主 server 时,可以用 `CARGO_BUILD_JOBS=1`、`--jobs 1` 或 CLI 内置等价限流执行 backend-core 专属编译,并必须用异步 job/status/health 证据回写 issue。 @@ -117,7 +117,7 @@ Use this sequence for backend-core Rust and frontend dev work: 1. Preflight the fixed workspace, then develop in a task-scoped `.worktree/` created from the latest `origin/master`; keep unrelated parallel changes separated with `git status`/`git diff`. 2. Run local non-Rust checks on the master server, for example `bun scripts/cli.ts check --files --scripts-typecheck --compose --logs`. -3. Commit and push the code to `origin master`; `deploy apply --env dev` cannot deploy unpushed local changes. +3. Commit the code from the task worktree and merge/push it to `origin/master` through the chosen lightweight PR or direct integration path; `deploy apply --env dev` cannot deploy unpushed local changes. 4. Update `deploy.json` `environments.dev.services` so `backend-core` and `frontend` point at the pushed commit, then commit and push that manifest update. 5. Preflight backend-core publication: `bun scripts/cli.ts ci publish-backend-core --commit --dry-run`. The result must have no `blockedScopes`, `wouldBuildOnD601=true`, D601 `unidesk-ci` Tekton runner metadata, D601 registry target `127.0.0.1:5000/unidesk/backend-core`, required labels for service id/source repo/source commit/Dockerfile, and `recommendedAction` pointing to the real publish command. 6. Publish the artifact first: `bun scripts/cli.ts ci publish-backend-core --commit --wait-ms 1200000` for backend-core, or `bun scripts/cli.ts ci publish-user-service --service --commit --wait-ms 1200000` for user services. diff --git a/docs/reference/release-governance.md b/docs/reference/release-governance.md index 2a9e94a9..545d11ed 100644 --- a/docs/reference/release-governance.md +++ b/docs/reference/release-governance.md @@ -31,7 +31,7 @@ Frontend and CLI ownership are intentionally separate from this stable line. The `release/v1` must not carry new product features, large architecture changes, the default Rust backend-core switch, or speculative Code Agent sandbox behavior. Any exception requires an explicit issue and a deployment rollback plan. -Until the release-line implementation is completed in CLI, CI, CD and documentation, the current repository rule still applies: UniDesk agent changes are developed on `master` and pushed to `origin master`. Creating or updating `release/v1` is an explicit release operation, not a replacement for arbitrary feature or fix branches. +Until the release-line implementation is completed in CLI, CI, CD and documentation, `master` remains the normal UniDesk integration target for agent changes. Task-scoped worktrees, short-lived branches and PRs are allowed for isolation and review, but creating or updating `release/v1` is an explicit release operation, not a replacement for ordinary task branches. ## Stabilization Mode diff --git a/scripts/src/agentrun.test.ts b/scripts/src/agentrun.test.ts index d27e08e0..bf97e1c9 100644 --- a/scripts/src/agentrun.test.ts +++ b/scripts/src/agentrun.test.ts @@ -1,5 +1,50 @@ +import { readFileSync, rmSync } from "node:fs"; import { describe, expect, test } from "bun:test"; -import { stripAgentRunResourceWrapperArgs } from "./agentrun"; +import { parseAgentRunClientConfigYaml, resolveAgentRunAuth, runAgentRunCommand, stripAgentRunResourceWrapperArgs } from "./agentrun"; + +const agentRunClientYaml = [ + "manager:", + " baseUrl: https://agentrun.127-0-0-1.nip.io/", + " timeoutMs: 15000", + "publicExposure:", + " enabled: true", + " proxyName: agentrun-v01-frpc", + " remotePort: 22880", + " publicBaseUrl: https://agentrun.127-0-0-1.nip.io/", + " masterBaseUrl: http://127.0.0.1:22880", + " masterFrps:", + " configPath: /opt/hwlab-frp/frps.dev.toml", + " containerName: hwlab-frps-dev", + " masterCaddy:", + " enabled: true", + " domain: agentrun.127-0-0-1.nip.io", + " configPath: /etc/caddy/Caddyfile", + " serviceName: caddy", + " upstreamBaseUrl: http://127.0.0.1:22880", + " responseHeaderTimeoutSeconds: 60", + "auth:", + " env: HWLAB_API_KEY", + " file: /tmp/hwlab-api-key.env", + " header: Authorization", + " scheme: Bearer", + "client:", + " role: render-only", + " transport: direct-http", +].join("\n"); + +const agentRunMinimalClientYaml = [ + "manager:", + " baseUrl: http://agentrun.example.local:8080", + " timeoutMs: 15000", + "auth:", + " env: HWLAB_API_KEY", + " file: /tmp/hwlab-api-key.env", + " header: Authorization", + " scheme: Bearer", + "client:", + " role: render-only", + " transport: direct-http", +].join("\n"); describe("AgentRun resource bridge argv", () => { test("does not forward UniDesk output flags to create task prompt argv", () => { @@ -38,3 +83,82 @@ describe("AgentRun resource bridge argv", () => { ]); }); }); + +describe("AgentRun render-only REST client config", () => { + test("parses explicit YAML config for direct REST render-only client", () => { + const config = parseAgentRunClientConfigYaml(agentRunClientYaml, "config/agentrun.yaml"); + expect(config.manager.baseUrl).toBe("https://agentrun.127-0-0-1.nip.io/"); + expect(config.publicExposure?.publicBaseUrl).toBe("https://agentrun.127-0-0-1.nip.io/"); + expect(config.publicExposure?.masterCaddy.domain).toBe("agentrun.127-0-0-1.nip.io"); + expect(config.auth.env).toBe("HWLAB_API_KEY"); + expect(config.auth.header).toBe("Authorization"); + expect(config.auth.scheme).toBe("Bearer"); + expect(config.client.role).toBe("render-only"); + expect(config.client.transport).toBe("direct-http"); + }); + + test("uses env auth before configured file auth without exposing the key", () => { + const config = parseAgentRunClientConfigYaml(agentRunClientYaml, "config/agentrun.yaml"); + const auth = resolveAgentRunAuth(config, { HWLAB_API_KEY: "secret-value" }); + expect(auth.source).toBe("env"); + expect(auth.value).toBe("secret-value"); + }); + + test("requires explicit render-only direct-http client contract", () => { + expect(() => parseAgentRunClientConfigYaml(agentRunClientYaml.replace(" transport: direct-http", " transport: ssh-bridge"), "config/agentrun.yaml")).toThrow("client.transport must be direct-http"); + expect(() => parseAgentRunClientConfigYaml(agentRunClientYaml.replace(" role: render-only", " role: proxy"), "config/agentrun.yaml")).toThrow("client.role must be render-only"); + }); +}); + +describe("AgentRun default transport contract", () => { + test("server-facing compatibility groups use REST dispatcher, not official SSH CLI wrapper", () => { + const source = readFileSync(new URL("./agentrun.ts", import.meta.url), "utf8"); + const commandRouter = source.slice(source.indexOf("export async function runAgentRunCommand"), source.indexOf("function isAgentRunRestCompatGroup")); + expect(commandRouter).toContain("runAgentRunRestCompatCommand"); + expect(commandRouter).not.toContain("runOfficialAgentRunCli"); + expect(commandRouter).not.toContain("runPreparedOfficialAgentRunCli"); + }); + + test("resource verbs can use direct REST without UniDesk SSH config", async () => { + const server = Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + expect(request.headers.get("authorization")).toBe("Bearer secret-value"); + expect(url.pathname).toBe("/api/v1/queue/tasks"); + expect(url.searchParams.get("queue")).toBe("commander"); + expect(url.searchParams.get("state")).toBe("running"); + return Response.json({ + ok: true, + data: { + items: [ + { id: "qt_1", state: "queued", queue: "commander" }, + ], + }, + }); + }, + }); + const previousConfig = process.env.AGENTRUN_CLIENT_CONFIG; + const previousKey = process.env.HWLAB_API_KEY; + process.env.HWLAB_API_KEY = "secret-value"; + const tempConfigPath = `/tmp/unidesk-agentrun-test-${process.pid}-${Date.now()}.yaml`; + try { + process.env.AGENTRUN_CLIENT_CONFIG = tempConfigPath; + await Bun.write(process.env.AGENTRUN_CLIENT_CONFIG, agentRunMinimalClientYaml.replace("http://agentrun.example.local:8080", server.url.href.replace(/\/$/u, ""))); + const result = await runAgentRunCommand(null, ["get", "tasks", "--queue", "commander", "-o", "json"]); + expect(result.ok).toBe(true); + expect("renderedText" in result).toBe(true); + if ("renderedText" in result) { + expect(result.renderedText).toContain("\"kind\": \"TaskList\""); + expect(result.renderedText).toContain("\"name\": \"qt_1\""); + } + } finally { + server.stop(true); + if (previousConfig === undefined) delete process.env.AGENTRUN_CLIENT_CONFIG; + else process.env.AGENTRUN_CLIENT_CONFIG = previousConfig; + if (previousKey === undefined) delete process.env.HWLAB_API_KEY; + else process.env.HWLAB_API_KEY = previousKey; + rmSync(tempConfigPath, { force: true }); + } + }); +}); diff --git a/scripts/src/agentrun.ts b/scripts/src/agentrun.ts index f647506f..64961deb 100644 --- a/scripts/src/agentrun.ts +++ b/scripts/src/agentrun.ts @@ -1,6 +1,6 @@ -import { readFileSync } from "node:fs"; +import { chmodSync, copyFileSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; -import type { UniDeskConfig } from "./config"; +import { rootPath, type UniDeskConfig } from "./config"; import type { RenderedCliResult } from "./output"; import { runSshCommandCapture, type SshCaptureResult } from "./ssh"; import { runRemoteSshCommandCapture } from "./remote"; @@ -48,6 +48,8 @@ export function agentRunHelp(): unknown { "bun scripts/cli.ts agentrun control-plane status --full", "bun scripts/cli.ts agentrun control-plane status --pipeline-run agentrun-v01-ci-", "bun scripts/cli.ts agentrun control-plane status --source-commit ", + "bun scripts/cli.ts agentrun control-plane expose --dry-run", + "bun scripts/cli.ts agentrun control-plane expose --confirm", "bun scripts/cli.ts agentrun control-plane trigger-current --dry-run", "bun scripts/cli.ts agentrun control-plane trigger-current --confirm", "bun scripts/cli.ts agentrun control-plane refresh --dry-run", @@ -62,8 +64,8 @@ export function agentRunHelp(): unknown { "bun scripts/cli.ts agentrun git-mirror flush --confirm", ], resources: ["task/qt", "run", "command/cmd", "runnerjob/rjob", "session/ses", "aipodspec/aps"], - description: "Operate AgentRun v0.1 through Kubernetes-style resource verbs. Human output is compact by default; -o json|yaml returns the UniDesk resource schema, and --raw exposes the official G14 /root/agentrun-v01 CLI bridge response.", - legacyCompatibility: "queue/runs/commands/runner/sessions/aipod-specs remain as compatibility bridge groups, but new commander work should use get/describe/events/logs/result/ack/cancel/dispatch/create/apply/steer/send.", + description: "Operate AgentRun v0.1 through Kubernetes-style resource verbs. Human output is compact by default; -o json|yaml returns the UniDesk render-only client schema, and --raw exposes the direct AgentRun REST envelope.", + legacyCompatibility: "queue/runs/commands/runner/sessions/aipod-specs remain as compatibility groups backed by direct HTTP; new commander work should use get/describe/events/logs/result/ack/cancel/dispatch/create/apply/steer/send.", }; } @@ -72,10 +74,14 @@ export async function runAgentRunCommand(config: UniDeskConfig | null, args: str const { group, action, actionArgs } = route; if (group === undefined || isHelpArg(group)) return renderAgentRunHelp(route.canonicalArgs); if (isHelpArg(action) || actionArgs.some(isHelpArg)) return renderAgentRunHelp(route.canonicalArgs); - if (config === null) throw new Error("agentrun command requires config outside help"); if (isResourceVerb(group)) return await runAgentRunResourceCommand(config, group, action, actionArgs, route.canonicalArgs); + if (isAgentRunRestCompatGroup(group)) { + return await runAgentRunRestCompatCommand(config, group, route.forwardArgs, route.canonicalArgs); + } + if (config === null) throw new Error("agentrun control-plane and git-mirror commands require UniDesk config"); if (group === "control-plane") { if (action === "status") return await status(config, parseStatusOptions(actionArgs)); + if (action === "expose") return await exposeAgentRun(config, parseConfirmOptions(actionArgs)); if (action === "trigger-current") return await triggerCurrent(config, parseTriggerOptions(actionArgs)); if (action === "refresh") return await refresh(config, parseConfirmOptions(actionArgs)); if (action === "cleanup-runs") return await cleanupRuns(config, parseCleanupRunsOptions(actionArgs)); @@ -89,17 +95,11 @@ export async function runAgentRunCommand(config: UniDeskConfig | null, args: str return await runGitMirrorJob(config, action, options); } } - if (group === "runner" && (action === "jobs" || action === "job-status")) { - return await runOfficialAgentRunCli(config, group, route.forwardArgs); - } - if (isOfficialAgentRunCliBridgeGroup(group)) { - return await runOfficialAgentRunCli(config, group, route.forwardArgs); - } return unsupported(route.canonicalArgs); } -function isOfficialAgentRunCliBridgeGroup(group: string | undefined): group is AgentRunOfficialCliBridgeGroup { - return group === "queue" || group === "sessions" || group === "aipod-specs" || group === "aipods" || group === "runs" || group === "commands"; +function isAgentRunRestCompatGroup(group: string | undefined): group is AgentRunRestCompatGroup { + return group === "queue" || group === "sessions" || group === "aipod-specs" || group === "aipods" || group === "runs" || group === "commands" || group === "runner"; } interface AgentRunCommandRoute { @@ -209,10 +209,11 @@ function agentRunHelpText(args: string[]): string { return [ "Usage: bun scripts/cli.ts agentrun control-plane [options]", "", - "Actions: status, trigger-current, refresh, cleanup-runs, cleanup-released-pvs", + "Actions: status, expose, trigger-current, refresh, cleanup-runs, cleanup-released-pvs", "Examples:", " bun scripts/cli.ts agentrun control-plane status", " bun scripts/cli.ts agentrun control-plane status --pipeline-run agentrun-v01-ci-", + " bun scripts/cli.ts agentrun control-plane expose --dry-run", " bun scripts/cli.ts agentrun control-plane trigger-current --dry-run", " bun scripts/cli.ts agentrun control-plane cleanup-runs --min-age-minutes 30 --limit 200 --dry-run", ].join("\n"); @@ -224,9 +225,9 @@ function agentRunHelpText(args: string[]): string { "Confirmed sync/flush returns an async job unless --wait is set.", ].join("\n"); } - if (verb !== undefined && isOfficialAgentRunCliBridgeGroup(verb)) { + if (verb !== undefined && isAgentRunRestCompatGroup(verb)) { return [ - `Compatibility bridge: agentrun ${verb} ...`, + `Compatibility group: agentrun ${verb} ...`, "", "Use resource primitives for daily commander work:", " bun scripts/cli.ts agentrun get tasks --queue commander --limit 20", @@ -234,7 +235,7 @@ function agentRunHelpText(args: string[]): string { " bun scripts/cli.ts agentrun events run/ --after-seq 0 --limit 100", " bun scripts/cli.ts agentrun logs session/ --tail 100", "", - "Use --raw on a resource command when you need the official bridge response.", + "Use --raw on a resource command when you need the direct AgentRun REST envelope.", ].join("\n"); } return [ @@ -252,7 +253,7 @@ function agentRunHelpText(args: string[]): string { "", "Machine/debug output:", " -o json|yaml emits a stable UniDesk resource object without the global JSON envelope.", - " --raw emits the official AgentRun CLI bridge response.", + " --raw emits the direct AgentRun REST envelope.", ].join("\n"); } @@ -267,7 +268,7 @@ function agentRunGetKindHelp(kindRaw: string): string { return "Unknown resource. Supported: tasks, sessions, runs, commands, runnerjobs, aipodspecs."; } -async function runAgentRunResourceCommand(config: UniDeskConfig, verb: AgentRunResourceVerb, action: string | undefined, actionArgs: string[], canonicalArgs: string[]): Promise { +async function runAgentRunResourceCommand(config: UniDeskConfig | null, verb: AgentRunResourceVerb, action: string | undefined, actionArgs: string[], canonicalArgs: string[]): Promise { if (isHelpArg(action) || actionArgs.some(isHelpArg)) return renderAgentRunHelp(canonicalArgs); if (verb === "explain") return renderedCliResult(true, "agentrun explain", agentRunExplain(action ?? "task")); const resourceArgs = action === undefined ? actionArgs : [action, ...actionArgs]; @@ -288,6 +289,7 @@ async function runAgentRunResourceCommand(config: UniDeskConfig, verb: AgentRunR if (verb === "steer") return await resourceSessionPromptCommand(config, command, "steer", action, bridgeActionArgs, options); if (verb === "send") return await resourceSessionPromptCommand(config, command, "turn", action, bridgeActionArgs, options); } catch (error) { + if (error instanceof AgentRunRestError) return renderAgentRunRestError(command, error, options); return renderedCliResult(false, command, `Error: ${error instanceof Error ? error.message : String(error)}`); } return renderedCliResult(false, command, `Unsupported AgentRun resource command. Try: bun scripts/cli.ts agentrun --help`); @@ -400,45 +402,52 @@ function requiredValue(value: string | null, flag: string): string { return value; } -async function resourceGet(config: UniDeskConfig, command: string, action: string | undefined, args: string[], options: AgentRunResourceOptions): Promise { +async function resourceGet(config: UniDeskConfig | null, command: string, action: string | undefined, args: string[], options: AgentRunResourceOptions): Promise { const kind = parseResourceKind(action); if (kind === null) throw new Error("get requires a resource: tasks|sessions|runs|commands|runnerjobs|aipodspecs"); let result: Record; if (kind === "task") { const taskListArgs = options.unread ? ["commander", "--reader-id", options.readerId, "--limit", String(options.limit)] - : ["list", "--state", taskListState(options), "--limit", String(options.queue === null ? options.limit : Math.max(options.limit, 100))]; - result = await runOfficialAgentRunCli(config, "queue", taskListArgs); + : [ + "list", + "--state", + taskListState(options), + "--limit", + String(options.queue === null ? options.limit : Math.max(options.limit, 100)), + ...(options.queue === null ? [] : ["--queue", options.queue]), + ]; + result = await runAgentRunRestCommand(config, "queue", taskListArgs); return renderResourceResult(command, result, options, "Task", normalizeTaskItems(innerData(result), options).slice(0, options.limit)); } if (kind === "session") { - result = await runOfficialAgentRunCli(config, "sessions", ["ps", "--limit", String(options.limit)]); + result = await runAgentRunRestCommand(config, "sessions", ["ps", "--limit", String(options.limit)]); return renderResourceResult(command, result, options, "Session", normalizeSessionItems(innerData(result)).slice(0, options.limit)); } if (kind === "aipodspec") { - result = await runOfficialAgentRunCli(config, "aipod-specs", ["list"]); + result = await runAgentRunRestCommand(config, "aipod-specs", ["list"]); return renderResourceResult(command, result, options, "AipodSpec", normalizeAipodSpecItems(innerData(result)).slice(0, options.limit)); } if (kind === "run" && options.taskId !== null) { - result = await runOfficialAgentRunCli(config, "queue", ["show", options.taskId]); + result = await runAgentRunRestCommand(config, "queue", ["show", options.taskId]); const task = record(innerData(result)); return renderResourceResult(command, result, options, "Run", normalizeAttemptResources(task, "run")); } if (kind === "command" && options.runId !== null && options.commandId !== null) { - result = await runOfficialAgentRunCli(config, "commands", ["show", options.commandId, "--run-id", options.runId]); + result = await runAgentRunRestCommand(config, "commands", ["show", options.commandId, "--run-id", options.runId]); return renderResourceResult(command, result, options, "Command", normalizeSingleCommand(innerData(result))); } if (kind === "runnerjob" && options.runId !== null && options.commandId !== null) { - result = await runOfficialAgentRunCli(config, "runner", ["jobs", "--run-id", options.runId, "--command-id", options.commandId]); + result = await runAgentRunRestCommand(config, "runner", ["jobs", "--run-id", options.runId, "--command-id", options.commandId]); return renderResourceResult(command, result, options, "RunnerJob", normalizeRunnerJobItems(innerData(result))); } throw new Error(`get ${kind}s requires more context; use --task, --run/--command, or describe /`); } -async function resourceDescribe(config: UniDeskConfig, command: string, action: string | undefined, args: string[], options: AgentRunResourceOptions): Promise { +async function resourceDescribe(config: UniDeskConfig | null, command: string, action: string | undefined, args: string[], options: AgentRunResourceOptions): Promise { const ref = parseResourceRef(action, args); if (ref.kind === "task") { - const result = await runOfficialAgentRunCli(config, "queue", ["show", ref.name, ...(options.full ? ["--full"] : [])]); + const result = await runAgentRunRestCommand(config, "queue", ["show", ref.name, ...(options.full ? ["--full"] : [])]); const data = record(innerData(result)); const task = unwrapTaskDetail(data); if (options.raw) return renderMachine(command, result, "json", result.ok !== false); @@ -446,39 +455,39 @@ async function resourceDescribe(config: UniDeskConfig, command: string, action: return renderedCliResult(result.ok !== false, command, renderTaskDescription(task, options)); } if (ref.kind === "run") { - const result = await runOfficialAgentRunCli(config, "runs", ["show", ref.name, ...(options.full ? ["--full"] : [])]); + const result = await runAgentRunRestCommand(config, "runs", ["show", ref.name, ...(options.full ? ["--full"] : [])]); return renderDescribe(command, result, options, ref, renderGenericDescription(ref, innerData(result))); } if (ref.kind === "command") { const runId = options.runId ?? requiredContext("command describe", "--run "); - const result = await runOfficialAgentRunCli(config, "commands", ["show", ref.name, "--run-id", runId, ...(options.full ? ["--full"] : [])]); + const result = await runAgentRunRestCommand(config, "commands", ["show", ref.name, "--run-id", runId, ...(options.full ? ["--full"] : [])]); return renderDescribe(command, result, options, ref, renderGenericDescription(ref, innerData(result))); } if (ref.kind === "runnerjob") { const runId = options.runId ?? requiredContext("runnerjob describe", "--run "); - const result = await runOfficialAgentRunCli(config, "runner", ["job-status", ref.name, "--run-id", runId]); + const result = await runAgentRunRestCommand(config, "runner", ["job-status", ref.name, "--run-id", runId]); return renderDescribe(command, result, options, ref, renderGenericDescription(ref, innerData(result))); } if (ref.kind === "session") { - const result = await runOfficialAgentRunCli(config, "sessions", ["show", ref.name, ...(options.full ? ["--full"] : [])]); + const result = await runAgentRunRestCommand(config, "sessions", ["show", ref.name, ...(options.full ? ["--full"] : [])]); return renderDescribe(command, result, options, ref, renderGenericDescription(ref, innerData(result))); } if (ref.kind === "aipodspec") { - const result = await runOfficialAgentRunCli(config, "aipod-specs", ["show", ref.name, ...(options.full ? ["--full"] : [])]); + const result = await runAgentRunRestCommand(config, "aipod-specs", ["show", ref.name, ...(options.full ? ["--full"] : [])]); return renderDescribe(command, result, options, ref, renderGenericDescription(ref, innerData(result))); } throw new Error(`unsupported describe kind: ${ref.kind}`); } -async function resourceEvents(config: UniDeskConfig, command: string, action: string | undefined, args: string[], options: AgentRunResourceOptions): Promise { +async function resourceEvents(config: UniDeskConfig | null, command: string, action: string | undefined, args: string[], options: AgentRunResourceOptions): Promise { const ref = parseResourceRef(action, args, "run"); const runId = ref.kind === "run" ? ref.name : options.runId ?? requiredContext("events", "--run "); const eventArgs = ["events", runId, "--after-seq", String(options.afterSeq ?? 0), "--limit", String(options.limit), "--tail-summary"]; - const result = await runOfficialAgentRunCli(config, "runs", eventArgs); + const result = await runAgentRunRestCommand(config, "runs", eventArgs); return renderEventLike(command, result, options, "Event", normalizeEventItems(innerData(result)), runId); } -async function resourceLogs(config: UniDeskConfig, command: string, action: string | undefined, args: string[], options: AgentRunResourceOptions): Promise { +async function resourceLogs(config: UniDeskConfig | null, command: string, action: string | undefined, args: string[], options: AgentRunResourceOptions): Promise { const ref = parseResourceRef(action, args, "session"); if (ref.kind !== "session") throw new Error("logs currently requires session/"); const effectiveLimit = options.tail ?? options.limit; @@ -486,90 +495,81 @@ async function resourceLogs(config: UniDeskConfig, command: string, action: stri if (options.afterSeq !== null) logArgs.push("--after-seq", String(options.afterSeq)); else logArgs.push("--tail"); if (options.fullText) logArgs.push("--full-text"); - const result = await runOfficialAgentRunCli(config, "sessions", logArgs); + const result = await runAgentRunRestCommand(config, "sessions", logArgs); return renderEventLike(command, result, { ...options, limit: effectiveLimit }, "Log", normalizeLogItems(innerData(result)), ref.name); } -async function resourceResult(config: UniDeskConfig, command: string, action: string | undefined, args: string[], options: AgentRunResourceOptions): Promise { +async function resourceResult(config: UniDeskConfig | null, command: string, action: string | undefined, args: string[], options: AgentRunResourceOptions): Promise { const ref = parseResourceRef(action, args); if (ref.kind === "run") { const commandId = options.commandId ?? requiredContext("run result", "--command "); - const result = await runOfficialAgentRunCli(config, "runs", ["result", ref.name, "--command-id", commandId]); + const result = await runAgentRunRestCommand(config, "runs", ["result", ref.name, "--command-id", commandId]); return renderResultSummary(command, result, options, ref); } if (ref.kind === "command") { const runId = options.runId ?? requiredContext("command result", "--run "); - const result = await runOfficialAgentRunCli(config, "commands", ["result", ref.name, "--run-id", runId]); + const result = await runAgentRunRestCommand(config, "commands", ["result", ref.name, "--run-id", runId]); return renderResultSummary(command, result, options, ref); } throw new Error("result supports run/ or command/"); } -async function resourceAck(config: UniDeskConfig, command: string, action: string | undefined, args: string[], options: AgentRunResourceOptions): Promise { +async function resourceAck(config: UniDeskConfig | null, command: string, action: string | undefined, args: string[], options: AgentRunResourceOptions): Promise { const ref = parseResourceRef(action, args, "task"); const result = ref.kind === "task" - ? await runOfficialAgentRunCli(config, "queue", ["read", ref.name, "--reader-id", options.readerId]) + ? await runAgentRunRestCommand(config, "queue", ["read", ref.name, "--reader-id", options.readerId]) : ref.kind === "session" - ? await runOfficialAgentRunCli(config, "sessions", ["read", ref.name, "--reader-id", options.readerId]) + ? await runAgentRunRestCommand(config, "sessions", ["read", ref.name, "--reader-id", options.readerId]) : null; if (result === null) throw new Error("ack supports task/ or session/"); return renderMutationSummary(command, result, options, `Acknowledged ${ref.kind}/${shortId(ref.name)}`); } -async function resourceCancel(config: UniDeskConfig, command: string, action: string | undefined, args: string[], options: AgentRunResourceOptions): Promise { +async function resourceCancel(config: UniDeskConfig | null, command: string, action: string | undefined, args: string[], options: AgentRunResourceOptions): Promise { const ref = parseResourceRef(action, args, "task"); const cancelArgs = ref.kind === "task" ? ["cancel", ref.name] : ref.kind === "session" ? ["cancel", ref.name] : null; if (cancelArgs === null) throw new Error("cancel supports task/ or session/"); if (options.reason !== null && ref.kind === "task") cancelArgs.push("--reason", options.reason); if (options.dryRun) cancelArgs.push("--dry-run"); const result = ref.kind === "task" - ? await runOfficialAgentRunCli(config, "queue", cancelArgs) - : await runOfficialAgentRunCli(config, "sessions", cancelArgs); + ? await runAgentRunRestCommand(config, "queue", cancelArgs) + : await runAgentRunRestCommand(config, "sessions", cancelArgs); return renderMutationSummary(command, result, options, `${options.dryRun ? "Planned cancel" : "Cancel requested"} ${ref.kind}/${shortId(ref.name)}`, options.dryRun ? [rerunWithoutDryRun(command)] : undefined); } -async function resourceDispatch(config: UniDeskConfig, command: string, action: string | undefined, args: string[], options: AgentRunResourceOptions): Promise { +async function resourceDispatch(config: UniDeskConfig | null, command: string, action: string | undefined, args: string[], options: AgentRunResourceOptions): Promise { const ref = parseResourceRef(action, args, "task"); if (ref.kind !== "task") throw new Error("dispatch supports task/"); - const result = await runOfficialAgentRunCli(config, "queue", ["dispatch", ref.name, ...stripLeadingResource(args, ref.name)]); + const result = await runAgentRunRestCommand(config, "queue", ["dispatch", ref.name, ...stripLeadingResource(args, ref.name)]); return renderMutationSummary(command, result, options, `Task dispatch submitted ${shortId(ref.name)}`, [ `bun scripts/cli.ts agentrun describe task/${ref.name}`, ]); } -async function resourceCreate(config: UniDeskConfig, command: string, action: string | undefined, args: string[], options: AgentRunResourceOptions): Promise { +async function resourceCreate(config: UniDeskConfig | null, command: string, action: string | undefined, args: string[], options: AgentRunResourceOptions): Promise { const kind = parseResourceKind(action); if (kind !== "task") throw new Error("create currently supports: create task"); const submitArgs = ["submit", ...stripLeadingResource(args, "task")]; - const result = await runOfficialAgentRunCli(config, "queue", submitArgs); + const result = await runAgentRunRestCommand(config, "queue", submitArgs); return renderMutationSummary(command, result, options, "Task create submitted", options.dryRun ? [rerunWithoutDryRun(command)] : undefined); } -async function resourceApply(config: UniDeskConfig, command: string, args: string[], options: AgentRunResourceOptions): Promise { +async function resourceApply(config: UniDeskConfig | null, command: string, args: string[], options: AgentRunResourceOptions): Promise { + void config; const file = options.file ?? requiredContext("apply", "-f |-"); const raw = file === "-" ? readFileSync(0, "utf8") : readFileSync(file, "utf8"); const parsed = parseTaskManifest(raw, file); - const json = `${JSON.stringify(parsed, null, 2)}\n`; - const submitArgs = ["queue", "submit", "--json-stdin", ...(options.dryRun ? ["--dry-run"] : [])]; - const prepared: PreparedAgentRunCliArgs = { - args: submitArgs, - materializedFiles: [], - stdinPayload: { - flag: "--json-stdin", - source: file, - bytes: Buffer.byteLength(json, "utf8"), - base64: Buffer.from(json, "utf8").toString("base64"), - }, - }; - const result = await runPreparedOfficialAgentRunCli(config, prepared); + const result = options.dryRun + ? agentRunDryRunPlan("queue-submit", "/api/v1/queue/tasks", parsed, `bun scripts/cli.ts ${command.replace(/\s+--dry-run\b/gu, "").trim()}`) + : await agentRunRestRequest("agentrun queue submit", "POST", "/api/v1/queue/tasks", parsed); return renderMutationSummary(command, result, options, `${options.dryRun ? "Dry-run applied" : "Applied"} task manifest`, options.dryRun ? [rerunWithoutDryRun(command)] : undefined); } -async function resourceSessionPromptCommand(config: UniDeskConfig, command: string, officialAction: "steer" | "turn", action: string | undefined, args: string[], options: AgentRunResourceOptions): Promise { +async function resourceSessionPromptCommand(config: UniDeskConfig | null, command: string, officialAction: "steer" | "turn", action: string | undefined, args: string[], options: AgentRunResourceOptions): Promise { const ref = parseResourceRef(action, args, "session"); if (ref.kind !== "session") throw new Error(`${officialAction === "steer" ? "steer" : "send"} requires session/`); const sessionArgs = [officialAction, ref.name, ...stripLeadingResource(args, ref.name)]; - const result = await runOfficialAgentRunCli(config, "sessions", sessionArgs); + const result = await runAgentRunRestCommand(config, "sessions", sessionArgs); return renderMutationSummary(command, result, options, officialAction === "steer" ? "Steer submitted" : "Session turn submitted"); } @@ -1359,6 +1359,209 @@ async function status(config: UniDeskConfig, options: StatusOptions): Promise> { + const clientConfig = readAgentRunClientConfig(); + const exposure = clientConfig.publicExposure; + if (!exposure?.enabled) { + return { + ok: true, + command: "agentrun control-plane expose", + action: "disabled-by-yaml", + publicExposure: exposureSummary(exposure), + valuesPrinted: false, + }; + } + if (options.dryRun) { + return { + ok: true, + command: "agentrun control-plane expose", + dryRun: true, + mutation: false, + publicExposure: exposureSummary(exposure), + plan: { + masterFrps: { + action: "ensure-allow-port", + configPath: exposure.masterFrps.configPath, + containerName: exposure.masterFrps.containerName, + remotePort: exposure.remotePort, + }, + masterCaddy: { + action: "ensure-https-vhost", + domain: exposure.masterCaddy.domain, + configPath: exposure.masterCaddy.configPath, + serviceName: exposure.masterCaddy.serviceName, + upstreamBaseUrl: exposure.masterCaddy.upstreamBaseUrl, + responseHeaderTimeoutSeconds: exposure.masterCaddy.responseHeaderTimeoutSeconds, + }, + }, + next: { confirm: "bun scripts/cli.ts agentrun control-plane expose --confirm" }, + valuesPrinted: false, + }; + } + const masterFrps = applyAgentRunMasterFrpsAllowPort(exposure); + const masterCaddy = applyAgentRunMasterCaddySite(exposure); + const ok = masterFrps.ok === true && masterCaddy.ok === true; + return { + ok, + command: "agentrun control-plane expose", + mutation: true, + publicExposure: exposureSummary(exposure), + masterFrps, + masterCaddy, + validation: { + publicProbe: `curl -fsS ${exposure.publicBaseUrl.replace(/\/+$/u, "")}/health/readiness`, + directClient: "bun scripts/cli.ts agentrun get tasks --queue commander -o json", + valuesPrinted: false, + }, + valuesPrinted: false, + }; +} + +function exposureSummary(exposure: AgentRunPublicExposure | null | undefined): Record { + if (!exposure) return { enabled: false, valuesPrinted: false }; + return { + enabled: exposure.enabled, + proxyName: exposure.proxyName, + remotePort: exposure.remotePort, + publicBaseUrl: exposure.publicBaseUrl, + masterBaseUrl: exposure.masterBaseUrl, + frps: { + configPath: exposure.masterFrps.configPath, + containerName: exposure.masterFrps.containerName, + }, + caddy: { + enabled: exposure.masterCaddy.enabled, + domain: exposure.masterCaddy.domain, + configPath: exposure.masterCaddy.configPath, + serviceName: exposure.masterCaddy.serviceName, + upstreamBaseUrl: exposure.masterCaddy.upstreamBaseUrl, + responseHeaderTimeoutSeconds: exposure.masterCaddy.responseHeaderTimeoutSeconds, + }, + valuesPrinted: false, + }; +} + +function applyAgentRunMasterFrpsAllowPort(exposure: AgentRunPublicExposure): Record { + const pathValue = exposure.masterFrps.configPath; + const port = exposure.remotePort; + const container = exposure.masterFrps.containerName; + if (!existsSync(pathValue)) return { ok: false, error: "master-frps-config-missing", path: pathValue, valuesPrinted: false }; + const before = readFileSync(pathValue, "utf8"); + const alreadyAllowed = frpsAllowPortExists(before, port); + let backupPath: string | null = null; + let action = "kept-existing"; + if (!alreadyAllowed) { + const stamp = new Date().toISOString().replace(/[-:]/gu, "").replace(/\..*$/u, "Z"); + backupPath = `${pathValue}.bak-agentrun-${stamp}`; + copyFileSync(pathValue, backupPath); + writeFileSync(pathValue, `${before.replace(/\s*$/u, "")}\n\n[[allowPorts]]\nstart = ${port}\nend = ${port}\n`, "utf8"); + chmodSync(pathValue, statSync(backupPath).mode & 0o777); + action = "added-allow-port"; + } + const restart = alreadyAllowed ? null : spawnSync("docker", ["restart", container], { encoding: "utf8" }); + const inspect = spawnSync("docker", ["inspect", "-f", "{{.State.Running}}", container], { encoding: "utf8" }); + const ok = alreadyAllowed || (restart?.status === 0 && inspect.status === 0 && String(inspect.stdout).trim() === "true"); + return { + ok, + action, + path: pathValue, + backupPath, + remotePort: port, + containerName: container, + restart: restart === null ? null : { exitCode: restart.status, stdoutTail: String(restart.stdout).slice(-1000), stderrTail: String(restart.stderr).slice(-1000) }, + inspect: { exitCode: inspect.status, running: String(inspect.stdout).trim() === "true", stderrTail: String(inspect.stderr).slice(-1000) }, + valuesPrinted: false, + }; +} + +function applyAgentRunMasterCaddySite(exposure: AgentRunPublicExposure): Record { + const caddy = exposure.masterCaddy; + if (!caddy.enabled) return { ok: true, action: "disabled-by-yaml", valuesPrinted: false }; + const pathValue = caddy.configPath; + if (!existsSync(pathValue)) return { ok: false, error: "master-caddy-config-missing", path: pathValue, valuesPrinted: false }; + const before = readFileSync(pathValue, "utf8"); + const desiredBlock = renderAgentRunCaddySiteBlock(caddy.domain, caddy.upstreamBaseUrl, caddy.responseHeaderTimeoutSeconds); + const existing = caddySiteBlock(before, caddy.domain); + const alreadyConfigured = existing === desiredBlock; + let backupPath: string | null = null; + let action = "kept-existing"; + if (!alreadyConfigured) { + const stamp = new Date().toISOString().replace(/[-:]/gu, "").replace(/\..*$/u, "Z"); + backupPath = `${pathValue}.bak-agentrun-https-${stamp}`; + copyFileSync(pathValue, backupPath); + const next = existing === null ? `${before.replace(/\s*$/u, "")}\n\n${desiredBlock}\n` : before.replace(existing, desiredBlock); + writeFileSync(pathValue, next, "utf8"); + chmodSync(pathValue, statSync(backupPath).mode & 0o777); + action = existing === null ? "added-site" : "updated-site"; + } + const validate = spawnSync("caddy", ["validate", "--config", pathValue, "--adapter", "caddyfile"], { encoding: "utf8" }); + const reload = validate.status === 0 && !alreadyConfigured ? spawnSync("systemctl", ["reload", caddy.serviceName], { encoding: "utf8" }) : null; + const active = spawnSync("systemctl", ["is-active", caddy.serviceName], { encoding: "utf8" }); + const ok = validate.status === 0 && (reload === null || reload.status === 0) && active.status === 0 && String(active.stdout).trim() === "active"; + return { + ok, + action, + path: pathValue, + backupPath, + domain: caddy.domain, + upstreamBaseUrl: caddy.upstreamBaseUrl, + serviceName: caddy.serviceName, + responseHeaderTimeoutSeconds: caddy.responseHeaderTimeoutSeconds, + validate: { exitCode: validate.status, stdoutTail: String(validate.stdout).slice(-1000), stderrTail: String(validate.stderr).slice(-2000) }, + reload: reload === null ? null : { exitCode: reload.status, stdoutTail: String(reload.stdout).slice(-1000), stderrTail: String(reload.stderr).slice(-1000) }, + active: { exitCode: active.status, stdoutTail: String(active.stdout).slice(-1000), stderrTail: String(active.stderr).slice(-1000) }, + valuesPrinted: false, + }; +} + +function renderAgentRunCaddySiteBlock(domain: string, upstreamBaseUrl: string, responseHeaderTimeoutSeconds: number): string { + const upstream = new URL(upstreamBaseUrl); + const upstreamHost = `${upstream.hostname}${upstream.port ? `:${upstream.port}` : ""}`; + return `${domain} { + encode zstd gzip + reverse_proxy ${upstreamHost} { + header_up Host {host} + header_up X-Real-IP {remote_host} + transport http { + dial_timeout 5s + response_header_timeout ${responseHeaderTimeoutSeconds}s + } + } +}`; +} + +function caddySiteBlock(text: string, domain: string): string | null { + const startMatch = new RegExp(`(^|\\n)${escapeRegExp(domain)}\\s*\\{`, "u").exec(text); + if (startMatch === null) return null; + const start = startMatch.index + (startMatch[1] === "\n" ? 1 : 0); + const open = text.indexOf("{", start); + if (open < 0) return null; + let depth = 0; + for (let index = open; index < text.length; index += 1) { + const ch = text[index]; + if (ch === "{") depth += 1; + if (ch === "}") { + depth -= 1; + if (depth === 0) return text.slice(start, index + 1); + } + } + return null; +} + +function frpsAllowPortExists(toml: string, port: number): boolean { + const sections = toml.split(/(?=\[\[allowPorts\]\])/u); + return sections.some((section) => { + if (!section.includes("[[allowPorts]]")) return false; + const start = section.match(/^\s*start\s*=\s*(\d+)\s*$/mu); + const end = section.match(/^\s*end\s*=\s*(\d+)\s*$/mu); + return Number(start?.[1]) === port && Number(end?.[1]) === port; + }); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + async function triggerCurrent(config: UniDeskConfig, options: TriggerOptions): Promise> { const source = await capture(config, g14SourceRoute, ["script", "--", [ "set -u", @@ -2492,28 +2695,759 @@ function startAsyncAgentRunJob(name: string, command: string[], note: string): R }; } -interface AgentRunCliMaterializedFile { - flag: string; - source: string; - remotePath: string; - bytes: number; - base64: string; +async function runAgentRunRestCompatCommand(config: UniDeskConfig | null, group: AgentRunRestCompatGroup, args: string[], canonicalArgs: string[]): Promise> { + try { + return await runAgentRunRestCommand(config, group, args); + } catch (error) { + if (error instanceof AgentRunRestError) return error.toPayload(`agentrun ${canonicalArgs.join(" ")}`.trim()); + throw error; + } } -interface AgentRunCliForwardedStdin { - flag: string; - source: string; - bytes: number; - base64: string; +async function runAgentRunRestCommand(config: UniDeskConfig | null, group: AgentRunRestCompatGroup, args: string[]): Promise> { + void config; + const compatGroup = group === "aipods" ? "aipod-specs" : group; + const [action, id] = args; + if (compatGroup === "queue") return await runAgentRunQueueRest(action, id, args); + if (compatGroup === "sessions") return await runAgentRunSessionsRest(action, id, args); + if (compatGroup === "runs") return await runAgentRunRunsRest(action, id, args); + if (compatGroup === "commands") return await runAgentRunCommandsRest(action, id, args); + if (compatGroup === "runner") return await runAgentRunRunnerRest(action, id, args); + if (compatGroup === "aipod-specs") return await runAgentRunAipodSpecsRest(action, id, args); + throw new AgentRunRestError("unsupported-version", `unsupported AgentRun REST compatibility group: ${group}`); } -interface PreparedAgentRunCliArgs { - args: string[]; - materializedFiles: AgentRunCliMaterializedFile[]; - stdinPayload: AgentRunCliForwardedStdin | null; +async function runAgentRunQueueRest(action: string | undefined, id: string | undefined, args: string[]): Promise> { + if (action === "list") return await agentRunRestRequest("agentrun queue list", "GET", `/api/v1/queue/tasks${agentRunQuery(args, ["queue", "state", "cursor", "limit", "updated-after"])}`); + if (action === "commander") return await agentRunRestRequest("agentrun queue commander", "GET", `/api/v1/queue/commander${agentRunQuery(args, ["queue", "reader-id"])}`); + if (action === "stats") return await agentRunRestRequest("agentrun queue stats", "GET", `/api/v1/queue/stats${agentRunQuery(args, ["queue"])}`); + if (action === "show" && id) return await agentRunRestRequest("agentrun queue show", "GET", `/api/v1/queue/tasks/${encodeURIComponent(id)}`); + if (action === "submit") return await submitQueueTaskRest(args); + if (action === "dispatch" && id) return await mutateQueueTaskRest("queue-dispatch", id, "dispatch", queueDispatchBodyFromArgs(args), args); + if (action === "read" && id) return await mutateQueueTaskRest("queue-read", id, "read", { readerId: agentRunOption(args, "reader-id") ?? "cli" }, args); + if (action === "cancel" && id) return await mutateQueueTaskRest("queue-cancel", id, "cancel", cancelBodyFromArgs(args), args); + if (action === "refresh" && id) return await mutateQueueTaskRest("queue-refresh", id, "refresh", {}, args); + throw new AgentRunRestError("validation-failed", `unsupported queue command: ${[action, id].filter(Boolean).join(" ") || "(empty)"}`); } -type AgentRunOfficialCliBridgeGroup = "queue" | "sessions" | "aipod-specs" | "aipods" | "runs" | "commands" | "runner"; +async function runAgentRunSessionsRest(action: string | undefined, id: string | undefined, args: string[]): Promise> { + if (action === "ps") return await agentRunRestRequest("agentrun sessions ps", "GET", `/api/v1/sessions${agentRunQuery(args, ["state", "profile", "backend-profile", "reader-id", "cursor", "limit"])}`); + if (action === "show" && id) return await agentRunRestRequest("agentrun sessions show", "GET", `/api/v1/sessions/${encodeURIComponent(id)}${agentRunQuery(args, ["reader-id"])}`); + if ((action === "trace" || action === "output") && id) return await agentRunRestRequest(`agentrun sessions ${action}`, "GET", `/api/v1/sessions/${encodeURIComponent(id)}/${action}${agentRunQuery(args, ["after-seq", "limit", "run-id"])}`); + if (action === "read" && id) { + const body = { readerId: agentRunOption(args, "reader-id") ?? "cli" }; + if (agentRunHasFlag(args, "dry-run")) return agentRunDryRunPlan("session-read", `/api/v1/sessions/${encodeURIComponent(id)}/read`, body, `bun scripts/cli.ts agentrun sessions read ${id} --reader-id ${body.readerId}`); + return await agentRunRestRequest("agentrun sessions read", "POST", `/api/v1/sessions/${encodeURIComponent(id)}/read`, body); + } + if (action === "cancel" && id) { + const body = { action: "cancel", ...cancelBodyFromArgs(args) }; + if (agentRunHasFlag(args, "dry-run")) return agentRunDryRunPlan("session-cancel", `/api/v1/sessions/${encodeURIComponent(id)}/control`, body, `bun scripts/cli.ts agentrun sessions cancel ${id}`); + return await agentRunRestRequest("agentrun sessions cancel", "POST", `/api/v1/sessions/${encodeURIComponent(id)}/control`, body); + } + if (action === "steer" && id) return await sessionSteerRest(id, args); + if (action === "turn") return await sessionTurnRest(id ?? null, args); + throw new AgentRunRestError("validation-failed", `unsupported sessions command: ${[action, id].filter(Boolean).join(" ") || "(empty)"}`); +} + +async function runAgentRunRunsRest(action: string | undefined, id: string | undefined, args: string[]): Promise> { + if (action === "create") return await agentRunRestRequest("agentrun runs create", "POST", "/api/v1/runs", await requiredJsonBody(args, "runs create")); + if (action === "show" && id) return await agentRunRestRequest("agentrun runs show", "GET", `/api/v1/runs/${encodeURIComponent(id)}`); + if (action === "events" && id) return await agentRunRestRequest("agentrun runs events", "GET", `/api/v1/runs/${encodeURIComponent(id)}/events${agentRunQuery(args, ["after-seq", "limit"])}`); + if (action === "result" && id) return await agentRunRestRequest("agentrun runs result", "GET", `/api/v1/runs/${encodeURIComponent(id)}/result${agentRunQuery(args, ["command-id", "command"])}`); + if (action === "cancel" && id) return await agentRunRestRequest("agentrun runs cancel", "POST", `/api/v1/runs/${encodeURIComponent(id)}/cancel`, cancelBodyFromArgs(args)); + throw new AgentRunRestError("validation-failed", `unsupported runs command: ${[action, id].filter(Boolean).join(" ") || "(empty)"}`); +} + +async function runAgentRunCommandsRest(action: string | undefined, id: string | undefined, args: string[]): Promise> { + if (action === "create" && id) { + const body = await optionalJsonBody(args); + if (!body.type) body.type = agentRunOption(args, "type") ?? "turn"; + const idempotencyKey = agentRunOption(args, "idempotency-key"); + if (idempotencyKey) body.idempotencyKey = idempotencyKey; + return await agentRunRestRequest("agentrun commands create", "POST", `/api/v1/runs/${encodeURIComponent(id)}/commands`, body); + } + if (action === "show" && id) { + const runId = requiredAgentRunOption(args, ["run-id", "run"], "commands show requires --run-id"); + return await agentRunRestRequest("agentrun commands show", "GET", `/api/v1/runs/${encodeURIComponent(runId)}/commands/${encodeURIComponent(id)}`); + } + if (action === "result" && id) { + const runId = requiredAgentRunOption(args, ["run-id", "run"], "commands result requires --run-id"); + return await agentRunRestRequest("agentrun commands result", "GET", `/api/v1/runs/${encodeURIComponent(runId)}/commands/${encodeURIComponent(id)}/result`); + } + if (action === "cancel" && id) return await agentRunRestRequest("agentrun commands cancel", "POST", `/api/v1/commands/${encodeURIComponent(id)}/cancel`, cancelBodyFromArgs(args)); + throw new AgentRunRestError("validation-failed", `unsupported commands command: ${[action, id].filter(Boolean).join(" ") || "(empty)"}`); +} + +async function runAgentRunRunnerRest(action: string | undefined, id: string | undefined, args: string[]): Promise> { + if (action === "jobs") { + const runId = requiredAgentRunOption(args, ["run-id", "run"], "runner jobs requires --run-id"); + return await agentRunRestRequest("agentrun runner jobs", "GET", `/api/v1/runs/${encodeURIComponent(runId)}/runner-jobs${agentRunQuery(args, ["command-id", "command"])}`); + } + if (action === "job-status") { + const runId = requiredAgentRunOption(args, ["run-id", "run"], "runner job-status requires --run-id"); + const runnerJobId = id ?? agentRunOption(args, "runner-job-id"); + if (!runnerJobId) return await agentRunRestRequest("agentrun runner job-status", "GET", `/api/v1/runs/${encodeURIComponent(runId)}/runner-jobs${agentRunQuery(args, ["command-id", "command"])}`); + return await agentRunRestRequest("agentrun runner job-status", "GET", `/api/v1/runs/${encodeURIComponent(runId)}/runner-jobs/${encodeURIComponent(runnerJobId)}`); + } + if (action === "job") { + const runId = requiredAgentRunOption(args, ["run-id", "run"], "runner job requires --run-id"); + const body = queueDispatchBodyFromArgs(args); + const commandId = requiredAgentRunOption(args, ["command-id", "command"], "runner job requires --command-id"); + body.commandId = commandId; + if (agentRunHasFlag(args, "dry-run")) return agentRunDryRunPlan("runner-job", `/api/v1/runs/${encodeURIComponent(runId)}/runner-jobs`, body, `bun scripts/cli.ts agentrun runner job --run-id ${runId} --command-id ${commandId}`); + return await agentRunRestRequest("agentrun runner job", "POST", `/api/v1/runs/${encodeURIComponent(runId)}/runner-jobs`, body); + } + throw new AgentRunRestError("validation-failed", `unsupported runner command: ${[action, id].filter(Boolean).join(" ") || "(empty)"}`); +} + +async function runAgentRunAipodSpecsRest(action: string | undefined, id: string | undefined, args: string[]): Promise> { + if (action === "list") return await agentRunRestRequest("agentrun aipod-specs list", "GET", "/api/v1/aipod-specs"); + if (action === "show" && id) return await agentRunRestRequest("agentrun aipod-specs show", "GET", `/api/v1/aipod-specs/${encodeURIComponent(id)}`); + if (action === "render" && id) return await agentRunRestRequest("agentrun aipod-specs render", "POST", `/api/v1/aipod-specs/${encodeURIComponent(id)}/render`, await aipodRenderInputFromArgs(args, 2)); + if (action === "apply" || action === "set") { + const yaml = readYamlInputFromArgs(args); + const pathValue = id ? `/api/v1/aipod-specs/${encodeURIComponent(id)}` : "/api/v1/aipod-specs"; + const method: AgentRunHttpMethod = id ? "PUT" : "POST"; + const body = { yaml }; + if (agentRunHasFlag(args, "dry-run")) return agentRunDryRunPlan("aipod-spec-apply", pathValue, { yamlBytes: Buffer.byteLength(yaml, "utf8") }, `bun scripts/cli.ts agentrun aipod-specs apply${id ? ` ${id}` : ""} --yaml-stdin`, method); + return await agentRunRestRequest("agentrun aipod-specs apply", method, pathValue, body); + } + if ((action === "delete" || action === "rm") && id) return await agentRunRestRequest("agentrun aipod-specs delete", "DELETE", `/api/v1/aipod-specs/${encodeURIComponent(id)}`); + throw new AgentRunRestError("validation-failed", `unsupported aipod-specs command: ${[action, id].filter(Boolean).join(" ") || "(empty)"}`); +} + +async function submitQueueTaskRest(args: string[]): Promise> { + const aipod = agentRunOption(args, "aipod") ?? agentRunOption(args, "aipod-spec"); + if (aipod) { + const rendered = await agentRunRestRequest("agentrun aipod-specs render", "POST", `/api/v1/aipod-specs/${encodeURIComponent(aipod)}/render`, await aipodRenderInputFromArgs(args, 2)); + const body = record(record(innerData(rendered)).queueTask); + if (Object.keys(body).length === 0) throw new AgentRunRestError("schema-mismatch", `aipod-spec ${aipod} render did not return queueTask`); + if (agentRunHasFlag(args, "dry-run")) return agentRunDryRunPlan("queue-submit", "/api/v1/queue/tasks", body, queueSubmitConfirmCommand(args, aipod), "POST", { jsonInput: { source: "aipod-spec", aipod, valuesPrinted: false } }); + return await agentRunRestRequest("agentrun queue submit", "POST", "/api/v1/queue/tasks", body); + } + const body = await requiredJsonBody(args, "queue submit"); + const idempotencyKey = agentRunOption(args, "idempotency-key"); + if (idempotencyKey) body.idempotencyKey = idempotencyKey; + if (agentRunHasFlag(args, "dry-run")) return agentRunDryRunPlan("queue-submit", "/api/v1/queue/tasks", body, queueSubmitConfirmCommand(args), "POST", { jsonInput: jsonInputDisclosureFromArgs(args) }); + return await agentRunRestRequest("agentrun queue submit", "POST", "/api/v1/queue/tasks", body); +} + +async function mutateQueueTaskRest(action: string, taskId: string, suffix: string, body: Record, args: string[]): Promise> { + const pathValue = `/api/v1/queue/tasks/${encodeURIComponent(taskId)}/${suffix}`; + if (agentRunHasFlag(args, "dry-run")) return agentRunDryRunPlan(action, pathValue, body, `bun scripts/cli.ts agentrun queue ${suffix} ${taskId}`); + return await agentRunRestRequest(`agentrun queue ${suffix}`, "POST", pathValue, body); +} + +async function sessionSteerRest(sessionId: string, args: string[]): Promise> { + const session = record(innerData(await agentRunRestRequest("agentrun sessions show", "GET", `/api/v1/sessions/${encodeURIComponent(sessionId)}${agentRunQuery(args, ["reader-id"])}`))); + const runId = stringOrNull(session.activeRunId) ?? stringOrNull(session.lastRunId); + if (runId === null) throw new AgentRunRestError("validation-failed", `session ${sessionId} has no run to steer`); + const prompt = readPromptFromArgs(args, 2); + const body: Record = { type: "steer", payload: { prompt } }; + const idempotencyKey = agentRunOption(args, "idempotency-key"); + if (idempotencyKey) body.idempotencyKey = idempotencyKey; + const command = await agentRunRestRequest("agentrun sessions steer", "POST", `/api/v1/runs/${encodeURIComponent(runId)}/commands`, body); + return { ok: command.ok !== false, command: "agentrun sessions steer", data: { action: "session-steer", sessionId, runId, command: innerData(command) }, bridge: command.bridge }; +} + +async function sessionTurnRest(positionalSessionId: string | null, args: string[]): Promise> { + const aipod = agentRunOption(args, "aipod") ?? agentRunOption(args, "aipod-spec"); + if (aipod) return await sessionTurnWithAipodRest(positionalSessionId, aipod, args); + const sessionId = positionalSessionId ?? agentRunOption(args, "session-id") ?? newAgentRunSessionId(); + const body = await optionalJsonBody(args); + const profile = agentRunOption(args, "profile") ?? agentRunOption(args, "backend-profile") ?? stringOrNull(body.backendProfile) ?? "codex"; + const prompt = readPromptFromArgs(args, positionalSessionId ? 2 : 1); + body.tenantId = agentRunOption(args, "tenant-id") ?? stringOrNull(body.tenantId) ?? "unidesk"; + body.projectId = agentRunOption(args, "project-id") ?? stringOrNull(body.projectId) ?? "default"; + body.providerId = agentRunOption(args, "provider-id") ?? stringOrNull(body.providerId) ?? "G14"; + body.backendProfile = profile; + body.workspaceRef = jsonObjectOption(args, "workspace-json") ?? record(body.workspaceRef); + if (Object.keys(record(body.workspaceRef)).length === 0) body.workspaceRef = { kind: "opaque", path: "." }; + body.executionPolicy = jsonObjectOption(args, "execution-policy-json") ?? record(body.executionPolicy); + if (Object.keys(record(body.executionPolicy)).length === 0) body.executionPolicy = defaultAgentRunExecutionPolicy(profile); + const sessionRef = record(body.sessionRef); + const metadata = record(sessionRef.metadata); + const title = agentRunOption(args, "title"); + if (title) metadata.title = title; + body.sessionRef = { ...sessionRef, sessionId, metadata }; + await ensureAgentRunSession(sessionId, body); + const run = await agentRunRestRequest("agentrun runs create", "POST", "/api/v1/runs", body); + const runData = record(innerData(run)); + const runId = stringOrNull(runData.id) ?? requiredContext("session turn", "run id returned by AgentRun server"); + const commandBody: Record = { type: "turn", payload: { prompt } }; + const commandIdempotencyKey = agentRunOption(args, "command-idempotency-key") ?? agentRunOption(args, "idempotency-key"); + if (commandIdempotencyKey) commandBody.idempotencyKey = commandIdempotencyKey; + const command = await agentRunRestRequest("agentrun commands create", "POST", `/api/v1/runs/${encodeURIComponent(runId)}/commands`, commandBody); + const commandId = stringOrNull(record(innerData(command)).id); + let runnerJob: Record | null = null; + if (!agentRunHasFlag(args, "no-runner-job") && commandId !== null) { + const runnerBody = await optionalRunnerJsonBody(args); + runnerBody.commandId = commandId; + copyAgentRunOptions(args, runnerBody, ["image", "namespace", "attempt-id", "runner-id", "source-commit", "service-account-name"]); + runnerJob = await agentRunRestRequest("agentrun runner job", "POST", `/api/v1/runs/${encodeURIComponent(runId)}/runner-jobs`, runnerBody); + } + return { ok: true, command: "agentrun sessions turn", data: { action: "session-turn", sessionId, profile, run: innerData(run), command: innerData(command), runnerJob: runnerJob ? innerData(runnerJob) : null, valuesPrinted: false }, bridge: runnerJob?.bridge ?? command.bridge ?? run.bridge }; +} + +async function sessionTurnWithAipodRest(positionalSessionId: string | null, aipod: string, args: string[]): Promise> { + const sessionId = positionalSessionId ?? agentRunOption(args, "session-id") ?? newAgentRunSessionId(); + const rendered = await agentRunRestRequest("agentrun aipod-specs render", "POST", `/api/v1/aipod-specs/${encodeURIComponent(aipod)}/render`, await aipodRenderInputFromArgs(args, positionalSessionId ? 2 : 1, { sessionId })); + const renderedData = record(innerData(rendered)); + const task = record(renderedData.queueTask); + if (Object.keys(task).length === 0) throw new AgentRunRestError("schema-mismatch", `aipod-spec ${aipod} render did not return queueTask`); + await ensureAgentRunSession(sessionId, task); + const sessionRef = record(task.sessionRef); + const metadata = record(sessionRef.metadata); + const title = agentRunOption(args, "title") ?? stringOrNull(task.title); + if (title) metadata.title = title; + const runBody: Record = { + 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 agentRunRestRequest("agentrun runs create", "POST", "/api/v1/runs", runBody); + const runId = stringOrNull(record(innerData(run)).id) ?? requiredContext("session turn", "run id returned by AgentRun server"); + const commandBody: Record = { type: "turn", payload: task.payload }; + const commandIdempotencyKey = agentRunOption(args, "command-idempotency-key") ?? agentRunOption(args, "idempotency-key"); + if (commandIdempotencyKey) commandBody.idempotencyKey = commandIdempotencyKey; + const command = await agentRunRestRequest("agentrun commands create", "POST", `/api/v1/runs/${encodeURIComponent(runId)}/commands`, commandBody); + const commandId = stringOrNull(record(innerData(command)).id); + let runnerJob: Record | null = null; + if (!agentRunHasFlag(args, "no-runner-job") && commandId !== null) { + const runnerDefaults = record(record(renderedData.dispatchDefaults).runnerJob); + const runnerBody = { ...runnerDefaults, ...(await optionalRunnerJsonBody(args)), commandId }; + copyAgentRunOptions(args, runnerBody, ["image", "namespace", "attempt-id", "runner-id", "source-commit", "service-account-name"]); + runnerJob = await agentRunRestRequest("agentrun runner job", "POST", `/api/v1/runs/${encodeURIComponent(runId)}/runner-jobs`, runnerBody); + } + return { ok: true, command: "agentrun sessions turn", data: { action: "session-turn", aipod, sessionId, profile: String(task.backendProfile ?? ""), run: innerData(run), command: innerData(command), runnerJob: runnerJob ? innerData(runnerJob) : null, valuesPrinted: false }, bridge: runnerJob?.bridge ?? command.bridge ?? run.bridge }; +} + +async function ensureAgentRunSession(sessionId: string, source: Record): Promise { + try { + await agentRunRestRequest("agentrun sessions storage", "GET", `/api/v1/sessions/${encodeURIComponent(sessionId)}/storage`); + return; + } catch { + // Missing storage is recovered by creating the session; other failures will surface on create. + } + await agentRunRestRequest("agentrun sessions create", "POST", "/api/v1/sessions", { + sessionId, + tenantId: source.tenantId ?? "unidesk", + projectId: source.projectId ?? "default", + backendProfile: source.backendProfile ?? "codex", + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), + }); +} + +async function agentRunRestRequest(command: string, method: AgentRunHttpMethod, pathValue: string, body?: unknown): Promise> { + const clientConfig = readAgentRunClientConfig(); + const auth = resolveAgentRunAuth(clientConfig); + const bridgeBase = agentRunRestBridgeMetadata(clientConfig, auth, method, pathValue); + const startedAt = Date.now(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), clientConfig.manager.timeoutMs); + let response: Response; + try { + const headers: Record = { + [clientConfig.auth.header]: `${clientConfig.auth.scheme} ${auth.value}`, + }; + const init: RequestInit = { method, headers, signal: controller.signal }; + if (body !== undefined) { + headers["content-type"] = "application/json"; + init.body = JSON.stringify(body); + } + response = await fetch(new URL(pathValue, clientConfig.manager.baseUrl), init); + } catch (error) { + throw new AgentRunRestError("agentrun-unreachable", `AgentRun server is unreachable for ${method} ${pathValue}: ${error instanceof Error ? error.message : String(error)}`, { bridge: { ...bridgeBase, elapsedMs: Date.now() - startedAt } }); + } finally { + clearTimeout(timeout); + } + const text = await response.text(); + const bridge = { ...bridgeBase, httpStatus: response.status, elapsedMs: Date.now() - startedAt }; + if (text.trim().length === 0) throw new AgentRunRestError("schema-mismatch", `AgentRun server returned an empty response for ${method} ${pathValue}`, { bridge, httpStatus: response.status }); + let envelope: Record; + try { + envelope = record(JSON.parse(text) as unknown); + } catch { + throw new AgentRunRestError("schema-mismatch", `AgentRun server returned non-JSON response for ${method} ${pathValue}`, { bridge, httpStatus: response.status }); + } + if (response.status === 401 || response.status === 403) throw new AgentRunRestError("auth-failed", stringOrNull(envelope.message) ?? "AgentRun API key was rejected", { bridge, httpStatus: response.status, details: safeAgentRunEnvelope(envelope) }); + if (!response.ok) throw new AgentRunRestError(response.status === 404 ? "unsupported-version" : "validation-failed", stringOrNull(envelope.message) ?? `AgentRun request failed with HTTP ${response.status}`, { bridge, httpStatus: response.status, details: safeAgentRunEnvelope(envelope) }); + if (envelope.ok !== true) { + const failureKind = normalizeAgentRunFailureKind(stringOrNull(envelope.failureKind), response.status); + throw new AgentRunRestError(failureKind, stringOrNull(envelope.message) ?? `AgentRun request failed for ${method} ${pathValue}`, { bridge, httpStatus: response.status, details: safeAgentRunEnvelope(envelope) }); + } + return { + ok: true, + command, + data: envelope.data ?? null, + bridge, + }; +} + +function renderAgentRunRestError(command: string, error: AgentRunRestError, options: AgentRunResourceOptions): RenderedCliResult { + const payload = error.toPayload(command); + if (options.raw || options.output === "json" || options.output === "yaml") return renderMachine(command, payload, options.output === "yaml" ? "yaml" : "json", false); + return renderedCliResult(false, command, [ + `Error: ${payload.failureKind}`, + String(payload.message ?? ""), + "", + "Next:", + " Check config/agentrun.yaml manager.baseUrl and the AgentRun API route.", + " Verify HWLAB_API_KEY is present in env or in the configured auth.file without printing the key.", + ].join("\n")); +} + +class AgentRunRestError extends Error { + readonly failureKind: AgentRunFailureKind; + readonly bridge: Record | null; + readonly httpStatus: number | null; + readonly details: Record | null; + + constructor(failureKind: AgentRunFailureKind, message: string, options: { bridge?: Record; httpStatus?: number; details?: Record } = {}) { + super(message); + this.name = "AgentRunRestError"; + this.failureKind = failureKind; + this.bridge = options.bridge ?? null; + this.httpStatus = options.httpStatus ?? null; + this.details = options.details ?? null; + } + + toPayload(command: string): Record { + return { + ok: false, + command, + failureKind: this.failureKind, + message: this.message, + transport: "direct-http", + clientRole: "render-only", + ...(this.httpStatus === null ? {} : { httpStatus: this.httpStatus }), + ...(this.bridge === null ? {} : { bridge: this.bridge }), + ...(this.details === null ? {} : { agentrun: this.details }), + valuesPrinted: false, + }; + } +} + +function readAgentRunClientConfig(env: NodeJS.ProcessEnv = process.env): AgentRunClientConfig { + const configPath = env.AGENTRUN_CLIENT_CONFIG ?? rootPath("config", "agentrun.yaml"); + if (!existsSync(configPath)) throw new AgentRunRestError("validation-failed", `AgentRun client config not found: ${configPath}`); + try { + const parsed = parseAgentRunClientConfigYaml(readFileSync(configPath, "utf8"), configPath); + return { ...parsed, sourcePath: configPath }; + } catch (error) { + if (error instanceof AgentRunRestError) throw error; + throw new AgentRunRestError("validation-failed", `AgentRun client config invalid: ${error instanceof Error ? error.message : String(error)}`, { + bridge: { mode: "direct-http", clientRole: "render-only", configPath }, + }); + } +} + +export function parseAgentRunClientConfigYaml(raw: string, sourcePath = "config/agentrun.yaml"): AgentRunClientConfig { + const input = record(Bun.YAML.parse(raw) as unknown); + const manager = record(input.manager); + const auth = record(input.auth); + const client = record(input.client); + const baseUrl = stringFieldFromRecord(manager, "baseUrl", "manager"); + try { + const parsed = new URL(baseUrl); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("invalid protocol"); + } catch { + throw new Error(`${sourcePath}: manager.baseUrl must be an http(s) URL`); + } + const timeoutMs = numberFieldFromRecord(manager, "timeoutMs", "manager", { min: 1, max: 60_000 }); + const header = stringFieldFromRecord(auth, "header", "auth"); + const scheme = stringFieldFromRecord(auth, "scheme", "auth"); + if (header.toLowerCase() !== "authorization") throw new Error(`${sourcePath}: auth.header must be Authorization`); + if (scheme !== "Bearer") throw new Error(`${sourcePath}: auth.scheme must be Bearer`); + const role = stringFieldFromRecord(client, "role", "client"); + const transport = stringFieldFromRecord(client, "transport", "client"); + if (role !== "render-only") throw new Error(`${sourcePath}: client.role must be render-only`); + if (transport !== "direct-http") throw new Error(`${sourcePath}: client.transport must be direct-http`); + const publicExposure = readAgentRunPublicExposureConfig(input.publicExposure, baseUrl.replace(/\/+$/u, "/"), sourcePath); + return { + sourcePath, + manager: { + baseUrl: baseUrl.replace(/\/+$/u, "/"), + timeoutMs, + }, + auth: { + env: stringFieldFromRecord(auth, "env", "auth"), + file: stringFieldFromRecord(auth, "file", "auth"), + header, + scheme, + }, + client: { + role, + transport, + }, + publicExposure, + }; +} + +function readAgentRunPublicExposureConfig(value: unknown, managerBaseUrl: string, sourcePath: string): AgentRunPublicExposure | null { + if (value === undefined || value === null) return null; + const exposure = record(value); + if (exposure.enabled !== true) return null; + const proxyName = stringFieldFromRecord(exposure, "proxyName", "publicExposure"); + if (!/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/u.test(proxyName)) throw new Error(`${sourcePath}: publicExposure.proxyName must be a DNS label`); + const remotePort = numberFieldFromRecord(exposure, "remotePort", "publicExposure", { min: 1, max: 65_535 }); + const publicBaseUrl = normalizedHttpUrl(stringFieldFromRecord(exposure, "publicBaseUrl", "publicExposure"), `${sourcePath}: publicExposure.publicBaseUrl`); + const masterBaseUrl = normalizedHttpUrl(stringFieldFromRecord(exposure, "masterBaseUrl", "publicExposure"), `${sourcePath}: publicExposure.masterBaseUrl`); + if (!publicBaseUrl.startsWith("https://")) throw new Error(`${sourcePath}: publicExposure.publicBaseUrl must use https://`); + if (publicBaseUrl !== managerBaseUrl) throw new Error(`${sourcePath}: manager.baseUrl must match publicExposure.publicBaseUrl`); + if (!masterBaseUrl.startsWith(`http://127.0.0.1:${remotePort}`)) throw new Error(`${sourcePath}: publicExposure.masterBaseUrl must point to http://127.0.0.1:${remotePort}`); + const masterFrps = record(exposure.masterFrps); + const masterCaddy = record(exposure.masterCaddy); + const caddyEnabled = masterCaddy.enabled === true; + const domain = stringFieldFromRecord(masterCaddy, "domain", "publicExposure.masterCaddy"); + if (!/^[a-z0-9.-]+$/u.test(domain) || !domain.endsWith(".nip.io")) throw new Error(`${sourcePath}: publicExposure.masterCaddy.domain must be a nip.io hostname`); + const caddyUpstreamBaseUrl = normalizedHttpUrl(stringFieldFromRecord(masterCaddy, "upstreamBaseUrl", "publicExposure.masterCaddy"), `${sourcePath}: publicExposure.masterCaddy.upstreamBaseUrl`); + if (caddyUpstreamBaseUrl !== masterBaseUrl) throw new Error(`${sourcePath}: publicExposure.masterCaddy.upstreamBaseUrl must match publicExposure.masterBaseUrl`); + return { + enabled: true, + proxyName, + remotePort, + publicBaseUrl, + masterBaseUrl, + masterFrps: { + configPath: absolutePathField(masterFrps, "configPath", "publicExposure.masterFrps", sourcePath), + containerName: stringFieldFromRecord(masterFrps, "containerName", "publicExposure.masterFrps"), + }, + masterCaddy: { + enabled: caddyEnabled, + domain, + configPath: absolutePathField(masterCaddy, "configPath", "publicExposure.masterCaddy", sourcePath), + serviceName: stringFieldFromRecord(masterCaddy, "serviceName", "publicExposure.masterCaddy"), + upstreamBaseUrl: caddyUpstreamBaseUrl, + responseHeaderTimeoutSeconds: numberFieldFromRecord(masterCaddy, "responseHeaderTimeoutSeconds", "publicExposure.masterCaddy", { min: 1, max: 600 }), + }, + }; +} + +function normalizedHttpUrl(value: string, label: string): string { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error(`${label} must be an http(s) URL`); + } + if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`${label} must be an http(s) URL`); + return value.replace(/\/+$/u, "/"); +} + +function absolutePathField(obj: Record, key: string, pathValue: string, sourcePath: string): string { + const value = stringFieldFromRecord(obj, key, pathValue); + if (!value.startsWith("/")) throw new Error(`${sourcePath}: ${pathValue}.${key} must be an absolute path`); + return value; +} + +export function resolveAgentRunAuth(config: AgentRunClientConfig, env: NodeJS.ProcessEnv = process.env): AgentRunResolvedAuth { + const envValue = env[config.auth.env]?.trim(); + if (envValue) return { source: "env", value: envValue }; + if (existsSync(config.auth.file)) { + const fromFile = readEnvValueFromFile(config.auth.file, config.auth.env); + if (fromFile !== null) return { source: "file", value: fromFile }; + } + throw new AgentRunRestError("auth-missing", `${config.auth.env} is required for direct AgentRun REST access`, { + bridge: { + mode: "direct-http", + clientRole: "render-only", + configPath: config.sourcePath, + auth: { env: config.auth.env, file: config.auth.file, source: "missing", header: config.auth.header, scheme: config.auth.scheme, valuesPrinted: false }, + }, + }); +} + +function readEnvValueFromFile(pathValue: string, envName: string): string | null { + const text = readFileSync(pathValue, "utf8"); + for (const line of text.split(/\r?\n/u)) { + const trimmed = line.trim(); + if (trimmed.length === 0 || trimmed.startsWith("#")) continue; + const normalized = trimmed.startsWith("export ") ? trimmed.slice("export ".length).trim() : trimmed; + const index = normalized.indexOf("="); + if (index <= 0) continue; + if (normalized.slice(0, index).trim() !== envName) continue; + const raw = normalized.slice(index + 1).trim(); + const value = raw.replace(/^['"]|['"]$/gu, ""); + return value.length > 0 ? value : null; + } + return null; +} + +function agentRunRestBridgeMetadata(config: AgentRunClientConfig, auth: AgentRunResolvedAuth, method: AgentRunHttpMethod, pathValue: string): Record { + return { + mode: "direct-http", + clientRole: "render-only", + compatibility: "no-hwlab-runtime-proxy-no-ssh-official-cli", + configPath: config.sourcePath, + baseUrl: config.manager.baseUrl, + request: { method, path: pathValue, timeoutMs: config.manager.timeoutMs }, + auth: { env: config.auth.env, file: config.auth.file, source: auth.source, header: config.auth.header, scheme: config.auth.scheme, valuesPrinted: false }, + }; +} + +function agentRunDryRunPlan(action: string, pathValue: string, body: Record, confirmCommand: string, method: AgentRunHttpMethod = "POST", extra: Record = {}): Record { + return { + ok: true, + action, + dryRun: true, + mutation: false, + transport: "direct-http", + clientRole: "render-only", + request: { + method, + path: pathValue, + bodyKeys: Object.keys(body), + valuesPrinted: false, + }, + ...extra, + next: { confirm: confirmCommand }, + valuesPrinted: false, + }; +} + +function agentRunQuery(args: string[], names: string[]): string { + const params = new URLSearchParams(); + const keyMap: Record = { + "after-seq": "afterSeq", + "backend-profile": "backendProfile", + "command": "commandId", + "command-id": "commandId", + "reader-id": "readerId", + "run-id": "runId", + "updated-after": "updatedAfter", + }; + for (const name of names) { + const value = agentRunOption(args, name); + if (value !== null) params.set(keyMap[name] ?? name, value); + } + const query = params.toString(); + return query.length > 0 ? `?${query}` : ""; +} + +function agentRunOption(args: string[], name: string): string | null { + const flag = `--${name}`; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] ?? ""; + if (arg === flag) { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new AgentRunRestError("validation-failed", `${flag} requires a value`); + return value; + } + if (arg.startsWith(`${flag}=`)) return arg.slice(flag.length + 1); + } + return null; +} + +function requiredAgentRunOption(args: string[], names: string[], message: string): string { + for (const name of names) { + const value = agentRunOption(args, name); + if (value !== null) return value; + } + throw new AgentRunRestError("validation-failed", message); +} + +function agentRunHasFlag(args: string[], name: string): boolean { + const flag = `--${name}`; + return args.includes(flag) || args.some((arg) => arg.startsWith(`${flag}=`)); +} + +async function requiredJsonBody(args: string[], context: string): Promise> { + const body = await optionalJsonBody(args); + if (Object.keys(body).length === 0) throw new AgentRunRestError("validation-failed", `${context} requires --json-stdin or --json-file `); + return body; +} + +async function optionalJsonBody(args: string[]): Promise> { + const raw = readJsonTextFromArgs(args, "json"); + if (raw === null) return {}; + return record(JSON.parse(raw) as unknown); +} + +async function optionalRunnerJsonBody(args: string[]): Promise> { + const raw = readJsonTextFromArgs(args, "runner-json"); + if (raw === null) return {}; + return record(JSON.parse(raw) as unknown); +} + +function readJsonTextFromArgs(args: string[], prefix: "json" | "runner-json"): string | null { + if (agentRunHasFlag(args, `${prefix}-stdin`)) return readFileSync(0, "utf8"); + const file = agentRunOption(args, `${prefix}-file`); + if (file !== null) return file === "-" ? readFileSync(0, "utf8") : readFileSync(file, "utf8"); + return null; +} + +function readYamlInputFromArgs(args: string[]): string { + if (agentRunHasFlag(args, "yaml-stdin")) return readFileSync(0, "utf8"); + const file = agentRunOption(args, "yaml-file"); + if (file !== null) return file === "-" ? readFileSync(0, "utf8") : readFileSync(file, "utf8"); + throw new AgentRunRestError("validation-failed", "aipod-spec YAML input is required; use --yaml-stdin or --yaml-file "); +} + +async function aipodRenderInputFromArgs(args: string[], trailingPromptStart: number, overrides: Record = {}): Promise> { + const input = await optionalJsonBody(args); + const prompt = optionalPromptFromArgs(args, trailingPromptStart); + if (prompt !== null) input.prompt = prompt; + copyAgentRunOptions(args, input, ["tenant-id", "project-id", "queue", "lane", "title", "provider-id", "idempotency-key", "session-id"]); + const profile = agentRunOption(args, "profile") ?? agentRunOption(args, "backend-profile"); + if (profile) input.backendProfile = profile; + const priority = agentRunOption(args, "priority"); + if (priority) input.priority = Number(priority); + const workspaceRef = jsonObjectOption(args, "workspace-json"); + if (workspaceRef !== null) input.workspaceRef = workspaceRef; + return { ...input, ...overrides }; +} + +function readPromptFromArgs(args: string[], trailingStart: number): string { + const prompt = optionalPromptFromArgs(args, trailingStart); + if (prompt === null) throw new AgentRunRestError("validation-failed", "prompt is required; use --prompt-stdin, --prompt-file, --prompt, or a trailing prompt"); + return prompt; +} + +function optionalPromptFromArgs(args: string[], trailingStart: number): string | null { + if (agentRunHasFlag(args, "prompt-stdin") || agentRunHasFlag(args, "stdin")) return readFileSync(0, "utf8"); + const promptFile = agentRunOption(args, "prompt-file"); + if (promptFile !== null) return promptFile === "-" ? readFileSync(0, "utf8") : readFileSync(promptFile, "utf8"); + const prompt = agentRunOption(args, "prompt"); + if (prompt !== null) return prompt; + const trailing = args.slice(trailingStart).filter((arg) => !arg.startsWith("-")); + return trailing.length > 0 ? trailing.join(" ") : null; +} + +function queueDispatchBodyFromArgs(args: string[]): Record { + const body = recordFromMaybeJson(args); + copyAgentRunOptions(args, body, ["idempotency-key", "image", "namespace", "attempt-id", "runner-id", "source-commit", "service-account-name"]); + const managerUrl = agentRunOption(args, "runner-manager-url"); + if (managerUrl) body.managerUrl = managerUrl; + return body; +} + +function recordFromMaybeJson(args: string[]): Record { + const raw = readJsonTextFromArgs(args, "json"); + if (raw === null) return {}; + return record(JSON.parse(raw) as unknown); +} + +function cancelBodyFromArgs(args: string[]): Record { + const reason = agentRunOption(args, "reason"); + return reason === null ? {} : { reason }; +} + +function copyAgentRunOptions(args: string[], target: Record, flagNames: string[]): void { + for (const flagName of flagNames) { + const value = agentRunOption(args, flagName); + if (value === null) continue; + target[camelCaseFlag(flagName)] = value; + } +} + +function camelCaseFlag(flagName: string): string { + return flagName.replace(/-([a-z])/gu, (_, letter: string) => String(letter).toUpperCase()); +} + +function jsonObjectOption(args: string[], flagName: string): Record | null { + const value = agentRunOption(args, flagName); + if (value === null) return null; + return record(JSON.parse(value) as unknown); +} + +function queueSubmitConfirmCommand(args: string[], aipod?: string): string { + const dryRunless = args.filter((arg) => arg !== "--dry-run").join(" "); + return `bun scripts/cli.ts agentrun queue submit${aipod ? ` --aipod ${aipod}` : ""}${dryRunless.length > 0 ? ` ${dryRunless}` : ""}`.trim(); +} + +function jsonInputDisclosureFromArgs(args: string[]): Record { + return { + source: agentRunHasFlag(args, "json-stdin") ? "stdin" : agentRunOption(args, "json-file") ?? "", + valuesPrinted: false, + }; +} + +function newAgentRunSessionId(): string { + return `sess_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; +} + +function defaultAgentRunExecutionPolicy(profile: string): Record { + return { backendProfile: profile, valuesPrinted: false }; +} + +function safeAgentRunEnvelope(envelope: Record): Record { + return pickCompact(envelope, ["ok", "failureKind", "message", "code", "details", "valuesPrinted"]); +} + +function normalizeAgentRunFailureKind(raw: string | null, httpStatus: number): AgentRunFailureKind { + if (raw === "auth-missing" || raw === "auth-failed" || raw === "agentrun-unreachable" || raw === "schema-mismatch" || raw === "unsupported-version" || raw === "validation-failed") return raw; + if (httpStatus === 401 || httpStatus === 403) return "auth-failed"; + if (httpStatus === 404) return "unsupported-version"; + return raw === "schema-invalid" ? "validation-failed" : "validation-failed"; +} + +function stringFieldFromRecord(obj: Record, key: string, pathValue: string): string { + const value = obj[key]; + if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${pathValue}.${key} must be a non-empty string`); + return value.trim(); +} + +function numberFieldFromRecord(obj: Record, key: string, pathValue: string, bounds: { min: number; max: number }): number { + const value = obj[key]; + if (typeof value !== "number" || !Number.isFinite(value) || value < bounds.min || value > bounds.max) throw new Error(`${pathValue}.${key} must be a number between ${bounds.min} and ${bounds.max}`); + return value; +} + +interface AgentRunClientConfig { + sourcePath: string; + manager: { + baseUrl: string; + timeoutMs: number; + }; + auth: { + env: string; + file: string; + header: string; + scheme: string; + }; + client: { + role: string; + transport: string; + }; + publicExposure: AgentRunPublicExposure | null; +} + +interface AgentRunPublicExposure { + enabled: true; + proxyName: string; + remotePort: number; + publicBaseUrl: string; + masterBaseUrl: string; + masterFrps: { + configPath: string; + containerName: string; + }; + masterCaddy: { + enabled: boolean; + domain: string; + configPath: string; + serviceName: string; + upstreamBaseUrl: string; + responseHeaderTimeoutSeconds: number; + }; +} + +interface AgentRunResolvedAuth { + source: "env" | "file"; + value: string; +} + +type AgentRunHttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; +type AgentRunFailureKind = "auth-missing" | "auth-failed" | "agentrun-unreachable" | "schema-mismatch" | "unsupported-version" | "validation-failed"; + +type AgentRunRestCompatGroup = "queue" | "sessions" | "aipod-specs" | "aipods" | "runs" | "commands" | "runner"; type AgentRunResourceVerb = "get" | "describe" | "events" | "logs" | "result" | "ack" | "cancel" | "dispatch" | "create" | "apply" | "steer" | "send" | "explain"; type AgentRunResourceKind = "task" | "run" | "command" | "runnerjob" | "session" | "aipodspec"; type AgentRunOutputMode = "human" | "wide" | "name" | "json" | "yaml"; @@ -2549,237 +3483,6 @@ interface AgentRunResourceOptions { passthroughArgs: string[]; } -async function runOfficialAgentRunCli(config: UniDeskConfig, group: AgentRunOfficialCliBridgeGroup, args: string[]): Promise> { - const prepared = prepareOfficialAgentRunCliArgs([group, ...args]); - return await runPreparedOfficialAgentRunCli(config, prepared); -} - -async function runPreparedOfficialAgentRunCli(config: UniDeskConfig, prepared: PreparedAgentRunCliArgs): Promise> { - const command = `agentrun ${prepared.args.join(" ")}`.trim(); - const script = officialAgentRunCliScript(prepared); - const result = await capture(config, g14SourceRoute, ["script", "--", script]); - const bridge = agentRunQueueBridgeMetadata(prepared.materializedFiles, prepared.stdinPayload, captureBridgeMetadata(result)); - const payload = captureJsonPayload(result); - if (result.exitCode === 0 && Object.keys(payload).length > 0) { - return { - ...rewriteOfficialAgentRunCliDrillDown(payload), - command, - bridge, - }; - } - if (result.exitCode === 0) { - return { - ok: true, - command, - bridge, - stdout: result.stdout.trim(), - remote: compactCapture(result, { full: true, stdoutTailChars: 4000, stderrTailChars: 2000 }), - }; - } - const bridgeFailureKind = bridgeExecutionFailureKind(result); - const agentrunFailureKind = stringOrNull(payload.failureKind) ?? stringOrNull(record(payload.error).failureKind); - return { - ok: false, - command, - degradedReason: bridgeFailureKind === null ? "agentrun-cli-returned-failure" : bridgeFailureKind.degradedReason, - ...(bridgeFailureKind === null ? {} : { failureKind: bridgeFailureKind.failureKind, recoveryActions: bridgeFailureKind.recoveryActions }), - ...(bridgeFailureKind === null && agentrunFailureKind !== null ? { failureKind: agentrunFailureKind } : {}), - bridge, - agentrun: payload, - remote: compactCapture(result, { full: true, stdoutTailChars: 8000, stderrTailChars: 4000 }), - }; -} - -function rewriteOfficialAgentRunCliDrillDown(value: Record): Record { - return rewriteRecordCommands(value); -} - -function rewriteRecordCommands(value: unknown): Record { - return record(rewriteOfficialCommandValue(value)); -} - -function rewriteOfficialCommandValue(value: unknown): unknown { - if (Array.isArray(value)) return value.map((item) => rewriteOfficialCommandValue(item)); - if (typeof value !== "object" || value === null) return value; - const rewritten: Record = {}; - for (const [key, item] of Object.entries(value)) { - rewritten[key] = shouldRewriteOfficialCommandField(key) ? rewriteOfficialCommandFieldValue(item) : rewriteOfficialCommandValue(item); - } - return rewritten; -} - -function rewriteOfficialCommandFieldValue(value: unknown): unknown { - if (typeof value === "string") return rewriteOfficialCommandString(value); - if (Array.isArray(value)) return value.map((item) => rewriteOfficialCommandFieldValue(item)); - if (typeof value !== "object" || value === null) return value; - const rewritten: Record = {}; - for (const [key, item] of Object.entries(value)) { - rewritten[key] = rewriteOfficialCommandFieldValue(item); - } - return rewritten; -} - -function shouldRewriteOfficialCommandField(key: string): boolean { - return key === "pollCommands" || key === "drillDownCommands" || key === "recoveryActions" || key === "nextActions" || key === "logPath"; -} - -function rewriteOfficialCommandString(value: string): string { - const trimmed = value.trim(); - if (trimmed.startsWith("./scripts/agentrun ")) { - return `bun scripts/cli.ts agentrun ${trimmed.slice("./scripts/agentrun ".length)}`; - } - if (trimmed.startsWith("kubectl ") || trimmed.includes(" kubectl ")) { - return "Use bun scripts/cli.ts agentrun runner jobs --run-id --command-id or runner job-status --run-id ; direct kubectl is intentionally hidden behind the AgentRun CLI bridge."; - } - return value; -} - -function prepareOfficialAgentRunCliArgs(args: string[]): PreparedAgentRunCliArgs { - const fileFlags = new Set(["--json-file", "--prompt-file", "--runner-json-file"]); - const remoteTmpDir = `/tmp/unidesk-agentrun-cli-${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2)}`; - const materializedFiles: AgentRunCliMaterializedFile[] = []; - const prepared: string[] = []; - let stdinBuffer: Buffer | null = null; - let stdinPayload: AgentRunCliForwardedStdin | null = null; - - const stdin = (): Buffer => { - if (stdinBuffer === null) stdinBuffer = readFileSync(0); - return stdinBuffer; - }; - - const materialize = (flag: string, source: string, buffer: Buffer): string => { - const remotePath = `${remoteTmpDir}/input-${materializedFiles.length}`; - materializedFiles.push({ - flag, - source, - remotePath, - bytes: buffer.length, - base64: buffer.toString("base64"), - }); - return remotePath; - }; - - const forwardStdin = (flag: string, source: string, buffer: Buffer): void => { - if (stdinPayload !== null) throw new Error(`${flag} cannot be combined with ${stdinPayload.flag}; pass one stdin payload per AgentRun CLI call`); - stdinPayload = { - flag, - source, - bytes: buffer.length, - base64: buffer.toString("base64"), - }; - }; - - const stdinFlagForFileFlag = (flag: string): string | null => { - if (flag === "--json-file") return "--json-stdin"; - if (flag === "--prompt-file") return "--prompt-stdin"; - if (flag === "--runner-json-file") return "--runner-json-stdin"; - return null; - }; - - for (let i = 0; i < args.length; i += 1) { - const arg = args[i] ?? ""; - const equalsFlag = Array.from(fileFlags).find((flag) => arg.startsWith(`${flag}=`)); - if (equalsFlag !== undefined) { - const source = arg.slice(equalsFlag.length + 1); - if (source.length === 0) throw new Error(`${equalsFlag} requires a path`); - const buffer = source === "-" ? stdin() : readFileSync(source); - if (source === "-") { - const stdinFlag = stdinFlagForFileFlag(equalsFlag); - if (!stdinFlag) throw new Error(`${equalsFlag} does not support stdin`); - forwardStdin(stdinFlag, "stdin", buffer); - prepared.push(stdinFlag); - continue; - } - prepared.push(equalsFlag, materialize(equalsFlag, source === "-" ? "stdin" : source, buffer)); - continue; - } - if (fileFlags.has(arg)) { - const source = args[i + 1]; - if (source === undefined || source.length === 0) throw new Error(`${arg} requires a path`); - const buffer = source === "-" ? stdin() : readFileSync(source); - if (source === "-") { - const stdinFlag = stdinFlagForFileFlag(arg); - if (!stdinFlag) throw new Error(`${arg} does not support stdin`); - forwardStdin(stdinFlag, "stdin", buffer); - prepared.push(stdinFlag); - i += 1; - continue; - } - prepared.push(arg, materialize(arg, source === "-" ? "stdin" : source, buffer)); - i += 1; - continue; - } - if (arg === "--prompt-stdin" || arg === "--stdin") { - forwardStdin("--prompt-stdin", "stdin", stdin()); - prepared.push("--prompt-stdin"); - continue; - } - if (arg === "--json-stdin") { - forwardStdin("--json-stdin", "stdin", stdin()); - prepared.push("--json-stdin"); - continue; - } - if (arg === "--runner-json-stdin") { - forwardStdin("--runner-json-stdin", "stdin", stdin()); - prepared.push("--runner-json-stdin"); - continue; - } - prepared.push(arg); - } - - return { args: prepared, materializedFiles, stdinPayload }; -} - -function officialAgentRunCliScript(prepared: PreparedAgentRunCliArgs): string { - const setup = prepared.materializedFiles.length === 0 - ? [] - : [ - `tmp_dir=${shQuote(parentDir(prepared.materializedFiles[0]?.remotePath ?? "/tmp/unidesk-agentrun-cli"))}`, - "mkdir -p \"$tmp_dir\"", - "trap 'rm -rf \"$tmp_dir\"' EXIT", - ...prepared.materializedFiles.map((file) => `printf %s ${shQuote(file.base64)} | base64 -d > ${shQuote(file.remotePath)}`), - ]; - const cliCommand = `./scripts/agentrun --manager-url auto ${prepared.args.map(shQuote).join(" ")}`; - const invocation = prepared.stdinPayload - ? `printf %s ${shQuote(prepared.stdinPayload.base64)} | base64 -d | ${cliCommand}` - : cliCommand; - return [ - "set -euo pipefail", - ...setup, - "cd /root/agentrun-v01", - invocation, - ].join("\n"); -} - -function parentDir(pathValue: string): string { - const index = pathValue.lastIndexOf("/"); - return index > 0 ? pathValue.slice(0, index) : "."; -} - -function agentRunQueueBridgeMetadata(materializedFiles: AgentRunCliMaterializedFile[], stdinPayload: AgentRunCliForwardedStdin | null, captureBridge: Record | null): Record { - return { - route: g14SourceRoute, - sourceWorktree: "/root/agentrun-v01", - sourceBranch, - managerUrl: "auto", - officialCli: "./scripts/agentrun", - mode: "direct-official-cli", - compatibility: "no-code-queue-adapter-no-double-write", - capture: captureBridge, - stdinForwarded: stdinPayload ? { - flag: stdinPayload.flag, - source: stdinPayload.source, - bytes: stdinPayload.bytes, - } : null, - fileFlagsMaterialized: materializedFiles.map((file) => ({ - flag: file.flag, - source: file.source, - remotePath: file.remotePath, - bytes: file.bytes, - })), - }; -} - type AgentRunBridgeCaptureBackend = "local-backend-core-broker" | "remote-frontend-websocket"; interface LocalBackendCoreStatus {