diff --git a/CHANGELOG.md b/CHANGELOG.md index 5454f28..247706a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,33 @@ All notable changes to Agent River will be documented in this file. +## v0.4.0 — 2026-07-06 + +### Added +- Exec envelope is versioned: it now leads with + `agent_river_contract: "exec-v1"` and `message_id`, so external adapters can + rely on a stable, evolvable stdin contract. +- Codex and exec runner replies now parse fenced `agent-dispatch` blocks and + create pending dispatch approvals, matching the opus lane — every lane can + propose cross-agent routing, and every proposal still waits for the owner. +- `telegram-codex-policy-set --direct-send-user-add / --direct-send-user-remove` + restores owner-list management that was accidentally dropped with the v1 + direct-send retirement (fresh installs could not become owner from the CLI). + +### Changed +- Dashboard gate approval buttons are bound to a hash of the task content and + fail closed: buttons whose task changed underneath, and legacy buttons + without a hash, are refused; approving twice answers idempotently. +- README (en/zh-Hant) rewritten for from-zero onboarding: prerequisites, + bot + owner bootstrap in an order that actually works, and a session + walkthrough. SECURITY.md documents the repository trust model (registering a + repo means executing its code) and state-directory retention responsibility. + +### Breaking +- Gate approval buttons sent before this release stop working after upgrade + (fail-closed by design). Re-trigger the pending approval to get a fresh + button. + ## v0.3.0 — 2026-07-06 ### Added diff --git a/README.md b/README.md index 11f546d..b1057d8 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,18 @@ Status: early but usable for a single local operator. The v3 dashboard, v2 launcher, exchange/session ledgers, dispatch approvals, runners, safety gates, and secret scanning are covered by the test suite. -## Quickstart +## Prerequisites + +- Linux with a systemd user session; other platforms are not supported. +- Node.js >= 20. +- The agent CLIs you want to drive, installed and logged in on this machine: + `codex` (Codex CLI) and/or `claude` (Claude Code). Agent River starts them + locally; without at least one of them there is nothing to drive. +- A Telegram bot token: create a bot with [@BotFather](https://t.me/BotFather). +- Your own numeric Telegram user id (for example from + [@userinfobot](https://t.me/userinfobot)) — needed to make yourself the owner. -System requirement: **Linux + systemd (user session); other platforms are not supported.** +## Quickstart ```sh git clone https://github.com/Hsi431/agent-river.git agent-river @@ -24,8 +33,29 @@ npm install node bin/codex-agent.js init ``` -Then fill `~/.config/codex-agent/telegram.env`, reload and enable the generated -user units: +`init` seeds the agent registry, writes the systemd user units, and creates +`~/.config/codex-agent/telegram.env`. Then: + +1. Put the bot token in `~/.config/codex-agent/telegram.env`. + +2. Make yourself the owner and enable routing. Without this step the dashboard + answers every command read-only: + +```sh +node bin/codex-agent.js telegram-codex-policy-set --state ~/.codex/agent \ + --direct-send-user-add \ + --v2-enabled true --workspace-root /home/you --default-repo "$PWD" +``` + +3. Enable the exchange agents that sessions and runners will use: + +```sh +node bin/codex-agent.js agent-enable --state ~/.codex/agent --agent codex --kind coding +node bin/codex-agent.js agent-enable --state ~/.codex/agent --agent opus --kind review +node bin/codex-agent.js telegram-codex-policy-set --state ~/.codex/agent --exchange-runner-enabled true +``` + +4. Reload and enable the generated user units: ```sh systemctl --user daemon-reload @@ -35,12 +65,33 @@ systemctl --user enable --now codex-agent-codex-runner.timer systemctl --user enable --now codex-agent-exec-runner.timer ``` -Start a Telegram session: +5. Message your bot on Telegram. + +### What using it looks like + +A multi-agent session (output abridged): + +```text +you > /session codex,opus -- should we cache the tokenizer? repo=myproj +bot > session opened (codex, opus), budget 20 messages / 20 minutes +bot > [codex] Loading the tokenizer costs ~1.2s per turn; a module-level cache ... +bot > [opus] Agree, but invalidate the cache when the model id changes ... +you > /say settle on the simplest option +bot > [codex] Final: module-level cache keyed by model id. +bot > session closed (budget exhausted), transcript saved +you > /task repo=myproj ← turn the conclusion into an edit task +``` + +A one-shot turn without a session: ```text -/session codex,opus -- your task +@claude repo=myproj -- review the current diff +@codex repo=myproj mode=write -- fix the failing test ``` +Write turns always come back to you as an approval button before anything +runs. + Optional: after `npm link`, the CLI is also available as `agent-river `. ## Core Flows @@ -111,6 +162,14 @@ journalctl --user -u codex-agent-dashboard.service -n 20 Owners are listed in `telegram_codex_policy.direct_send_user_allowlist`; this field is retained as the owner list after the v1 direct-send retirement. +Manage it with: + +```sh +node bin/codex-agent.js telegram-codex-policy-set --state ~/.codex/agent \ + --direct-send-user-add +node bin/codex-agent.js telegram-codex-policy-set --state ~/.codex/agent \ + --direct-send-user-remove +``` Enable v2 routing and a workspace root: diff --git a/README.zh-Hant.md b/README.zh-Hant.md index 66a67bd..f540c61 100644 --- a/README.zh-Hant.md +++ b/README.zh-Hant.md @@ -11,9 +11,17 @@ Codex/Claude worker。Telegram v3 看板負責 owner 操作、v2 agent 啟動、 狀態:早期但可用,適合單一本機 operator。v3 看板、v2 launcher、exchange/session 帳本、 dispatch 核准、runner、安全閘與機密掃描都有測試覆蓋。 -## Quickstart +## 前置需求 + +- Linux + systemd(user session);其他平台未支援。 +- Node.js >= 20。 +- 想指揮的 agent CLI 要先在本機裝好並登入:`codex`(Codex CLI)和/或 + `claude`(Claude Code)。Agent River 是在本機啟動它們,一個都沒有就沒東西可指揮。 +- 一個 Telegram bot token:找 [@BotFather](https://t.me/BotFather) 建。 +- 你自己的 Telegram 數字 user id(可問 [@userinfobot](https://t.me/userinfobot)), + 設 owner 要用。 -系統需求:**Linux + systemd(user session);其他平台未支援**。 +## Quickstart ```sh git clone https://github.com/Hsi431/agent-river.git agent-river @@ -22,7 +30,28 @@ npm install node bin/codex-agent.js init ``` -接著填 `~/.config/codex-agent/telegram.env`,reload 並 enable 產生的 user units: +`init` 會種好 agent registry、寫出 systemd user units、建立 +`~/.config/codex-agent/telegram.env`。接著: + +1. 把 bot token 填進 `~/.config/codex-agent/telegram.env`。 + +2. 把自己設成 owner 並打開路由。跳過這步的話,看板對你的每個指令都只回唯讀: + +```sh +node bin/codex-agent.js telegram-codex-policy-set --state ~/.codex/agent \ + --direct-send-user-add <你的 telegram user id> \ + --v2-enabled true --workspace-root /home/you --default-repo "$PWD" +``` + +3. 啟用 session 和 runner 會用到的 exchange agent: + +```sh +node bin/codex-agent.js agent-enable --state ~/.codex/agent --agent codex --kind coding +node bin/codex-agent.js agent-enable --state ~/.codex/agent --agent opus --kind review +node bin/codex-agent.js telegram-codex-policy-set --state ~/.codex/agent --exchange-runner-enabled true +``` + +4. reload 並 enable 產生的 user units: ```sh systemctl --user daemon-reload @@ -32,12 +61,32 @@ systemctl --user enable --now codex-agent-codex-runner.timer systemctl --user enable --now codex-agent-exec-runner.timer ``` -到 Telegram 開 session: +5. 到 Telegram 跟你的 bot 講話。 + +### 用起來長這樣 + +多 agent session(輸出節錄): + +```text +你 > /session codex,opus -- tokenizer 要不要加快取? repo=myproj +bot > session 已開(codex, opus),預算 20 封 / 20 分鐘 +bot > [codex] tokenizer 每個 turn 要載 ~1.2 秒,module 層級快取可以 ... +bot > [opus] 同意,但 model id 換了要失效 ... +你 > /say 收斂到最簡單的方案 +bot > [codex] 結論:以 model id 為 key 的 module 層級快取。 +bot > session 結束(預算用完),逐字稿已落檔 +你 > /task repo=myproj ← 把結論一鍵轉成 edit task +``` + +不開 session 的單發任務: ```text -/session codex,opus -- 題目 +@claude repo=myproj -- review 目前的 diff +@codex repo=myproj mode=write -- 修掉紅的測試 ``` +會寫檔的 turn 一律先跳核准按鈕給你,按了才跑。 + 可選:跑過 `npm link` 後,也可用 `agent-river `。 ## 核心流程 @@ -103,7 +152,14 @@ journalctl --user -u codex-agent-dashboard.service -n 20 ## Owner 使用方式 Owner 名單存在 `telegram_codex_policy.direct_send_user_allowlist`;v1 direct-send 退役後, -這個欄位保留作 owner list。 +這個欄位保留作 owner list。用這組指令管理: + +```sh +node bin/codex-agent.js telegram-codex-policy-set --state ~/.codex/agent \ + --direct-send-user-add +node bin/codex-agent.js telegram-codex-policy-set --state ~/.codex/agent \ + --direct-send-user-remove +``` 啟用 v2 routing 與 workspace root: diff --git a/SECURITY.md b/SECURITY.md index a65dc5b..889765d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -26,8 +26,22 @@ Agent River is a local, single-operator control plane. It assumes: - one primary bridge/runner set owns an agent state directory. Agent state under `~/.codex/agent` can contain raw task, chat, prompt, reply, -repository, and audit data. Keep it private, restrict filesystem access, and -never commit it. +repository, and audit data. Secret scanning redacts known patterns but does not +guarantee the ledgers are free of sensitive content. Keep the state directory +private, restrict filesystem access, never commit it, and treat backup, +encryption, and retention as the operator's responsibility. + +## Repository Trust + +Pointing Agent River at a repository means its code will be executed: edit +tasks run the repository's test command (e.g. `npm test`), and repository +config (project `CLAUDE.md`, settings, hooks) is inherited by the runners. +This is safe only for repositories you already trust to run locally. + +Do not register untrusted or unreviewed repositories. For code you do not +trust, limit yourself to read-only review without automatic script execution; +Agent River does not sandbox repository-defined commands (OS-level sandboxing +is deferred to Phase 2). ## v2 Trust Model (local parity) diff --git a/package.json b/package.json index 7e76144..5b4e393 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-river", - "version": "0.3.0", + "version": "0.4.0", "description": "Local approval-gated Codex/Telegram agent control plane.", "type": "module", "bin": { diff --git a/src/agent/cli.js b/src/agent/cli.js index a74b9d1..10d6655 100644 --- a/src/agent/cli.js +++ b/src/agent/cli.js @@ -36,7 +36,7 @@ export async function runAgentCli(argv) { const [command, ...rest] = argv; const args = parseArgs(rest); - validateValueOptions(args, ["agent", "budget-messages", "budget-minutes", "capabilities", "channel", "chat-id", "codex-runner-model", "dashboard-chat-id", "days", "default-repo", "dir", "exec", "exec-cwd", "exec-timeout-seconds", "exchange-notify-chat-id", "exchange-notify-enabled", "exchange-notify-max-per-cycle", "exchange-runner-daily-max", "exchange-runner-enabled", "exchange-runner-max-attempts", "exchange-runner-model", "exchange-runner-timeout-seconds", "from", "from-file", "id", "initiator", "interval-seconds", "kind", "lease-seconds", "long-poll-seconds", "max-cycles", "max-runtime-seconds", "memory-enabled", "memory-state", "name", "participants", "repo", "request", "session", "settings", "sleep-seconds", "state", "style", "systemd-dir", "text", "thread", "to", "token-file", "tokens", "topic", "transport", "update-json", "user", "v2-enabled", "workspace-root", "write-access"]); + validateValueOptions(args, ["agent", "budget-messages", "budget-minutes", "capabilities", "channel", "chat-id", "codex-runner-model", "dashboard-chat-id", "days", "default-repo", "dir", "direct-send-user-add", "direct-send-user-remove", "exec", "exec-cwd", "exec-timeout-seconds", "exchange-notify-chat-id", "exchange-notify-enabled", "exchange-notify-max-per-cycle", "exchange-runner-daily-max", "exchange-runner-enabled", "exchange-runner-max-attempts", "exchange-runner-model", "exchange-runner-timeout-seconds", "from", "from-file", "id", "initiator", "interval-seconds", "kind", "lease-seconds", "long-poll-seconds", "max-cycles", "max-runtime-seconds", "memory-enabled", "memory-state", "name", "participants", "repo", "request", "session", "settings", "sleep-seconds", "state", "style", "systemd-dir", "text", "thread", "to", "token-file", "tokens", "topic", "transport", "update-json", "user", "v2-enabled", "workspace-root", "write-access"]); if (args.help) { return printHelp(); } @@ -240,6 +240,8 @@ export async function runAgentCli(argv) { return printResult({ policy: setTelegramCodexPolicy(agentHome, { default_repo: args["default-repo"], + direct_send_user_add: args["direct-send-user-add"], + direct_send_user_remove: args["direct-send-user-remove"], memory_enabled: args["memory-enabled"], exchange_notify_enabled: args["exchange-notify-enabled"], exchange_notify_chat_id: args["exchange-notify-chat-id"], @@ -439,7 +441,7 @@ function printHelp() { telegram-update --update-json '{"message":{"from":{"id":123},"chat":{"id":456},"text":"agent status"}}' telegram-poll [--transport fetch|curl] telegram-codex-policy - telegram-codex-policy-set [--default-repo /path] [--memory-enabled true|false] [--exchange-notify-enabled true|false] [--exchange-notify-chat-id ID] [--exchange-notify-max-per-cycle N] [--exchange-runner-enabled true|false] [--exchange-runner-model sonnet|opus] [--codex-runner-model MODEL] [--exchange-runner-max-attempts N] [--exchange-runner-timeout-seconds N] [--exchange-runner-daily-max N] [--v2-enabled true|false] [--workspace-root /path] + telegram-codex-policy-set [--default-repo /path] [--direct-send-user-add ID] [--direct-send-user-remove ID] [--memory-enabled true|false] [--exchange-notify-enabled true|false] [--exchange-notify-chat-id ID] [--exchange-notify-max-per-cycle N] [--exchange-runner-enabled true|false] [--exchange-runner-model sonnet|opus] [--codex-runner-model MODEL] [--exchange-runner-max-attempts N] [--exchange-runner-timeout-seconds N] [--exchange-runner-daily-max N] [--v2-enabled true|false] [--workspace-root /path] dashboard-once [--transport fetch|curl] [--long-poll-seconds N] [--dashboard-chat-id ID] dashboard-bridge [--transport fetch|curl] [--long-poll-seconds N] [--max-cycles N] [--sleep-seconds N] [--dashboard-chat-id ID] dashboard-service-print [--repo /path] [--long-poll-seconds N] diff --git a/src/agent/codex-exchange-runner.js b/src/agent/codex-exchange-runner.js index 8f9d041..9919086 100644 --- a/src/agent/codex-exchange-runner.js +++ b/src/agent/codex-exchange-runner.js @@ -13,7 +13,7 @@ import { replyExchangeMessage, } from "./exchange.js"; import { isSessionExchangeEligible } from "./sessions.js"; -import { DISPATCH_CHANNEL, dispatchTargetAllowlist } from "./dispatch.js"; +import { createDispatchApproval, DISPATCH_CHANNEL, dispatchTargetAllowlist, parseDispatchProposal } from "./dispatch.js"; import { realCodexRunner } from "./codex-runner.js"; import { resolveMessageRepoBinding } from "./runner-repo.js"; @@ -118,6 +118,18 @@ export async function runCodexExchangeRunnerOnce({ const relay = reply.session_id ? relaySessionReply({ agentHome, message, reply }) : null; + const parsed = parseDispatchProposal(reply.text); + const proposed = parsed.valid + ? createDispatchApproval({ + agentHome, + proposedBy: RUNNER_AGENT, + proposal: parsed.proposal, + parentMsgId: message.id, + parentDispatch: message.dispatch || null, + chatId: message.chat_id || null, + now, + }) + : null; recordCodexDispatch(paths, { messageId: message.id, attempt, outcome: "replied", model, now, repoFallback: repoBinding.repoFallback }); return summary({ ran: true, @@ -128,6 +140,8 @@ export async function runCodexExchangeRunnerOnce({ reply_error: runResult.replyError || null, relay_message_id: relay?.message?.id || null, relay_skipped: relay && !relay.relayed ? relay.reason : null, + dispatch_approval_id: proposed?.approval?.id || null, + dispatch_blocked_reason: proposed?.blocked ? proposed.reason : null, }); } diff --git a/src/agent/dashboard/bot.js b/src/agent/dashboard/bot.js index 0c2b075..dda1ab6 100644 --- a/src/agent/dashboard/bot.js +++ b/src/agent/dashboard/bot.js @@ -4,6 +4,7 @@ import { redactSecrets } from "../../lib/secret-scan.js"; import { agentPaths } from "../paths.js"; import { approveAgentRegistration, rejectAgentRegistration } from "../registry.js"; import { getTelegramCodexPolicy } from "../safety.js"; +import { readTask, taskApprovalHash } from "../tasks.js"; import { approveAgentTask, rejectAgentTask } from "../orchestrator.js"; import { maybeHandleV2, @@ -227,12 +228,25 @@ async function handleDashboardCallback({ agentHome, client, callback }) { }); notice = result.text; } else { - const task = parsed.action === "approve" - ? approveAgentTask({ agentHome, id: parsed.taskId }) - : rejectAgentTask({ agentHome, id: parsed.taskId }); - notice = parsed.action === "approve" - ? `已放行 ${task.id}` - : `已拒絕 ${task.id}`; + if (parsed.action === "approve") { + const task = readTask(agentHome, parsed.taskId); + if (!task) { + throw new Error(`Task not found: ${parsed.taskId}`); + } + if (parsed.hash === null) { + notice = "舊版按鈕已失效,請重新產生核准請求"; + } else if (parsed.hash !== taskApprovalHash(task)) { + notice = "task 內容已變更,請重新檢視"; + } else if (task.approval === "approved") { + notice = "已處理過"; + } else { + const approved = approveAgentTask({ agentHome, id: parsed.taskId }); + notice = `已放行 ${approved.id}`; + } + } else { + const rejected = rejectAgentTask({ agentHome, id: parsed.taskId }); + notice = `已拒絕 ${rejected.id}`; + } } } catch { if (parsed.kind === "join") { @@ -272,9 +286,9 @@ async function answerSafe(client, { callbackQueryId, text }) { function parseDashboardCallback(data) { const raw = String(data || ""); - const gate = raw.match(/^gate:(approve|reject):(task_[A-Za-z0-9_-]+)$/); + const gate = raw.match(/^gate:(approve|reject):(task_[A-Za-z0-9_-]+)(?::([a-f0-9]{8}))?$/); if (gate) { - return { kind: "gate", action: gate[1], taskId: gate[2] }; + return { kind: "gate", action: gate[1], taskId: gate[2], hash: gate[3] || null }; } const join = raw.match(/^join:(approve|reject):([a-z][a-z0-9_-]*)$/); if (join) { diff --git a/src/agent/dashboard/feed.js b/src/agent/dashboard/feed.js index 91e35f3..8aa77f5 100644 --- a/src/agent/dashboard/feed.js +++ b/src/agent/dashboard/feed.js @@ -6,7 +6,7 @@ import { redactSecrets } from "../../lib/secret-scan.js"; import { agentPaths } from "../paths.js"; import { listRegisteredAgents } from "../registry.js"; import { getSession } from "../sessions.js"; -import { listTasks } from "../tasks.js"; +import { listTasks, taskApprovalHash } from "../tasks.js"; const SUMMARY_CHARS = 60; @@ -75,11 +75,12 @@ export function collectDashboardFeed(agentHome, { cursor = loadDashboardCursor(a return { events, cursor: next }; } -export function gateMarkup(taskId) { +export function gateMarkup(task) { + const hash = taskApprovalHash(task); return { inline_keyboard: [[ - { text: "放行", callback_data: `gate:approve:${taskId}` }, - { text: "拒絕", callback_data: `gate:reject:${taskId}` }, + { text: "放行", callback_data: `gate:approve:${task.id}:${hash}` }, + { text: "拒絕", callback_data: `gate:reject:${task.id}:${hash}` }, ]], }; } @@ -198,7 +199,7 @@ function gateEvents(agentHome, cursor) { task_id: task.id, created_at: task.updated_at || task.created_at || "", text: `硬閘 edit task ${task.id} pending repo=${task.repo}\n${oneLine(task.request, SUMMARY_CHARS)}`, - reply_markup: gateMarkup(task.id), + reply_markup: gateMarkup(task), })); } diff --git a/src/agent/exec-runner.js b/src/agent/exec-runner.js index 6c73c54..78693d8 100644 --- a/src/agent/exec-runner.js +++ b/src/agent/exec-runner.js @@ -12,7 +12,7 @@ import { relaySessionReply, replyExchangeMessage, } from "./exchange.js"; -import { DISPATCH_CHANNEL, dispatchTargetAllowlist } from "./dispatch.js"; +import { createDispatchApproval, DISPATCH_CHANNEL, dispatchTargetAllowlist, parseDispatchProposal } from "./dispatch.js"; import { getSession, isSessionExchangeEligible } from "./sessions.js"; import { listRegisteredAgents } from "./registry.js"; import { terminateGroup } from "./v2/kill.js"; @@ -80,6 +80,8 @@ export function pickEligibleExecMessage(agentHome, agentName, { now = Date.now() export function buildExecEnvelope({ agentHome, message, repoPromptLine = null }) { const session = message.session_id ? getSession(agentHome, message.session_id) : null; return `${JSON.stringify({ + agent_river_contract: "exec-v1", + message_id: message.id, sender: String(message.from || ""), session_id: message.session_id || null, session_topic: session?.topic || null, @@ -219,6 +221,18 @@ async function runOneExecAgent({ agentHome, paths, agent, repoDir, spawnImpl, no const relay = reply.session_id ? relaySessionReply({ agentHome, message, reply }) : null; + const parsed = parseDispatchProposal(reply.text); + const proposed = parsed.valid + ? createDispatchApproval({ + agentHome, + proposedBy: agent.name, + proposal: parsed.proposal, + parentMsgId: message.id, + parentDispatch: message.dispatch || null, + chatId: message.chat_id || null, + now, + }) + : null; recordExecDispatch(paths, { agent: agent.name, messageId: message.id, attempt, outcome: "replied", now, repoFallback: repoBinding.repoFallback }); return summary(agent.name, { ran: true, @@ -229,6 +243,8 @@ async function runOneExecAgent({ agentHome, paths, agent, repoDir, spawnImpl, no reply_error: run.replyError || null, relay_message_id: relay?.message?.id || null, relay_skipped: relay && !relay.relayed ? relay.reason : null, + dispatch_approval_id: proposed?.approval?.id || null, + dispatch_blocked_reason: proposed?.blocked ? proposed.reason : null, }); } diff --git a/src/agent/tasks.js b/src/agent/tasks.js index 9114516..8fade7e 100644 --- a/src/agent/tasks.js +++ b/src/agent/tasks.js @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { appendJsonl, readJsonl } from "../lib/jsonl.js"; -import { shortHash } from "../lib/hash.js"; +import { sha256, shortHash } from "../lib/hash.js"; import { agentPaths } from "./paths.js"; export function createTask({ @@ -77,6 +77,10 @@ export function createTask({ return task; } +export function taskApprovalHash(task) { + return sha256(`${task.repo}\n${task.mode}\n${task.request}`).slice(0, 8); +} + export function listTasks(agentHome) { const { tasksDir } = agentPaths(agentHome); if (!fs.existsSync(tasksDir)) { diff --git a/test/agent-dashboard.test.js b/test/agent-dashboard.test.js index 9703b5f..b22c3e1 100644 --- a/test/agent-dashboard.test.js +++ b/test/agent-dashboard.test.js @@ -9,7 +9,7 @@ import { allowGatewayUser, enableExchangeAgent, setTelegramCodexPolicy } from ". import { closeSession, openSession } from "../src/agent/sessions.js"; import { claimExchangeMessage, kickoffSession, replyExchangeMessage, submitExchangeMessage } from "../src/agent/exchange.js"; import { createDispatchApproval } from "../src/agent/dispatch.js"; -import { createTask, listTasks, readTask } from "../src/agent/tasks.js"; +import { createTask, listTasks, readTask, taskApprovalHash, writeTask } from "../src/agent/tasks.js"; import { appendJsonl, readJsonl } from "../src/lib/jsonl.js"; import { acquireDashboardLock, dashboardOnce, releaseDashboardLock } from "../src/agent/dashboard/bot.js"; import { collectDashboardFeed, initializeDashboardCursor } from "../src/agent/dashboard/feed.js"; @@ -64,7 +64,7 @@ test("dashboard feed cursor sends new session, exchange, and gate events once", assert.equal(sentTexts.some((text) => /U2_DASHBOARD_FEED_MESSAGE_TOKEN/.test(text)), true); assert.equal(sentTexts.some((text) => /U2_DASHBOARD_FEED_REPLY_TOKEN/.test(text)), true); assert.equal(sentTexts.some((text) => text.includes(task.id) && /U2_DASHBOARD_GATE_TOKEN/.test(text)), true); - assert.equal(firstCalls.find((call) => call.body.reply_markup)?.body.reply_markup.inline_keyboard[0][0].callback_data, `gate:approve:${task.id}`); + assert.equal(firstCalls.find((call) => call.body.reply_markup)?.body.reply_markup.inline_keyboard[0][0].callback_data, `gate:approve:${task.id}:${taskApprovalHash(task)}`); const secondCalls = []; const second = await dashboardOnce({ @@ -339,7 +339,7 @@ test("session task callback creates a pending codex edit task and next feed gate assert.match(task.request, new RegExp(`完整逐字稿:.*${session.session_id}\\.md`)); assert.match(task.request, new RegExp(`\\[session:${session.session_id}\\]`)); assert.equal(result.feed.some((event) => event.kind === "gate" && event.task_id === task.id), true); - assert.equal(sent.some((body) => body.text.includes(`硬閘 edit task ${task.id}`) && body.reply_markup.inline_keyboard[0][0].callback_data === `gate:approve:${task.id}`), true); + assert.equal(sent.some((body) => body.text.includes(`硬閘 edit task ${task.id}`) && body.reply_markup.inline_keyboard[0][0].callback_data === `gate:approve:${task.id}:${taskApprovalHash(task)}`), true); }); test("dashboard /task handles missing repo, repo override, and duplicate conversion notice", async () => { @@ -658,7 +658,7 @@ test("dashboard gate callbacks approve tasks and reject non-owner callbacks", as token: "test-token", fetchImpl: sequencedTelegramFetch(calls, [[ telegramCallbackUpdate({ updateId: 30, fromId: 999, chatId: 456, data: `gate:approve:${approved.id}` }), - telegramCallbackUpdate({ updateId: 31, fromId: 123, chatId: 456, data: `gate:approve:${approved.id}` }), + telegramCallbackUpdate({ updateId: 31, fromId: 123, chatId: 456, data: `gate:approve:${approved.id}:${taskApprovalHash(approved)}` }), telegramCallbackUpdate({ updateId: 32, fromId: 123, chatId: 456, data: `gate:reject:${rejected.id}` }), ]]), }); @@ -671,6 +671,139 @@ test("dashboard gate callbacks approve tasks and reject non-owner callbacks", as assert.equal(readTask(agentHome, rejected.id).approval, "rejected"); }); +test("dashboard gate approve accepts new hash matching current task content", async () => { + const agentHome = makeAgentHome("u12-dashboard-gate-hash-approve-"); + setTelegramCodexPolicy(agentHome, { direct_send_user_add: "123" }); + const task = createTask({ + agentHome, + repo: process.cwd(), + request: "U12_GATE_HASH_APPROVE_TOKEN edit task.", + mode: "edit", + }); + const calls = []; + + await dashboardOnce({ + agentHome, + token: "test-token", + fetchImpl: sequencedTelegramFetch(calls, [[ + telegramCallbackUpdate({ updateId: 80, fromId: 123, chatId: 456, data: `gate:approve:${task.id}:${taskApprovalHash(task)}` }), + ]]), + }); + const answers = calls.filter((call) => call.method === "answerCallbackQuery").map((call) => call.body.text); + const stored = readTask(agentHome, task.id); + + assert.match(answers[0], /已放行/); + assert.equal(stored.approval, "approved"); +}); + +test("dashboard gate approve rejects a stale hash after task content changes", async () => { + const agentHome = makeAgentHome("u12-dashboard-gate-stale-hash-"); + setTelegramCodexPolicy(agentHome, { direct_send_user_add: "123" }); + const cursor = initializeDashboardCursor(agentHome); + const task = createTask({ + agentHome, + repo: process.cwd(), + request: "U12_GATE_STALE_HASH_TOKEN original edit task.", + mode: "edit", + }); + const feed = collectDashboardFeed(agentHome, { cursor }); + const callbackData = feed.events.find((event) => event.task_id === task.id).reply_markup.inline_keyboard[0][0].callback_data; + const changed = { ...readTask(agentHome, task.id), request: "U12_GATE_STALE_HASH_TOKEN changed edit task." }; + writeTask(agentHome, changed); + const calls = []; + + await dashboardOnce({ + agentHome, + token: "test-token", + fetchImpl: sequencedTelegramFetch(calls, [[ + telegramCallbackUpdate({ updateId: 81, fromId: 123, chatId: 456, data: callbackData }), + ]]), + }); + const answers = calls.filter((call) => call.method === "answerCallbackQuery").map((call) => call.body.text); + const stored = readTask(agentHome, task.id); + + assert.match(answers[0], /內容已變更/); + assert.equal(stored.approval, "pending"); +}); + +test("dashboard gate approve rejects old callbacks without a hash", async () => { + const agentHome = makeAgentHome("u12-dashboard-gate-old-approve-"); + setTelegramCodexPolicy(agentHome, { direct_send_user_add: "123" }); + const task = createTask({ + agentHome, + repo: process.cwd(), + request: "U12_GATE_OLD_APPROVE_TOKEN edit task.", + mode: "edit", + }); + const calls = []; + + await dashboardOnce({ + agentHome, + token: "test-token", + fetchImpl: sequencedTelegramFetch(calls, [[ + telegramCallbackUpdate({ updateId: 82, fromId: 123, chatId: 456, data: `gate:approve:${task.id}` }), + ]]), + }); + const answers = calls.filter((call) => call.method === "answerCallbackQuery").map((call) => call.body.text); + const stored = readTask(agentHome, task.id); + + assert.equal(answers[0], "舊版按鈕已失效,請重新產生核准請求"); + assert.equal(stored.approval, "pending"); +}); + +test("dashboard gate approve reports already-approved tasks without mutating them", async () => { + const agentHome = makeAgentHome("u12-dashboard-gate-approved-again-"); + setTelegramCodexPolicy(agentHome, { direct_send_user_add: "123" }); + const task = createTask({ + agentHome, + repo: process.cwd(), + request: "U12_GATE_APPROVED_AGAIN_TOKEN edit task.", + mode: "edit", + }); + const approved = { ...readTask(agentHome, task.id), approval: "approved" }; + writeTask(agentHome, approved); + const before = readTask(agentHome, task.id); + const calls = []; + + await dashboardOnce({ + agentHome, + token: "test-token", + fetchImpl: sequencedTelegramFetch(calls, [[ + telegramCallbackUpdate({ updateId: 83, fromId: 123, chatId: 456, data: `gate:approve:${task.id}:${taskApprovalHash(before)}` }), + ]]), + }); + const answers = calls.filter((call) => call.method === "answerCallbackQuery").map((call) => call.body.text); + const after = readTask(agentHome, task.id); + + assert.equal(answers[0], "已處理過"); + assert.deepEqual(after, before); +}); + +test("dashboard gate reject still accepts old callbacks without a hash", async () => { + const agentHome = makeAgentHome("u12-dashboard-gate-old-reject-"); + setTelegramCodexPolicy(agentHome, { direct_send_user_add: "123" }); + const task = createTask({ + agentHome, + repo: process.cwd(), + request: "U12_GATE_OLD_REJECT_TOKEN edit task.", + mode: "edit", + }); + const calls = []; + + await dashboardOnce({ + agentHome, + token: "test-token", + fetchImpl: sequencedTelegramFetch(calls, [[ + telegramCallbackUpdate({ updateId: 84, fromId: 123, chatId: 456, data: `gate:reject:${task.id}` }), + ]]), + }); + const answers = calls.filter((call) => call.method === "answerCallbackQuery").map((call) => call.body.text); + const stored = readTask(agentHome, task.id); + + assert.match(answers[0], /已拒絕/); + assert.equal(stored.approval, "rejected"); +}); + test("dashboard cycle flushes v2, exchange, and dispatch notifications from real ledgers", async () => { const agentHome = makeAgentHome("codex-agent-dashboard-flush-"); setTelegramCodexPolicy(agentHome, { diff --git a/test/agent-exec-runner.test.js b/test/agent-exec-runner.test.js index ce2a446..c41f49d 100644 --- a/test/agent-exec-runner.test.js +++ b/test/agent-exec-runner.test.js @@ -223,11 +223,23 @@ test("exec runner envelope warns when session message has no bound repo", () => const envelope = JSON.parse(buildExecEnvelope({ agentHome: makeAgentHome("u12-exec-unbound-envelope-"), message: { + id: "msg_u12_exec_envelope", from: "codex", text: "U12 exec unbound prompt check.", }, })); + assert.deepEqual(Object.keys(envelope), [ + "agent_river_contract", + "message_id", + "sender", + "session_id", + "session_topic", + "repo_status", + "text", + ]); + assert.equal(envelope.agent_river_contract, "exec-v1"); + assert.equal(envelope.message_id, "msg_u12_exec_envelope"); assert.match(envelope.repo_status, /未綁定任何 repo/); }); diff --git a/test/agent-runner-dispatch-proposals.test.js b/test/agent-runner-dispatch-proposals.test.js new file mode 100644 index 0000000..ec90995 --- /dev/null +++ b/test/agent-runner-dispatch-proposals.test.js @@ -0,0 +1,147 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { runCodexExchangeRunnerOnce } from "../src/agent/codex-exchange-runner.js"; +import { DISPATCH_CHANNEL } from "../src/agent/dispatch.js"; +import { submitExchangeMessage } from "../src/agent/exchange.js"; +import { runExecRunnerOnce } from "../src/agent/exec-runner.js"; +import { agentPaths } from "../src/agent/paths.js"; +import { approveAgentRegistration, joinAgentRegistry } from "../src/agent/registry.js"; +import { enableExchangeAgent, setTelegramCodexPolicy } from "../src/agent/safety.js"; +import { readJsonl } from "../src/lib/jsonl.js"; + +test("codex runner creates dispatch approval from a valid reply block", async () => { + const agentHome = makeAgentHome("runner-dispatch-codex-"); + enableExchangeAgent(agentHome, { agentId: "codex", kind: "coding" }); + enableExchangeAgent(agentHome, { agentId: "opus", kind: "review" }); + setTelegramCodexPolicy(agentHome, { exchange_runner_enabled: true }); + const message = submitExchangeMessage({ + agentHome, + from: "opus", + to: "codex", + channel: DISPATCH_CHANNEL, + chatId: "chat_codex_dispatch", + dispatch: { kind: "agent_dispatch", hop: 1, proposed_by: "opus" }, + text: "Ask codex to inspect the follow-up dispatch path.", + }); + const replyText = dispatchReply({ + to: "opus", + task: "Review CODEX_DISPATCH_APPROVAL_TOKEN in the codex reply proposal.", + reason: "Opus should verify the proposal created by the codex lane.", + }); + + const result = await runCodexExchangeRunnerOnce({ + agentHome, + repoDir: process.cwd(), + codexRunnerImpl: async () => ({ ok: true, text: replyText }), + now: 1000, + }); + const approvals = readJsonl(agentPaths(agentHome).dispatchApprovals); + + assert.match(result.dispatch_approval_id, /^dispatch_/); + assert.equal(result.dispatch_blocked_reason, null); + assert.equal(approvals.length, 1); + assert.equal(approvals[0].status, "pending"); + assert.equal(approvals[0].proposed_by, "codex"); + assert.equal(approvals[0].to, "opus"); + assert.equal(approvals[0].parent_msg_id, message.id); + assert.equal(approvals[0].parent_hop, 1); + assert.equal(approvals[0].hop, 2); + assert.equal(approvals[0].chat_id, "chat_codex_dispatch"); + assert.match(approvals[0].task, /CODEX_DISPATCH_APPROVAL_TOKEN/); +}); + +test("exec runner creates dispatch approval from a valid reply block", async () => { + const agentHome = makeAgentHome("runner-dispatch-exec-"); + registerExecAgent(agentHome, "shellbot", printfCommand(dispatchReply({ + to: "codex", + task: "Handle EXEC_DISPATCH_APPROVAL_TOKEN from the exec reply proposal.", + reason: "Codex should receive the exec lane follow-up proposal.", + }))); + const message = submitExchangeMessage({ + agentHome, + from: "codex", + to: "shellbot", + channel: DISPATCH_CHANNEL, + chatId: "chat_exec_dispatch", + dispatch: { kind: "agent_dispatch", hop: 1, proposed_by: "codex" }, + text: "Ask shellbot to answer with a dispatch proposal.", + }); + + const result = await runExecRunnerOnce({ agentHome, repoDir: process.cwd(), now: 2000 }); + const approvals = readJsonl(agentPaths(agentHome).dispatchApprovals); + const record = result.results[0]; + + assert.match(record.dispatch_approval_id, /^dispatch_/); + assert.equal(record.dispatch_blocked_reason, null); + assert.equal(approvals.length, 1); + assert.equal(approvals[0].status, "pending"); + assert.equal(approvals[0].proposed_by, "shellbot"); + assert.equal(approvals[0].to, "codex"); + assert.equal(approvals[0].parent_msg_id, message.id); + assert.equal(approvals[0].parent_hop, 1); + assert.equal(approvals[0].hop, 2); + assert.equal(approvals[0].chat_id, "chat_exec_dispatch"); + assert.match(approvals[0].task, /EXEC_DISPATCH_APPROVAL_TOKEN/); +}); + +test("exec runner without a reply block creates no dispatch approval", async () => { + const agentHome = makeAgentHome("runner-dispatch-none-"); + registerExecAgent(agentHome, "plainbot", printfCommand("Plain reply without a dispatch block.")); + submitExchangeMessage({ + agentHome, + from: "codex", + to: "plainbot", + channel: "telegram", + chatId: "chat_no_dispatch", + text: "Ask plainbot for a normal reply.", + }); + + const result = await runExecRunnerOnce({ agentHome, repoDir: process.cwd(), now: 3000 }); + const record = result.results[0]; + + assert.equal(record.reason, "replied"); + assert.equal(record.dispatch_approval_id, null); + assert.equal(record.dispatch_blocked_reason, null); + assert.equal(readJsonl(agentPaths(agentHome).dispatchApprovals).length, 0); +}); + +function dispatchReply({ to, task, reason, mode = "plan" }) { + return [ + "Reply complete.", + "```agent-dispatch", + JSON.stringify({ to, task, reason, mode }), + "```", + ].join("\n"); +} + +function registerExecAgent(agentHome, name, command) { + joinAgentRegistry({ + agentHome, + name, + style: "exec", + capabilities: "read", + execCommand: command, + execTimeoutSeconds: 5, + now: new Date("2026-07-06T00:00:00.000Z"), + }); + approveAgentRegistration({ + agentHome, + name, + now: new Date("2026-07-06T00:00:01.000Z"), + }); +} + +function printfCommand(text) { + return `printf '%s' ${sh(text)}`; +} + +function makeAgentHome(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function sh(value) { + return `'${String(value).replace(/'/g, "'\\''")}'`; +}