From cfbe5372dbae64d7838401d675ca61e6fdfc4d5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=C3=A1n=20McNamara?= Date: Sat, 7 Nov 2026 07:37:50 +0000 Subject: [PATCH 01/14] fix(claude): pass short permission-hook policy names (#2640) Claude kernel and team launches pass the named policy kernel or team to the PreToolUse hook instead of the base64 policy, shrinking the argument from 988 to 6 characters for kernel seats and from 1,640 to 4 for team seats. The three policy lists move unchanged into a shared CommonJS asset with TypeScript declarations, read by both the launcher and the hook, so every allow and ask decision is unchanged; legacy encoded arguments still decode, and the missing-hook fallback is kept. rig-spec.md documents that named-policy sessions read the installed lists on each later Bash hook call while native inline allowances stay as launched, and that rolling back in place to a pre-name hook gives no hook decision, so the lifecycle asks can disappear under the native Bash(rig:*) allowance until affected Claude seats are relaunched. Contributed by seanmcn; requested in #2639. --- docs/reference/rig-spec.md | 16 ++++++ .../assets/claude-permission-policies.cjs | 41 +++++++++++++++ .../assets/claude-permission-policies.d.cts | 3 ++ .../daemon/assets/claude-team-permissions.cjs | 16 ++++-- .../daemon/src/adapters/kernel-authority.ts | 52 ++++--------------- .../test/claude-team-permissions.test.ts | 26 ++++++++-- 6 files changed, 106 insertions(+), 48 deletions(-) create mode 100644 packages/daemon/assets/claude-permission-policies.cjs create mode 100644 packages/daemon/assets/claude-permission-policies.d.cts diff --git a/docs/reference/rig-spec.md b/docs/reference/rig-spec.md index a589e044a..5161adc15 100644 --- a/docs/reference/rig-spec.md +++ b/docs/reference/rig-spec.md @@ -404,6 +404,22 @@ ask rules; help can prompt in that fallback. A helper that disappears or times o after launch cannot supply a decision, leaving Claude's remaining native rules in effect. The hook is a convenience policy, not a containment boundary. +Claude sessions launched with a named `team` or `kernel` hook policy read the +installed built-in lists on each Bash hook call. Replacing those files at the +same path changes the hook's lists on its next call; native inline allowances +remain as launched. Sessions launched with an encoded policy keep their launch +lists. A session retaining a versioned runtime path continues to use the files +at that path, so installing another version elsewhere does not change its hook. +This does not reload all permission settings in a running session. + +An in-place rollback to a hook from before named policies has a different effect: +that helper cannot read `team` or `kernel` and returns no hook decision. Lifecycle +asks can then disappear under the native `Bash(rig:*)` allowance. Relaunch affected +Claude sessions before relying on those asks after such a rollback. Native +personal, project and managed restrictions are not removed. This applies when +the rollback replaces the hook at the path a running named-policy session uses; +it does not describe every rollback or sessions using a retained versioned path. + Codex keeps `workspace-write` with its existing approval policy and receives the configured OpenRig workspace root plus its pod's shared state directory as additional writable directories. The team workspace preparer creates missing diff --git a/packages/daemon/assets/claude-permission-policies.cjs b/packages/daemon/assets/claude-permission-policies.cjs new file mode 100644 index 000000000..721f9420b --- /dev/null +++ b/packages/daemon/assets/claude-permission-policies.cjs @@ -0,0 +1,41 @@ +"use strict"; + +// Command families used for rig lifecycle, substrate inspection, remote operations and upgrades. +// These are tool allowances, not containment: node/npm/python/ssh can themselves run arbitrary code. +const KERNEL_CLAUDE_ALLOW = [ + "Skill", "Read", "Edit", "Write", "Glob", "Grep", "WebFetch", + ...["rig", "tmux", "node", "npm", "git", "ssh", "scp", "rsync", "curl", + "uname", "ps", "pgrep", "lsof", "df", "du", "ls", "cat", "head", "tail", "rg", "grep", + "find", "stat", "date", "sleep", "mkdir", "cp", "mv", "chmod", "tar", "shasum", + "cd", "pwd", "echo", "printf", "wc", "sort", "python", "python3"] + .map(command => `Bash(${command}:*)`), + "Bash(command -v:*)", + // Read-only provider login checks, so the operator can check a chosen team's provider without a prompt. + "Bash(claude auth status:*)", "Bash(codex login status:*)", + "Read(~/**)", +]; + +// Team defaults are command allowances, not containment. A project's test runner can run code. +// Native deny/ask rules remain in force. The session hook owns generated lifecycle +// asks so that a help invocation is not caught by an unconditional ask prefix. +// Team agents run most rig commands without a prompt. The commands that start, stop or remove teams or +// agents ask, and so do six kept for a person: import (its --instantiate mode launches nodes, and a +// prefix can't tell its modes apart), seat set-permissions and policy apply under both spellings (an +// explicit permission choice stays with a person), setup (its --policy option records one, and a prefix +// can't tell that from ordinary setup), and compact (it replaces a peer's working context). Other rig +// commands (archive, attach, config set, seat set-model and the like) fall under the Bash(rig:*) allowance. +const TEAM_CLAUDE_ALLOW = [ + "Skill", "Read(./**)", "Glob", "Grep", "Bash(rig:*)", + ...["pwd", "ls", "cat", "head", "tail", "rg", "grep", "find"].map(command => `Bash(${command}:*)`), + ...["npm test", "npm run test", "pnpm test", "pnpm run test", "yarn test", "yarn run test", + "bun test", "bun run test", "node --test", "npx vitest", "npx jest", "pytest", "python -m pytest", + "python3 -m pytest", "go test", "cargo test", "make test"].map(command => `Bash(${command}:*)`), +]; +const TEAM_CLAUDE_ASK = [ + "up", "down", "start", "launch", "restore", "fork", "bootstrap", "create", "add", "remove", + "grow", "shrink", "expand", "destroy", "handover", "env down", + "daemon start", "daemon stop", "bundle install", "seat launch", "seat stop", "seat handover", "seat restart", + "import", "seat set-permissions", "policy apply", "policy permissions apply", "setup", "compact", +].map(command => `Bash(rig ${command}:*)`); + +module.exports = { KERNEL_CLAUDE_ALLOW, TEAM_CLAUDE_ALLOW, TEAM_CLAUDE_ASK }; diff --git a/packages/daemon/assets/claude-permission-policies.d.cts b/packages/daemon/assets/claude-permission-policies.d.cts new file mode 100644 index 000000000..2c5572b3c --- /dev/null +++ b/packages/daemon/assets/claude-permission-policies.d.cts @@ -0,0 +1,3 @@ +export const KERNEL_CLAUDE_ALLOW: string[]; +export const TEAM_CLAUDE_ALLOW: string[]; +export const TEAM_CLAUDE_ASK: string[]; diff --git a/packages/daemon/assets/claude-team-permissions.cjs b/packages/daemon/assets/claude-team-permissions.cjs index 8c7095536..bd92e8be0 100644 --- a/packages/daemon/assets/claude-team-permissions.cjs +++ b/packages/daemon/assets/claude-team-permissions.cjs @@ -1,7 +1,7 @@ "use strict"; -// Session-only Claude PreToolUse helper. The launcher supplies the kernel or team -// lists; this file never reads or writes a person's permission settings. +// Session-only Claude PreToolUse helper. The launcher selects the kernel or team +// policy; this file never reads or writes a person's permission settings. // This is command convenience, not containment (test runners can execute code). const { basename, isAbsolute } = require("node:path"); @@ -249,7 +249,17 @@ if (require.main === module) { try { const input = JSON.parse(raw); if (input.tool_name !== "Bash" || typeof input.tool_input?.command !== "string") return; - const policy = JSON.parse(Buffer.from(process.argv[2], "base64").toString("utf8")); + const kind = process.argv[2]; + let policy; + if (kind === "kernel" || kind === "team") { + // Each invocation reads the installed lists; native inline allowances remain as launched. + const { KERNEL_CLAUDE_ALLOW, TEAM_CLAUDE_ALLOW, TEAM_CLAUDE_ASK } = require("./claude-permission-policies.cjs"); + policy = kind === "kernel" ? { allow: KERNEL_CLAUDE_ALLOW, ask: [] } + : { allow: TEAM_CLAUDE_ALLOW, ask: TEAM_CLAUDE_ASK }; + } else { + // Existing running sessions still carry their encoded launch policy. + policy = JSON.parse(Buffer.from(kind, "base64").toString("utf8")); + } const decision = decide(input.tool_input.command, policy); if (decision) process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: decision, diff --git a/packages/daemon/src/adapters/kernel-authority.ts b/packages/daemon/src/adapters/kernel-authority.ts index 66990495d..44dc6effd 100644 --- a/packages/daemon/src/adapters/kernel-authority.ts +++ b/packages/daemon/src/adapters/kernel-authority.ts @@ -7,43 +7,8 @@ import { shellQuote } from "./shell-quote.js"; import { fileURLToPath } from "node:url"; import { existsSync } from "node:fs"; -// Command families used for rig lifecycle, substrate inspection, remote operations and upgrades. -// These are tool allowances, not containment: node/npm/python/ssh can themselves run arbitrary code. -export const KERNEL_CLAUDE_ALLOW = [ - "Skill", "Read", "Edit", "Write", "Glob", "Grep", "WebFetch", - ...["rig", "tmux", "node", "npm", "git", "ssh", "scp", "rsync", "curl", - "uname", "ps", "pgrep", "lsof", "df", "du", "ls", "cat", "head", "tail", "rg", "grep", - "find", "stat", "date", "sleep", "mkdir", "cp", "mv", "chmod", "tar", "shasum", - "cd", "pwd", "echo", "printf", "wc", "sort", "python", "python3"] - .map(command => `Bash(${command}:*)`), - "Bash(command -v:*)", - // Read-only provider login checks, so the operator can check a chosen team's provider without a prompt. - "Bash(claude auth status:*)", "Bash(codex login status:*)", - "Read(~/**)", -]; - -// Team defaults are command allowances, not containment. A project's test runner can run code. -// Native deny/ask rules remain in force. The session hook owns generated lifecycle -// asks so that a help invocation is not caught by an unconditional ask prefix. -// Team agents run most rig commands without a prompt. The commands that start, stop or remove teams or -// agents ask, and so do six kept for a person: import (its --instantiate mode launches nodes, and a -// prefix can't tell its modes apart), seat set-permissions and policy apply under both spellings (an -// explicit permission choice stays with a person), setup (its --policy option records one, and a prefix -// can't tell that from ordinary setup), and compact (it replaces a peer's working context). Other rig -// commands (archive, attach, config set, seat set-model and the like) fall under the Bash(rig:*) allowance. -export const TEAM_CLAUDE_ALLOW = [ - "Skill", "Read(./**)", "Glob", "Grep", "Bash(rig:*)", - ...["pwd", "ls", "cat", "head", "tail", "rg", "grep", "find"].map(command => `Bash(${command}:*)`), - ...["npm test", "npm run test", "pnpm test", "pnpm run test", "yarn test", "yarn run test", - "bun test", "bun run test", "node --test", "npx vitest", "npx jest", "pytest", "python -m pytest", - "python3 -m pytest", "go test", "cargo test", "make test"].map(command => `Bash(${command}:*)`), -]; -export const TEAM_CLAUDE_ASK = [ - "up", "down", "start", "launch", "restore", "fork", "bootstrap", "create", "add", "remove", - "grow", "shrink", "expand", "destroy", "handover", "env down", - "daemon start", "daemon stop", "bundle install", "seat launch", "seat stop", "seat handover", "seat restart", - "import", "seat set-permissions", "policy apply", "policy permissions apply", "setup", "compact", -].map(command => `Bash(rig ${command}:*)`); +import { KERNEL_CLAUDE_ALLOW, TEAM_CLAUDE_ALLOW, TEAM_CLAUDE_ASK } from "../../assets/claude-permission-policies.cjs"; +export { KERNEL_CLAUDE_ALLOW, TEAM_CLAUDE_ALLOW, TEAM_CLAUDE_ASK }; type LaunchChoice = Pick & { /** Whether OpenRig's managed compaction acts on this Claude launch; read from the policy when absent. */ @@ -72,12 +37,14 @@ function withIdleCompactionOff(args: string[], on: boolean): string[] { return [...args.slice(0, at + 1), JSON.stringify({ ...settings, idleCompaction: false }), ...args.slice(at + 2)]; } -function claudeCommandSettings(allow: string[], ask: string[] = []): string[] { +/** Selects a built-in hook policy; keeps native allow/ask settings when the optional hook is absent. */ +function claudeCommandSettings(kind: "kernel" | "team"): string[] { + const allow = kind === "kernel" ? KERNEL_CLAUDE_ALLOW : TEAM_CLAUDE_ALLOW; + const ask = kind === "kernel" ? [] : TEAM_CLAUDE_ASK; const permissions = { allow, ...(ask.length ? { ask } : {}) }; const hook = fileURLToPath(new URL("../../assets/claude-team-permissions.cjs", import.meta.url)); if (!existsSync(hook)) return ["--settings", JSON.stringify({ permissions })]; - const policy = Buffer.from(JSON.stringify({ allow, ask })).toString("base64"); - const command = [process.execPath, hook, policy].map(shellQuote).join(" "); + const command = [process.execPath, hook, kind].map(shellQuote).join(" "); return ["--settings", JSON.stringify({ permissions: { allow }, hooks: { PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command, timeout: 5 }] }] }, @@ -106,10 +73,11 @@ export function operationalLaunchArgs(runtime: string, choice: LaunchChoice): st return choice.kernelAuthority ? [] : nonInterruptiveArgs(runtime, choice); } +/** Applies kernel or eligible team defaults before falling back to the explicit launch posture. */ function claudeOperationalArgs(choice: LaunchChoice): string[] { - if (choice.kernelAuthority) return claudeCommandSettings(KERNEL_CLAUDE_ALLOW); + if (choice.kernelAuthority) return claudeCommandSettings("kernel"); if (choice.teamPermissionDefault && !choice.permissionMode && (!choice.launchPosture || choice.launchPosture === "floor")) { - return claudeCommandSettings(TEAM_CLAUDE_ALLOW, TEAM_CLAUDE_ASK); + return claudeCommandSettings("team"); } return nonInterruptiveArgs("claude-code", choice); } diff --git a/packages/daemon/test/claude-team-permissions.test.ts b/packages/daemon/test/claude-team-permissions.test.ts index fdbd43bb2..cb9b99498 100644 --- a/packages/daemon/test/claude-team-permissions.test.ts +++ b/packages/daemon/test/claude-team-permissions.test.ts @@ -44,6 +44,25 @@ describe("Claude kernel inspection permissions", () => { } }); }); + it.each(["kernel", "team"])("passes only the %s policy name and preserves legacy decisions", kind => { + const settings = JSON.parse(operationalLaunchArgs("claude-code", kind === "kernel" + ? { kernelAuthority: true } : { teamPermissionDefault: true })[1]); + const hook = settings.hooks.PreToolUse[0].hooks[0]; + expect(hook.command).toBe([process.execPath, asset, kind].map(shellQuote).join(" ")); + const legacy = Buffer.from(JSON.stringify(kind === "kernel" + ? { allow: ["Bash(rig:*)"], ask: [] } : { allow: ["Bash(rig:*)"], ask: ["Bash(rig down:*)"] })).toString("base64"); + for (const [command, expected] of [["rig down demo", kind === "kernel" ? "allow" : "ask"], + ["rig down --help", "allow"], ["unknown-command", undefined]]) { + for (const argument of [kind, legacy]) { + const result = spawnSync(process.execPath, [asset, argument], { + input: JSON.stringify({ tool_name: "Bash", tool_input: { command } }), encoding: "utf8", + }); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout ? JSON.parse(result.stdout).hookSpecificOutput.permissionDecision : undefined).toBe(expected); + } + } + }); + it("keeps kernel lifecycle authority distinct from team asks, with no blanket shell grant", () => { const kernel = { allow: KERNEL_CLAUDE_ALLOW, ask: [] }; expect(decide("rig down demo", kernel)).toBe("allow"); @@ -178,12 +197,13 @@ describe("Claude team command permissions", () => { } }); - it("is self-contained under an install path with spaces and quotes", () => { + it.each(["kernel", "team", "legacy"])("loads %s policy under an install path with spaces and quotes", kind => { const dir = mkdtempSync(join(tmpdir(), "claude-hook 'path ")); try { const copy = join(dir, "hook.cjs"); copyFileSync(asset, copy); - const encoded = Buffer.from(JSON.stringify(policy)).toString("base64"); - const result = spawnSync("/bin/sh", ["-c", [process.execPath, copy, encoded].map(shellQuote).join(" ")], { + if (kind !== "legacy") copyFileSync(new URL("../assets/claude-permission-policies.cjs", import.meta.url), join(dir, "claude-permission-policies.cjs")); + const argument = kind === "legacy" ? Buffer.from(JSON.stringify({ allow: ["Bash(rig:*)"], ask: ["Bash(rig down:*)"] })).toString("base64") : kind; + const result = spawnSync("/bin/sh", ["-c", [process.execPath, copy, argument].map(shellQuote).join(" ")], { input: JSON.stringify({ tool_name: "Bash", tool_input: { command: "rig down --help" } }), encoding: "utf8", }); expect(result.status, result.stderr).toBe(0); From 892eb55b5ed3b435d9ff81fdf0529016835f0ed7 Mon Sep 17 00:00:00 2001 From: Mike Schwarz <171890339+mvschwarz@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:48:10 -0800 Subject: [PATCH 02/14] fix(transport): leave a pane's mode through the seat's address (#2644) leavePaneMode passes the seat's session name to cancelPaneMode, as the text and Enter writes do. The guard resolves the seat and writes to its bound pane; the mode reads stay on the bound pane. Before this, the cancel addressed the raw pane ID, and when a stale binding in another rig named the same reused ID, resolveGuardTarget matched two bindings and refused the cancel (guard_target_unknown), so the send failed pane_in_mode with no text sent. A new session-transport cell covers the stale same-pane binding, asserting delivery and submission, the cancel's target, the reads on the pane, and resolveGuardTarget on both forms. Not changed: reconciling stale bindings, and a person scrolling again between leaving the mode and the Enter. --- .../daemon/src/domain/session-transport.ts | 5 ++- .../daemon/test/session-transport.test.ts | 34 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/daemon/src/domain/session-transport.ts b/packages/daemon/src/domain/session-transport.ts index 292cc2ce4..9884657c2 100644 --- a/packages/daemon/src/domain/session-transport.ts +++ b/packages/daemon/src/domain/session-transport.ts @@ -2904,7 +2904,10 @@ export class SessionTransport { let failure = ""; const failed = (message: string) => { failure = ` Leaving it failed: ${message.trim().replace(/\s+/g, " ").replace(/\.$/, "")}.`; }; try { - const left = typeof this.tmuxAdapter.cancelPaneMode === "function" ? await this.tmuxAdapter.cancelPaneMode(readTarget) : undefined; + // Leave through the seat's address, as the text and Enter writes do; the guard writes to its bound pane. A raw + // pane id can match a stale binding elsewhere (tmux reuses ids after a server restart), and the guard refuses an + // ambiguous target before writing anything, so the mode was never left. + const left = typeof this.tmuxAdapter.cancelPaneMode === "function" ? await this.tmuxAdapter.cancelPaneMode(sessionName) : undefined; if (left && !left.ok) failed(left.message); } catch (error) { failed(error instanceof Error ? error.message : String(error)); } if (await readPaneInMode() !== true) return null; diff --git a/packages/daemon/test/session-transport.test.ts b/packages/daemon/test/session-transport.test.ts index 8842eb9bd..70383dd48 100644 --- a/packages/daemon/test/session-transport.test.ts +++ b/packages/daemon/test/session-transport.test.ts @@ -694,6 +694,40 @@ describe("SessionTransport", () => { expect(callOrder).toEqual(["cancelPaneMode", "sendText", "sendKeys:Enter"]); }); + it("leaves the mode through the seat's address when a stale binding elsewhere names the same pane", async () => { + const { node } = seedCanonicalRig(); + sessionRegistry.updateBinding(node.id, { tmuxPane: "%27" }); + // tmux reuses pane ids after a server restart: another rig's binding can still name %27. + const studio = rigRepo.createRig("studio-build"); + const stale = rigRepo.addNode(studio.id, "sdk.impl", { role: "worker", runtime: "claude-code" }); + sessionRegistry.registerSession(stale.id, "sdk-impl@studio-build"); + sessionRegistry.updateBinding(stale.id, { tmuxSession: "sdk-impl@studio-build", tmuxPane: "%27" }); + // The guard can't resolve the raw pane id to one seat, so a write addressed to it is refused before any key. + expect(resolveGuardTarget(db, "%27")).toBeNull(); + expect(resolveGuardTarget(db, "dev-impl@my-rig")).toMatchObject({ nodeId: node.id, pane: "%27" }); + const callOrder: string[] = []; + const tmux = modalPane({ leaves: true, callOrder }); + const modeReads: string[] = []; + let left = false; + tmux.isPaneInMode = vi.fn(async (target: string) => { modeReads.push(target); return !left; }); + const cancelTargets: string[] = []; + // As the adapter's guarded write does: an unresolvable target writes nothing, so the pane stays in its mode. + tmux.cancelPaneMode = vi.fn(async (target: string) => { + cancelTargets.push(target); callOrder.push("cancelPaneMode"); + if (!resolveGuardTarget(db, target)) return { ok: false as const, code: "guard_target_unknown", message: `Cannot establish managed input target ${target}; no input written.` }; + left = true; + return { ok: true as const }; + }); + const transport = createTransport(tmux); + + const result = await transport.send("dev-impl@my-rig", "hello"); + // The person's harm first: the message is delivered and submitted, not refused as pane_in_mode. + expect(result).toMatchObject({ ok: true }); + expect(callOrder).toEqual(["cancelPaneMode", "sendText", "sendKeys:Enter"]); + expect(cancelTargets).toEqual(["dev-impl@my-rig"]); + expect(modeReads.every(target => target === "%27")).toBe(true); + }); + it("leaves the mode before a submitOnly Enter", async () => { seedCanonicalRig(); const callOrder: string[] = []; From b1065870f6761c6fec5d4c6db65f4caf26bf6bab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=C3=A1n=20McNamara?= Date: Sat, 7 Nov 2026 07:52:50 +0000 Subject: [PATCH 03/14] feat(pi): switch accounts and preserve selection through the seat lifecycle (#2630) rig provider switch --account-home and --clear-account-home select or clear an existing Pi seat's account home through a confirmed successor with a fresh conversation and handover; an unconfirmed or failed switch leaves the old account and state intact, and the selection survives restart, resume, restore and daemon restart (migration 151 keeps switch history and creates or copies no credentials). The chosen directory must already hold a readable auth.json, which the runner links into the seat's managed agent directory; env-only accounts and wrappers that create credentials later are outside this path, and an existing seat-local login or unmanaged auth link is preserved through the confirmed switch. Selected accounts load a Node preload that points only their auth-lock operations at the chosen account's auth.json.lock pathname, even when that auth.json is a symlink, and removes its own NODE_OPTIONS entry before Pi starts tools; outside the managed target it does nothing. A Node-hosted launch wrapper that execs Pi can consume the preload, so that Pi gets no shared-auth lock and keeps its native per-seat lock; compiled Pi and wrappers that discard NODE_OPTIONS are likewise not covered. Contributed by seanmcn; requested in #2629. --- docs/reference/rig-spec.md | 29 ++- package-lock.json | 37 +++ packages/cli/src/commands/provider.ts | 5 +- packages/cli/test/provider.test.ts | 11 +- packages/daemon/assets/pi-account-lock.cjs | 39 +++ packages/daemon/package.json | 1 + packages/daemon/src/adapters/pi-resume.ts | 31 ++- .../daemon/src/adapters/pi-runner-protocol.ts | 3 + packages/daemon/src/adapters/pi-runner.ts | 45 ++++ .../daemon/src/adapters/pi-runtime-adapter.ts | 69 ++++-- packages/daemon/src/db/all-migrations.ts | 2 + .../db/migrations/151_pi_account_switches.ts | 45 ++++ .../daemon/src/domain/pi-account-state.ts | 16 ++ packages/daemon/src/domain/rig-repository.ts | 2 +- .../src/domain/runtime-switch-materials.ts | 6 +- .../src/domain/seat-launch-environment.ts | 16 +- .../daemon/src/domain/seat-runtime-home.ts | 11 +- .../src/domain/seat-runtime-switch-service.ts | 65 ++++- .../daemon/src/domain/seat-status-service.ts | 9 +- packages/daemon/src/startup.ts | 12 +- .../daemon/test/account-home-snapshot.test.ts | 32 +++ packages/daemon/test/helpers/test-app.ts | 3 +- .../test/permission-policy-lifecycle.test.ts | 2 +- .../daemon/test/pi-account-launch.test.ts | 224 ++++++++++++++++++ packages/daemon/test/pi-account-lock.test.ts | 91 +++++++ .../test/pi-account-switch-migration.test.ts | 26 ++ .../daemon/test/pi-runner-path-native.test.ts | 10 +- .../daemon/test/pi-runtime-adapter.test.ts | 43 +++- .../test/pi-shell-launch-native.test.ts | 10 +- ...seat-launch-environment-regression.test.ts | 2 +- .../daemon/test/seat-runtime-home.test.ts | 28 ++- .../test/seat-runtime-switch-service.test.ts | 133 ++++++++++- .../daemon/test/seat-status-service.test.ts | 4 +- 33 files changed, 976 insertions(+), 86 deletions(-) create mode 100644 packages/daemon/assets/pi-account-lock.cjs create mode 100644 packages/daemon/src/db/migrations/151_pi_account_switches.ts create mode 100644 packages/daemon/src/domain/pi-account-state.ts create mode 100644 packages/daemon/test/pi-account-launch.test.ts create mode 100644 packages/daemon/test/pi-account-lock.test.ts create mode 100644 packages/daemon/test/pi-account-switch-migration.test.ts diff --git a/docs/reference/rig-spec.md b/docs/reference/rig-spec.md index 5161adc15..c0271d8f0 100644 --- a/docs/reference/rig-spec.md +++ b/docs/reference/rig-spec.md @@ -564,7 +564,7 @@ or local settings. Managed settings aren't inspected. `rig export` writes the se with an account home or an API-key selection (select those again after import), so a later switch shows there, and `rig provider switch --stored-login` removes it. Only `claude-code` members and legacy flat nodes accept it, and there's no rig-level default. -**Switching to an isolated account home.** A running Codex or Claude Code seat can change its native account home +**Switching to an isolated account home.** A running Codex, Claude Code or Pi seat can change its account home while staying in its rig: ```sh @@ -576,7 +576,20 @@ rig seat set-runtime dev-impl@my-rig --runtime claude-code \ The directory must already exist and contain the runtime's login or API settings. You choose the account and budget; OpenRig does not choose or create credentials. `--account-home` sets `CODEX_HOME` for Codex or `CLAUDE_CONFIG_DIR` for -Claude Code before the successor starts. +Claude Code before the successor starts. For Pi, the account directory must contain a readable `auth.json`; OpenRig links +that file into a separate managed successor directory. The existing Pi process keeps its credentials and runner state +until the successor confirms. Pi account changes keep the current model and effort unless explicitly overridden. + +```sh +rig provider switch --seat pi-worker@my-rig --to-account work \ + --account-home /home/me/accounts/pi-work --reason "Use the work account" --dry-run +# Run again without --dry-run to stage the successor and request its confirmation. +rig provider switch --seat pi-worker@my-rig --clear-account-home --reason "Return to configured login" +``` + +Pi account changes work with or without `launch_wrapper`. The wrapper receives the successor's managed Pi directory; +it must preserve the selected credentials if you want OpenRig's account choice to apply. Switching a seat between Pi +and another runtime is not supported by this account-home feature. This switch starts a fresh conversation with a handover, even when the runtime stays the same. The existing subscription-login switch preserves the conversation. Once the successor confirms, OpenRig commits its home, runtime, @@ -920,7 +933,7 @@ members: - **Scope and literal argv:** Supported only on local built-in `runtime: pi` and `runtime: codex` members (and legacy flat `pi`/`codex` nodes); other runtimes (`claude-code`, `omp`, `terminal`, `stub` and community harnesses) reject the field, including `[]`. There is no rig-level, pod-level or fork inheritance: a fork uses its target member's own `launch_wrapper`. Every element must be a string without NUL, CR or LF characters and is quoted literally with `shellQuote`, preserving argument order, spaces, quotes, shell metacharacters and empty non-executable arguments without shell or template expansion. - **Executable resolution and `exec` contract:** `launch_wrapper[0]` must be a non-empty bare command filename on the daemon's launch `PATH` or an absolute path (no relative paths with slashes such as `./x` or `../x`, no `/` in a bare name, and no shell aliases or functions). Preflight and launch resolve `launch_wrapper[0]` to an executable file using the daemon's launch `PATH` and daemon working directory (before the seat's `rig` bin directory is prepended). For `codex`, the prefix appears immediately before the resolved Codex entry (`'' '' …` or `'' …`) inside the public seat environment envelope, preserving the session's TTY stdio. For `pi`, OpenRig's host runner stays host-side and unwrapped (`node …`), preserving its RPC lifecycle, sidecars, and status reporting; the wrapper prefixes the resolved Pi child executable (`pi --mode rpc`) inside that runner, and the child's stdio pipes preserve RPC stdin, stdout, and stderr (not a TTY). The wrapper must prepare its environment and `exec` the target command in place so the child process preserves working directory, child stdio pipes (for Pi) or TTY stdio (for Codex), OpenRig session/identity environment, process lineage, exit status and signals; supervising wrappers that keep a parent process are unsupported. -- **Fail-closed (never unwraps):** When a wrapper is selected, OpenRig applies it across fresh launch, relaunch, rebuild, native resume, fork, restore, handover successor and `pi`↔`codex` runtime switches, and never falls back to bare launch. For `codex`, wrapper resolution and launch environment assembly occur within the shell prefix: if the pane shell is non-POSIX (`nu`, `fish`, etc.), OpenRig session identity is missing or mismatched, `SeatLaunchEnvironment` is unavailable, or `launch_wrapper[0]` is missing or unexecutable, Codex launch fails closed (`OpenRig: launch_wrapper unavailable; no agent started.`). For `pi`, wrapper lookup resolves against the daemon launch `PATH` and daemon working directory before state creation or process dispatch, failing closed before pending launch state if `launch_wrapper[0]` cannot be resolved or executed; runtime child wrapper failures are subsequently reported via the host runner sidecars and markers. If Pi encounters a long launch command (`launch_path_too_long`), it retries execution using the raw host runner command; because that command carries the exact same child wrapper arguments, the Pi child executable never runs unwrapped. In both runtimes, malformed stored wrapper metadata fails closed during reads. +- **Fail-closed (never unwraps):** When a wrapper is selected, OpenRig applies it across fresh launch, relaunch, rebuild, native resume, fork, restore, handover successor and supported runtime switches, and never falls back to bare launch. For `codex`, wrapper resolution and launch environment assembly occur within the shell prefix: if the pane shell is non-POSIX (`nu`, `fish`, etc.), OpenRig session identity is missing or mismatched, `SeatLaunchEnvironment` is unavailable, or `launch_wrapper[0]` is missing or unexecutable, Codex launch fails closed (`OpenRig: launch_wrapper unavailable; no agent started.`). For `pi`, wrapper lookup resolves against the daemon launch `PATH` and daemon working directory before state creation or process dispatch, failing closed before pending launch state if `launch_wrapper[0]` cannot be resolved or executed; runtime child wrapper failures are subsequently reported via the host runner sidecars and markers. If Pi encounters a long launch command (`launch_path_too_long`), it retries execution using the raw host runner command; because that command carries the exact same child wrapper arguments, the Pi child executable never runs unwrapped. In both runtimes, malformed stored wrapper metadata fails closed during reads. - **Persistence, `[]` clear vs absent retain, and recreation:** On a new seat, omitting `launch_wrapper` or declaring `launch_wrapper: []` stores no wrapper (`NULL`). When an authored spec is applied to an existing seat, omitting `launch_wrapper` retains the seat's stored wrapper, `launch_wrapper: []` explicitly clears it, and a non-empty array replaces it. A rig that is recreated or imported, including a replacement import, creates new nodes from the incoming spec and uses only what that spec declares, so omitting the field gives the new seat no wrapper. Stored wrapper definitions remain strictly validated; corrupted wrapper rows fail closed during reads without silent unwrap. Restoring an older snapshot that predates `launch_wrapper` preserves the seat's current stored wrapper. First-start retry (`rig launch `) compares the retained declaration with the node row and refuses to add, change or remove a wrapper on retry. Switching a wrapped seat to an unsupported runtime (`claude-code`, `omp`, etc.) is refused before side effects. There is no live `rig seat` edit verb or management route for `launch_wrapper`; to change or clear a wrapper, edit the `RigSpec` (use `launch_wrapper: []` to clear) and use the supported create/recreate/apply flow. Export (`rig export`) and cached node inventory / `rig ps --nodes --json` (including `--full` and `--fields launchWrapper`) include `launch_wrapper` / `launchWrapper` when configured and omit it when absent. - **Boundary and limits:** A `launch_wrapper` is only an argv prefix. Configuring or observing a prefix is **not** proof of sandbox isolation, tooling availability, memory wiring or governance enforcement; verify the wrapper's own guarantees and the runtime independently. @@ -928,10 +941,14 @@ members: `runtime: pi` launches the Pi coding agent (`pi --mode rpc`) through OpenRig's RPC runner. -- **State:** Each seat uses `$OPENRIG_HOME/state/pi//agent` as its Pi agent directory, with `sessions/` beside it, instead of your default `~/.pi/agent`. The runner sets `PI_CODING_AGENT_DIR` to that directory. `` is the seat's session name, unchanged; for a pod member it is `{podId}-{memberId}@{rigName}`. `$OPENRIG_HOME` defaults to `~/.openrig`. OpenRig creates these directories at launch and does not copy anything from `~/.pi/agent`. +- **State:** Each seat uses `$OPENRIG_HOME/state/pi//agent` as its Pi agent directory, with `sessions/` beside it, instead of your default `~/.pi/agent`. The runner sets `PI_CODING_AGENT_DIR` to that directory. `` is the seat's session name, unchanged; for a pod member it is `{podId}-{memberId}@{rigName}`. `$OPENRIG_HOME` defaults to `~/.openrig`. OpenRig creates these directories at launch and does not copy anything from `~/.pi/agent`. A confirmed account switch moves the seat into an isolated `state/pi/account-switches///` directory; subsequent launches and resumes use that committed directory. - **Thinking level:** A Pi seat's effort (the choice saved with `rig seat set-effort`, else the spec's `effort`) is sent to Pi as `--thinking ` on every managed launch and resume: fresh launch, successor and restore. Pi accepts `off`, `minimal`, `low`, `medium`, `high`, `xhigh` and `max`; OpenRig passes the value as written and does not translate it, so Codex's `none` is not mapped to `off`. Only a value of the shape `set-effort` accepts (a letter, then up to 31 letters, digits, `-` or `_`) is sent; any other spec value, such as one with a space or one that starts with `--`, is left out, and `rig seat effort` says so. An unfamiliar level gets an advisory (a warning from `set-effort`), never a refusal, and Pi itself ignores an unknown level with a warning and keeps the level it would use without the flag: a model suffix, the resumed session's level, or its default. With no effort resolved, no flag is sent. The effort wins over a model suffix, and the model string is never changed. `rig seat effort` reports the level sent at launch and, on Pi 0.99.0 or later, the `thinkingLevel` Pi records on this launch's latest successful assistant turn (error and aborted turns are skipped); that is the session's level after Pi's own cap, not proof the provider applied it, and it is shown apart from the level sent when they differ. On older Pi it is NOT VERIFIED. Without an effort, you can still set the level as a model suffix, for example `model: openrouter/x-ai/grok-4:high`. Pi first looks for a model whose id is the whole pin, and uses it when there is one, suffix included. Only when there is none does Pi read the suffix as the level and record the model without it. Any other suffix, such as OpenRouter's `:free`, stays part of the model id. The model-divergence check reads the pin literally when the seat's `models.json` defines a model with that exact id; otherwise it accepts the model with or without the suffix. If the seat's `models.json` can't be read, a pin with a suffix stays unchecked, with the usual warning, when the recorded model matches the whole pin or the pin without its suffix. A record that matches neither is reported. -- **Custom models:** Pi reads `models.json` from its agent directory, so a seat reads `$OPENRIG_HOME/state/pi//agent/models.json`, not `~/.pi/agent/models.json`. Custom provider and model definitions for a seat go in that file. A seat that uses only providers Pi already includes does not need one. OpenRig does not create or write `models.json`. -- **Credentials:** The Pi process receives only `PATH`, `HOME`, `USER`, `LOGNAME`, `SSH_AUTH_SOCK`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY` or `MINIMAX_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/`, `zai/`, `kimi-coding/` or `minimax/`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable. +- **Custom models:** Pi reads `models.json` from its agent directory, so a seat reads `$OPENRIG_HOME/state/pi//agent/models.json`, not `~/.pi/agent/models.json`. Custom provider and model definitions for a seat go in that file (or the corresponding committed account-switch directory after a switch). A seat that uses only providers Pi already includes does not need one. OpenRig does not create or write `models.json`. +- **Credentials:** The Pi process receives only `PATH`, `HOME`, `USER`, `LOGNAME`, `SSH_AUTH_SOCK`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `NODE_OPTIONS` for the selected-account Node preload, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY` or `MINIMAX_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/`, `zai/`, `kimi-coding/` or `minimax/`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable. + +- **Account homes:** Select an account with `rig provider switch --account-home`, or declare an absolute `PI_CODING_AGENT_DIR` in the seat’s `env:` as its configured account home; the persisted selection takes precedence. `~` is not expanded. A relative value is ignored for managed account selection (and reported by status), leaving the managed seat login in use when no persisted account is selected. The chosen directory must already contain a readable `auth.json`; env-only credentials and wrappers that create credentials later are outside this prepared-account path. Launches with neither a persisted selection nor a declared absolute account home retain their existing credential behaviour. The runner links that file into its managed agent directory before starting Pi, including launches through `launch_wrapper`; settings, models, extensions and sessions remain seat-local. Selected accounts use a Node preload shim that redirects only their auth-lock filesystem operations to the chosen account’s `auth.json.lock` pathname, matching direct Pi use even when the source `auth.json` is a symlink. The preload removes its own `NODE_OPTIONS` entry before Pi starts tools, preserving other Node options; descendants and unrelated agent directories keep their normal filesystem behaviour. This requires a Node-hosted Pi executable and a wrapper that preserves `NODE_OPTIONS`; compiled executables and wrappers that discard the preload are not covered. Removing the selection removes only the managed auth link, not the source account file. A missing account file stops the launch. Direct launch refuses to replace an existing seat-local login or an unmanaged auth link; use a confirmed account switch to preserve that login. An ambient pane-shell `PI_CODING_AGENT_DIR` does not select an account; only the resolved seat configuration or persisted account selection does. Persisted account selections survive resume and cannot be bypassed by the long-command fallback; explicit selections clear inherited provider-key overrides. Use `rig provider switch --account-home` or `--clear-account-home` to change an existing Pi seat through a confirmed successor with a fresh conversation and handover. The account reference and committed state directory survive daemon restart and resume; unconfirmed successors leave the old account and state intact. Clearing returns to the configured home, or the original managed seat login if no home was configured. Status reports a configured account home when present; without one, its home is null because Pi uses the managed seat directory rather than the operator's default login. + +**Upgrade note for prepared accounts:** An existing built-in Pi seat that declares an absolute `PI_CODING_AGENT_DIR` now selects that home on fresh launch, restart, resume and restore, even without a stored account override. Before upgrading, either prepare a readable `auth.json` there or remove that declaration to keep the managed seat login. Env-only accounts and wrappers that create the file after Pi starts are not supported by this path. Migration 151 preserves switch history; it does not create or copy credentials, or rewrite existing seat declarations. Relative values remain available to community harnesses and are not globally removed. - **Extension dialogs:** The RPC runner shows extension `select`, `confirm`, `input` and `editor` requests in the pane and cancels them because the managed pane cannot answer them. The seat needs attention through the end of the turn, until a new agent run starts. Review the cancelled request and send an instruction the extension can complete without a dialog. Extension `notify` text is shown without raising attention or sending a response. diff --git a/package-lock.json b/package-lock.json index 5846c9dec..2934b34be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4260,6 +4260,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/graphlib": { "version": "2.1.8", "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz", @@ -5230,6 +5237,18 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, "node_modules/proxy-addr": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", @@ -5531,6 +5550,16 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -5839,6 +5868,13 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, "node_modules/smol-toml": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", @@ -7317,6 +7353,7 @@ "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.0.0", "@types/tar": "^6.1.13", + "proper-lockfile": "^4.1.2", "tsx": "^4.19.0", "typescript": "^5.7.0", "vitest": "^3.0.0" diff --git a/packages/cli/src/commands/provider.ts b/packages/cli/src/commands/provider.ts index 105b776c1..d8fa0910f 100644 --- a/packages/cli/src/commands/provider.ts +++ b/packages/cli/src/commands/provider.ts @@ -603,6 +603,9 @@ API-key approval before the first turn. OpenRig does not enforce API budgets or Codex can select a saved API-key auth profile with --to-account just like a subscription profile. Its shared-home effects and native credential-store requirements are unchanged. No fresh account home or conversation is created. Do not combine --api-key-file with --account-home or --clear-account-home; select a home and switch credentials separately. +Pi seats support --account-home with an existing readable auth.json, or --clear-account-home, through a fresh successor +with a handover. Their current model and effort are retained unless overridden. Pi account changes work with or without +a launch_wrapper; changing a seat between Pi and another runtime is not supported. --clear-account-home returns to the configured member/rig/daemon/native home through a fresh successor with a handover. Use it without --to-account, --stored-login, --account-home or --token-file. It removes an account-home token reference only after confirmation; it does not restore historical login bytes. Anthropic active/default profiles live outside @@ -638,7 +641,7 @@ CLAUDE_CONFIG_DIR and are not isolated by an account home; check them before rel try { const status = await client.get<{ runtime?: string }>(`/api/seat/status/${encodeURIComponent(opts.seat)}`); const runtime = status.data.runtime; - if (status.status >= 400 || (runtime !== "codex" && runtime !== "claude-code")) { + if (status.status >= 400 || (runtime !== "codex" && runtime !== "claude-code" && runtime !== "pi")) { const refusal = { ok: false, code: "account_home_preflight_failed", message: "The seat runtime could not be read; no switch was requested." }; if (opts.json) console.log(JSON.stringify(refusal)); else console.error(refusal.message); process.exitCode = 1; return; diff --git a/packages/cli/test/provider.test.ts b/packages/cli/test/provider.test.ts index 21384f39a..bfa555729 100644 --- a/packages/cli/test/provider.test.ts +++ b/packages/cli/test/provider.test.ts @@ -90,6 +90,15 @@ describe("rig provider CLI grammar (daemon-backed)", () => { expect(seen.length).toBe(before); }); + it.each([false, true])("preflights and submits a Pi account change (clear=%s)", async clear => { + const before = seen.length; + const result = await run(["switch", "--seat", "pi-seat@rig", ...(clear ? ["--clear-account-home"] : ["--to-account", "work", "--account-home", "/accounts/pi"]), "--reason", "Pi budget", "--json"], runningDeps(port)); + expect(result.exitCode ?? 0).toBe(0); + const calls = seen.slice(before); + expect(JSON.parse(calls.find(c => c.url.startsWith("/api/seat/set-runtime/"))!.body)).toMatchObject({ runtime: "pi", dryRun: true }); + expect(JSON.parse(calls.find(c => c.url === "/api/provider/switch")!.body)).toMatchObject(clear ? { clearAccountHome: true } : { accountHome: "/accounts/pi" }); + }); + it("switches to a selected account home without sending credential values", async () => { const result = await run(["switch", "--seat", "dev-a@rig", "--to-account", "api-account", "--account-home", "/accounts/api", "--reason", "Plan limit", "--model", "gpt-5.5", "--json"], runningDeps(port)); @@ -195,7 +204,7 @@ describe("rig provider CLI grammar (daemon-backed)", () => { seen.push({ url: req.url ?? "", method: req.method ?? "", body }); const send = (code: number, obj: unknown) => { res.writeHead(code, { "Content-Type": "application/json" }); res.end(JSON.stringify(obj)); }; const path = (req.url ?? "").split("?")[0]; - if (path?.startsWith("/api/seat/status/")) return send(200, { runtime: "codex" }); + if (path?.startsWith("/api/seat/status/")) return send(200, { runtime: path.includes("pi-seat") ? "pi" : "codex" }); if (path?.startsWith("/api/seat/set-runtime/")) { const request = JSON.parse(body); if (path.includes("old-daemon")) return send(200, { ok: true, dryRun: true }); diff --git a/packages/daemon/assets/pi-account-lock.cjs b/packages/daemon/assets/pi-account-lock.cjs new file mode 100644 index 000000000..f5b421d95 --- /dev/null +++ b/packages/daemon/assets/pi-account-lock.cjs @@ -0,0 +1,39 @@ +// Pi uses proper-lockfile with realpath:false. Redirect only the managed auth +// link's lock operations, so seat aliases and the account owner use one lock. +const fs = require('node:fs'); +const path = require('node:path'); +const { syncBuiltinESMExports } = require('node:module'); +// The runner injects this exact option. Consume only our preload before Pi can +// launch tools, preserving any other Node options supplied by its wrapper. +const preload = `--require=${JSON.stringify(__filename)}`; +if (process.env.NODE_OPTIONS) { + const remaining = process.env.NODE_OPTIONS.replace(preload, '').trim(); + if (remaining) process.env.NODE_OPTIONS = remaining; + else delete process.env.NODE_OPTIONS; +} +let localLock, sharedLock; +try { + const agentDir = process.env.PI_CODING_AGENT_DIR; + if (!agentDir) return; + const auth = path.join(agentDir, 'auth.json'); + const account = fs.readFileSync(path.join(agentDir, '.openrig-account'), 'utf8'); + const source = path.resolve(path.dirname(auth), fs.readlinkSync(auth)); + if (source !== path.resolve(account, 'auth.json')) return; + localLock = path.resolve(auth + '.lock'); + // Match direct Pi's realpath:false pathname, even when the source is a link. + sharedLock = source + '.lock'; +} catch { + // Preparation validates selected credentials. Unrelated processes are untouched. + return; +} +/** Map only the managed auth lock to the shared target; leave all other filesystem paths unchanged. */ +function lockPath(value) { + return typeof value === 'string' && path.resolve(value) === localLock ? sharedLock : value; +} +for (const name of ['mkdir', 'rmdir', 'stat', 'utimes']) { + for (const method of [name, name + 'Sync']) { + const original = fs[method]; + fs[method] = function (file, ...args) { return original.call(this, lockPath(file), ...args); }; + } +} +syncBuiltinESMExports(); diff --git a/packages/daemon/package.json b/packages/daemon/package.json index e5d67a52a..f3b4716f3 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -115,6 +115,7 @@ "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.0.0", "@types/tar": "^6.1.13", + "proper-lockfile": "^4.1.2", "tsx": "^4.19.0", "typescript": "^5.7.0", "vitest": "^3.0.0" diff --git a/packages/daemon/src/adapters/pi-resume.ts b/packages/daemon/src/adapters/pi-resume.ts index 97cb3a6cc..95f2eca30 100644 --- a/packages/daemon/src/adapters/pi-resume.ts +++ b/packages/daemon/src/adapters/pi-resume.ts @@ -29,6 +29,9 @@ export interface PiResumeFsOps { } interface PiResumeOptions { + /** Resolve the persisted/configured account without relying on live tmux metadata. */ + accountHomeForSession?: (session: string) => string | undefined; + stateRootForSession?: (session: string) => string; seatLaunchEnvironment?: SeatLaunchEnvironment; pollMs?: number; maxWaitMs?: number; @@ -93,7 +96,7 @@ export class PiResumeAdapter { } } - const seat = piSeatPaths(this.paths.stateRoot, tmuxSessionName); + const seat = piSeatPaths(this.options.stateRootForSession?.(tmuxSessionName) ?? this.paths.stateRoot, tmuxSessionName); this.fs.mkdirp(seat.agentDir); this.fs.mkdirp(seat.sessionsDir); @@ -114,11 +117,23 @@ export class PiResumeAdapter { const appliedLaunch = this.runtime === "omp" ? observeOmpApprovalMode(`--approval-mode ${trust === "approve" ? "yolo" : "always-ask"}`) : observePiResourceTrust(trust); + let nodeId: string | undefined; + if (this.runtime === "pi" && this.options.seatLaunchEnvironment) { + try { nodeId = await this.tmux.getSessionEnv(tmuxSessionName, "OPENRIG_NODE_ID") ?? undefined; } + catch { /* Account-free resume retains best-effort identity correction. */ } + } + const accountHome = this.runtime === "pi" ? this.options.accountHomeForSession?.(tmuxSessionName) + ?? (nodeId ? this.options.seatLaunchEnvironment?.accountHome(nodeId, this.runtime) : undefined) : undefined; + if (!nodeId && this.runtime === "pi" && (accountHome || this.fs.exists(`${seat.agentDir}/.openrig-account`))) { + return { ok: false, code: "resume_failed", message: "Pi resume needs seat identity to preserve its selected account; restore identity and retry resume." }; + } + if (!nodeId && this.runtime === "pi" && this.options.seatLaunchEnvironment) console.warn("[pi-resume] Seat identity unavailable; account-free resume continues with best-effort routing."); const cmd = buildPiRunnerCommand({ + accountHome, runtime: this.runtime, runnerEntryPath: this.paths.runnerEntryPath, sessionName: tmuxSessionName, - stateRoot: this.paths.stateRoot, + stateRoot: this.options.stateRootForSession?.(tmuxSessionName) ?? this.paths.stateRoot, cwd, // The model declaration must survive resume: the runner's provider-key // allowlist keys off the declared provider (VM leg finding). @@ -134,14 +149,14 @@ export class PiResumeAdapter { ...(resolvedWrapper ? { launchWrapper: resolvedWrapper } : {}), }); + const fallback = cmd; const command = this.runtime === "pi" && this.options.seatLaunchEnvironment - ? await this.options.seatLaunchEnvironment.command(tmuxSessionName, cmd, { runtime: this.runtime }) - : cmd; + ? await this.options.seatLaunchEnvironment.command(tmuxSessionName, cmd, { runtime: this.runtime, nodeId, accountHome }) + : fallback; // Short commands retain the direct path; long commands exec from a private script. let textResult = await this.tmux.sendShellCommand(tmuxSessionName, command, undefined, { stageIfLong: true, execInScript: true }); - // This refusal precedes any input: retain the bare-command fallback when routing cannot fit. - if (!textResult.ok && textResult.code === "launch_path_too_long" && command !== cmd) { - textResult = await this.tmux.sendShellCommand(tmuxSessionName, cmd, undefined, { stageIfLong: true, execInScript: true }); + if (!textResult.ok && textResult.code === "launch_path_too_long" && command !== fallback && accountHome === undefined) { + textResult = await this.tmux.sendShellCommand(tmuxSessionName, fallback, undefined, { stageIfLong: true, execInScript: true }); } if (!textResult.ok) { return { ok: false, code: "resume_failed", message: textResult.message }; @@ -162,7 +177,7 @@ export class PiResumeAdapter { const attempts = Math.max(1, Math.floor(maxWaitMs / Math.max(pollMs, 1)) + 1); for (let attempt = 0; attempt < attempts; attempt++) { - const { runnerStatePath } = piSeatPaths(this.paths.stateRoot, tmuxSessionName); + const { runnerStatePath } = piSeatPaths(this.options.stateRootForSession?.(tmuxSessionName) ?? this.paths.stateRoot, tmuxSessionName); const state = this.fs.exists(runnerStatePath) ? parsePiRunnerState(this.readSafe(runnerStatePath)) : null; if (state?.launchId === launchId) { diff --git a/packages/daemon/src/adapters/pi-runner-protocol.ts b/packages/daemon/src/adapters/pi-runner-protocol.ts index 094c0742f..aed03f3c0 100644 --- a/packages/daemon/src/adapters/pi-runner-protocol.ts +++ b/packages/daemon/src/adapters/pi-runner-protocol.ts @@ -242,6 +242,8 @@ export function buildPiChildEnv( // ── Command construction ───────────────────────────────────────────────────── export interface PiRunnerLaunchOpts { + /** Explicit prepared account, never inferred from the shell environment. */ + accountHome?: string; /** Explicit runtime; omitted keeps the original Pi command byte-for-byte. */ runtime?: RunnerRuntime; /** Absolute path to the compiled runner entry (daemon dist). */ @@ -313,6 +315,7 @@ export function buildPiRunnerCommand(opts: PiRunnerLaunchOpts): string { parts.push("--usage-facts"); if (opts.openRouterCharge) parts.push("--openrouter-charge"); } + if (opts.accountHome && opts.runtime !== "omp") parts.push("--account-home", shellQuote(opts.accountHome)); if (opts.launchWrapper?.length && opts.runtime !== "omp") { for (const arg of opts.launchWrapper) { parts.push("--launch-wrapper-arg", shellQuote(arg)); diff --git a/packages/daemon/src/adapters/pi-runner.ts b/packages/daemon/src/adapters/pi-runner.ts index aaa485c6a..b84481399 100644 --- a/packages/daemon/src/adapters/pi-runner.ts +++ b/packages/daemon/src/adapters/pi-runner.ts @@ -38,6 +38,34 @@ import { type PiRunnerState, type RunnerRuntime, } from "./pi-runner-protocol.js"; +/** Bind the selected account's credentials while retaining the seat's own Pi resources. */ +export function preparePiAccount(agentDir: string, accountHome: string | undefined): void { + fs.mkdirSync(agentDir, { recursive: true, mode: 0o700 }); + const marker = nodePath.join(agentDir, ".openrig-account"); + const auth = nodePath.join(agentDir, "auth.json"); + // Validate every explicit home, including the seat's own directory, before changing any link. + if (accountHome) { + const source = nodePath.join(accountHome, "auth.json"); + fs.accessSync(source, fs.constants.R_OK); + if (!fs.statSync(source).isFile()) throw new Error("Selected Pi auth must be a file"); + } + if (accountHome && fs.realpathSync(accountHome) !== fs.realpathSync(agentDir)) { + const source = nodePath.join(accountHome, "auth.json"); + const current = fs.lstatSync(auth, { throwIfNoEntry: false }); + if (current && (!current.isSymbolicLink() || !fs.existsSync(marker))) { + throw new Error("Existing Pi login is not a managed account link; use a confirmed account switch"); + } + fs.rmSync(auth, { force: true }); + // Record ownership before linking so a crash between these writes is retryable. + fs.writeFileSync(marker, accountHome, { mode: 0o600 }); + fs.symlinkSync(source, auth); + } else if (!accountHome && fs.existsSync(marker)) { + // A Pi login may have replaced the link since it was installed. + if (fs.lstatSync(auth, { throwIfNoEntry: false })?.isSymbolicLink()) fs.rmSync(auth); + fs.rmSync(marker); + } +} + // ── Submitted input boundaries ───────────────────────────────────────────── // Canonical TTY buffers can overflow before Node sees even the paste terminator. // Use Node's line editor in raw mode, with only paste framing handled here. @@ -974,6 +1002,7 @@ export class RunnerCore { // ── CLI entry ──────────────────────────────────────────────────────────────── interface RunnerArgs { + accountHome?: string; runtime?: RunnerRuntime; sessionName: string; stateRoot: string; @@ -1011,6 +1040,7 @@ export function parseRunnerArgs(argv: string[]): RunnerArgs { args.runtime = runtime; break; } + case "--account-home": args.accountHome = next(); break; case "--state-root": args.stateRoot = next(); break; case "--cwd": args.cwd = next(); break; case "--launch-id": args.launchId = next(); break; @@ -1048,6 +1078,9 @@ export function parseRunnerArgs(argv: string[]): RunnerArgs { throw new Error("an explicit trust flag is required: --approve or --no-approve"); } if (args.sessionFile && args.forkRef) throw new Error("--session and --fork are mutually exclusive"); + if (args.accountHome !== undefined && ((args.runtime ?? "pi") !== "pi" || !nodePath.isAbsolute(args.accountHome) || /[\0\r\n]/.test(args.accountHome))) { + throw new Error("--account-home requires an absolute Pi account directory"); + } if (args.launchWrapper) { if (args.runtime === "omp") { throw new Error("--launch-wrapper-arg is only supported for the Pi runtime"); @@ -1229,6 +1262,14 @@ export async function main(argv = process.argv.slice(2)): Promise { const paths = piSeatPaths(args.stateRoot, args.sessionName); fs.mkdirSync(paths.agentDir, { recursive: true }); fs.mkdirSync(paths.sessionsDir, { recursive: true }); + if (runtime === "pi") { + try { preparePiAccount(paths.agentDir, args.accountHome); } + catch { + console.error(`${PI_RUNNER_ERROR_MARKER} Pi account preparation failed for ${JSON.stringify(nodePath.join(args.accountHome ?? paths.agentDir, "auth.json"))}; provide a readable prepared account file and keep any existing seat login intact, then retry the account operation.`); + process.exitCode = 2; + return; + } + } const { catchUpSince } = prepareRunnerSidecar( { @@ -1253,6 +1294,10 @@ export async function main(argv = process.argv.slice(2)): Promise { openrigHome: process.env.OPENRIG_HOME, openrigUrl: endpoint?.baseUrl ?? process.env.OPENRIG_URL, }); + if (runtime === "pi" && args.accountHome && fs.lstatSync(nodePath.join(paths.agentDir, "auth.json"), { throwIfNoEntry: false })?.isSymbolicLink()) { + // Only selected accounts load the Node compatibility shim; unrelated launches are unchanged. + childEnv.NODE_OPTIONS = `--require=${JSON.stringify(nodePath.resolve(import.meta.dirname, "../../assets/pi-account-lock.cjs"))}`; + } const childArgs = buildPiChildArgs({ sessionsDir: paths.sessionsDir, sessionName: args.sessionName, diff --git a/packages/daemon/src/adapters/pi-runtime-adapter.ts b/packages/daemon/src/adapters/pi-runtime-adapter.ts index fca5e9c47..8f670b08b 100644 --- a/packages/daemon/src/adapters/pi-runtime-adapter.ts +++ b/packages/daemon/src/adapters/pi-runtime-adapter.ts @@ -1,3 +1,4 @@ +import { piAccountSwitchStateRoot } from "../domain/pi-account-state.js"; // OPR.0.4.6.PI1 — the Pi runtime adapter (RPC-first, runner-in-a-pane). // // The adapter launches the OpenRig-owned pi-runner inside the seat's normal @@ -53,6 +54,7 @@ export interface PiRuntimeAdapterDeps { /** Root under which every Pi seat gets its isolated state dir (FR-7). * Typically /state/pi. */ stateRoot: string; + stateRootForSession?: (session: string) => string; /** Absolute path to the compiled pi-runner entry in the daemon dist. */ runnerEntryPath: string; /** Trust posture for managed launches (BR-5 — always explicit; ambient @@ -78,6 +80,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter { private seatLaunchEnvironment?: SeatLaunchEnvironment; private fs: PiAdapterFsOps; protected stateRoot: string; + private stateRootForSession: (session: string) => string; private runnerEntryPath: string; private trustPosture: "approve" | "no-approve"; private sleep: (ms: number) => Promise; @@ -94,18 +97,33 @@ export class PiRuntimeAdapter implements RuntimeAdapter { this.seatLaunchEnvironment = deps.seatLaunchEnvironment; this.fs = deps.fsOps; this.stateRoot = deps.stateRoot; + this.stateRootForSession = deps.stateRootForSession ?? (() => this.stateRoot); this.runnerEntryPath = deps.runnerEntryPath; this.trustPosture = deps.trustPosture ?? "no-approve"; this.sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); this.newLaunchId = deps.newLaunchId ?? (() => randomUUID()); } + /** Resolve canonical identity and isolated state for a staged or committed occupant. */ + private runnerLocation(binding: NodeBinding): { sessionName: string; stateRoot: string } { + const staged = this.runtime === "pi" && binding.runtimeSwitchFrom; + const sessionName = staged ? staged.session : binding.tmuxSession!; + return { sessionName, stateRoot: staged && binding.launchGeneration + ? piAccountSwitchStateRoot(this.stateRoot, binding.launchGeneration) : this.stateRootForSession(sessionName) }; + } + + /** Resolve files in the occupant’s state directory rather than its temporary staging pane. */ + private bindingPaths(binding: NodeBinding) { + const { sessionName, stateRoot } = this.runnerLocation(binding); + return piSeatPaths(stateRoot, sessionName); + } + /** The pi-runner sidecar reader shape resume-token-capture consumes * (deriveResumeToken's piRunnerStateStore dep). */ readSessionFile(sessionName: string): { ok: true; sessionFile: string } | { ok: false; reason: string } { const state = this.readRunnerState(sessionName); if (state === null) { - const { runnerStatePath } = piSeatPaths(this.stateRoot, sessionName); + const { runnerStatePath } = piSeatPaths(this.stateRootForSession(sessionName), sessionName); return this.fs.exists(runnerStatePath) ? { ok: false, reason: "parse_error" } : { ok: false, reason: "missing_sidecar" }; @@ -123,9 +141,9 @@ export class PiRuntimeAdapter implements RuntimeAdapter { readRunnerSidecar(sessionName: string): { state: PiRunnerState | null; sessionsDir: string; entryPath: string; stateRoot: string } { return { state: this.readRunnerState(sessionName), - sessionsDir: piSeatPaths(this.stateRoot, sessionName).sessionsDir, + sessionsDir: piSeatPaths(this.stateRootForSession(sessionName), sessionName).sessionsDir, entryPath: this.runnerEntryPath, - stateRoot: this.stateRoot, + stateRoot: this.stateRootForSession(sessionName), }; } @@ -133,7 +151,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter { const results: InstalledResource[] = []; const sessionName = binding.tmuxSession; if (!sessionName) return results; - const { agentDir } = piSeatPaths(this.stateRoot, sessionName); + const { agentDir } = this.bindingPaths(binding); const skillsDir = nodePath.join(agentDir, "skills"); if (this.fs.exists(skillsDir) && this.fs.listFiles) { for (const file of this.fs.listFiles(skillsDir)) { @@ -146,7 +164,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter { /** Conflict detection must inspect this seat's copy, not a Claude sibling. */ skillTargetPath(tmuxSession: string | null, effectiveId: string): string | null { if (!tmuxSession) return null; - return nodePath.join(piSeatPaths(this.stateRoot, tmuxSession).agentDir, "skills", effectiveId, "SKILL.md"); + return nodePath.join(piSeatPaths(this.stateRootForSession(tmuxSession), tmuxSession).agentDir, "skills", effectiveId, "SKILL.md"); } async project(plan: ProjectionPlan, binding: NodeBinding): Promise { @@ -201,7 +219,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter { } case "skill_install": { if (!binding.tmuxSession) throw new Error("No tmux session bound — cannot resolve the Pi seat state dir"); - const { agentDir } = piSeatPaths(this.stateRoot, binding.tmuxSession); + const { agentDir } = this.bindingPaths(binding); const targetDir = nodePath.join(agentDir, "skills", nodePath.basename(nodePath.dirname(file.absolutePath))); this.fs.mkdirp(targetDir); this.fs.writeFile(nodePath.join(targetDir, nodePath.basename(file.path)), content); @@ -282,7 +300,8 @@ export class PiRuntimeAdapter implements RuntimeAdapter { } const sessionName = binding.tmuxSession; - const paths = piSeatPaths(this.stateRoot, sessionName); + const location = this.runnerLocation(binding); + const paths = this.bindingPaths(binding); this.fs.mkdirp(paths.agentDir); this.fs.mkdirp(paths.sessionsDir); @@ -292,7 +311,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter { // The prior record is read FIRST so the durable catch-up cursor // (lastEntryId, FR-5) survives the reset. const launchId = this.newLaunchId(); - const prior = this.readRunnerState(sessionName); + const prior = this.readRunnerState(location.sessionName, location.stateRoot); this.fs.writeFile( paths.runnerStatePath, JSON.stringify(buildPendingRunnerState(launchId, new Date().toISOString(), prior)), @@ -304,11 +323,16 @@ export class PiRuntimeAdapter implements RuntimeAdapter { const appliedLaunch = this.runtime === "omp" ? observeOmpApprovalMode(`--approval-mode ${trust === "approve" ? "yolo" : "always-ask"}`) : observePiResourceTrust(trust); + const accountHome = binding.accountHome !== undefined ? binding.accountHome + : binding.nodeId ? this.seatLaunchEnvironment?.accountHome(binding.nodeId, this.runtime) : undefined; + const preparedHome = accountHome === null && binding.nodeId + ? this.seatLaunchEnvironment?.accountHome(binding.nodeId, this.runtime, true) : accountHome; const cmd = buildPiRunnerCommand({ + accountHome: preparedHome ?? undefined, runtime: this.runtime, runnerEntryPath: this.runnerEntryPath, - sessionName, - stateRoot: this.stateRoot, + sessionName: location.sessionName, + stateRoot: location.stateRoot, cwd: binding.cwd, model: binding.model, // The seat's resolved effort, sent as Pi's --thinking; OMP unchanged. @@ -324,14 +348,14 @@ export class PiRuntimeAdapter implements RuntimeAdapter { ...(resolvedWrapper ? { launchWrapper: resolvedWrapper } : {}), }); + const fallback = cmd; const command = this.runtime === "pi" && this.seatLaunchEnvironment - ? await this.seatLaunchEnvironment.command(sessionName, cmd, { nodeId: binding.nodeId, generation: binding.launchGeneration, runtime: this.runtime }) - : cmd; + ? await this.seatLaunchEnvironment.command(sessionName, cmd, { nodeId: binding.nodeId, generation: binding.launchGeneration, runtime: this.runtime, accountHome }) + : fallback; // Stage long commands without leaving a shell above the live runner. let textResult = await this.tmux.sendShellCommand(sessionName, command, undefined, { stageIfLong: true, execInScript: true }); - // This refusal precedes any input: retain the bare-command fallback when routing cannot fit. - if (!textResult.ok && textResult.code === "launch_path_too_long" && command !== cmd) { - textResult = await this.tmux.sendShellCommand(sessionName, cmd, undefined, { stageIfLong: true, execInScript: true }); + if (!textResult.ok && textResult.code === "launch_path_too_long" && command !== fallback && accountHome === undefined) { + textResult = await this.tmux.sendShellCommand(sessionName, fallback, undefined, { stageIfLong: true, execInScript: true }); } if (!textResult.ok) { return { ok: false, error: `Failed to send launch command: ${textResult.message}` }; @@ -339,7 +363,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter { // The runner writes the sidecar after its first successful get_state; // that sidecar (not pane content) is the token source of truth (FR-5). - const state = await this.waitForRunnerReady(sessionName, launchId); + const state = await this.waitForRunnerReady(location.sessionName, launchId, location.stateRoot); if (!state.ok) return state.failure; const sessionFile = state.value.sessionFile; @@ -383,7 +407,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter { // make a stopped seat ready). const paneCommand = (await this.tmux.getPaneCommand(binding.tmuxSession)) ?? ""; const atShell = SHELL_COMMANDS.has(paneCommand); - const state = this.readRunnerState(binding.tmuxSession); + const state = this.readRunnerState(this.runnerLocation(binding).sessionName, this.runnerLocation(binding).stateRoot); if (state?.exited) { return { ready: false, reason: `${this.runtime}-runner exited (code ${state.exited.code ?? "unknown"})`, code: "runner_exited" }; } @@ -414,8 +438,8 @@ export class PiRuntimeAdapter implements RuntimeAdapter { // ── internals ────────────────────────────────────────────────────────────── - private readRunnerState(sessionName: string): PiRunnerState | null { - const { runnerStatePath } = piSeatPaths(this.stateRoot, sessionName); + private readRunnerState(sessionName: string, stateRoot = this.stateRootForSession(sessionName)): PiRunnerState | null { + const { runnerStatePath } = piSeatPaths(stateRoot, sessionName); if (!this.fs.exists(runnerStatePath)) return null; try { return parsePiRunnerState(this.fs.readFile(runnerStatePath)); @@ -427,11 +451,12 @@ export class PiRuntimeAdapter implements RuntimeAdapter { private async waitForRunnerReady( sessionName: string, launchId: string, + stateRoot = this.stateRootForSession(sessionName), ): Promise<{ ok: true; value: PiRunnerState } | { ok: false; failure: HarnessLaunchResult }> { const pollMs = 250; const attempts = 60; // ~15s: runner boot + pi spawn + first get_state for (let attempt = 0; attempt < attempts; attempt++) { - const state = this.readRunnerState(sessionName); + const state = this.readRunnerState(sessionName, stateRoot); // Launch-attempt scoping: only THIS attempt's sidecar states count. // Stale ready/exited records from prior instances are ignored, and // stale pane markers in scrollback are never consulted here — the @@ -470,7 +495,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter { * (a person's own Pi home, a shared file) would read this rig's guidance. */ guidanceFileFor(binding: NodeBinding): { file: string | null; note?: string } { if (!binding.tmuxSession) return { file: null }; - const file = nodePath.join(piSeatPaths(this.stateRoot, binding.tmuxSession).agentDir, "AGENTS.md"); + const file = nodePath.join(this.bindingPaths(binding).agentDir, "AGENTS.md"); const linked = guidanceFileSharedByLink(file, (path) => this.fs.linkInfo?.(path) ?? null); return linked ? { file: null, note: `${this.runtime}: ${linked} is a link, so this seat's managed guidance stays in its repository AGENTS.md` } : { file }; } @@ -496,7 +521,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter { if (entry.category === "skill") { if (!binding.tmuxSession) return false; - const { agentDir } = piSeatPaths(this.stateRoot, binding.tmuxSession); + const { agentDir } = this.bindingPaths(binding); const targetDir = nodePath.join(agentDir, "skills", entry.effectiveId); this.fs.mkdirp(targetDir); let isDir = false; diff --git a/packages/daemon/src/db/all-migrations.ts b/packages/daemon/src/db/all-migrations.ts index c6d290344..11a6669ab 100644 --- a/packages/daemon/src/db/all-migrations.ts +++ b/packages/daemon/src/db/all-migrations.ts @@ -1,3 +1,4 @@ +import { piAccountSwitchesSchema } from "./migrations/151_pi_account_switches.js"; import { activityGrantsSchema } from "./migrations/143_activity_grants.js"; import { environmentBindingsSchema } from "./migrations/144_environment_bindings.js"; import { nodePermissionsToSchema } from "./migrations/145_node_permissions_to.js"; @@ -303,4 +304,5 @@ export const ALL_MIGRATIONS: Migration[] = [ outboxDispatchedIndexSchema, streamIntakeCursorSchema, nodeLaunchWrapperSchema, + piAccountSwitchesSchema, ]; diff --git a/packages/daemon/src/db/migrations/151_pi_account_switches.ts b/packages/daemon/src/db/migrations/151_pi_account_switches.ts new file mode 100644 index 000000000..8b6902a62 --- /dev/null +++ b/packages/daemon/src/db/migrations/151_pi_account_switches.ts @@ -0,0 +1,45 @@ +import type { Migration } from "../migrate.js"; + +/** Widen runtime switches to Pi while retaining history and interrupted cutovers. */ +export const piAccountSwitchesSchema: Migration = { + name: "151_pi_account_switches.sql", + sql: ` + CREATE TABLE seat_runtime_switches_pi ( + id TEXT PRIMARY KEY, + node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, + canonical_session TEXT NOT NULL, + staging_session TEXT NOT NULL UNIQUE, + predecessor_pane TEXT NOT NULL, + successor_pane TEXT, + predecessor_generation TEXT NOT NULL, + successor_generation TEXT NOT NULL UNIQUE, + from_runtime TEXT NOT NULL CHECK (from_runtime IN ('codex', 'claude-code', 'pi')), + to_runtime TEXT NOT NULL CHECK (to_runtime IN ('codex', 'claude-code', 'pi')), + phase TEXT NOT NULL DEFAULT 'preparing' + CHECK (phase IN ('preparing', 'confirmed', 'committed', 'finished', 'abandoned')), + reason TEXT NOT NULL, + requested_by TEXT, + plan_json TEXT NOT NULL, + confirmation_note TEXT, + confirmed_at TEXT, + successor_resume_type TEXT, + successor_resume_token TEXT, + committed_at TEXT, + -- When the switch should have finished (its successor's start plus the confirmation timeout); a waiting send says so. + deadline_at TEXT NOT NULL, + outcome TEXT, + attention TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + CHECK ((phase IN ('committed', 'finished')) = (committed_at IS NOT NULL)) + ); + + INSERT INTO seat_runtime_switches_pi SELECT * FROM seat_runtime_switches; + DROP TABLE seat_runtime_switches; + ALTER TABLE seat_runtime_switches_pi RENAME TO seat_runtime_switches; + CREATE UNIQUE INDEX idx_seat_runtime_switches_open ON seat_runtime_switches(node_id) + WHERE phase IN ('preparing', 'confirmed', 'committed'); + CREATE INDEX idx_seat_runtime_switches_predecessor ON seat_runtime_switches(node_id, predecessor_generation) + WHERE committed_at IS NOT NULL; + `, +}; diff --git a/packages/daemon/src/domain/pi-account-state.ts b/packages/daemon/src/domain/pi-account-state.ts new file mode 100644 index 000000000..4b6a02ce4 --- /dev/null +++ b/packages/daemon/src/domain/pi-account-state.ts @@ -0,0 +1,16 @@ +import path from "node:path"; +import type Database from "better-sqlite3"; + +/** A staged Pi occupant must not change the current occupant's credentials or sidecar. */ +export function piAccountSwitchStateRoot(root: string, generation: string): string { + return path.join(root, "account-switches", generation); +} + +/** The committed switch ledger keeps the chosen state directory stable across daemon restarts. */ +export function piAccountStateRoot(db: Database.Database, root: string, session: string): string { + const row = db.prepare(`SELECT s.successor_generation FROM seat_runtime_switches s + JOIN bindings b ON b.node_id = s.node_id JOIN nodes n ON n.id = s.node_id + WHERE b.tmux_session = ? AND n.runtime = 'pi' AND s.to_runtime = 'pi' AND s.committed_at IS NOT NULL + ORDER BY s.rowid DESC LIMIT 1`).get(session) as { successor_generation: string } | undefined; + return row ? piAccountSwitchStateRoot(root, row.successor_generation) : root; +} diff --git a/packages/daemon/src/domain/rig-repository.ts b/packages/daemon/src/domain/rig-repository.ts index e85a70ca1..ade57e5be 100644 --- a/packages/daemon/src/domain/rig-repository.ts +++ b/packages/daemon/src/domain/rig-repository.ts @@ -469,7 +469,7 @@ export class RigRepository { .get(nodeId) as { rig_id: string; env_json: string | null } | undefined; if (!row) return {}; const env = withoutIgnoredRuntimeHomes(mergeSeatEnv(this.getRigEnv(row.rig_id), parseStoredSeatEnv(row.env_json))); - for (const runtime of ignoreAccountHome ? [] : ["codex", "claude-code"] as const) { + for (const runtime of ignoreAccountHome ? [] : ["codex", "claude-code", "pi"] as const) { const accountHome = seatAccountHome(this.db, nodeId, runtime); if (accountHome) env[RUNTIME_HOME_VAR[runtime]] = accountHome; } diff --git a/packages/daemon/src/domain/runtime-switch-materials.ts b/packages/daemon/src/domain/runtime-switch-materials.ts index 458f42fd2..b8c08f610 100644 --- a/packages/daemon/src/domain/runtime-switch-materials.ts +++ b/packages/daemon/src/domain/runtime-switch-materials.ts @@ -16,7 +16,7 @@ import { seatSkillsPointerStartupFile } from "./seat-skills-pointer.js"; import { readSeatEffort } from "./seat-effort.js"; import type { ClaudeManagedBlockFile } from "./managed-blocks.js"; -export type SwitchRuntime = "codex" | "claude-code"; +export type SwitchRuntime = "codex" | "claude-code" | "pi"; /** Where an effective target setting came from. `native_default` means OpenRig passes no flag. */ export type RuntimeSwitchSettingSource = "explicit" | "agent_spec" | "native_default"; @@ -133,7 +133,7 @@ export function planRuntimeSwitchMaterials(input: RuntimeSwitchMaterialsInput): // Saved startup files are runtime-neutral except the seat-skills pointer and a Codex seat's catalog; those are rebuilt // for the target. Managed guidance the target projects isn't also delivered as a file (the launch's own rule). - const sourceSeat = node.runtime === "codex" || node.runtime === "claude-code" + const sourceSeat = node.runtime === "codex" || node.runtime === "claude-code" || node.runtime === "pi" ? seatSkillPaths({ rigId: node.rigId, logicalId: node.logicalId, runtime: node.runtime }, input.openrigHome) : undefined; const pointer = seatSkillsPointerStartupFile(); const projectedGuidance = new Set(plan.entries @@ -186,7 +186,7 @@ export function planRuntimeSwitchMaterials(input: RuntimeSwitchMaterialsInput): async prepare(binding, adapter) { const notes: string[] = []; if (runtime === "claude-code") notes.push(...ensureSeatSkillPlugin(seatSkills)); - if (skillLoadout) { + if (skillLoadout && runtime !== "pi") { const reconcile = (selected: SkillLoadout) => reconcileSkillLoadout({ loadout: selected, runtime, cwd, apply: true, topologyOwner: input.canonicalSession, location: seatSkillLocation(seatSkills, runtime) }); let projection = reconcile(skillLoadout); diff --git a/packages/daemon/src/domain/seat-launch-environment.ts b/packages/daemon/src/domain/seat-launch-environment.ts index d17c2fa27..b4c091663 100644 --- a/packages/daemon/src/domain/seat-launch-environment.ts +++ b/packages/daemon/src/domain/seat-launch-environment.ts @@ -1,4 +1,4 @@ -import { ACCOUNT_HOME_PROVIDER_ENV, RUNTIME_HOME_VAR, type HomeRuntime } from "./seat-runtime-home.js"; +import { ACCOUNT_HOME_PROVIDER_ENV, PI_ACCOUNT_HOME_PROVIDER_ENV, RUNTIME_HOME_VAR, type HomeRuntime } from "./seat-runtime-home.js"; import { isLaunchWrapperRuntime, validateLaunchWrapper } from "./launch-wrapper.js"; import { accessSync, closeSync, constants, mkdirSync, openSync, readFileSync, readdirSync, readlinkSync, readSync, realpathSync, statSync, symlinkSync } from "node:fs"; import { createHash } from "node:crypto"; @@ -100,6 +100,11 @@ export class SeatLaunchEnvironment { return resolveLaunchWrapperArgv(wrapper, this.sessionEnv.PATH ?? "", this.daemonCwd); } + /** The persisted account selection, also used to decide whether a bare launch fallback is safe. */ + accountHome(nodeId: string, runtime: string, ignoreAccountHome = false): string | undefined { + return this.selectedAccountHome?.(nodeId, runtime, ignoreAccountHome); + } + private rigBin(): string { const cli = realpathSync(this.cliPath ?? pairedCli()); accessSync(cli, constants.X_OK); @@ -126,7 +131,7 @@ export class SeatLaunchEnvironment { /** A Claude seat's own token step (claude-seat-token.ts), run before Claude in the staged subshell. A POSIX pane only: * a caller whose command comes back without it knows the token wasn't applied. */ claudeTokenStep?: string } = {}): Promise { - const accountHome = target.accountHome !== undefined ? target.accountHome : (target.nodeId && target.runtime ? this.selectedAccountHome?.(target.nodeId, target.runtime) : undefined); + const accountHome = target.accountHome !== undefined ? target.accountHome : (target.nodeId && target.runtime ? this.accountHome(target.nodeId, target.runtime) : undefined); const clearing = target.accountHome === null; const wrapped = target.launchWrapper?.length ? target.launchWrapper : undefined; const configuredHome = clearing && target.nodeId && target.runtime ? this.selectedAccountHome?.(target.nodeId, target.runtime, true) : undefined; @@ -134,7 +139,9 @@ export class SeatLaunchEnvironment { const clearStep = clearing && homeVar ? `unset ${homeVar}; ${configuredHome !== undefined ? `export ${homeVar}=${shellQuote(configuredHome)}; ` : ""}` : ""; const codexHome = (target.runtime === "codex" ? accountHome : undefined) ?? target.codexHome ?? this.codexHome; const searchPath = this.sessionEnv.PATH; - const accountStep = clearStep + (accountHome ? `unset ${ACCOUNT_HOME_PROVIDER_ENV.join(" ")}; ` : ""); + const selectedPiHome = target.runtime === "pi" ? accountHome ?? configuredHome : undefined; + const scrub = target.runtime === "pi" ? [...ACCOUNT_HOME_PROVIDER_ENV, ...PI_ACCOUNT_HOME_PROVIDER_ENV] : ACCOUNT_HOME_PROVIDER_ENV; + const accountStep = clearStep + (accountHome || selectedPiHome ? `unset ${scrub.join(" ")}; ` : ""); const codexEnv = target.codexCwd !== undefined ? [searchPath ? `PATH=${shellQuote(searchPath)}` : "", codexHome ? `CODEX_HOME=${shellQuote(codexHome)}` : ""].filter(Boolean) : []; @@ -178,6 +185,9 @@ export class SeatLaunchEnvironment { const env = publicSeatEnvironment({ OPENRIG_TRANSCRIPTS_LINES: "", OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS: "", ...this.sessionEnv, ...identity }); if (target.codexCwd !== undefined && codexHome) env.CODEX_HOME = codexHome; if (accountHome && target.runtime === "claude-code") env.CLAUDE_CONFIG_DIR = accountHome; + if (target.runtime === "pi") { + if (selectedPiHome) env.PI_CODING_AGENT_DIR = selectedPiHome; + } // Classic Claude may be an rc alias or function, not a PATH executable. // Its caller sources the staged command in a pane-shell subshell. Leading // assignments preserve shell lookup; /usr/bin/env would bypass it. diff --git a/packages/daemon/src/domain/seat-runtime-home.ts b/packages/daemon/src/domain/seat-runtime-home.ts index eed53bc55..1e67b9ae2 100644 --- a/packages/daemon/src/domain/seat-runtime-home.ts +++ b/packages/daemon/src/domain/seat-runtime-home.ts @@ -5,13 +5,16 @@ import type Database from "better-sqlite3"; import { parseStoredSeatEnv, type SeatEnv } from "./seat-env.js"; /** The native variable that selects each runtime's home, and with it the account that home is logged in to. */ -export const RUNTIME_HOME_VAR = { "claude-code": "CLAUDE_CONFIG_DIR", codex: "CODEX_HOME" } as const; +export const RUNTIME_HOME_VAR = { "claude-code": "CLAUDE_CONFIG_DIR", codex: "CODEX_HOME", pi: "PI_CODING_AGENT_DIR" } as const; export type HomeRuntime = keyof typeof RUNTIME_HOME_VAR; /** Provider environment excluded by an explicit isolated account-home selection. */ export const ACCOUNT_HOME_PROVIDER_ENV = [...OUTRANKING_TOKEN_ENV, ...ANTHROPIC_PROFILE_ENV, "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_BASE_URL", "OPENAI_API_KEY", "CODEX_API_KEY", "CODEX_ACCESS_TOKEN", "OPENAI_FEDERATION_RULE_ID", "OPENAI_IDENTITY_TOKEN_FILE", "OPENAI_BASE_URL", "OPENAI_ORG_ID", "OPENAI_ORGANIZATION", "OPENAI_PROJECT", "OPENAI_PROJECT_ID", "AWS_BEARER_TOKEN_BEDROCK"] as const; +/** Additional provider keys excluded only when selecting a Pi account. */ +export const PI_ACCOUNT_HOME_PROVIDER_ENV = ["OPENROUTER_API_KEY", "ZAI_API_KEY", "KIMI_API_KEY", "MINIMAX_API_KEY", "MISTRAL_API_KEY", "GEMINI_API_KEY"] as const; + export interface SeatRuntimeHome { /** The seat's own home: an absolute path its member, else its rig, declares in `env:`. */ home?: string; @@ -59,10 +62,10 @@ export function seatRuntimeHome(db: Database.Database, nodeId: string | null | u } /** The home a seat's launches and OpenRig's readers use: its own, else the daemon's explicit value. Undefined means the - * runtime's native default. */ + * runtime's native default, except Pi which keeps its managed seat login and never inherits the daemon home. */ export function effectiveRuntimeHome(db: Database.Database, nodeId: string | null | undefined, runtime: HomeRuntime, daemonHome: string | undefined, ignoreAccountHome = false): string | undefined { - return seatRuntimeHome(db, nodeId, runtime, ignoreAccountHome).home ?? daemonHome; + return seatRuntimeHome(db, nodeId, runtime, ignoreAccountHome).home ?? (runtime === "pi" ? undefined : daemonHome); } /** Every distinct home seats declare for a runtime (absolute values only), for readers that find a record by an id that is @@ -90,7 +93,7 @@ export function declaredRuntimeHomes(db: Database.Database, runtime: HomeRuntime * ignored, so the launch leaves it out too and the runtime resolves as if the seat declared none. */ export function withoutIgnoredRuntimeHomes(env: SeatEnv): SeatEnv { const out = { ...env }; - for (const name of Object.values(RUNTIME_HOME_VAR)) { + for (const name of [RUNTIME_HOME_VAR.codex, RUNTIME_HOME_VAR["claude-code"]]) { const value = out[name]; if (value !== undefined && !path.isAbsolute(value)) delete out[name]; } diff --git a/packages/daemon/src/domain/seat-runtime-switch-service.ts b/packages/daemon/src/domain/seat-runtime-switch-service.ts index 6ff958467..1763b89a1 100644 --- a/packages/daemon/src/domain/seat-runtime-switch-service.ts +++ b/packages/daemon/src/domain/seat-runtime-switch-service.ts @@ -1,8 +1,12 @@ +import { getOpenRigHome } from "../openrig-compat.js"; +import { piAccountStateRoot, piAccountSwitchStateRoot } from "./pi-account-state.js"; +import { piSeatPaths } from "../adapters/pi-runner-protocol.js"; +import { readSeatEffort } from "./seat-effort.js"; import { claudeSeatTokenRefProblem, claudeSeatTokenFileProblem, type ClaudeSeatTokenRef } from "./claude-seat-token.js"; import { isolatedClaudeAccountProblem } from "./claude-credential-precedence.js"; import nodePath from "node:path"; import fs from "node:fs"; -import { RUNTIME_HOME_VAR } from "./seat-runtime-home.js"; +import { RUNTIME_HOME_VAR, seatRuntimeHome } from "./seat-runtime-home.js"; import { ulid } from "ulid"; import type Database from "better-sqlite3"; import type { RigRepository } from "./rig-repository.js"; @@ -152,7 +156,17 @@ const COSTS = [ "A second process runs on the target's account until cutover.", "Messages sent to the seat while it switches are held, not typed; rig send says the seat is switching and until when, and they need sending again afterwards.", "A failed switch can leave the target runtime's prepared files (its seat storage and projections).", - "Codex and Claude Code only, single-pane seats only.", + "Codex and Claude Code runtime switches, or Pi account-home changes; single-pane seats only.", +]; + +const PI_ACCOUNT_COSTS = [ + "A new conversation: only the handover carries over; the previous conversation stays with the predecessor.", + "Same address, different terminal: the seat's session and address stay, its pane and scrollback change.", + "The runtime stays Pi. The seat keeps its model and saved effort choice unless explicitly changed; its launch wrapper is retained.", + "A second process runs on the selected account until cutover.", + "Messages sent to the seat while it switches are held, not typed; they need sending again afterwards.", + "A failed switch can leave the successor's prepared state directory under account-switches.", + "Pi account-home changes; single-pane seats only.", ]; export class SeatRuntimeSwitchService { @@ -179,8 +193,8 @@ export class SeatRuntimeSwitchService { /** Validate and plan a successor-first runtime/account move, executing it under the seat's lifecycle guard unless dry-run. */ async setRuntime(input: SetRuntimeInput): Promise { const refuse = (code: string, message: string, guidance?: string): RuntimeSwitchRefusal => ({ ok: false, code, message, ...(guidance ? { guidance } : {}) }); - if (input.runtime !== "codex" && input.runtime !== "claude-code") { - return refuse("invalid_runtime_settings", "Choose --runtime codex or --runtime claude-code."); + if (input.runtime !== "codex" && input.runtime !== "claude-code" && input.runtime !== "pi") { + return refuse("invalid_runtime_settings", "Choose --runtime codex or --runtime claude-code; Pi supports account-home changes on an existing Pi seat."); } const target: SwitchRuntime = input.runtime; if (input.clearAccountHome !== undefined && typeof input.clearAccountHome !== "boolean") return refuse("invalid_runtime_settings", "clearAccountHome must be true or false."); @@ -235,8 +249,18 @@ export class SeatRuntimeSwitchService { : open.phase === "abandoned" ? "whose staged successor hasn't been retired" : "in progress"} (${open.from_runtime} → ${open.to_runtime}, switch ${open.id}).`, `Inspect it with rig seat status ${seatRef}; finish an interrupted one with rig seat set-runtime ${seatRef} --recover.`), switchId: open.id }; } - if (node.runtime !== "codex" && node.runtime !== "claude-code") { - return refuse("runtime_switch_unsupported", `${seatRef} runs ${node.runtime ?? "an unknown runtime"}; only Codex and Claude Code seats can switch.`); + if (node.runtime !== "codex" && node.runtime !== "claude-code" && node.runtime !== "pi") { + return refuse("runtime_switch_unsupported", `${seatRef} runs ${node.runtime ?? "an unknown runtime"}; only Codex and Claude Code seats can change runtime; Pi supports account-home changes.`); + } + if ((target === "pi" || node.runtime === "pi") && target !== node.runtime) { + return refuse("runtime_switch_unsupported", "Pi supports account-home changes on an existing Pi seat; changing to or from Pi is not supported."); + } + if (target === "pi" && input.accountHome !== undefined) { + try { + const auth = nodePath.join(input.accountHome, "auth.json"); + if (!fs.statSync(auth).isFile()) throw new Error(); + fs.accessSync(auth, fs.constants.R_OK); + } catch { return refuse("invalid_runtime_settings", `Pi account file ${JSON.stringify(nodePath.join(input.accountHome, "auth.json"))} must be readable; sign in to that home first, then retry. Nothing changed.`); } } const binding = sessionRegistry.getBindingForNode(node.id); const sessionName = binding?.tmuxSession ?? null; @@ -252,6 +276,11 @@ export class SeatRuntimeSwitchService { const problem = isolatedClaudeAccountProblem(input.accountHome, node.cwd, !!input.accountToken, input.accountToken?.kind === "api-key"); if (problem) return refuse("invalid_runtime_settings", problem); } + // An account change keeps this Pi seat's current model and effort unless explicitly changed. + if (target === "pi") { + explicit.model ??= node.model ?? undefined; + explicit.effort ??= readSeatEffort(db, node.id)?.effective ?? node.effort ?? undefined; + } const predecessorGeneration = sessionRegistry.currentOccupantTenure(node.id)?.generationUuid; const running = sessionRegistry.getSessionsForRig(node.rigId).filter((session) => session.nodeId === node.id && session.status === "running"); const unsupported = !sessionName || !binding?.tmuxPane ? NO_PANE @@ -310,7 +339,7 @@ export class SeatRuntimeSwitchService { settings: materials.settings, permission: selection ? { mode: selection.mode, source: "explicit" } : { mode: "inherit", source: "member_or_rig_policy" }, dropped, - costs: COSTS, + costs: target === "pi" ? PI_ACCOUNT_COSTS : COSTS, notRun: materials.notRun.map((command) => `startup slash command not run by a switch: ${command}`), warnings: materials.warnings, }; @@ -416,7 +445,22 @@ export class SeatRuntimeSwitchService { successorPane = pane; db.prepare("UPDATE seat_runtime_switches SET successor_pane = ?, updated_at = ? WHERE id = ?").run(pane, stamp(), id); }, - staged: { prepare: (binding, adapter) => materials.prepare(binding, adapter), + staged: { prepare: (binding, adapter) => { + if (target === "pi") { + const base = nodePath.join(this.deps.openrigHome ?? getOpenRigHome(), "state", "pi"); + const source = piSeatPaths(piAccountStateRoot(db, base, sessionName), sessionName).agentDir; + const destination = piSeatPaths(piAccountSwitchStateRoot(base, successorGeneration), sessionName).agentDir; + if (fs.existsSync(source)) fs.cpSync(source, destination, { recursive: true, verbatimSymlinks: true, + filter: file => file !== nodePath.join(source, "auth.json") && file !== nodePath.join(source, ".openrig-account") }); + // Clearing without a configured home returns to the original seat-local login, if it had one. + const baseline = piSeatPaths(base, sessionName).agentDir; + if (ctx.input.clearAccountHome && !seatRuntimeHome(db, node.id, "pi", true).home && !fs.existsSync(nodePath.join(baseline, ".openrig-account")) && fs.existsSync(nodePath.join(baseline, "auth.json"))) { + fs.mkdirSync(destination, { recursive: true }); + fs.copyFileSync(nodePath.join(baseline, "auth.json"), nodePath.join(destination, "auth.json")); + } + } + return materials.prepare(binding, adapter); + }, binding: { ...materials.bindingAdditions(), ...(ctx.input.accountHome !== undefined ? { accountHome: ctx.input.accountHome, accountToken: ctx.input.accountToken ?? null } : ctx.input.clearAccountHome || node.accountHome ? { accountHome: null, ...(node.accountHome ? { accountToken: null } : {}) } : {}), runtimeSwitchFrom: { runtime: node.runtime!, session: sessionName, pane: ctx.predecessorPane, generation: ctx.predecessorGeneration } } }, }); @@ -532,10 +576,11 @@ export class SeatRuntimeSwitchService { } const at = this.now().toISOString(); const spec = materials.resolvedSpec; - db.prepare(`UPDATE nodes SET runtime = ?, model = ?, effort = ?, effort_choice = NULL, advisor = ?, codex_config_profile = ?, + db.prepare(`UPDATE nodes SET runtime = ?, model = ?, effort = ?, effort_choice = ?, advisor = ?, codex_config_profile = ?, ${spec ? "resolved_spec_name = ?, resolved_spec_version = ?, resolved_spec_hash = ?, restore_policy = ?," : ""} occupant_lifecycle = 'active', continuity_outcome = 'fresh', handover_result = 'complete', previous_occupant = ?, handover_at = ? WHERE id = ?`) - .run(target, materials.settings.model.value, materials.settings.effort.value, materials.settings.advisor.value, materials.settings.codexConfigProfile.value, + .run(target, materials.settings.model.value, target === "pi" && ctx.input.effort === undefined ? node.effort ?? null : materials.settings.effort.value, + target === "pi" && ctx.input.effort === undefined ? readSeatEffort(db, node.id)?.choice ?? null : null, materials.settings.advisor.value, materials.settings.codexConfigProfile.value, ...(spec ? [spec.name, spec.version, spec.hash, spec.restorePolicy] : []), ctx.sessionName, at, node.id); db.prepare("UPDATE nodes SET account_home = ?, claude_token_ref = CASE WHEN ? THEN ? ELSE claude_token_ref END WHERE id = ?") .run(ctx.input.accountHome ?? null, ctx.input.accountHome !== undefined || node.accountHome ? 1 : 0, diff --git a/packages/daemon/src/domain/seat-status-service.ts b/packages/daemon/src/domain/seat-status-service.ts index 22bb0d992..6a2f6d19b 100644 --- a/packages/daemon/src/domain/seat-status-service.ts +++ b/packages/daemon/src/domain/seat-status-service.ts @@ -44,10 +44,10 @@ export interface SeatStatus { previous_occupant: string | null; handover_at: string | null; restore_outcome: NodeInventoryEntry["restoreOutcome"]; - /** Claude and Codex seats: the home (and with it the login) the seat's launches use. Configured, not proof of + /** Claude, Codex and Pi seats: the home (and with it the login) the seat's launches use. Configured, not proof of * which login a running process holds. `ignored` is a declared value that isn't an absolute path. */ runtime_home?: { - variable: "CLAUDE_CONFIG_DIR" | "CODEX_HOME"; + variable: "CLAUDE_CONFIG_DIR" | "CODEX_HOME" | "PI_CODING_AGENT_DIR"; home: string | null; source: "member" | "rig" | "account-switch" | "daemon" | "native_default"; ignored?: { source: "member" | "rig" | "account-switch"; value: string }; @@ -215,10 +215,11 @@ export class SeatStatusService { const ignored = own.ignored ? { ignored: own.ignored } : {}; if (own.home && own.source) return { runtime_home: { variable, home: own.home, source: own.source } }; // The daemon's explicit value, as its launches pass it on; else the runtime's native default under HOME. - const daemon = this.env[variable]; + const daemon = runtime === "pi" ? undefined : this.env[variable]; if (daemon) return { runtime_home: { variable, home: daemon, source: "daemon", ...ignored } }; const home = this.env.HOME; - const native = home ? path.join(home, runtime === "codex" ? ".codex" : ".claude") : null; + // Unconfigured Pi uses its managed seat directory, not the operator's ~/.pi/agent login. + const native = home && runtime !== "pi" ? path.join(home, runtime === "codex" ? ".codex" : ".claude") : null; return { runtime_home: { variable, home: native, source: "native_default", ...ignored } }; } diff --git a/packages/daemon/src/startup.ts b/packages/daemon/src/startup.ts index 75186cee7..b64a5a099 100644 --- a/packages/daemon/src/startup.ts +++ b/packages/daemon/src/startup.ts @@ -1,8 +1,9 @@ +import { piAccountStateRoot } from "./domain/pi-account-state.js"; import { EnvironmentStartup } from "./domain/environment-startup.js"; import { EnvironmentPlatform } from "./domain/environment-platform.js"; import { OpenShellDriver } from "./adapters/openshell-driver.js"; import { isBoundTerminalSeat } from "./domain/terminal-seat.js"; -import { seatAccountHome, effectiveRuntimeHome, type HomeRuntime } from "./domain/seat-runtime-home.js"; +import { seatRuntimeHomeBySession, seatAccountHome, effectiveRuntimeHome, type HomeRuntime } from "./domain/seat-runtime-home.js"; import { reportRestoreDeliveryFailure } from "./domain/compaction-restore-notice.js"; import { ClaudeTokenUsage, claudeTokenUsageReader } from "./domain/provider/claude-token-usage.js"; import type { ClaudeTokenIdentity } from "./domain/provider/provider-types.js"; @@ -723,7 +724,9 @@ export async function createDaemon(opts?: DaemonOptions): Promise }); const { SeatLaunchEnvironment } = await import("./domain/seat-launch-environment.js"); const seatLaunchEnvironment = new SeatLaunchEnvironment(tmuxAdapter, launchSessionEnv, process.cwd(), undefined, configuredCodexHome, - (nodeId, runtime, ignoreAccountHome) => ignoreAccountHome + (nodeId, runtime, ignoreAccountHome) => runtime === "pi" + ? effectiveRuntimeHome(db, nodeId, "pi", undefined, ignoreAccountHome) + : ignoreAccountHome ? effectiveRuntimeHome(db, nodeId, runtime as HomeRuntime, runtime === "codex" ? configuredCodexHome : configuredClaudeHome, true) : seatAccountHome(db, nodeId, runtime)); const nodeLauncher = new NodeLauncher({ @@ -768,6 +771,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise // dist). Shared by the Pi runtime adapter, the resume adapter, and the // resume-token capture sidecar reader. const piStateRoot = nodePath.join(OPENRIG_HOME, "state", "pi"); + const piStateRootForSession = (session: string) => piAccountStateRoot(db, piStateRoot, session); const ompStateRoot = nodePath.join(OPENRIG_HOME, "state", "omp"); const piRunnerEntryPath = nodePath.resolve(import.meta.dirname, "./adapters/pi-runner.js"); // OPR.0.7.0.2: Pi seats report cost facts from persisted entries only while usage.pi_cost_facts is on; @@ -784,7 +788,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise tmuxAdapter, { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }) }, { stateRoot: piStateRoot, runnerEntryPath: piRunnerEntryPath }, - { seatLaunchEnvironment, usageFactsEnabled: piUsageFactsEnabled, openRouterChargeEnabled: piOpenRouterChargeEnabled }, + { accountHomeForSession: session => seatRuntimeHomeBySession(db, session, "pi"), stateRootForSession: piStateRootForSession, seatLaunchEnvironment, usageFactsEnabled: piUsageFactsEnabled, openRouterChargeEnabled: piOpenRouterChargeEnabled }, ); const ompResume = new OmpResumeAdapter( tmuxAdapter, @@ -985,7 +989,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise } }); // OPR.0.4.6.PI1 — the RPC-first Pi adapter (runner-in-a-pane). Same fsOps // shape as the Codex adapter; seat isolation roots under piStateRoot. - const piAdapter = new PiRuntimeAdapter({ managedBlockRecords: guidanceBlockRecords, tmux: tmuxAdapter, seatLaunchEnvironment, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), linkInfo: lstatLinkInfo }, stateRoot: piStateRoot, runnerEntryPath: piRunnerEntryPath, usageFactsEnabled: piUsageFactsEnabled, openRouterChargeEnabled: piOpenRouterChargeEnabled }); + const piAdapter = new PiRuntimeAdapter({ managedBlockRecords: guidanceBlockRecords, tmux: tmuxAdapter, seatLaunchEnvironment, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), linkInfo: lstatLinkInfo }, stateRootForSession: piStateRootForSession, stateRoot: piStateRoot, runnerEntryPath: piRunnerEntryPath, usageFactsEnabled: piUsageFactsEnabled, openRouterChargeEnabled: piOpenRouterChargeEnabled }); const ompAdapter = new OmpRuntimeAdapter({ managedBlockRecords: guidanceBlockRecords, tmux: tmuxAdapter, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; } }, stateRoot: ompStateRoot, runnerEntryPath: piRunnerEntryPath }); // OPR.0.5.1.1 — the stub runtime adapter (Pi-shaped node-script runner in a pane). // Same fsOps shape as Pi; the compiled runner entry lives in the daemon dist. diff --git a/packages/daemon/test/account-home-snapshot.test.ts b/packages/daemon/test/account-home-snapshot.test.ts index 45856a927..cfc3f0e34 100644 --- a/packages/daemon/test/account-home-snapshot.test.ts +++ b/packages/daemon/test/account-home-snapshot.test.ts @@ -54,3 +54,35 @@ printf '%s|%s' "$CLAUDE_CONFIG_DIR" "$token" db.close(); rmSync(root, { recursive: true, force: true }); } }); + +it("Pi down and existing-rig up retain the selected account home", async () => { + const root = mkdtempSync(path.join(tmpdir(), "pi-account-snapshot-")); + const db = createFullTestDb(); + try { + const adapter = { + runtime: "pi", launchHarness: async () => ({ ok: true }), checkReady: async () => ({ ready: true }), + project: async () => ({ projected: [], skipped: [], failed: [] }), + deliverStartup: async () => ({ delivered: [], failed: [] }), + } as unknown as import("../src/domain/runtime-adapter.js").RuntimeAdapter; + const { app, rigRepo, sessionRegistry, snapshotRepo } = createTestApp(db, { adapters: { pi: adapter } }); + const rig = rigRepo.createRig("pi-account-snapshot"); + const node = rigRepo.addNode(rig.id, "worker", { runtime: "pi", cwd: root }); + db.prepare("UPDATE nodes SET account_home = ? WHERE id = ?").run(root, node.id); + const session = sessionRegistry.registerSession(node.id, "worker@pi-account-snapshot"); + sessionRegistry.updateStatus(session.id, "running"); + db.prepare("UPDATE sessions SET restore_policy = 'relaunch_fresh' WHERE id = ?").run(session.id); + const down = await app.request("/api/down", { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rigId: rig.id, snapshot: true, force: true }) }); + expect(down.status).toBe(200); + const stopped = await down.json(); + expect(stopped.errors).toEqual([]); + expect(snapshotRepo.getSnapshot(stopped.snapshotId)!.data.nodes[0]).toMatchObject({ accountHome: root, runtime: "pi" }); + const up = await app.request("/api/up", { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sourceRef: rig.name, existing: true }) }); + expect(up.status).toBe(200); + expect((await up.json()).status).toBe("restored"); + expect(rigRepo.getEffectiveSeatEnv(node.id).PI_CODING_AGENT_DIR).toBe(root); + const status = await (await app.request("/api/seat/status/worker%40pi-account-snapshot")).json(); + expect(status.runtime_home).toMatchObject({ home: root, source: "account-switch" }); + } finally { db.close(); rmSync(root, { recursive: true, force: true }); } +}); diff --git a/packages/daemon/test/helpers/test-app.ts b/packages/daemon/test/helpers/test-app.ts index d44ed131e..720a72d66 100644 --- a/packages/daemon/test/helpers/test-app.ts +++ b/packages/daemon/test/helpers/test-app.ts @@ -1,3 +1,4 @@ +import { piAccountSwitchesSchema } from "../../src/db/migrations/151_pi_account_switches.js"; import { activityGrantsSchema } from "../../src/db/migrations/143_activity_grants.js"; import { environmentBindingsSchema } from "../../src/db/migrations/144_environment_bindings.js"; import { nodePermissionsToSchema } from "../../src/db/migrations/145_node_permissions_to.js"; @@ -160,7 +161,7 @@ import fs from "node:fs"; /** Seam B R6: the canonical full-fixture migration list, exported so file-backed * DB-reopen tests migrate IDENTICALLY to createFullTestDb. */ -export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema, nodeClaudeTokenRefSchema, nodeClaudePermissionsSchema, nodeCodexPermissionsSchema, escalationTargetRefsSchema, sessionAgentRunnerSchema, nodeAccountHomeSchema, nodeClaudeMcpSchema, activityGrantsSchema, environmentBindingsSchema, nodePermissionsToSchema, nodeStartupGuidanceFileSchema, managedBlockWritesSchema, streamIntakeCursorSchema, nodeLaunchWrapperSchema]; +export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema, nodeClaudeTokenRefSchema, nodeClaudePermissionsSchema, nodeCodexPermissionsSchema, escalationTargetRefsSchema, sessionAgentRunnerSchema, nodeAccountHomeSchema, nodeClaudeMcpSchema, activityGrantsSchema, environmentBindingsSchema, nodePermissionsToSchema, nodeStartupGuidanceFileSchema, managedBlockWritesSchema, streamIntakeCursorSchema, nodeLaunchWrapperSchema, piAccountSwitchesSchema]; /** * P24 — the DECLARED exclusions for {@link migrationsForFullTestDb}. That list is deliberately a diff --git a/packages/daemon/test/permission-policy-lifecycle.test.ts b/packages/daemon/test/permission-policy-lifecycle.test.ts index abd1785fa..581342845 100644 --- a/packages/daemon/test/permission-policy-lifecycle.test.ts +++ b/packages/daemon/test/permission-policy-lifecycle.test.ts @@ -566,7 +566,7 @@ describe("GF2 — the COMPLETE production-altitude launch/restore matrix", () => try { const { PiRuntimeAdapter } = await import("../src/adapters/pi-runtime-adapter.js"); const { PiResumeAdapter } = await import("../src/adapters/pi-resume.js"); - const piFs = { readFile: () => "{}", writeFile: () => {}, exists: () => true, mkdirp: () => {}, listFiles: () => [] }; + const piFs = { readFile: () => "{}", writeFile: () => {}, exists: (file: string) => !file.endsWith("/.openrig-account"), mkdirp: () => {}, listFiles: () => [] }; for (const [posture, expectedTrust] of [["full_bypass", "approve"], ["floor", "no-approve"]] as const) { const tmux = mockTmux(); const adapter = new PiRuntimeAdapter({ tmux, fsOps: piFs as never, stateRoot: "/tmp/pi-state", runnerEntryPath: "/tmp/pi-runner.js", sleep: async () => {} }); diff --git a/packages/daemon/test/pi-account-launch.test.ts b/packages/daemon/test/pi-account-launch.test.ts new file mode 100644 index 000000000..2d1498e31 --- /dev/null +++ b/packages/daemon/test/pi-account-launch.test.ts @@ -0,0 +1,224 @@ +import { afterEach, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { preparePiAccount } from "../src/adapters/pi-runner.js"; + +const roots: string[] = []; +afterEach(() => roots.splice(0).forEach(root => fs.rmSync(root, { recursive: true, force: true }))); + +it("links only the selected account auth and removes its managed link when cleared", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-account-")); + roots.push(root); + const agent = path.join(root, "seat", "agent"), account = path.join(root, "account"); + fs.mkdirSync(account); + fs.writeFileSync(path.join(account, "auth.json"), JSON.stringify({ mistral: { type: "api_key", key: "test-key" } })); + preparePiAccount(agent, account); + expect(fs.realpathSync(path.join(agent, "auth.json"))).toBe(fs.realpathSync(path.join(account, "auth.json"))); + preparePiAccount(agent, undefined); + expect(fs.existsSync(path.join(agent, "auth.json"))).toBe(false); + expect(fs.existsSync(path.join(account, "auth.json"))).toBe(true); +}); + +it.each([undefined, ["wrapper", "arg with spaces", "--"]])("preserves the selected account and wrapper when resuming a Pi seat (%j)", async wrapper => { + const { PiResumeAdapter } = await import("../src/adapters/pi-resume.js"); + const { SeatLaunchEnvironment } = await import("../src/domain/seat-launch-environment.js"); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-resume-account-")); + roots.push(root); + const cli = path.join(root, "rig"), sessionFile = path.join(root, "session.jsonl"), account = path.join(root, "account"); + fs.writeFileSync(path.join(root, "wrapper"), "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + fs.writeFileSync(cli, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + fs.writeFileSync(sessionFile, "{}"); + let launched = ""; + const identity: Record = { OPENRIG_NODE_ID: "pi-node", OPENRIG_SESSION_NAME: "build@rig", OPENRIG_RUNTIME: "pi", OPENRIG_OCCUPANT_GENERATION: "generation" }; + const tmux = { + getSessionEnv: async (_session: string, key: string) => identity[key], + getPaneCommand: async () => "bash", + sendShellCommand: async (_session: string, command: string) => { + launched = command; + fs.writeFileSync(path.join(root, "account-switches", "committed-generation", "build@rig/runner-state.json"), JSON.stringify({ ready: true, launchId: "attempt", sessionFile, updatedAt: "now" })); + return { ok: true as const }; + }, + } as unknown as import("../src/adapters/tmux.js").TmuxAdapter; + const resumedRoot = path.join(root, "account-switches", "committed-generation"); + const launch = new SeatLaunchEnvironment(tmux, { PATH: `${root}:/usr/bin`, OPENRIG_HOME: root }, root, cli, undefined, + (node, runtime) => node === "pi-node" && runtime === "pi" ? account : undefined); + const adapter = new PiResumeAdapter(tmux, { + readFile: file => fs.readFileSync(file, "utf8"), writeFile: (file, text) => fs.writeFileSync(file, text), + exists: fs.existsSync, mkdirp: file => fs.mkdirSync(file, { recursive: true }), + }, { stateRoot: root, runnerEntryPath: "/runner.js" }, { stateRootForSession: () => resumedRoot, seatLaunchEnvironment: launch, newLaunchId: () => "attempt" }); + expect(await adapter.resume("build@rig", "pi_session_file", sessionFile, root, "mistral/devstral-latest", undefined, undefined, wrapper)).toMatchObject({ ok: true }); + expect(launched).toContain(`--account-home '${account}'`); + expect(launched).toContain(`--state-root '${resumedRoot}'`); + if (wrapper) expect(launched).toContain(`--launch-wrapper-arg '${root}/wrapper' --launch-wrapper-arg 'arg with spaces' --launch-wrapper-arg '--'`); + let attempts = 0; + tmux.sendShellCommand = async () => { attempts++; return { ok: false, code: "launch_path_too_long", message: "fixture bound" }; }; + expect(await adapter.resume("build@rig", "pi_session_file", sessionFile, root, "mistral/devstral-latest", undefined, undefined, wrapper)) + .toMatchObject({ ok: false, code: "resume_failed" }); + expect(attempts).toBe(1); +}); + +it("keeps the previous credentials when a selected account cannot be read, and leaves unselected seat auth alone", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-account-")); + roots.push(root); + const agent = path.join(root, "agent"), account = path.join(root, "account"); + fs.mkdirSync(agent); + fs.mkdirSync(account); + const auth = path.join(agent, "auth.json"); + fs.writeFileSync(auth, "seat-auth"); + preparePiAccount(agent, undefined); + expect(fs.readFileSync(auth, "utf8")).toBe("seat-auth"); + expect(() => preparePiAccount(agent, account)).toThrow(); + expect(fs.readFileSync(auth, "utf8")).toBe("seat-auth"); + preparePiAccount(agent, agent); + expect(fs.readFileSync(auth, "utf8")).toBe("seat-auth"); + const alias = path.join(root, "alias"); + fs.symlinkSync(agent, alias, "junction"); + preparePiAccount(agent, alias); + expect(fs.readFileSync(auth, "utf8")).toBe("seat-auth"); +}); + +it.each(["selected", "cleared"])("executes a %s Pi account environment without leaking inherited provider keys", async selection => { + const { execFileSync } = await import("node:child_process"); + const { SeatLaunchEnvironment } = await import("../src/domain/seat-launch-environment.js"); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-account-env-")); + roots.push(root); + const cli = path.join(root, "rig"); + fs.writeFileSync(cli, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + const tmux = { + getPaneCommand: async () => "bash", + getSessionEnv: async (_session: string, key: string) => key === "OPENRIG_NODE_ID" ? "pi-node" : "seat@rig", + } as unknown as import("../src/adapters/tmux.js").TmuxAdapter; + const launch = new SeatLaunchEnvironment(tmux, { PATH: "/usr/bin:/bin", OPENRIG_HOME: root }, root, cli, undefined, + (_node, _runtime, clearing) => clearing ? "/pi/configured" : "/pi/selected"); + const command = await launch.command("seat@rig", "/usr/bin/env", { + nodeId: "pi-node", runtime: "pi", ...(selection === "cleared" ? { accountHome: null } : {}), + }); + const output = execFileSync("/bin/sh", ["-c", command], { encoding: "utf8", env: { + PATH: "/usr/bin:/bin", PI_CODING_AGENT_DIR: "/pi/stale", MISTRAL_API_KEY: "fixture-mistral", GEMINI_API_KEY: "fixture-gemini", OPENROUTER_API_KEY: "fixture-openrouter", ZAI_API_KEY: "fixture-zai", KIMI_API_KEY: "fixture-kimi", MINIMAX_API_KEY: "fixture-minimax", + } }); + expect(output).toContain(`PI_CODING_AGENT_DIR=/pi/${selection === "cleared" ? "configured" : "selected"}\n`); + expect(output).not.toContain("fixture-"); +}); + +it("refuses to replace a seat login or unowned link, and preserves a login created after selection", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-account-owned-")); + roots.push(root); + const agent = path.join(root, "agent"), account = path.join(root, "account"); + fs.mkdirSync(agent); fs.mkdirSync(account); + fs.writeFileSync(path.join(account, "auth.json"), "selected-auth"); + const auth = path.join(agent, "auth.json"), marker = path.join(agent, ".openrig-account"); + fs.writeFileSync(auth, "seat-login"); + expect(() => preparePiAccount(agent, account)).toThrow("not a managed account link"); + expect(fs.readFileSync(auth, "utf8")).toBe("seat-login"); + expect(fs.existsSync(marker)).toBe(false); + fs.rmSync(auth); fs.symlinkSync(path.join(account, "auth.json"), auth); + expect(() => preparePiAccount(agent, account)).toThrow("not a managed account link"); + expect(fs.lstatSync(auth).isSymbolicLink()).toBe(true); + fs.rmSync(auth); + preparePiAccount(agent, account); + preparePiAccount(agent, account); + expect(fs.readFileSync(auth, "utf8")).toBe("selected-auth"); + fs.rmSync(auth); fs.writeFileSync(auth, "new-seat-login"); + preparePiAccount(agent, undefined); + expect(fs.readFileSync(auth, "utf8")).toBe("new-seat-login"); + expect(fs.existsSync(marker)).toBe(false); +}); + +it.each(["pi", "codex", "claude-code"])("scopes extra provider-key scrubbing to Pi and resets ambient selection (%s)", async runtime => { + const { execFileSync } = await import("node:child_process"); + const { SeatLaunchEnvironment } = await import("../src/domain/seat-launch-environment.js"); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-account-scope-")); + roots.push(root); + const cli = path.join(root, "rig"); + fs.writeFileSync(cli, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + const tmux = { + getPaneCommand: async () => "bash", + getSessionEnv: async (_session: string, key: string) => key === "OPENRIG_NODE_ID" ? "node" : "seat@rig", + } as unknown as import("../src/adapters/tmux.js").TmuxAdapter; + const launch = new SeatLaunchEnvironment(tmux, { PATH: "/usr/bin:/bin", OPENRIG_HOME: root }, root, cli); + const inherited = { PATH: "/usr/bin:/bin", OPENROUTER_API_KEY: "custom-provider-key", OPENAI_API_KEY: "existing-scrub", PI_CODING_AGENT_DIR: "/ambient", OPENRIG_PI_ACCOUNT_HOME: "/stale" }; + const command = await launch.command("seat@rig", "/usr/bin/env", { nodeId: "node", runtime, accountHome: "/selected" }); + const output = execFileSync("/bin/sh", ["-c", command], { encoding: "utf8", env: inherited }); + expect(output).not.toContain("existing-scrub"); + if (runtime === "pi") { + expect(output).not.toContain("custom-provider-key"); + expect(output).toContain("OPENRIG_PI_ACCOUNT_HOME=/stale\n"); + const unselected = await launch.command("seat@rig", "/usr/bin/env", { nodeId: "node", runtime }); + const ambient = execFileSync("/bin/sh", ["-c", unselected], { encoding: "utf8", env: inherited }); + expect(ambient).toContain("OPENRIG_PI_ACCOUNT_HOME=/stale\n"); + } else expect(output).toContain("OPENROUTER_API_KEY=custom-provider-key\n"); +}); + +it.each(["nu", "fish", "bash"])("retains the short account-free fallback (%s)", async shell => { + const { execFileSync } = await import("node:child_process"); + const { SeatLaunchEnvironment } = await import("../src/domain/seat-launch-environment.js"); + const tmux = { + getPaneCommand: async () => shell, + getSessionEnv: async () => undefined, + } as unknown as import("../src/adapters/tmux.js").TmuxAdapter; + const launch = new SeatLaunchEnvironment(tmux, {}, "/tmp"); + const command = await launch.command("seat@rig", "/usr/bin/env", { runtime: "pi" }); + expect(command).toBe("/usr/bin/env"); + const output = execFileSync("/bin/sh", ["-c", command], { encoding: "utf8", env: { OPENRIG_PI_ACCOUNT_HOME: "/unselected" } }); + expect(output).toContain("OPENRIG_PI_ACCOUNT_HOME=/unselected\n"); // The runner ignores this obsolete variable. + expect(await launch.command("seat@rig", "/usr/bin/env", { runtime: "pi", accountHome: "/selected" })).toContain("no agent started"); +}); + +it("retries an interrupted initial account link without treating it as a person's own link", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-account-interrupted-")); roots.push(root); + const agent = path.join(root, "agent"), account = path.join(root, "account"); + fs.mkdirSync(account); fs.writeFileSync(path.join(account, "auth.json"), "synthetic-auth"); + const symlink = fs.symlinkSync; + const interruption = vi.spyOn(fs, "symlinkSync").mockImplementation((...args) => { + symlink(...args); throw new Error("simulated interruption immediately after linking"); + }); + try { expect(() => preparePiAccount(agent, account)).toThrow("simulated interruption"); } + finally { interruption.mockRestore(); } + preparePiAccount(agent, account); + expect(fs.readFileSync(path.join(agent, "auth.json"), "utf8")).toBe("synthetic-auth"); + expect(fs.readFileSync(path.join(agent, ".openrig-account"), "utf8")).toBe(account); +}); + +it.each([false, true])("resume tolerates unavailable tmux identity only without a chosen account (%s)", async chosen => { + const { PiResumeAdapter } = await import("../src/adapters/pi-resume.js"); + const { SeatLaunchEnvironment } = await import("../src/domain/seat-launch-environment.js"); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-resume-identity-")); roots.push(root); + const sessionFile = path.join(root, "session.jsonl"); fs.writeFileSync(sessionFile, "{}"); + let dispatched = false; + const tmux = { + getSessionEnv: async () => { throw new Error("metadata unavailable"); }, + getPaneCommand: async () => "bash", + sendShellCommand: async () => { + dispatched = true; + fs.writeFileSync(path.join(root, "seat@rig/runner-state.json"), JSON.stringify({ ready: true, launchId: "attempt", sessionFile, updatedAt: "now" })); + return { ok: true as const }; + }, + } as unknown as import("../src/adapters/tmux.js").TmuxAdapter; + const launch = new SeatLaunchEnvironment(tmux, {}, root); + const adapter = new PiResumeAdapter(tmux, { + readFile: file => fs.readFileSync(file, "utf8"), writeFile: fs.writeFileSync, + exists: fs.existsSync, mkdirp: file => fs.mkdirSync(file, { recursive: true }), + }, { stateRoot: root, runnerEntryPath: "/runner.js" }, { + seatLaunchEnvironment: launch, accountHomeForSession: () => chosen ? "/chosen" : undefined, newLaunchId: () => "attempt", + }); + expect(await adapter.resume("seat@rig", "pi_session_file", sessionFile, root)).toMatchObject({ ok: !chosen }); + expect(dispatched).toBe(!chosen); +}); + +it("reports a missing prepared auth file in the seat's own home without an uncaught lstat error", async () => { + const { main } = await import("../src/adapters/pi-runner.js"); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-own-account-")); roots.push(root); + const agent = path.join(root, "seat", "agent"); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const exitCode = process.exitCode; + try { + await expect(main(["--session-name", "seat", "--state-root", root, "--cwd", root, + "--launch-id", "attempt", "--no-approve", "--account-home", agent])).resolves.toBeUndefined(); + expect(process.exitCode).toBe(2); + expect(error.mock.calls.flat().join("\n")).toContain(path.join(agent, "auth.json")); + expect(error.mock.calls.flat().join("\n")).toContain("Pi account preparation failed"); + expect(fs.existsSync(path.join(agent, ".openrig-account"))).toBe(false); + expect(() => preparePiAccount(agent, undefined)).not.toThrow(); + } finally { process.exitCode = exitCode; error.mockRestore(); } +}); diff --git a/packages/daemon/test/pi-account-lock.test.ts b/packages/daemon/test/pi-account-lock.test.ts new file mode 100644 index 000000000..333fa880e --- /dev/null +++ b/packages/daemon/test/pi-account-lock.test.ts @@ -0,0 +1,91 @@ +import { afterEach, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { execFileSync, spawn } from "node:child_process"; +import { once } from "node:events"; +import { preparePiAccount } from "../src/adapters/pi-runner.js"; + +const require = createRequire(import.meta.url); +const lockModule = require.resolve("proper-lockfile"); +const lockfile = require("proper-lockfile"); +const shim = path.resolve(import.meta.dirname, "../assets/pi-account-lock.cjs"); +const roots: string[] = []; +afterEach(() => roots.splice(0).forEach(root => fs.rmSync(root, { recursive: true, force: true }))); + +it.each([[false, false], [true, false], [true, true]])("Pi's realpath:false lock across account aliases (shim: %s, linked source: %s)", async (useShim, linkedSource) => { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "pi-lock-"))); roots.push(root); + const account = path.join(root, "account"), a = path.join(root, "a"), b = path.join(root, "b"); + fs.mkdirSync(account); const source = path.join(account, "auth.json"); + if (linkedSource) { + fs.writeFileSync(path.join(root, "credentials.json"), "{}"); + fs.symlinkSync("../credentials.json", source); + } else fs.writeFileSync(source, "{}"); + preparePiAccount(a, account); preparePiAccount(b, account); + const env = (agentDir: string) => ({ ...process.env, NODE_OPTIONS: useShim ? `--require=${JSON.stringify(shim)}` : "", PI_CODING_AGENT_DIR: agentDir }); + const child = spawn(process.execPath, ["-e", ` + const fs = require('node:fs'), lock = require(${JSON.stringify(lockModule)}); + const auth = ${JSON.stringify(path.join(a, "auth.json"))}; + const release = lock.lockSync(auth, {realpath:false}); + fs.writeFileSync(auth, JSON.stringify({first:'refreshed'})); + process.send('locked'); + process.on('message', () => { release(); process.disconnect(); }); + `], { env: env(a), stdio: ["ignore", "pipe", "pipe", "ipc"] }); + try { + await Promise.race([once(child, "message"), once(child, "exit").then(() => { throw new Error("lock holder exited early"); })]); + const probe = execFileSync(process.execPath, ["-e", ` + const lock = require(${JSON.stringify(lockModule)}); + (async () => { + try { const release = await lock.lock(${JSON.stringify(path.join(b, "auth.json"))}, {realpath:false}); await release(); console.log('acquired'); } + catch (e) { if(e.code !== 'ELOCKED') throw e; console.log('blocked'); } + })().catch(e=>{console.error(e);process.exitCode=1}); + `], { env: env(b), encoding: "utf8" }); + expect(probe.trim()).toBe(useShim ? "blocked" : "acquired"); + // Native direct use of the chosen account contends with the same lock too. + if (useShim) expect(() => lockfile.lockSync(source, { realpath: false })).toThrow(/already being held/); + else lockfile.lockSync(source, { realpath: false })(); + const exited = once(child, "exit"); child.send("release"); await exited; + execFileSync(process.execPath, ["-e", ` + const fs = require('node:fs'), lock = require(${JSON.stringify(lockModule)}); + const auth = ${JSON.stringify(path.join(b, "auth.json"))}; + const release = lock.lockSync(auth, {realpath:false}); + const state = JSON.parse(fs.readFileSync(auth)); state.second = 'refreshed'; + fs.writeFileSync(auth, JSON.stringify(state)); release(); + `], { env: env(b) }); + expect(JSON.parse(fs.readFileSync(source, "utf8"))).toEqual({ first: "refreshed", second: "refreshed" }); + expect(fs.existsSync(source + ".lock")).toBe(false); + expect(fs.existsSync(path.join(a, "auth.json.lock"))).toBe(false); + expect(fs.existsSync(path.join(b, "auth.json.lock"))).toBe(false); + } finally { child.kill(); } +}, 10000); + +// Exercise the process boundary used by Pi's bash tool without provider access. +it("consumes only its preload and leaves Node tools and nested unselected Pi untouched", () => { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "pi-child-"))); roots.push(root); + const account = path.join(root, "account"), agent = path.join(root, "agent"); + fs.mkdirSync(account); fs.writeFileSync(path.join(account, "auth.json"), "{}"); + preparePiAccount(agent, account); + const nested = path.join(root, "nested"); + fs.mkdirSync(nested); fs.writeFileSync(path.join(nested, "auth.json"), "{}"); + const probe = ` + const fs = require('node:fs'); + const native = Function.prototype.toString.call(fs.mkdirSync); + if (native.includes('lockPath')) throw new Error('filesystem was patched'); + if (process.env.NODE_OPTIONS !== '--no-warnings') throw new Error('other options lost'); + const lock = require(${JSON.stringify(lockModule)}); + lock.lockSync(${JSON.stringify(path.join(nested, "auth.json"))}, {realpath:false})(); + `; + execFileSync(process.execPath, ["-e", ` + const {execFileSync} = require('node:child_process'); + for (const dir of [process.env.PI_CODING_AGENT_DIR, ${JSON.stringify(nested)}, '/missing/pi-home']) { + execFileSync(process.execPath, ['-e', ${JSON.stringify(probe)}], { + env: {...process.env, PI_CODING_AGENT_DIR: dir} + }); + } + `], { env: { ...process.env, PI_CODING_AGENT_DIR: agent, + NODE_OPTIONS: `--no-warnings --require=${JSON.stringify(shim)}` } }); + // A wrapper may preload into an unrelated process itself: this must be harmless too. + execFileSync(process.execPath, ["-e", probe], { env: { ...process.env, + PI_CODING_AGENT_DIR: nested, NODE_OPTIONS: `--require=${JSON.stringify(shim)} --no-warnings` } }); +}); diff --git a/packages/daemon/test/pi-account-switch-migration.test.ts b/packages/daemon/test/pi-account-switch-migration.test.ts new file mode 100644 index 000000000..09d987c05 --- /dev/null +++ b/packages/daemon/test/pi-account-switch-migration.test.ts @@ -0,0 +1,26 @@ +import { expect, it } from "vitest"; +import { createDb } from "../src/db/connection.js"; +import { migrate } from "../src/db/migrate.js"; +import { ALL_MIGRATIONS } from "../src/db/all-migrations.js"; +import { piAccountSwitchesSchema } from "../src/db/migrations/151_pi_account_switches.js"; + +it("retains an interrupted runtime switch when upgrading to Pi-capable switches", () => { + const db = createDb(); + try { + migrate(db, ALL_MIGRATIONS.filter(migration => migration.name !== piAccountSwitchesSchema.name)); + db.exec("INSERT INTO rigs (id, name) VALUES ('rig', 'upgrade'); INSERT INTO nodes (id, rig_id, logical_id) VALUES ('seat', 'rig', 'dev.build');"); + const insert = db.prepare(`INSERT INTO seat_runtime_switches + (id, node_id, canonical_session, staging_session, predecessor_pane, predecessor_generation, successor_generation, from_runtime, to_runtime, reason, plan_json, deadline_at) + VALUES (?, 'seat', 'build@upgrade', ?, '%0', 'before', ?, 'codex', ?, 'allowance', '{}', '2026-11-03T03:00:00Z')`); + insert.run("old", "staging-old", "after-old", "claude-code"); + const before = db.prepare("SELECT * FROM seat_runtime_switches WHERE id = 'old'").get(); + migrate(db, ALL_MIGRATIONS); + expect(db.prepare("SELECT * FROM seat_runtime_switches WHERE id = 'old'").get()).toEqual(before); + expect(() => insert.run("duplicate", "staging-new", "after-new", "pi")).toThrow(); + db.exec("UPDATE seat_runtime_switches SET phase = 'abandoned' WHERE id = 'old'"); + insert.run("pi", "staging-new", "after-new", "pi"); + expect(db.prepare("SELECT to_runtime FROM seat_runtime_switches WHERE id = 'pi'").get()).toEqual({ to_runtime: "pi" }); + migrate(db, ALL_MIGRATIONS); + expect(db.pragma("foreign_key_check")).toEqual([]); + } finally { db.close(); } +}); diff --git a/packages/daemon/test/pi-runner-path-native.test.ts b/packages/daemon/test/pi-runner-path-native.test.ts index 2eaf69f28..e19e59a47 100644 --- a/packages/daemon/test/pi-runner-path-native.test.ts +++ b/packages/daemon/test/pi-runner-path-native.test.ts @@ -54,7 +54,7 @@ else { process.stdin.resume(); console.error('Error: Unknown options: --name, -- fileURLToPath(new URL("../src/adapters/pi-runner.ts", import.meta.url)), "--session-name", "fixture", "--state-root", stateRoot, "--cwd", temp, "--launch-id", "path-attempt", "--no-approve"], { - env: { PATH: searchPath, HOME: temp, OPENRIG_HOME: path.join(temp, "home") }, + env: { OPENRIG_PI_ACCOUNT_HOME: "/ignored/ambient", PI_CODING_AGENT_DIR: "/ambient/must-not-be-used", PATH: searchPath, HOME: temp, OPENRIG_HOME: path.join(temp, "home") }, detached: true, stdio: ["pipe", "pipe", "pipe"], }); let output = "", ready = false, timedOut = false; @@ -93,6 +93,8 @@ else { process.stdin.resume(); console.error('Error: Unknown options: --name, -- const temp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "pi-wrap-child-"))); const first = path.join(temp, "first"), second = path.join(temp, "second"); fs.mkdirSync(first); fs.mkdirSync(second); + const account = path.join(temp, "account"); fs.mkdirSync(account); + fs.writeFileSync(path.join(account, "auth.json"), "synthetic-selected-account"); const session = path.join(temp, "session.jsonl"); fs.writeFileSync(session, "fixture\n"); const piLogPath = path.join(temp, "pi-child-log.json"); @@ -150,6 +152,7 @@ WRAPPER_SH_PID="$$" WRAPPER_PPID="$PPID" ${quote(process.execPath)} -e ' wrapperPpid: Number(process.env.WRAPPER_PPID), wrapperArgs: argv.slice(0, sep), execArgv: argv.slice(sep + 1), + auth: fs.readFileSync(require("node:path").join(process.env.PI_CODING_AGENT_DIR, "auth.json"), "utf8"), cwd: process.cwd(), tty: [0, 1, 2].map(fd => Boolean(tty.isatty(fd))), })); @@ -181,8 +184,9 @@ exec "$@" "--launch-wrapper-arg", "--", ]; + runnerArgv.push("--account-home", account); const child = spawn(process.execPath, runnerArgv, { - env: { PATH: [first, second, "/usr/bin", "/bin"].join(path.delimiter), HOME: temp, OPENRIG_HOME: path.join(temp, "home"), OPENRIG_NODE_ID: "node-1" }, + env: { OPENRIG_PI_ACCOUNT_HOME: "/ignored/ambient", PI_CODING_AGENT_DIR: "/ambient/must-not-be-used", PATH: [first, second, "/usr/bin", "/bin"].join(path.delimiter), HOME: temp, OPENRIG_HOME: path.join(temp, "home"), OPENRIG_NODE_ID: "node-1" }, detached: true, stdio: ["pipe", "pipe", "pipe"], }); let output = "", ready = false, timedOut = false; @@ -205,6 +209,8 @@ exec "$@" const wrapperRec = JSON.parse(fs.readFileSync(wrapperLogPath, "utf8")); const piRec = JSON.parse(fs.readFileSync(piLogPath, "utf8")); + expect(wrapperRec.auth).toBe("synthetic-selected-account"); + expect(fs.realpathSync(path.join(seat.agentDir, "auth.json"))).toBe(fs.realpathSync(path.join(account, "auth.json"))); const expectedPiPath = mode === "mise" ? fs.realpathSync(good) : fs.realpathSync(path.join(first, "pi")); const expectedChildArgs = buildPiChildArgs({ sessionsDir: seat.sessionsDir, diff --git a/packages/daemon/test/pi-runtime-adapter.test.ts b/packages/daemon/test/pi-runtime-adapter.test.ts index 1538dd1eb..989e85c8d 100644 --- a/packages/daemon/test/pi-runtime-adapter.test.ts +++ b/packages/daemon/test/pi-runtime-adapter.test.ts @@ -42,6 +42,7 @@ function mockTmux(overrides?: { capturePaneContent: overrides?.capturePaneContent ?? vi.fn(async () => ""), hasSession: overrides?.hasSession ?? vi.fn(async () => true), getPaneCommand: overrides?.getPaneCommand ?? vi.fn(async () => "node"), + getSessionEnv: async (_session: string, key: string) => key === "OPENRIG_NODE_ID" ? "node-pi" : undefined, createSession: async () => ({ ok: true as const }), killSession: async () => ({ ok: true as const }), listSessions: async () => [], @@ -841,6 +842,7 @@ describe("PiRuntimeAdapter and PiResumeAdapter launch_wrapper", () => { const commandCalls: Array<{ session: string; cmd: string; target: unknown }> = []; const resolveLaunchWrapper = vi.fn((w: readonly string[]) => [`/resolved/bin/${w[0]}`, ...w.slice(1)]); const seatLaunchEnvironment = { + accountHome: () => undefined, resolveLaunchWrapper, command: vi.fn(async (session: string, cmd: string, target: unknown) => { commandCalls.push({ session, cmd, target }); @@ -947,6 +949,7 @@ describe("PiRuntimeAdapter and PiResumeAdapter launch_wrapper", () => { it("PiRuntimeAdapter retains launch_path_too_long bare runner retry when wrapped because the raw runner command carries the exact same --launch-wrapper-arg flags", async () => { const seatLaunchEnvironment = { + accountHome: () => undefined, resolveLaunchWrapper: vi.fn((w: readonly string[]) => [`/abs/bin/${w[0]}`, ...w.slice(1)]), command: vi.fn(async (_s: string, cmd: string) => `/usr/bin/env 'OPENRIG_RUNTIME=pi' ${cmd}`), } as never; @@ -1008,6 +1011,7 @@ describe("PiRuntimeAdapter and PiResumeAdapter launch_wrapper", () => { const commandCalls: Array<{ session: string; cmd: string; target: unknown }> = []; const resolveLaunchWrapper = vi.fn((w: readonly string[]) => [`/abs/bin/${w[0]}`, ...w.slice(1)]); const seatLaunchEnvironment = { + accountHome: () => undefined, resolveLaunchWrapper, command: vi.fn(async (session: string, cmd: string, target: unknown) => { commandCalls.push({ session, cmd, target }); @@ -1032,7 +1036,7 @@ describe("PiRuntimeAdapter and PiResumeAdapter launch_wrapper", () => { expect(commandCalls).toEqual([{ session: SESSION, cmd: expect.stringContaining("--approve --model 'zai/glm-5.2' --thinking 'high' --session"), - target: { runtime: "pi" }, + target: { runtime: "pi", nodeId: "node-pi", accountHome: undefined }, }]); const expectedFlags = "--launch-wrapper-arg '/abs/bin/my-wrapper' --launch-wrapper-arg '--opt' --launch-wrapper-arg '--'"; expect(sendShellCommand.mock.calls[0]![1]).toContain("node '/daemon-dist/adapters/pi-runner.js'"); @@ -1094,3 +1098,40 @@ describe("PiRuntimeAdapter and PiResumeAdapter launch_wrapper", () => { expect(tooLongSend.mock.calls[1]![1]).toContain(expectedFlags); }); }); + +it.each(["selected", "cleared"])("does not bypass a %s Pi account when a wrapped launch cannot be staged", async selection => { + const fs = memFs(); + const accountHome = selection === "cleared" ? null : "/accounts/pi"; + const sendShellCommand = vi.fn(async (): Promise => ({ ok: false, code: "launch_path_too_long", message: "fixture bound" })); + const command = vi.fn(async (_session: string, cmd: string) => `/usr/bin/env ${cmd}`); + const adapter = new PiRuntimeAdapter({ + tmux: mockTmux({ sendShellCommand }), fsOps: fs, stateRoot: STATE_ROOT, runnerEntryPath: RUNNER, + seatLaunchEnvironment: { + accountHome: () => "/accounts/previous", resolveLaunchWrapper: () => ["/wrapper", "--"], command, + } as never, + }); + const result = await adapter.launchHarness({ tmuxSession: SESSION, nodeId: "node-pi", cwd: "/work", accountHome, launchWrapper: ["wrapper", "--"] } as never, { name: SESSION }); + expect(result.ok).toBe(false); + expect(command.mock.calls[0]).toEqual([SESSION, expect.stringContaining("--launch-wrapper-arg '/wrapper'"), expect.objectContaining({ accountHome })]); + expect(sendShellCommand).toHaveBeenCalledTimes(1); +}); + +it("stages a Pi account successor with canonical identity and a separate state directory", async () => { + const fs = memFs(); + const predecessorPath = piSeatPaths(STATE_ROOT, SESSION).runnerStatePath; + fs.files[predecessorPath] = readyState(SESSION_FILE, "old-launch"); + const stagedRoot = `${STATE_ROOT}/account-switches/new-generation`; + const stagedPath = piSeatPaths(stagedRoot, SESSION).runnerStatePath; + const token = `${stagedRoot}/${SESSION}/sessions/new.jsonl`; + const sendShellCommand = vi.fn(async (target: string, text: string) => { + expect(target).toBe("staging-pane"); + expect(text).toContain(`--session-name '${SESSION}' --state-root '${stagedRoot}'`); + fs.files[stagedPath] = readyState(token, launchIdFrom(text)); + return { ok: true as const }; + }); + const adapter = new PiRuntimeAdapter({ tmux: mockTmux({ sendShellCommand }), fsOps: fs, stateRoot: STATE_ROOT, runnerEntryPath: RUNNER }); + const binding = { tmuxSession: "staging-pane", launchGeneration: "new-generation", cwd: "/work", runtimeSwitchFrom: { runtime: "pi", session: SESSION, pane: "%0", generation: "old-generation" } } as never; + expect(await adapter.launchHarness(binding, { name: SESSION })).toMatchObject({ ok: true, resumeToken: token }); + expect(await adapter.checkReady(binding)).toMatchObject({ ready: true }); + expect(fs.files[predecessorPath]).toBe(readyState(SESSION_FILE, "old-launch")); +}); diff --git a/packages/daemon/test/pi-shell-launch-native.test.ts b/packages/daemon/test/pi-shell-launch-native.test.ts index 108ffaecd..bf79ae730 100644 --- a/packages/daemon/test/pi-shell-launch-native.test.ts +++ b/packages/daemon/test/pi-shell-launch-native.test.ts @@ -30,7 +30,7 @@ describe.skipIf(!hasTmux || process.platform === "win32")("Pi launch through nat const launchMode = mode.split("-")[0]; const longTmp = mode.endsWith("long-tmpdir"); const routing = mode.includes("-routing"); - const temp = fs.mkdtempSync(path.join(os.tmpdir(), "pi-shell-")); + const temp = fs.realpathSync(fs.mkdtempSync(path.join(process.env.TMUX_TMPDIR ?? (process.platform === "darwin" ? "/private/tmp" : os.tmpdir()), "pi-shell-"))); const socket = path.join(temp, "tmux.sock"); const session = "pi-fixture"; const long = Array.from({ length: 14 }, () => "nested-directory-" + "x".repeat(35)).join(path.sep); @@ -181,7 +181,7 @@ setInterval(() => {}, 1000); it.each(["fresh", "fork", "resume", "staged-long", "long-tmpdir-retry"] as const)("executes launch_wrapper in native tmux for Pi %s with real host runner and wrapped Pi child over pipe stdio", async mode => { const launchMode = mode === "staged-long" || mode === "long-tmpdir-retry" ? "fresh" : mode; - const temp = fs.mkdtempSync(path.join(os.tmpdir(), "pi-wrapper-native-")); + const temp = fs.realpathSync(fs.mkdtempSync(path.join(process.env.TMUX_TMPDIR ?? (process.platform === "darwin" ? "/private/tmp" : os.tmpdir()), "pi-w-"))); const socket = path.join(temp, "tmux.sock"); const session = "pi-wrapped"; const cwd = path.join(temp, "work dir with 'quotes'"); @@ -305,7 +305,7 @@ await main(); const wrapper = ["generic-wrapper", "arg with spaces", "quote's", "$UNEXPANDED;false", "", "--"]; const model = mode === "staged-long" ? "provider/model-" + "m".repeat(1200) - : mode === "long-tmpdir-retry" ? "provider/model-" + "m".repeat(300) + : mode === "long-tmpdir-retry" ? "provider/model-" + "m".repeat(100) : "provider/model-short"; try { @@ -414,7 +414,7 @@ await main(); }, 15_000); it.each(["missing-wrapper", "failing-wrapper-exit-7", "non-exec-zero-exit", "long-tmpdir-too-long"] as const)("fails closed in native tmux on %s without starting bare Pi", async failure => { - const temp = fs.mkdtempSync(path.join(os.tmpdir(), "pi-wrapper-fail-")); + const temp = fs.realpathSync(fs.mkdtempSync(path.join(process.env.TMUX_TMPDIR ?? (process.platform === "darwin" ? "/private/tmp" : os.tmpdir()), "pi-f-"))); const socket = path.join(temp, "tmux.sock"); const session = "pi-fail"; const cwd = path.join(temp, "work"); @@ -535,7 +535,7 @@ await main(); }, 15_000); it.each(["fresh", "fork", "resume"] as const)("executes launch_wrapper in native tmux for Codex %s with resolved interpreter+entry, intact pid, cwd, tty, env and exit code", async mode => { - const temp = fs.mkdtempSync(path.join(os.tmpdir(), "codex-wrapper-native-")); + const temp = fs.realpathSync(fs.mkdtempSync(path.join(process.env.TMUX_TMPDIR ?? (process.platform === "darwin" ? "/private/tmp" : os.tmpdir()), "cx-w-"))); const socket = path.join(temp, "tmux.sock"); const session = "codex-wrapped"; const cwd = path.join(temp, "codex work 'quotes'"); diff --git a/packages/daemon/test/seat-launch-environment-regression.test.ts b/packages/daemon/test/seat-launch-environment-regression.test.ts index 058261095..273fbde63 100644 --- a/packages/daemon/test/seat-launch-environment-regression.test.ts +++ b/packages/daemon/test/seat-launch-environment-regression.test.ts @@ -123,7 +123,7 @@ it("export helper resolves executable from cwd when PATH has empty segments", () }); -it.each(["nu", "nu.exe", "fish", "csh", "", "unknown"])("Pi preserves its existing command on an unsupported pane: %s", async shell => { +it.each(["nu", "nu.exe", "fish", "csh", "", "unknown"])("Pi preserves its account-free command on an unsupported pane: %s", async shell => { const f = fixture(); f.tmux.getPaneCommand.mockResolvedValue(shell); const command = "node '/runner.js' --session '/history.jsonl'"; expect(await f.launch.command("seat@rig", command, { runtime: "pi" })).toBe(command); diff --git a/packages/daemon/test/seat-runtime-home.test.ts b/packages/daemon/test/seat-runtime-home.test.ts index f0f680d13..deaec7003 100644 --- a/packages/daemon/test/seat-runtime-home.test.ts +++ b/packages/daemon/test/seat-runtime-home.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from "vitest"; import Database from "better-sqlite3"; -import { claudeConfigRootFor, declaredRuntimeHomes, effectiveRuntimeHome, seatAccountHome, seatRuntimeHome, withoutRuntimeHomes } from "../src/domain/seat-runtime-home.js"; +import { claudeConfigRootFor, declaredRuntimeHomes, effectiveRuntimeHome, seatAccountHome, seatRuntimeHome, withoutRuntimeHomes, withoutIgnoredRuntimeHomes } from "../src/domain/seat-runtime-home.js"; // A seat's own runtime home comes from its stored `env:`: the member's value, else the rig's. const open: Database.Database[] = []; @@ -83,6 +83,16 @@ describe("withoutRuntimeHomes and claudeConfigRootFor", () => { }); }); +it("resolves Pi member homes and clears a stored selection back to the configured home", () => { + const d = db({ rig: { PI_CODING_AGENT_DIR: "/pi/rig" }, members: { a: { PI_CODING_AGENT_DIR: "/pi/member" }, b: undefined } }); + d.exec("ALTER TABLE nodes ADD COLUMN account_home TEXT; ALTER TABLE nodes ADD COLUMN runtime TEXT"); + d.prepare("UPDATE nodes SET account_home = ?, runtime = 'pi' WHERE id = 'a'").run("/pi/account"); + expect(effectiveRuntimeHome(d, "a", "pi", "/pi/daemon")).toBe("/pi/account"); + expect(effectiveRuntimeHome(d, "a", "pi", "/pi/daemon", true)).toBe("/pi/member"); + expect(seatRuntimeHome(d, "b", "pi")).toEqual({ home: "/pi/rig", source: "rig" }); + expect(withoutRuntimeHomes({ PI_CODING_AGENT_DIR: "/pi/account", DEBUG: "1" })).toEqual({ DEBUG: "1" }); +}); + describe("declaredRuntimeHomes", () => { it("lists each distinct absolute home declared on members and rigs, once", () => { const d = db({ rig: { CODEX_HOME: "/homes/shared" }, @@ -91,3 +101,19 @@ describe("declaredRuntimeHomes", () => { expect(declaredRuntimeHomes(d, "claude-code")).toEqual(["/homes/claude"]); }); }); + +it("never selects an ambient daemon Pi home, including when clearing a stored selection", () => { + const d = db({ members: { own: { PI_CODING_AGENT_DIR: "/pi/member" }, plain: undefined } }); + d.exec("ALTER TABLE nodes ADD COLUMN runtime TEXT; ALTER TABLE nodes ADD COLUMN account_home TEXT; UPDATE nodes SET runtime = 'pi', account_home = '/pi/selected';"); + expect(effectiveRuntimeHome(d, "own", "pi", "/pi/ambient")).toBe("/pi/selected"); + expect(effectiveRuntimeHome(d, "own", "pi", "/pi/ambient", true)).toBe("/pi/member"); + expect(effectiveRuntimeHome(d, "plain", "pi", "/pi/ambient", true)).toBeUndefined(); + d.exec("UPDATE nodes SET account_home = NULL;"); + expect(effectiveRuntimeHome(d, "own", "pi", "/pi/ambient")).toBe("/pi/member"); + expect(effectiveRuntimeHome(d, "plain", "pi", "/pi/ambient")).toBeUndefined(); +}); + +it("preserves relative Pi homes for community harnesses", () => { + expect(withoutIgnoredRuntimeHomes({ PI_CODING_AGENT_DIR: "relative/pi-home", CODEX_HOME: "relative/codex", DEBUG: "1" })) + .toEqual({ PI_CODING_AGENT_DIR: "relative/pi-home", DEBUG: "1" }); +}); diff --git a/packages/daemon/test/seat-runtime-switch-service.test.ts b/packages/daemon/test/seat-runtime-switch-service.test.ts index 2c811d7d0..677b83522 100644 --- a/packages/daemon/test/seat-runtime-switch-service.test.ts +++ b/packages/daemon/test/seat-runtime-switch-service.test.ts @@ -1,3 +1,6 @@ +import { piAccountStateRoot, piAccountSwitchStateRoot } from "../src/domain/pi-account-state.js"; +import { piSeatPaths } from "../src/adapters/pi-runner-protocol.js"; +import { PiRuntimeAdapter } from "../src/adapters/pi-runtime-adapter.js"; import { providerRoutes } from "../src/routes/provider.js"; // Slice 16: a live seat moves between Codex and Claude Code, successor first. Real repositories on an in-memory // database; tmux and the runtime adapters are fakes that keep which pane each session holds, so the exchange, the @@ -87,7 +90,7 @@ describe("live seat runtime switch", () => { const service = (overrides: Partial[0]> = {}) => new SeatRuntimeSwitchService({ db, rigRepo, sessionRegistry, discoveryRepo: new DiscoveryRepository(db), eventBus, tmuxAdapter: fakeTmux as unknown as TmuxAdapter, queueRepo, - runtimeAdapters: { codex: adapter("codex"), "claude-code": adapter("claude-code") }, + runtimeAdapters: { codex: adapter("codex"), "claude-code": adapter("claude-code"), pi: adapter("pi") }, occupantInvalidator: new DefaultOccupantInvalidator({ enforcer: { invalidateOccupant: vi.fn() }, contextUsage: { invalidateOccupantSidecar: vi.fn() }, queue: queueRepo }), activityOracle: { declareOccupantSwap }, openrigHome: home, readinessTimeoutMs: 1000, confirmPollMs: 1000, sleep: async (ms) => { clock += ms; await onSleep?.(); }, now: () => new Date(clock), @@ -132,6 +135,110 @@ describe("live seat runtime switch", () => { }); afterEach(() => { db.close(); fs.rmSync(home, { recursive: true, force: true }); }); + it("switches a Pi account only after confirmation, preserves its seat and selects durable isolated state", async () => { + db.prepare("UPDATE nodes SET runtime = 'pi', model = 'mistral/devstral', effort_choice = 'max', codex_config_profile = NULL WHERE id = ?").run(nodeId); + const base = path.join(home, "state", "pi"); + const original = piSeatPaths(base, SEAT).agentDir; + fs.mkdirSync(original, { recursive: true }); + fs.writeFileSync(path.join(original, "auth.json"), "original-seat-login"); + fs.writeFileSync(path.join(original, "models.json"), "{\"providers\":{}}"); + const accountHome = path.join(home, "account"); fs.mkdirSync(accountHome); + fs.writeFileSync(path.join(accountHome, "auth.json"), "selected-login"); + const item = await queueRepo.create({ sourceSession: "orch-lead@seat-rig", destinationSession: SEAT, body: "review" }); + queueRepo.claim({ qitemId: item.qitemId, destinationSession: SEAT }); + const target = adapter("pi"); + vi.mocked(target.launchHarness).mockImplementation(async binding => { + expect(binding.accountHome).toBe(accountHome); + expect(binding.launchWrapper).toEqual(["wrapper", "--"]); + expect(rigRepo.getEffectiveSeatEnv(nodeId).PI_CODING_AGENT_DIR).toBeUndefined(); + expect(piAccountStateRoot(db, base, SEAT)).toBe(base); + expect(fs.readFileSync(path.join(original, "auth.json"), "utf8")).toBe("original-seat-login"); + const successor = piSeatPaths(piAccountSwitchStateRoot(base, binding.launchGeneration!), SEAT); + expect(fs.readFileSync(path.join(successor.agentDir, "models.json"), "utf8")).toBe("{\"providers\":{}}"); + expect(fs.existsSync(path.join(successor.agentDir, "auth.json"))).toBe(false); + fs.mkdirSync(successor.sessionsDir, { recursive: true }); + const token = path.join(successor.sessionsDir, "conversation.jsonl"); + fs.writeFileSync(token, "{}"); + fs.writeFileSync(successor.runnerStatePath, JSON.stringify({ ready: true, sessionFile: token, launchId: "successor", updatedAt: "now" })); + return { ok: true, resumeType: "pi_session_file", resumeToken: token }; + }); + db.prepare("UPDATE nodes SET launch_wrapper = ? WHERE id = ?").run(JSON.stringify(["wrapper", "--"]), nodeId); + const svc = service({ runtimeAdapters: { pi: target } }); successorConfirms(svc); + expect(await svc.setRuntime({ seatRef: SEAT, runtime: "pi", accountHome, reason: "Change budget" })) + .toMatchObject({ ok: true, changed: true, from: "pi", to: "pi", claimsMoved: 1, recoveryPending: false }); + expect(nodeRow()).toMatchObject({ runtime: "pi", model: "mistral/devstral", effort: "high", effort_choice: "max" }); + expect(rigRepo.getEffectiveSeatEnv(nodeId).PI_CODING_AGENT_DIR).toBe(accountHome); + expect(piAccountStateRoot(db, base, SEAT)).toBe(piAccountSwitchStateRoot(base, openSwitch()!.successor_generation)); + expect(sessionRegistry.getBindingForNode(nodeId)).toMatchObject({ tmuxSession: SEAT, tmuxPane: "%5" }); + // A new adapter (as after a daemon restart) finds the committed occupant's sidecar, not the predecessor's. + const reader = new PiRuntimeAdapter({ tmux: fakeTmux as unknown as TmuxAdapter, stateRoot: base, + stateRootForSession: session => piAccountStateRoot(db, base, session), runnerEntryPath: "/runner.js", + fsOps: { exists: fs.existsSync, readFile: p => fs.readFileSync(p, "utf8"), writeFile: fs.writeFileSync, mkdirp: p => fs.mkdirSync(p, { recursive: true }) } }); + expect(reader.readSessionFile(SEAT)).toMatchObject({ ok: true, sessionFile: expect.stringContaining("account-switches") }); + const rigId = (db.prepare("SELECT rig_id FROM nodes WHERE id = ?").get(nodeId) as { rig_id: string }).rig_id; + const snapshot = new SnapshotRepository(db).findLatestRestoreUsable(rigId); + expect(snapshot?.kind).toBe("runtime-switch"); + expect(snapshot?.data.nodes.find(node => node.id === nodeId)).toMatchObject({ accountHome, runtime: "pi" }); + expect(fs.readFileSync(path.join(original, "auth.json"), "utf8")).toBe("original-seat-login"); + }); + + it("an unconfirmed Pi switch preserves its old account and state root", async () => { + db.prepare("UPDATE nodes SET runtime = 'pi', account_home = '/accounts/old' WHERE id = ?").run(nodeId); + const accountHome = path.join(home, "account"); fs.mkdirSync(accountHome); fs.writeFileSync(path.join(accountHome, "auth.json"), "{}"); + expect(await service().setRuntime({ seatRef: SEAT, runtime: "pi", accountHome, reason: "Try account", confirmTimeoutMs: 1 })) + .toMatchObject({ ok: false, committed: false }); + expect(rigRepo.getEffectiveSeatEnv(nodeId).PI_CODING_AGENT_DIR).toBe("/accounts/old"); + expect(sessionRegistry.getBindingForNode(nodeId)).toMatchObject({ tmuxPane: "%0" }); + expect(piAccountStateRoot(db, home, SEAT)).toBe(home); + }); + + it.each([true, false])("clears a Pi selection preserving source credentials and the original login (configured home: %s)", async configured => { + const accountHome = path.join(home, "account"); fs.mkdirSync(accountHome); fs.writeFileSync(path.join(accountHome, "auth.json"), "selected-login"); + db.prepare("UPDATE nodes SET runtime = 'pi', account_home = ?, env_json = ? WHERE id = ?") + .run(accountHome, JSON.stringify(configured ? { PI_CODING_AGENT_DIR: "/accounts/configured" } : {}), nodeId); + const base = path.join(home, "state", "pi"); + const original = piSeatPaths(base, SEAT).agentDir; + fs.mkdirSync(original, { recursive: true }); + fs.writeFileSync(path.join(original, "auth.json"), "original-seat-login"); + const target = adapter("pi"); + vi.mocked(target.launchHarness).mockImplementation(async binding => { + expect(binding.accountHome).toBeNull(); + const stagedAuth = path.join(piSeatPaths(piAccountSwitchStateRoot(base, binding.launchGeneration!), SEAT).agentDir, "auth.json"); + if (configured) expect(fs.existsSync(stagedAuth)).toBe(false); + else expect(fs.readFileSync(stagedAuth, "utf8")).toBe("original-seat-login"); + expect(fs.readFileSync(path.join(original, "auth.json"), "utf8")).toBe("original-seat-login"); + expect(rigRepo.getEffectiveSeatEnv(nodeId).PI_CODING_AGENT_DIR).toBe(accountHome); + return { ok: true }; + }); + const svc = service({ runtimeAdapters: { pi: target } }); successorConfirms(svc); + expect(await svc.setRuntime({ seatRef: SEAT, runtime: "pi", clearAccountHome: true, reason: "Back to configured" })).toMatchObject({ ok: true, changed: true, accountHome: null }); + expect(rigRepo.getEffectiveSeatEnv(nodeId).PI_CODING_AGENT_DIR).toBe(configured ? "/accounts/configured" : undefined); + expect(fs.readFileSync(path.join(accountHome, "auth.json"), "utf8")).toBe("selected-login"); + expect(await svc.setRuntime({ seatRef: SEAT, runtime: "pi", clearAccountHome: true, reason: "Already cleared" })).toMatchObject({ ok: true, changed: false }); + }); + + it("previews Pi account costs without claiming a runtime change or lost model and effort", async () => { + db.prepare("UPDATE nodes SET runtime = 'pi', model = 'mistral/devstral', effort_choice = 'max' WHERE id = ?").run(nodeId); + const accountHome = path.join(home, "account"); fs.mkdirSync(accountHome); + fs.writeFileSync(path.join(accountHome, "auth.json"), "synthetic-auth"); + const result = await service({ runtimeAdapters: { pi: adapter("pi") } }).setRuntime({ + seatRef: SEAT, runtime: "pi", accountHome, reason: "Change account", dryRun: true, + }); + expect(result).toMatchObject({ ok: true, dryRun: true, from: "pi", to: "pi" }); + if (!result.ok || !result.dryRun) throw new Error("Expected switch preview"); + expect(result.costs.join("\n")).toContain("keeps its model and saved effort choice"); + expect(result.costs.join("\n")).not.toMatch(/change_runtime|settings are cleared/); + expect(result.settings.model.value).toBe("mistral/devstral"); + expect(result.settings.effort.value).toBe("max"); + }); + + it("refuses Pi homes without credentials before staging, and keeps cross-runtime Pi switches out of scope", async () => { + db.prepare("UPDATE nodes SET runtime = 'pi' WHERE id = ?").run(nodeId); + expect(await service().setRuntime({ seatRef: SEAT, runtime: "pi", accountHome: home, reason: "No auth", dryRun: true })).toMatchObject({ ok: false, code: "invalid_runtime_settings" }); + expect(await service().setRuntime({ seatRef: SEAT, runtime: "codex", reason: "Change runtime", dryRun: true })).toMatchObject({ ok: false, code: "runtime_switch_unsupported" }); + expect(fakeTmux.createSession).not.toHaveBeenCalled(); + }); + it("switches a Codex seat to another account home on the same runtime, committing only after confirmation", async () => { const accountHome = path.join(home, "api-account"); fs.mkdirSync(accountHome); @@ -244,19 +351,21 @@ describe("live seat runtime switch", () => { expect(nodeRow().runtime).toBe("codex"); }); - it("the provider-switch route plans the same-runtime account selection through the native switch", async () => { + it.each(["codex", "pi"])("the provider-switch route plans a %s account selection and clear", async runtime => { + db.prepare("UPDATE nodes SET runtime = ? WHERE id = ?").run(runtime, nodeId); const accountHome = path.join(home, "target-account"); fs.mkdirSync(accountHome); + fs.writeFileSync(path.join(accountHome, "auth.json"), "{}"); const app = new Hono(); app.use("*", async (c, next) => { for (const [key, value] of Object.entries({ db, rigRepo, sessionRegistry, eventBus, queueRepo, - discoveryRepo: new DiscoveryRepository(db), tmuxAdapter: fakeTmux, runtimeAdapters: { codex: adapter("codex") } })) c.set(key as never, value as never); + discoveryRepo: new DiscoveryRepository(db), tmuxAdapter: fakeTmux, runtimeAdapters: { [runtime]: adapter(runtime) } })) c.set(key as never, value as never); await next(); }); app.route("/api/provider", providerRoutes()); const response = await app.request("/api/provider/switch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ seat: SEAT, toAccount: "api", accountHome, reason: "Use API budget", dryRun: true }) }); expect(response.status).toBe(200); - expect(await response.json()).toMatchObject({ ok: true, dryRun: true, from: "codex", to: "codex", accountHome }); + expect(await response.json()).toMatchObject({ ok: true, dryRun: true, from: runtime, to: runtime, accountHome }); expect(openSwitch()).toBeUndefined(); const cleared = await app.request("/api/provider/switch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ seat: SEAT, clearAccountHome: true, reason: "Return to configured login", dryRun: true }) }); @@ -631,9 +740,9 @@ describe("live seat runtime switch", () => { guidance: expect.stringMatching(/start it first.*never started.*declare the other runtime.*rig spec.*doesn't carry this seat's edges, settings or queued work/s) }); }); it("a seat on another runtime", async () => { - db.prepare("UPDATE nodes SET runtime = 'pi' WHERE id = ?").run(nodeId); + db.prepare("UPDATE nodes SET runtime = 'omp' WHERE id = ?").run(nodeId); expect(await service().setRuntime({ seatRef: SEAT, runtime: "claude-code", reason: "x", dryRun: true })) - .toMatchObject({ ok: false, code: "runtime_switch_unsupported", message: expect.stringMatching(/only Codex and Claude Code seats can switch/) }); + .toMatchObject({ ok: false, code: "runtime_switch_unsupported", message: expect.stringMatching(/only Codex and Claude Code seats can change runtime/) }); }); it("a target this daemon can't start", async () => { expect(await service({ runtimeAdapters: { codex: adapter("codex") } }).setRuntime({ seatRef: SEAT, runtime: "claude-code", reason: "x", dryRun: true })) @@ -689,6 +798,18 @@ describe("live seat runtime switch", () => { expect(openSwitch()).toMatchObject({ phase: "finished" }); }); + it.each(["preparing", "confirmed", "committed"] as const)("recovers a Pi account switch in phase %s from its durable decision", async phase => { + record(phase); + db.prepare("UPDATE nodes SET runtime = 'pi', account_home = ? WHERE id = ?").run(phase === "committed" ? "/accounts/new" : "/accounts/old", nodeId); + db.exec("UPDATE seat_runtime_switches SET from_runtime = 'pi', to_runtime = 'pi'"); + panes.set(SEAT, phase === "preparing" ? "%0" : "%5"); + panes.set("openrig-switch-sw1", phase === "preparing" ? "%5" : "%0"); + expect(await service().recover(nodeId)).toMatchObject({ ok: true }); + expect(panes.get(SEAT)).toBe(phase === "committed" ? "%5" : "%0"); + expect(rigRepo.getEffectiveSeatEnv(nodeId).PI_CODING_AGENT_DIR).toBe(phase === "committed" ? "/accounts/new" : "/accounts/old"); + expect(piAccountStateRoot(db, home, SEAT)).toBe(phase === "committed" ? piAccountSwitchStateRoot(home, "g-next") : home); + }); + it("panes that match neither side: nothing is touched, and the seat's status says why", async () => { record("preparing"); panes.set(SEAT, "%9"); diff --git a/packages/daemon/test/seat-status-service.test.ts b/packages/daemon/test/seat-status-service.test.ts index f2c9ec562..c3e4e5fad 100644 --- a/packages/daemon/test/seat-status-service.test.ts +++ b/packages/daemon/test/seat-status-service.test.ts @@ -129,12 +129,13 @@ describe("SeatStatusService", () => { own: rigRepo.addNode(rig.id, "dev.own", { runtime: "codex" }), rel: rigRepo.addNode(rig.id, "dev.rel", { runtime: "codex" }), plain: rigRepo.addNode(rig.id, "dev.plain", { runtime: "claude-code" }), + pi: rigRepo.addNode(rig.id, "dev.pi", { runtime: "pi" }), term: rigRepo.addNode(rig.id, "dev.term", { runtime: "terminal" }), }; for (const [name, node] of Object.entries(nodes)) sessionRegistry.registerSession(node.id, `dev-${name}@home-rig`); db.prepare("UPDATE nodes SET env_json = ? WHERE id = ?").run(JSON.stringify({ CODEX_HOME: "/homes/own" }), nodes.own.id); db.prepare("UPDATE nodes SET env_json = ? WHERE id = ?").run(JSON.stringify({ CODEX_HOME: "rel/codex" }), nodes.rel.id); - const svc = new SeatStatusService({ rigRepo, env: { HOME: "/users/op", CODEX_HOME: "/daemon/codex" } }); + const svc = new SeatStatusService({ rigRepo, env: { HOME: "/users/op", CODEX_HOME: "/daemon/codex", PI_CODING_AGENT_DIR: "/daemon/pi-ignored" } }); const status = (ref: string) => { const result = svc.getStatus(ref); if (!result.ok) throw new Error(result.code); @@ -144,6 +145,7 @@ describe("SeatStatusService", () => { expect(status("dev-rel@home-rig").runtime_home).toEqual({ variable: "CODEX_HOME", home: "/daemon/codex", source: "daemon", ignored: { source: "member", value: "rel/codex" } }); expect(status("dev-plain@home-rig").runtime_home).toEqual({ variable: "CLAUDE_CONFIG_DIR", home: "/users/op/.claude", source: "native_default" }); + expect(status("dev-pi@home-rig").runtime_home).toEqual({ variable: "PI_CODING_AGENT_DIR", home: null, source: "native_default" }); expect(status("dev-term@home-rig")).not.toHaveProperty("runtime_home"); }); From 378d25301d4ff39dab8774bd68c2d463ce9bff29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=C3=A1n=20McNamara?= Date: Sat, 7 Nov 2026 09:07:22 +0000 Subject: [PATCH 04/14] fix(cli): avoid cold compile-cache writes for piped bare usage (#2643) bin-wrapper enables Node's compile cache except for a bare invocation with stdin or stdout piped or redirected, which only prints usage. That case loaded the command tree only to print usage, and with a cold cache the deferred cache writes near exit could push it past a caller's deadline after the usage was already printed. Any script argument, including piped --help and --version, and a bare run with both streams on a terminal, keep the existing cache attempt, matching the front door's own predicate. A new bin-wrapper test covers the piped and terminal cases. Contributed by seanmcn; reported in #2642. --- packages/cli/src/bin-wrapper.ts | 3 ++- packages/cli/test/bin-wrapper.test.ts | 37 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/bin-wrapper.ts b/packages/cli/src/bin-wrapper.ts index c1f6aad86..7f5bbd83b 100644 --- a/packages/cli/src/bin-wrapper.ts +++ b/packages/cli/src/bin-wrapper.ts @@ -12,7 +12,8 @@ const REEXEC_GUARD_ENV = "OPENRIG_BIN_REEXEC"; // Each `rig` is a new process: Node's on-disk compile cache lets the next call skip compiling the modules // again. The cache dates from Node v22.1.0, but this JavaScript API from v22.8.0: before it, `?.()` does // nothing. NODE_COMPILE_CACHE and NODE_DISABLE_COMPILE_CACHE still control it. -module.enableCompileCache?.(); +// A piped bare invocation only prints usage; cold-cache writes can otherwise delay its exit. +if (process.argv.length > 2 || (process.stdin.isTTY && process.stdout.isTTY)) module.enableCompileCache?.(); export function resolveBinEntry(invokedPath = process.argv[1], moduleUrl = import.meta.url): string { const wrapperPath = invokedPath ? realpathSync(invokedPath) : realpathSync(fileURLToPath(moduleUrl)); diff --git a/packages/cli/test/bin-wrapper.test.ts b/packages/cli/test/bin-wrapper.test.ts index 80ac4029c..99eee85c0 100644 --- a/packages/cli/test/bin-wrapper.test.ts +++ b/packages/cli/test/bin-wrapper.test.ts @@ -1,6 +1,9 @@ import { afterEach, describe, expect, it } from "vitest"; +import { spawnSync } from "node:child_process"; +import ts from "typescript"; import fs from "node:fs"; import os from "node:os"; +import module from "node:module"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { resolveBinEntry, resolveNodeReexecBinary, run } from "../src/bin-wrapper.js"; @@ -15,6 +18,40 @@ describe("bin-wrapper", () => { } }); + it.skipIf(!module.getCompileCacheDir || !module.enableCompileCache).each([ + { args: [], interactive: false, explicitCache: false, expected: false }, + { args: ["--version"], interactive: false, explicitCache: false, expected: true }, + { args: [], interactive: false, explicitCache: true, expected: true }, + { args: [], interactive: true, explicitCache: false, expected: true }, + ])("compile cache for args=$args, interactive=$interactive, explicitCache=$explicitCache", ({ args, interactive, explicitCache, expected }) => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openrig-bin-cache-")); + const dist = path.join(tempDir, "dist"); + fs.mkdirSync(dist); + fs.writeFileSync(path.join(tempDir, "package.json"), '{ "type": "module" }'); + const wrapper = path.join(dist, "bin-wrapper.js"); + const source = fs.readFileSync(new URL("../src/bin-wrapper.ts", import.meta.url), "utf8"); + const { outputText } = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext }, + }); + fs.writeFileSync(wrapper, outputText); + const report = `import { getCompileCacheDir } from "node:module"; +export async function runProgram() { console.log(JSON.stringify({ enabled: !!getCompileCacheDir() })); } +`; + fs.writeFileSync(path.join(dist, "index.js"), report + "export function createProgram() { return {}; }\n"); + fs.writeFileSync(path.join(dist, "lazy-program.js"), report + "export async function createLazyProgram() { return {}; }\n"); + const env = { ...process.env, TMPDIR: tempDir }; + delete env.NODE_DISABLE_COMPILE_CACHE; + delete env.NODE_COMPILE_CACHE; + if (explicitCache) env.NODE_COMPILE_CACHE = path.join(tempDir, "explicit-cache"); + // Set stream flags before the wrapper loads; front-door.test.ts covers a real PTY launch. + const nodeArgs = interactive + ? ["--import", "data:text/javascript,process.stdin.isTTY=true;process.stdout.isTTY=true"] + : []; + const result = spawnSync(process.execPath, [...nodeArgs, wrapper, ...args], { env, encoding: "utf8", timeout: 5000 }); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ enabled: expected }); + }); + it("resolves the real dist entry from a symlinked wrapper path", () => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openrig-bin-wrapper-")); const wrapperSource = fileURLToPath(new URL("../src/bin-wrapper.ts", import.meta.url)); From 9b1d94d6f030583f23ca028b9384ae77b95fc2bb Mon Sep 17 00:00:00 2001 From: Wu Shuwen Date: Sat, 7 Nov 2026 17:39:01 +0800 Subject: [PATCH 05/14] docs(reference): clarify bearer and browser boundary (#2652) daemon-http-clients.md no longer says a token never gets past the browser boundary's 403 refusals. A valid configured bearer token in the Authorization header can pass the target-name check for a well-formed but unaccepted Host; it cannot pass a malformed Host or a refused Origin. The sentence links Browser access for the full rules. Docs only. Contributed by dajiaohuang; refs #2486, where the correction was reported. --- docs/reference/daemon-http-clients.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/reference/daemon-http-clients.md b/docs/reference/daemon-http-clients.md index ca790a136..8ee23a534 100644 --- a/docs/reference/daemon-http-clients.md +++ b/docs/reference/daemon-http-clients.md @@ -65,8 +65,9 @@ or else from the file `$OPENRIG_HOME/terminal-token`, which the person writes; t A wrong or missing token gets HTTP 401. The routes that check `OPENRIG_AUTH_BEARER_TOKEN`, and `/api/transport` and `/api/compaction`, answer `{ "error": "unauthorized", "message", "what_failed", "why_it_matters", "what_to_do" }`. -The `GET /api/terminal/` routes, the websocket included, answer `{ "error": "unauthorized", "hint" }`. A token -doesn't get past the browser boundary's 403 refusals, which [Browser access](browser-access.md) describes. +The `GET /api/terminal/` routes, the websocket included, answer `{ "error": "unauthorized", "hint" }`. A valid configured bearer token in the Authorization header can pass the browser boundary's target-name check +for a well-formed but unaccepted Host. It cannot pass a malformed Host or a refused Origin; see +[Browser access](browser-access.md). ## Event streams From 0c57bdb38c32888e3d78d461e15896d0ddd8f975 Mon Sep 17 00:00:00 2001 From: Mike Schwarz <171890339+mvschwarz@users.noreply.github.com> Date: Sat, 7 Nov 2026 02:07:43 -0800 Subject: [PATCH 06/14] fix(terminal): preserve styling in snapshots and scroll-back (#2654) The terminal broker's attach snapshot and scroll-back window now request tmux captures with escape sequences, so colour, bold and dim survive the first paint. The painter keeps tmux's carried styling across rows instead of resetting each row, resets once before clearing the screen and once after the last painted row, and replays the styling of rows above the first painted row when a height crop or the scroll-back window drops them. The captures feed only the websocket painter; activity, input-box and prompt detection keep their own plain captures. Reported by niklasbaldi in #2598. --- .../src/terminal/TerminalSessionBroker.ts | 30 +++---- .../test/terminal-session-broker.test.ts | 86 +++++++++++++++++-- 2 files changed, 93 insertions(+), 23 deletions(-) diff --git a/packages/daemon/src/terminal/TerminalSessionBroker.ts b/packages/daemon/src/terminal/TerminalSessionBroker.ts index a2388708f..d9d17da10 100644 --- a/packages/daemon/src/terminal/TerminalSessionBroker.ts +++ b/packages/daemon/src/terminal/TerminalSessionBroker.ts @@ -71,11 +71,11 @@ export interface BrokerTmux { stopPipePane(name: string): Promise; sendKeys(name: string, keys: string[]): Promise; sendText(name: string, text: string): Promise; - capturePaneScreen(name: string): Promise; + capturePaneScreen(name: string, opts?: { escapeSequences?: boolean }): Promise; getPaneCursorPosition(name: string): Promise; /** Capture the last `lines` lines INCLUDING scrollback history (tmux * capture-pane -S -lines). Used for the per-subscriber scroll-back window. */ - capturePaneContent(name: string, lines: number): Promise; + capturePaneContent(name: string, lines: number, opts?: { escapeSequences?: boolean }): Promise; } export interface BrokerOptions { @@ -109,26 +109,25 @@ export function cursorPositionEscape(x: number, y: number): string { * exact same rows tmux has it - never a relative append that drifts as content * scrolls. Normalizes CRLF, drops one trailing print newline, and (when a pane * height is known and the capture is taller) keeps only the last `height` rows. - * Lifted from the FR-4 seed work. + * SGR carries across captured rows. Replay skipped rows' SGR, and reset only at the paint boundaries. */ export function screenSnapshotEscape( snapshot: string, cursor: { x: number; y: number; height?: number } | null, + priorRows = "", ): string { const normalized = snapshot.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); const withoutTrailingPrintNewline = normalized.endsWith("\n") ? normalized.slice(0, -1) : normalized; const rows = withoutTrailingPrintNewline.split("\n"); - const visibleRows = cursor?.height && rows.length > cursor.height - ? rows.slice(rows.length - cursor.height) - : rows; - - const paintedRows = visibleRows + const firstRow = cursor?.height ? Math.max(0, rows.length - cursor.height) : 0; + const priorStyle = `${priorRows}\n${rows.slice(0, firstRow).join("\n")}`.match(/\x1b\[[0-9;:]*m/g)?.join("") ?? ""; + const paintedRows = rows.slice(firstRow) .map((row, index) => `\x1b[${index + 1};1H${row}`) .join(""); - return `\x1b[2J${paintedRows}${cursor ? cursorPositionEscape(cursor.x, cursor.y) : "\x1b[H"}`; + return `\x1b[0m\x1b[2J${priorStyle}${paintedRows}\x1b[0m${cursor ? cursorPositionEscape(cursor.x, cursor.y) : "\x1b[H"}`; } /** @@ -340,7 +339,7 @@ export class TerminalSessionBroker { // the TOP `rows` would jump a whole extra screen up on the first wheel notch. let content: string | null = null; try { - content = await this.tmux.capturePaneContent(this.sessionName, clamped + this.rows); + content = await this.tmux.capturePaneContent(this.sessionName, clamped + this.rows, { escapeSequences: true }); } catch { content = null; } @@ -350,11 +349,10 @@ export class TerminalSessionBroker { // element is the true live-bottom row and the offset stays honest. if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); const bottom = lines.length - clamped; // exclusive end: the window sits `offset` up - const window = bottom <= this.rows - ? lines.slice(0, this.rows) // scrolled at/past the top of history: oldest screenful - : lines.slice(bottom - this.rows, bottom); + const firstRow = Math.max(0, bottom - this.rows); + const window = lines.slice(firstRow, firstRow + this.rows); try { - sub.send(screenSnapshotEscape(window.join("\n"), null)); + sub.send(screenSnapshotEscape(window.join("\n"), null, lines.slice(0, firstRow).join("\n"))); } catch { /* dead subscriber */ } } @@ -362,7 +360,7 @@ export class TerminalSessionBroker { private async repaintScreen(sub: TerminalSubscriber): Promise { let snapshot: string | null = null; let cursor: TmuxCursorPosition | null = null; - try { snapshot = await this.tmux.capturePaneScreen(this.sessionName); } catch { snapshot = null; } + try { snapshot = await this.tmux.capturePaneScreen(this.sessionName, { escapeSequences: true }); } catch { snapshot = null; } try { cursor = await this.tmux.getPaneCursorPosition(this.sessionName); } catch { cursor = null; } if (snapshot !== null) { try { sub.send(screenSnapshotEscape(snapshot, cursor)); } catch { /* dead */ } @@ -475,7 +473,7 @@ export class TerminalSessionBroker { let snapshot: string | null = null; let cursor: TmuxCursorPosition | null = null; try { - snapshot = await this.tmux.capturePaneScreen(this.sessionName); + snapshot = await this.tmux.capturePaneScreen(this.sessionName, { escapeSequences: true }); } catch { snapshot = null; } diff --git a/packages/daemon/test/terminal-session-broker.test.ts b/packages/daemon/test/terminal-session-broker.test.ts index d59d5eb03..38447a7ac 100644 --- a/packages/daemon/test/terminal-session-broker.test.ts +++ b/packages/daemon/test/terminal-session-broker.test.ts @@ -64,7 +64,7 @@ describe("cursor-safe seed helpers", () => { it("screenSnapshotEscape paints each row with an ABSOLUTE move (no row drift)", () => { const out = screenSnapshotEscape("alpha\nbeta\ngamma", { x: 2, y: 1, height: 24 }); - expect(out.startsWith("\x1b[2J")).toBe(true); + expect(out.startsWith("\x1b[0m\x1b[2J")).toBe(true); expect(out).toContain("\x1b[1;1Halpha"); expect(out).toContain("\x1b[2;1Hbeta"); expect(out).toContain("\x1b[3;1Hgamma"); @@ -79,8 +79,25 @@ describe("cursor-safe seed helpers", () => { expect(out).toContain("\x1b[2;1Hr5"); }); + it("preserves dim, bold and colour across rows and resets only at paint boundaries", () => { + const out = screenSnapshotEscape("\x1b[2msuggestion\n\x1b[1;31mwarning\nplain", null); + expect(out).toBe("\x1b[0m\x1b[2J\x1b[1;1H\x1b[2msuggestion\x1b[2;1H\x1b[1;31mwarning\x1b[3;1Hplain\x1b[0m\x1b[H"); + }); + + it.each(["\x1b[2m", "\x1b[44m"])("carries %j across a row with no SGR of its own", (sgr) => { + expect(screenSnapshotEscape(`${sgr}first\nsecond\x1b[0m`, null)) + .toBe(`\x1b[0m\x1b[2J\x1b[1;1H${sgr}first\x1b[2;1Hsecond\x1b[0m\x1b[0m\x1b[H`); + }); + + it("replays cumulative dim, bold and colour when height cropping drops their rows", () => { + const out = screenSnapshotEscape("\x1b[2mold\n\x1b[1;31mmiddle\n\x1b[32mvisible\nlast\x1b[0m", { x: 0, y: 1, height: 2 }); + expect(out).toBe("\x1b[0m\x1b[2J\x1b[2m\x1b[1;31m\x1b[1;1H\x1b[32mvisible\x1b[2;1Hlast\x1b[0m\x1b[0m\x1b[2;1H"); + expect(out).not.toContain("old"); + expect(out).not.toContain("middle"); + }); + it("normalizes CRLF and drops exactly one trailing newline; null cursor homes", () => { - expect(screenSnapshotEscape("a\r\nb\r\n", null)).toBe("\x1b[2J\x1b[1;1Ha\x1b[2;1Hb\x1b[H"); + expect(screenSnapshotEscape("a\r\nb\r\n", null)).toBe("\x1b[0m\x1b[2J\x1b[1;1Ha\x1b[2;1Hb\x1b[0m\x1b[H"); }); }); @@ -261,7 +278,7 @@ describe("TerminalSessionBroker", () => { await broker.attach(a); expect(a.received[0]).toBe(screenSnapshotEscape("line one\nline two", { x: 3, y: 1, height: 40 })); - expect(a.received[0]!.startsWith("\x1b[2J")).toBe(true); + expect(a.received[0]!.startsWith("\x1b[0m\x1b[2J")).toBe(true); }); it("test 8b: EACH subscriber gets its own seed (2nd subscriber seeded too, no shared pipe)", async () => { @@ -284,7 +301,7 @@ describe("TerminalSessionBroker", () => { const a = makeSub(); await broker.attach(a); - expect(capturePaneScreen).toHaveBeenCalledWith("dev@rig"); + expect(capturePaneScreen).toHaveBeenCalledWith("dev@rig", { escapeSequences: true }); const seed = a.received[0]!; expect(seed).toContain("\x1b[1;1Hr1"); expect(seed).toContain("\x1b[3;1Hr3"); @@ -653,6 +670,61 @@ describe("TerminalSessionBroker - teardown during seed (honest close)", () => { }); }); +// The adapter models capture-pane: it returns SGR only when the caller asks for -e. +describe("terminal snapshot styling (#2598)", () => { + const styled = "\x1b[2msuggestion\x1b[0m\nplain"; + const captureScreen = async (_name: string, opts?: { escapeSequences?: boolean }) => + opts?.escapeSequences ? styled : "suggestion\nplain"; + + it("keeps a dim suggestion on every attach", async () => { + const broker = track(new TerminalSessionBroker("dev@rig", makeTmux({ + capturePaneScreen: captureScreen, + }))); + for (const sub of [makeSub(), makeSub()]) { + await broker.attach(sub); + expect(sub.received[0]).toContain("\x1b[2msuggestion\x1b[0m"); + } + }); + + it("keeps dim text in the selected scroll-back window", async () => { + const broker = track(new TerminalSessionBroker("dev@rig", makeTmux({ + capturePaneContent: async (_name: string, _lines: number, opts?: { escapeSequences?: boolean }) => + opts?.escapeSequences ? "\x1b[2mhistory\x1b[0m\nplain\nlive" : "history\nplain\nlive", + }), { rows: 2 })); + const sub = makeSub(); + await broker.attach(sub); + await broker.scroll(sub, 1); + expect(sub.received.at(-1)).toContain("\x1b[1;1H\x1b[2mhistory\x1b[0m"); + expect(sub.received.at(-1)).toContain("\x1b[2;1Hplain"); + expect(sub.received.at(-1)).not.toContain("live"); + }); + + it("replays dim from above the first visible scroll-back row", async () => { + const broker = track(new TerminalSessionBroker("dev@rig", makeTmux({ + capturePaneContent: async () => "\x1b[2molder\ncontinued\nlast\x1b[0m\nlive\nbottom", + }), { rows: 2 })); + const sub = makeSub(); + await broker.attach(sub); + await broker.scroll(sub, 2); + expect(sub.received.at(-1)).toBe("\x1b[0m\x1b[2J\x1b[2m\x1b[1;1Hcontinued\x1b[2;1Hlast\x1b[0m\x1b[0m\x1b[H"); + expect(sub.received.at(-1)).not.toContain("older"); + }); + + it("keeps a dim suggestion when returning from scroll-back to live", async () => { + const broker = track(new TerminalSessionBroker("dev@rig", makeTmux({ + capturePaneScreen: captureScreen, + capturePaneContent: async () => "history\nplain\nlive", + }), { rows: 2 })); + const sub = makeSub(); + await broker.attach(sub); + await broker.scroll(sub, 1); + const beforeReturn = sub.received.length; + await broker.scroll(sub, 0); + expect(sub.received.length).toBe(beforeReturn + 1); + expect(sub.received.at(-1)).toContain("\x1b[2msuggestion\x1b[0m"); + }); +}); + // ---- OPR.0.4.0.39 per-subscriber scroll-back (tmux capture-pane window) ------ // The live xterm screen is only the current `rows`; scrolling UP must show tmux // SCROLLBACK. Because the broker fans ONE pipe out to many viewers, scroll-back is @@ -684,10 +756,10 @@ describe("TerminalSessionBroker - per-subscriber scroll-back (OPR.0.4.0.39)", () // Captures (offset + rows) = 3 + 3 = 6 lines back; the painted window is `rows` (3) // lines ending `offset` (3) ABOVE the live bottom: bottom row = L200 - 3 = L197, // so the window is L195..L197 (NOT the older top of the capture, NOT the live tail). - expect(capturePaneContent).toHaveBeenCalledWith("dev@rig", 6); + expect(capturePaneContent).toHaveBeenCalledWith("dev@rig", 6, { escapeSequences: true }); expect(a.received.length).toBe(aBefore + 1); const painted = a.received[a.received.length - 1]!; - expect(painted.startsWith("\x1b[2J")).toBe(true); + expect(painted.startsWith("\x1b[0m\x1b[2J")).toBe(true); expect(painted).toContain("\x1b[1;1HL195"); expect(painted).toContain("\x1b[2;1HL196"); expect(painted).toContain("\x1b[3;1HL197"); @@ -734,7 +806,7 @@ describe("TerminalSessionBroker - per-subscriber scroll-back (OPR.0.4.0.39)", () await broker.scroll(a, 0); // back to the live bottom - expect(capturePaneScreen).toHaveBeenCalledWith("dev@rig"); + expect(capturePaneScreen).toHaveBeenCalledWith("dev@rig", { escapeSequences: true }); expect(a.received.length).toBe(beforeReturn + 1); expect(a.received[a.received.length - 1]!).toContain("LIVE SCREEN"); From 73638fcdddda4bcd4762e4ee4b56edcea58f6e61 Mon Sep 17 00:00:00 2001 From: Mike Schwarz <171890339+mvschwarz@users.noreply.github.com> Date: Sat, 7 Nov 2026 02:09:44 -0800 Subject: [PATCH 07/14] feat(cli): rig acp serve, OpenRig as an agent in ACP apps (#2648) rig acp serve runs OpenRig as an ACP agent over stdio. A prompt becomes a queue row to the receiver named by --to, attributed to the caller (--as, the seat environment, or the default person) with the session's follow-up lineage in its body; the turn follows the row and its handoffs, including handoff-and-complete's done/handed_off_to form, and ends with the team's result, naming the closer from the row's closing transition. The row ID is chosen before the create: when a create gets no answer, a readback that finds the row carries on, and one that doesn't keeps the ID as unconfirmed in the session's lineage; an answered 4xx is a plain refusal and a 5xx stays unknown. The JSON-RPC peer splits into the existing client end and a new stream end over stdin and stdout, with no protocol SDK dependency. The queue is the only store, and a 2 s poll follows open turns. --- docs/as-built/cli-reference.md | 1 + docs/reference/acp-agents.md | 42 ++++ packages/cli/src/acp/agent-server.ts | 286 ++++++++++++++++++++++++ packages/cli/src/acp/json-rpc-stdio.ts | 94 +++++--- packages/cli/src/commands/acp.ts | 114 +++++++++- packages/cli/test/acp-serve.test.ts | 284 +++++++++++++++++++++++ scripts/generate-context-packs.test.mjs | 4 +- 7 files changed, 787 insertions(+), 38 deletions(-) create mode 100644 packages/cli/src/acp/agent-server.ts create mode 100644 packages/cli/test/acp-serve.test.ts diff --git a/docs/as-built/cli-reference.md b/docs/as-built/cli-reference.md index 2a8356e1c..a66169d47 100644 --- a/docs/as-built/cli-reference.md +++ b/docs/as-built/cli-reference.md @@ -414,6 +414,7 @@ do not establish connectivity to a live peer. |---|---|---| | `rig acp` | — | — | | `rig acp run ` | — | `--cwd `
`--permissions-to `
`--load-session `
`--poll-seconds ` | +| `rig acp serve` | — | `--to `
`--as
`
`--poll-seconds ` | | `rig acp cancel ` | — | — | | `rig acp stop [stopCommand...]` | — | — | diff --git a/docs/reference/acp-agents.md b/docs/reference/acp-agents.md index d22352b5f..6244a4340 100644 --- a/docs/reference/acp-agents.md +++ b/docs/reference/acp-agents.md @@ -181,3 +181,45 @@ rig exec cancel - One run is one turn for one row. - The client offers the agent no file-system or terminal methods; the agent works in its own environment. - Spend is whatever the agent's provider charges. OpenRig doesn't measure it; it records only what the agent reports. + +## OpenRig as an agent in ACP apps + +The other direction: `rig acp serve`. An app that speaks ACP and can launch a custom agent command can offer your OpenRig team as its agent. The app starts +`rig acp serve` and talks to it on stdin and stdout: + +```sh +rig acp serve --to +``` + +- **A prompt** becomes a queue row from you to `--to`, the seat that takes your team's new work. The row's body is your + prompt, plus a line saying which app sent it and that its closing resolution is your answer. +- **Progress:** each row the work reaches shows in the app as a step (`Sent to …`, then `Handed to …` at each handoff), + with the row's notes as they're written, each with the seat that wrote it. +- **The reply** is the closing resolution, with who closed the row and how (`done`, `done (canceled)`, `denied`, …). The + turn ending says only that the turn ended: read the reply for how the work ended. +- **A follow-up** in the same session goes to `--to` as a new row whose body names the earlier rows and their states, + and the app first shows any earlier row that's still open. +- **Cancel** stops `rig acp serve` waiting. The team's work carries on, and the reply says so. +- **Work handed to a seat on another machine** can't be followed from here; the reply says where it went. + +**Who you are:** `--as
`, else `OPENRIG_SESSION_NAME`, else this machine's default person +(`rig gateway default `; the address is `@external`). The app's sign-in step, "OpenRig on this machine", checks +that the daemon answers and that you resolve to an address; until then, signing in and new sessions fail with the +reason and the fix. The agent talks only to the daemon on this computer, so start it first (`rig daemon start`). + +**In Zed**, add the agent to `settings.json`: + +```json +"agent_servers": { + "OpenRig": { "type": "custom", "command": "rig", "args": ["acp", "serve", "--to", "lead@my-team"] } +} +``` + +Other apps take the same command and arguments wherever they configure a custom ACP agent. + +**Limits:** +- One receiver per agent command; the app has no team picker. +- The team's questions to you, and reopening an earlier session, aren't served: a question addressed to you waits in + your usual places (the TUI, Slack). +- Prompts are text. Links and embedded text files are passed on as written; images and audio aren't accepted. +- Progress is read every 2 seconds while a turn is open (`--poll-seconds`). diff --git a/packages/cli/src/acp/agent-server.ts b/packages/cli/src/acp/agent-server.ts new file mode 100644 index 000000000..70c5596af --- /dev/null +++ b/packages/cli/src/acp/agent-server.ts @@ -0,0 +1,286 @@ +import { randomBytes } from "node:crypto"; +import type { JsonRpcPeer } from "./json-rpc-stdio.js"; + +// OpenRig as an ACP agent (rig acp serve): an ACP app's prompt becomes a queue row to the team's receiver, the row's +// transitions and handoffs come back as progress, and its closing resolution is the reply. The queue is the only work +// store; this process keeps nothing but which rows each open session sent. A turn's end_turn says only that the turn +// ended: the reply says whether the team closed the item, and a cancel stops this process waiting, never the team. + +export const ACP_PROTOCOL_VERSION = 1; +export const AUTH_METHOD_ID = "openrig-local"; + +/** One queue row as this adapter reads it. */ +export interface TeamRow { + qitemId: string; + state: string; + destinationSession: string; + handedOffTo: string | null; + closureReason: string | null; + closureTarget: string | null; + resolution: string | null; + /** Who closed the row, from its closing transition (a note written after the close doesn't change it); null while open. */ + closer: string | null; +} + +export interface TeamTransition { + transitionId: number; + ts: string; + state: string; + transitionNote: string | null; + actorSession: string; +} + +/** The daemon answered a create and refused it, so nothing was written. Any other failure of a create leaves it unknown. */ +export class TeamRefused extends Error {} + +/** The queue verbs the adapter uses, acting as the caller. */ +export interface TeamQueue { + /** Create the row under the id the adapter chose, so a create whose answer is lost can be read back by that id. Throws + * TeamRefused only for a refusal the daemon answered with. */ + create(row: { qitemId: string; destination: string; body: string; summary: string; tags: string[] }): Promise; + show(qitemId: string): Promise; + /** The row, or null when this machine has no such row; throws when it can't tell. */ + find(qitemId: string): Promise; + transitions(qitemId: string): Promise; + /** The row a handoff of `qitemId` created on this machine, if any. */ + successor(qitemId: string): Promise; +} + +/** What a session works through: the queue as the caller, and the seat that receives the caller's tasks (when set). */ +export interface TeamConnection { + queue: TeamQueue; + caller: string; + receiver: string | undefined; +} + +export interface AcpAgentOptions { + /** Reaches the local daemon and resolves the caller; returns guidance when it can't. Called lazily, never at initialize. */ + connect: () => Promise; + version: string; + pollMs: number; + log?: (line: string) => void; + sleep?: (ms: number) => Promise; + /** Settles when the app's connection ends: open turns stop following their work (nothing could carry a reply). */ + closed?: Promise; +} + +const ACTIVE_STATES = new Set(["pending", "in-progress", "blocked"]); +const AUTH_REQUIRED = -32000; +const INVALID_PARAMS = -32602; + +class AcpError extends Error { + constructor(readonly code: number, message: string, readonly data?: unknown) { super(message); } +} + +interface Session { + id: string; + /** The first row each prompt created, oldest first, with the last row its work reached. */ + sent: Array<{ root: string; tail: string; closed: boolean }>; + turn?: { cancel: () => void }; +} + +type ContentBlock = { type: string; text?: string; uri?: string; name?: string; resource?: { uri?: string; text?: string } }; + +/** The prompt as plain text for the row's body: text as written, links and embedded text resources named. */ +export function promptText(prompt: ContentBlock[]): string { + const parts: string[] = []; + for (const block of prompt) { + if (block.type === "text" && block.text) parts.push(block.text); + else if (block.type === "resource_link" && block.uri) parts.push(`[${block.name ?? "link"}](${block.uri})`); + else if (block.type === "resource" && block.resource?.uri) { + parts.push(block.resource.text !== undefined ? `${block.resource.uri}:\n\`\`\`\n${block.resource.text}\n\`\`\`` : block.resource.uri); + } + } + return parts.join("\n\n").trim(); +} + +/** A queue row id in the form the CLI mints (`qitem--<16 hex>`). */ +export const newQitemId = () => `qitem-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(8).toString("hex")}`; + +function oneLine(text: string, max: number): string { + const line = text.split("\n").find((l) => l.trim())?.trim() ?? ""; + return line.length > max ? `${line.slice(0, max - 1)}…` : line; +} + +const toolStatus = (state: string) => + state === "pending" ? "pending" : ACTIVE_STATES.has(state) ? "in_progress" : state === "done" || state === "handed-off" ? "completed" : "failed"; + +const transitionLine = (t: TeamTransition) => + `${t.ts.slice(11, 19)}Z ${t.actorSession} · ${t.state}${t.transitionNote ? `: ${t.transitionNote}` : ""}`; + +export function serveAcpAgent(peer: JsonRpcPeer, options: AcpAgentOptions): void { + const sessions = new Map(); + const sleep = options.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + const log = options.log ?? (() => {}); + let clientName = "an ACP app"; + let connection: TeamConnection | undefined; + void options.closed?.then(() => { for (const session of sessions.values()) session.turn?.cancel(); }); + + const connected = async (): Promise => { + if (connection) return connection; + const result = await options.connect(); + if ("error" in result) throw new AcpError(AUTH_REQUIRED, result.error); + connection = result; + return result; + }; + + const update = (sessionId: string, body: Record) => peer.notify("session/update", { sessionId, update: body }); + const say = (sessionId: string, text: string) => update(sessionId, { sessionUpdate: "agent_message_chunk", content: { type: "text", text } }); + + peer.onRequest("initialize", async (params) => { + const p = (params ?? {}) as { clientInfo?: { name?: string; title?: string } }; + clientName = p.clientInfo?.title ?? p.clientInfo?.name ?? clientName; + return { + protocolVersion: ACP_PROTOCOL_VERSION, + agentCapabilities: { + loadSession: false, + promptCapabilities: { image: false, audio: false, embeddedContext: true }, + mcpCapabilities: { http: false, sse: false }, + }, + authMethods: [{ + id: AUTH_METHOD_ID, + name: "OpenRig on this machine", + description: "Sends your work to your OpenRig team through the OpenRig daemon running on this computer, as you; this agent talks only to that daemon. Start it with `rig daemon start`.", + }], + agentInfo: { name: "openrig", title: "OpenRig", version: options.version }, + }; + }); + + peer.onRequest("authenticate", async (params) => { + const methodId = (params as { methodId?: unknown } | undefined)?.methodId; + if (methodId !== AUTH_METHOD_ID) throw new AcpError(INVALID_PARAMS, `Unknown auth method ${String(methodId)}; this agent offers ${AUTH_METHOD_ID}`); + const team = await connected(); + log(`authenticated as ${team.caller}${team.receiver ? `, sending to ${team.receiver}` : ", with no receiver set"}`); + return {}; + }); + + peer.onRequest("session/new", async () => { + await connected(); + const id = `acps-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(4).toString("hex")}`; + sessions.set(id, { id, sent: [] }); + return { sessionId: id }; + }); + + peer.onNotification("session/cancel", (params) => { + const sessionId = (params as { sessionId?: string } | undefined)?.sessionId; + if (sessionId) sessions.get(sessionId)?.turn?.cancel(); + }); + + peer.onRequest("session/prompt", async (params) => { + const p = (params ?? {}) as { sessionId?: string; prompt?: ContentBlock[] }; + const session = p.sessionId ? sessions.get(p.sessionId) : undefined; + if (!session) throw new AcpError(INVALID_PARAMS, `No session ${String(p.sessionId)} in this agent; start one with session/new`); + if (session.turn) throw new AcpError(INVALID_PARAMS, "This session already has a turn running; wait for it or cancel it first"); + const team = await connected(); + const text = promptText(Array.isArray(p.prompt) ? p.prompt : []); + if (!text) { + say(session.id, "Nothing to send: the prompt had no text."); + return { stopReason: "end_turn" }; + } + if (!team.receiver) { + say(session.id, "This OpenRig agent has no team receiver, so nothing was sent. Add `--to ` (the seat that takes your team's new work) to the agent's command in your app's settings."); + return { stopReason: "end_turn" }; + } + + let cancelled = false; + let wake: () => void = () => {}; + session.turn = { cancel: () => { cancelled = true; wake(); } }; + let root: string | undefined; + try { + // Each earlier item as it stands; one whose create was never confirmed may not exist, or can't be read: say which. + const earlier = await Promise.all(session.sent.map(async (s) => { + const row = await team.queue.find(s.tail).catch(() => undefined); + return { ...s, row, state: row === undefined ? "state unknown" : row === null ? "not found here" : row.state }; + })); + const open = earlier.filter((s) => s.row && ACTIVE_STATES.has(s.row.state)); + if (open.length > 0) say(session.id, `${open.map((s) => `${s.row!.qitemId} is still ${s.row!.state} with ${s.row!.destinationSession}`).join("; ")}. Sending this as a follow-up.\n\n`); + const lineage = earlier.length === 0 ? "" : `\nFollows up on ${earlier.map((s) => `${s.root} (${s.tail === s.root ? "" : `reached ${s.tail}, `}${s.state})`).join(", ")}.`; + const body = `${text}\n\n---\nSent by ${team.caller} from ${clientName} through rig acp serve (session ${session.id}).${lineage}\n` + + `The app shows this item's notes and handoffs as progress, and its closing resolution as the answer: close it with a resolution written for ${team.caller}.`; + const qitemId = newQitemId(); + try { + await team.queue.create({ qitemId, destination: team.receiver, body, summary: `ACP: ${oneLine(text, 100)}`, tags: ["acp-serve", `acp-session:${session.id}`] }); + } catch (error) { + const reason = (error as Error).message; + // A refusal the daemon answered with wrote nothing, so it can be said plainly. + if (error instanceof TeamRefused) throw new AcpError(-32603, `The team's queue refused the task: ${reason}`); + // No answer: the request may still land after any readback, so only a row that reads back settles it. + const landed = await team.queue.find(qitemId).catch(() => undefined); + if (!landed) { + session.sent.push({ root: qitemId, tail: qitemId, closed: false }); + say(session.id, `Sending ${qitemId} to ${team.receiver} got no answer (${reason}), and ${landed === null ? "it wasn't found on the immediate readback" : "it couldn't be read back"}: delivery is unconfirmed, so it may still reach the team. A new prompt in this session names it, and \`rig queue show ${qitemId}\` shows it if it landed.`); + throw new AcpError(-32603, `Couldn't confirm ${qitemId}: ${reason}`); + } + log(`session ${session.id}: ${qitemId}'s create got no answer, but it reads back`); + } + root = qitemId; + const sent = { root, tail: root, closed: false }; + session.sent.push(sent); + log(`session ${session.id}: sent ${root} to ${team.receiver}`); + + // ponytail: polls the local daemon for the chain's rows and transitions; the daemon's event stream is the upgrade + // path if a poll per open turn ever costs too much. + const chain: string[] = [root]; + const seen = new Map(); + for (;;) { + let tail!: TeamRow; + for (const qitemId of chain) { + const row = await team.queue.show(qitemId); + const transitions = await team.queue.transitions(qitemId); + tail = row; + const status = toolStatus(row.state); + let entry = seen.get(qitemId); + if (!entry) { + entry = { lines: [], lastId: 0, status }; + seen.set(qitemId, entry); + update(session.id, { + sessionUpdate: "tool_call", toolCallId: qitemId, kind: "other", status, + title: qitemId === root ? `Sent to ${row.destinationSession} (${qitemId})` : `Handed to ${row.destinationSession} (${qitemId})`, + rawInput: { qitemId, destination: row.destinationSession }, + }); + } + const fresh = transitions.filter((t) => t.transitionId > entry!.lastId); + if (fresh.length > 0 || status !== entry.status) { + for (const t of fresh) { entry.lines.push(transitionLine(t)); entry.lastId = Math.max(entry.lastId, t.transitionId); } + entry.status = status; + update(session.id, { + sessionUpdate: "tool_call_update", toolCallId: qitemId, status, + ...(entry.lines.length > 0 ? { content: [{ type: "content", content: { type: "text", text: entry.lines.join("\n") } }] } : {}), + }); + } + } + + sent.tail = tail.qitemId; + // Both handoff forms pass the work on: `handoff` leaves the row handed-off, `handoff-and-complete` closes it done + // with closure reason handed_off_to. Either way the answer is the successor's, not this row's. + if (tail.state === "handed-off" || (tail.state === "done" && tail.closureReason === "handed_off_to")) { + const next = await team.queue.successor(tail.qitemId); + if (next) { chain.push(next); continue; } + sent.closed = true; + say(session.id, `${tail.qitemId} was handed to ${tail.handedOffTo ?? tail.closureTarget ?? "another seat"}, whose row isn't on this machine, so this app can't follow it further. Ask your team for its result.`); + return { stopReason: "end_turn" }; + } + if (!ACTIVE_STATES.has(tail.state)) { + sent.closed = true; + const who = tail.closer ?? tail.destinationSession; + // A done row's closure reason says how it ended (canceled is a done close too); the ordinary one goes unsaid. + const reason = tail.closureReason && tail.closureReason !== "no-follow-on" ? ` (${tail.closureReason})` : ""; + const how = tail.state === "done" ? `done${reason}` : `as ${tail.state}${reason}`; + say(session.id, `${who} closed ${tail.qitemId} ${how}.\n\n${tail.resolution?.trim() || "(No resolution was written.)"}`); + return { stopReason: "end_turn" }; + } + if (cancelled) { + say(session.id, `Stopped waiting. ${tail.qitemId} is still ${tail.state} with ${tail.destinationSession}: cancelling here doesn't stop the team's work. A new prompt in this session goes to the team as a follow-up and shows where this stands.`); + return { stopReason: "cancelled" }; + } + await Promise.race([sleep(options.pollMs), new Promise((resolve) => { wake = resolve; })]); + } + } catch (error) { + if (error instanceof AcpError || !root) throw error; + say(session.id, `Lost track of ${root}: ${(error as Error).message}. The item stays with the team; its state is unknown from here.`); + throw new AcpError(-32603, `Couldn't follow ${root}: ${(error as Error).message}`); + } finally { + session.turn = undefined; + } + }); +} diff --git a/packages/cli/src/acp/json-rpc-stdio.ts b/packages/cli/src/acp/json-rpc-stdio.ts index a6ec44472..db0040d66 100644 --- a/packages/cli/src/acp/json-rpc-stdio.ts +++ b/packages/cli/src/acp/json-rpc-stdio.ts @@ -1,9 +1,10 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { createInterface } from "node:readline"; -// ACP v1 is JSON-RPC 2.0 over newline-delimited JSON on the agent's stdio. This peer covers what an OpenRig client -// needs (a few outgoing requests, incoming requests and notifications); the official SDK would add a dependency for -// a handful of methods, so swap to it only if the method set grows. +// ACP v1 is JSON-RPC 2.0 over newline-delimited JSON on the agent's stdio. This peer covers what OpenRig needs on either +// side (a few outgoing requests, incoming requests and notifications): StdioJsonRpcPeer is the client end of an agent +// process OpenRig launched, and JsonRpcPeer over this process's own stdin and stdout is the agent end (rig acp serve). +// The official SDK would add a dependency for a handful of methods, so swap to it only if the method set grows. export interface JsonRpcErrorObject { code: number; @@ -33,27 +34,18 @@ export type NotificationHandler = (params: unknown) => void; /** Every packet in both directions, in order; lines that aren't JSON arrive as `in-text`. */ export type PacketRecorder = (direction: "out" | "in" | "in-text", packet: unknown) => void; -export class StdioJsonRpcPeer { +/** One JSON-RPC connection over a pair of streams: incoming lines on `input`, outgoing packets written to `output`. */ +export class JsonRpcPeer { private nextId = 0; private readonly pending = new Map void; reject: (error: Error) => void }>(); private readonly requestHandlers = new Map(); private readonly notificationHandlers = new Map(); private ended = false; - private readonly lines: ReturnType; - readonly exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>; + protected readonly lines: ReturnType; - constructor(private readonly child: ChildProcessWithoutNullStreams, private readonly record: PacketRecorder = () => {}) { - this.lines = createInterface({ input: child.stdout }); + constructor(input: NodeJS.ReadableStream, private readonly output: NodeJS.WritableStream, private readonly record: PacketRecorder = () => {}) { + this.lines = createInterface({ input }); this.lines.on("line", (line) => this.receive(line)); - this.exited = new Promise((resolve) => child.once("close", (code, signal) => { - this.ended = true; - for (const [id, request] of this.pending) { - this.pending.delete(id); - request.reject(new JsonRpcTransportClosed(request.method)); - } - resolve({ code, signal }); - })); - child.stdin.on("error", () => { /* EPIPE after the agent exits; pending requests reject on close */ }); } request(method: string, params: unknown): Promise { @@ -77,25 +69,18 @@ export class StdioJsonRpcPeer { this.notificationHandlers.set(method, handler); } - /** Close the agent's stdin (detach) and wait for the process to exit, up to `timeoutMs`. */ - async end(timeoutMs: number): Promise<{ code: number | null; signal: NodeJS.Signals | null } | "still-running"> { - this.child.stdin.end(); - let timer: NodeJS.Timeout | undefined; - const timeout = new Promise<"still-running">((resolve) => { timer = setTimeout(() => resolve("still-running"), timeoutMs); }); - try { return await Promise.race([this.exited, timeout]); } finally { clearTimeout(timer); } - } - - /** Drop this side's hold on the agent (its pipes and process handle) without signalling it, so a detached agent that - * keeps running doesn't keep the caller's process alive. */ - release(): void { - this.lines.close(); - for (const stream of [this.child.stdin, this.child.stdout, this.child.stderr]) stream.destroy(); - this.child.unref(); + /** The connection is gone: nothing more is sent, and every request still waiting rejects as closed. */ + protected close(): void { + this.ended = true; + for (const [id, request] of this.pending) { + this.pending.delete(id); + request.reject(new JsonRpcTransportClosed(request.method)); + } } private send(packet: Record): void { this.record("out", packet); - this.child.stdin.write(`${JSON.stringify(packet)}\n`); + this.output.write(`${JSON.stringify(packet)}\n`); } private receive(line: string): void { @@ -127,7 +112,7 @@ export class StdioJsonRpcPeer { private async answer(method: string, id: JsonRpcId, params: unknown): Promise { const handler = this.requestHandlers.get(method); if (!handler) { - this.send({ jsonrpc: "2.0", id, error: { code: -32601, message: `Client does not handle ${method}` } }); + this.send({ jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } }); return; } try { @@ -135,7 +120,48 @@ export class StdioJsonRpcPeer { if (!this.ended) this.send({ jsonrpc: "2.0", id, result }); } catch (error) { const code = typeof (error as { code?: unknown }).code === "number" ? (error as { code: number }).code : -32603; - if (!this.ended) this.send({ jsonrpc: "2.0", id, error: { code, message: (error as Error).message } }); + const data = (error as { data?: unknown }).data; + if (!this.ended) this.send({ jsonrpc: "2.0", id, error: { code, message: (error as Error).message, ...(data !== undefined ? { data } : {}) } }); } } } + +/** The client end of an ACP agent process OpenRig launched. */ +export class StdioJsonRpcPeer extends JsonRpcPeer { + readonly exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>; + + constructor(private readonly child: ChildProcessWithoutNullStreams, record: PacketRecorder = () => {}) { + super(child.stdout, child.stdin, record); + this.exited = new Promise((resolve) => child.once("close", (code, signal) => { + this.close(); + resolve({ code, signal }); + })); + child.stdin.on("error", () => { /* EPIPE after the agent exits; pending requests reject on close */ }); + } + + /** Close the agent's stdin (detach) and wait for the process to exit, up to `timeoutMs`. */ + async end(timeoutMs: number): Promise<{ code: number | null; signal: NodeJS.Signals | null } | "still-running"> { + this.child.stdin.end(); + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise<"still-running">((resolve) => { timer = setTimeout(() => resolve("still-running"), timeoutMs); }); + try { return await Promise.race([this.exited, timeout]); } finally { clearTimeout(timer); } + } + + /** Drop this side's hold on the agent (its pipes and process handle) without signalling it, so a detached agent that + * keeps running doesn't keep the caller's process alive. */ + release(): void { + this.lines.close(); + for (const stream of [this.child.stdin, this.child.stdout, this.child.stderr]) stream.destroy(); + this.child.unref(); + } +} + +/** The agent end over this process's stdin and stdout (or any stream pair): `closed` settles when the input ends. */ +export class StreamJsonRpcPeer extends JsonRpcPeer { + readonly closed: Promise; + + constructor(input: NodeJS.ReadableStream, output: NodeJS.WritableStream, record: PacketRecorder = () => {}) { + super(input, output, record); + this.closed = new Promise((resolve) => this.lines.once("close", () => { this.close(); resolve(); })); + } +} diff --git a/packages/cli/src/commands/acp.ts b/packages/cli/src/commands/acp.ts index 1c65ee352..662b45acf 100644 --- a/packages/cli/src/commands/acp.ts +++ b/packages/cli/src/commands/acp.ts @@ -4,11 +4,14 @@ import { randomBytes } from "node:crypto"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { DaemonClient, type DaemonResponse } from "../client.js"; -import { daemonStatusGuard, getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js"; +import { daemonMayAnswer, daemonStatusGuard, getDaemonStatus, getDaemonUrl, statusGuardMessage } from "../daemon-lifecycle.js"; import { processIdentity } from "../local-daemon-owner.js"; import { getOpenRigHome, readOpenRigEnv } from "../openrig-compat.js"; import { answerCommand, runAcpAssignment, type AcpQueue } from "../acp/connector.js"; import { findRunProcesses } from "../acp/run-processes.js"; +import { serveAcpAgent, TeamRefused, type TeamConnection, type TeamQueue } from "../acp/agent-server.js"; +import { StreamJsonRpcPeer } from "../acp/json-rpc-stdio.js"; +import { CLI_VERSION } from "../version.js"; import { realDeps } from "./daemon.js"; import type { StatusDeps } from "./status.js"; @@ -44,6 +47,64 @@ export function daemonAcpQueue(client: DaemonClient): AcpQueue { }; } +/** rig acp serve's queue verbs over the daemon API, acting as the caller the client was built for. */ +export function daemonTeamQueue(client: DaemonClient): TeamQueue { + const ok = (response: DaemonResponse, what: string): T => { + if (response.status >= 400) throw new Error(`${what} returned HTTP ${response.status}: ${JSON.stringify(response.data)}`); + return response.data; + }; + const face = (data: Record) => { + const row = (data.item ?? data) as Record; + const text = (value: unknown) => (typeof value === "string" ? value : null); + return { + qitemId: String(row.qitemId), state: String(row.state), destinationSession: String(row.destinationSession), + handedOffTo: text(row.handedOffTo), closureReason: text(row.closureReason), closureTarget: text(row.closureTarget), resolution: text(row.resolution), + closer: text((row.closingTransition as { actorSession?: unknown } | null | undefined)?.actorSession), + }; + }; + return { + async create(row) { + const response = await client.post("/api/queue/create", { qitemId: row.qitemId, destinationSession: row.destination, body: row.body, summary: row.summary, tags: row.tags }); + // A 4xx is the daemon's answered refusal, with nothing written; a 5xx leaves the write unknown, like no answer. + if (response.status >= 400 && response.status < 500) throw new TeamRefused(`HTTP ${response.status}: ${JSON.stringify(response.data)}`); + ok(response, `queue create for ${row.destination}`); + }, + async show(qitemId) { + return face(ok(await client.get>(`/api/queue/${encodeURIComponent(qitemId)}`), `queue show ${qitemId}`)); + }, + async find(qitemId) { + const response = await client.get>(`/api/queue/${encodeURIComponent(qitemId)}`); + if (response.status === 404) return null; + return face(ok(response, `queue show ${qitemId}`)); + }, + async transitions(qitemId) { + const data = ok(await client.get>>(`/api/queue/${encodeURIComponent(qitemId)}/transitions`), `queue transitions ${qitemId}`); + return data.map((t) => ({ + transitionId: Number(t.transitionId), ts: String(t.ts), state: String(t.state), + transitionNote: typeof t.transitionNote === "string" ? t.transitionNote : null, actorSession: String(t.actorSession), + })); + }, + async successor(qitemId) { + const data = ok(await client.get(`/api/queue/list?handedOffFrom=${encodeURIComponent(qitemId)}&limit=1`), `queue successor of ${qitemId}`); + const rows = (Array.isArray(data) ? data : ((data as { items?: unknown[] }).items ?? [])) as Array<{ qitemId?: string }>; + return rows[0]?.qitemId; + }, + }; +} + +/** The person this machine's OpenRig names as its default (`@external`), or why there isn't one. */ +async function defaultPersonAddress(): Promise<{ address: string } | { reason: string }> { + try { + const registry = await import("@openrig/daemon/gateway-human-registry"); + const people = registry.projectHumans(); + if (!people.ok) return { reason: people.error }; + const chosen = registry.resolveDefaultPerson(people.entities); + return chosen.person ? { address: chosen.person.address } : { reason: chosen.source === "none" ? chosen.reason : "no default person" }; + } catch (error) { + return { reason: (error as Error).message }; + } +} + function readRun(runId: string): RunFile | undefined { try { return JSON.parse(readFileSync(join(runsDir(), `${runId}.json`), "utf8")) as RunFile; } catch { return undefined; } } @@ -62,7 +123,7 @@ function liveConnector(run: RunFile): boolean { } export function acpCommand(depsOverride?: StatusDeps): Command { - const cmd = new Command("acp").description("Hand a queue row's task to an ACP (Agent Client Protocol) agent and keep its outcome on the row"); + const cmd = new Command("acp").description("ACP (Agent Client Protocol): hand a queue row's task to an ACP agent, or serve OpenRig as an agent to ACP apps"); const getDeps = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) }; const connect = async (): Promise => { const deps = getDeps(); @@ -135,6 +196,55 @@ Guide: rig context get reference/acp-agents.md`) } }); + cmd + .command("serve") + .description("Serve OpenRig as an ACP agent on stdio, for an ACP app (Zed, JetBrains and others) to launch: its prompts go to your team as queue rows") + .option("--to ", "The seat that receives your tasks: your team's entry point (required to send anything)") + .option("--as
", "Who the tasks come from (default: OPENRIG_SESSION_NAME, else this machine's default person)") + .option("--poll-seconds ", "How often to read the work's progress while a turn is open", "2") + .addHelpText("after", ` +An ACP app launches this command and talks to it on stdin and stdout. Each prompt becomes a queue row from you to +--to. The app shows the row's notes and handoffs as progress, and the closing resolution as the reply. A later +prompt in the same session goes to --to as a follow-up that names the earlier items. + +The turn ends when the team closes the item, or when you cancel. Cancelling stops this process waiting; the team's +work carries on. A turn that ended doesn't mean the work was accepted: the reply says how the item was closed. + +It uses the OpenRig daemon on this computer, started with rig daemon start. The app's sign-in step ("OpenRig on this +machine") checks that the daemon answers and that you resolve to an address: --as, OPENRIG_SESSION_NAME, or the +default person (rig gateway default ). + +In Zed's settings.json: + "agent_servers": { "OpenRig": { "type": "custom", "command": "rig", "args": ["acp", "serve", "--to", "lead@my-team"] } } + +Guide: rig context get reference/acp-agents.md#openrig-as-an-agent-in-acp-apps`) + .action(async (opts: { to?: string; as?: string; pollSeconds: string }) => { + // Only ACP messages may reach stdout: anything a helper prints goes to stderr instead. + console.log = console.error; + console.info = console.error; + process.stdout.on("error", () => { /* EPIPE once the app has gone; the input closing ends the server */ }); + const connectTeam = async (): Promise => { + const status = await getDaemonStatus(getDeps().lifecycleDeps); + if (!daemonMayAnswer(status)) { + const err = statusGuardMessage(status); + return { error: `${err.fact} ${err.action}` }; + } + let caller = opts.as?.trim() || self()?.trim(); + if (!caller) { + const person = await defaultPersonAddress(); + if ("reason" in person) return { error: `OpenRig doesn't know who you are on this machine (${person.reason}). Choose the default person with rig gateway default , or add --as
to the agent's command.` }; + caller = person.address; + } + return { queue: daemonTeamQueue(new DaemonClient(getDaemonUrl(status), { sessionName: caller })), caller, receiver: opts.to?.trim() || undefined }; + }; + const peer = new StreamJsonRpcPeer(process.stdin, process.stdout); + serveAcpAgent(peer, { + connect: connectTeam, version: CLI_VERSION, pollMs: Math.max(1, Number(opts.pollSeconds) || 2) * 1000, + log: (line) => console.error(`[rig acp serve] ${line}`), closed: peer.closed, + }); + await peer.closed; + }); + cmd .command("cancel ") .description("Ask a running rig acp run to cancel its agent's turn (the same as Ctrl-C there)") diff --git a/packages/cli/test/acp-serve.test.ts b/packages/cli/test/acp-serve.test.ts new file mode 100644 index 000000000..802eb849f --- /dev/null +++ b/packages/cli/test/acp-serve.test.ts @@ -0,0 +1,284 @@ +// rig acp serve against the real queue: the daemon's queueRoutes and QueueRepository over a fully migrated test DB, on +// loopback, driven by a scripted ACP client over a stream pair. The team's moves go through the same HTTP routes a seat's +// CLI uses, each as its own seat. +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { serve } from "@hono/node-server"; +import { Hono } from "hono"; +import { PassThrough } from "node:stream"; +import type Database from "better-sqlite3"; +import { createFullTestDb } from "../../daemon/test/helpers/test-app.js"; +import { EventBus } from "../../daemon/src/domain/event-bus.js"; +import { QueueRepository } from "../../daemon/src/domain/queue-repository.js"; +import { queueRoutes } from "../../daemon/src/routes/queue.js"; +import { DaemonClient } from "../src/client.js"; +import { daemonTeamQueue } from "../src/commands/acp.js"; +import { AUTH_METHOD_ID, serveAcpAgent, type AcpAgentOptions, type TeamConnection, type TeamQueue } from "../src/acp/agent-server.js"; +import { JsonRpcRequestError, StreamJsonRpcPeer } from "../src/acp/json-rpc-stdio.js"; + +const CALLER = "human-ada@external"; +const LEAD = "lead@team"; +const IMPL = "impl@team"; +let db: Database.Database; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let server: any; +let port = 0; +const url = () => `http://127.0.0.1:${port}`; +const ambient = ["OPENRIG_URL", "RIGGED_URL", "OPENRIG_PORT", "OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME"].map((key) => [key, process.env[key]] as const); + +beforeAll(async () => { + for (const [key] of ambient) delete process.env[key]; + db = createFullTestDb(); + const bus = new EventBus(db); + const repo = new QueueRepository(db, bus, { validateRig: (session) => !session.endsWith("@gone-rig") }); + const app = new Hono(); + app.use("*", async (c, next) => { c.set("eventBus" as never, bus); c.set("queueRepo" as never, repo); await next(); }); + app.route("/api/queue", queueRoutes()); + await new Promise((resolve) => { server = serve({ fetch: app.fetch, hostname: "127.0.0.1", port: 0 }, (info: { port: number }) => { port = info.port; resolve(); }); }); +}); + +afterAll(async () => { + if (server) await new Promise((resolve) => server.close(() => resolve())); + db?.close(); + for (const [key, value] of ambient) if (value !== undefined) process.env[key] = value; +}); + +const ends: PassThrough[] = []; +afterEach(() => { for (const end of ends.splice(0)) end.end(); }); + +/** A scripted ACP client wired to an in-process rig acp serve. */ +function connect(overrides: Partial = {}, receiver: string | null = LEAD, wrap: (queue: TeamQueue) => TeamQueue = (q) => q) { + const toAgent = new PassThrough(); + const fromAgent = new PassThrough(); + ends.push(toAgent); + const out: string[] = []; + fromAgent.on("data", (chunk: Buffer) => out.push(chunk.toString())); + const team: TeamConnection = { queue: wrap(daemonTeamQueue(new DaemonClient(url(), { sessionName: CALLER }))), caller: CALLER, receiver: receiver ?? undefined }; + const agent = new StreamJsonRpcPeer(toAgent, fromAgent); + serveAcpAgent(agent, { connect: async () => team, version: "9.9.9-test", pollMs: 10, closed: agent.closed, ...overrides }); + const client = new StreamJsonRpcPeer(fromAgent, toAgent); + const updates: Array> = []; + client.onNotification("session/update", (params) => updates.push((params as { update: Record }).update)); + return { client, updates, out }; +} + +async function open(client: StreamJsonRpcPeer): Promise { + await client.request("initialize", { protocolVersion: 1, clientCapabilities: {}, clientInfo: { name: "zed", title: "Zed" } }); + await client.request("authenticate", { methodId: AUTH_METHOD_ID }); + return ((await client.request("session/new", { cwd: "/tmp", mcpServers: [] })) as { sessionId: string }).sessionId; +} + +const prompt = (sessionId: string, text: string) => ({ sessionId, prompt: [{ type: "text", text }] }); +const seat = (session: string) => new DaemonClient(url(), { sessionName: session }); +/** A team seat's move through the queue routes; a refused move fails the test with the daemon's answer. */ +async function move(session: string, path: string, body: Record): Promise { + const response = await seat(session).post(path, body); + if (response.status >= 400) throw new Error(`${session} ${path}: HTTP ${response.status} ${JSON.stringify(response.data)}`); +} +const rowsTo = (destination: string) => db.prepare("SELECT qitem_id, source_session, state, body, tags FROM queue_items WHERE destination_session = ? ORDER BY rowid").all(destination) as Array<{ qitem_id: string; source_session: string; state: string; body: string; tags: string }>; +const messages = (updates: Array>) => updates.filter((u) => u.sessionUpdate === "agent_message_chunk").map((u) => (u.content as { text: string }).text); + +async function until(read: () => T | undefined, what: string): Promise { + for (let i = 0; i < 300; i++) { + const value = read(); + if (value !== undefined) return value; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`timed out waiting for ${what}`); +} + +describe("rig acp serve", () => { + it("answers initialize without reaching OpenRig, and offers the one real sign-in method", async () => { + let connected = 0; + const { client, out } = connect({ connect: async () => { connected += 1; return { error: "unused" }; } }); + const init = (await client.request("initialize", { protocolVersion: 1, clientCapabilities: { terminal: true } })) as Record; + expect(init.protocolVersion).toBe(1); + expect(init.authMethods).toEqual([expect.objectContaining({ id: AUTH_METHOD_ID, name: "OpenRig on this machine" })]); + expect(init.authMethods[0].type).toBeUndefined(); + expect(init.agentInfo).toEqual({ name: "openrig", title: "OpenRig", version: "9.9.9-test" }); + expect(init.agentCapabilities.loadSession).toBe(false); + expect(connected).toBe(0); + for (const line of out.join("").split("\n").filter(Boolean)) expect(JSON.parse(line).jsonrpc).toBe("2.0"); + }); + + it("sign-in and a new session fail with auth_required and the guidance when OpenRig can't be reached", async () => { + const { client } = connect({ connect: async () => ({ error: "OpenRig daemon is not running. Start it with: rig daemon start" }) }); + await client.request("initialize", { protocolVersion: 1, clientCapabilities: {} }); + for (const [method, params] of [["authenticate", { methodId: AUTH_METHOD_ID }], ["session/new", { cwd: "/tmp", mcpServers: [] }]] as const) { + const error = await client.request(method, params).then(() => undefined, (e: unknown) => e); + expect(error).toBeInstanceOf(JsonRpcRequestError); + expect((error as JsonRpcRequestError).error).toEqual({ code: -32000, message: "OpenRig daemon is not running. Start it with: rig daemon start" }); + } + const unknown = await client.request("authenticate", { methodId: "api-key" }).then(() => undefined, (e: unknown) => e); + expect((unknown as JsonRpcRequestError).error.code).toBe(-32602); + }); + + it("a prompt becomes the caller's row to the receiver; handoffs and notes come back attributed; the resolution is the reply", async () => { + const { client, updates } = connect(); + const sessionId = await open(client); + const turn = client.request("session/prompt", prompt(sessionId, "Please fix the login bug\nIt fails on Safari.")); + + const task = await until(() => rowsTo(LEAD)[0], "the row to the lead"); + expect(task.source_session).toBe(CALLER); + expect(task.state).toBe("pending"); + expect(task.body).toMatch(/^Please fix the login bug\nIt fails on Safari\.\n\n---\nSent by human-ada@external from Zed through rig acp serve \(session acps-/); + expect(task.body).toContain("close it with a resolution written for human-ada@external"); + expect(JSON.parse(task.tags)).toEqual(["acp-serve", `acp-session:${sessionId}`]); + + await move(LEAD, `/api/queue/${task.qitem_id}/claim`, {}); + await move(LEAD, `/api/queue/${task.qitem_id}/update`, { state: "in-progress", transitionNote: "reproduced on Safari 19" }); + await move(LEAD, `/api/queue/${task.qitem_id}/handoff`, { toSession: IMPL, transitionNote: "impl owns the auth code", nudge: false }); + const next = await until(() => rowsTo(IMPL)[0], "the impl row"); + await move(IMPL, `/api/queue/${next.qitem_id}/claim`, {}); + await move(IMPL, `/api/queue/${next.qitem_id}/update`, { state: "done", closureReason: "no-follow-on", resolution: "Fixed in PR 123: the cookie lacked SameSite=None." }); + + expect(await turn).toEqual({ stopReason: "end_turn" }); + const calls = updates.filter((u) => u.sessionUpdate === "tool_call"); + expect(calls.map((u) => [u.toolCallId, u.title])).toEqual([ + [task.qitem_id, `Sent to ${LEAD} (${task.qitem_id})`], + [next.qitem_id, `Handed to ${IMPL} (${next.qitem_id})`], + ]); + const lastOf = (id: string) => updates.filter((u) => u.sessionUpdate === "tool_call_update" && u.toolCallId === id).at(-1)!; + expect(lastOf(task.qitem_id).status).toBe("completed"); + const leadLines = ((lastOf(task.qitem_id).content as Array<{ content: { text: string } }>)[0]!.content.text); + expect(leadLines).toMatch(/Z lead@team · in-progress: reproduced on Safari 19/); + expect(lastOf(next.qitem_id).status).toBe("completed"); + expect(messages(updates).at(-1)).toBe(`${IMPL} closed ${next.qitem_id} done.\n\nFixed in PR 123: the cookie lacked SameSite=None.`); + }); + + it("a handoff-and-complete passes the work on: the reply is the successor's result, not the first row's done", async () => { + const { client, updates } = connect({}, "front@team"); + const sessionId = await open(client); + const turn = client.request("session/prompt", prompt(sessionId, "Upgrade the parser")); + const task = await until(() => rowsTo("front@team")[0], "the front row"); + await move("front@team", `/api/queue/${task.qitem_id}/claim`, {}); + await move("front@team", `/api/queue/${task.qitem_id}/handoff-and-complete`, { toSession: "back@team", transitionNote: "back owns the parser", nudge: false }); + expect(db.prepare("SELECT state, closure_reason FROM queue_items WHERE qitem_id = ?").get(task.qitem_id)).toEqual({ state: "done", closure_reason: "handed_off_to" }); + const next = await until(() => rowsTo("back@team")[0], "the back row"); + await move("back@team", `/api/queue/${next.qitem_id}/claim`, {}); + await move("back@team", `/api/queue/${next.qitem_id}/update`, { state: "done", closureReason: "no-follow-on", resolution: "Parser upgraded to 3.2." }); + expect(await turn).toEqual({ stopReason: "end_turn" }); + expect(updates.filter((u) => u.sessionUpdate === "tool_call").map((u) => u.toolCallId)).toEqual([task.qitem_id, next.qitem_id]); + expect(messages(updates).at(-1)).toBe(`back@team closed ${next.qitem_id} done.\n\nParser upgraded to 3.2.`); + }); + + it("the reply names the seat that closed the row: not its owner by default, nor one that wrote a note after the close", async () => { + // A slow poll, so the close and the later note both land before the agent reads the row again. + const { client, updates } = connect({ pollMs: 500 }, "closer@team"); + const sessionId = await open(client); + const turn = client.request("session/prompt", prompt(sessionId, "Ship the hotfix")); + const task = await until(() => rowsTo("closer@team")[0], "the row"); + // The lead closes its report's row: the closer is neither the row's owner nor the newest writer. + await move("closer@team", `/api/queue/${task.qitem_id}/claim`, {}); + await move("lead@team", `/api/queue/${task.qitem_id}/update`, { state: "done", closureReason: "no-follow-on", resolution: "Hotfix shipped." }); + await move("watcher@team", `/api/queue/${task.qitem_id}/update`, { transitionNote: "seen, thanks" }); + const notes = db.prepare("SELECT actor_session FROM queue_transitions WHERE qitem_id = ? ORDER BY rowid").all(task.qitem_id) as Array<{ actor_session: string }>; + expect(notes.at(-1)!.actor_session).toBe("watcher@team"); + expect(await turn).toEqual({ stopReason: "end_turn" }); + expect(messages(updates).at(-1)).toBe(`lead@team closed ${task.qitem_id} done.\n\nHotfix shipped.`); + }); + + it("a create whose answer is lost but whose row landed carries on with that row", async () => { + const lostAck = (q: TeamQueue): TeamQueue => ({ ...q, create: async (row) => { await q.create(row); throw new Error("socket hang up"); } }); + const { client, updates } = connect({}, "acked@team", lostAck); + const sessionId = await open(client); + const turn = client.request("session/prompt", prompt(sessionId, "Rotate the logs")); + const task = await until(() => rowsTo("acked@team")[0], "the row"); + await move("acked@team", `/api/queue/${task.qitem_id}/claim`, {}); + await move("acked@team", `/api/queue/${task.qitem_id}/update`, { state: "done", closureReason: "no-follow-on", resolution: "Rotated." }); + expect(await turn).toEqual({ stopReason: "end_turn" }); + expect(rowsTo("acked@team")).toHaveLength(1); + expect(messages(updates).at(-1)).toBe(`acked@team closed ${task.qitem_id} done.\n\nRotated.`); + }); + + it("a create that can't be confirmed either way is named as unknown, and the next prompt names it", async () => { + let readbacks = 0; + const blind = (q: TeamQueue): TeamQueue => ({ + ...q, + create: async (row) => { await q.create(row); throw new Error("socket hang up"); }, + find: async (id) => { readbacks += 1; if (readbacks === 1) throw new Error("connect ECONNREFUSED"); return q.find(id); }, + }); + const { client, updates } = connect({}, "unsure@team", blind); + const sessionId = await open(client); + const error = await client.request("session/prompt", prompt(sessionId, "Archive March")).then(() => undefined, (e: unknown) => e); + const first = rowsTo("unsure@team")[0]!; + expect((error as JsonRpcRequestError).error).toMatchObject({ code: -32603, message: `Couldn't confirm ${first.qitem_id}: socket hang up` }); + expect(messages(updates).at(-1)).toBe(`Sending ${first.qitem_id} to unsure@team got no answer (socket hang up), and it couldn't be read back: delivery is unconfirmed, so it may still reach the team. A new prompt in this session names it, and \`rig queue show ${first.qitem_id}\` shows it if it landed.`); + + const second = client.request("session/prompt", prompt(sessionId, "Archive March, please")); + const followUp = await until(() => rowsTo("unsure@team")[1], "the follow-up row"); + expect(followUp.body).toContain(`Follows up on ${first.qitem_id} (pending).`); + expect(messages(updates)).toContain(`${first.qitem_id} is still pending with unsure@team. Sending this as a follow-up.\n\n`); + client.notify("session/cancel", { sessionId }); + expect(await second).toEqual({ stopReason: "cancelled" }); + }); + + it("an unanswered create that isn't found on the immediate readback stays unconfirmed, and is named when it lands late", async () => { + // The request is still in flight when the agent reads back: it lands only after the readback found nothing. + let late: (() => Promise) | undefined; + const slow = (q: TeamQueue): TeamQueue => ({ ...q, create: async (row) => { + if (late) return q.create(row); + late = () => q.create(row); + throw new Error("socket hang up"); + } }); + const { client, updates } = connect({}, "late@team", slow); + const sessionId = await open(client); + const error = await client.request("session/prompt", prompt(sessionId, "Tag the release")).then(() => undefined, (e: unknown) => e); + expect(rowsTo("late@team")).toHaveLength(0); + const id = /Couldn't confirm (qitem-\S+):/.exec((error as JsonRpcRequestError).error.message)![1]!; + expect(messages(updates).at(-1)).toBe(`Sending ${id} to late@team got no answer (socket hang up), and it wasn't found on the immediate readback: delivery is unconfirmed, so it may still reach the team. A new prompt in this session names it, and \`rig queue show ${id}\` shows it if it landed.`); + + await late!(); + expect(rowsTo("late@team")[0]!.qitem_id).toBe(id); + const second = client.request("session/prompt", prompt(sessionId, "Tag the release, please")); + const followUp = await until(() => rowsTo("late@team")[1], "the follow-up row"); + expect(followUp.body).toContain(`Follows up on ${id} (pending).`); + client.notify("session/cancel", { sessionId }); + expect(await second).toEqual({ stopReason: "cancelled" }); + }); + + it("a create the daemon answers with a refusal is said plainly, and nothing is kept as sent", async () => { + // A seat whose rig has left the topology can't be sent work: the queue answers with a refusal. + const { client, updates } = connect({}, "lead@gone-rig"); + const sessionId = await open(client); + const error = await client.request("session/prompt", prompt(sessionId, "Anything")).then(() => undefined, (e: unknown) => e); + expect((error as JsonRpcRequestError).error.code).toBe(-32603); + expect((error as JsonRpcRequestError).error.message).toMatch(/^The team's queue refused the task: HTTP 4\d\d: /); + expect(rowsTo("lead@gone-rig")).toHaveLength(0); + expect(messages(updates)).toEqual([]); + }); + + it("cancel stops waiting, never the work; the next prompt is a follow-up that names the open item", async () => { + const { client, updates } = connect({}, "solo@team"); + const sessionId = await open(client); + const first = client.request("session/prompt", prompt(sessionId, "Draft the release notes")); + const row = await until(() => rowsTo("solo@team")[0], "the first row"); + client.notify("session/cancel", { sessionId }); + expect(await first).toEqual({ stopReason: "cancelled" }); + expect(messages(updates).at(-1)).toBe(`Stopped waiting. ${row.qitem_id} is still pending with solo@team: cancelling here doesn't stop the team's work. A new prompt in this session goes to the team as a follow-up and shows where this stands.`); + expect(rowsTo("solo@team")[0]!.state).toBe("pending"); + + const second = client.request("session/prompt", prompt(sessionId, "Also mention the Safari fix")); + const followUp = await until(() => rowsTo("solo@team")[1], "the follow-up row"); + expect(followUp.body).toContain(`Follows up on ${row.qitem_id} (pending).`); + expect(messages(updates)).toContain(`${row.qitem_id} is still pending with solo@team. Sending this as a follow-up.\n\n`); + await move("solo@team", `/api/queue/${followUp.qitem_id}/claim`, {}); + await move("solo@team", `/api/queue/${followUp.qitem_id}/update`, { state: "done", closureReason: "canceled", resolution: "Needs the first item first." }); + expect(await second).toEqual({ stopReason: "end_turn" }); + expect(messages(updates).at(-1)).toBe(`solo@team closed ${followUp.qitem_id} done (canceled).\n\nNeeds the first item first.`); + }); + + it("with no receiver configured, a prompt sends nothing and says how to set one", async () => { + const { client, updates } = connect({}, null); + const sessionId = await open(client); + const before = (db.prepare("SELECT count(*) AS n FROM queue_items").get() as { n: number }).n; + expect(await client.request("session/prompt", prompt(sessionId, "anything"))).toEqual({ stopReason: "end_turn" }); + expect((db.prepare("SELECT count(*) AS n FROM queue_items").get() as { n: number }).n).toBe(before); + expect(messages(updates).at(-1)).toContain("Add `--to `"); + }); + + it("an unknown method gets JSON-RPC's method-not-found", async () => { + const { client } = connect(); + const error = await client.request("session/fork", { sessionId: "x" }).then(() => undefined, (e: unknown) => e); + expect((error as JsonRpcRequestError).error).toEqual({ code: -32601, message: "Method not found: session/fork" }); + }); +}); diff --git a/scripts/generate-context-packs.test.mjs b/scripts/generate-context-packs.test.mjs index 0c6591d28..ca0d07d3e 100644 --- a/scripts/generate-context-packs.test.mjs +++ b/scripts/generate-context-packs.test.mjs @@ -629,10 +629,10 @@ test("HELP ADDRESSES: every guide address taught in help.md resolves through the "reference/whats-new.md", ]); assert.deepEqual(await unresolvedAddresses(app, addresses), []); - // `rig acp run --help` names its guide by address too. + // `rig acp run --help` and `rig acp serve --help` name their guide by address too. const acpHelp = readFileSync(join(REPO, "packages/cli/src/commands/acp.ts"), "utf8"); const acpAddresses = [...acpHelp.matchAll(/rig context get (reference\/[^`\s]+)/g)].map((m) => m[1]); - assert.deepEqual(acpAddresses, ["reference/acp-agents.md"]); + assert.deepEqual(acpAddresses, ["reference/acp-agents.md", "reference/acp-agents.md#openrig-as-an-agent-in-acp-apps"]); assert.deepEqual(await unresolvedAddresses(app, acpAddresses), []); const section = await (await app.request( `/api/context-packs/library/resolve-address?address=${encodeURIComponent("reference/getting-started.md#incomplete-setup-and-restart")}`, From 00cb932c0f0cad3c3c81e33d6da173c4db0a2b02 Mon Sep 17 00:00:00 2001 From: Wu Shuwen Date: Sat, 7 Nov 2026 18:11:39 +0800 Subject: [PATCH 08/14] fix(cli): report masked configuration overrides (#2547) A whole-file rig config reset now names the keys whose effective value still comes from an environment variable, and keeps the existing message when there are none. If reading the settings back after the reset fails, the reset is still reported as successful (exit 0) and the read-back problem is noted on stderr. Single-key reset is unchanged. Contributed by dajiaohuang; reported in #2528. --- packages/cli/src/commands/config.ts | 28 +++++++-- .../cli/test/config-get-with-source.test.ts | 62 +++++++++++++++++-- 2 files changed, 81 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index 4556d8d61..8545d557c 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -200,10 +200,17 @@ Precedence: CLI flag > environment variable > config file > default`) .action(async (key: string, value: string) => { try { store.set(key, value); - console.log(`${key} = ${store.get(key)}`); + const resolved = store.resolveWithSource(key); + console.log(resolved.source === "env" + ? `${key} = ${resolved.value} (source: env; file override saved but masked)` + : resolved.source === "default" + ? `${key} file override saved; effective value ${resolved.value} (source: default)` + : `${key} = ${resolved.value}`); // SWEEP-c: boot-only key + running daemon = stale-until-restart; say so loudly. if (isBootOnlyKey(key) && (await probeDaemonRunning())) { - console.error(`note: '${key}' is read at daemon BOOT — the running daemon keeps its current value; this takes effect on the next daemon restart (rig daemon stop && rig daemon start).`); + console.error(resolved.source === "env" + ? `note: '${key}' is read at daemon BOOT — the running daemon keeps its current value; the saved value remains masked after restart while the environment override is set.` + : `note: '${key}' is read at daemon BOOT — the running daemon keeps its current value; this takes effect on the next daemon restart (rig daemon stop && rig daemon start).`); if (key === "terminal.tmux_server") console.error("note: surviving sessions keep their previous server until deliberately stopped; rig terminal status shows the effective selection and any pending migration."); } } catch (err) { @@ -219,9 +226,22 @@ Precedence: CLI flag > environment variable > config file > default`) try { store.reset(key); if (key) { - console.log(`${key} reset to default (${store.get(key)}).`); + const resolved = store.resolveWithSource(key); + console.log(resolved.source === "env" + ? `${key} file override cleared; effective value ${resolved.value} (source: env; default: ${resolved.defaultValue}).` + : `${key} reset to default (${resolved.value}).`); } else { - console.log("Config reset to defaults."); + let envKeys: string[] = []; + try { + envKeys = Object.entries(store.resolveAllWithSource()) + .filter(([, resolved]) => resolved.source === "env") + .map(([name]) => name); + } catch (err) { + console.error(`note: could not read effective configuration after reset: ${(err as Error).message}`); + } + console.log(envKeys.length + ? `Config reset to defaults, except where the environment overrides them: ${envKeys.join(", ")}.` + : "Config reset to defaults."); } } catch (err) { console.error((err as Error).message); diff --git a/packages/cli/test/config-get-with-source.test.ts b/packages/cli/test/config-get-with-source.test.ts index 92e534bff..4951b0e41 100644 --- a/packages/cli/test/config-get-with-source.test.ts +++ b/packages/cli/test/config-get-with-source.test.ts @@ -5,10 +5,10 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { configCommand } from "../src/commands/config.js"; +import { ENV_MAP } from "../src/config-store.js"; let dir: string; let configPath: string; -let savedPort: string | undefined; async function run(args: string[]): Promise { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); @@ -25,13 +25,14 @@ describe("rig config get --with-source", () => { beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), "config-get-with-source-")); configPath = path.join(dir, "config.json"); - savedPort = process.env["OPENRIG_PORT"]; - delete process.env["OPENRIG_PORT"]; + for (const { primary, legacy } of Object.values(ENV_MAP)) { + vi.stubEnv(primary, undefined); + if (legacy) vi.stubEnv(legacy, undefined); + } }); afterEach(() => { - if (savedPort === undefined) delete process.env["OPENRIG_PORT"]; - else process.env["OPENRIG_PORT"] = savedPort; + vi.unstubAllEnvs(); fs.rmSync(dir, { recursive: true, force: true }); }); @@ -72,4 +73,55 @@ describe("rig config get --with-source", () => { const json = JSON.parse(await run(["get", "daemon.port", "--json"])); expect(json).toMatchObject({ value: 7555, source: "file" }); }); + + it("reports the active environment override when setting or resetting a file value", async () => { + process.env["OPENRIG_PORT"] = "7666"; + expect(await run(["set", "daemon.port", "7555"])).toContain("7666 (source: env; file override saved but masked)"); + expect(JSON.parse(fs.readFileSync(configPath, "utf8")).daemon.port).toBe(7555); + expect(await run(["reset", "daemon.port"])).toContain("effective value 7666 (source: env; default: 7433)"); + expect(await run(["reset"])).toContain("environment overrides them: daemon.port."); + }); + + it("retains the default reset message when no environment override remains", async () => { + expect(await run(["reset"])).toBe("Config reset to defaults."); + }); + + it("reports a successful reset when reading effective configuration fails", async () => { + fs.writeFileSync(configPath, JSON.stringify({ daemon: { port: 7555 } })); + vi.stubEnv("OPENRIG_CONTEXT_PACKS_ROOT", "/old-context"); + const previousExitCode = process.exitCode; + process.exitCode = 0; + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + expect(await run(["reset"])).toBe("Config reset to defaults."); + expect(fs.existsSync(configPath)).toBe(false); + expect(errorSpy.mock.calls.flat().join(" ")).toContain("OPENRIG_CONTEXT_PACKS_ROOT was removed"); + expect(process.exitCode).toBe(0); + } finally { + process.exitCode = previousExitCode; + errorSpy.mockRestore(); + } + }); + + it("does not promise a masked boot setting will take effect on restart", async () => { + process.env["OPENRIG_PORT"] = "7666"; + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + await configCommand(configPath, { probeDaemonRunning: async () => true }) + .parseAsync(["node", "rig", "set", "daemon.port", "7555"]); + expect(errorSpy.mock.calls.flat().join(" ")).toContain("remains masked after restart"); + } finally { errorSpy.mockRestore(); } + }); + + it("distinguishes the saved threshold from the default used after an invalid environment pair", async () => { + vi.stubEnv("OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT", "98"); + vi.stubEnv("OPENRIG_HEALTH_CONTEXT_PRESSURE_CRITICAL_PERCENT", undefined); + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + try { + expect(await run(["set", "health.context_pressure.critical_percent", "97"])) + .toContain("file override saved; effective value 99 (source: default)"); + expect(JSON.parse(fs.readFileSync(configPath, "utf8")).health.contextPressure.criticalPercent).toBe(97); + expect(stderr).toHaveBeenCalled(); + } finally { stderr.mockRestore(); } + }); }); From bfbe41686161de5d7c1eb8e0211dd612c3f13fee Mon Sep 17 00:00:00 2001 From: Mike Schwarz <171890339+mvschwarz@users.noreply.github.com> Date: Sat, 7 Nov 2026 02:49:24 -0800 Subject: [PATCH 09/14] fix: qualify bare external attach names with their rig (#2641) An external (non-tmux) self-attach resolves its session name in SelfAttachService.resolveContext. The explicit name (an external_cli context's sessionName, or else --display-name) is kept exactly when it already contains @ or is already saved for the same node in sessions; any other explicit bare name becomes name@rig. With no explicit name, the derived default stays logicalId-with-dashes@rig, as before. A tmux context passes through unchanged. The attach command's --display-name help says new bare names gain @rig. A member already bound under a bare name keeps it on an ordinary re-attach, and so stays unaddressable by the queue until an explicit repair. --- packages/cli/src/commands/attach.ts | 2 +- .../daemon/src/domain/self-attach-service.ts | 14 +++- .../daemon/test/self-attach-address.test.ts | 77 +++++++++++++++++++ 3 files changed, 89 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/attach.ts b/packages/cli/src/commands/attach.ts index 4b2da0468..b7087c6a8 100644 --- a/packages/cli/src/commands/attach.ts +++ b/packages/cli/src/commands/attach.ts @@ -62,7 +62,7 @@ export function attachCommand(depsOverride?: StatusDeps): Command { .option("--member ", "Member name for pod attach mode") .option("--runtime ", "Runtime for pod attach mode; optional guard for node mode") .option("--cwd ", "Working directory to record", process.cwd()) - .option("--display-name ", "Display label inside tmux; external session name outside tmux") + .option("--display-name ", "Display label inside tmux; external session address outside tmux (new bare names gain @rig)") .option("--print-env", "Print shell exports for OPENRIG_NODE_ID and OPENRIG_SESSION_NAME") .option("--json", "JSON output, including the activity env the attached app needs. Its token is a credential: don't print or paste it, and use rig whoami for identity") .action(async (opts: { diff --git a/packages/daemon/src/domain/self-attach-service.ts b/packages/daemon/src/domain/self-attach-service.ts index 9fd3fdb1d..eb5a22ca8 100644 --- a/packages/daemon/src/domain/self-attach-service.ts +++ b/packages/daemon/src/domain/self-attach-service.ts @@ -160,7 +160,7 @@ export class SelfAttachService { runtime: node.runtime ?? opts.runtime, cwd: node.cwd ?? opts.cwd, displayName: opts.displayName, - context: this.resolveContext(node.logicalId, rig.rig.name, opts.displayName, opts.context), + context: this.resolveContext(node.id, node.logicalId, rig.rig.name, opts.displayName, opts.context), }); } @@ -203,7 +203,7 @@ export class SelfAttachService { nodeId: node.id, logicalId, displayName: opts.displayName, - context: this.resolveContext(logicalId, rig.rig.name, opts.displayName, opts.context), + context: this.resolveContext(node.id, logicalId, rig.rig.name, opts.displayName, opts.context), }); return attached; }); @@ -334,6 +334,7 @@ export class SelfAttachService { } private resolveContext( + nodeId: string, logicalId: string, rigName: string, displayName?: string, @@ -343,9 +344,16 @@ export class SelfAttachService { return context; } const explicit = context?.attachmentType === "external_cli" ? context.sessionName?.trim() : displayName?.trim(); + let sessionName = `${logicalId.replace(/\./g, "-")}@${rigName}`; + if (explicit) { + // Release keeps history; an ordinary re-attach is not an identity repair. + const savedName = this.db.prepare("SELECT 1 FROM sessions WHERE node_id=? AND session_name=? LIMIT 1") + .get(nodeId, explicit); + sessionName = explicit.includes("@") || savedName ? explicit : `${explicit}@${rigName}`; + } return { attachmentType: "external_cli", - sessionName: explicit || `${logicalId.replace(/\./g, "-")}@${rigName}`, + sessionName, }; } diff --git a/packages/daemon/test/self-attach-address.test.ts b/packages/daemon/test/self-attach-address.test.ts index 5e70dfbbc..51b30587f 100644 --- a/packages/daemon/test/self-attach-address.test.ts +++ b/packages/daemon/test/self-attach-address.test.ts @@ -16,6 +16,9 @@ import { EventBus } from "../src/domain/event-bus.js"; import { SelfAttachService } from "../src/domain/self-attach-service.js"; import { SessionTransport } from "../src/domain/session-transport.js"; import { QueueRepository } from "../src/domain/queue-repository.js"; +import { makeTopologyDestinationCheck } from "../src/domain/queue-destination-check.js"; +import { RigLifecycleService } from "../src/domain/rig-lifecycle-service.js"; +import { DiscoveryRepository } from "../src/domain/discovery-repository.js"; import { SeatDeliveryGuard, resolveGuardTarget } from "../src/domain/seat-delivery-guard.js"; import { WhoamiService } from "../src/domain/whoami-service.js"; import { TranscriptStore } from "../src/domain/transcript-store.js"; @@ -49,6 +52,80 @@ describe("attached team address preserves physical custody", () => { afterEach(() => { db.close(); rmSync(transcriptRoot, { recursive: true, force: true }); }); const context = (tmuxSession: string) => ({ attachmentType: "tmux" as const, tmuxSession, tmuxWindow: "@12", tmuxPane: "%42" }); + it("keeps a legacy bare name and its work history through unclaim and same-node re-attach", async () => { + const rig = rigs.createRig("team"), node = rigs.addNode(rig.id, "people.agent", { runtime: "terminal" }); + // Persist the pre-fix external attachment format, then use the real release path. + sessions.updateBinding(node.id, { attachmentType: "external_cli", externalSessionName: "desk" }); + const old = sessions.registerClaimedSession(node.id, "desk"); + const queue = new QueueRepository(db, bus, { + validateRig: makeTopologyDestinationCheck({ findRigsByName: name => rigs.findRigsByName(name), loadHumanRegistry: () => { throw new Error("no human address in this fixture"); } }), + resolveSessionAddresses: name => sessions.sessionAddresses(name), + }); + const sent = await queue.create({ sourceSession: "desk", destinationSession: "operator@team", body: "retained work", nudge: false }); + const lifecycle = new RigLifecycleService({ db, rigRepo: rigs, sessionRegistry: sessions, eventBus: bus, + discoveryRepo: new DiscoveryRepository(db), queueRepo: queue }); + expect(await lifecycle.unclaimSession(old.id)).toMatchObject({ ok: true, nodeId: node.id, sessionName: "desk" }); + expect(sessions.getBindingForNode(node.id)).toBeNull(); + const released = db.prepare("SELECT * FROM sessions WHERE id=?").get(old.id); + expect(released).toMatchObject({ session_name: "desk", status: "detached" }); + const response = await app.request(`/api/rigs/${rig.id}/attach-self`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ logicalId: node.logicalId, attachmentType: "external_cli", displayName: "desk" }), + }); + expect(response.status).toBe(201); + const result = await response.json() as { nodeId: string; sessionId: string; sessionName: string; senderAddress: string; env: { OPENRIG_SESSION_NAME: string } }; + expect(result.nodeId).toBe(node.id); + expect(db.prepare("SELECT * FROM sessions WHERE id=?").get(old.id)).toEqual(released); + expect(queue.getById(sent.qitemId)?.sourceSession).toBe("desk"); + expect(sessions.getBindingForNode(node.id)).toMatchObject({ attachmentType: "external_cli", externalSessionName: "desk" }); + expect(result).toMatchObject({ sessionName: "desk", senderAddress: "desk", env: { OPENRIG_SESSION_NAME: "desk" } }); + expect(db.prepare("SELECT session_name FROM sessions WHERE id=?").get(result.sessionId)).toEqual({ session_name: "desk" }); + expect(queue.whoami(result.env.OPENRIG_SESSION_NAME).asSource.total).toBe(1); + // This preserves the legacy limitation, rather than claiming an implicit repair. + expect(queue.acceptsDestination(result.senderAddress)).toBe(false); + }); + + it("keeps the derived default byte-identical for a member containing @", async () => { + const rig = rigs.createRig("team"); pods.createPod(rig.id, "people", "People"); + expect(await attach.attachToPod({ rigId: rig.id, podNamespace: "people", memberName: "a@b", runtime: "terminal" })) + .toMatchObject({ ok: true, sessionName: "people-a@b@team", senderAddress: "people-a@b@team" }); + }); + + it.each(["node", "pod"].flatMap(mode => [ + { mode, displayName: "desk", address: "desk@team" }, + { mode, displayName: "custom-desk@team", address: "custom-desk@team" }, + { mode, displayName: undefined, address: "people-agent@team" }, + ]))("external $mode attach with displayName=$displayName can receive work at its returned identity", async ({ mode, displayName, address }) => { + const rig = rigs.createRig("team"), pod = pods.createPod(rig.id, "people", "People"); + const existing = mode === "node" ? rigs.addNode(rig.id, "people.agent", { runtime: "terminal", podId: pod.id }) : null; + const body = { attachmentType: "external_cli", displayName, runtime: "terminal", + ...(mode === "node" ? { logicalId: existing!.logicalId } : { podNamespace: "people", memberName: "agent" }) }; + const response = await app.request(`/api/rigs/${rig.id}/attach-self`, { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), + }); + expect(response.status).toBe(201); + const result = await response.json() as { nodeId: string; sessionName: string; senderAddress: string; env: { OPENRIG_SESSION_NAME: string } }; + const binding = sessions.getBindingForNode(result.nodeId); + expect(binding).toMatchObject({ attachmentType: "external_cli", externalSessionName: result.sessionName }); + const whoami = new WhoamiService({ db, rigRepo: rigs, sessionRegistry: sessions, transcriptStore: new TranscriptStore({ transcriptsRoot: transcriptRoot }) }); + expect(whoami.resolve({ nodeId: result.nodeId })?.identity).toMatchObject({ nodeId: result.nodeId, senderAddress: result.env.OPENRIG_SESSION_NAME }); + expect(whoami.resolve({ sessionName: result.env.OPENRIG_SESSION_NAME })?.identity.nodeId).toBe(result.nodeId); + const queue = new QueueRepository(db, bus, { + validateRig: makeTopologyDestinationCheck({ findRigsByName: name => rigs.findRigsByName(name), loadHumanRegistry: () => { throw new Error("no human address in this fixture"); } }), + resolveSessionAddresses: name => sessions.sessionAddresses(name), + }); + // Use precisely the address attach advertised, with the production gate. + const item = await queue.create({ sourceSession: "operator@team", destinationSession: result.env.OPENRIG_SESSION_NAME, body: "work for attached member", nudge: false }); + expect(queue.claim({ qitemId: item.qitemId, destinationSession: result.env.OPENRIG_SESSION_NAME }).destinationSession).toBe(address); + expect(queue.whoami(result.env.OPENRIG_SESSION_NAME).asDestination.inProgress).toBe(1); + expect(result).toMatchObject({ sessionName: address, senderAddress: address, env: { OPENRIG_SESSION_NAME: address } }); + // A second attach does not rename a persisted member, even with a new label. + const before = sessions.getSessionsForRig(rig.id); + expect(await attach.attachToNode({ rigId: rig.id, logicalId: "people.agent", displayName: "renamed" })).toMatchObject({ ok: false, code: "already_bound" }); + expect(sessions.getBindingForNode(result.nodeId)).toEqual(binding); + expect(sessions.getSessionsForRig(rig.id)).toEqual(before); + }); + it.each(["0", "person-terminal"])("returns a routable sender and records the display label without renaming %s", async (physical) => { const rig = rigs.createRig("sample-team"), node = rigs.addNode(rig.id, "person.agent", { runtime: "terminal" }); const result = await attach.attachToNode({ rigId: rig.id, logicalId: node.logicalId, displayName: "Taylor", context: context(physical) }); From 30b95563559f52ae7c6ea6a90d229865583dce2d Mon Sep 17 00:00:00 2001 From: Samuel Date: Sat, 7 Nov 2026 12:14:50 +0100 Subject: [PATCH 10/14] feat(gateway): add authenticated loopback text relay (#2624) Adds a relay connector to the gateway: an HTTP loopback transport to an operator-owned bridge process, configured per connector from an absolute secrets file (OPENRIG_RELAY_URL, OPENRIG_RELAY_OUTBOUND_TOKEN, OPENRIG_RELAY_INBOUND_TOKEN), with no environment fallback. Outbound, each decision freezes its complete relayEnvelope on its DispatchBuffer entry before the first attempt and posts it whole, once per attempt, with deliveryId set to the decision ID; a 2xx records relay-owner-notification-accepted and drains the entry, a failure records relay-owner-notification-transport-failed and keeps it for replay of the same bytes, and a stop mid-attempt records neither. Inbound, POST /api/gateway/relay/inbound requires the connector's bearer token from a direct loopback socket peer (forwarding headers are ignored), attributes the reply by sender.handle against that connector's registered people, and commits the answer or resolve with its audit record through the Mission Control write contract, deduplicating an exact replay of the same eventId. Inbound size limits are a documented protocol contract, outbound references are fixed-width digests that the inbound contract accepts whole, and a ledger whose state can't be read returns a scoped retryable 503. Readiness warns, rather than going unready, on group or other bits on a readable secrets file and on one token used for both directions within a connector. docs/reference/gateway-relay.md describes the topology, custody, configuration and wire protocol. --- docs/reference/gateway-participants.md | 5 +- docs/reference/gateway-relay.md | 239 ++ packages/cli/src/commands/gateway.ts | 22 +- .../test/gateway-human-registry-verb.test.ts | 17 + .../domain/gateway/connections-projection.ts | 46 +- .../daemon/src/domain/gateway/dispatcher.ts | 17 +- .../src/domain/gateway/gateway-subsystem.ts | 3 + .../src/domain/gateway/human-readiness.ts | 51 + .../src/domain/gateway/human-registry.ts | 112 +- .../gateway/operator-delivery-engine.ts | 9 +- .../daemon/src/domain/gateway/relay-config.ts | 420 +++ .../src/domain/gateway/relay-inbound.ts | 897 +++++++ .../src/domain/gateway/relay-outbound.ts | 939 +++++++ .../src/domain/gateway/slack/open-asks.ts | 16 +- .../domain/gateway/slack/slack-subsystem.ts | 269 +- .../mission-control-write-contract.ts | 153 +- .../src/domain/policies/quiet-release.ts | 9 +- .../daemon/src/domain/queue-repository.ts | 250 +- .../daemon/src/domain/queue-transition-log.ts | 264 +- .../daemon/src/domain/queue-wake-ladder.ts | 14 +- .../daemon/src/domain/quiet-hold-custody.ts | 13 +- packages/daemon/src/routes/gateway.ts | 98 +- packages/daemon/src/server.ts | 11 +- packages/daemon/src/startup.ts | 17 +- .../test/archive-identity-provenance.test.ts | 230 +- .../test/complete-human-notifications.test.ts | 140 +- .../daemon/test/decision-revision.test.ts | 187 +- .../daemon/test/delivery-rules-engine.test.ts | 139 +- .../daemon/test/fixtures/recording-relay.ts | 601 +++++ .../test/gateway-channel-lifecycle.test.ts | 88 +- .../daemon/test/gateway-human-handle.test.ts | 97 + .../test/gateway-human-readiness.test.ts | 243 +- .../test/gateway-human-registry.test.ts | 80 + .../daemon/test/gateway-relay-inbound.test.ts | 2392 +++++++++++++++++ .../test/gateway-relay-integration.test.ts | 1261 +++++++++ .../test/gateway-subsystem-activation.test.ts | 103 + .../test/gateway-subsystem-wire.test.ts | 177 ++ .../test/queue-list-delivery-ledger.test.ts | 85 + .../daemon/test/queue-wake-ladder.test.ts | 75 + .../daemon/test/quiet-hold-custody.test.ts | 109 +- .../daemon/test/quiet-hold-release.test.ts | 240 ++ packages/daemon/test/relay-config.test.ts | 302 +++ packages/daemon/test/relay-outbound.test.ts | 1580 +++++++++++ .../daemon/test/slack-post-open-asks.test.ts | 141 + .../test/slack-subsystem-cutover.test.ts | 410 +++ .../daemon/test/steering-only-asks.test.ts | 110 +- packages/tui/src/attention/attention-model.ts | 27 +- .../tui/test/feed-open-human-updates.test.ts | 42 + 48 files changed, 12552 insertions(+), 198 deletions(-) create mode 100644 docs/reference/gateway-relay.md create mode 100644 packages/daemon/src/domain/gateway/relay-config.ts create mode 100644 packages/daemon/src/domain/gateway/relay-inbound.ts create mode 100644 packages/daemon/src/domain/gateway/relay-outbound.ts create mode 100644 packages/daemon/test/fixtures/recording-relay.ts create mode 100644 packages/daemon/test/gateway-relay-inbound.test.ts create mode 100644 packages/daemon/test/gateway-relay-integration.test.ts create mode 100644 packages/daemon/test/relay-config.test.ts create mode 100644 packages/daemon/test/relay-outbound.test.ts diff --git a/docs/reference/gateway-participants.md b/docs/reference/gateway-participants.md index f7352a0e2..ebd38ea26 100644 --- a/docs/reference/gateway-participants.md +++ b/docs/reference/gateway-participants.md @@ -14,7 +14,7 @@ returns `503` with `error: "human_registry_unavailable"`. The result does not in ## Participants A participant is either: -- **a person**, registered with `rig gateway human add`, with or without a connector binding; or +- **a person**, registered with `rig gateway human add`, with or without a connector binding (such as Slack or an HTTP loopback [relay](gateway-relay.md)); or - **a shared channel**: one vetted Slack channel registered as one participant, `@external`, not a person per sender. @@ -22,6 +22,9 @@ People and channels share one address space: a channel can't take a person's id. A registered person is addressed as `@external`, including without Slack: for example, `rig gateway human add alex --display-name "Alex" --delivery-class B` creates `alex@external`, reached in the Feed and Steering. +A person can also bind to a local bridge adapter using a `relay` binding: +`rig gateway human add bob --display-name "Bob" --delivery-class B --binding "relay:local-bridge:file:/etc/openrig/relay.env:primary:handle=bob-ops"` +(see [Gateway Relay Reference](gateway-relay.md) for endpoint, secret provisioning, and custody architecture). `human@host` is the unnamed person at the terminal; `human@kernel` is also accepted as a human/gate address. These aliases (including `human-@host` and `human-@kernel`) are not agent sessions: reply with `rig queue create --destination human@host --human-intent update --summary "" --evidence-ref "" --body "..." --verify`, diff --git a/docs/reference/gateway-relay.md b/docs/reference/gateway-relay.md new file mode 100644 index 000000000..4207f5436 --- /dev/null +++ b/docs/reference/gateway-relay.md @@ -0,0 +1,239 @@ +# Gateway Relay: HTTP Loopback Transport Adapter & Custody Reference + +OpenRig's Gateway Relay is a local HTTP loopback transport adapter. It enables registered people to receive outbound notifications and questions (asks, updates, and operator digests) through an operator-owned local bridge process, and to post answers back into OpenRig with sender attribution resolved against registered human bindings. + +OpenRig does not embed third-party messaging platform SDKs (such as WhatsApp, Matrix, or Signal). An external bridge process translates between OpenRig loopback HTTP requests and external messaging platforms. + +``` ++--------------------+ HTTP Loopback +--------------------+ +| OpenRig Daemon | POST ---> | Operator Bridge | +| (Gateway Subsys) | <--- POST .../relay/inbound | (Loopback Server) | ++--------------------+ +--------------------+ + | + Platform-specific transport + v + +--------------------+ + | External Platform | + | (Mobile / Client) | + +--------------------+ +``` + +--- + +## 1. System Topology & Custody Boundaries + +### Outbound Custody & Recovery +- OpenRig enqueues outbound decisions to a local `DispatchBuffer` before issuing any HTTP `POST` to `OPENRIG_RELAY_URL`. +- Each logical decision freezes its complete `relayEnvelope` onto the parent `DispatchBuffer` entry before the first network attempt, with no self-imposed outbound 64 KiB cap or multi-part splitting. +- Transport is at-least-once: OpenRig sends the complete JSON envelope in a single HTTP `POST` per attempt with `deliveryId = ` (preserving the full `body`, `humanDetail`, `humanQuestions`/options, `summary` overflow, and digest `items` without silent clipping). +- An HTTP `2xx` response from the bridge acknowledges durable bridge custody of that complete envelope (`deliveryId`). The bridge must persist each envelope by `deliveryId` and deduplicate replayed `deliveryId`s idempotently. +- OpenRig records the logical queue acceptance receipt (`relay-owner-notification-accepted` with `deliveryId`) and drains the decision from `DispatchBuffer` once the HTTP `POST` returns `2xx`. Bridge acceptance confirms custody by the bridge process only (and is projected as `accepted` / `"Accepted by bridge"`, whereas Slack posts use `posted` / `"Delivered"`). It does not indicate downstream platform delivery, posting, or human receipt. +- Failed dispatches remain in OpenRig's `DispatchBuffer` and record a relay-specific failure transition note (`relay-owner-notification-transport-failed` naming the `connectorRef`, distinct from Slack's `slack-owner-notification-transport-failed`). Replays after a lost ACK or daemon/bridge restart re-send the exact frozen `relayEnvelope` with the same `deliveryId` and bytes. If the relay subsystem stops while an outbound HTTP `POST` is in flight (or before it starts), the transport aborts cleanly (`stopped`) without recording a false failure transition note or false acceptance receipt, retaining the buffered decision for replay on restart. There is currently no periodic background timer redriving outbound dispatches on timeout. + +### Inbound Custody & Resolution +- The operator bridge posts replies to `POST /api/gateway/relay/inbound`. +- Requests require `Authorization: Bearer ` matching a configured, ready relay profile. +- OpenRig verifies that the caller is a direct loopback peer (`127.0.0.1/8` or `::1`) over the underlying TCP socket. Proxy and forwarding headers (`Via`, `X-Forwarded-*`, `Forwarded`, `X-Real-Ip`) are ignored rather than rejected because caller identity is established directly from the loopback socket peer and the bearer token. +- Sender attribution resolves by matching `sender.handle` against human fragments registered to the authenticated connector. Harmless extra fields on the top-level inbound JSON object or inside `sender` are ignored and can never override handle-to-person attribution or alter the event fingerprint. The handle is asserted by the trusted bridge; it does not provide independent proof of the physical human on the external platform. +- When an inbound reply is accepted (`200 OK`), OpenRig reserves the event in `relay-inbound-seen.jsonl` (`received:`), durably commits the answer/resolve action and its `mission_control_actions` audit record atomically through its Mission Control write contract (using existing `excludeOperationReply` revision checking without a second answer store or synthetic revision refresh), and marks the event `processed:`. Exact duplicate replays of the same `eventId` and payload succeed idempotently (`deduplicated: true`). + +### Exclusions & Scope +The relay adapter does not handle interactive UI buttons, message attachments, group rooms, unsolicited inbound triggers, multi-bridge failover clustering, or third-party platform SDK backends. + +--- + +## 2. Secrets & Environment Configuration + +Each relay connector loads configuration from an absolute file referenced by the binding's `secretsRef` (`file:`). + +### Security & File Requirements +- **Absolute Path Only**: Must begin with `file:/`. Relative paths, bare paths, and `file://` authority URLs are rejected. +- **File Permissions**: The file should restrict access to the owner only (no group or other permission bits set, i.e. `(mode & 0o077) === 0`, such as `0600` or `0400`). Owner-only modes (`0600`, `0400`) produce no warning. If group or world permission bits are set (e.g. `0644`), OpenRig logs a redacted warning in the profile's readiness `warnings` and `reason` (matching the Slack secret-file posture) while keeping the profile `ready`. Missing or unreadable secret files remain `not-ready`. +- **No Environment Fallback**: OpenRig does not fall back to process environment variables (`process.env`). All variables must come directly from the referenced file. +- **Profile Independence**: If a secrets file is missing, unreadable, or missing required keys, only connectors bound to that file report `not-ready`. Other connectors remain functional. + +### Required Secrets Keys + +```ini +OPENRIG_RELAY_URL=http://127.0.0.1:8088/webhook +OPENRIG_RELAY_OUTBOUND_TOKEN=sec_outbound_token_value +OPENRIG_RELAY_INBOUND_TOKEN=sec_inbound_token_value +``` + +- `OPENRIG_RELAY_URL`: Must use `http:`, loopback host (`127.0.0.1/8`, `::1`, or `localhost`), without URL credentials or hash fragments. Redirects (3xx) are rejected. +- `OPENRIG_RELAY_OUTBOUND_TOKEN`: Non-empty Bearer token sent by OpenRig on outbound requests to the bridge. +- `OPENRIG_RELAY_INBOUND_TOKEN`: Non-empty Bearer token required on inbound requests from the bridge. Must be unique across different configured relay connector profiles (`connectorRef`); sharing the same inbound token across multiple relay profiles is rejected as ambiguous (`not-ready`). Using the same token value for both `OPENRIG_RELAY_OUTBOUND_TOKEN` and `OPENRIG_RELAY_INBOUND_TOKEN` within a single profile is allowed, with a redacted readiness warning explaining why separate directional tokens are recommended so an outbound capture cannot authenticate inbound replies. + +--- + +## 3. Wire Protocol Specifications + +### Outbound Envelope (`POST `) + +```json +{ + "v": 1, + "deliveryId": "dec-019283-abc", + "recipient": { "handle": "alice-ops" }, + "intent": "decision", + "outcome": "interrupt", + "summary": "Database Migration Approval", + "body": "Production migration script 004_add_indexes.sql is ready to execute. Reply YES to approve, NO to abort.", + "replyRef": "eyJ2IjoxLCJwYXlsb2FkSGFzaCI6..." +} +``` + +- `v`: Protocol version integer (`1`). +- `deliveryId`: Stable whole-decision delivery identifier (``). +- `recipient.handle`: Recipient's registered safe alias for this connector. +- `intent`: `"decision"` (actionable question) or `"update"` (informational notice). +- `outcome`: Delivery classification (`"interrupt"`, `"notify"`, or `"digest"`). +- `summary`: Plain text summary preview (up to 1,000 characters on the `summary` field, cut on a Unicode code-point boundary; any summary text exceeding 1,000 characters is preserved in full at the beginning of `body` or `item.body`). +- `body`: Complete plain text content (including full `summary` overflow, `body`, `humanDetail`, and rendered `humanQuestions` headers, prompts, and option labels/descriptions) sent in a single complete JSON envelope without silent character clipping or self-imposed outbound byte splitting. +- `replyRef`: Fixed-size opaque base64url token (`<= 4,096` characters for any supported ID lengths — `246`–`266` characters in protocol v1, hashing the canonical frozen `RelayReplyRefPayload` and binding `connectorHash`, `mode`, and optional `digest: true`) for actionable decisions; `null` for informational updates (and `null` at the top level of multi-item digests). +- `items`: Present when `outcome` is `"digest"`. Array of objects with `qitemId`, `notificationKey`, `summary`, optional `body`, and per-item `replyRef` (or `null`). + +### Inbound Envelope (`POST /api/gateway/relay/inbound`) + +```json +{ + "eventId": "evt-relay-20261107-001", + "sender": { "handle": "alice-ops" }, + "replyRef": "eyJ2IjoxLCJwYXlsb2FkSGFzaCI6...", + "text": "Approved. Proceed with rollout.", + "ordering": "after-question" +} +``` + +- `eventId`: Bridge-assigned identifier (`1..256` chars, matching `/^[A-Za-z0-9._:-]{1,256}$/`). +- `sender.handle`: Sender's alias, matching `/^[A-Za-z0-9._-]+$/`. Must match a human fragment bound to the authenticated connector. Harmless extra fields inside `sender` or on the top-level JSON object are ignored and never override `sender.handle` resolution or affect the event fingerprint. +- `replyRef`: Exact fixed-size opaque base64url token copied from the outbound envelope (maximum 4,096 characters). Bridges must not parse or alter `replyRef`. +- `text`: Plain text response (maximum 32,768 characters, non-empty after trim, no NUL bytes). Processed as unstructured text without automatic natural-language approval parsing. +- `ordering`: Optional enum (`"after-question"` | `"uncertain"`, default `"uncertain"`). When `"uncertain"`, text is accepted as recorded context rather than verified sequence relative to the question. + +#### Inbound Statuses & Responses +- **200 OK**: Request accepted (`{ ok: true, deduplicated: false, reconciled: false, qitemId, verb, actorSession, actionId }`). Exact duplicate submissions of the same `eventId` return `ok: true` with `deduplicated: true`. +- **400 Bad Request**: Malformed JSON, non-object payload, or invalid required field syntax/bounds (`eventId`, `sender.handle`, `replyRef`, `text`, or `ordering`). Harmless extra fields are ignored. +- **401 Unauthorized**: Missing, malformed, unrecognized, unready, or cross-profile ambiguous inbound Bearer token, or no humans configured (`noHumans`). +- **403 Forbidden**: Non-loopback TCP socket peer, browser cross-origin request, connector mismatch (`replyRef` emitted by another connector), or unregistered `sender.handle`. (`Via` and `X-Forwarded-*` headers are ignored). +- **404 Not Found**: Queue item referenced in `replyRef` does not exist. +- **409 Conflict**: Stale `decisionRevision` (question cancelled, answered, or re-asked), attempt to answer an informational update, unrecognized `replyRef` when both durable receipts and the pending dispatch buffer are readable, or conflicting payload submitted under an existing `eventId`. +- **413 Payload Too Large**: Request body exceeds 64 KiB (65,536 bytes). +- **503 Service Unavailable**: + - `human_registry_unavailable`: Local human registry (`gateway/humans/*.yaml`) failed to load due to a broken fragment or filesystem read error (normal `noHumans` returns `401`). + - `queue_store_unavailable`: Local SQLite queue/transition store read failed while resolving or verifying `replyRef`, returning `503` with zero seen-store reservation or Mission Control action side effects. + - `dispatch_buffer_unavailable`: An un-receipted `replyRef` required fallback verification against `/gateway/dispatch-buffer.json`, and reading the buffer failed (`pendingStrict()`), returning `503` with zero seen-store reservation or Mission Control action side effects. (`replyRef`s already vouched for by durable accepted receipts in `queue_transitions` or `queue_transitions_archive` are verified before consulting the buffer and still succeed.) + - `seen_store_unavailable` (response includes `seenPath`): Local seen-ledger (`state/relay-inbound-seen.jsonl`) is unreadable, contains unparseable JSONL or unkeyable records (missing `id`), failed a reservation/processed mark, or has a corrupt/unvouched record for the requested `eventId`'s key. Keyed suspect records (`missing/invalid status` or conflicting recorded fingerprints for the same key; diagnostic `ts` or extra metadata fields are ignored) scope refusal to that `eventId` key only so unrelated `eventId`s continue to operate normally, while never treating unparseable or suspect history as unseen. + +--- + +## 4. Identity & Binding Configuration + +Bindings are managed through human fragment files (`gateway/humans/.yaml`) and CLI verbs (`rig gateway human`). + +### Binding Syntax +``` +relay::file::[:handle=] +``` +- `kind`: `relay`. +- `connectorRef`: Identifier for the connector instance (e.g. `local-bridge`). +- `secretsRef`: Absolute file path reference (`file:/path/to/relay.env`). +- `role`: `primary` or `secondary` (at least one primary binding required for outbound dispatch). +- `handle`: Optional platform alias (`handle=`). Required for inbound reply resolution; omitted means outbound-only. + +Handles must match `/^[A-Za-z0-9._-]+$/` and are unique per `connectorRef`. Multiple people can share a connector, each registered with their own handle. + +--- + +## 5. Operational Lifecycle + +### Step 1: Provision Secrets File +Create an owner-only secrets file (no group/other access bits, e.g. `0600` or `0400`): +```bash +mkdir -p "$HOME/.config/openrig-relay" +touch "$HOME/.config/openrig-relay/relay.env" +chmod 600 "$HOME/.config/openrig-relay/relay.env" + +cat << 'EOF' > "$HOME/.config/openrig-relay/relay.env" +OPENRIG_RELAY_URL=http://127.0.0.1:8088/webhook +OPENRIG_RELAY_OUTBOUND_TOKEN=replace_with_outbound_token +OPENRIG_RELAY_INBOUND_TOKEN=replace_with_inbound_token +EOF +``` + +### Step 2: Register Human Binding +```bash +# Add a person with a primary relay binding +rig gateway human add alice --display-name "Alice Smith" --delivery-class B \ + --binding "relay:local-bridge:file:$HOME/.config/openrig-relay/relay.env:primary:handle=alice-ops" + +# Replace an existing binding slot (requires the full binding spec) +rig gateway human set alice binding.0 "relay:local-bridge:file:$HOME/.config/openrig-relay/relay.env:primary:handle=alice-ops" +``` + +> [!NOTE] +> `rig gateway human set alice binding.` requires a full binding specification string. Supplying `unset` is not supported for binding slots. To reassign a participant, overwrite the slot with a full binding string or remove and re-add the human fragment. + +Operator notices use the existing default person. If several people are registered, choose the recipient explicitly when needed: + +```bash +rig gateway default alice +``` + +### Step 3: Activate Daemon +When bindings or secrets files change, restart the daemon process: +```bash +rig daemon stop +rig daemon start +``` +*(OpenRig does not provide a `rig daemon restart` command).* + +### Step 4: Verify Readiness & Status +```bash +# Check delivery readiness for all participants +rig gateway human list + +# Inspect detailed readiness status for an individual +rig gateway human show alice + +# Check daemon process status +rig daemon status +``` + +In `rig gateway human show --json`, `deliveryReadiness.state` reports `ready` when the binding configuration and local secrets file are valid. This confirms local readiness, not whether the operator bridge process is currently running. + +### Step 5: Removing Bindings or Participants +Before removing a participant, ensure any pending queue items and conversations assigned to them are resolved. +```bash +rig gateway human remove alice +``` +*(The remove verb refuses to delete a participant if open conversations or non-terminal items exist, unless `--force` is specified).* + +--- + +## 6. Troubleshooting Common Conditions + +| Condition | Observation | Resolution | +| :--- | :--- | :--- | +| **Missing Secrets File** | `deliveryReadiness.state: not-ready`
`reason: relay secret file ... is missing or unreadable` | Verify file path in `secretsRef` is absolute and readable. | +| **Insecure Permissions** | `deliveryReadiness.state: ready` (with warning)
`reason: ... warning: ... insecure permissions (mode 0644; group/other access bits are set ...)` | Restrict permissions so group/other have no access (`chmod 600 ` or `chmod 400 `). | +| **Identical Directional Tokens** | `deliveryReadiness.state: ready` (with warning)
`reason: ... warning: ... uses the same token for OPENRIG_RELAY_OUTBOUND_TOKEN and OPENRIG_RELAY_INBOUND_TOKEN; separate tokens are recommended ...` | Provision distinct outbound and inbound tokens so outbound capture cannot authenticate inbound replies. | +| **Invalid Loopback URL** | `deliveryReadiness.state: not-ready`
`reason: ... OPENRIG_RELAY_URL host must be loopback ...` | Ensure URL uses `http://` and host is `127.0.0.1/8`, `::1`, or `localhost`. | +| **Reused Inbound Token Across Profiles** | `deliveryReadiness.state: not-ready`
`reason: ... reuses an ambiguous OPENRIG_RELAY_INBOUND_TOKEN shared with relay profile(s): ...` | Each distinct relay connector profile (`connectorRef`) must declare a unique inbound token. | +| **Stale Question Reply** | HTTP 409 on inbound endpoint | Question was already answered, cancelled, or superseded by a newer revision. | +| **Unregistered Handle** | HTTP 403 on inbound endpoint | The `sender.handle` is not bound to the authenticated `connectorRef`. | +| **Non-Loopback Inbound** | HTTP 403 on inbound endpoint | Connection originated from a non-loopback TCP socket peer or included a cross-origin browser header. (`Via` and `X-Forwarded-*` headers are ignored). | +| **Registry or Buffer Read Failure** | HTTP 503 on inbound endpoint (`human_registry_unavailable` or `dispatch_buffer_unavailable`) | Inspect local `gateway/humans/*.yaml` fragments or `/gateway/dispatch-buffer.json` for syntax/permission corruption before retrying the bridge event. | +| **Seen-Ledger Corrupt / Suspect** | HTTP 503 on inbound endpoint (`seen_store_unavailable`, includes `seenPath`) | Unreadable/unparseable/unkeyable `relay-inbound-seen.jsonl` refuses all inbound requests; a keyed suspect entry refuses only that specific `eventId`. Follow the conservative recovery procedure below. | + +### Conservative Seen-Ledger Recovery (`seen_store_unavailable`) + +When `POST /api/gateway/relay/inbound` returns `503` with `error: "seen_store_unavailable"`, the response body and message include the resolved `seenPath` (`/state/relay-inbound-seen.jsonl`): + +1. **Quiesce writers first**: Pause the loopback bridge and stop the daemon (`rig daemon stop`) so no concurrent inbound request or retry appends to the ledger during inspection. +2. **Preserve a full backup**: Copy `` to a timestamped backup outside `state/` before making any edit. +3. **Correlate against durable evidence**: Each ledger entry keys `id` by `eventKey = SHA256(JSON.stringify([connectorRef, eventId]))` (64-character lowercase hex digest) and stores `status` as `received:` or `processed:`. Because `eventKey` is a one-way hash, correlate any damaged line or keyed suspect `id` against `mission_control_actions.audit_notes_json` (which records `operationId: "relay::"`, `connectorRef`, and `eventId`) and bridge delivery logs by computing `SHA256(JSON.stringify([connectorRef, eventId]))` for candidate events—never by guessing or assuming the hash is reversible. +4. **Apply only evidence-verified, history-preserving repair**: Repair a damaged record only when its exact `id` (``) and `status` (`received:` or `processed:`) are verified from `mission_control_actions.audit_notes_json` or bridge evidence, or restore only from a backup that preserves every subsequently acknowledged event. Do **not** delete, truncate, or skip unknown/unparseable records, and do **not** restore an older backup that omits acknowledged events; if an entry cannot be verified without losing history, leave the ledger in place (continuing to fail closed with `503`) and retain the backup. +5. **Resume and retry**: After a verified, history-preserving repair is saved at ``, start the daemon (`rig daemon start`), resume the loopback bridge, and retry the original inbound delivery with the same `eventId`. + +For automated loopback bridge test reference, see `packages/daemon/test/fixtures/recording-relay.ts`. diff --git a/packages/cli/src/commands/gateway.ts b/packages/cli/src/commands/gateway.ts index d3b27e54e..201b71081 100644 --- a/packages/cli/src/commands/gateway.ts +++ b/packages/cli/src/commands/gateway.ts @@ -175,7 +175,7 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command { .requiredOption("--display-name ", "Human-readable display name") .option( "--binding ]>", - "Connector binding (repeatable); leave it out for a person with no connector, who's reached in the Feed and Steering only. With bindings, role primary|secondary, EXACTLY ONE primary. connectorRef and secretsRef are config/vault labels (e.g. for Slack: slack:openrig-personal:file::primary:handle=; tokens come from 'rig slack setup --secrets-env-file', never inline). Optional handle= makes the binding inbound-resolvable (unique per kind); omit it for outbound-only.", + "Connector binding (repeatable); leave it out for a person with no connector, who's reached in the Feed and Steering only. With bindings, role primary|secondary, EXACTLY ONE primary. connectorRef and secretsRef are config/vault labels (e.g. for Slack: slack:openrig-personal:file::primary:handle=; tokens come from 'rig slack setup --secrets-env-file', never inline; or file: for relay: relay::file::primary:handle=). Optional handle= makes the binding inbound-resolvable (unique per kind for Slack, unique per connectorRef+handle for relay); omit it for outbound-only.", (v: string, acc: string[] = []) => { acc.push(v); return acc; }, ) .requiredOption("--delivery-class ", "Notification loudness class (the notifications register selection)") @@ -253,16 +253,24 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command { .addHelpText("after", ` Binding specification details: Format: ]> - - kind: Connector kind (e.g. "slack") - - connectorRef: Configuration label identifying the connector instance (e.g. "openrig-personal" or "main") - - secretsRef: Pointer or label identifying where secrets are resolved (e.g. "file:/path/to/slack.env" or "vault://slack/user"). - NOTE: Never pass credentials or tokens inline here; credentials (like SLACK_BOT_TOKEN) are loaded from the connector's secrets env file (configured via 'rig slack setup --secrets-env-file'). + - kind: Connector kind: "slack" or "relay" + - connectorRef: Configuration label identifying the connector instance (e.g. "openrig-personal", "main", or "local-bridge") + - secretsRef: Pointer or label identifying where secrets are resolved: + * For Slack: "file:/path/to/slack.env" or "vault://slack/user". Credentials (like SLACK_BOT_TOKEN) are loaded from the connector's secrets env file (configured via 'rig slack setup --secrets-env-file'). + * For Relay: "file:" pointing to a chmod 600 file holding OPENRIG_RELAY_URL (HTTP loopback only, e.g. http://127.0.0.1:8088), OPENRIG_RELAY_OUTBOUND_TOKEN, and OPENRIG_RELAY_INBOUND_TOKEN (distinct non-empty values; no environment fallback). + NOTE: Never pass credentials or tokens inline here. - role: "primary" (exactly one binding must be primary) or "secondary" - - handle: Optional platform user identifier (e.g. "handle=U12345678") for inbound routing + - handle: Optional platform user identifier (e.g. "handle=U12345678" for Slack, or "handle=" matching /^[A-Za-z0-9._-]+$/ for relay) for inbound routing. For relay, handles are unique per connectorRef. -Example: +Examples: + # Slack binding rig gateway human add alice --display-name "Alice" \\ --binding "slack:openrig-personal:file:/etc/openrig/slack.env:primary:handle=U12345678" \\ + --delivery-class B + + # Relay binding (loopback bridge adapter) + rig gateway human add bob --display-name "Bob" \\ + --binding "relay:local-bridge:file:/etc/openrig/relay.env:primary:handle=bob-ops" \\ --delivery-class B`); // ── S12 (OPR.0.5.5.12): the fragment lifecycle beyond add. Every verb operates through diff --git a/packages/cli/test/gateway-human-registry-verb.test.ts b/packages/cli/test/gateway-human-registry-verb.test.ts index 747714a29..bd0c587ae 100644 --- a/packages/cli/test/gateway-human-registry-verb.test.ts +++ b/packages/cli/test/gateway-human-registry-verb.test.ts @@ -48,6 +48,23 @@ describe("rig gateway human add verb (post-relocate)", () => { expect(bindingOpt).toBeDefined(); expect(bindingOpt!.description).toContain("connectorRef and secretsRef are config/vault labels"); expect(bindingOpt!.description).toContain("tokens come from 'rig slack setup --secrets-env-file'"); + expect(bindingOpt!.description).toContain("or file: for relay"); + }); + + it("gateway human add help text documents relay binding syntax and requirements", () => { + const gw = createProgram().commands.find((c) => c.name() === "gateway"); + const human = gw!.commands.find((c) => c.name() === "human"); + const addCmd = human!.commands.find((c) => c.name() === "add"); + let help = ""; + addCmd!.configureOutput({ + writeOut: (text) => { help += text; }, + writeErr: () => {}, + }); + addCmd!.outputHelp(); + expect(help).toContain("relay::file::primary:handle="); + expect(help).toContain("OPENRIG_RELAY_URL"); + expect(help).toContain("OPENRIG_RELAY_OUTBOUND_TOKEN"); + expect(help).toContain("OPENRIG_RELAY_INBOUND_TOKEN"); }); it("add writes a fragment + projection; address DERIVED; vault-pointer secretsRef (with ':') survives", async () => { diff --git a/packages/daemon/src/domain/gateway/connections-projection.ts b/packages/daemon/src/domain/gateway/connections-projection.ts index 2a216449c..74dd84944 100644 --- a/packages/daemon/src/domain/gateway/connections-projection.ts +++ b/packages/daemon/src/domain/gateway/connections-projection.ts @@ -4,6 +4,7 @@ import { createHash } from "node:crypto"; import { loadConfig, configPathFor, DEFAULT_CONFIG, type SlackConnectorConfig } from "./slack/config.js"; import { resolveSecret } from "./slack/secrets.js"; import { loadHumanRegistry } from "./human-registry.js"; +import { loadRelayProfiles } from "./relay-config.js"; import { channelStateDigest } from "./channel-operations.js"; import type { SettingsStore } from "../user-settings/settings-store.js"; @@ -82,33 +83,58 @@ export function connectionsProjection(home: string, gateway: Record; try { registry = loadHumanRegistry(home, { readOnly: true }); } catch { registry = { ok: false, error: "registry unavailable" }; } + const relayProfilesResult = registry.ok ? loadRelayProfiles(home, { entities: registry.entities }) : null; + const relayProfileValues = relayProfilesResult ? [...relayProfilesResult.profiles.values()] : []; + const relayTokens = relayProfileValues + .flatMap((p) => [p.credentials?.inboundToken, p.credentials?.outboundToken]) + .filter((t): t is string => Boolean(t)); + const safeText = (v: unknown): string | null => { + const base = text(v); + if (base === null) return null; + let out = base; + for (const tok of relayTokens) out = out.split(tok).join("[redacted]"); + return out; + }; + const relaySummary = { + configured: relayProfileValues.length > 0, + ready: relayProfileValues.length > 0 && relayProfileValues.every((p) => p.ready), + profiles: relayProfileValues.map((p) => ({ + connectorRef: safeText(p.connectorRef), + state: p.state, + configured: p.configured, + ready: p.ready, + reason: safeText(p.reason), + nextAction: safeText(p.nextAction), + })), + }; const instance = ["host.name", "workspace.root", "workspace.operator_seat_name"] as const; const browserKey = (value: string) => createHash("sha256").update(value).digest("hex").slice(0, 16); return { - observedAt: new Date().toISOString(), home: text(home), pid: process.pid, - settingsSource: text(settings?.configPath), + observedAt: new Date().toISOString(), home: safeText(home), pid: process.pid, + settingsSource: safeText(settings?.configPath), settings: instance.map((key) => { try { const r = settings?.resolveOne(key); - return { key, value: text(r?.value), source: r?.source ?? "unavailable" }; + return { key, value: safeText(r?.value), source: r?.source ?? "unavailable" }; } catch { return { key, value: null, source: "unavailable" }; } }), - configSource: { state: configState, path: text(configPath), sourceState }, configuration, - running: { state: text(gateway?.state) ?? "unavailable", activatedAt: text(gateway?.activatedAt), + configSource: { state: configState, path: safeText(configPath), sourceState }, configuration, + running: { state: safeText(gateway?.state) ?? "unavailable", activatedAt: safeText(gateway?.activatedAt), outboundReady: typeof connector?.outboundReady === "boolean" ? connector.outboundReady : null, inboundReady: typeof connector?.inboundReady === "boolean" ? connector.inboundReady : null, - inboundState: text(inbound?.state) ?? "unverified", + inboundState: safeText(inbound?.state) ?? "unverified", // Delivery health beside the socket state: an open socket is not proof that events arrive. - inboundDelivery: text(inbound?.delivery), inboundEventsMissingSince: text(inbound?.eventsMissingSince), applied }, + inboundDelivery: safeText(inbound?.delivery), inboundEventsMissingSince: safeText(inbound?.eventsMissingSince), applied }, state, nextAction, - registry: { state: registry.ok ? "available" : "unavailable", path: text(join(home, "gateway", "humans")) }, + relay: relaySummary, + registry: { state: registry.ok ? "available" : "unavailable", path: safeText(join(home, "gateway", "humans")) }, humans: registry.ok ? registry.entities.map((h) => ({ browserKey: browserKey(h.entityId), - entityId: text(h.entityId) ?? "[withheld]", address: text(h.address) ?? "[withheld]", displayName: text(h.displayName), class: h.class, away: h.prefs.away ?? null, + entityId: safeText(h.entityId) ?? "[withheld]", address: safeText(h.address) ?? "[withheld]", displayName: safeText(h.displayName), class: h.class, away: h.prefs.away ?? null, deliveryClass: h.prefs.deliveryClass, availability: h.prefs.availability ?? (h.prefs.away ? "away" : "available"), excluded: cfg ? cfg.outboundDestinations.length > 0 && !cfg.outboundDestinations.includes(h.address) : null, bindings: h.connectorBindings.map((b) => ({ browserKey: browserKey(JSON.stringify([b.kind, b.connectorRef, b.role, b.handle ?? null])), - kind: b.kind, ref: text(b.connectorRef), role: b.role, handle: text(b.handle), credentialReference: Boolean(b.secretsRef) })), + kind: b.kind, ref: safeText(b.connectorRef), role: b.role, handle: safeText(b.handle), credentialReference: Boolean(b.secretsRef) })), })) : [], }; } diff --git a/packages/daemon/src/domain/gateway/dispatcher.ts b/packages/daemon/src/domain/gateway/dispatcher.ts index fa36930c8..ff52590a7 100644 --- a/packages/daemon/src/domain/gateway/dispatcher.ts +++ b/packages/daemon/src/domain/gateway/dispatcher.ts @@ -18,6 +18,8 @@ export interface DispatcherDeps { send: (decision: OutboundDecision) => void; /** Injectable id source (tests pin decisionIds; prod = a UUID). */ newDecisionId?: () => string; + /** Optional synchronous preparation seam run BEFORE durable enqueue (e.g. to freeze relay envelope/refs on the decision payload). */ + prepare?: (decision: OutboundDecision) => OutboundDecision | void; } export type DispatchResult = @@ -54,7 +56,20 @@ export class GatewayDispatcher { if (opts?.decisionId && this.deps.buffer.pending().some((d) => d.decisionId === decisionId)) { return { ok: true, decisionId }; // idempotent accept: already durably enqueued; replay owns the redrive } - const decision: OutboundDecision = { kind: "outbound_decision", decisionId, op, entityBindingRef, payload }; + let payloadToEnqueue = payload; + if (this.deps.prepare) { + const prepared = this.deps.prepare({ kind: "outbound_decision", decisionId, op, entityBindingRef, payload }); + if (prepared && prepared.payload !== undefined) { + payloadToEnqueue = prepared.payload; + } + } + const decision: OutboundDecision = { + kind: "outbound_decision", + decisionId, + op, + entityBindingRef, + payload: payloadToEnqueue, + }; this.deps.buffer.enqueue(decision); // durable BEFORE dispatch this.deps.send(decision); return { ok: true, decisionId }; diff --git a/packages/daemon/src/domain/gateway/gateway-subsystem.ts b/packages/daemon/src/domain/gateway/gateway-subsystem.ts index 7980a3f04..e510a9cd2 100644 --- a/packages/daemon/src/domain/gateway/gateway-subsystem.ts +++ b/packages/daemon/src/domain/gateway/gateway-subsystem.ts @@ -41,6 +41,8 @@ export interface InProcessWireOpts { /** The platform delivery seam. Until the relay cutover wires Slack, startup passes an * honest unwired refusal — dispatches are retained durably, never silently dropped. */ deliver: SubsystemDeliverFn; + /** Optional synchronous preparation seam run BEFORE durable enqueue so relay envelopes/refs are frozen once. */ + prepare?: (decision: OutboundDecision) => OutboundDecision | void; /** Ops the delivery layer advertises. The shipped proof-9 rail holds in-process: an * unadvertised op is refused, never attempted. Empty = nothing dispatchable yet. */ ops?: string[]; @@ -72,6 +74,7 @@ export function buildInProcessWire(opts: InProcessWireOpts): GatewayWire { const dispatcher: GatewayDispatcher = new GatewayDispatcher({ buffer, send: (decision) => { void deliverAndAck(decision); }, + ...(opts.prepare ? { prepare: opts.prepare } : {}), }); dispatcher.onCapability({ kind: "capability", diff --git a/packages/daemon/src/domain/gateway/human-readiness.ts b/packages/daemon/src/domain/gateway/human-readiness.ts index 5e20fbec4..0a28562f2 100644 --- a/packages/daemon/src/domain/gateway/human-readiness.ts +++ b/packages/daemon/src/domain/gateway/human-readiness.ts @@ -2,6 +2,8 @@ import type { HumanFragment } from "./human-registry.js"; import type { SlackConnectorConfig } from "./slack/config.js"; import { verifyScopes, verifyChannelMembership, type ScopeVerdict } from "./slack/slack-api.js"; import { mappedChannels } from "./slack/channel-map.js"; +import { loadRelayProfiles, type LoadRelayProfilesResult } from "./relay-config.js"; +import type { SecretFsOps } from "./slack/secrets.js"; export type HumanDeliveryReadinessState = "ready" | "not-ready" | "indeterminate"; @@ -23,12 +25,17 @@ export interface HumanDeliveryReadinessInput { config: SlackConnectorConfig; gatewayState: string; botToken: string | null; + /** Optional preloaded relay profiles; when omitted for a relay primary binding, loaded from the full human registry. */ + relayProfiles?: LoadRelayProfilesResult | null; + home?: string; } export interface HumanDeliveryReadinessDeps { verifyScopes?: (token: string, required: string[]) => Promise; verifyMembership?: (token: string, channel: string) => Promise<{ ok: boolean; isMember: boolean; error?: string }>; now?: () => Date; + fsops?: SecretFsOps; + entities?: readonly HumanFragment[]; } /** Resolve the registered human's PRIMARY binding to delivery truth in one record. @@ -49,6 +56,50 @@ export async function resolveHumanDeliveryReadiness( connector: { kind: primary.kind, ref: primary.connectorRef }, checkedAt, }; + if (primary.kind === "relay") { + const loaded = input.relayProfiles !== undefined + ? input.relayProfiles + : loadRelayProfiles(input.home, { entities: deps.entities, fsops: deps.fsops }); + const profile = loaded?.ok ? loaded.get(primary.connectorRef) : undefined; + const defaultNextAction = "rig gateway human show " + input.human.entityId + " --json"; + if (!profile || !profile.ready) { + return { + state: "not-ready", + configured: profile?.configured ?? false, + enabled: true, + active: false, + ready: false, + ...base, + reason: profile?.reason + ?? (loaded && !loaded.ok && loaded.registryError + ? loaded.registryError + : `relay profile '${primary.connectorRef}' is not configured`), + nextAction: profile?.nextAction ?? defaultNextAction, + }; + } + if (input.gatewayState !== "active") { + return { + state: "not-ready", + configured: true, + enabled: true, + active: false, + ready: false, + ...base, + reason: `gateway subsystem is ${input.gatewayState}`, + nextAction: "rig daemon logs", + }; + } + return { + state: "ready", + configured: true, + enabled: true, + active: true, + ready: true, + ...base, + reason: profile.reason, + nextAction: null, + }; + } const configured = primary.kind === "slack" && input.botToken !== null && input.config.channel !== null; const enabled = input.config.enabled; const active = enabled && input.gatewayState === "active"; diff --git a/packages/daemon/src/domain/gateway/human-registry.ts b/packages/daemon/src/domain/gateway/human-registry.ts index 092d63daa..9773964a4 100644 --- a/packages/daemon/src/domain/gateway/human-registry.ts +++ b/packages/daemon/src/domain/gateway/human-registry.ts @@ -16,7 +16,7 @@ import { createHash, randomUUID } from "node:crypto"; import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, readdirSync, rmSync } from "node:fs"; -import { join, dirname } from "node:path"; +import { join, dirname, isAbsolute } from "node:path"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import { getOpenRigHome } from "../../openrig-compat.js"; import { DispatchBuffer } from "./dispatch-buffer.js"; @@ -24,7 +24,8 @@ import { parseSessionName } from "../session-name.js"; // ── Schema (closed enums; extend only additively behind the contract) ── export const HUMAN_ENTITY_CLASSES = new Set(["human"]); // M1's only class -export const HUMAN_CONNECTOR_KINDS = new Set(["slack"]); // M1's only kind +export const HUMAN_CONNECTOR_KINDS = new Set(["slack", "relay"]); +export type HumanConnectorKind = "slack" | "relay"; export const HUMAN_BINDING_ROLES = new Set(["primary", "secondary"]); export const HUMAN_DELIVERY_CLASSES = new Set(["A", "B", "C", "D"]); // entityId is the fragment key + filename: a stable slug that survives platform @@ -109,7 +110,7 @@ function unknownKey(obj: Record, allowed: Set): string } export interface HumanConnectorBinding { - kind: "slack"; + kind: HumanConnectorKind; connectorRef: string; /** POINTER at the connector's vault (secrets-on-connector) — NEVER the secret. */ secretsRef: string; @@ -118,11 +119,24 @@ export interface HumanConnectorBinding { /** A6 v3: the platform-native id of this human on the connector (e.g. Slack user id). * OPTIONAL — a handle-less binding is OUTBOUND-ONLY (delivery works; it is NOT * inbound-resolvable, so an inbound event on it fails admission LOUDLY). REQUIRED to be - * inbound-resolvable. UNIQUE per kind across ALL bindings of ALL humans (one platform id = - * exactly one human; a duplicate is a registration conflict, REFUSED). */ + * inbound-resolvable. UNIQUE per kind (Slack) or per connectorRef (relay) across ALL + * bindings of ALL humans (one platform id = one human; a duplicate is a registration + * conflict, REFUSED). */ handle?: string; } +function handleScopeKey(binding: Pick & { handle: string }): string { + return binding.kind === "relay" + ? JSON.stringify(["relay", binding.connectorRef, binding.handle]) + : `${binding.kind}:${binding.handle}`; +} + +function handleScopeLabel(binding: Pick & { handle: string }): string { + return binding.kind === "relay" + ? `relay (${binding.connectorRef}) handle "${binding.handle}"` + : `${binding.kind} handle "${binding.handle}"`; +} + export interface HumanPrefs { /** Per-ENTITY loudness — a SELECTION from the notifications register (spec §6); * A3 carries it, never redefines it. Forward-compatible if the register grows. */ @@ -214,7 +228,7 @@ export function validateHumanFragment(raw: unknown): ValidateResult { return { ok: false, error: `connectorBindings[${i}].handle "${String(b.handle)}" must match ${HANDLE_PATTERN} (a platform id — no ':' '@' or whitespace, which could forge a ref)` }; } } - const binding: HumanConnectorBinding = { kind: "slack", connectorRef: b.connectorRef, secretsRef: b.secretsRef, role: b.role as "primary" | "secondary" }; + const binding: HumanConnectorBinding = { kind: b.kind as HumanConnectorKind, connectorRef: b.connectorRef, secretsRef: b.secretsRef, role: b.role as "primary" | "secondary" }; if (b.handle !== undefined) { handle = b.handle as string; binding.handle = handle; } bindings.push(binding); } @@ -222,15 +236,20 @@ export function validateHumanFragment(raw: unknown): ValidateResult { if (bindings.length > 0 && primaryCount !== 1) { return { ok: false, error: `exactly one connectorBinding must have role "primary" (found ${primaryCount}) — it is the default delivery channel` }; } - // A6 v3 pin-1 (within-fragment): a handle is UNIQUE per kind across this human's bindings — - // one human must not claim the same platform id twice. (Cross-fragment uniqueness is enforced - // in projectHumans, which sees every human at once.) + // A6 v3 pin-1 (within-fragment): a handle is UNIQUE per kind (Slack) or per connectorRef + // (relay) across this human's bindings — one human must not claim the same platform id twice. + // (Cross-fragment uniqueness is enforced in projectHumans, which sees every human at once.) const seenHandles = new Set(); for (const b of bindings) { if (b.handle === undefined) continue; - const key = `${b.kind}:${b.handle}`; + const key = handleScopeKey({ kind: b.kind, connectorRef: b.connectorRef, handle: b.handle }); if (seenHandles.has(key)) { - return { ok: false, error: `duplicate ${b.kind} handle "${b.handle}" across this human's bindings — a handle is unique per kind (one platform id = one human)` }; + return { + ok: false, + error: b.kind === "relay" + ? `duplicate relay (${b.connectorRef}) handle "${b.handle}" across this human's bindings — a relay handle is unique per connectorRef` + : `duplicate ${b.kind} handle "${b.handle}" across this human's bindings — a handle is unique per kind (one platform id = one human)`, + }; } seenHandles.add(key); } @@ -321,10 +340,10 @@ function validateEntityCollection(entities: readonly HumanFragment[]): string | entityIds.add(entity.entityId); for (const binding of entity.connectorBindings) { if (binding.handle === undefined) continue; - const key = `${binding.kind}:${binding.handle}`; + const key = handleScopeKey({ kind: binding.kind, connectorRef: binding.connectorRef, handle: binding.handle }); const prior = handleOwner.get(key); if (prior !== undefined && prior !== entity.entityId) { - return `${binding.kind} handle "${binding.handle}" is claimed by both "${prior}" and "${entity.entityId}" — a handle maps to exactly one human (registration conflict)`; + return `${handleScopeLabel({ kind: binding.kind, connectorRef: binding.connectorRef, handle: binding.handle })} is claimed by both "${prior}" and "${entity.entityId}" — a handle maps to exactly one human (registration conflict)`; } handleOwner.set(key, entity.entityId); } @@ -403,6 +422,41 @@ export function resolveSlackHandle( }; } +export type RelayHandleResolution = + | { kind: "registered"; entityId: string; address: string; connectorRef: string; handle: string } + | { kind: "unregistered"; connectorRef: string; handle: string; error: string }; + +/** Resolve an INBOUND relay handle scoped to the authenticated relay `connectorRef`. + * Unlike Slack (where user IDs are globally unique across kind=slack), relay aliases are + * scoped to `(kind="relay", connectorRef, handle)` so an authenticated relay can assert + * only its own bindings while allowing multiple registered people on the same relay to + * answer with their own entity address. Handle-less relay bindings remain outbound-only. */ +export function resolveRelayHandle( + connectorRef: string, + handle: string, + entities: readonly HumanFragment[], +): RelayHandleResolution { + if (connectorRef.length > 0 && handle.length > 0) { + for (const e of entities) { + for (const b of e.connectorBindings) { + if (b.kind === "relay" && b.connectorRef === connectorRef && b.handle !== undefined && b.handle === handle) { + return { kind: "registered", entityId: e.entityId, address: e.address, connectorRef, handle }; + } + } + } + } + return { + kind: "unregistered", + connectorRef, + handle, + error: + `inbound relay (${connectorRef}) sender "${handle}" is not a registered human (no relay binding for "${connectorRef}" with handle "${handle}"). ` + + `Register the human + their handle first: rig gateway human add --display-name … ` + + `--binding relay:${connectorRef}:file::primary:handle=${handle} --delivery-class …; ` + + `until then this message is REFUSED (it was NOT landed as a fabricated human seat).`, + }; +} + export function atomicWrite(path: string, body: string): { ok: true } | { ok: false; error: string } { try { mkdirSync(dirname(path), { recursive: true }); @@ -451,20 +505,8 @@ export function addHumanFragment( } // A6 v3 pin-1: reject a handle already claimed by a DIFFERENT human BEFORE writing (never leave a // conflicting fragment on disk with a failed re-projection). projectHumans is the load-time backstop. - const existing = projectHumans(home); - if (!existing.ok) return { ok: false, error: `cannot validate handle uniqueness — existing registry is invalid: ${existing.error}` }; - const claimed = new Map(); - for (const e of existing.entities) { - if (e.entityId === v.fragment.entityId) continue; // a replace of the same human is fine - for (const b of e.connectorBindings) if (b.handle !== undefined) claimed.set(`${b.kind}:${b.handle}`, e.entityId); - } - for (const b of v.fragment.connectorBindings) { - if (b.handle === undefined) continue; - const owner = claimed.get(`${b.kind}:${b.handle}`); - if (owner !== undefined) { - return { ok: false, error: `${b.kind} handle "${b.handle}" is already registered to human "${owner}" — a handle maps to exactly one human (registration conflict)` }; - } - } + const conflict = handleConflict(v.fragment, home); + if (conflict) return { ok: false, error: conflict }; const w = atomicWrite(file, stringifyYaml(v.fragment)); if (!w.ok) return { ok: false, error: w.error }; // A new or replaced record may author any availability: it's a change of its own. @@ -570,6 +612,12 @@ export function parseBindingSpec(spec: string): if (!kind || !connectorRef || !secretsRef || !role) { return { ok: false, error: `binding spec fields must be non-empty: kind:connectorRef:secretsRef:role[:handle=] (got "${spec}")` }; } + if (kind === "relay" && secretsRef.startsWith("file:")) { + const filePath = secretsRef.slice("file:".length); + if (!filePath || filePath.startsWith("//") || !isAbsolute(filePath)) { + return { ok: false, error: `binding file secretsRef must be file: (got "${spec}")` }; + } + } return { ok: true, binding: handle !== undefined ? { kind, connectorRef, secretsRef, role, handle } : { kind, connectorRef, secretsRef, role } }; } @@ -667,13 +715,17 @@ function handleConflict(fragment: HumanFragment, home: string): string | undefin const claimed = new Map(); for (const e of existing.entities) { if (e.entityId === fragment.entityId) continue; - for (const b of e.connectorBindings) if (b.handle !== undefined) claimed.set(`${b.kind}:${b.handle}`, e.entityId); + for (const b of e.connectorBindings) { + if (b.handle !== undefined) { + claimed.set(handleScopeKey({ kind: b.kind, connectorRef: b.connectorRef, handle: b.handle }), e.entityId); + } + } } for (const b of fragment.connectorBindings) { if (b.handle === undefined) continue; - const owner = claimed.get(`${b.kind}:${b.handle}`); + const owner = claimed.get(handleScopeKey({ kind: b.kind, connectorRef: b.connectorRef, handle: b.handle })); if (owner !== undefined) { - return `${b.kind} handle "${b.handle}" is already registered to human "${owner}" — a handle maps to exactly one human (registration conflict)`; + return `${handleScopeLabel({ kind: b.kind, connectorRef: b.connectorRef, handle: b.handle })} is already registered to human "${owner}" — a handle maps to exactly one human (registration conflict)`; } } return undefined; diff --git a/packages/daemon/src/domain/gateway/operator-delivery-engine.ts b/packages/daemon/src/domain/gateway/operator-delivery-engine.ts index 4e0f374c7..d2359185d 100644 --- a/packages/daemon/src/domain/gateway/operator-delivery-engine.ts +++ b/packages/daemon/src/domain/gateway/operator-delivery-engine.ts @@ -6,6 +6,7 @@ // dispatcher and the REAL tick. import type { QueueItem, QueueRepository } from "../queue-repository.js"; +import { RELAY_OWNER_NOTIFICATION_ACCEPTED_PREFIX } from "../queue-transition-log.js"; import { DispatchBuffer } from "./dispatch-buffer.js"; import { lastMeaningfulTransition } from "../queue-waiting.js"; import type { DispatchResult } from "./dispatcher.js"; @@ -92,6 +93,7 @@ export function makeOperatorDeliveryEngine(deps: { if (note.startsWith("escalation-rung: operator dispatched-to-engine")) resolved = null; if (note.split(/\s+/).includes(`notification_key=${key}`)) { if (note.startsWith("slack-owner-notification-posted ")) resolved = "posted"; + if (note.startsWith(`${RELAY_OWNER_NOTIFICATION_ACCEPTED_PREFIX} `)) resolved = "accepted"; if (note.startsWith("delivery-termination:")) resolved = "terminated"; } } @@ -134,9 +136,12 @@ export function makeOperatorDeliveryEngine(deps: { // Already-accepted decisions returned above retain gateway custody, even if readiness later changes. try { const readiness = await (deps.readiness?.(human) ?? resolveHumanDeliveryReadiness({ - human, config: cfg, gatewayState: deps.gatewayState?.() ?? "unavailable", + human, + config: cfg, + gatewayState: deps.gatewayState?.() ?? "unavailable", botToken: resolveSecret("SLACK_BOT_TOKEN", { envFile: cfg.secretsEnvFile ?? undefined }), - })); + home: deps.home, + }, { entities: reg.entities })); if (!readiness.ready) return { decision: `unavailable:human-delivery:${readiness.reason}`, resolved: false, dispatched: false }; } catch { return { decision: "unavailable:human-delivery:readiness-unavailable", resolved: false, dispatched: false }; diff --git a/packages/daemon/src/domain/gateway/relay-config.ts b/packages/daemon/src/domain/gateway/relay-config.ts new file mode 100644 index 000000000..75d65cede --- /dev/null +++ b/packages/daemon/src/domain/gateway/relay-config.ts @@ -0,0 +1,420 @@ +import { isAbsolute, resolve as resolvePath } from "node:path"; +import { getOpenRigHome } from "../../openrig-compat.js"; +import { constantTimeEqual } from "../../middleware/auth-bearer-token.js"; +import { + loadHumanRegistry, + registeredPeople, + type HumanFragment, +} from "./human-registry.js"; +import { + nodeSecretFs, + parseEnvFile, + type SecretFsOps, +} from "./slack/secrets.js"; + +export const RELAY_ENV_KEYS = { + url: "OPENRIG_RELAY_URL", + outboundToken: "OPENRIG_RELAY_OUTBOUND_TOKEN", + inboundToken: "OPENRIG_RELAY_INBOUND_TOKEN", +} as const; + +export interface RelayCredentials { + readonly url: string; + readonly outboundToken: string; + readonly inboundToken: string; +} + +export interface RelayProfileReadiness { + readonly connectorRef: string; + readonly state: "ready" | "not-ready"; + readonly configured: boolean; + readonly ready: boolean; + readonly reason: string; + readonly nextAction: string | null; + readonly warnings: readonly string[]; +} + +export interface RelayBoundHuman { + readonly entityId: string; + readonly address: string; + readonly role: "primary" | "secondary"; + readonly secretsRef: string; + readonly handle?: string; +} + +export interface RelayProfile extends RelayProfileReadiness { + readonly secretsRef: string | null; + readonly envFile: string | null; + readonly endpointUrl: string | null; + readonly readiness: RelayProfileReadiness; + readonly humans: readonly RelayBoundHuman[]; + /** Private credentials for outbound/inbound transport; null when not ready; non-enumerable and never serialized. */ + readonly credentials: RelayCredentials | null; +} + +export interface LoadRelayProfilesOptions { + readonly entities?: readonly HumanFragment[]; + readonly fsops?: SecretFsOps; +} + +export interface LoadRelayProfilesResult { + readonly ok: boolean; + readonly registryError: string | null; + readonly profiles: ReadonlyMap; + get(connectorRef: string): RelayProfile | undefined; +} + +/** Parse a relay binding's `secretsRef` (`file:`). + * Rejects bare paths, relative paths, empty pointers, and `file://` authority URLs. */ +export function parseRelaySecretsRef( + secretsRef: string, +): { ok: true; envFile: string } | { ok: false; reason: string } { + const trimmed = secretsRef.trim(); + if (!trimmed.startsWith("file:") || trimmed.includes("\0")) { + return { ok: false, reason: "relay secretsRef must use file: syntax" }; + } + const rawPath = trimmed.slice("file:".length); + if (!rawPath || rawPath.startsWith("//") || !isAbsolute(rawPath)) { + return { ok: false, reason: "relay secretsRef must point to an absolute file path" }; + } + return { ok: true, envFile: resolvePath(rawPath) }; +} + +const IPV4_LOOPBACK_RE = /^127(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/; + +function extractRawAuthorityHost(rawUrl: string): string | null { + const match = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/([^/?#]+)/.exec(rawUrl); + if (!match) return null; + const authority = match[1]!; + if (authority.startsWith("[")) { + const end = authority.indexOf("]"); + return end === -1 ? null : authority.slice(1, end).toLowerCase(); + } + const colon = authority.indexOf(":"); + return (colon === -1 ? authority : authority.slice(0, colon)).toLowerCase(); +} + +function isStrictLoopbackHost(hostname: string, rawHost: string): boolean { + const norm = hostname.replace(/^\[|\]$/g, "").toLowerCase(); + if (norm === "localhost" && rawHost === "localhost") return true; + if ((norm === "::1" || norm === "0:0:0:0:0:0:0:1") && (rawHost === "::1" || rawHost === "0:0:0:0:0:0:0:1")) { + return true; + } + return IPV4_LOOPBACK_RE.test(norm) && IPV4_LOOPBACK_RE.test(rawHost); +} + +/** Validate `OPENRIG_RELAY_URL`: must be `http:`, loopback host (`127.x.y.z`, `::1`, or `localhost`), + * with no embedded credentials and no fragment. Never echoes raw URL or credentials in reasons. */ +export function validateRelayUrl( + rawUrl: string, +): { ok: true; url: string } | { ok: false; reason: string } { + const trimmed = rawUrl.trim(); + if (!trimmed || /[\r\n\0]/.test(trimmed)) { + return { ok: false, reason: "OPENRIG_RELAY_URL is empty or malformed" }; + } + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return { ok: false, reason: "OPENRIG_RELAY_URL is not a valid URL" }; + } + if (parsed.protocol !== "http:") { + return { ok: false, reason: "OPENRIG_RELAY_URL must use http: scheme" }; + } + if (parsed.username !== "" || parsed.password !== "") { + return { ok: false, reason: "OPENRIG_RELAY_URL must not contain embedded credentials" }; + } + if (parsed.hash !== "") { + return { ok: false, reason: "OPENRIG_RELAY_URL must not contain a URL fragment" }; + } + const rawHost = extractRawAuthorityHost(trimmed); + if (!rawHost || !isStrictLoopbackHost(parsed.hostname, rawHost)) { + return { + ok: false, + reason: "OPENRIG_RELAY_URL host must be loopback (numeric 127.0.0.1/8, ::1, or localhost)", + }; + } + return { ok: true, url: parsed.toString() }; +} + +function createPrivateCredentials(url: string, outboundToken: string, inboundToken: string): RelayCredentials { + const creds = { url, outboundToken, inboundToken }; + Object.defineProperty(creds, "toJSON", { value: () => "[REDACTED]", enumerable: false }); + Object.defineProperty(creds, Symbol.for("nodejs.util.inspect.custom"), { + value: () => "[RelayCredentials REDACTED]", + enumerable: false, + }); + return Object.freeze(creds); +} + +/** Check permissions on a relay secret file. Like Slack secret file checks, unsafe group/other + * permission bits (`(mode & 0o077) !== 0`) produce a warning rather than making the profile unready. + * Owner-only modes such as `0600` or `0400` produce no warning (`null`). */ +function checkRelaySecretFilePermissions( + envFile: string, + fsops: SecretFsOps, + connectorRef: string, +): string | null { + const mode = fsops.statMode(envFile); + if (mode === null) return null; + if ((mode & 0o077) !== 0) { + return `relay secret file for '${connectorRef}' has insecure permissions (mode 0${mode.toString(8).padStart(3, "0")}; group/other access bits are set — restrict so group/other have no access, e.g. 0600 or 0400)`; + } + return null; +} + +interface CandidateProfile { + connectorRef: string; + secretsRef: string | null; + envFile: string | null; + endpointUrl: string | null; + humans: RelayBoundHuman[]; + nextAction: string | null; + warnings: string[]; + unreadyReason?: string; + url?: string; + outboundToken?: string; + inboundToken?: string; +} + +function evaluateRelayCandidate( + connectorRef: string, + humans: RelayBoundHuman[], + fsops: SecretFsOps, +): CandidateProfile { + const firstHuman = humans[0]!; + const defaultNextAction = `rig gateway human show ${firstHuman.entityId} --json`; + const warnings: string[] = []; + const fail = ( + unreadyReason: string, + secretsRef: string | null = firstHuman.secretsRef, + envFile: string | null = null, + nextAction: string = defaultNextAction, + ): CandidateProfile => ({ + connectorRef, + secretsRef, + envFile, + endpointUrl: null, + humans, + nextAction, + warnings, + unreadyReason, + }); + + const envFiles = new Set(); + for (const h of humans) { + const parsed = parseRelaySecretsRef(h.secretsRef); + if (!parsed.ok) { + return fail( + `relay profile '${connectorRef}' has invalid secretsRef: ${parsed.reason}`, + h.secretsRef, + null, + `rig gateway human show ${h.entityId} --json`, + ); + } + envFiles.add(parsed.envFile); + } + if (envFiles.size > 1) { + return fail( + `relay profile '${connectorRef}' has inconsistent provisioning across human bindings (conflicting secretsRef file paths)`, + null, + null, + "rig gateway human list --json", + ); + } + + const [envFile] = envFiles as Set & [string]; + const secretsRef = firstHuman.secretsRef; + const mode = fsops.statMode(envFile!); + if (mode === null) { + return fail(`relay secret file for '${connectorRef}' is missing or unreadable`, secretsRef, envFile!); + } + const permWarning = checkRelaySecretFilePermissions(envFile!, fsops, connectorRef); + if (permWarning) { + warnings.push(permWarning); + } + + let rawText: string; + try { + rawText = fsops.readFileSync(envFile!); + } catch { + return fail(`relay secret file for '${connectorRef}' is missing or unreadable`, secretsRef, envFile!); + } + + const parsedEnv = parseEnvFile(rawText); + const rawUrl = (parsedEnv[RELAY_ENV_KEYS.url] ?? "").trim(); + const outboundToken = (parsedEnv[RELAY_ENV_KEYS.outboundToken] ?? "").trim(); + const inboundToken = (parsedEnv[RELAY_ENV_KEYS.inboundToken] ?? "").trim(); + + if (!rawUrl) { + return fail(`relay profile '${connectorRef}' is missing ${RELAY_ENV_KEYS.url}`, secretsRef, envFile!); + } + if (!outboundToken || /[\r\n\0]/.test(outboundToken)) { + return fail(`relay profile '${connectorRef}' is missing ${RELAY_ENV_KEYS.outboundToken}`, secretsRef, envFile!); + } + if (!inboundToken || /[\r\n\0]/.test(inboundToken)) { + return fail(`relay profile '${connectorRef}' is missing ${RELAY_ENV_KEYS.inboundToken}`, secretsRef, envFile!); + } + if (constantTimeEqual(outboundToken, inboundToken)) { + warnings.push( + `relay profile '${connectorRef}' uses the same token for ${RELAY_ENV_KEYS.outboundToken} and ${RELAY_ENV_KEYS.inboundToken}; separate tokens are recommended so outbound bridge capture cannot authenticate inbound replies`, + ); + } + + const urlVerdict = validateRelayUrl(rawUrl); + if (!urlVerdict.ok) { + return fail( + `relay profile '${connectorRef}' has invalid ${RELAY_ENV_KEYS.url}: ${urlVerdict.reason}`, + secretsRef, + envFile!, + ); + } + + return { + connectorRef, + secretsRef, + envFile: envFile!, + endpointUrl: urlVerdict.url, + humans, + nextAction: defaultNextAction, + warnings, + url: urlVerdict.url, + outboundToken, + inboundToken, + }; +} + +function buildRelayProfile(c: CandidateProfile): RelayProfile { + const ready = !c.unreadyReason; + const frozenWarnings = Object.freeze([...c.warnings]); + const baseVerifiedReason = "relay profile verified (loopback HTTP endpoint and directional tokens configured)"; + const readyReason = + frozenWarnings.length > 0 + ? `${baseVerifiedReason}; warning: ${frozenWarnings.join("; ")}` + : baseVerifiedReason; + const readiness: RelayProfileReadiness = Object.freeze({ + connectorRef: c.connectorRef, + state: ready ? "ready" : "not-ready", + configured: ready, + ready, + reason: c.unreadyReason ?? readyReason, + nextAction: ready ? null : c.nextAction, + warnings: frozenWarnings, + }); + const credentials = ready ? createPrivateCredentials(c.url!, c.outboundToken!, c.inboundToken!) : null; + const profile = { + ...readiness, + secretsRef: c.secretsRef, + envFile: c.envFile, + endpointUrl: c.endpointUrl, + readiness, + humans: Object.freeze([...c.humans]), + } as unknown as RelayProfile; + + Object.defineProperty(profile, "credentials", { + value: credentials, + enumerable: false, + writable: false, + configurable: false, + }); + + return Object.freeze(profile); +} + +/** Load and validate all relay profiles referenced by registered humans' `kind: "relay"` bindings. + * Performs zero network I/O and never reads from `process.env`. */ +export function loadRelayProfiles( + home: string = getOpenRigHome(), + opts: LoadRelayProfilesOptions = {}, +): LoadRelayProfilesResult { + const fsops = opts.fsops ?? nodeSecretFs; + let entities: readonly HumanFragment[] = []; + let registryError: string | null = null; + let ok = true; + + if (opts.entities !== undefined) { + entities = opts.entities; + } else { + const loaded = loadHumanRegistry(home, { readOnly: true }); + const people = registeredPeople(loaded); + if (people === null) { + ok = false; + registryError = loaded.ok ? null : loaded.error; + } else { + entities = people; + } + } + + const grouped = new Map(); + for (const entity of entities) { + for (const binding of entity.connectorBindings) { + if (binding.kind !== "relay") continue; + const list = grouped.get(binding.connectorRef) ?? []; + list.push({ + entityId: entity.entityId, + address: entity.address, + role: binding.role, + secretsRef: binding.secretsRef, + ...(binding.handle !== undefined ? { handle: binding.handle } : {}), + }); + grouped.set(binding.connectorRef, list); + } + } + + const candidates = [...grouped.keys()] + .sort() + .map((connectorRef) => evaluateRelayCandidate(connectorRef, grouped.get(connectorRef)!, fsops)); + + const validCandidates = candidates.filter((c) => !c.unreadyReason && c.inboundToken !== undefined); + for (const current of validCandidates) { + const collidingRefs = validCandidates + .filter((other) => other.connectorRef !== current.connectorRef && constantTimeEqual(current.inboundToken!, other.inboundToken!)) + .map((other) => other.connectorRef); + if (collidingRefs.length > 0) { + current.unreadyReason = + `relay profile '${current.connectorRef}' reuses an ambiguous ${RELAY_ENV_KEYS.inboundToken} shared with relay profile(s): ${collidingRefs.join(", ")}`; + } + } + + const profiles = new Map(); + for (const c of candidates) { + profiles.set(c.connectorRef, buildRelayProfile(c)); + } + + return Object.freeze({ + ok, + registryError, + profiles, + get(connectorRef: string): RelayProfile | undefined { + return profiles.get(connectorRef); + }, + }); +} + +export type RelayInboundAuthResult = + | { ok: true; connectorRef: string; profile: RelayProfile } + | { ok: false; reason: string }; + +/** Derive authenticated relay identity from a bearer token by comparing against all ready + * profiles' `inboundToken` using `constantTimeEqual` without short-circuiting. */ +export function authenticateRelayInboundToken( + loaded: LoadRelayProfilesResult, + bearerToken: string | null | undefined, +): RelayInboundAuthResult { + if (typeof bearerToken !== "string" || bearerToken.length === 0) { + return { ok: false, reason: "missing relay bearer token" }; + } + const matches: RelayProfile[] = []; + for (const profile of loaded.profiles.values()) { + if (profile.ready && profile.credentials && constantTimeEqual(bearerToken, profile.credentials.inboundToken)) { + matches.push(profile); + } + } + if (matches.length === 1) { + return { ok: true, connectorRef: matches[0]!.connectorRef, profile: matches[0]! }; + } + return { + ok: false, + reason: matches.length > 1 ? "ambiguous relay bearer token across profiles" : "invalid relay bearer token", + }; +} diff --git a/packages/daemon/src/domain/gateway/relay-inbound.ts b/packages/daemon/src/domain/gateway/relay-inbound.ts new file mode 100644 index 000000000..9e5d3bd6d --- /dev/null +++ b/packages/daemon/src/domain/gateway/relay-inbound.ts @@ -0,0 +1,897 @@ +import { createHash } from "node:crypto"; +import { join } from "node:path"; +import { + HANDLE_PATTERN, + loadHumanRegistry, + registeredPeople, + resolveRelayHandle, + type HumanFragment, +} from "./human-registry.js"; +import { + authenticateRelayInboundToken, + loadRelayProfiles, +} from "./relay-config.js"; +import { + RELAY_PROTOCOL_VERSION, + decodeRelayReplyRef, + encodeRelayReplyRef, + findPendingRelayReplyRef, + hashRelayConnectorRef, + type RelayReplyRefPayload, + type RelayReplyRefToken, +} from "./relay-outbound.js"; +import { SeenStore, nodeStateFs, type StateFsOps } from "./slack/state-store.js"; +import type { RelayOwnerNotificationReceipt } from "../queue-transition-log.js"; +import type { QueueRepository } from "../queue-repository.js"; +import { + MissionControlWriteContractError, + type MissionControlActionInput, + type MissionControlWriteContract, +} from "../mission-control/mission-control-write-contract.js"; +import type { MissionControlActionLog } from "../mission-control/mission-control-action-log.js"; + +export const RELAY_INBOUND_SEEN_FILE = "relay-inbound-seen.jsonl" as const; +export const RELAY_INBOUND_MAX_BODY_BYTES = 64 * 1024; + +const EVENT_ID_PATTERN = /^[A-Za-z0-9._:-]{1,256}$/; +const MAX_REPLY_REF_CHARS = 4096; +const MAX_REPLY_TEXT_CHARS = 32_768; +const VALID_SEEN_STATUS_PATTERN = /^(received|processed):([0-9a-f]{64})$/; +const SUSPECT_SEEN_STATUS = "suspect"; + +export type RelayInboundOrdering = "after-question" | "uncertain"; + +export interface RelayInboundEnvelope { + eventId: string; + sender: { handle: string }; + replyRef: string; + text: string; + ordering: RelayInboundOrdering; +} + +export interface RelayInboundRequestInput { + authorizationHeader: string | undefined; + rawBody: string; +} + +export interface RelayInboundResponse { + status: number; + body: Record; +} + +export interface RelayInboundHandlerDeps { + home: string; + queueRepo: QueueRepository; + missionControlWriteContract: MissionControlWriteContract; + missionControlActionLog?: MissionControlActionLog; + stateFs?: StateFsOps; + loadEntities?: () => + | { ok: true; entities: HumanFragment[] } + | { ok: false; error: string; noHumans?: true }; +} + +export interface RelayInboundHandler { + handle(input: RelayInboundRequestInput): Promise; +} + +/** + * Verify that a TCP socket remoteAddress is a direct local loopback peer. + * Never trusts HTTP Host or X-Forwarded-* headers. + * Rejects missing/empty remoteAddress (e.g. in-memory app.request without a socket). + */ +export function isDirectLoopbackPeer(remoteAddress: string | undefined | null): boolean { + if (typeof remoteAddress !== "string") return false; + const trimmed = remoteAddress.trim().toLowerCase(); + if (!trimmed) return false; + if (trimmed === "::1") return true; + const stripped = trimmed.startsWith("::ffff:") ? trimmed.slice("::ffff:".length) : trimmed; + const ipv4Match = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(stripped); + if (!ipv4Match) return false; + return [ipv4Match[1], ipv4Match[2], ipv4Match[3]].every((octet) => { + const num = Number(octet); + return Number.isInteger(num) && num >= 0 && num <= 255; + }); +} + +/** + * Deterministic SeenStore key for a relay inbound event, scoped globally across + * the authenticated relay profile's questions so reusing an eventId on a + * different question is detected as equivocation. + */ +export function computeRelayEventKey(connectorRef: string, eventId: string): string { + return createHash("sha256") + .update(JSON.stringify([connectorRef, eventId])) + .digest("hex"); +} + +/** + * Deterministic metadata-only fingerprint of the canonical inbound envelope and + * resolved responder identity. Never stores plaintext reply text. + */ +export function computeRelayEventFingerprint(input: { + connectorRef: string; + eventId: string; + senderHandle: string; + actorSession: string; + replyRef: string; + qitemId: string; + episodeKey: string; + decisionRevision: string; + mode: "ask" | "park"; + ordering: RelayInboundOrdering; + text: string; +}): string { + const textHash = createHash("sha256").update(input.text).digest("hex"); + return createHash("sha256") + .update( + JSON.stringify({ + v: 1, + connectorRef: input.connectorRef, + eventId: input.eventId, + senderHandle: input.senderHandle, + actorSession: input.actorSession, + replyRef: input.replyRef, + qitemId: input.qitemId, + episodeKey: input.episodeKey, + decisionRevision: input.decisionRevision, + mode: input.mode, + ordering: input.ordering, + textHash, + }), + ) + .digest("hex"); +} + +function extractBearerToken(header: string | undefined): string | null { + if (typeof header !== "string") return null; + const match = /^Bearer\s+(\S+)$/.exec(header.trim()); + if (!match || !match[1]) return null; + return match[1]; +} + +function parseInboundEnvelope( + rawBody: string, +): { ok: true; envelope: RelayInboundEnvelope } | { ok: false; status: number; code: string; message: string } { + if (Buffer.byteLength(rawBody, "utf8") > RELAY_INBOUND_MAX_BODY_BYTES) { + return { + ok: false, + status: 413, + code: "payload_too_large", + message: `relay inbound payload exceeds ${RELAY_INBOUND_MAX_BODY_BYTES} bytes`, + }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(rawBody); + } catch { + return { + ok: false, + status: 400, + code: "invalid_json", + message: "request body must be valid JSON", + }; + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { + ok: false, + status: 400, + code: "invalid_envelope", + message: "relay inbound payload must be a JSON object", + }; + } + + const obj = parsed as Record; + const { eventId, sender, replyRef, text, ordering } = obj; + if (typeof eventId !== "string" || !EVENT_ID_PATTERN.test(eventId)) { + return { + ok: false, + status: 400, + code: "invalid_envelope", + message: "eventId must be a non-empty identifier (1..256 chars, [A-Za-z0-9._:-])", + }; + } + + if (!sender || typeof sender !== "object" || Array.isArray(sender)) { + return { + ok: false, + status: 400, + code: "invalid_envelope", + message: "sender must be an object with a handle field", + }; + } + const senderObj = sender as Record; + if (typeof senderObj.handle !== "string" || !HANDLE_PATTERN.test(senderObj.handle)) { + return { + ok: false, + status: 400, + code: "invalid_envelope", + message: "sender.handle must be a valid non-empty handle", + }; + } + + if (typeof replyRef !== "string" || replyRef.length === 0 || replyRef.length > MAX_REPLY_REF_CHARS) { + return { + ok: false, + status: 400, + code: "invalid_envelope", + message: "replyRef must be a non-empty string", + }; + } + + if ( + typeof text !== "string" || + text.includes("\0") || + text.length > MAX_REPLY_TEXT_CHARS || + text.trim().length === 0 + ) { + return { + ok: false, + status: 400, + code: "invalid_envelope", + message: "text must be a non-empty string without NUL bytes", + }; + } + + let normalizedOrdering: RelayInboundOrdering = "uncertain"; + if (ordering !== undefined) { + if (ordering !== "after-question" && ordering !== "uncertain") { + return { + ok: false, + status: 400, + code: "invalid_envelope", + message: "ordering must be 'after-question' or 'uncertain' when provided", + }; + } + normalizedOrdering = ordering; + } + + return { + ok: true, + envelope: { + eventId, + sender: { handle: senderObj.handle }, + replyRef, + text: text.trim(), + ordering: normalizedOrdering, + }, + }; +} + +function reconstructPayloadFromReceipt( + receipt: RelayOwnerNotificationReceipt & { qitemId: string }, + replyRef: string, + tokenRef: RelayReplyRefToken, +): { ok: true; payload: RelayReplyRefPayload; receiptKind: string | null } | { ok: false } { + const episodeKey = receipt.episodeKey ?? receipt.notificationKey; + const decisionRevision = receipt.revision; + const decisionId = receipt.deliveryId; + const connectorRef = receipt.connectorRef; + if ( + typeof receipt.qitemId !== "string" || + receipt.qitemId.length === 0 || + typeof episodeKey !== "string" || + episodeKey.length === 0 || + typeof decisionRevision !== "string" || + decisionRevision.length === 0 || + typeof decisionId !== "string" || + decisionId.length === 0 || + typeof connectorRef !== "string" || + connectorRef.length === 0 + ) { + return { ok: false }; + } + const candidate: RelayReplyRefPayload = { + v: RELAY_PROTOCOL_VERSION, + qitemId: receipt.qitemId, + episodeKey, + decisionRevision, + decisionId, + connectorRef, + mode: tokenRef.mode, + ...(tokenRef.digest === true ? { digest: true } : {}), + }; + if (encodeRelayReplyRef(candidate) !== replyRef) { + return { ok: false }; + } + return { ok: true, payload: candidate, receiptKind: receipt.kind ?? null }; +} + +function resolveOfferedReplyRefContext( + home: string, + queueRepo: QueueRepository, + replyRef: string, + tokenRef: RelayReplyRefToken, + authenticatedConnectorRef: string, +): + | { ok: true; ref: RelayReplyRefPayload; receiptKind: string | null } + | { ok: false; status: 403 | 409 | 503; code: string; message: string } { + let resolvedRef: RelayReplyRefPayload | null = null; + let receiptKind: string | null = null; + + // 1. Check durable accepted receipts FIRST (active queue_transitions + queue_transitions_archive) + let receipt: (RelayOwnerNotificationReceipt & { qitemId: string }) | null = null; + try { + if (typeof queueRepo.findRelayOwnerNotificationReceiptByReplyRef === "function") { + receipt = queueRepo.findRelayOwnerNotificationReceiptByReplyRef(replyRef); + } + } catch { + return { + ok: false, + status: 503, + code: "queue_store_unavailable", + message: "Relay inbound receipt lookup failed because queue transition store is unavailable", + }; + } + + if (receipt) { + const reconstructed = reconstructPayloadFromReceipt(receipt, replyRef, tokenRef); + if (reconstructed.ok) { + resolvedRef = reconstructed.payload; + receiptKind = reconstructed.receiptKind; + } else if (receipt.kind === "human-update") { + return { + ok: false, + status: 409, + code: "reply_not_allowed_for_update", + message: `qitem ${receipt.qitemId} is an informational update and cannot be answered`, + }; + } + } + + // 2. Fallback to pending DispatchBuffer ONLY when no durable accepted receipt matched + if (!resolvedRef) { + try { + const pending = findPendingRelayReplyRef(home, replyRef); + if ( + pending.found && + pending.qitemId && + pending.episodeKey && + pending.decisionRevision && + pending.decisionId && + pending.connectorRef && + pending.mode + ) { + const candidate: RelayReplyRefPayload = { + v: RELAY_PROTOCOL_VERSION, + qitemId: pending.qitemId, + episodeKey: pending.episodeKey, + decisionRevision: pending.decisionRevision, + decisionId: pending.decisionId, + connectorRef: pending.connectorRef, + mode: pending.mode, + ...(pending.digest === true ? { digest: true } : {}), + }; + if (encodeRelayReplyRef(candidate) === replyRef) { + resolvedRef = candidate; + } + } + } catch { + return { + ok: false, + status: 503, + code: "dispatch_buffer_unavailable", + message: + "Relay outbound dispatch buffer is unreadable; refusing to reject or reserve un-receipted replyRef", + }; + } + } + + if (!resolvedRef) { + return { + ok: false, + status: 409, + code: "unrecognized_reply_ref", + message: "replyRef does not match any active or accepted outbound relay decision", + }; + } + + if (resolvedRef.connectorRef !== authenticatedConnectorRef) { + return { + ok: false, + status: 403, + code: "connector_ref_mismatch", + message: "replyRef connectorRef does not match the authenticated relay profile", + }; + } + + return { ok: true, ref: resolvedRef, receiptKind }; +} + +function verifyOfferedReplyTarget( + queueRepo: QueueRepository, + resolvedRef: RelayReplyRefPayload, + receiptKind: string | null, +): { ok: true } | { ok: false; status: 404 | 409 | 503; code: string; message: string } { + if (receiptKind === "human-update") { + return { + ok: false, + status: 409, + code: "reply_not_allowed_for_update", + message: `qitem ${resolvedRef.qitemId} is an informational update and cannot be answered`, + }; + } + + let item: ReturnType; + let transitions: ReturnType; + try { + item = queueRepo.getById(resolvedRef.qitemId); + transitions = item ? queueRepo.listTransitions(resolvedRef.qitemId) : []; + } catch { + return { + ok: false, + status: 503, + code: "queue_store_unavailable", + message: "Relay inbound target verification failed because queue store is unavailable", + }; + } + + if (!item) { + return { + ok: false, + status: 404, + code: "qitem_not_found", + message: `qitem ${resolvedRef.qitemId} not found`, + }; + } + + if (item.humanIntent === "update") { + return { + ok: false, + status: 409, + code: "reply_not_allowed_for_update", + message: `qitem ${resolvedRef.qitemId} is an informational update and cannot be answered`, + }; + } + + const episodeIdPart = Number(resolvedRef.episodeKey.slice(`${resolvedRef.qitemId}:`.length)); + const episodeTransition = transitions.find((t) => t.transitionId === episodeIdPart); + if (episodeTransition?.ownerNotificationKind === "human-update") { + return { + ok: false, + status: 409, + code: "reply_not_allowed_for_update", + message: `episode ${resolvedRef.episodeKey} is an informational update and cannot be answered`, + }; + } + + return { ok: true }; +} + +function formatDecisionText(text: string, ordering: RelayInboundOrdering): string { + if (ordering === "after-question") return text; + return `${text} [relay ordering=uncertain: timing relative to question was not verified by relay]`; +} + +/** + * Wrap StateFsOps so SeenStore.statuses() fails closed (503) when history is + * unreadable or contains unparseable/unkeyable lines, while scoping keyed + * suspect records (missing/invalid status or conflicting fingerprints) to only + * their own eventKey so unrelated keys remain available and unvouched history + * never counts as unseen. Diagnostic fields such as `ts` or extra metadata are + * ignored when `id` and `status` vouch for the event. + */ +function strictRelayStateFs(base: StateFsOps = nodeStateFs): StateFsOps { + return { + ...base, + readFileSync(p: string): string { + const raw = base.readFileSync(p); + const suspectIds = new Set(); + const seenFingerprints = new Map(); + const normalizedLines: string[] = []; + + for (const line of raw.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + throw new Error("corrupt relay inbound seen-store JSONL line"); + } + if ( + !parsed || + typeof parsed !== "object" || + Array.isArray(parsed) || + typeof (parsed as { id?: unknown }).id !== "string" || + !(parsed as { id: string }).id.trim() + ) { + throw new Error("unkeyable relay inbound seen-store record"); + } + const rec = parsed as { id: string; status?: unknown }; + const id = rec.id; + const statusStr = typeof rec.status === "string" ? rec.status : ""; + const match = VALID_SEEN_STATUS_PATTERN.exec(statusStr); + const priorFp = seenFingerprints.get(id); + if ( + !match || + suspectIds.has(id) || + (priorFp !== undefined && priorFp !== match[2]) + ) { + suspectIds.add(id); + normalizedLines.push(JSON.stringify({ id, status: SUSPECT_SEEN_STATUS })); + } else { + seenFingerprints.set(id, match[2]!); + normalizedLines.push(trimmed); + } + } + return normalizedLines.join("\n"); + }, + }; +} + +export function createRelayInboundHandler(deps: RelayInboundHandlerDeps): RelayInboundHandler { + const seenPath = join(deps.home, "state", RELAY_INBOUND_SEEN_FILE); + const seenStore = new SeenStore(seenPath, strictRelayStateFs(deps.stateFs ?? nodeStateFs)); + const inFlightByKey = new Map>(); + + const loadEntities = + deps.loadEntities ?? + (() => loadHumanRegistry(deps.home, { readOnly: true })); + + const runSerialized = async ( + eventKey: string, + fn: () => Promise, + ): Promise => { + const prior = inFlightByKey.get(eventKey) ?? Promise.resolve({ status: 200, body: {} }); + const next = prior.then( + () => fn(), + () => fn(), + ); + inFlightByKey.set(eventKey, next); + try { + return await next; + } finally { + if (inFlightByKey.get(eventKey) === next) { + inFlightByKey.delete(eventKey); + } + } + }; + + return { + async handle(input: RelayInboundRequestInput): Promise { + // 1. Require explicit Bearer token; missing/unready never bypasses authentication + const bearerToken = extractBearerToken(input.authorizationHeader); + if (!bearerToken) { + return { + status: 401, + body: { + error: "unauthorized", + message: "Missing or malformed Authorization: Bearer token for relay inbound", + }, + }; + } + + let entities: HumanFragment[] | null; + try { + entities = registeredPeople(loadEntities()); + } catch { + entities = null; + } + if (entities === null) { + return { + status: 503, + body: { + error: "human_registry_unavailable", + message: "Relay inbound authentication is unavailable because human registry cannot be read", + }, + }; + } + + const profiles = loadRelayProfiles(deps.home, { entities }); + const auth = authenticateRelayInboundToken(profiles, bearerToken); + if (!auth.ok) { + return { + status: 401, + body: { + error: "unauthorized", + reason: auth.reason, + message: "Invalid or unready relay inbound bearer token", + }, + }; + } + + const authenticatedConnectorRef = auth.profile.connectorRef; + + // 2. Parse and validate closed JSON envelope + const parsedEnv = parseInboundEnvelope(input.rawBody); + if (!parsedEnv.ok) { + return { + status: parsedEnv.status, + body: { + error: parsedEnv.code, + message: parsedEnv.message, + }, + }; + } + const { envelope } = parsedEnv; + + // 3. Decode replyRef token and enforce profile match + sender handle resolution + const decodedRef = decodeRelayReplyRef(envelope.replyRef); + if (!decodedRef.ok) { + return { + status: 400, + body: { + error: "invalid_reply_ref", + message: decodedRef.error, + }, + }; + } + const tokenRef = decodedRef.ref; + + if (tokenRef.connectorHash !== hashRelayConnectorRef(authenticatedConnectorRef)) { + return { + status: 403, + body: { + error: "connector_ref_mismatch", + message: "replyRef connectorRef does not match the authenticated relay profile", + }, + }; + } + + const senderResolution = resolveRelayHandle( + authenticatedConnectorRef, + envelope.sender.handle, + entities, + ); + if (senderResolution.kind !== "registered") { + return { + status: 403, + body: { + error: "sender_not_bound", + message: senderResolution.error, + }, + }; + } + + const actorSession = senderResolution.address; + const eventKey = computeRelayEventKey(authenticatedConnectorRef, envelope.eventId); + + return runSerialized(eventKey, async () => { + // 4. Strict SeenStore read path: fail closed (503) if corrupt/unreadable or keyed suspect + let statuses: Map; + try { + statuses = seenStore.statuses(); + } catch { + return { + status: 503, + body: { + error: "seen_store_unavailable", + seenPath, + message: `Relay inbound seen-store is unreadable (${seenPath}); refusing to process or acknowledge`, + }, + }; + } + + const existingStatus = statuses.get(eventKey); + let existingFp: string | undefined; + if (existingStatus !== undefined) { + const match = VALID_SEEN_STATUS_PATTERN.exec(existingStatus); + if (!match) { + return { + status: 503, + body: { + error: "seen_store_unavailable", + seenPath, + message: `Relay inbound seen-store record for this eventId is corrupt or unvouched (${seenPath}); refusing to process or acknowledge`, + }, + }; + } + existingFp = match[2]; + } + + // 5. Resolve offered replyRef context (durable accepted receipt first, then pending DispatchBuffer) + const resolved = resolveOfferedReplyRefContext( + deps.home, + deps.queueRepo, + envelope.replyRef, + tokenRef, + authenticatedConnectorRef, + ); + if (!resolved.ok) { + return { + status: resolved.status, + body: { + error: resolved.code, + message: resolved.message, + }, + }; + } + + const ref = resolved.ref; + const fingerprint = computeRelayEventFingerprint({ + connectorRef: authenticatedConnectorRef, + eventId: envelope.eventId, + senderHandle: envelope.sender.handle, + actorSession, + replyRef: envelope.replyRef, + qitemId: ref.qitemId, + episodeKey: ref.episodeKey, + decisionRevision: ref.decisionRevision, + mode: ref.mode, + ordering: envelope.ordering, + text: envelope.text, + }); + + const receivedTag = `received:${fingerprint}`; + const processedTag = `processed:${fingerprint}`; + const verb = ref.mode === "ask" ? ("answer" as const) : ("resolve" as const); + + // 6. Check existing seen-store status BEFORE reading current queue target so processed replays never depend on current getById/listTransitions + if (existingStatus !== undefined) { + if (existingFp !== fingerprint) { + return { + status: 409, + body: { + error: "event_conflict", + message: "eventId was already used with a different payload, sender, or target", + }, + }; + } + if (existingStatus === processedTag) { + return { + status: 200, + body: { + ok: true, + deduplicated: true, + qitemId: ref.qitemId, + verb, + actorSession, + }, + }; + } + } else { + // 7. Verify current target & non-update guards only for new actions before first reservation + const targetVerified = verifyOfferedReplyTarget( + deps.queueRepo, + ref, + resolved.receiptKind, + ); + if (!targetVerified.ok) { + return { + status: targetVerified.status, + body: { + error: targetVerified.code, + message: targetVerified.message, + }, + }; + } + + try { + seenStore.mark(eventKey, receivedTag); + } catch { + return { + status: 503, + body: { + error: "seen_store_unavailable", + seenPath, + message: `Failed to reserve relay inbound event in seen-store (${seenPath})`, + }, + }; + } + } + + const operationId = `relay:${eventKey}:${fingerprint}`; + const decision = formatDecisionText(envelope.text, envelope.ordering); + const actionInput: MissionControlActionInput = { + verb, + qitemId: ref.qitemId, + actorSession, + identityProvenance: "transport:v1", + decision, + ...(verb === "resolve" && envelope.ordering === "after-question" + ? { answerText: envelope.text } + : {}), + expectedRevision: ref.decisionRevision, + auditNotes: { + operationId, + connectorRef: authenticatedConnectorRef, + eventId: envelope.eventId, + senderHandle: envelope.sender.handle, + episodeKey: ref.episodeKey, + ordering: envelope.ordering, + }, + }; + + try { + const result = await deps.missionControlWriteContract.act(actionInput); + try { + seenStore.mark(eventKey, processedTag); + } catch { + return { + status: 503, + body: { + error: "seen_store_unavailable", + seenPath, + message: `Action committed but seen-store processed mark failed (${seenPath}); retry safely with same eventId`, + }, + }; + } + + return { + status: 200, + body: { + ok: true, + deduplicated: Boolean(result.reconciled), + reconciled: Boolean(result.reconciled), + qitemId: ref.qitemId, + verb, + actorSession, + actionId: result.actionId, + }, + }; + } catch (err) { + if (err instanceof MissionControlWriteContractError) { + // If this event was already reserved as `received:` and its own + // committed action changed the item's revision before `processed:` + // was marked (e.g. a resolve that committed and was later re-parked), check + // whether the audit log already holds this exact operationId and reconcile + // without any new mutation. + if ( + existingStatus === receivedTag && + err.code === "decision_revision_changed" && + deps.missionControlActionLog + ) { + const priorAudit = deps.missionControlActionLog + .listForQitem(ref.qitemId, 50) + .find((entry) => entry.auditNotes?.operationId === operationId); + if (priorAudit) { + try { + seenStore.mark(eventKey, processedTag); + } catch { + return { + status: 503, + body: { + error: "seen_store_unavailable", + seenPath, + message: `Action reconciled but seen-store processed mark failed (${seenPath})`, + }, + }; + } + return { + status: 200, + body: { + ok: true, + deduplicated: true, + reconciled: true, + qitemId: ref.qitemId, + verb, + actorSession, + actionId: priorAudit.actionId, + }, + }; + } + } + + const status = + err.code === "qitem_not_found" + ? 404 + : err.code === "decision_revision_changed" || + err.code === "qitem_already_terminal" || + err.code === "qitem_not_leg1_parked" || + err.code === "answer_not_recorded" || + err.code === "answer_not_closed" + ? 409 + : 400; + return { + status, + body: { + error: err.code, + message: err.message, + }, + }; + } + + return { + status: 500, + body: { + error: "internal_error", + message: "Relay inbound action failed before completion", + }, + }; + } + }); + }, + }; +} diff --git a/packages/daemon/src/domain/gateway/relay-outbound.ts b/packages/daemon/src/domain/gateway/relay-outbound.ts new file mode 100644 index 000000000..7a0693ad6 --- /dev/null +++ b/packages/daemon/src/domain/gateway/relay-outbound.ts @@ -0,0 +1,939 @@ +import { createHash } from "node:crypto"; +import path from "node:path"; +import { DispatchBuffer } from "./dispatch-buffer.js"; +import type { SubsystemDeliverFn, SubsystemDeliveryOutcome } from "./gateway-subsystem.js"; +import { HANDLE_PATTERN, type HumanFragment, type LoadResult } from "./human-registry.js"; +import type { OutboundDecision } from "./protocol.js"; +import { validateRelayUrl } from "./relay-config.js"; +import { redactSecrets } from "./slack/message.js"; +import { OUTBOUND_OP, type OutboundPostPayload } from "./slack/outbound-driver.js"; +import type { FetchImpl } from "./slack/slack-api.js"; +import { SeenStore } from "./slack/state-store.js"; +import type { QueueRepository } from "../queue-repository.js"; + +export const RELAY_PROTOCOL_VERSION = 1 as const; +export const RELAY_ATTEMPTED_DECISIONS_FILE = "relay-attempted-decisions.jsonl"; +export const RELAY_DELIVERED_DECISIONS_FILE = "relay-delivered-decisions.jsonl"; + +export const RELAY_SUMMARY_MAX_CHARS = 1000; +export const RELAY_DEFAULT_TIMEOUT_MS = 15_000; + +export type RelayQuestionMode = "ask" | "park"; +export type RelayIntent = "decision" | "update"; +export type RelayDeliveryOutcome = "interrupt" | "notify" | "digest"; + +export interface RelayReplyRefPayload { + v: 1; + qitemId: string; + episodeKey: string; + decisionRevision: string; + decisionId: string; + connectorRef: string; + mode: RelayQuestionMode; + digest?: boolean; +} + +export interface RelayReplyRefToken { + v: 1; + payloadHash: string; + connectorHash: string; + mode: RelayQuestionMode; + digest?: boolean; +} + +export interface RelayDigestItem { + qitemId: string; + notificationKey: string; + summary: string; + body?: string; + replyRef: string | null; +} + +export interface RelayOutboundEnvelope { + v: 1; + deliveryId: string; + recipient: { + handle: string; + }; + intent: RelayIntent; + outcome: RelayDeliveryOutcome; + summary: string; + body: string; + replyRef: string | null; + items?: RelayDigestItem[]; +} + +export interface RelayPayloadCarry extends OutboundPostPayload { + relayConnectorRef?: string; + relayEnvelope?: RelayOutboundEnvelope; + relayRevision?: string | null; + relayReplyRefs?: Record; + deliveryDigestPost?: boolean; + digestId?: string; + memberReceipts?: Array<{ + qitemId: string; + notificationKey: string; + level: string; + kind: string; + summary?: string; + body?: string; + }>; + quietRelease?: boolean; + deliveryDeferralFire?: boolean; +} + +function asRelayPayloadCarry(v: unknown): RelayPayloadCarry | null { + if (!isObj(v)) return null; + return v as unknown as RelayPayloadCarry; +} + +const ALLOWED_REPLY_REF_TOKEN_KEYS = new Set([ + "v", + "payloadHash", + "connectorHash", + "mode", + "digest", +]); + +const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/; + +const ALLOWED_ENVELOPE_KEYS = new Set([ + "v", + "deliveryId", + "recipient", + "intent", + "outcome", + "summary", + "body", + "replyRef", + "items", +]); + +const ALLOWED_RECIPIENT_KEYS = new Set(["handle"]); +const ALLOWED_DIGEST_ITEM_KEYS = new Set(["qitemId", "notificationKey", "summary", "body", "replyRef"]); + +function isObj(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +function nonEmptyStr(v: unknown): v is string { + return typeof v === "string" && v.trim().length > 0; +} + +function unknownKey(obj: Record, allowed: Set): string | undefined { + for (const k of Object.keys(obj)) { + if (!allowed.has(k)) return k; + } + return undefined; +} + +function canonicalizeRelayReplyRefPayload(payload: RelayReplyRefPayload): RelayReplyRefPayload { + return { + v: RELAY_PROTOCOL_VERSION, + qitemId: payload.qitemId, + episodeKey: payload.episodeKey, + decisionRevision: payload.decisionRevision, + decisionId: payload.decisionId, + connectorRef: payload.connectorRef, + mode: payload.mode, + ...(payload.digest === true ? { digest: true } : {}), + }; +} + +/** Deterministic SHA-256 hex digest of the canonical frozen RelayReplyRefPayload. */ +export function hashRelayReplyRefPayload(payload: RelayReplyRefPayload): string { + return createHash("sha256") + .update(JSON.stringify(canonicalizeRelayReplyRefPayload(payload)), "utf8") + .digest("hex"); +} + +/** Deterministic SHA-256 hex digest of a relay connectorRef for bounded token binding. */ +export function hashRelayConnectorRef(connectorRef: string): string { + return createHash("sha256").update(connectorRef, "utf8").digest("hex"); +} + +/** Encode structured correlation fields into a fixed-size opaque base64url replyRef token. */ +export function encodeRelayReplyRef(payload: RelayReplyRefPayload): string { + const canonical = canonicalizeRelayReplyRefPayload(payload); + const token: RelayReplyRefToken = { + v: RELAY_PROTOCOL_VERSION, + payloadHash: hashRelayReplyRefPayload(canonical), + connectorHash: hashRelayConnectorRef(canonical.connectorRef), + mode: canonical.mode, + ...(canonical.digest === true ? { digest: true } : {}), + }; + return Buffer.from(JSON.stringify(token), "utf8").toString("base64url"); +} + +/** Decode and strictly validate a fixed-size opaque base64url replyRef token. */ +export function decodeRelayReplyRef( + raw: unknown, +): { ok: true; ref: RelayReplyRefToken } | { ok: false; error: string } { + if (typeof raw !== "string" || raw.length === 0 || raw.length > 4096 || !/^[A-Za-z0-9_-]+$/.test(raw)) { + return { ok: false, error: "replyRef must be a non-empty base64url string" }; + } + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8")); + } catch { + return { ok: false, error: "replyRef is not valid base64url JSON" }; + } + if (!isObj(parsed)) return { ok: false, error: "replyRef payload must be a JSON object" }; + const uk = unknownKey(parsed, ALLOWED_REPLY_REF_TOKEN_KEYS); + if (uk) return { ok: false, error: `replyRef has unknown key "${uk}"` }; + if (parsed.v !== RELAY_PROTOCOL_VERSION) return { ok: false, error: `replyRef.v must be ${RELAY_PROTOCOL_VERSION}` }; + if (typeof parsed.payloadHash !== "string" || !SHA256_HEX_PATTERN.test(parsed.payloadHash)) { + return { ok: false, error: "replyRef.payloadHash must be a 64-char lowercase hex sha256" }; + } + if (typeof parsed.connectorHash !== "string" || !SHA256_HEX_PATTERN.test(parsed.connectorHash)) { + return { ok: false, error: "replyRef.connectorHash must be a 64-char lowercase hex sha256" }; + } + if (parsed.mode !== "ask" && parsed.mode !== "park") { + return { ok: false, error: "replyRef.mode must be ask|park" }; + } + if (parsed.digest !== undefined && typeof parsed.digest !== "boolean") { + return { ok: false, error: "replyRef.digest must be a boolean when present" }; + } + return { + ok: true, + ref: { + v: RELAY_PROTOCOL_VERSION, + payloadHash: parsed.payloadHash, + connectorHash: parsed.connectorHash, + mode: parsed.mode, + ...(parsed.digest === true ? { digest: true } : {}), + }, + }; +} + +/** Deterministic SHA-256 hex digest of an offered replyRef for transition-note evidence. */ +export function hashRelayReplyRef(replyRef: string): string { + return createHash("sha256").update(replyRef, "utf8").digest("hex"); +} + +/** Validate a closed protocol v1 RelayOutboundEnvelope. */ +export function validateRelayOutboundEnvelope( + raw: unknown, +): { ok: true; envelope: RelayOutboundEnvelope } | { ok: false; error: string } { + if (!isObj(raw)) return { ok: false, error: "relay envelope must be an object" }; + const uk = unknownKey(raw, ALLOWED_ENVELOPE_KEYS); + if (uk) return { ok: false, error: `relay envelope has unknown key "${uk}"` }; + if (raw.v !== RELAY_PROTOCOL_VERSION) return { ok: false, error: `relay envelope.v must be ${RELAY_PROTOCOL_VERSION}` }; + if (!nonEmptyStr(raw.deliveryId)) return { ok: false, error: "relay envelope.deliveryId must be a non-empty string" }; + if (!isObj(raw.recipient)) return { ok: false, error: "relay envelope.recipient must be an object" }; + const ruk = unknownKey(raw.recipient, ALLOWED_RECIPIENT_KEYS); + if (ruk) return { ok: false, error: `relay envelope.recipient has unknown key "${ruk}"` }; + if (!nonEmptyStr(raw.recipient.handle) || !HANDLE_PATTERN.test(raw.recipient.handle)) { + return { ok: false, error: "relay envelope.recipient.handle must match HANDLE_PATTERN" }; + } + if (raw.intent !== "decision" && raw.intent !== "update") { + return { ok: false, error: "relay envelope.intent must be decision|update" }; + } + if (raw.outcome !== "interrupt" && raw.outcome !== "notify" && raw.outcome !== "digest") { + return { ok: false, error: "relay envelope.outcome must be interrupt|notify|digest" }; + } + if (typeof raw.summary !== "string") return { ok: false, error: "relay envelope.summary must be a string" }; + if (typeof raw.body !== "string") return { ok: false, error: "relay envelope.body must be a string" }; + if (raw.replyRef !== null && typeof raw.replyRef !== "string") { + return { ok: false, error: "relay envelope.replyRef must be string or null" }; + } + if (raw.intent === "update" && raw.replyRef !== null) { + return { ok: false, error: "relay envelope.replyRef must be null when intent is update" }; + } + if (raw.replyRef !== null) { + const decoded = decodeRelayReplyRef(raw.replyRef); + if (!decoded.ok) return { ok: false, error: `relay envelope.replyRef invalid: ${decoded.error}` }; + } + let items: RelayDigestItem[] | undefined; + if (raw.items !== undefined) { + if (!Array.isArray(raw.items)) return { ok: false, error: "relay envelope.items must be an array when present" }; + items = []; + for (const [i, item] of raw.items.entries()) { + if (!isObj(item)) return { ok: false, error: `relay envelope.items[${i}] must be an object` }; + const iuk = unknownKey(item, ALLOWED_DIGEST_ITEM_KEYS); + if (iuk) return { ok: false, error: `relay envelope.items[${i}] has unknown key "${iuk}"` }; + if (!nonEmptyStr(item.qitemId) || !nonEmptyStr(item.notificationKey) || typeof item.summary !== "string") { + return { ok: false, error: `relay envelope.items[${i}] missing required fields` }; + } + if (item.body !== undefined && typeof item.body !== "string") { + return { ok: false, error: `relay envelope.items[${i}].body must be a string when present` }; + } + if (item.replyRef !== null && typeof item.replyRef !== "string") { + return { ok: false, error: `relay envelope.items[${i}].replyRef must be string or null` }; + } + if (typeof item.replyRef === "string") { + const dec = decodeRelayReplyRef(item.replyRef); + if (!dec.ok) return { ok: false, error: `relay envelope.items[${i}].replyRef invalid: ${dec.error}` }; + } + items.push({ + qitemId: item.qitemId, + notificationKey: item.notificationKey, + summary: item.summary, + ...(typeof item.body === "string" ? { body: item.body } : {}), + replyRef: item.replyRef, + }); + } + } + return { + ok: true, + envelope: { + v: RELAY_PROTOCOL_VERSION, + deliveryId: raw.deliveryId, + recipient: { handle: raw.recipient.handle }, + intent: raw.intent, + outcome: raw.outcome, + summary: raw.summary, + body: raw.body, + replyRef: raw.replyRef, + ...(items !== undefined ? { items } : {}), + }, + }; +} + +/** Snap a UTF-16 index so it never splits a surrogate pair (`0xD800..0xDBFF` followed by `0xDC00..0xDFFF`). */ +function snapCodePointIndex(str: string, idx: number): number { + if (idx <= 0) return 0; + if (idx >= str.length) return str.length; + const prev = str.charCodeAt(idx - 1); + const curr = str.charCodeAt(idx); + if (prev >= 0xd800 && prev <= 0xdbff && curr >= 0xdc00 && curr <= 0xdfff) { + return idx - 1; + } + return idx; +} + +export interface RelayQuestionState { + qitemId: string; + mode: RelayQuestionMode; + episodeKey: string; + decisionRevision: string; + humanIntent?: "decision" | "update" | null; + summary?: string | null; + body?: string | null; +} + +export interface PrepareRelayDecisionOpts { + loadHumanRegistry: () => LoadResult | { ok: boolean; entities?: readonly HumanFragment[] }; + queueRepo?: Pick; + lookupQuestionState?: (qitemId: string, fallbackKey?: string | null) => RelayQuestionState | null; + resolveOutcome?: (payload: OutboundPostPayload) => RelayDeliveryOutcome; +} + +/** Look up actual current question state from QueueRepository. Never mints synthetic state when row or current episode is missing. */ +function defaultLookupQuestionState( + queueRepo: Pick | undefined, + qitemId: string, + expectedEpisodeKey?: string | null, +): RelayQuestionState | null { + if (!queueRepo) return null; + const row = queueRepo.getById(qitemId); + if (!row || (row.state !== "pending" && row.state !== "blocked")) return null; + const ep = queueRepo.currentHumanRequiredEpisode(qitemId); + if (!ep) return null; + if (expectedEpisodeKey && expectedEpisodeKey !== ep.key) return null; + const decisionRevision = queueRepo.decisionRevision(row); + const mode: RelayQuestionMode = row.state === "blocked" ? "park" : "ask"; + return { + qitemId, + mode, + episodeKey: ep.key, + decisionRevision, + humanIntent: row.humanIntent ?? null, + summary: row.summary ?? null, + }; +} + +function renderPlainTextBody(p: OutboundPostPayload): string { + const sections: string[] = []; + const cleanedSummary = redactSecrets(String(p.summary ?? "")).replace(/\s+/g, " ").trim(); + const mainBody = redactSecrets(String(p.body ?? "")).trim(); + if (cleanedSummary.length > RELAY_SUMMARY_MAX_CHARS && !mainBody.includes(cleanedSummary)) { + sections.push(cleanedSummary); + } + if (mainBody) sections.push(mainBody); + const detail = redactSecrets(String(p.humanDetail ?? "")).trim(); + if (detail) sections.push(detail); + if (Array.isArray(p.humanQuestions) && p.humanQuestions.length > 0) { + const qLines: string[] = []; + for (const q of p.humanQuestions) { + const header = redactSecrets(String((q as { header?: string }).header ?? "")).trim(); + if (header) qLines.push(`[${header}]`); + qLines.push(redactSecrets(String(q.question ?? "")).trim()); + for (const opt of q.options ?? []) { + const desc = redactSecrets(String((opt as { description?: string }).description ?? "")).trim(); + const descSuffix = desc ? ` — ${desc}` : ""; + qLines.push(`- ${redactSecrets(String(opt.label ?? "")).trim()}${opt.recommended ? " (recommended)" : ""}${descSuffix}`); + } + } + if (qLines.length > 0) sections.push(qLines.join("\n")); + } + return sections.join("\n\n"); +} + +function renderPlainTextSummary(summary: string | null | undefined, fallback: string): string { + const cleaned = redactSecrets(String(summary ?? fallback)).replace(/\s+/g, " ").trim() || fallback; + return cleaned.length > RELAY_SUMMARY_MAX_CHARS + ? cleaned.slice(0, snapCodePointIndex(cleaned, RELAY_SUMMARY_MAX_CHARS)) + : cleaned; +} + +/** Resolve the recipient human's primary binding when it is a `relay` binding. + * Requires exact registered human address or exact bare entityId (no broad localpart/domain splitting). */ +export function resolvePrimaryRelayBinding( + destinationOrRef: string | null | undefined, + reg: LoadResult | { ok: boolean; entities?: readonly HumanFragment[] }, +): { entity: HumanFragment; connectorRef: string; handle: string; secretsRef: string } | null { + if (!reg.ok || !reg.entities) return null; + const raw = String(destinationOrRef ?? "").trim(); + if (!raw) return null; + const human = reg.entities.find((e) => e.address === raw || e.entityId === raw); + if (!human) return null; + const primary = human.connectorBindings.find((b) => b.role === "primary"); + if (!primary || (primary.kind as string) !== "relay") return null; + return { + entity: human, + connectorRef: primary.connectorRef, + handle: primary.handle ?? human.entityId, + secretsRef: primary.secretsRef, + }; +} + +/** Synchronous pre-enqueue preparation hook: freezes the protocol v1 relay envelope and replyRef(s) + * onto the decision payload when the recipient's primary binding is `relay`. */ +export function prepareRelayDecision(decision: OutboundDecision, opts: PrepareRelayDecisionOpts): OutboundDecision { + if (decision.op !== OUTBOUND_OP) return decision; + const p = asRelayPayloadCarry(decision.payload); + if (!p) return decision; + // Already frozen on an earlier pass: never recompute refs. + if (p.relayEnvelope && p.relayConnectorRef) return decision; + + const reg = opts.loadHumanRegistry(); + const binding = resolvePrimaryRelayBinding(p.destinationSession ?? decision.entityBindingRef, reg); + if (!binding) return decision; + + const outcome: RelayDeliveryOutcome = p.deliveryDigestPost + ? "digest" + : p.quietRelease + ? "notify" + : p.deliveryDeferralFire + ? "interrupt" + : opts.resolveOutcome?.(p) ?? "notify"; + + const lookup = (qitemId: string, fallbackKey?: string | null): RelayQuestionState | null => + opts.lookupQuestionState + ? opts.lookupQuestionState(qitemId, fallbackKey) + : defaultLookupQuestionState(opts.queueRepo, qitemId, fallbackKey); + + const memberReceipts = Array.isArray(p.memberReceipts) ? p.memberReceipts : undefined; + let intent: RelayIntent; + let replyRef: string | null = null; + let items: RelayDigestItem[] | undefined; + let relayRevision: string | null = null; + const relayReplyRefs: Record = {}; + + if (memberReceipts && memberReceipts.length > 0) { + items = memberReceipts.map((m) => { + const qState = lookup(m.qitemId, m.notificationKey); + const isDecisionMember = m.kind === "human-required" && qState !== null && qState.humanIntent !== "update"; + let memberRef: string | null = null; + if (isDecisionMember) { + const memberPayload: RelayReplyRefPayload = { + v: RELAY_PROTOCOL_VERSION, + qitemId: m.qitemId, + episodeKey: qState.episodeKey, + decisionRevision: qState.decisionRevision, + decisionId: decision.decisionId, + connectorRef: binding.connectorRef, + mode: qState.mode, + digest: true, + }; + memberRef = encodeRelayReplyRef(memberPayload); + relayReplyRefs[memberRef] = memberPayload; + } + const rawItemSummary = qState?.summary ?? m.summary; + const rawItemBody = qState?.body ?? m.body; + const cleanedItemSummary = + typeof rawItemSummary === "string" ? redactSecrets(rawItemSummary).replace(/\s+/g, " ").trim() : ""; + let cleanedItemBody = typeof rawItemBody === "string" ? redactSecrets(rawItemBody).trim() : ""; + if (cleanedItemSummary.length > RELAY_SUMMARY_MAX_CHARS && !cleanedItemBody.includes(cleanedItemSummary)) { + cleanedItemBody = cleanedItemBody ? `${cleanedItemSummary}\n\n${cleanedItemBody}` : cleanedItemSummary; + } + return { + qitemId: m.qitemId, + notificationKey: m.notificationKey, + summary: renderPlainTextSummary(rawItemSummary, m.qitemId), + ...(cleanedItemBody ? { body: cleanedItemBody } : {}), + replyRef: memberRef, + }; + }); + const anyDecision = items.some((it) => it.replyRef !== null); + intent = anyDecision ? "decision" : "update"; + // Multi-item digests must have top-level replyRef = null so one reply cannot resolve several questions. + replyRef = items.length === 1 ? items[0]!.replyRef : null; + } else { + const isDecision = + p.humanIntent !== "update" && + (p.ownerNotificationKind === "human-required" || + (p.humanIntent === "decision" && p.ownerNotificationKind !== "human-decision-resolved")); + intent = isDecision ? "decision" : "update"; + if (isDecision && p.qitemId) { + const qState = lookup(p.qitemId, p.notificationKey); + if (qState) { + relayRevision = qState.decisionRevision; + const askPayload: RelayReplyRefPayload = { + v: RELAY_PROTOCOL_VERSION, + qitemId: p.qitemId, + episodeKey: qState.episodeKey, + decisionRevision: qState.decisionRevision, + decisionId: decision.decisionId, + connectorRef: binding.connectorRef, + mode: qState.mode, + ...(outcome === "digest" ? { digest: true } : {}), + }; + replyRef = encodeRelayReplyRef(askPayload); + relayReplyRefs[replyRef] = askPayload; + } + } + } + + const envelope: RelayOutboundEnvelope = { + v: RELAY_PROTOCOL_VERSION, + deliveryId: decision.decisionId, + recipient: { handle: binding.handle }, + intent, + outcome, + summary: renderPlainTextSummary(p.summary, p.qitemId || decision.decisionId), + body: renderPlainTextBody(p), + replyRef, + ...(items ? { items } : {}), + }; + + return { + ...decision, + payload: { + ...p, + relayConnectorRef: binding.connectorRef, + relayEnvelope: envelope, + ...(relayRevision ? { relayRevision } : {}), + ...(Object.keys(relayReplyRefs).length > 0 ? { relayReplyRefs } : {}), + } satisfies RelayPayloadCarry, + }; +} + +export interface PendingRelayReplyRefMatch { + found: boolean; + decisionId?: string; + connectorRef?: string; + qitemId?: string; + episodeKey?: string; + decisionRevision?: string; + mode?: RelayQuestionMode; + digest?: boolean; +} + +/** Check the durable DispatchBuffer for an un-Acked decision that offered the exact `replyRef`. */ +export function findPendingRelayReplyRef(home: string, replyRef: string): PendingRelayReplyRefMatch { + const decoded = decodeRelayReplyRef(replyRef); + if (!decoded.ok) return { found: false }; + const pending = new DispatchBuffer(home).pendingStrict(); + for (const decision of pending) { + const p = asRelayPayloadCarry(decision.payload); + if (!p) continue; + const env = p.relayEnvelope; + if (!env) continue; + const matchedTop = env.replyRef === replyRef; + const matchedItem = env.items?.some((it) => it.replyRef === replyRef) ?? false; + if (!matchedTop && !matchedItem) continue; + const frozen = p.relayReplyRefs?.[replyRef]; + if ( + !isObj(frozen) || + frozen.v !== RELAY_PROTOCOL_VERSION || + !nonEmptyStr(frozen.qitemId) || + !nonEmptyStr(frozen.episodeKey) || + !nonEmptyStr(frozen.decisionRevision) || + !nonEmptyStr(frozen.decisionId) || + !nonEmptyStr(frozen.connectorRef) || + (frozen.mode !== "ask" && frozen.mode !== "park") || + (frozen.digest !== undefined && typeof frozen.digest !== "boolean") + ) { + continue; + } + const candidate: RelayReplyRefPayload = { + v: RELAY_PROTOCOL_VERSION, + qitemId: frozen.qitemId, + episodeKey: frozen.episodeKey, + decisionRevision: frozen.decisionRevision, + decisionId: frozen.decisionId, + connectorRef: frozen.connectorRef, + mode: frozen.mode, + ...(frozen.digest === true ? { digest: true } : {}), + }; + if (encodeRelayReplyRef(candidate) !== replyRef) continue; + if (decision.decisionId !== candidate.decisionId) continue; + if (p.relayConnectorRef && p.relayConnectorRef !== candidate.connectorRef) continue; + return { + found: true, + decisionId: decision.decisionId, + connectorRef: candidate.connectorRef, + qitemId: candidate.qitemId, + episodeKey: candidate.episodeKey, + decisionRevision: candidate.decisionRevision, + mode: candidate.mode, + ...(candidate.digest ? { digest: true } : {}), + }; + } + return { found: false }; +} + +export interface PostRelayEnvelopeInput { + url: string; + outboundToken: string; + envelope: RelayOutboundEnvelope; + fetchImpl?: FetchImpl; + timeoutMs?: number; + stopSignal?: AbortSignal; +} + +export type RelayHttpResult = + | { ok: true; status: number } + | { ok: false; status: number; class: string; detail: string }; + +/** Loopback HTTP POST to the operator's relay bridge. + * - Validates loopback HTTP URL via `validateRelayUrl`. + * - Rejects redirects (`redirect: "error"`). + * - Does not buffer or log arbitrary bridge response bodies (status is sufficient for custody). */ +export async function postRelayEnvelope(input: PostRelayEnvelopeInput): Promise { + const urlCheck = validateRelayUrl(input.url); + if (!urlCheck.ok) { + return { + ok: false, + status: 0, + class: "invalid-endpoint", + detail: urlCheck.reason, + }; + } + const token = input.outboundToken.trim(); + if (!token) { + return { + ok: false, + status: 0, + class: "missing-credential", + detail: "relay outbound token is empty", + }; + } + const valid = validateRelayOutboundEnvelope(input.envelope); + if (!valid.ok) { + return { + ok: false, + status: 0, + class: "invalid-envelope", + detail: valid.error, + }; + } + const bodyJson = JSON.stringify(valid.envelope); + + if (input.stopSignal?.aborted) { + return { + ok: false, + status: 0, + class: "stopped", + detail: "relay transport stopped", + }; + } + + const timeoutMs = input.timeoutMs ?? RELAY_DEFAULT_TIMEOUT_MS; + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(new Error(`timeout after ${timeoutMs}ms`)), timeoutMs); + const signal = input.stopSignal ? AbortSignal.any([ctrl.signal, input.stopSignal]) : ctrl.signal; + + try { + const fetchFn = input.fetchImpl ?? fetch; + const res = await fetchFn(urlCheck.url, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json; charset=utf-8", + }, + body: bodyJson, + redirect: "error", + signal, + }); + void res.body?.cancel?.().catch(() => {}); + if (res.status >= 300 && res.status < 400) { + return { + ok: false, + status: res.status, + class: "redirect-refused", + detail: `relay redirect (${res.status}) is refused`, + }; + } + if (!res.ok) { + return { + ok: false, + status: res.status, + class: `http-${res.status}`, + detail: `http ${res.status}`, + }; + } + return { ok: true, status: res.status }; + } catch (error) { + if (input.stopSignal?.aborted) { + return { + ok: false, + status: 0, + class: "stopped", + detail: "relay transport stopped", + }; + } + const rawMsg = (error as Error)?.message || ""; + const isRedirect = /redirect/i.test(rawMsg); + const isTimeout = ctrl.signal.aborted || /timeout|abort/i.test(rawMsg); + return { + ok: false, + status: 0, + class: isRedirect ? "redirect-refused" : isTimeout ? "timeout" : "transport", + detail: isRedirect + ? "relay redirect is refused" + : isTimeout + ? `relay request timed out after ${timeoutMs}ms` + : "relay transport request failed", + }; + } finally { + clearTimeout(timer); + } +} + +export interface RelayAcceptedReceiptInfo { + qitemId: string; + notificationKey: string; + connectorRef: string; + deliveryId: string; + replyRef: string | null; + refHash: string | null; + revision: string | null; + episodeKey: string | null; + level: string; + kind: string; + humanIntent?: "decision" | "update" | null; + digestId?: string | null; +} + +export type ResolvedRelayOutboundProfile = + | { ok: true; connectorRef: string; url: string; outboundToken: string } + | { ok: false; reason: string }; + +export interface RelayOutboundTransportOpts { + attempted: SeenStore; + delivered: SeenStore; + outboundSeen: SeenStore; + resolveProfile: (connectorRef: string) => ResolvedRelayOutboundProfile; + onAccepted: (info: RelayAcceptedReceiptInfo) => void; + hasAcceptedReceipt?: (info: { qitemId: string; notificationKey: string }) => boolean; + onTransportFailed?: (payload: OutboundPostPayload, failureClass: string, detail: string) => void; + release?: (notificationKey: string) => void; + fetchImpl?: FetchImpl; + timeoutMs?: number; + stopSignal?: AbortSignal; + log?: (msg: string) => void; +} + +export interface RelayOutboundTransport { + deliver: SubsystemDeliverFn; + wasAttempted: (decision: OutboundDecision) => boolean; +} + +function buildExpectedReceipts(decision: OutboundDecision, p: RelayPayloadCarry, connectorRef: string, envelope: RelayOutboundEnvelope): RelayAcceptedReceiptInfo[] { + if (p.deliveryDigestPost && Array.isArray(p.memberReceipts) && p.memberReceipts.length > 0) { + return p.memberReceipts.map((m) => { + const itemEntry = envelope.items?.find((it) => it.qitemId === m.qitemId && it.notificationKey === m.notificationKey); + const itemRef = itemEntry?.replyRef ?? (p.memberReceipts!.length === 1 ? envelope.replyRef : null); + const frozenItemRef = itemRef ? p.relayReplyRefs?.[itemRef] : undefined; + return { + qitemId: m.qitemId, + notificationKey: m.notificationKey, + connectorRef, + deliveryId: decision.decisionId, + replyRef: itemRef, + refHash: itemRef ? hashRelayReplyRef(itemRef) : null, + revision: frozenItemRef?.decisionRevision ?? null, + episodeKey: frozenItemRef?.episodeKey ?? m.notificationKey, + level: m.level, + kind: m.kind, + humanIntent: m.kind === "human-required" ? "decision" : "update", + digestId: p.digestId ?? "unknown", + }; + }); + } + if (p.qitemId) { + const key = p.notificationKey ?? p.qitemId; + const frozenRef = envelope.replyRef ? p.relayReplyRefs?.[envelope.replyRef] : undefined; + return [ + { + qitemId: p.qitemId, + notificationKey: key, + connectorRef, + deliveryId: decision.decisionId, + replyRef: envelope.replyRef, + refHash: envelope.replyRef ? hashRelayReplyRef(envelope.replyRef) : null, + revision: frozenRef?.decisionRevision ?? (p.relayRevision ?? null), + episodeKey: frozenRef?.episodeKey ?? key, + level: p.ownerNotificationLevel ?? "RECORD", + kind: p.ownerNotificationKind ?? "unclassified", + humanIntent: p.humanIntent ?? null, + }, + ]; + } + return []; +} + +/** SeenStore-backed outbound relay transport. + * - Strict SeenStore reads (`statuses()`): unreadable state retains the decision in DispatchBuffer. + * - Persists attempt metadata BEFORE the first HTTP request (no secrets/answers in metadata). + * - Replays the exact frozen `relayEnvelope` with the same `deliveryId`. + * - Marks `outboundSeen` and releases poll guard BEFORE marking `delivered`; reconciles from durable receipt on retry without reposting. */ +export function createRelayOutboundTransport(opts: RelayOutboundTransportOpts): RelayOutboundTransport { + const log = opts.log ?? (() => {}); + + const wasAttempted = (decision: OutboundDecision): boolean => { + const map = opts.attempted.statuses(); + return map.has(decision.decisionId); + }; + + const completeLocalGuards = ( + decision: OutboundDecision, + p: RelayPayloadCarry, + outboundSeenMap: Map, + deliveredMap: Map, + ): SubsystemDeliveryOutcome => { + try { + if (p.qitemId && !p.deliveryDigestPost) { + const key = p.notificationKey ?? p.qitemId; + if (!outboundSeenMap.has(key)) { + opts.outboundSeen.mark(key, "relay-accepted"); + } + opts.release?.(key); + } + if (!deliveredMap.has(decision.decisionId)) { + opts.delivered.mark(decision.decisionId, "delivered"); + } + return { ok: true }; + } catch (error) { + const detail = (error as Error).message || "relay state write failed"; + log(`relay local state mark FAILED for ${decision.decisionId}: ${detail} — retained for replay`); + return { ok: false, class: "relay-state-write-failed", detail }; + } + }; + + const deliver: SubsystemDeliverFn = async (decision: OutboundDecision): Promise => { + let deliveredMap: Map; + let attemptedMap: Map; + let outboundSeenMap: Map; + try { + deliveredMap = opts.delivered.statuses(); + attemptedMap = opts.attempted.statuses(); + outboundSeenMap = opts.outboundSeen.statuses(); + } catch (error) { + const detail = (error as Error).message || "relay SeenStore unreadable"; + log(`relay state unreadable for ${decision.decisionId}: ${detail} — retained`); + return { ok: false, class: "relay-state-unreadable", detail }; + } + + const p = (decision.payload ?? {}) as RelayPayloadCarry; + const connectorRef = p.relayConnectorRef; + const envelope = p.relayEnvelope; + if (!connectorRef || !envelope) { + const detail = "relay decision is missing frozen relayEnvelope or relayConnectorRef"; + log(`relay delivery ${decision.decisionId} refused: ${detail}`); + return { ok: false, class: "relay-envelope-missing", detail }; + } + + const expectedReceipts = buildExpectedReceipts(decision, p, connectorRef, envelope); + const alreadyReceipted = + expectedReceipts.length > 0 && + opts.hasAcceptedReceipt !== undefined && + expectedReceipts.every((r) => opts.hasAcceptedReceipt!({ qitemId: r.qitemId, notificationKey: r.notificationKey })); + + if (deliveredMap.has(decision.decisionId) || alreadyReceipted) { + log(`relay delivery: decision ${decision.decisionId} already accepted — reconciling local guards without repost`); + return completeLocalGuards(decision, p, outboundSeenMap, deliveredMap); + } + + const profile = opts.resolveProfile(connectorRef); + if (!profile.ok) { + log(`relay profile "${connectorRef}" unready for ${decision.decisionId}: ${profile.reason} — retained`); + try { + opts.onTransportFailed?.(p, "relay-profile-unready", profile.reason); + } catch (e) { + log(`relay transport-failed receipt write FAILED for ${p.qitemId ?? decision.decisionId}: ${(e as Error).message}`); + } + return { ok: false, class: "relay-profile-unready", detail: profile.reason }; + } + + if (opts.stopSignal?.aborted) { + return { ok: false, class: "stopped", detail: "relay transport stopped" }; + } + + if (!attemptedMap.has(decision.decisionId)) { + try { + opts.attempted.mark(decision.decisionId, `attempted:${connectorRef}:${envelope.outcome}`); + } catch (error) { + const detail = (error as Error).message || "failed to record relay attempt"; + log(`relay attempt record FAILED for ${decision.decisionId}: ${detail} — retained`); + return { ok: false, class: "relay-attempt-unrecorded", detail }; + } + } + + const res = await postRelayEnvelope({ + url: profile.url, + outboundToken: profile.outboundToken, + envelope, + fetchImpl: opts.fetchImpl, + timeoutMs: opts.timeoutMs, + stopSignal: opts.stopSignal, + }); + + if (!res.ok) { + if (res.class !== "stopped") { + try { + opts.onTransportFailed?.(p, res.class, res.detail); + } catch (e) { + log(`relay transport-failed receipt write FAILED for ${p.qitemId ?? decision.decisionId}: ${(e as Error).message}`); + return { ok: false, class: "receipt-failed", detail: (e as Error).message }; + } + } + return { ok: false, class: res.class, detail: res.detail }; + } + + try { + for (const receipt of expectedReceipts) { + if (opts.hasAcceptedReceipt?.({ qitemId: receipt.qitemId, notificationKey: receipt.notificationKey })) { + continue; + } + opts.onAccepted(receipt); + } + } catch (error) { + const detail = (error as Error).message || "relay acceptance receipt write failed"; + log(`relay acceptance receipt write FAILED for ${p.qitemId ?? decision.decisionId}: ${detail} — retained for replay`); + return { ok: false, class: "receipt-failed", detail }; + } + + const finalized = completeLocalGuards(decision, p, outboundSeenMap, deliveredMap); + if (!finalized.ok) return finalized; + + log(`relay delivered ${decision.decisionId}${p.qitemId ? ` (qitem ${p.qitemId})` : ""} via ${connectorRef}`); + return { ok: true }; + }; + + return { + deliver, + wasAttempted, + }; +} + +export function relayStatePaths(home: string): { attemptedPath: string; deliveredPath: string } { + const dir = path.join(home, "state"); + return { + attemptedPath: path.join(dir, RELAY_ATTEMPTED_DECISIONS_FILE), + deliveredPath: path.join(dir, RELAY_DELIVERED_DECISIONS_FILE), + }; +} diff --git a/packages/daemon/src/domain/gateway/slack/open-asks.ts b/packages/daemon/src/domain/gateway/slack/open-asks.ts index cbd75bade..94c5fd33d 100644 --- a/packages/daemon/src/domain/gateway/slack/open-asks.ts +++ b/packages/daemon/src/domain/gateway/slack/open-asks.ts @@ -19,6 +19,7 @@ import type { QueueRepository } from "../../queue-repository.js"; import type { DispatchResult } from "../dispatcher.js"; import { DispatchBuffer } from "../dispatch-buffer.js"; import type { LoadResult } from "../human-registry.js"; +import { sharedChannelAddresses } from "../participants.js"; import { makeQueuePorts } from "./queue-access.js"; import { SeenStore } from "./state-store.js"; import { OUTBOUND_OP, toPayload, type OutboundPostPayload } from "./outbound-driver.js"; @@ -120,7 +121,20 @@ export interface PostOpenAsksInput { /** Show the open asks again: reconcile each, offer the ones enable seeded, then report every ask's outcome. */ export async function postOpenAsks(input: PostOpenAsksInput): Promise { const { queueRepo } = input; - const ports = makeQueuePorts(queueRepo, { loadHumanRegistry: input.loadHumanRegistry }); + const loadSlackHumanRegistry = (): LoadResult => { + const loaded = input.loadHumanRegistry(); + if (!loaded.ok) return loaded; + return { + ...loaded, + entities: loaded.entities.filter((entity) => + entity.connectorBindings.some((b) => b.role === "primary" && b.kind === "slack"), + ), + }; + }; + const ports = makeQueuePorts(queueRepo, { + loadHumanRegistry: loadSlackHumanRegistry, + sharedChannels: () => sharedChannelAddresses(input.home), + }); const episodes = await ports.listOpenAsks({ minimumLevel: input.minimumLevelThatPosts }); const seen = new SeenStore(input.seenPath ?? path.join(input.home, "state", "slack-outbound-seen.jsonl")); const attempted = new SeenStore(path.join(input.home, "state", "slack-attempted-decisions.jsonl")); diff --git a/packages/daemon/src/domain/gateway/slack/slack-subsystem.ts b/packages/daemon/src/domain/gateway/slack/slack-subsystem.ts index bb3777dc7..ec2acb894 100644 --- a/packages/daemon/src/domain/gateway/slack/slack-subsystem.ts +++ b/packages/daemon/src/domain/gateway/slack/slack-subsystem.ts @@ -46,7 +46,10 @@ import { humanAnswerText } from "../../human-questions.js"; import { formatReplyToChoice, formatReplyToThreadMissing, REPLY_TO_CHOICE_ACTOR, REPLY_TO_CHAIN_LIMIT, type ReplyToChoice } from "../../reply-to-choice.js"; import { parseSessionName } from "../../session-name.js"; import type { FetchImpl } from "./slack-api.js"; -import { ownerNotificationLevelAtLeast } from "../../queue-transition-log.js"; +import { + ownerNotificationLevelAtLeast, + RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX, +} from "../../queue-transition-log.js"; // OPR.0.5.6.1 — the delivery rules engine: ONE decision per message replaces // the two dial-reads (the S14 default-decision stub). The legacy dial path // survives ONLY as the stated degrade when the destination is not a @@ -64,6 +67,14 @@ import { confirmReleaseAtPost, currentAskVerdict, holdForQuiet } from "../../pol import { noteRePresentNotPosted, RE_PRESENT_DECISION_PREFIX } from "./open-asks.js"; import { quietHoldFor } from "../human-quiet-windows.js"; import { WatchdogJobsRepository } from "../../watchdog-jobs-repository.js"; +import { loadRelayProfiles } from "../relay-config.js"; +import { + createRelayOutboundTransport, + prepareRelayDecision, + relayStatePaths, + resolvePrimaryRelayBinding, + type RelayPayloadCarry, +} from "../relay-outbound.js"; const SECRET_BOT = "SLACK_BOT_TOKEN"; const SECRET_APP = "SLACK_APP_TOKEN"; @@ -306,7 +317,34 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire { const deadForwards = new DeadLetterStore<{ host: string; envelope: SocketEnvelope }>( path.join(stateDir(opts.home), "slack-inbound-forward-deadletter.jsonl")); - if (!outboundReady && !inboundReady) { + const registrySurface: RegistrySurface = opts.registry ?? { loadHumanRegistry, resolveSlackHandle }; + const hasRelayPrimaryBinding = (): boolean => { + try { + const reg = registrySurface.loadHumanRegistry(opts.home); + return ( + reg.ok && + reg.entities.some((e) => + e.connectorBindings.some((b) => b.role === "primary" && (b.kind as string) === "relay"), + ) + ); + } catch { + return false; + } + }; + const hasPendingRelayDecisions = (): boolean => { + try { + return new DispatchBuffer(opts.home).pending().some((d) => { + const p = (d.payload ?? {}) as RelayPayloadCarry; + return Boolean(p.relayConnectorRef || p.relayEnvelope); + }); + } catch { + return false; + } + }; + const relayConfigured = hasRelayPrimaryBinding() || hasPendingRelayDecisions(); + const anyOutboundEnabled = outboundReady || relayConfigured; + + if (!outboundReady && !inboundReady && !relayConfigured) { const missing = !cfg.enabled ? "connector disabled (rig slack enable)" : !bot ? "SLACK_BOT_TOKEN unresolved" : "channel unconfigured"; log(`slack delivery not configured (${missing}) — wire is inert; dispatches would be refused honestly`); const inert = buildInProcessWire({ @@ -318,7 +356,6 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire { return { ...inert, status: () => ({ platform: "slack", configurationDigest: channelStateDigest(cfg), outboundReady: false, inboundReady: false, inboundRouting, recovery: { state: "unavailable", reason: "inbound-not-configured" }, inbound: { state: "not-configured" }, ...deadLetterBacklogFields(dead, deadActions, deadForwards) }) }; } - const registrySurface: RegistrySurface = opts.registry ?? { loadHumanRegistry, resolveSlackHandle }; const ports = makeQueuePorts(opts.queueRepo, { loadHumanRegistry: () => registrySurface.loadHumanRegistry(opts.home), sharedChannels: () => sharedChannelAddresses(opts.home), @@ -401,19 +438,53 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire { }).catch((e) => log(`undeliverable notice FAILED for ${p.qitemId}: ${(e as Error).message}`)); }; - const recordTransportFailed = (p: OutboundPostPayload, failureClass: string, detail: string, partlyPosted?: boolean): void => { + const resolveRelayConnectorRef = (p: OutboundPostPayload): string | null => { + const carry = p as RelayPayloadCarry; + if (typeof carry.relayConnectorRef === "string" && carry.relayConnectorRef.trim().length > 0) { + return carry.relayConnectorRef.trim(); + } + try { + const reg = registrySurface.loadHumanRegistry(opts.home); + return resolvePrimaryRelayBinding(p.destinationSession, reg)?.connectorRef ?? null; + } catch { + return null; + } + }; + + const recordTransportFailed = ( + p: OutboundPostPayload, + failureClass: string, + detail: string, + partlyPosted?: boolean, + relay = false, + ): void => { if (!p.qitemId) return; - if (failureClass === "human-message-unrenderable" || failureClass === "slack-invalid-blocks") { + const carry = p as RelayPayloadCarry; + const isRelay = relay || Boolean(carry.relayConnectorRef || carry.relayEnvelope); + if (!isRelay && (failureClass === "human-message-unrenderable" || failureClass === "slack-invalid-blocks")) { notifySourceUndeliverable(p, detail, partlyPosted, failureClass === "slack-invalid-blocks"); } const key = p.notificationKey ?? p.qitemId; + if (isRelay) { + opts.queueRepo.recordRelayOwnerNotificationTransportFailed({ + qitemId: p.qitemId, + notificationKey: key, + connectorRef: resolveRelayConnectorRef(p), + failureClass, + detail, + }); + return; + } const previousFailure = opts.queueRepo.transitionLog.listForQitem(p.qitemId).filter((transition) => - transition.transitionNote?.startsWith("slack-owner-notification-transport-failed ") + (transition.transitionNote?.startsWith("slack-owner-notification-transport-failed ") + || transition.transitionNote?.startsWith(`${RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX} `)) && transition.transitionNote.split(/\s+/).includes(`notification_key=${key}`)).at(-1); // A corrected attempt can fail transiently after a permanent rejection, and a // transient failure can be followed by a definite rejection. Preserve both changes. - const alreadyRecorded = previousFailure && - previousFailure.transitionNote!.includes("class=slack-invalid-blocks ") === (failureClass === "slack-invalid-blocks"); + const alreadyRecorded = Boolean( + previousFailure?.transitionNote?.startsWith("slack-owner-notification-transport-failed ") && + previousFailure.transitionNote.includes("class=slack-invalid-blocks ") === (failureClass === "slack-invalid-blocks"), + ); if (alreadyRecorded) return; opts.queueRepo.update({ qitemId: p.qitemId, @@ -694,6 +765,98 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire { }) : async () => ({ ok: false as const, class: "slack-outbound-not-configured", detail: "bot token or channel missing" }); + const { attemptedPath: relayAttemptedPath, deliveredPath: relayDeliveredPath } = relayStatePaths(opts.home); + const relayAttempted = new SeenStore(relayAttemptedPath); + const relayDelivered = new SeenStore(relayDeliveredPath); + const relayTransport = createRelayOutboundTransport({ + attempted: relayAttempted, + delivered: relayDelivered, + outboundSeen, + fetchImpl: opts.fetchImpl, + stopSignal: runCtl.signal, + log, + resolveProfile: (connectorRef) => { + const reg = registrySurface.loadHumanRegistry(opts.home); + const loaded = loadRelayProfiles(opts.home, reg.ok ? { entities: reg.entities } : undefined); + if (!loaded.ok && loaded.registryError) { + return { ok: false, reason: loaded.registryError }; + } + const profile = loaded.get(connectorRef); + if (!profile) { + return { ok: false, reason: `relay profile "${connectorRef}" is not registered` }; + } + if (!profile.ready || !profile.credentials) { + return { ok: false, reason: profile.reason }; + } + return { + ok: true, + connectorRef: profile.connectorRef, + url: profile.credentials.url, + outboundToken: profile.credentials.outboundToken, + }; + }, + hasAcceptedReceipt: ({ qitemId, notificationKey }) => + opts.queueRepo.hasRelayOwnerNotificationReceipt(qitemId, notificationKey), + onAccepted: (info) => { + opts.queueRepo.recordRelayOwnerNotificationAccepted({ + qitemId: info.qitemId, + notificationKey: info.notificationKey, + deliveryId: info.deliveryId, + connectorRef: info.connectorRef, + replyRef: info.replyRef, + replyRefHash: info.refHash, + revision: info.revision, + episodeKey: info.episodeKey, + level: info.level, + kind: info.kind, + humanIntent: info.humanIntent, + closeUpdate: !info.digestId, + ...(info.digestId ? { digest: info.digestId } : {}), + }); + }, + onTransportFailed: (p, failureClass, detail) => recordTransportFailed(p, failureClass, detail, false, true), + release: (q) => releaseRef(q), + }); + + const prepareDecision = (decision: Parameters[0]) => + prepareRelayDecision(decision, { + loadHumanRegistry: () => registrySurface.loadHumanRegistry(opts.home), + queueRepo: opts.queueRepo, + resolveOutcome: (p) => { + let ruled: DeliveryDecision | null = null; + try { ruled = p.qitemId ? decideForPayload(p) : null; } catch { ruled = null; } + if (ruled) { + if (ruled.outcome === "interrupt" && ruled.deferMinutes === undefined) return "interrupt"; + if (ruled.outcome === "digest") return "digest"; + return "notify"; + } + const interrupt = Boolean( + p.ownerNotificationLevel && ownerNotificationLevelAtLeast(p.ownerNotificationLevel, cfg.minimumLevelThatInterrupts), + ); + return interrupt ? "interrupt" : "notify"; + }, + }); + + const isRelayDecision = (decision: Parameters[0], p: OutboundPostPayload): boolean => { + const carry = p as RelayPayloadCarry; + if (carry.relayConnectorRef || carry.relayEnvelope) return true; + try { + const reg = registrySurface.loadHumanRegistry(opts.home); + return resolvePrimaryRelayBinding(p.destinationSession ?? decision.entityBindingRef, reg) !== null; + } catch { + return false; + } + }; + + const targetDeliverFor = (decision: Parameters[0], p: OutboundPostPayload): SubsystemDeliverFn => { + if (!isRelayDecision(decision, p)) return deliver; + return (d) => { + const carry = (d.payload ?? {}) as RelayPayloadCarry; + const readyDecision = carry.relayConnectorRef && carry.relayEnvelope ? d : prepareDecision(d); + return relayTransport.deliver(readyDecision); + }; + }; + // OPR.0.5.6.1 — decision containment ahead of the transport: log-class and // digest-class produce ZERO post attempts (AM-F5 tooth); an away-escalation // deferral arms the watchdog substrate and posts nothing now (AM-F1); an @@ -753,8 +916,12 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire { // contents, and receipts bind to exactly the members posted under it. An earlier attempt of the original that may // have landed is resolved by the reconciler first. If the quiet state can't be read, nothing posts (see the catch). const deliverDigestPost = async (decision: Parameters[0], p: OutboundPostPayload) => { - const settled = await resolveEarlierAttempt(decision); - if (settled) return settled; + const relay = isRelayDecision(decision, p); + const targetDeliver = targetDeliverFor(decision, p); + if (!relay) { + const settled = await resolveEarlierAttempt(decision); + if (settled) return settled; + } const recipient = (p.destinationSession ?? "").split("@")[0] ?? ""; let quiet: ReturnType; try { @@ -763,7 +930,10 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire { // An attempted digest (it may be in Slack) is retained: acked, the next flush would mint the ask again from the // row as it is now. A never-attempted one is acked: its members keep their digest notes, so the next flush // selects them again. A store check only: no Slack read on a failure path. - if ("attempted" in deliver && (deliver as { attempted: (d: typeof decision) => boolean }).attempted(decision)) { + const wasAttempted = relay + ? relayTransport.wasAttempted(decision) + : "attempted" in deliver && (deliver as { attempted: (d: typeof decision) => boolean }).attempted(decision); + if (wasAttempted) { log(`digest post ${decision.decisionId} retained: quiet hours couldn't be read: ${(error as Error).message}`); return { ok: false as const, class: "quiet-unreadable", detail: (error as Error).message }; } @@ -788,7 +958,7 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire { holdForQuiet({ queueRepo: opts.queueRepo, jobsRepo, qitemId: m.qitemId, entityId: recipient, hold: { ...quiet!, notificationKey: m.notificationKey } }); return false; }); - if (kept.length === members.length) return deliver(decision); // unchanged: the original, as it is + if (kept.length === members.length) return targetDeliver(decision); // unchanged: the original, as it is if (kept.length === 0 || !aggregate) return { ok: true as const }; // nothing left to post under it const keptIds = new Set(kept.map((m) => m.qitemId)); const body = String(p.body ?? "").split("\n").filter((line) => [...keptIds].some((id) => line.endsWith(`[${id}]`))).join("\n"); @@ -796,12 +966,26 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire { const keptId = `${decision.decisionId}:kept:${createHash("sha256").update(kept.map((m) => m.notificationKey).sort().join(",")).digest("hex").slice(0, 16)}`; // Durable first: the new decision is in the buffer before the original is acked (returning ok). A crash between the // two replays the original, which computes the same kept set and id; the dispatcher accepts the pending id once. - const res = selfDispatch(keptId, { ...p, memberReceipts: kept, body, summary }); + const { relayConnectorRef: _rc, relayEnvelope: _re, relayRevision: _rr, ...restPayload } = p as RelayPayloadCarry; + const res = selfDispatch(keptId, { ...restPayload, memberReceipts: kept, body, summary }); return res.ok ? { ok: true as const } : { ok: false as const, class: "kept-dispatch-refused", detail: res.error }; }; const engineDeliver: SubsystemDeliverFn = async (decision) => { const p = ((decision as { payload?: unknown }).payload ?? {}) as OutboundPostPayload & { deliveryDeferralFire?: boolean }; + const relay = isRelayDecision(decision, p); + const targetDeliver = targetDeliverFor(decision, p); + if (relay) { + let alreadyAttempted = false; + try { + alreadyAttempted = relayTransport.wasAttempted(decision); + } catch { + return targetDeliver(decision); + } + if (alreadyAttempted) { + return targetDeliver(decision); + } + } // The decision-resolved notice is written at closure, so a closed row is its normal case. if (replayedAtStart.has(decision.decisionId) && p.qitemId && p.ownerNotificationKind !== "human-decision-resolved" && !(p as { deliveryDigestPost?: boolean }).deliveryDigestPost) { @@ -827,8 +1011,10 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire { releaseRef(p.qitemId); return { ok: true as const }; } - const settled = await resolveEarlierAttempt(decision); - if (settled) return settled; + if (!relay) { + const settled = await resolveEarlierAttempt(decision); + if (settled) return settled; + } const verdict = !p.qitemId ? "post" : await confirmReleaseAtPost({ queueRepo: opts.queueRepo, jobsRepo: new WatchdogJobsRepository(opts.queueRepo.db), @@ -838,7 +1024,7 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire { qitemId: p.qitemId, notificationKey: key, }); - if (verdict === "post") return deliver(decision); + if (verdict === "post") return targetDeliver(decision); // Declined (the row says why) or couldn't be checked (nothing written): either way nothing posts now. Acking is // safe because the window's release job owns redelivery until the ask has a receipt or a disposition; keeping it // buffered would wait for a restart and block the job's own re-dispatch of the same decision id. @@ -859,21 +1045,27 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire { // leniently. A log that exists but can't be read is unknown, not "never attempted": the decision stays in the buffer, // with no post (delivery's reconcile reads the same log) and no not-posted record, until it reads. try { - attempted.statuses(); + (relay ? relayAttempted : attempted).statuses(); } catch (error) { log(`re-shown ${decision.decisionId} retained: the attempt log couldn't be read (${(error as Error).message})`); return { ok: false as const, class: "re-present-attempt-log-unreadable", detail: (error as Error).message }; } // An earlier attempt of this decision found in Slack goes to delivery, which records it. - const landing = "landing" in deliver ? await (deliver as { landing: (d: typeof decision) => Promise }).landing(decision) : "unattempted"; - if (landing === "found" || landing === "partial") return deliver(decision); + const landing = !relay && "landing" in deliver ? await (deliver as { landing: (d: typeof decision) => Promise }).landing(decision) : "unattempted"; + if (landing === "found" || landing === "partial") return targetDeliver(decision); + const regForVerdict = registrySurface.loadHumanRegistry(opts.home); + const alertsForVerdict = + regForVerdict.ok || regForVerdict.noHumans + ? await ports.listHumanAlerts({ minimumLevel: cfg.minimumLevelThatPosts }) + : undefined; const verdict = await currentAskVerdict({ queueRepo: opts.queueRepo, home: opts.home, - registry: { loadHumanRegistry: () => registrySurface.loadHumanRegistry(opts.home) }, + registry: { loadHumanRegistry: () => regForVerdict }, entityId: (p.destinationSession ?? "").split("@")[0] ?? "", qitemId: p.qitemId, notificationKey: key, + ...(alertsForVerdict ? { alerts: alertsForVerdict } : {}), }); if (verdict.kind !== "post") { // One whose earlier attempt Slack couldn't rule out may already be posted: it's never recorded as not posted, and @@ -894,12 +1086,12 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire { releaseRef(p.qitemId); return { ok: true as const }; } - return deliver(decision); + return targetDeliver(decision); } // A post confirmed partly in Slack finishes without consulting quiet hours, so it is resolved before the decision // (which reads them). Only a started multipart post is checked here, from the stores first, so an ordinary // retry isn't scanned twice; any other attempted decision is resolved at the gate below. - const started = "unfinished" in deliver && (deliver as { unfinished: (d: typeof decision) => boolean }).unfinished(decision); + const started = !relay && "unfinished" in deliver && (deliver as { unfinished: (d: typeof decision) => boolean }).unfinished(decision); if (started) { const settled = await resolveEarlierAttempt(decision); if (settled) return settled; @@ -911,7 +1103,10 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire { // An attempted decision (it may be partly or wholly in Slack) is retained and nothing posts: acking it would drop // the post's missing parts and let the sweep offer the ask again under a new id, posting it from the start. Its // replay decides again once the quiet state reads. A store check only: no Slack read on a failure path. - if ("attempted" in deliver && (deliver as { attempted: (d: typeof decision) => boolean }).attempted(decision)) { + const wasAttempted = relay + ? relayTransport.wasAttempted(decision) + : "attempted" in deliver && (deliver as { attempted: (d: typeof decision) => boolean }).attempted(decision); + if (wasAttempted) { log(`delivery decision for attempted ${decision.decisionId} couldn't be made; retained: ${(error as Error).message}`); return { ok: false as const, class: "quiet-unreadable", detail: (error as Error).message }; } @@ -927,7 +1122,7 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire { releaseRef(p.notificationKey ?? p.qitemId!); return { ok: true as const }; } - if (!started && ruled && (ruled.outcome === "log" || (ruled.outcome === "hold" && ruled.quietHold) + if (!relay && !started && ruled && (ruled.outcome === "log" || (ruled.outcome === "hold" && ruled.quietHold) || ruled.outcome === "digest" || ruled.deferMinutes !== undefined)) { const settled = await resolveEarlierAttempt(decision); if (settled) return settled; @@ -983,20 +1178,21 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire { }); } catch (e) { log(`deferral arm failed for ${p.qitemId}: ${(e as Error).message} — falling through to immediate notify-class delivery (fail-loud beats silent loss)`); - return deliver(decision); + return targetDeliver(decision); } outboundSeen.mark(episodeKey, "interrupt-deferred"); releaseRef(p.qitemId); return { ok: true as const }; } } - return deliver(decision); + return targetDeliver(decision); }; const wire = buildInProcessWire({ home: opts.home, - ops: outboundReady ? [OUTBOUND_OP] : [], - deliver: outboundReady ? engineDeliver : deliver, + ops: anyOutboundEnabled ? [OUTBOUND_OP] : [], + ...(anyOutboundEnabled ? { prepare: prepareDecision } : {}), + deliver: anyOutboundEnabled ? engineDeliver : deliver, log, }); selfDispatch = (decisionId, payload) => @@ -1026,10 +1222,23 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire { ...(relayed ? { relayed: true } : {}), }); - if (outboundReady) { + if (anyOutboundEnabled) { + const driverQueue = outboundReady + ? ports + : { + listHumanAlerts: async (filter: Parameters[0]) => { + const alerts = await ports.listHumanAlerts(filter); + try { + const reg = registrySurface.loadHumanRegistry(opts.home); + return alerts.filter((alert) => resolvePrimaryRelayBinding(alert.destinationSession, reg) !== null); + } catch { + return []; + } + }, + }; const driver = new SlackOutboundDriver({ home: opts.home, - queue: ports, + queue: driverQueue, seen: outboundSeen, filter: { minimumLevel: cfg.minimumLevelThatPosts }, dispatch: (op, ref, payload) => wire.dispatcher.dispatch(op, ref, payload), diff --git a/packages/daemon/src/domain/mission-control/mission-control-write-contract.ts b/packages/daemon/src/domain/mission-control/mission-control-write-contract.ts index 56090f8a2..a7459b7e4 100644 --- a/packages/daemon/src/domain/mission-control/mission-control-write-contract.ts +++ b/packages/daemon/src/domain/mission-control/mission-control-write-contract.ts @@ -42,7 +42,7 @@ import { createHash } from "node:crypto"; import type Database from "better-sqlite3"; import type { EventBus } from "../event-bus.js"; -import { isTypedGateBlocker, UNATTRIBUTED_ACTOR, type QueueRepository, type QueueItem } from "../queue-repository.js"; +import { isTypedGateBlocker, mcOperationTag, UNATTRIBUTED_ACTOR, type QueueRepository, type QueueItem } from "../queue-repository.js"; import type { PersistedEvent } from "../types.js"; import { MissionControlActionLog, @@ -178,7 +178,13 @@ export class MissionControlWriteContract { return { actionId: last.actionId, verb: input.verb, qitemId: input.qitemId, closedQitem: item, createdQitemId: null, notifyAttempted: false, notifyResult: null, auditedAt: last.actedAt, reconciled: true, decisionRevision: current! }; } - throw this.revisionChanged(item, current!); + const withoutOwnReply = + operation && input.verb === "answer" + ? this.queueRepo.decisionRevision(item, { excludeOperationReply: operation }) + : current; + if (withoutOwnReply !== input.expectedRevision) { + throw this.revisionChanged(item, current!); + } } const result = await this.actOn(operation ? { ...input, auditNotes: { ...input.auditNotes, operation } } : input); const saved = this.queueRepo.getById(input.qitemId); @@ -596,11 +602,29 @@ export class MissionControlWriteContract { if (existing && (ask.state === "pending" || ask.state === "done")) { const decision = questions.length && unansweredQuestions(questions, ask.humanAnswers ?? {}).length === 0 ? formatHumanAnswers(questions, ask.humanAnswers ?? {}).join("; ") : humanAnswerText(existing.body); - if (ask.state === "pending" && !this.queueRepo.resolveDirectHumanReply({ qitemId: input.qitemId, actorSession: input.actorSession, decision, primaryUser })) { - throw new MissionControlWriteContractError("answer_not_closed", `The answer is already in ${existing.qitemId}, but ${input.qitemId} didn't close.`, { qitemId: input.qitemId }); - } - return this.recordAnswer(input, before, decision, { outcome: "answered", unanswered: [], - message: `Already answered; the original answer went to ${existing.destinationSession} as ${existing.qitemId}. No new reply was sent.` }, existing.qitemId); + return this.recordAnswer( + input, + before, + decision, + { + outcome: "answered", + unanswered: [], + message: `Already answered; the original answer went to ${existing.destinationSession} as ${existing.qitemId}. No new reply was sent.`, + }, + existing.qitemId, + ask.state === "pending" + ? { + primaryUser, + allowAlreadyDone: false, + onUnclosed: () => + new MissionControlWriteContractError( + "answer_not_closed", + `The answer is already in ${existing.qitemId}, but ${input.qitemId} didn't close.`, + { qitemId: input.qitemId }, + ), + } + : undefined, + ); } // One answer per ask: another person's committed return is the answer. Before anything of this person's is recorded, // finish settling the ask with it and say who answered; nothing of theirs is recorded or sent. @@ -650,13 +674,21 @@ export class MissionControlWriteContract { const answeredBy = !forAsked ? `Answered by ${input.actorSession}` : unattributed ? `Answered at the terminal by someone OpenRig can't name (${UNNAMED_PRIMARY_USER}), unattributed, on ${forAsked}'s ask` : `Answered by ${input.actorSession}, ${primaryUser ? "the primary user at the terminal" : "a registered person"}, on ${forAsked}'s ask`; + const operation = + typeof input.auditNotes?.operation === "string" && input.auditNotes.operation.length > 0 + ? input.auditNotes.operation + : null; let returned: QueueItem; try { returned = await this.queueRepo.createHumanAnswer({ sourceSession: input.actorSession, destinationSession: ask.sourceSession, body: `${reply}\n${answeredBy} in OpenRig.`, summary: `${forAsked && unattributed ? `Unattributed (${UNNAMED_PRIMARY_USER})` : input.actorSession} answered${forAsked ? ` ${forAsked}'s ask` : ""}: ${ask.summary ?? input.qitemId}`, - tags: ["human-answer", ...(ask.tags ?? []).filter((t) => /^(project|mission|slice):/.test(t))], + tags: [ + "human-answer", + ...(ask.tags ?? []).filter((t) => /^(project|mission|slice):/.test(t)), + ...(operation ? [mcOperationTag(operation)] : []), + ], inboundReplyTo: input.qitemId, ...(input.notify === false ? { nudge: false } : {}), }); } catch (error) { @@ -681,13 +713,23 @@ export class MissionControlWriteContract { const revision = now ? this.queueRepo.decisionRevision(now) : null; if (now && revision !== input.expectedRevision) throw this.revisionChanged(now, revision!); } - if (!this.queueRepo.resolveDirectHumanReply({ qitemId: input.qitemId, actorSession: input.actorSession, decision, primaryUser }) - && this.queueRepo.getById(input.qitemId)?.state !== "done") { - throw new MissionControlWriteContractError("answer_not_closed", - `The answer went back to ${ask.sourceSession} as ${replyId}, but qitem ${input.qitemId} didn't close: it changed meanwhile.`, - { qitemId: input.qitemId, replyQitemId: replyId }); - } - return this.recordAnswer(input, before, decision, { outcome: "answered", unanswered: [] }, replyId); + return this.recordAnswer( + input, + before, + decision, + { outcome: "answered", unanswered: [] }, + replyId, + { + primaryUser, + allowAlreadyDone: true, + onUnclosed: () => + new MissionControlWriteContractError( + "answer_not_closed", + `The answer went back to ${ask.sourceSession} as ${replyId}, but qitem ${input.qitemId} didn't close: it changed meanwhile.`, + { qitemId: input.qitemId, replyQitemId: replyId }, + ), + }, + ); } /** Another person's committed return already answers the ask: settle the ask with it if it's still pending (the @@ -700,23 +742,74 @@ export class MissionControlWriteContract { { qitemId: input.qitemId, answeredBy: other.sourceSession, replyQitemId: other.qitemId }); } - /** The audit row and event for one answer (its queue writes are the repository's own). */ - private recordAnswer(input: MissionControlActionInput, before: Record, reason: string, - answer: NonNullable, createdQitemId: string | null = null): MissionControlActionResult { + /** Close a pending ask (when requested) and record its audit row and event in one atomic transaction. */ + private recordAnswer( + input: MissionControlActionInput, + before: Record, + reason: string, + answer: NonNullable, + createdQitemId: string | null = null, + closePending?: { + primaryUser: boolean; + allowAlreadyDone: boolean; + onUnclosed: () => MissionControlWriteContractError; + }, + ): MissionControlActionResult { const evaluatedAt = this.now().toISOString(); - const after = this.queueRepo.getById(input.qitemId); + let after: QueueItem | null = null; let actionEntry: ReturnType | null = null; - const persistedEvents: PersistedEvent[] = []; - this.db.transaction(() => { - actionEntry = this.actionLog.record({ actionVerb: "answer", qitemId: input.qitemId, actorSession: input.actorSession, - actedAt: evaluatedAt, beforeState: before, afterState: after ? snapshotQitem(after) : null, reason, - auditNotes: input.auditNotes ?? null, identityProvenance: input.identityProvenance ?? null }); - persistedEvents.push(this.eventBus.persistWithinTransaction({ type: "mission_control.action_executed", - actionId: actionEntry.actionId, actionVerb: "answer", qitemId: input.qitemId, actorSession: input.actorSession })); - })(); - for (const e of persistedEvents) this.eventBus.notifySubscribers(e); - return { actionId: actionEntry!.actionId, verb: "answer", qitemId: input.qitemId, closedQitem: after, createdQitemId, - notifyAttempted: false, notifyResult: null, auditedAt: evaluatedAt, answer }; + try { + this.eventBus.withNotifyEnvelope((register) => { + if (closePending) { + const closed = this.queueRepo.resolveDirectHumanReply({ + qitemId: input.qitemId, + actorSession: input.actorSession, + decision: reason, + primaryUser: closePending.primaryUser, + }); + if (!closed && (!closePending.allowAlreadyDone || this.queueRepo.getById(input.qitemId)?.state !== "done")) { + throw closePending.onUnclosed(); + } + } + after = this.queueRepo.getById(input.qitemId); + actionEntry = this.actionLog.record({ + actionVerb: "answer", + qitemId: input.qitemId, + actorSession: input.actorSession, + actedAt: evaluatedAt, + beforeState: before, + afterState: after ? snapshotQitem(after) : null, + reason, + auditNotes: input.auditNotes ?? null, + identityProvenance: input.identityProvenance ?? null, + }); + register( + this.eventBus.persistWithinTransaction({ + type: "mission_control.action_executed", + actionId: actionEntry.actionId, + actionVerb: "answer", + qitemId: input.qitemId, + actorSession: input.actorSession, + }), + ); + }); + } catch (err) { + if (err instanceof MissionControlActionLogError) { + throw new MissionControlWriteContractError(err.code, err.message, err.details); + } + throw err; + } + return { + actionId: actionEntry!.actionId, + verb: "answer", + qitemId: input.qitemId, + closedQitem: after, + createdQitemId, + notifyAttempted: false, + notifyResult: null, + auditedAt: evaluatedAt, + answer, + }; } /** diff --git a/packages/daemon/src/domain/policies/quiet-release.ts b/packages/daemon/src/domain/policies/quiet-release.ts index 96215b752..cb1f63774 100644 --- a/packages/daemon/src/domain/policies/quiet-release.ts +++ b/packages/daemon/src/domain/policies/quiet-release.ts @@ -256,7 +256,7 @@ export async function runQuietRelease(input: RunQuietReleaseInput): Promise<{ st return { state: "pending", released, dispositions }; } if (open === 0) { - input.jobsRepo.markTerminal(input.jobId, "quiet release complete: every held ask posted or given a disposition"); + input.jobsRepo.markTerminal(input.jobId, "quiet release complete: every held ask posted, accepted by its bridge, or given a disposition"); return { state: "released", released, dispositions }; } return { state: "pending", released, dispositions }; @@ -274,10 +274,13 @@ function heldKeys(queueRepo: QueueRepository, windowId: string): Array<[string, return out; } -/** Posted, or given a disposition: nothing more to release for this key. */ +/** Posted, accepted by a relay bridge (which ends local quiet-release custody without claiming downstream person delivery), + * or given a disposition: nothing more to release for this key. */ function resolved(queueRepo: QueueRepository, qitemId: string, key: string, notes?: string[]): boolean { const all = notes ?? queueRepo.listTransitions(qitemId).map((t) => t.transitionNote ?? ""); - return all.some((n) => hasToken(n, "slack-owner-notification-posted", key) || hasToken(n, DISPOSITION_PREFIX, key)); + return all.some((n) => hasToken(n, "slack-owner-notification-posted", key) + || hasToken(n, "relay-owner-notification-accepted", key) + || hasToken(n, DISPOSITION_PREFIX, key)); } /** Watchdog-engine policy wrapper: the scheduler's cadence drives evaluation; the current quiet state owns the timing. */ diff --git a/packages/daemon/src/domain/queue-repository.ts b/packages/daemon/src/domain/queue-repository.ts index bc937926d..93fc92a10 100644 --- a/packages/daemon/src/domain/queue-repository.ts +++ b/packages/daemon/src/domain/queue-repository.ts @@ -8,7 +8,59 @@ import { loadHumanRegistry, registeredPeople, resolveRegisteredHumanAddress, sla import { sharedChannelAddresses } from "./gateway/participants.js"; import { resolveExternal } from "./gateway/external-admission.js"; import type { AgentActivity, PersistedEvent } from "./types.js"; -import { QueueTransitionLog, type OwnerNotificationLevel, type QueueTransition, type RecentQueueTransitionScope } from "./queue-transition-log.js"; +import { + QueueTransitionLog, + RELAY_OWNER_NOTIFICATION_ACCEPTED_PREFIX, + RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX, + formatRelayOwnerNotificationAccepted, + formatRelayOwnerNotificationTransportFailed, + parseRelayOwnerNotificationAccepted, + parseRelayOwnerNotificationTransportFailed, + type FormatRelayOwnerNotificationAcceptedInput, + type FormatRelayOwnerNotificationTransportFailedInput, + type OwnerNotificationLevel, + type QueueTransition, + type RecentQueueTransitionScope, + type RelayOwnerNotificationReceipt, + type RelayOwnerNotificationTransportFailure, +} from "./queue-transition-log.js"; + +export { + RELAY_OWNER_NOTIFICATION_ACCEPTED_PREFIX, + RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX, + formatRelayOwnerNotificationAccepted, + formatRelayOwnerNotificationTransportFailed, + parseRelayOwnerNotificationAccepted, + parseRelayOwnerNotificationTransportFailed, + type FormatRelayOwnerNotificationAcceptedInput, + type FormatRelayOwnerNotificationTransportFailedInput, + type RelayOwnerNotificationReceipt, + type RelayOwnerNotificationTransportFailure, +}; + +export type HumanIntent = "decision" | "update"; + +export const MC_OPERATION_TAG_PREFIX = "mc-operation:" as const; + +export function mcOperationTag(operation: string): string { + return `${MC_OPERATION_TAG_PREFIX}${operation}`; +} + +export interface DecisionRevisionOptions { + excludeOperationReply?: string | null; +} + +export interface RecordRelayOwnerNotificationAcceptedInput extends FormatRelayOwnerNotificationAcceptedInput { + qitemId: string; + actorSession?: string; + closeUpdate?: boolean; + humanIntent?: HumanIntent | null; +} + +export interface RecordRelayOwnerNotificationTransportFailedInput extends FormatRelayOwnerNotificationTransportFailedInput { + qitemId: string; + actorSession?: string; +} import { WAKE_INTENT_PREFIX, type OutboxHandler, type OutboxEntry } from "./outbox-handler.js"; import { physicalSessionName } from "./session-registry.js"; import type { QueueWakeFacts } from "./draft-aware-delivery.js"; @@ -210,10 +262,10 @@ export interface QueueItem { priority: QueuePriority; tier: string | null; /** OPR.0.5.6.14 — the delivery LEDGER verdict for gateway-routed rows - * (posted / transport-failed / never-posted), derived from the row's own + * (posted / accepted / transport-failed / never-posted), derived from the row's own * transitions. Pane-bound rows carry null — the field never lies about a * class it does not govern. Populated on getById and findUndelivered. */ - deliveryOutcome?: "posted" | "transport-failed" | "never-posted" | null; + deliveryOutcome?: "posted" | "accepted" | "transport-failed" | "never-posted" | null; /** The undelivered surface's class for ledger-derived entries (the route * prefers this over the nudge-literal regex when present). */ deliveryFailureClass?: string; @@ -1206,10 +1258,15 @@ export class QueueRepository { // A person's Slack message routed to a person is already in Slack: as it arrived, it's on Steering and the TUI and // isn't echoed back. A later park of it on a person is a new ask, classified (and posted) on that update. if (input.gatewayInbound) return { kind: "human-required", level: "RECORD" }; - // The person's Slack subscription decides: `every-ask` (the default) posts every ask (ALERT); `urgent-only` posts only - // one marked --alert, and a plain ask lands on Steering and the TUI (RECORD: below the posting dial, so no post is owed). + // Slack subscription filters only apply when Slack is the primary connector. When a person's primary connector + // is relay (or by default when no Slack subscription restricts it), plain asks post by default (`every-ask`), + // so ordinary relay asks post (ALERT) independently of any retained Slack-specific subscription. const person = people.find((entity) => entity.address === destinationHuman); - const posts = input.humanAlert === true || slackSubscriptionFor(person).preset === "every-ask"; + const primaryBinding = person?.connectorBindings.find((b) => b.role === "primary"); + const posts = input.humanAlert === true + || (primaryBinding?.kind === "relay" + ? true + : slackSubscriptionFor(person).preset === "every-ask"); return input.humanIntent === "update" ? { kind: "human-update", level: "NOTICE" } : { kind: "human-required", level: posts ? "ALERT" : "RECORD" }; @@ -3908,7 +3965,9 @@ export class QueueRepository { return this.closeDirectAsk(item, input.actorSession, input.decision, input.primaryUser === true); })(); if (!result) return false; - for (const event of result.persistedEvents) this.eventBus.notifySubscribers(event); + for (const event of result.persistedEvents) { + if (!this.eventBus.registerPersistedWithinActiveEnvelope(event)) this.eventBus.notifySubscribers(event); + } return mine; } @@ -4031,10 +4090,24 @@ export class QueueRepository { /** The opaque revision of what a person decides on (#2446): the question (body, intent, questions) and its decision state * (open, blocked or terminal; who it's addressed to or parked on; its answers, a decision made for them, the current park, * and the replies returned). A label, a wake, a claim or a note on a park doesn't change it; a new park does, on any blocker. */ - decisionRevision(item: QueueItem): string { + decisionRevision(item: QueueItem, opts?: DecisionRevisionOptions): string { const phase = item.state === "blocked" ? "blocked" : item.state === "pending" || item.state === "in-progress" ? "open" : item.state; - const replies = this.hasReplyToColumn ? (this.db.prepare("SELECT qitem_id FROM queue_items WHERE reply_to = ? ORDER BY ts_created, rowid") - .all(item.qitemId) as Array<{ qitem_id: string }>).map((row) => row.qitem_id) : []; + const excludeOp = + typeof opts?.excludeOperationReply === "string" && opts.excludeOperationReply.length > 0 + ? mcOperationTag(opts.excludeOperationReply) + : null; + const replies = this.hasReplyToColumn + ? (excludeOp + ? (this.db + .prepare( + "SELECT qitem_id FROM queue_items WHERE reply_to = ? AND NOT (tags IS NOT NULL AND json_valid(tags) AND EXISTS (SELECT 1 FROM json_each(tags) WHERE value = ?)) ORDER BY ts_created, rowid", + ) + .all(item.qitemId, excludeOp) as Array<{ qitem_id: string }>) + : (this.db + .prepare("SELECT qitem_id FROM queue_items WHERE reply_to = ? ORDER BY ts_created, rowid") + .all(item.qitemId) as Array<{ qitem_id: string }>) + ).map((row) => row.qitem_id) + : []; // The park this is: the transition into blocked from another state (a later blocked-to-blocked note stays this park). const transitions = item.state === "blocked" ? this.transitionLog.listForQitem(item.qitemId) : []; let parkEntry: number | null = null; @@ -4046,6 +4119,92 @@ export class QueueRepository { return `dr1-${createHash("sha256").update(JSON.stringify(decision)).digest("hex").slice(0, 24)}`; } + listRelayOwnerNotificationReceipts(qitemId: string): RelayOwnerNotificationReceipt[] { + return this.transitionLog.listRelayOwnerNotificationReceipts(qitemId); + } + + getRelayOwnerNotificationReceipt(qitemId: string, notificationKey?: string): RelayOwnerNotificationReceipt | null { + return this.transitionLog.getRelayOwnerNotificationReceipt(qitemId, notificationKey); + } + + hasRelayOwnerNotificationReceipt(qitemId: string, notificationKey: string): boolean { + return this.transitionLog.hasRelayOwnerNotificationReceipt(qitemId, notificationKey); + } + + findRelayOwnerNotificationReceiptByReplyRef( + replyRef: string, + ): (RelayOwnerNotificationReceipt & { qitemId: string }) | null { + return this.transitionLog.findRelayOwnerNotificationReceiptByReplyRef(replyRef); + } + + recordRelayOwnerNotificationTransportFailed(input: RecordRelayOwnerNotificationTransportFailedInput): QueueItem { + const current = this.getByIdOrThrow(input.qitemId); + const key = typeof input.notificationKey === "string" && input.notificationKey.trim().length > 0 + ? input.notificationKey.trim() + : input.qitemId; + const normalizedConnectorRef = + typeof input.connectorRef === "string" && input.connectorRef.trim().length > 0 + ? input.connectorRef.trim() + : null; + const previousFailure = this.transitionLog + .listForQitem(input.qitemId) + .filter( + (transition) => + (transition.transitionNote?.startsWith("slack-owner-notification-transport-failed ") || + transition.transitionNote?.startsWith(`${RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX} `)) && + transition.transitionNote.split(/\s+/).includes(`notification_key=${key}`), + ) + .at(-1); + if (previousFailure?.transitionNote?.startsWith(`${RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX} `)) { + const parsedPrevious = parseRelayOwnerNotificationTransportFailed(previousFailure.transitionNote); + if (parsedPrevious && parsedPrevious.connectorRef === normalizedConnectorRef) { + return current; + } + } + const note = formatRelayOwnerNotificationTransportFailed({ + notificationKey: key, + connectorRef: normalizedConnectorRef, + failureClass: input.failureClass, + detail: input.detail, + }); + return this.update({ + qitemId: input.qitemId, + actorSession: input.actorSession ?? "daemon@kernel", + transitionNote: note, + }); + } + + recordRelayOwnerNotificationAccepted(input: RecordRelayOwnerNotificationAcceptedInput): QueueItem { + const current = this.getByIdOrThrow(input.qitemId); + const isUpdate = + current.humanIntent === "update" && + current.state !== "blocked" && + !current.blockedOn && + this.isPersonAddress(current.destinationSession); + const shouldCloseUpdate = isUpdate && input.closeUpdate !== false && isBlockerLive(current.state); + if (this.transitionLog.hasRelayOwnerNotificationReceipt(input.qitemId, input.notificationKey)) { + if (shouldCloseUpdate) { + return this.update({ + qitemId: input.qitemId, + actorSession: input.actorSession ?? "daemon@kernel", + state: "done", + closureReason: "no-follow-on", + }); + } + return current; + } + const note = formatRelayOwnerNotificationAccepted({ + ...input, + revision: input.revision ?? this.decisionRevision(current), + }); + return this.update({ + qitemId: input.qitemId, + actorSession: input.actorSession ?? "daemon@kernel", + ...(shouldCloseUpdate ? { state: "done", closureReason: "no-follow-on" } : {}), + transitionNote: note, + }); + } + currentHumanRequiredEpisode(qitemId: string): { key: string; receipt: ReturnType } | null { const episode = this.transitionLog.listForQitem(qitemId).filter((transition) => transition.ownerNotificationKind === "human-required").at(-1); if (!episode) return null; @@ -4160,7 +4319,7 @@ export class QueueRepository { return { ...item, deliveryOutcome: ledger?.outcome ?? null, - ...(ledger && ledger.outcome !== "posted" ? { deliveryFailureDetail: ledger.detail } : {}), + ...(ledger && ledger.outcome !== "posted" && ledger.outcome !== "accepted" ? { deliveryFailureDetail: ledger.detail } : {}), ...(item.replyTo ? { replyToFallback: this.replyToFallbackFor(item.qitemId) } : {}), }; } @@ -4313,7 +4472,7 @@ export class QueueRepository { return { ...item, deliveryOutcome: ledger?.outcome ?? null, - ...(ledger && ledger.outcome !== "posted" ? { deliveryFailureDetail: ledger.detail } : {}), + ...(ledger && ledger.outcome !== "posted" && ledger.outcome !== "accepted" ? { deliveryFailureDetail: ledger.detail } : {}), }; }); return opts?.compact ? items.map(compactRow) : items; @@ -4350,7 +4509,7 @@ export class QueueRepository { */ /** Delivered informational records remain queryable after closure. Receipt filtering * happens before LIMIT; no prose/tier classifier and no second event store. */ - listDeliveredHumanUpdates(opts: { limit?: number } = {}): Array { + listDeliveredHumanUpdates(opts: { limit?: number } = {}): Array { if (!this.hasHumanIntentColumn) return []; const limit = Number.isFinite(opts.limit) ? Math.max(1, Math.min(101, Math.floor(opts.limit!))) : 20; const receipts = `SELECT qitem_id, ts, transition_note FROM queue_transitions` + @@ -4359,10 +4518,20 @@ export class QueueRepository { SELECT q.*, r.ts AS delivered_at, r.transition_note AS delivery_receipt FROM queue_items q JOIN (${receipts}) r ON r.qitem_id = q.qitem_id WHERE q.human_intent = 'update' AND is_human_seat_session(q.destination_session) = 1 - AND r.transition_note LIKE 'slack-owner-notification-posted %' + AND (r.transition_note LIKE 'slack-owner-notification-posted %' OR r.transition_note LIKE 'relay-owner-notification-accepted %') ORDER BY r.ts DESC, q.qitem_id DESC LIMIT ? `).all(limit) as Array; - return rows.map((row) => ({ ...this.rowToItem(row), deliveredAt: row.delivered_at, deliveryReceipt: row.delivery_receipt })); + return rows.map((row) => { + const deliveryOutcome: "posted" | "accepted" = row.delivery_receipt.startsWith(`${RELAY_OWNER_NOTIFICATION_ACCEPTED_PREFIX} `) + ? "accepted" + : "posted"; + return { + ...this.rowToItem(row), + deliveryOutcome, + deliveredAt: row.delivered_at, + deliveryReceipt: row.delivery_receipt, + }; + }); } /** Current explicit human FYIs; delivery receipts are not required for local visibility. @@ -4578,7 +4747,9 @@ export class QueueRepository { OR EXISTS (SELECT 1 FROM queue_transitions receipt WHERE receipt.qitem_id = queue_items.qitem_id AND (receipt.transition_note LIKE 'slack-owner-notification-posted %' - OR receipt.transition_note LIKE 'slack-owner-notification-transport-failed %')))`, + OR receipt.transition_note LIKE 'relay-owner-notification-accepted %' + OR receipt.transition_note LIKE 'slack-owner-notification-transport-failed %' + OR receipt.transition_note LIKE 'relay-owner-notification-transport-failed %')))`, ]; const params: unknown[] = []; if (opts?.rig) { @@ -4593,7 +4764,7 @@ export class QueueRepository { for (const r of rows) { const item = this.rowToItem(r, !opts?.compact); const ledger = this.deliveryOutcomeFor(item.qitemId); - if (ledger?.outcome === "posted") continue; // the receipt wins, always + if (ledger?.outcome === "posted" || ledger?.outcome === "accepted") continue; // the receipt wins, always if (ledger?.outcome === "transport-failed") { out.push({ ...item, @@ -4761,23 +4932,34 @@ export class QueueRepository { * transitions are same-row but keyed by qitemId:OWNER-transitionId; an old * posted receipt cannot mask a later human park. Legacy pre-OWNER or literal * external rows retain their row-scoped fallback. */ - deliveryOutcomeFor(qitemId: string): { outcome: "posted" | "transport-failed" | "never-posted"; detail: string } | null { + deliveryOutcomeFor(qitemId: string): { outcome: "posted" | "accepted" | "transport-failed" | "never-posted"; detail: string } | null { const ledger = this.deliveryLedger(qitemId); return ledger ? { outcome: ledger.outcome, detail: ledger.detail } : null; } - /** #514 — true only when this row's CURRENT human-notification episode (OWNER history addressed - * to a registered human, aliases included) has a POSTED receipt. Legacy rows with no OWNER + /** OPR.0.5.6.14 + #514 — true when the row's CURRENT ask episode (create-time pending ask + * to a registered human, aliases included) has a POSTED or ACCEPTED receipt. Legacy rows with no OWNER * history, inactive episodes, never-posted and transport-failed all read false. Same derivation * as deliveryOutcomeFor; it reads row fields, never the waiting view. */ humanNotificationPostedThisEpisode(qitemId: string, listed?: ListedLedger): boolean { - if (!this.hasQueueTransitionsTable) return false; + return this.humanNotificationDeliveryThisEpisode(qitemId, listed) !== null; + } + + private humanNotificationDeliveryThisEpisode(qitemId: string, listed?: ListedLedger): "posted" | "accepted" | null { + if (!this.hasQueueTransitionsTable) return null; const owners = listed?.owners ?? this.transitionLog.ownerNotificationsForQitems([qitemId]).get(qitemId) ?? []; const owner = this.currentAskNotification(qitemId, owners); - if (!owner) return false; + if (!owner) return null; // The ledger's episode exists only when the destination resolves to a registered human (aliases included). const ledger = listed ? listed.ledger : this.deliveryLedger(qitemId, owners); - return ledger !== null && ledger.outcome === "posted" && ledger.episode?.notificationKey === `${qitemId}:${owner.transitionId}`; + if ( + ledger !== null && + (ledger.outcome === "posted" || ledger.outcome === "accepted") && + ledger.episode?.notificationKey === `${qitemId}:${owner.transitionId}` + ) { + return ledger.outcome; + } + return null; } /** The ask's own notification while it still binds its recipient. Only that notification does: create writes it in @@ -4860,7 +5042,9 @@ export class QueueRepository { this.receiptNotesStatement ??= this.db.prepare( `SELECT qitem_id, transition_note FROM queue_transitions WHERE qitem_id IN (SELECT value FROM json_each(?)) AND (transition_note LIKE 'slack-owner-notification-posted %' - OR transition_note LIKE 'slack-owner-notification-transport-failed %') + OR transition_note LIKE 'relay-owner-notification-accepted %' + OR transition_note LIKE 'slack-owner-notification-transport-failed %' + OR transition_note LIKE 'relay-owner-notification-transport-failed %') ORDER BY transition_id DESC`, ); const notes = new Map(); @@ -4879,7 +5063,13 @@ export class QueueRepository { : notes; const posted = currentNotes.find((note) => note.startsWith("slack-owner-notification-posted ")); if (posted) return { outcome: "posted", detail: posted, episode }; - const failed = currentNotes.find((note) => note.startsWith("slack-owner-notification-transport-failed ")); + const accepted = currentNotes.find((note) => note.startsWith("relay-owner-notification-accepted ")); + if (accepted) return { outcome: "accepted", detail: accepted, episode }; + const failed = currentNotes.find( + (note) => + note.startsWith("slack-owner-notification-transport-failed ") || + note.startsWith(`${RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX} `), + ); if (failed) return { outcome: "transport-failed", detail: failed, episode }; if (episode?.quiet) return null; const startedAt = episode?.startedAt ?? item.tsCreated; @@ -5177,7 +5367,8 @@ export class QueueRepository { if (view && ["pending", "in-progress"].includes(view.state)) { // #514 — the unclaimed sweep skips a pending ask already posted to its human in the current // episode (same predicate as runStuckSweep), so the view must not promise that sweep. - const posted = view.state === "pending" && this.humanNotificationPostedThisEpisode(qitemId, listed); + const episodeDelivery = view.state === "pending" ? this.humanNotificationDeliveryThisEpisode(qitemId, listed) : null; + const posted = episodeDelivery !== null; const onSteering = !posted && view.state === "pending" && this.humanAskWithoutAlert(qitemId, listed); const row = view.state === "pending" && !onSteering ? this.db.prepare(`SELECT state, ${this.hasHumanIntentColumn ? "human_intent" : "NULL AS human_intent"}, destination_session, last_nudge_result FROM queue_items WHERE qitem_id = ?`).get(qitemId) as { state: QueueItem["state"]; human_intent: QueueItem["humanIntent"]; destination_session: string; last_nudge_result: string | null } | undefined @@ -5188,13 +5379,14 @@ export class QueueRepository { && this.isLocalHumanDecisionWithoutGateway({ state: row.state, humanIntent: row.human_intent, destinationSession: row.destination_session, lastNudgeResult: row.last_nudge_result }); const awaitingHuman = posted || onSteering || localOnly; if (awaitingHuman || humanUpdate) { + const deliveryPhrase = episodeDelivery === "accepted" ? "accepted by the bridge" : "posted to the human"; view.nextBackstop = { owner: view.owner, mechanism: localOnly ? "local attention feed (no registered human gateway)" : onSteering ? "none (on Steering for the human, with no Slack alert; awaiting their decision)" : humanUpdate ? posted - ? "none (posted to the human; informational correspondence requires no claim)" + ? `none (${deliveryPhrase}; informational correspondence requires no claim)` : "none (informational correspondence to the human; no claim required)" - : "none (posted to the human; awaiting their decision)", dueAt: null, intervalSeconds: null }; + : `none (${deliveryPhrase}; awaiting their decision)`, dueAt: null, intervalSeconds: null }; } // Quiet hours: a held ask is owned by its window's release job until it posts. const quiet = awaitingHuman ? null : quietCustodyBackstop(view.owner, quietHoldCustody(this.db, qitemId)); @@ -5317,7 +5509,7 @@ export class QueueRepository { /** The row fields a delivery ledger reads, as rowToItem presents them. */ type LedgerItem = Pick; -type DeliveryLedger = { outcome: "posted" | "transport-failed" | "never-posted"; detail: string; episode: { notificationKey: string; startedAt: string } | null }; +type DeliveryLedger = { outcome: "posted" | "accepted" | "transport-failed" | "never-posted"; detail: string; episode: { notificationKey: string; startedAt: string } | null }; /** A listed row's owner notifications (newest first) and its delivery ledger, read once for the whole listing. */ type ListedLedger = { owners: QueueTransition[]; ledger: DeliveryLedger | null }; diff --git a/packages/daemon/src/domain/queue-transition-log.ts b/packages/daemon/src/domain/queue-transition-log.ts index a1a254511..76f1c2f70 100644 --- a/packages/daemon/src/domain/queue-transition-log.ts +++ b/packages/daemon/src/domain/queue-transition-log.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import type Database from "better-sqlite3"; import type { ClosureReason } from "./hot-potato-enforcer.js"; import { archiveWhereClause } from "./rig-repository.js"; @@ -6,6 +7,198 @@ import { isHumanSeatSessionRef, parseSessionName } from "./session-name.js"; export const OWNER_NOTIFICATION_LEVELS = ["RECORD", "NOTICE", "ALERT"] as const; export type OwnerNotificationLevel = (typeof OWNER_NOTIFICATION_LEVELS)[number]; +export const RELAY_OWNER_NOTIFICATION_ACCEPTED_PREFIX = "relay-owner-notification-accepted" as const; +export const RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX = "relay-owner-notification-transport-failed" as const; + +export interface RelayOwnerNotificationReceipt { + notificationKey: string; + deliveryId: string | null; + connectorRef: string | null; + replyRef: string | null; + replyRefHash: string | null; + revision: string | null; + episodeKey: string | null; + level: OwnerNotificationLevel | null; + kind: string | null; + digest: string | null; +} + +export interface FormatRelayOwnerNotificationAcceptedInput { + notificationKey: string; + deliveryId?: string | null; + connectorRef?: string | null; + replyRef?: string | null; + replyRefHash?: string | null; + revision?: string | null; + episodeKey?: string | null; + level?: OwnerNotificationLevel | string | null; + kind?: string | null; + digest?: string | null; +} + +export interface RelayOwnerNotificationTransportFailure { + notificationKey: string; + connectorRef: string | null; + failureClass: string; + error: string; +} + +export interface FormatRelayOwnerNotificationTransportFailedInput { + notificationKey: string; + connectorRef?: string | null; + failureClass: string; + detail: string; +} + +function encodeReceiptToken(value: string | null | undefined): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (trimmed.length === 0) return null; + return trimmed.replace(/%|[\s=]/g, (ch) => encodeURIComponent(ch)); +} + +function encodeConnectorRefToken(value: string | null | undefined): string | null { + if (typeof value !== "string" || value.length === 0) return null; + return encodeURIComponent(value); +} + +function decodeReceiptToken(value: string | null | undefined): string | null { + if (typeof value !== "string" || value.length === 0) return null; + try { + return decodeURIComponent(value); + } catch { + return null; + } +} + +export function formatRelayOwnerNotificationAccepted(input: FormatRelayOwnerNotificationAcceptedInput): string { + const notificationKey = typeof input.notificationKey === "string" ? input.notificationKey.trim() : ""; + if (!notificationKey || /\s/.test(notificationKey)) { + throw new Error("formatRelayOwnerNotificationAccepted requires a non-empty notificationKey"); + } + const deliveryId = encodeReceiptToken(input.deliveryId); + const connectorRef = encodeConnectorRefToken(input.connectorRef ?? null); + const rawReplyRef = typeof input.replyRef === "string" && input.replyRef.trim().length > 0 ? input.replyRef.trim() : null; + const replyRef = encodeReceiptToken(rawReplyRef); + const replyRefHash = + encodeReceiptToken(input.replyRefHash) ?? + (rawReplyRef ? createHash("sha256").update(rawReplyRef).digest("hex") : null); + const revision = encodeReceiptToken(input.revision); + const episodeKey = encodeReceiptToken(input.episodeKey); + const level = encodeReceiptToken(input.level); + const kind = encodeReceiptToken(input.kind); + const digest = encodeReceiptToken(input.digest); + + const tokens = [`${RELAY_OWNER_NOTIFICATION_ACCEPTED_PREFIX}`, `notification_key=${notificationKey}`]; + if (deliveryId) tokens.push(`delivery_id=${deliveryId}`); + if (connectorRef) tokens.push(`connector_ref=${connectorRef}`); + if (replyRef) tokens.push(`reply_ref=${replyRef}`); + if (replyRefHash) tokens.push(`reply_ref_hash=${replyRefHash}`); + if (revision) tokens.push(`revision=${revision}`); + if (episodeKey) tokens.push(`episode_key=${episodeKey}`); + if (level) tokens.push(`level=${level}`); + if (kind) tokens.push(`kind=${kind}`); + if (digest) tokens.push(`digest=${digest}`); + return tokens.join(" "); +} + +export function parseRelayOwnerNotificationAccepted( + note: string | null | undefined, +): RelayOwnerNotificationReceipt | null { + if (!note || !note.startsWith(`${RELAY_OWNER_NOTIFICATION_ACCEPTED_PREFIX} `)) return null; + const fields = note.trim().split(/\s+/); + const rawValue = (...names: string[]): string | null => { + for (const name of names) { + const prefix = `${name}=`; + const found = fields.find((field) => field.startsWith(prefix)); + if (found) { + const sliced = found.slice(prefix.length); + if (sliced.length > 0) return sliced; + } + } + return null; + }; + const decodedValue = (...names: string[]): string | null => decodeReceiptToken(rawValue(...names)); + const notificationKey = rawValue("notification_key"); + if (!notificationKey) return null; + const replyRef = decodedValue("reply_ref"); + const replyRefHash = + decodedValue("reply_ref_hash") ?? + (replyRef ? createHash("sha256").update(replyRef).digest("hex") : null); + const rawLevel = decodedValue("level"); + const level = + rawLevel && OWNER_NOTIFICATION_LEVELS.includes(rawLevel as OwnerNotificationLevel) + ? (rawLevel as OwnerNotificationLevel) + : null; + return { + notificationKey, + deliveryId: decodedValue("delivery_id"), + connectorRef: decodedValue("connector_ref"), + replyRef, + replyRefHash, + revision: decodedValue("revision"), + episodeKey: decodedValue("episode_key"), + level, + kind: decodedValue("kind"), + digest: decodedValue("digest"), + }; +} + +export function formatRelayOwnerNotificationTransportFailed( + input: FormatRelayOwnerNotificationTransportFailedInput, +): string { + const notificationKey = typeof input.notificationKey === "string" ? input.notificationKey.trim() : ""; + if (!notificationKey || /\s/.test(notificationKey)) { + throw new Error("formatRelayOwnerNotificationTransportFailed requires a non-empty notificationKey"); + } + const connectorRef = encodeConnectorRefToken(input.connectorRef ?? null); + const failureClass = encodeReceiptToken(input.failureClass); + if (!failureClass) { + throw new Error("formatRelayOwnerNotificationTransportFailed requires a non-empty failureClass"); + } + const detail = typeof input.detail === "string" ? input.detail.replace(/[\r\n]+/g, " ").trim() : ""; + const tokens = [ + `${RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX}`, + `notification_key=${notificationKey}`, + ]; + if (connectorRef) tokens.push(`connector_ref=${connectorRef}`); + tokens.push(`class=${failureClass}`); + tokens.push(`error=${detail}`); + return tokens.join(" "); +} + +export function parseRelayOwnerNotificationTransportFailed( + note: string | null | undefined, +): RelayOwnerNotificationTransportFailure | null { + if (!note || !note.startsWith(`${RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX} `)) return null; + const trimmed = note.trim(); + const fields = trimmed.split(/\s+/); + const rawValue = (...names: string[]): string | null => { + for (const name of names) { + const prefix = `${name}=`; + const found = fields.find((field) => field.startsWith(prefix)); + if (found) { + const sliced = found.slice(prefix.length); + if (sliced.length > 0) return sliced; + } + } + return null; + }; + const notificationKey = rawValue("notification_key"); + if (!notificationKey) return null; + const connectorRef = decodeReceiptToken(rawValue("connector_ref")); + const failureClass = decodeReceiptToken(rawValue("class")); + if (!failureClass) return null; + const errorIdx = trimmed.indexOf(" error="); + const error = errorIdx >= 0 ? trimmed.slice(errorIdx + " error=".length) : (rawValue("error") ?? ""); + return { + notificationKey, + connectorRef, + failureClass, + error, + }; +} + export function ownerNotificationLevelAtLeast( level: OwnerNotificationLevel, minimum: OwnerNotificationLevel, @@ -464,7 +657,14 @@ export class QueueTransitionLog { } hasOwnerNotificationReceipt(qitemId: string, notificationKey: string): boolean { - return this.getOwnerNotificationReceipt(qitemId, notificationKey) !== null; + return ( + this.getOwnerNotificationReceipt(qitemId, notificationKey) !== null || + this.getRelayOwnerNotificationReceipt(qitemId, notificationKey) !== null + ); + } + + hasRelayOwnerNotificationReceipt(qitemId: string, notificationKey: string): boolean { + return this.getRelayOwnerNotificationReceipt(qitemId, notificationKey) !== null; } /** The latest owner-notification episode per ACTIVE queue row, in queue order. One query @@ -476,7 +676,7 @@ export class QueueTransitionLog { * * The eligible set is restricted to active queue items before any ranking happens, so a * large notification archive never adds scan or ranking work to a sweep with no active item. */ - activeOwnerNotificationEpisodes(includePosted: boolean): Array<{ qitemId: string; transition: QueueTransition; posted: boolean }> { + activeOwnerNotificationEpisodes(includePosted = false): Array<{ qitemId: string; transition: QueueTransition; posted: boolean }> { if (!this.hasOwnerNotificationColumns) return []; // Exact token match with no wildcard risk: pad both ends so a key at the note's // start or end still matches, and instr() compares literally (qitem ids may @@ -485,7 +685,7 @@ export class QueueTransitionLog { `EXISTS ( SELECT 1 FROM ${this.historySource} r WHERE r.qitem_id = ${qitemIdExpr} - AND r.transition_note LIKE 'slack-owner-notification-posted %' + AND (r.transition_note LIKE 'slack-owner-notification-posted %' OR r.transition_note LIKE 'relay-owner-notification-accepted %') AND instr(' ' || r.transition_note || ' ', ' notification_key=' || ${qitemIdExpr} || ':' || ${transitionIdExpr} || ' ') > 0 )`; const postedFilter = includePosted ? "" : `AND NOT ${receipt("latest.qitem_id", "latest.transition_id")}`; @@ -540,6 +740,64 @@ export class QueueTransitionLog { return null; } + listRelayOwnerNotificationReceipts(qitemId: string): RelayOwnerNotificationReceipt[] { + const rows = this.db + .prepare( + `SELECT transition_id, transition_note FROM ${this.historySource} + WHERE qitem_id = ? AND transition_note LIKE 'relay-owner-notification-accepted %' + ORDER BY transition_id DESC`, + ) + .all(qitemId) as Array<{ transition_id: number; transition_note: string }>; + const receipts: RelayOwnerNotificationReceipt[] = []; + for (const row of rows) { + const parsed = parseRelayOwnerNotificationAccepted(row.transition_note); + if (parsed) receipts.push(parsed); + } + return receipts; + } + + getRelayOwnerNotificationReceipt(qitemId: string, notificationKey?: string): RelayOwnerNotificationReceipt | null { + for (const parsed of this.listRelayOwnerNotificationReceipts(qitemId)) { + if (notificationKey === undefined || parsed.notificationKey === notificationKey) { + return parsed; + } + } + return null; + } + + findRelayOwnerNotificationReceiptByReplyRef( + replyRef: string, + ): (RelayOwnerNotificationReceipt & { qitemId: string }) | null { + const trimmed = replyRef.trim(); + if (!trimmed) return null; + const fullHash = createHash("sha256").update(trimmed, "utf8").digest("hex"); + const rows = this.db + .prepare( + `SELECT qitem_id, transition_note FROM ${this.historySource} + WHERE transition_note LIKE 'relay-owner-notification-accepted %' + AND ( + instr(' ' || transition_note || ' ', ' reply_ref_hash=' || ? || ' ') > 0 + OR instr(' ' || transition_note || ' ', ' reply_ref=' || ? || ' ') > 0 + ) + ORDER BY transition_id DESC`, + ) + .all(fullHash, trimmed) as Array<{ qitem_id: string; transition_note: string }>; + for (const row of rows) { + const parsed = parseRelayOwnerNotificationAccepted(row.transition_note); + if (!parsed) continue; + if (parsed.replyRef !== null) { + if (parsed.replyRef === trimmed && parsed.replyRefHash === fullHash) { + return { qitemId: row.qitem_id, ...parsed }; + } + continue; + } + if (parsed.replyRefHash === fullHash) { + return { qitemId: row.qitem_id, ...parsed }; + } + } + return null; + } + private rowToTransition(row: QueueTransitionRow): QueueTransition { return { transitionId: row.transition_id, diff --git a/packages/daemon/src/domain/queue-wake-ladder.ts b/packages/daemon/src/domain/queue-wake-ladder.ts index e084ea7ce..d7ac8cf69 100644 --- a/packages/daemon/src/domain/queue-wake-ladder.ts +++ b/packages/daemon/src/domain/queue-wake-ladder.ts @@ -72,6 +72,10 @@ import { resolveOperatorSeat, resolveSessionNodeId, } from "./queue-stuck-sweep.js"; +import { + RELAY_OWNER_NOTIFICATION_ACCEPTED_PREFIX, + RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX, +} from "./queue-transition-log.js"; export const WAKE_RETRY_INTERVAL_KEY = "queue.wake_retry_interval_seconds"; export const DEFAULT_WAKE_RETRY_INTERVAL_SECONDS = 300; @@ -308,12 +312,16 @@ function readLadder(db: Database.Database, qitemId: string, after = lastMeaningf view.opOutcomeResolved = false; } // Outcome resolution (AM-F3, R1 B-3, R2 pre-marker discriminator): the S14 - // posted receipt or the termination record closes the episode ONLY when it + // posted receipt, relay-owner-notification-accepted receipt, or the termination record closes the episode ONLY when it // (a) follows the dispatch marker chronologically (this loop resets the // flag at each dispatch, so pre-marker notes never survive) and (b) carries // the dispatched key when the marker is keyed. An unkeyed dispatch // (injected legacy ports) keeps the any-following-note shape. - if (note.startsWith("slack-owner-notification-posted ") || note.startsWith("delivery-termination:")) { + if ( + note.startsWith("slack-owner-notification-posted ") || + note.startsWith(`${RELAY_OWNER_NOTIFICATION_ACCEPTED_PREFIX} `) || + note.startsWith("delivery-termination:") + ) { if (view.opEngineKey === null || note.split(/\s+/).includes(`notification_key=${view.opEngineKey}`)) { view.opOutcomeResolved = true; } @@ -357,6 +365,8 @@ function hasPickupEvidence(db: Database.Database, row: Pick h?.notificationKey === key); if (!hold) return null; const windowId = hold.windowId; - if (notes.some((n) => hasToken(n, "slack-owner-notification-posted", key) || hasToken(n, "quiet-release-disposition", key))) return null; + if (notes.some((n) => hasToken(n, "slack-owner-notification-posted", key) + || hasToken(n, "relay-owner-notification-accepted", key) + || hasToken(n, "quiet-release-disposition", key))) return null; const live = db.prepare("SELECT 1 FROM watchdog_jobs WHERE policy = 'quiet-release' AND state = 'active' AND spec_yaml LIKE ? LIMIT 1") .get(`%${releaseJobWindowSpec(windowId)}%`); // Without a live job nothing redrives a release or releases a hold, whichever step it reached: orphaned. @@ -55,11 +58,13 @@ export function quietHoldCustody(db: Database.Database, qitemId: string, now: Da if (hasToken(notes[i]!, "quiet-release-dispatched", key) || hasToken(notes[i]!, "quiet-release-returned", key)) { step = i; break; } } if (step >= 0 && notes[step]!.startsWith("quiet-release-dispatched ")) { - // A post failure for this notification with no receipt (a receipt or disposition ended custody above) is still + // A post or relay transport failure for this notification with no receipt (a Slack post receipt, relay bridge + // acceptance, or disposition ended custody above) is still // unresolved, even one recorded before this dispatch: the gateway records a failure once per notification, so a // failed re-dispatch adds none. That is the ledger's rule too: failed until a receipt. A release still in flight // after an earlier failure therefore reads as failed until its post lands. - const failed = notes.find((n) => hasToken(n, "slack-owner-notification-transport-failed", key)); + const failed = notes.find((n) => hasToken(n, "slack-owner-notification-transport-failed", key) + || hasToken(n, "relay-owner-notification-transport-failed", key)); if (failed) { const failure = failed.split(/\s+/).find((t) => t.startsWith("class="))?.slice("class=".length) ?? "unknown"; return { state: "release-failed", windowId, notificationKey: key, nextBoundary: null, failure }; diff --git a/packages/daemon/src/routes/gateway.ts b/packages/daemon/src/routes/gateway.ts index c9217cb76..7b73f26c0 100644 --- a/packages/daemon/src/routes/gateway.ts +++ b/packages/daemon/src/routes/gateway.ts @@ -10,6 +10,7 @@ import { connectionsProjection } from "../domain/gateway/connections-projection.js"; import type { SettingsStore } from "../domain/user-settings/settings-store.js"; import { Hono } from "hono"; +import { bodyLimit } from "hono/body-limit"; import { HTTPException } from "hono/http-exception"; import path from "node:path"; import type { QueueRepository } from "../domain/queue-repository.js"; @@ -18,11 +19,17 @@ import { SeenStore } from "../domain/gateway/slack/state-store.js"; import { makeQueuePorts, seedBacklogAsHistory } from "../domain/gateway/slack/queue-access.js"; import { postOpenAsks } from "../domain/gateway/slack/open-asks.js"; import { DispatchBuffer } from "../domain/gateway/dispatch-buffer.js"; -import { OPENRIG_HOME } from "../openrig-compat.js"; +import { OPENRIG_HOME, getOpenRigHome } from "../openrig-compat.js"; import { loadHumanRegistry } from "../domain/gateway/human-registry.js"; -import { participantsJsonView } from "../domain/gateway/participants.js"; +import { participantsJsonView, sharedChannelAddresses } from "../domain/gateway/participants.js"; +import { resolvePrimaryRelayBinding, type RelayPayloadCarry } from "../domain/gateway/relay-outbound.js"; import { resolveSecret } from "../domain/gateway/slack/secrets.js"; import { resolveHumanDeliveryReadiness, type HumanDeliveryReadiness } from "../domain/gateway/human-readiness.js"; +import { + RELAY_INBOUND_MAX_BODY_BYTES, + isDirectLoopbackPeer, + type RelayInboundHandler, +} from "../domain/gateway/relay-inbound.js"; import { requireSenderIdentity } from "./require-sender-identity.js"; import { runChannelOperation } from "../domain/gateway/channel-operations.js"; import { buildSlackAppManifest } from "../domain/gateway/slack/manifest.js"; @@ -49,6 +56,8 @@ export function gatewayRoutes(opts: { /** The daemon's auth bearer (OPENRIG_AUTH_BEARER_TOKEN, the one `rig host pair` issues); a relay from a * shared app's hub must carry it. Null: no bearer configured, so the route trusts the bind (loopback or tailnet). */ relayBearerToken?: string | null; + /** Concrete local relay inbound handler (`POST /api/gateway/relay/inbound`). */ + relayInboundHandler?: RelayInboundHandler; /** The clock quiet hours are resolved against (tests inject one). */ now?: () => Date; /** The Slack directory reads' fetch and secret environment (tests inject both). */ @@ -56,6 +65,58 @@ export function gatewayRoutes(opts: { } = {}): Hono { const app = new Hono(); + app.post( + "/relay/inbound", + async (c, next) => { + const remoteAddress = ( + c.env as { incoming?: { socket?: { remoteAddress?: string | null } } } | undefined + )?.incoming?.socket?.remoteAddress; + if (!isDirectLoopbackPeer(remoteAddress)) { + return c.json( + { + error: "loopback_peer_required", + message: "Relay inbound requests must originate from a direct local loopback TCP peer.", + }, + 403, + ); + } + + return next(); + }, + bodyLimit({ + maxSize: RELAY_INBOUND_MAX_BODY_BYTES, + onError: (c) => + c.json( + { + error: "payload_too_large", + message: `relay inbound payload exceeds ${RELAY_INBOUND_MAX_BODY_BYTES} bytes`, + }, + 413, + ), + }), + async (c) => { + const handler = + opts.relayInboundHandler ?? + (c.get("relayInboundHandler" as never) as RelayInboundHandler | undefined); + if (!handler) { + return c.json( + { + error: "relay_inbound_unavailable", + message: "Relay inbound handler is not configured on this daemon.", + }, + 503, + ); + } + + const rawBody = await c.req.text(); + const result = await handler.handle({ + authorizationHeader: c.req.header("Authorization") ?? c.req.header("authorization"), + rawBody, + }); + return c.json(result.body, result.status as never); + }, + ); + // A shared Slack app across machines: the hub relays an event or click for a channel this machine // owns, and it runs through this machine's own inbound router. 503 keeps it in the hub's retry. // Registered before /slack/:operation, which would otherwise answer it with 404. @@ -74,12 +135,12 @@ export function gatewayRoutes(opts: { const subsystem = c.get("gatewaySubsystem" as never) as SubsystemHandle | undefined; let status: Record | null = null; try { status = subsystem?.status() ?? null; } catch { /* unavailable is preserved */ } - return c.json(connectionsProjection(opts.home ?? OPENRIG_HOME, status, + return c.json(connectionsProjection(opts.home ?? getOpenRigHome(), status, c.get("settingsStore" as never) as SettingsStore | undefined)); }); app.get("/participants", (c) => { - const result = participantsJsonView(opts.home ?? OPENRIG_HOME, c.req.query("organization")); + const result = participantsJsonView(opts.home ?? getOpenRigHome(), c.req.query("organization")); return result.ok ? c.json(result) : c.json({ error: "human_registry_unavailable", message: result.error }, 503); }); @@ -131,14 +192,17 @@ export function gatewayRoutes(opts: { const readiness = await opts.readiness(entityId, gatewayState); return readiness ? c.json({ ok: true, readiness }) : c.json({ error: "human_not_found", entityId }, 404); } - const home = opts.home ?? OPENRIG_HOME; + const home = opts.home ?? getOpenRigHome(); const registry = loadHumanRegistry(home); if (!registry.ok) return c.json({ error: "human_registry_unavailable", message: registry.error }, 503); const human = registry.entities.find((candidate) => candidate.entityId === entityId); if (!human) return c.json({ error: "human_not_found", entityId }, 404); const cfg = loadConfig(home); const botToken = resolveSecret("SLACK_BOT_TOKEN", { envFile: cfg.secretsEnvFile ?? undefined }); - const readiness = await resolveHumanDeliveryReadiness({ human, config: cfg, gatewayState, botToken }); + const readiness = await resolveHumanDeliveryReadiness( + { human, config: cfg, gatewayState, botToken, home }, + { entities: registry.entities }, + ); return c.json({ ok: true, readiness }); }); @@ -370,10 +434,28 @@ export function gatewayRoutes(opts: { "Register a human with rig gateway human add, or repair and re-project the existing human registry before enabling delivery.", }, 503) }); const seen = new SeenStore(path.join(home, "state", "slack-outbound-seen.jsonl")); + const slackOnlyRegistry = { + ...registry, + entities: registry.entities.filter((entity) => + entity.connectorBindings.some((b) => b.role === "primary" && b.kind === "slack"), + ), + }; + const dispatchBuffer = new DispatchBuffer(home); value = await seedBacklogAsHistory({ - queue: makeQueuePorts(queueRepo!, { loadHumanRegistry: () => registry }), seen, + queue: makeQueuePorts(queueRepo!, { + loadHumanRegistry: () => slackOnlyRegistry, + sharedChannels: () => sharedChannelAddresses(home), + }), + seen, filter: { minimumLevel: cfg.minimumLevelThatPosts }, - buffer: new DispatchBuffer(home), + buffer: { + pending: () => + dispatchBuffer.pending().filter((d) => { + const p = (d.payload ?? {}) as RelayPayloadCarry; + if (p.relayConnectorRef || p.relayEnvelope) return false; + return resolvePrimaryRelayBinding(p.destinationSession ?? d.entityBindingRef, registry) === null; + }), + }, }); } else value.onlineStatus = "slack connector disabled"; saveConfig({ ...cfg, enabled }, home); diff --git a/packages/daemon/src/server.ts b/packages/daemon/src/server.ts index 5d5ed0111..7247d2312 100644 --- a/packages/daemon/src/server.ts +++ b/packages/daemon/src/server.ts @@ -248,6 +248,8 @@ export interface AppDeps { * Optional so test harnesses constructing AppDeps directly need not provide one; the health * route reports honestly when absent. */ gatewaySubsystem?: import("./domain/gateway/gateway-subsystem.js").GatewaySubsystem; + /** Concrete local relay inbound handler (`POST /api/gateway/relay/inbound`). */ + relayInboundHandler?: import("./domain/gateway/relay-inbound.js").RelayInboundHandler; periodicSnapshotScheduler?: import("./domain/periodic-snapshot-scheduler.js").PeriodicSnapshotScheduler; workflowRuntime?: WorkflowRuntime; /** @@ -577,6 +579,7 @@ export function createApp(deps: AppDeps): Hono { c.set("cmuxAdapter" as never, deps.cmuxAdapter); // S10 — the in-daemon gateway subsystem handle (health surface + dispatch seam). c.set("gatewaySubsystem" as never, deps.gatewaySubsystem); + c.set("relayInboundHandler" as never, deps.relayInboundHandler); // Slice 24 — per-rig CMUX workspace launcher wiring. c.set( "cmuxLayoutService" as never, @@ -901,7 +904,13 @@ export function createApp(deps: AppDeps): Hono { app.route("/api/health-diagnosis", healthDiagnosisRoutes()); // S10 — gateway subsystem admin (slack enable/disable with the seeding rule preserved). // A shared Slack app's relay carries the token `rig host pair` issued: this daemon's auth bearer. - app.route("/api/gateway", gatewayRoutes({ relayBearerToken: deps.missionControlBearerToken ?? null })); + app.route( + "/api/gateway", + gatewayRoutes({ + relayBearerToken: deps.missionControlBearerToken ?? null, + relayInboundHandler: deps.relayInboundHandler, + }), + ); app.route("/api/rigs/:rigId/env", envRoutes()); app.route("/api/restore-check", restoreCheckRoutes); // Slice 09 (OPR.0.3.2.9) — rig-policy bindings (operator context mode). diff --git a/packages/daemon/src/startup.ts b/packages/daemon/src/startup.ts index b64a5a099..fb2f68633 100644 --- a/packages/daemon/src/startup.ts +++ b/packages/daemon/src/startup.ts @@ -211,6 +211,7 @@ import { managedBlockRecords } from "./domain/managed-block-records.js"; import { getCompatibleOpenRigPath, getDefaultOpenRigPath, + getOpenRigHome, readOpenRigEnv, } from "./openrig-compat.js"; @@ -2764,18 +2765,28 @@ export async function createDaemon(opts?: DaemonOptions): Promise // no drivers) — `rig slack status` names what is missing. const { GatewaySubsystem } = await import("./domain/gateway/gateway-subsystem.js"); const { buildSlackGatewayWire, makeHumanReplyResolver } = await import("./domain/gateway/slack/slack-subsystem.js"); + const { createRelayInboundHandler } = await import("./domain/gateway/relay-inbound.js"); + const effectiveOpenRigHome = getOpenRigHome(); const gatewaySubsystem = new GatewaySubsystem({ - home: OPENRIG_HOME, + home: effectiveOpenRigHome, wire: () => buildSlackGatewayWire({ - home: OPENRIG_HOME, + home: effectiveOpenRigHome, queueRepo: queueRepoInstance, - resolveHumanReply: makeHumanReplyResolver(queueRepoInstance, deps.missionControlWriteContract, OPENRIG_HOME), + resolveHumanReply: makeHumanReplyResolver(queueRepoInstance, deps.missionControlWriteContract, effectiveOpenRigHome), log: (m) => console.log(`[gateway] ${m}`), }), log: (m) => console.log(`[gateway] ${m}`), }); gatewaySubsystem.start(); deps.gatewaySubsystem = gatewaySubsystem; + if (deps.missionControlWriteContract) { + deps.relayInboundHandler = createRelayInboundHandler({ + home: effectiveOpenRigHome, + queueRepo: queueRepoInstance, + missionControlWriteContract: deps.missionControlWriteContract, + missionControlActionLog: deps.missionControlActionLog, + }); + } // OPR.0.5.6.1 — bind the delivery policies' late gateway ref. lateGatewayDispatch.fn = (op, ref, payload, opts) => gatewaySubsystem.dispatch(op, ref, payload, opts); diff --git a/packages/daemon/test/archive-identity-provenance.test.ts b/packages/daemon/test/archive-identity-provenance.test.ts index b3a4f5920..dd28216e8 100644 --- a/packages/daemon/test/archive-identity-provenance.test.ts +++ b/packages/daemon/test/archive-identity-provenance.test.ts @@ -1,9 +1,15 @@ +import { createHash } from "node:crypto"; import { afterEach, describe, expect, it } from "vitest"; import { Hono } from "hono"; import { createDb } from "../src/db/connection.js"; import { migrate } from "../src/db/migrate.js"; import { ALL_MIGRATIONS } from "../src/db/all-migrations.js"; -import { QueueTransitionLog } from "../src/domain/queue-transition-log.js"; +import { + QueueTransitionLog, + formatRelayOwnerNotificationAccepted, + parseRelayOwnerNotificationAccepted, + parseRelayOwnerNotificationTransportFailed, +} from "../src/domain/queue-transition-log.js"; import { QueueRepository } from "../src/domain/queue-repository.js"; import { EventBus } from "../src/domain/event-bus.js"; import { queueRoutes } from "../src/routes/queue.js"; @@ -116,4 +122,226 @@ describe("archived audit identity — real migration, writer and history readers expect(db.prepare("SELECT * FROM queue_transitions").all()).toEqual(before); expect(db.prepare("SELECT * FROM queue_transitions_archive").all()).toEqual([]); }); + + it("records and reads relay owner notification acceptance separately from Slack posted receipts across live and archive", async () => { + const db = database(); + const repo = new QueueRepository(db, new EventBus(db), { + loadHumanRegistry: () => ({ + ok: true as const, + entities: [ + { + entityId: "human-founder", + class: "human" as const, + displayName: "Founder", + address: "human-founder@external", + connectorBindings: [ + { + kind: "slack" as const, + connectorRef: "primary", + secretsRef: "env:SLACK_BOT_TOKEN", + role: "primary" as const, + handle: "UFOUNDER", + }, + ], + prefs: { deliveryClass: "A" as const }, + }, + ], + }), + } as never); + const log = new QueueTransitionLog(db); + const ask = await repo.create({ + sourceSession: "sender@rig-a", + destinationSession: "human-founder@external", + body: "Relay ask", + summary: "Ask?", + nudge: false, + }); + const qitemId = ask.qitemId; + const episode = log.latestOwnerNotificationForQitem(ask.qitemId)!; + const key = `${ask.qitemId}:${episode.transitionId}`; + + expect(log.activeOwnerNotificationEpisodes(false).some((e) => e.qitemId === qitemId)).toBe(true); + + repo.recordRelayOwnerNotificationAccepted({ + qitemId, + notificationKey: key, + deliveryId: "del-1", + connectorRef: "tg-main", + replyRef: "tg:msg:99", + revision: "dr1-abc", + episodeKey: key, + level: "ALERT", + kind: "human-required", + digest: "sha256:111", + }); + + // Verify full 64-hex sha256 derivation when replyRefHash is not explicitly passed + const rawReplyRef = "tg:msg:99"; + const expectedFullSha256 = createHash("sha256").update(rawReplyRef).digest("hex"); + expect(expectedFullSha256).toHaveLength(64); + + expect(log.getOwnerNotificationReceipt(qitemId, key)).toBeNull(); + const expectedReceipt = { + notificationKey: key, + deliveryId: "del-1", + connectorRef: "tg-main", + replyRef: rawReplyRef, + replyRefHash: expectedFullSha256, + revision: "dr1-abc", + episodeKey: key, + level: "ALERT", + kind: "human-required", + digest: "sha256:111", + }; + expect(log.getRelayOwnerNotificationReceipt(qitemId, key)).toEqual(expectedReceipt); + expect(repo.getRelayOwnerNotificationReceipt(qitemId, key)).toEqual(expectedReceipt); + expect(log.findRelayOwnerNotificationReceiptByReplyRef(rawReplyRef)).toEqual({ + qitemId, + ...expectedReceipt, + }); + expect(repo.findRelayOwnerNotificationReceiptByReplyRef(rawReplyRef)).toEqual({ + qitemId, + ...expectedReceipt, + }); + expect(log.findRelayOwnerNotificationReceiptByReplyRef("tg:msg:unknown")).toBeNull(); + expect(log.hasRelayOwnerNotificationReceipt(qitemId, key)).toBe(true); + expect(log.hasRelayOwnerNotificationReceipt(qitemId, "other:key")).toBe(false); + expect(log.hasOwnerNotificationReceipt(qitemId, key)).toBe(true); + expect(log.activeOwnerNotificationEpisodes(false).some((e) => e.qitemId === qitemId)).toBe(false); + expect(log.activeOwnerNotificationEpisodes(true).find((e) => e.qitemId === qitemId)).toMatchObject({ + qitemId, + posted: true, + }); + const outcome = repo.deliveryOutcomeFor(qitemId); + expect(outcome).toEqual({ + outcome: "accepted", + detail: expect.stringContaining("relay-owner-notification-accepted"), + }); + expect(outcome.outcome).not.toBe("posted"); + expect(outcome.outcome).not.toBe("never-posted"); + + // Archive and verify readers still work across archive + db.prepare("UPDATE queue_items SET state = 'done', ts_updated = ? WHERE qitem_id = ?").run(OLD, qitemId); + db.prepare("UPDATE queue_transitions SET ts = ? WHERE qitem_id = ?").run(OLD, qitemId); + expect(archiveAgedTerminalTransitions(db, { nowIso: NOW }).archivedQitems).toBe(1); + expect(log.getOwnerNotificationReceipt(qitemId, key)).toBeNull(); + expect(log.getRelayOwnerNotificationReceipt(qitemId, key)).toEqual(expectedReceipt); + expect(log.findRelayOwnerNotificationReceiptByReplyRef(rawReplyRef)).toEqual({ + qitemId, + ...expectedReceipt, + }); + expect(repo.findRelayOwnerNotificationReceiptByReplyRef(rawReplyRef)).toEqual({ + qitemId, + ...expectedReceipt, + }); + expect(log.hasRelayOwnerNotificationReceipt(qitemId, key)).toBe(true); + expect(log.hasOwnerNotificationReceipt(qitemId, key)).toBe(true); + + // Lossless connectorRef round-trip across whitespace, underscore, and delimiter punctuation without altering literal notification_key + const distinctConnectorRefs = [ + "bridge one", + "bridge_one", + "bridge=one:two/three?four#five&six%25seven\teight", + ]; + for (const [idx, connectorRef] of distinctConnectorRefs.entries()) { + const distinctKey = `${qitemId}:distinct-${idx}`; + const distinctReplyRef = `replyRef_${idx}`; + const distinctFullHash = createHash("sha256").update(distinctReplyRef).digest("hex"); + const formatted = formatRelayOwnerNotificationAccepted({ + notificationKey: distinctKey, + deliveryId: `del-${idx}`, + connectorRef, + replyRef: distinctReplyRef, + revision: "dr1-abc", + episodeKey: distinctKey, + level: "ALERT", + kind: "human-required", + }); + // SQL activeOwnerNotificationEpisodes relies on literal ' notification_key=: ' + expect(` ${formatted} `).toContain(` notification_key=${distinctKey} `); + const parsed = parseRelayOwnerNotificationAccepted(formatted); + expect(parsed?.connectorRef).toBe(connectorRef); + expect(parsed?.notificationKey).toBe(distinctKey); + expect(parsed?.replyRef).toBe(distinctReplyRef); + expect(parsed?.replyRefHash).toBe(distinctFullHash); + expect(parsed?.replyRefHash).toHaveLength(64); + } + const noteWithSpaces = formatRelayOwnerNotificationAccepted({ + notificationKey: `${qitemId}:space`, + connectorRef: "bridge one", + }); + const noteWithUnderscore = formatRelayOwnerNotificationAccepted({ + notificationKey: `${qitemId}:space`, + connectorRef: "bridge_one", + }); + expect(noteWithSpaces).not.toBe(noteWithUnderscore); + expect(parseRelayOwnerNotificationAccepted(noteWithSpaces)?.connectorRef).toBe("bridge one"); + expect(parseRelayOwnerNotificationAccepted(noteWithUnderscore)?.connectorRef).toBe("bridge_one"); + + // Relay transport-failed note with connector_ref, dedupe, and queue-repository readers + const failedAsk = await repo.create({ + sourceSession: "sender@rig-a", + destinationSession: "human-founder@external", + body: "Relay ask that fails transport", + summary: "Fail ask?", + nudge: false, + }); + const failedEpisode = log.latestOwnerNotificationForQitem(failedAsk.qitemId)!; + const failedKey = `${failedAsk.qitemId}:${failedEpisode.transitionId}`; + repo.recordRelayOwnerNotificationTransportFailed({ + qitemId: failedAsk.qitemId, + notificationKey: failedKey, + connectorRef: "tg-main", + failureClass: "relay-http-503", + detail: "bridge unavailable", + }); + const countAfterFirstFailure = repo.listTransitions(failedAsk.qitemId).length; + // Duplicate failure for the same episode and same connector is deduplicated + repo.recordRelayOwnerNotificationTransportFailed({ + qitemId: failedAsk.qitemId, + notificationKey: failedKey, + connectorRef: "tg-main", + failureClass: "relay-http-503", + detail: "bridge unavailable", + }); + expect(repo.listTransitions(failedAsk.qitemId)).toHaveLength(countAfterFirstFailure); + expect(repo.deliveryOutcomeFor(failedAsk.qitemId)).toEqual({ + outcome: "transport-failed", + detail: `relay-owner-notification-transport-failed notification_key=${failedKey} connector_ref=tg-main class=relay-http-503 error=bridge unavailable`, + }); + expect(repo.getById(failedAsk.qitemId)).toMatchObject({ + deliveryOutcome: "transport-failed", + deliveryFailureDetail: expect.stringContaining("relay-owner-notification-transport-failed"), + }); + expect(repo.findUndelivered().find((q) => q.qitemId === failedAsk.qitemId)).toMatchObject({ + qitemId: failedAsk.qitemId, + deliveryOutcome: "transport-failed", + deliveryFailureClass: "transport-failed", + deliveryFailureDetail: expect.stringContaining("connector_ref=tg-main"), + }); + + // Failure under a changed relay connectorRef must record a new note naming the new connector rather than suppressing + repo.recordRelayOwnerNotificationTransportFailed({ + qitemId: failedAsk.qitemId, + notificationKey: failedKey, + connectorRef: "wa-backup", + failureClass: "relay-http-502", + detail: "backup bridge bad gateway", + }); + expect(repo.listTransitions(failedAsk.qitemId)).toHaveLength(countAfterFirstFailure + 1); + const parsedFailures = repo + .listTransitions(failedAsk.qitemId) + .map((t) => parseRelayOwnerNotificationTransportFailed(t.transitionNote)) + .filter((f): f is NonNullable => f !== null); + expect(parsedFailures.at(-1)).toMatchObject({ + notificationKey: failedKey, + connectorRef: "wa-backup", + failureClass: "relay-http-502", + error: "backup bridge bad gateway", + }); + expect(repo.deliveryOutcomeFor(failedAsk.qitemId)).toEqual({ + outcome: "transport-failed", + detail: `relay-owner-notification-transport-failed notification_key=${failedKey} connector_ref=wa-backup class=relay-http-502 error=backup bridge bad gateway`, + }); + }); }); diff --git a/packages/daemon/test/complete-human-notifications.test.ts b/packages/daemon/test/complete-human-notifications.test.ts index dd985c95f..eda0b233d 100644 --- a/packages/daemon/test/complete-human-notifications.test.ts +++ b/packages/daemon/test/complete-human-notifications.test.ts @@ -188,11 +188,29 @@ describe("complete human notifications", () => { expect(await (await app.request("/api/queue/list?attention=1")).json()).toEqual([]); }); - it("keeps failed FYIs pending and out of delivered history", async () => { + it("keeps failed FYIs pending and out of delivered history for both Slack and Relay failure notes", async () => { const item = await repo.create({ ...request, humanIntent: "update" }); expect(item.state).toBe("pending"); expect(repo.listDeliveredHumanUpdates()).toEqual([]); - repo.update({ qitemId: item.qitemId, actorSession: "daemon@kernel", transitionNote: "slack-owner-notification-transport-failed notification_key=x class=transport error=synthetic" }); - expect(repo.getById(item.qitemId)?.state).toBe("pending"); expect(repo.listDeliveredHumanUpdates()).toEqual([]); + const slackKey = `${item.qitemId}:${repo.transitionLog.latestOwnerNotificationForQitem(item.qitemId)!.transitionId}`; + repo.update({ qitemId: item.qitemId, actorSession: "daemon@kernel", transitionNote: `slack-owner-notification-transport-failed notification_key=${slackKey} class=transport error=synthetic` }); + expect(repo.getById(item.qitemId)).toMatchObject({ state: "pending", deliveryOutcome: "transport-failed" }); + expect(repo.listDeliveredHumanUpdates()).toEqual([]); + + const relayItem = await repo.create({ ...request, humanIntent: "update", summary: "Relay FYI" }); + const relayKey = `${relayItem.qitemId}:${repo.transitionLog.latestOwnerNotificationForQitem(relayItem.qitemId)!.transitionId}`; + repo.update({ + qitemId: relayItem.qitemId, + actorSession: "daemon@kernel", + transitionNote: `relay-owner-notification-transport-failed notification_key=${relayKey} connector_ref=relay-1 class=relay-http-502 error=bad-gateway`, + }); + expect(repo.getById(relayItem.qitemId)).toMatchObject({ + state: "pending", + deliveryOutcome: "transport-failed", + deliveryFailureDetail: expect.stringContaining("relay-owner-notification-transport-failed"), + }); + expect(repo.getById(relayItem.qitemId)?.deliveryFailureDetail).toContain("connector_ref=relay-1"); + expect(repo.findUndelivered().map((q) => q.qitemId)).toContain(relayItem.qitemId); + expect(repo.listDeliveredHumanUpdates()).toEqual([]); }); it("keeps digest timing but dispatches each human request/update completely with stable identity", async () => { @@ -210,4 +228,120 @@ describe("complete human notifications", () => { await runDeliveryDigestFlush(input); expect((dispatch.mock.calls.slice(2) as unknown as typeof first).map((call) => call[3].decisionId)).toEqual(ids); }); + + it("closes informational updates on relay acceptance while keeping decision asks and blocked work open", async () => { + const update = await repo.create({ ...request, humanIntent: "update", body: "FYI update via relay" }); + const ask = await repo.create({ + ...request, + humanIntent: "decision", + body: "Decision ask via relay", + humanQuestions: [ + { + id: "q1", + question: "Approve phase 1?", + options: [ + { id: "yes", label: "Yes" }, + { id: "no", label: "No" }, + ], + }, + { + id: "q2", + question: "Approve phase 2?", + options: [ + { id: "yes", label: "Yes" }, + { id: "no", label: "No" }, + ], + }, + ], + }); + expect( + repo.recordHumanAnswer({ + qitemId: ask.qitemId, + actorSession: "human-founder@external", + questionId: "q1", + optionId: "yes", + }), + ).toMatchObject({ status: "recorded", complete: false }); + + const blockedWork = await repo.create({ + ...request, + destinationSession: "worker@rig", + body: "Agent work parked on human decision", + }); + repo.update({ + qitemId: blockedWork.qitemId, + actorSession: "worker@rig", + state: "blocked", + blockedOn: "human-founder@external", + summary: "Blocked on founder", + evidenceRef: "/private/retained-proof.md", + transitionNote: "parked on founder", + }); + + repo.recordRelayOwnerNotificationAccepted({ + qitemId: update.qitemId, + notificationKey: `${update.qitemId}:1`, + deliveryId: "d-update", + connectorRef: "relay-1", + level: "NOTICE", + kind: "human-update", + }); + const transitionsAfterFirstUpdateReceipt = repo.listTransitions(update.qitemId).length; + + // Idempotent duplicate receipt does not append another transition + repo.recordRelayOwnerNotificationAccepted({ + qitemId: update.qitemId, + notificationKey: `${update.qitemId}:1`, + deliveryId: "d-update", + connectorRef: "relay-1", + level: "NOTICE", + kind: "human-update", + }); + expect(repo.listTransitions(update.qitemId)).toHaveLength(transitionsAfterFirstUpdateReceipt); + + expect(repo.getById(update.qitemId)).toMatchObject({ + state: "done", + closureReason: "no-follow-on", + }); + const delivered = repo.listDeliveredHumanUpdates({ limit: 10 }); + const deliveredUpdate = delivered.find((item) => item.qitemId === update.qitemId); + expect(deliveredUpdate).toBeDefined(); + expect(deliveredUpdate?.deliveryReceipt.startsWith("relay-owner-notification-accepted ")).toBe(true); + expect(deliveredUpdate?.deliveryOutcome).toBe("accepted"); + + // Caller humanIntent override cannot close a decision ask or alter recorded answers + repo.recordRelayOwnerNotificationAccepted({ + qitemId: ask.qitemId, + notificationKey: `${ask.qitemId}:2`, + deliveryId: "d-ask", + connectorRef: "relay-1", + replyRef: "ref-ask", + level: "ALERT", + kind: "human-required", + humanIntent: "update", + }); + + expect(repo.getById(ask.qitemId)).toMatchObject({ + state: "pending", + humanAnswers: { q1: "yes" }, + }); + + // Notice acceptance with caller humanIntent="update" on blocked agent work must NEVER close or unpark it + repo.recordRelayOwnerNotificationAccepted({ + qitemId: blockedWork.qitemId, + notificationKey: `${blockedWork.qitemId}:3`, + deliveryId: "d-blocked-notice", + connectorRef: "relay-1", + level: "NOTICE", + kind: "human-update", + humanIntent: "update", + }); + + expect(repo.getById(blockedWork.qitemId)).toMatchObject({ + state: "blocked", + blockedOn: "human-founder@external", + humanAnswers: null, + }); + expect(repo.hasRelayOwnerNotificationReceipt(blockedWork.qitemId, `${blockedWork.qitemId}:3`)).toBe(true); + }); }); diff --git a/packages/daemon/test/decision-revision.test.ts b/packages/daemon/test/decision-revision.test.ts index 8c6d59fde..75825d6d8 100644 --- a/packages/daemon/test/decision-revision.test.ts +++ b/packages/daemon/test/decision-revision.test.ts @@ -64,15 +64,16 @@ function fixture() { }; const revision = async (id: string) => (await (await app.request(`/api/queue/${id}`)).json() as { decisionRevision?: unknown }).decisionRevision; const act = async (body: Record) => { + const actor = (body.actorSession as string | undefined) ?? PERSON; const response = await app.request("/api/mission-control/action", { method: "POST", - headers: { "Content-Type": "application/json", "X-OpenRig-Session": PERSON }, body: JSON.stringify({ actorSession: PERSON, ...body }) }); + headers: { "Content-Type": "application/json", "X-OpenRig-Session": actor }, body: JSON.stringify({ actorSession: actor, ...body }) }); return { status: response.status, body: await response.json() as Record }; }; const count = (sql: string, id: string) => (db.prepare(sql).get(id) as { n: number }).n; const written = (id: string) => ({ row: repo.getById(id), transitions: repo.listTransitions(id).length, replies: count("SELECT count(*) n FROM queue_items WHERE reply_to = ?", id), actions: count("SELECT count(*) n FROM mission_control_actions WHERE qitem_id = ?", id) }); - return { db, repo, ask, park, permissionAsk, revision, act, written }; + return { db, repo, contract, ask, park, permissionAsk, revision, act, written }; } describe("an opt-in decision revision on the settling verbs (#2446)", () => { @@ -199,6 +200,188 @@ describe("an opt-in decision revision on the settling verbs (#2446)", () => { expect(await f.revision(id)).toBe(read); expect(await f.act({ verb: "approve", qitemId: id, expectedRevision: read })).toMatchObject({ status: 200, body: { closedQitem: { state: "done" } } }); }); + + it("repairs a direct-answer partial commit on retry without duplicating the reply row, then reconciles subsequent retries", async () => { + const f = fixture(); + const row = await f.repo.create({ sourceSession: ASKER, destinationSession: PERSON, body: "Ship the relay?", summary: "Ship?", humanIntent: "decision", nudge: false }); + const id = row.qitemId; + const read = await f.revision(id); + + const original = f.repo.createHumanAnswer.bind(f.repo); + vi.spyOn(f.repo, "createHumanAnswer").mockImplementationOnce(async input => { + await original(input); + throw new Error("simulated crash after answer row commit before audit"); + }); + + await expect( + f.contract.act({ + verb: "answer", + qitemId: id, + actorSession: PERSON, + decision: "Ship it now", + expectedRevision: read as string, + auditNotes: { operationId: "relay-op-1" }, + }), + ).rejects.toThrow("simulated crash after answer row commit before audit"); + + expect(f.written(id)).toMatchObject({ replies: 1, actions: 0, row: { state: "pending" } }); + expect(await f.revision(id)).not.toBe(read); + + const retry = await f.act({ + verb: "answer", + qitemId: id, + decision: "Ship it now", + expectedRevision: read, + auditNotes: { operationId: "relay-op-1" }, + }); + expect(retry.status).toBe(200); + expect(f.written(id)).toMatchObject({ replies: 1, actions: 1, row: { state: "done" } }); + + const replyRow = f.db.prepare("SELECT tags FROM queue_items WHERE reply_to = ?").get(id) as { tags: string }; + const tags = JSON.parse(replyRow.tags) as string[]; + expect(tags.some(t => t.startsWith("mc-operation:"))).toBe(true); + + const third = await f.act({ + verb: "answer", + qitemId: id, + decision: "Ship it now", + expectedRevision: read, + auditNotes: { operationId: "relay-op-1" }, + }); + expect(third).toMatchObject({ status: 200, body: { reconciled: true } }); + expect(f.written(id)).toMatchObject({ replies: 1, actions: 1 }); + }); + + it("recovers from a crash between ask close and audit record with one answer, one close transition, and one audit row", async () => { + const f = fixture(); + const id = await f.ask(); + const read = await f.revision(id); + + // Fault injected at audit record time (after resolveDirectHumanReply inside the atomic close+audit transaction) + const actionLog = (f.contract as unknown as { actionLog: MissionControlActionLog }).actionLog; + const origRecord = actionLog.record.bind(actionLog); + let stateAtAuditCall: string | undefined; + vi.spyOn(actionLog, "record").mockImplementationOnce((entry) => { + stateAtAuditCall = f.repo.getById(id)?.state; + throw new Error("simulated crash after ask close before audit"); + return origRecord(entry); + }); + + await expect( + f.contract.act({ + verb: "answer", + qitemId: id, + actorSession: PERSON, + decision: "Ship Option A", + expectedRevision: read as string, + auditNotes: { operationId: "relay-close-audit-op-1" }, + }), + ).rejects.toThrow("simulated crash after ask close before audit"); + + expect(stateAtAuditCall).toBe("done"); + // Atomic rollback of close+audit leaves the ask pending with only the 1 tagged reply row + expect(f.written(id)).toMatchObject({ replies: 1, actions: 0, row: { state: "pending" } }); + + // Conflicting payload or different operationId under the original revision still rejects (409) + expect( + await f.act({ + verb: "answer", + qitemId: id, + decision: "Different words", + expectedRevision: read, + auditNotes: { operationId: "relay-close-audit-op-1" }, + }), + ).toMatchObject({ status: 409, body: { error: "decision_revision_changed" } }); + + const retry = await f.act({ + verb: "answer", + qitemId: id, + decision: "Ship Option A", + expectedRevision: read, + auditNotes: { operationId: "relay-close-audit-op-1" }, + }); + expect(retry.status).toBe(200); + expect(f.written(id)).toMatchObject({ replies: 1, actions: 1, row: { state: "done" } }); + const resolvedTransitions = f.repo + .listTransitions(id) + .filter((t) => t.ownerNotificationKind === "human-decision-resolved"); + expect(resolvedTransitions).toHaveLength(1); + }); + + it("enforces guarded retry non-bypass invariants across different payloads, stale edits, and cross-responder attempts", async () => { + const f = fixture(); + const row1 = await f.repo.create({ sourceSession: ASKER, destinationSession: PERSON, body: "Ship the relay?", summary: "Ship?", humanIntent: "decision", nudge: false }); + const id1 = row1.qitemId; + const read1 = await f.revision(id1); + + const original = f.repo.createHumanAnswer.bind(f.repo); + vi.spyOn(f.repo, "createHumanAnswer").mockImplementationOnce(async input => { + await original(input); + throw new Error("simulated crash after answer row commit before audit"); + }); + + await expect( + f.contract.act({ + verb: "answer", + qitemId: id1, + actorSession: PERSON, + decision: "Yes", + expectedRevision: read1 as string, + auditNotes: { operationId: "relay-op-2" }, + }), + ).rejects.toThrow("simulated crash after answer row commit before audit"); + + expect(await f.act({ verb: "answer", qitemId: id1, decision: "Yes", expectedRevision: read1, auditNotes: { operationId: "relay-op-other" } })) + .toMatchObject({ status: 409, body: { error: "decision_revision_changed" } }); + expect(await f.act({ verb: "answer", qitemId: id1, decision: "No", expectedRevision: read1, auditNotes: { operationId: "relay-op-2" } })) + .toMatchObject({ status: 409, body: { error: "decision_revision_changed" } }); + expect(await f.act({ verb: "answer", qitemId: id1, decision: "Yes", expectedRevision: read1 })) + .toMatchObject({ status: 409, body: { error: "decision_revision_changed" } }); + expect(await f.act({ verb: "answer", qitemId: id1, actorSession: OTHER, decision: "Yes", expectedRevision: read1, auditNotes: { operationId: "relay-op-2" } })) + .toMatchObject({ status: 409, body: { error: "decision_revision_changed" } }); + + // Stale edit during partial commit + const row2 = await f.repo.create({ sourceSession: ASKER, destinationSession: PERSON, body: "Initial ask text", summary: "Ship?", humanIntent: "decision", nudge: false }); + const id2 = row2.qitemId; + const read2 = await f.revision(id2); + + vi.spyOn(f.repo, "createHumanAnswer").mockImplementationOnce(async input => { + await original(input); + throw new Error("simulated crash after answer row commit before audit"); + }); + + await expect( + f.contract.act({ + verb: "answer", + qitemId: id2, + actorSession: PERSON, + decision: "Proceed", + expectedRevision: read2 as string, + auditNotes: { operationId: "relay-op-3" }, + }), + ).rejects.toThrow("simulated crash after answer row commit before audit"); + + f.db.prepare("UPDATE queue_items SET body = 'Edited ask text' WHERE qitem_id = ?").run(id2); + + expect(await f.act({ verb: "answer", qitemId: id2, decision: "Proceed", expectedRevision: read2, auditNotes: { operationId: "relay-op-3" } })) + .toMatchObject({ status: 409, body: { error: "decision_revision_changed" } }); + }); + + it("keeps resolve operation fingerprint retry intact and rejects a new park episode under an old revision", async () => { + const f = fixture(); + const id = await f.park(); + const read = await f.revision(id); + + const first = await f.act({ verb: "resolve", qitemId: id, decision: "Unblocked", expectedRevision: read, auditNotes: { operationId: "resolve-op-1" } }); + expect(first.status).toBe(200); + + const retry = await f.act({ verb: "resolve", qitemId: id, decision: "Unblocked", expectedRevision: read, auditNotes: { operationId: "resolve-op-1" } }); + expect(retry).toMatchObject({ status: 200, body: { reconciled: true } }); + + f.repo.update({ qitemId: id, actorSession: "owner@test-rig", state: "blocked", blockedOn: PERSON }); + const staleNewEpisode = await f.act({ verb: "resolve", qitemId: id, decision: "Unblocked", expectedRevision: read, auditNotes: { operationId: "resolve-op-2" } }); + expect(staleNewEpisode).toMatchObject({ status: 409, body: { error: "decision_revision_changed" } }); + }); }); describe("the route carries a client's revision to the write that compares it (#2446)", () => { diff --git a/packages/daemon/test/delivery-rules-engine.test.ts b/packages/daemon/test/delivery-rules-engine.test.ts index e823866b9..9259d8143 100644 --- a/packages/daemon/test/delivery-rules-engine.test.ts +++ b/packages/daemon/test/delivery-rules-engine.test.ts @@ -10,7 +10,7 @@ // Every section below fails at base for exactly those reasons. import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import Database from "better-sqlite3"; @@ -1193,4 +1193,141 @@ describe("OPR.0.5.6.1 §8 — the production composition is live (R2 B-1/B-2/B-3 wire.stop(); } }); + + it("recognizes relay owner acceptance on operator escalations as outcome accepted (never posted) without closing underlying work", async () => { + const mod = await operatorEngineModule(); + expect(mod).not.toBeNull(); + + const src = await repo.create({ sourceSession: "sender@r", destinationSession: "relay@r", body: "obligation" }); + const { created } = await repo.handoff({ qitemId: src.qitemId, fromSession: "relay@r", toSession: "worker@r", nudge: false }); + db.prepare("UPDATE queue_items SET last_nudge_attempt = ?, last_nudge_result = ? WHERE qitem_id = ?") + .run(new Date(Date.now() - 10 * 60_000).toISOString(), "failed:tmux session not found", created.qitemId); + + const relayEnvPath = join(home, "relay-op.env"); + writeFileSync( + relayEnvPath, + [ + "OPENRIG_RELAY_URL=http://127.0.0.1:43117/outbound", + "OPENRIG_RELAY_OUTBOUND_TOKEN=out-secret-token", + "OPENRIG_RELAY_INBOUND_TOKEN=in-secret-token", + ].join("\n") + "\n", + { mode: 0o600 }, + ); + + const relayRegistry = { + ok: true as const, + entities: [ + { + entityId: "human-founder", + class: "human" as const, + displayName: "Founder", + address: "human-founder@external", + connectorBindings: [ + { + kind: "relay" as const, + connectorRef: "relay-op", + secretsRef: `file:${relayEnvPath}`, + role: "primary" as const, + handle: "founder_relay", + }, + ], + prefs: { deliveryClass: "B" as const, availability: "available" as const }, + }, + ], + }; + + const make = (mod as { + makeOperatorDeliveryEngine: (deps: unknown) => { + dispatchEscalation: (row: QueueItem, reason: string) => Promise<{ decision: string; resolved: boolean; dispatched?: boolean; notificationKey?: string }>; + hasAcceptedEscalation: (row: QueueItem) => boolean; + }; + }).makeOperatorDeliveryEngine; + + // Cross-entity provisioning conflict must be caught by default readiness via { entities: reg.entities } + const conflictingRegistry = { + ok: true as const, + entities: [ + ...relayRegistry.entities, + { + entityId: "human-second", + class: "human" as const, + displayName: "Second", + address: "human-second@external", + connectorBindings: [ + { + kind: "relay" as const, + connectorRef: "relay-op", + secretsRef: `file:${join(home, "other-relay.env")}`, + role: "secondary" as const, + handle: "second_relay", + }, + ], + prefs: { deliveryClass: "B" as const, availability: "available" as const }, + }, + ], + }; + mkdirSync(join(home, "gateway"), { recursive: true }); + writeFileSync( + join(home, "gateway", "participants.yaml"), + "version: 1\ndefault: human-founder\norganizations: {}\ngroups: {}\nparticipants: {}\n", + ); + + const conflictingPort = make({ + home, + gatewayState: () => "active", + queueRepo: repo, + dispatch: () => ({ ok: true, decisionId: "d-conflict" }), + registry: { loadHumanRegistry: () => conflictingRegistry }, + }); + const conflictResult = await conflictingPort.dispatchEscalation(created, "wake failed"); + expect(conflictResult).toMatchObject({ + dispatched: false, + resolved: false, + decision: expect.stringMatching(/^unavailable:human-delivery:/), + }); + + // Default readiness with provisioned relay profile (no custom readiness stub, no Slack config/network probe) + const port = make({ + home, + gatewayState: () => "active", + queueRepo: repo, + dispatch: () => ({ ok: true, decisionId: "d-relay" }), + registry: { loadHumanRegistry: () => relayRegistry }, + }); + + const first = await port.dispatchEscalation(created, "wake failed"); + expect(first).toMatchObject({ decision: "interrupt", dispatched: true, resolved: false }); + expect(first.notificationKey).toBeTruthy(); + + // Hypothetical non-shipped alternative formats must NOT resolve the escalation + repo.update({ + qitemId: created.qitemId, + actorSession: "daemon@kernel", + transitionNote: `relay_accepted notification_key=${first.notificationKey!} outcome=relay_accepted`, + }); + expect(port.hasAcceptedEscalation(created)).toBe(false); + + // Real relay-owner-notification-accepted evidence resolves as "accepted" (never "posted") without closing work + repo.recordRelayOwnerNotificationAccepted({ + qitemId: created.qitemId, + notificationKey: first.notificationKey!, + deliveryId: "del-op-1", + connectorRef: "relay-op", + level: "ALERT", + kind: "human-required", + }); + + expect(port.hasAcceptedEscalation(created)).toBe(true); + + const second = await port.dispatchEscalation(created, "wake failed"); + expect(second).toMatchObject({ + decision: "accepted", + resolved: true, + dispatched: true, + notificationKey: first.notificationKey, + }); + expect(second.decision).toBe("accepted"); + expect(second.decision).not.toBe("posted"); + expect(repo.getById(created.qitemId)?.state).toBe("pending"); + }); }); diff --git a/packages/daemon/test/fixtures/recording-relay.ts b/packages/daemon/test/fixtures/recording-relay.ts new file mode 100644 index 000000000..5f617ab9b --- /dev/null +++ b/packages/daemon/test/fixtures/recording-relay.ts @@ -0,0 +1,601 @@ +/** + * Test-only recording local HTTP bridge fixture (`recording-relay.ts`). + * + * NOTE: This is a local test recording bridge for verifying OpenRig's loopback + * HTTP relay contract in integration tests and the private environment validator. + * It is NOT an installed or production messaging platform backend. + * + * Strictly zero `vitest` runtime imports so standalone `tsx` scripts can reuse it. + */ + +import { createHash, timingSafeEqual } from "node:crypto"; +import { + closeSync, + existsSync, + fsyncSync, + mkdirSync, + openSync, + readFileSync, + writeSync, +} from "node:fs"; +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from "node:http"; +import type { AddressInfo } from "node:net"; +import { dirname } from "node:path"; +import type { + RelayDigestItem, + RelayOutboundEnvelope, +} from "../../src/domain/gateway/relay-outbound.js"; +import type { RelayInboundOrdering } from "../../src/domain/gateway/relay-inbound.js"; + +export type { RelayDigestItem, RelayOutboundEnvelope, RelayInboundOrdering }; + +export interface BridgeCustodyRecord { + deliveryId: string; + payloadSha256: string; + rawBytesSha256: string; + receivedAt: string; + envelope: RelayOutboundEnvelope; +} + +export interface BridgeSanitizedStats { + relayPostCount: number; + acceptedCount: number; + deduplicatedCount: number; + conflictCount: number; + unauthorizedCount: number; + lostAckCount: number; + ignoredProbeCount: number; + custodyCount: number; + custodyDigestSha256: string; +} + +export interface RelayInboundReplyInput { + eventId: string; + sender: { + handle: string; + }; + replyRef: string; + text: string; + ordering?: RelayInboundOrdering; +} + +export interface SendInboundReplyOptions { + /** Override Bearer token (`null` omits the Authorization header). */ + tokenOverride?: string | null; +} + +export interface SendInboundReplyResult { + status: number; + body: Record; +} + +export interface RecordingRelayOptions { + /** Expected Bearer token on `POST /outbound` from OpenRig. Held in memory only. */ + outboundToken: string; + /** Bearer token sent on `POST /api/gateway/relay/inbound` to OpenRig. Held in memory only. */ + inboundToken: string; + /** Path to durable JSONL custody store file. */ + custodyFilePath: string; + /** Optional TCP port (defaults to 0 for OS-assigned loopback port). */ + port?: number; +} + +export interface RecordingRelayHandle { + readonly baseUrl: string; + readonly outboundUrl: string; + readonly port: number; + readonly custodyFilePath: string; + setLoseNextAck(lose: boolean): void; + getCustodyRecords(): BridgeCustodyRecord[]; + getSanitizedStats(): BridgeSanitizedStats; + restart(): Promise; + stop(): Promise; + sendInboundReply( + daemonBaseUrl: string, + payload: RelayInboundReplyInput, + opts?: SendInboundReplyOptions, + ): Promise; +} + +function canonicalJsonStringify(value: unknown): string { + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => canonicalJsonStringify(item)).join(",")}]`; + } + const obj = value as Record; + const keys = Object.keys(obj) + .filter((k) => obj[k] !== undefined) + .sort(); + return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJsonStringify(obj[k])}`).join(",")}}`; +} + +export function computeCanonicalSha256(value: unknown): string { + return createHash("sha256") + .update(canonicalJsonStringify(value), "utf8") + .digest("hex"); +} + +function constantTimeBearerCheck( + authHeader: string | undefined, + expectedToken: string, +): boolean { + if (!authHeader || typeof authHeader !== "string") return false; + const match = /^Bearer\s+(\S+)$/.exec(authHeader.trim()); + if (!match?.[1] || !expectedToken) return false; + const providedBuf = Buffer.from(match[1], "utf8"); + const expectedBuf = Buffer.from(expectedToken, "utf8"); + if (providedBuf.length !== expectedBuf.length) { + timingSafeEqual(expectedBuf, expectedBuf); + return false; + } + return timingSafeEqual(providedBuf, expectedBuf); +} + +function isPlainObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +const ALLOWED_ENVELOPE_KEYS = new Set([ + "v", + "deliveryId", + "recipient", + "intent", + "outcome", + "summary", + "body", + "replyRef", + "items", +]); + +const ALLOWED_ITEM_KEYS = new Set([ + "qitemId", + "notificationKey", + "summary", + "body", + "replyRef", +]); + +function validateOutboundEnvelope( + parsed: unknown, +): { ok: true; envelope: RelayOutboundEnvelope } | { ok: false; error: string } { + if (!isPlainObject(parsed)) { + return { ok: false, error: "Envelope must be a JSON object" }; + } + for (const k of Object.keys(parsed)) { + if (!ALLOWED_ENVELOPE_KEYS.has(k)) { + return { ok: false, error: `Unknown envelope field: ${k}` }; + } + } + if (parsed.v !== 1) { + return { ok: false, error: "Expected protocol version v: 1" }; + } + if (typeof parsed.deliveryId !== "string" || !parsed.deliveryId.trim()) { + return { ok: false, error: "Missing or empty deliveryId" }; + } + if ( + !isPlainObject(parsed.recipient) || + typeof parsed.recipient.handle !== "string" || + !parsed.recipient.handle.trim() || + Object.keys(parsed.recipient).some((k) => k !== "handle") + ) { + return { ok: false, error: "Invalid recipient.handle" }; + } + if (parsed.intent !== "decision" && parsed.intent !== "update") { + return { ok: false, error: "Invalid intent (expected decision | update)" }; + } + if ( + parsed.outcome !== "interrupt" && + parsed.outcome !== "notify" && + parsed.outcome !== "digest" + ) { + return { ok: false, error: "Invalid outcome (expected interrupt | notify | digest)" }; + } + if (typeof parsed.summary !== "string" || typeof parsed.body !== "string") { + return { ok: false, error: "Missing summary or body string" }; + } + if ( + parsed.replyRef !== null && + (typeof parsed.replyRef !== "string" || !parsed.replyRef.trim()) + ) { + return { ok: false, error: "Invalid replyRef (expected string | null)" }; + } + + let items: RelayDigestItem[] | undefined; + if (parsed.items !== undefined) { + if (!Array.isArray(parsed.items)) { + return { ok: false, error: "Invalid items array" }; + } + items = []; + for (const item of parsed.items) { + if (!isPlainObject(item)) { + return { ok: false, error: "Digest item must be an object" }; + } + for (const k of Object.keys(item)) { + if (!ALLOWED_ITEM_KEYS.has(k)) { + return { ok: false, error: `Unknown digest item field: ${k}` }; + } + } + if ( + typeof item.qitemId !== "string" || + typeof item.notificationKey !== "string" || + typeof item.summary !== "string" || + (item.body !== undefined && typeof item.body !== "string") || + (item.replyRef !== null && typeof item.replyRef !== "string") + ) { + return { ok: false, error: "Malformed digest item fields" }; + } + items.push({ + qitemId: item.qitemId, + notificationKey: item.notificationKey, + summary: item.summary, + ...(item.body !== undefined ? { body: item.body } : {}), + replyRef: item.replyRef, + }); + } + } + + return { + ok: true, + envelope: { + v: 1, + deliveryId: parsed.deliveryId, + recipient: { handle: parsed.recipient.handle }, + intent: parsed.intent, + outcome: parsed.outcome, + summary: parsed.summary, + body: parsed.body, + replyRef: parsed.replyRef, + ...(items !== undefined ? { items } : {}), + }, + }; +} + +function loadCustodyRecordsStrict(custodyFilePath: string): BridgeCustodyRecord[] { + if (!existsSync(custodyFilePath)) return []; + const raw = readFileSync(custodyFilePath, "utf8"); + if (!raw.trim()) return []; + const lines = raw.split("\n").filter((l) => l.trim().length > 0); + return lines.map((line, idx) => { + const parsed = JSON.parse(line) as unknown; + if ( + !isPlainObject(parsed) || + typeof parsed.deliveryId !== "string" || + typeof parsed.payloadSha256 !== "string" || + typeof parsed.rawBytesSha256 !== "string" || + typeof parsed.receivedAt !== "string" || + !isPlainObject(parsed.envelope) + ) { + throw new Error(`Invalid custody record at line ${idx + 1}`); + } + return { + deliveryId: parsed.deliveryId, + payloadSha256: parsed.payloadSha256, + rawBytesSha256: parsed.rawBytesSha256, + receivedAt: parsed.receivedAt, + envelope: parsed.envelope as unknown as RelayOutboundEnvelope, + }; + }); +} + +function appendCustodyRecordDurable( + custodyFilePath: string, + record: BridgeCustodyRecord, +): void { + mkdirSync(dirname(custodyFilePath), { recursive: true }); + const fd = openSync(custodyFilePath, "a", 0o600); + try { + writeSync(fd, `${JSON.stringify(record)}\n`, undefined, "utf8"); + fsyncSync(fd); + } finally { + closeSync(fd); + } +} + +function readBody( + req: IncomingMessage, +): Promise<{ ok: true; text: string } | { ok: false; status: number; error: string }> { + return new Promise((resolve) => { + const chunks: Buffer[] = []; + let settled = false; + req.on("data", (chunk: Buffer | string) => { + if (settled) return; + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, "utf8"); + chunks.push(buf); + }); + req.on("end", () => { + if (!settled) { + settled = true; + resolve({ ok: true, text: Buffer.concat(chunks).toString("utf8") }); + } + }); + req.on("error", () => { + if (!settled) { + settled = true; + resolve({ ok: false, status: 400, error: "stream_error" }); + } + }); + }); +} + +function sendJson( + res: ServerResponse, + status: number, + payload: Record, +): void { + const body = JSON.stringify(payload); + res.writeHead(status, { + "content-type": "application/json; charset=utf-8", + "content-length": String(Buffer.byteLength(body, "utf8")), + }); + res.end(body); +} + +export async function startRecordingRelay( + opts: RecordingRelayOptions, +): Promise { + // Secrets remain in closure memory only — never written to custody log or stats + const expectedOutboundToken = opts.outboundToken; + const defaultInboundToken = opts.inboundToken; + const custodyFilePath = opts.custodyFilePath; + const host = "127.0.0.1"; + + let loseNextAck = false; + let relayPostCount = 0; + let acceptedCount = 0; + let deduplicatedCount = 0; + let conflictCount = 0; + let unauthorizedCount = 0; + let lostAckCount = 0; + let ignoredProbeCount = 0; + + let server: Server | null = null; + let boundPort = opts.port ?? 0; + + const handleRequest = async ( + req: IncomingMessage, + res: ServerResponse, + ): Promise => { + const method = (req.method ?? "GET").toUpperCase(); + const urlPath = (req.url ?? "/").split("?")[0]; + + // Ignore non-relay probes (such as host GET / checks) without counting as relay POSTs + if (method !== "POST" || urlPath !== "/outbound") { + ignoredProbeCount += 1; + sendJson(res, 404, { ok: false, error: "not_found" }); + return; + } + + relayPostCount += 1; + + const authHeader = + typeof req.headers.authorization === "string" + ? req.headers.authorization + : undefined; + if (!constantTimeBearerCheck(authHeader, expectedOutboundToken)) { + unauthorizedCount += 1; + sendJson(res, 401, { ok: false, error: "unauthorized" }); + return; + } + + const bodyResult = await readBody(req); + if (!bodyResult.ok) { + sendJson(res, bodyResult.status, { ok: false, error: bodyResult.error }); + return; + } + + const rawBytesSha256 = createHash("sha256") + .update(bodyResult.text, "utf8") + .digest("hex"); + + let parsedJson: unknown; + try { + parsedJson = JSON.parse(bodyResult.text); + } catch { + sendJson(res, 400, { ok: false, error: "invalid_json" }); + return; + } + + const validation = validateOutboundEnvelope(parsedJson); + if (!validation.ok) { + sendJson(res, 400, { ok: false, error: validation.error }); + return; + } + + const envelope = validation.envelope; + const payloadSha256 = computeCanonicalSha256(envelope); + + let existingRecords: BridgeCustodyRecord[]; + try { + existingRecords = loadCustodyRecordsStrict(custodyFilePath); + } catch { + sendJson(res, 503, { ok: false, error: "custody_store_corrupted" }); + return; + } + + const existing = existingRecords.find( + (r) => r.deliveryId === envelope.deliveryId, + ); + if (existing) { + if (existing.payloadSha256 === payloadSha256) { + deduplicatedCount += 1; + sendJson(res, 200, { + ok: true, + deliveryId: envelope.deliveryId, + deduplicated: true, + }); + return; + } + conflictCount += 1; + sendJson(res, 409, { + ok: false, + error: "delivery_id_payload_conflict", + deliveryId: envelope.deliveryId, + }); + return; + } + + // Durable custody commit + fsync BEFORE returning 2xx (or simulating lost ACK) + const record: BridgeCustodyRecord = { + deliveryId: envelope.deliveryId, + payloadSha256, + rawBytesSha256, + receivedAt: new Date().toISOString(), + envelope, + }; + + try { + appendCustodyRecordDurable(custodyFilePath, record); + } catch { + sendJson(res, 503, { ok: false, error: "custody_write_failed" }); + return; + } + + acceptedCount += 1; + + if (loseNextAck) { + loseNextAck = false; + lostAckCount += 1; + sendJson(res, 502, { + ok: false, + error: "simulated_lost_ack_after_durable_commit", + }); + return; + } + + sendJson(res, 200, { + ok: true, + deliveryId: envelope.deliveryId, + deduplicated: false, + }); + }; + + const listenServer = async (portToBind: number): Promise => { + const srv = createServer((req, res) => { + void handleRequest(req, res); + }); + await new Promise((resolve, reject) => { + srv.once("error", reject); + srv.listen(portToBind, host, () => { + srv.removeListener("error", reject); + resolve(); + }); + }); + boundPort = (srv.address() as AddressInfo).port; + server = srv; + }; + + const closeServer = async (): Promise => { + const srv = server; + server = null; + if (!srv) return; + await new Promise((resolve, reject) => { + srv.close((err) => (err ? reject(err) : resolve())); + }); + }; + + await listenServer(boundPort); + + return { + get baseUrl(): string { + return `http://${host}:${boundPort}`; + }, + get outboundUrl(): string { + return `http://${host}:${boundPort}/outbound`; + }, + get port(): number { + return boundPort; + }, + get custodyFilePath(): string { + return custodyFilePath; + }, + setLoseNextAck(lose: boolean): void { + loseNextAck = lose; + }, + getCustodyRecords(): BridgeCustodyRecord[] { + return loadCustodyRecordsStrict(custodyFilePath); + }, + getSanitizedStats(): BridgeSanitizedStats { + let records: BridgeCustodyRecord[] = []; + try { + records = loadCustodyRecordsStrict(custodyFilePath); + } catch { + records = []; + } + const digestInput = records + .map((r) => `${r.deliveryId}:${r.payloadSha256}`) + .join("\n"); + const custodyDigestSha256 = createHash("sha256") + .update(digestInput, "utf8") + .digest("hex"); + + return { + relayPostCount, + acceptedCount, + deduplicatedCount, + conflictCount, + unauthorizedCount, + lostAckCount, + ignoredProbeCount, + custodyCount: records.length, + custodyDigestSha256, + }; + }, + async restart(): Promise { + const portToRebind = boundPort; + await closeServer(); + await listenServer(portToRebind); + }, + async stop(): Promise { + await closeServer(); + }, + async sendInboundReply( + daemonBaseUrl: string, + payload: RelayInboundReplyInput, + replyOpts?: SendInboundReplyOptions, + ): Promise { + const targetUrl = `${daemonBaseUrl.replace(/\/+$/, "")}/api/gateway/relay/inbound`; + const headers: Record = { + "content-type": "application/json", + }; + const tokenToUse = + replyOpts && "tokenOverride" in replyOpts + ? replyOpts.tokenOverride + : defaultInboundToken; + if (tokenToUse !== null && tokenToUse !== undefined) { + headers.authorization = `Bearer ${tokenToUse}`; + } + + const wirePayload = JSON.stringify({ + eventId: payload.eventId, + sender: { handle: payload.sender.handle }, + replyRef: payload.replyRef, + text: payload.text, + ordering: payload.ordering ?? "after-question", + }); + + const response = await fetch(targetUrl, { + method: "POST", + headers, + body: wirePayload, + }); + + const rawText = await response.text(); + let parsedBody: Record = {}; + try { + const json = JSON.parse(rawText) as unknown; + if (isPlainObject(json)) parsedBody = json; + } catch { + parsedBody = { rawText }; + } + + return { status: response.status, body: parsedBody }; + }, + }; +} diff --git a/packages/daemon/test/gateway-channel-lifecycle.test.ts b/packages/daemon/test/gateway-channel-lifecycle.test.ts index 6608ed935..02d4c6718 100644 --- a/packages/daemon/test/gateway-channel-lifecycle.test.ts +++ b/packages/daemon/test/gateway-channel-lifecycle.test.ts @@ -12,6 +12,9 @@ import { EventBus } from "../src/domain/event-bus.js"; import { QueueRepository } from "../src/domain/queue-repository.js"; import { SeenStore } from "../src/domain/gateway/slack/state-store.js"; import { addHumanFragment, writeProjection } from "../src/domain/gateway/human-registry.js"; +import { setSharedChannel } from "../src/domain/gateway/participants.js"; +import { DispatchBuffer } from "../src/domain/gateway/dispatch-buffer.js"; +import { OUTBOUND_OP } from "../src/domain/gateway/slack/outbound-driver.js"; const homes: string[] = []; const databases: ReturnType[] = []; @@ -35,9 +38,9 @@ function fixture(opts: { registerHuman?: boolean } = {}) { const queueRepo = new QueueRepository(db, new EventBus(db)); const prepare = vi.spyOn(db, "prepare"); const selectionCount = () => prepare.mock.calls.filter(([sql]) => /SELECT qitem_id FROM queue_items/.test(sql)).length; - const alert = (id: string) => { + const alert = (id: string, destination = "alex@external") => { db.prepare(`INSERT INTO queue_items(qitem_id,ts_created,ts_updated,source_session,destination_session,state,body) - VALUES (?, '2026-01-01', '2026-01-01', 'author@rig', 'alex@external', 'pending', 'fixture backlog')`).run(id); + VALUES (?, '2026-01-01', '2026-01-01', 'author@rig', ?, 'pending', 'fixture backlog')`).run(id, destination); const result = db.prepare(`INSERT INTO queue_transitions(qitem_id,ts,state,actor_session,owner_notification_level,owner_notification_kind) VALUES (?, '2026-01-01', 'pending', 'author@rig', 'ALERT', 'human-required')`).run(id); return `${id}:${result.lastInsertRowid}`; @@ -168,4 +171,85 @@ describe("human channel lifecycle at the daemon door", () => { expect(loadConfig(f.home).enabled).toBe(false); expect(f.restart).not.toHaveBeenCalled(); }); + + it("seeds only Slack-primary humans and Slack shared channels on enable without seeding Relay-primary episodes or claiming Relay buffer entries as Slack posts", async () => { + const f = fixture(); + expect( + addHumanFragment( + { + entityId: "sam", + class: "human", + displayName: "Sam", + address: "sam@external", + connectorBindings: [ + { + kind: "relay", + connectorRef: "wa-main", + secretsRef: `file:${join(f.home, "relay.env")}`, + role: "primary", + handle: "sam_wa", + }, + ], + prefs: { deliveryClass: "B" }, + }, + f.home, + ).ok, + ).toBe(true); + expect( + setSharedChannel( + "ops-room", + { + displayName: "Ops Room", + channel: { + connector: "slack", + connectorRef: "main", + secretsRef: "env:private-pointer", + channelId: "COPSROOM", + }, + }, + {}, + f.home, + ).ok, + ).toBe(true); + + const slackKey = f.alert("slack-backlog", "alex@external"); + const channelKey = f.alert("channel-backlog", "ops-room@external"); + const relayKey = f.alert("relay-backlog", "sam@external"); + + const buffer = new DispatchBuffer(f.home); + buffer.enqueue({ + kind: "outbound_decision", + decisionId: "relay-pending-1", + op: OUTBOUND_OP, + entityBindingRef: "sam@external", + payload: { + qitemId: "relay-backlog", + notificationKey: relayKey, + destinationSession: "sam@external", + relayConnectorRef: "wa-main", + }, + }); + buffer.enqueue({ + kind: "outbound_decision", + decisionId: "slack-pending-1", + op: OUTBOUND_OP, + entityBindingRef: "alex@external", + payload: { + qitemId: "earlier-slack", + notificationKey: "earlier-slack:1", + destinationSession: "alex@external", + }, + }); + + const response = await f.post("enable"); + expect(response.status).toBe(200); + const body = (await response.json()) as { ok: boolean; seeded: number; onlineStatus: string }; + expect(body.seeded).toBe(2); + expect(f.seen().sort()).toEqual([channelKey, slackKey].sort()); + expect(f.seen()).not.toContain(relayKey); + // Scoped buffer read view counts only the 1 Slack decision, never claiming the Relay decision + expect(body.onlineStatus).toContain("1 earlier undelivered post(s) wait in the replay buffer"); + // Both durable buffer entries remain intact on disk + expect(buffer.pending().map((d) => d.decisionId)).toEqual(["relay-pending-1", "slack-pending-1"]); + }); }); diff --git a/packages/daemon/test/gateway-human-handle.test.ts b/packages/daemon/test/gateway-human-handle.test.ts index 632fffc56..5d53c9416 100644 --- a/packages/daemon/test/gateway-human-handle.test.ts +++ b/packages/daemon/test/gateway-human-handle.test.ts @@ -8,6 +8,7 @@ import { addHumanFragment, projectHumans, resolveSlackHandle, + resolveRelayHandle, humansDir, type HumanFragment, } from "../src/domain/gateway/human-registry.js"; @@ -31,6 +32,9 @@ function frag(entityId: string, bindings: Record[]): Record = {}): Record => ({ kind: "slack", connectorRef: "slack-main", secretsRef: "vault://slack/x", role: "primary", ...over, }); +const relay = (over: Record = {}): Record => ({ + kind: "relay", connectorRef: "relay-main", secretsRef: "file:/etc/openrig/relay.env", role: "primary", ...over, +}); describe("A6 v3 handle schema", () => { it("accepts an optional well-formed handle; a handle-less binding is still valid (outbound-only)", () => { @@ -57,6 +61,21 @@ describe("A6 v3 handle schema", () => { expect(r.ok).toBe(false); if (!r.ok) expect(r.error).toMatch(/duplicate .*handle|unique per kind/i); }); + + it("relay handle within-fragment uniqueness is scoped to connectorRef", () => { + const dupSameRef = validateHumanFragment(frag("mike", [ + relay({ connectorRef: "relay-a", handle: "alice", role: "primary" }), + relay({ connectorRef: "relay-a", handle: "alice", role: "secondary" }), + ])); + expect(dupSameRef.ok).toBe(false); + if (!dupSameRef.ok) expect(dupSameRef.error).toMatch(/duplicate .*handle/i); + + const diffRefs = validateHumanFragment(frag("mike", [ + relay({ connectorRef: "relay-a", handle: "alice", role: "primary" }), + relay({ connectorRef: "relay-b", handle: "alice", role: "secondary" }), + ])); + expect(diffRefs.ok).toBe(true); + }); }); describe("A6 v3 handle uniqueness across humans (pin-1 registry-level)", () => { @@ -88,6 +107,40 @@ describe("A6 v3 handle uniqueness across humans (pin-1 registry-level)", () => { const r = addHumanFragment(frag("mike", [slack({ handle: "U1", connectorRef: "slack-main" })]), home, { replace: true }); expect(r.ok).toBe(true); }); + + it("relay handle uniqueness across humans is scoped by connectorRef (same ref collides, different ref allowed)", () => { + expect(addHumanFragment(frag("mike", [relay({ connectorRef: "relay-a", handle: "op" })]), home).ok).toBe(true); + const collision = addHumanFragment(frag("dana", [relay({ connectorRef: "relay-a", handle: "op" })]), home); + expect(collision.ok).toBe(false); + if (!collision.ok) expect(collision.error).toMatch(/already registered to human "mike"|registration conflict/i); + + const differentRef = addHumanFragment(frag("dana", [relay({ connectorRef: "relay-b", handle: "op" })]), home); + expect(differentRef.ok).toBe(true); + }); + + it("handles relay connectorRef containing colons unambiguously without false collision", () => { + // If colon-concatenation were used ("relay:" + connectorRef + ":" + handle), + // ("net:east", "worker") and ("net", "east:worker") could collide if handles permitted colons. + // Even with handle restricted to HANDLE_PATTERN, distinct connectorRefs like "bridge:1" vs "bridge" + // with handles "2" and "1:2" would be ambiguous if handles had colons. + // With unambiguous JSON tuple [kind, connectorRef, handle], different connectorRefs with colons never falsely collide. + expect(addHumanFragment(frag("mike", [relay({ connectorRef: "region:us-east:primary", handle: "admin" })]), home).ok).toBe(true); + const otherRef = addHumanFragment(frag("dana", [relay({ connectorRef: "region:us-east:secondary", handle: "admin" })]), home); + expect(otherRef.ok).toBe(true); + + const sameColonRefCollision = addHumanFragment(frag("alex", [relay({ connectorRef: "region:us-east:primary", handle: "admin" })]), home); + expect(sameColonRefCollision.ok).toBe(false); + }); + + it("projectHumans refuses two fragments colliding on the same relay connectorRef+handle", () => { + const dir = humansDir(home); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "mike.yaml"), stringifyYaml(frag("mike", [relay({ connectorRef: "relay-a", handle: "op" })]))); + writeFileSync(join(dir, "dana.yaml"), stringifyYaml(frag("dana", [relay({ connectorRef: "relay-a", handle: "op" })]))); + const p = projectHumans(home); + expect(p.ok).toBe(false); + if (!p.ok) expect(p.error).toMatch(/claimed by both|registration conflict/i); + }); }); describe("A6 v3 resolveSlackHandle (pins 2+3)", () => { @@ -116,3 +169,47 @@ describe("A6 v3 resolveSlackHandle (pins 2+3)", () => { expect(r.kind).toBe("unregistered"); }); }); + +describe("resolveRelayHandle (scoped to connectorRef + handle)", () => { + const mike = (validateHumanFragment(frag("mike", [relay({ connectorRef: "relay-a", handle: "founder" })])) as { ok: true; fragment: HumanFragment }).fragment; + const dana = (validateHumanFragment(frag("dana", [relay({ connectorRef: "relay-a", handle: "partner" })])) as { ok: true; fragment: HumanFragment }).fragment; + const otherRelay = (validateHumanFragment(frag("alex", [relay({ connectorRef: "relay-b", handle: "founder" })])) as { ok: true; fragment: HumanFragment }).fragment; + const outboundRelay = (validateHumanFragment(frag("outbound", [relay({ connectorRef: "relay-a" })])) as { ok: true; fragment: HumanFragment }).fragment; + const slackOnly = (validateHumanFragment(frag("slackperson", [slack({ connectorRef: "relay-a", handle: "slack-alias" })])) as { ok: true; fragment: HumanFragment }).fragment; + const all = [mike, dana, otherRelay, outboundRelay, slackOnly]; + + it("resolves a registered relay connectorRef+handle to the exact person on that relay", () => { + expect(resolveRelayHandle("relay-a", "founder", all)).toMatchObject({ + kind: "registered", + entityId: "mike", + address: "mike@external", + connectorRef: "relay-a", + handle: "founder", + }); + expect(resolveRelayHandle("relay-a", "partner", all)).toMatchObject({ + kind: "registered", + entityId: "dana", + address: "dana@external", + connectorRef: "relay-a", + handle: "partner", + }); + expect(resolveRelayHandle("relay-b", "founder", all)).toMatchObject({ + kind: "registered", + entityId: "alex", + address: "alex@external", + connectorRef: "relay-b", + handle: "founder", + }); + }); + + it("rejects cross-profile handle assertions, slack handles, and handle-less relay bindings", () => { + const wrongProfile = resolveRelayHandle("relay-b", "partner", all); + expect(wrongProfile.kind).toBe("unregistered"); + if (wrongProfile.kind === "unregistered") { + expect(wrongProfile.error).toMatch(/not a registered human/i); + expect(wrongProfile.error).toMatch(/NOT landed as a fabricated human seat/i); + } + expect(resolveRelayHandle("relay-a", "slack-alias", all).kind).toBe("unregistered"); + expect(resolveRelayHandle("relay-a", "relay-a", all).kind).toBe("unregistered"); + }); +}); diff --git a/packages/daemon/test/gateway-human-readiness.test.ts b/packages/daemon/test/gateway-human-readiness.test.ts index 060b53c08..2242b3feb 100644 --- a/packages/daemon/test/gateway-human-readiness.test.ts +++ b/packages/daemon/test/gateway-human-readiness.test.ts @@ -1,8 +1,16 @@ import { describe, expect, it, vi } from "vitest"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; import { Hono } from "hono"; import { resolveHumanDeliveryReadiness } from "../src/domain/gateway/human-readiness.js"; +import { loadRelayProfiles } from "../src/domain/gateway/relay-config.js"; import { gatewayRoutes } from "../src/routes/gateway.js"; -import type { HumanFragment } from "../src/domain/gateway/human-registry.js"; +import { + addHumanFragment, + projectionPath, + type HumanFragment, +} from "../src/domain/gateway/human-registry.js"; import type { SlackConnectorConfig } from "../src/domain/gateway/slack/config.js"; const human: HumanFragment = { @@ -149,3 +157,236 @@ describe("#192 readiness checks every channel the connector posts to", () => { expect(await run({ ok: false, isMember: false, error: "boom" })).toEqual({ asked: ["C1"], state: "indeterminate", reason: "channel membership verification unavailable: boom" }); }); }); + +describe("relay primary-binding readiness", () => { + const unconfiguredSlack: SlackConnectorConfig = { + ...config, + enabled: false, + channel: null, + }; + + it("reports ready for a valid relay profile even when Slack is disabled and unconfigured", async () => { + const dir = mkdtempSync(join(tmpdir(), "relay-ready-")); + try { + const envFile = join(dir, "relay.env"); + writeFileSync( + envFile, + [ + "OPENRIG_RELAY_URL=http://127.0.0.1:8787/outbound", + "OPENRIG_RELAY_OUTBOUND_TOKEN=out-secret-token-1", + "OPENRIG_RELAY_INBOUND_TOKEN=in-secret-token-1", + ].join("\n"), + { mode: 0o600 }, + ); + const relayHuman: HumanFragment = { + ...human, + connectorBindings: [ + { + kind: "relay", + connectorRef: "bridge-main", + secretsRef: `file:${envFile}`, + role: "primary", + handle: "founder", + }, + ], + }; + const verifyScopes = vi.fn(); + const result = await resolveHumanDeliveryReadiness( + { human: relayHuman, config: unconfiguredSlack, gatewayState: "active", botToken: null, home: dir }, + { verifyScopes, entities: [relayHuman], now: () => new Date("2026-11-06T00:00:00.000Z") }, + ); + expect(verifyScopes).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + state: "ready", + configured: true, + enabled: true, + active: true, + ready: true, + connector: { kind: "relay", ref: "bridge-main" }, + nextAction: null, + checkedAt: "2026-11-06T00:00:00.000Z", + }); + expect(JSON.stringify(result)).not.toContain("out-secret-token-1"); + expect(JSON.stringify(result)).not.toContain("in-secret-token-1"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("keeps relay profile ready with redacted warning when secret file has group/other bits or uses the same directional token", async () => { + const dir = mkdtempSync(join(tmpdir(), "relay-warn-ready-")); + try { + const envFile = join(dir, "relay-warn.env"); + writeFileSync( + envFile, + [ + "OPENRIG_RELAY_URL=http://127.0.0.1:8787/outbound", + "OPENRIG_RELAY_OUTBOUND_TOKEN=shared-bidirectional-tok-99", + "OPENRIG_RELAY_INBOUND_TOKEN=shared-bidirectional-tok-99", + ].join("\n"), + { mode: 0o644 }, + ); + chmodSync(envFile, 0o644); + const relayHuman: HumanFragment = { + ...human, + connectorBindings: [ + { + kind: "relay", + connectorRef: "bridge-warn", + secretsRef: `file:${envFile}`, + role: "primary", + handle: "founder", + }, + ], + }; + const result = await resolveHumanDeliveryReadiness( + { human: relayHuman, config: unconfiguredSlack, gatewayState: "active", botToken: null, home: dir }, + { entities: [relayHuman], now: () => new Date("2026-11-07T00:00:00.000Z") }, + ); + expect(result).toMatchObject({ + state: "ready", + configured: true, + enabled: true, + active: true, + ready: true, + connector: { kind: "relay", ref: "bridge-warn" }, + nextAction: null, + }); + expect(result.reason).toMatch(/warning/i); + expect(result.reason).toMatch(/group\/other/i); + expect(result.reason).toMatch(/OPENRIG_RELAY_OUTBOUND_TOKEN/); + expect(JSON.stringify(result)).not.toContain("shared-bidirectional-tok-99"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("reports not-ready with sanitized reason when relay credentials are invalid or ambiguous", async () => { + const dir = mkdtempSync(join(tmpdir(), "relay-unready-")); + try { + const envFile1 = join(dir, "relay1.env"); + const envFile2 = join(dir, "relay2.env"); + writeFileSync( + envFile1, + [ + "OPENRIG_RELAY_URL=http://127.0.0.1:8787/out1", + "OPENRIG_RELAY_OUTBOUND_TOKEN=out-1", + "OPENRIG_RELAY_INBOUND_TOKEN=shared-inbound-secret", + ].join("\n"), + { mode: 0o600 }, + ); + writeFileSync( + envFile2, + [ + "OPENRIG_RELAY_URL=http://127.0.0.1:8788/out2", + "OPENRIG_RELAY_OUTBOUND_TOKEN=out-2", + "OPENRIG_RELAY_INBOUND_TOKEN=shared-inbound-secret", + ].join("\n"), + { mode: 0o600 }, + ); + const h1: HumanFragment = { + ...human, + entityId: "founder", + address: "founder@external", + connectorBindings: [{ kind: "relay", connectorRef: "r1", secretsRef: `file:${envFile1}`, role: "primary", handle: "f1" }], + }; + const h2: HumanFragment = { + ...human, + entityId: "partner", + address: "partner@external", + connectorBindings: [{ kind: "relay", connectorRef: "r2", secretsRef: `file:${envFile2}`, role: "primary", handle: "p1" }], + }; + const relayProfiles = loadRelayProfiles(dir, { entities: [h1, h2] }); + const r1Readiness = await resolveHumanDeliveryReadiness({ + human: h1, + config: unconfiguredSlack, + gatewayState: "active", + botToken: null, + relayProfiles, + }); + expect(r1Readiness).toMatchObject({ + state: "not-ready", + configured: false, + enabled: true, + active: false, + ready: false, + connector: { kind: "relay", ref: "r1" }, + }); + expect(r1Readiness.reason).toMatch(/ambiguous.*OPENRIG_RELAY_INBOUND_TOKEN/i); + expect(r1Readiness.reason).not.toContain("shared-inbound-secret"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("never falls back to single input.human when home registry is corrupt or human is unregistered, and preserves cross-profile ambiguity from home", async () => { + const dir = mkdtempSync(join(tmpdir(), "relay-failclosed-")); + try { + const envFile1 = join(dir, "relay1.env"); + const envFile2 = join(dir, "relay2.env"); + writeFileSync( + envFile1, + [ + "OPENRIG_RELAY_URL=http://127.0.0.1:8787/out1", + "OPENRIG_RELAY_OUTBOUND_TOKEN=out-1", + "OPENRIG_RELAY_INBOUND_TOKEN=shared-inbound-secret", + ].join("\n"), + { mode: 0o600 }, + ); + writeFileSync( + envFile2, + [ + "OPENRIG_RELAY_URL=http://127.0.0.1:8788/out2", + "OPENRIG_RELAY_OUTBOUND_TOKEN=out-2", + "OPENRIG_RELAY_INBOUND_TOKEN=shared-inbound-secret", + ].join("\n"), + { mode: 0o600 }, + ); + const h1: HumanFragment = { + ...human, + entityId: "founder", + address: "founder@external", + connectorBindings: [{ kind: "relay", connectorRef: "r1", secretsRef: `file:${envFile1}`, role: "primary", handle: "f1" }], + }; + const h2: HumanFragment = { + ...human, + entityId: "partner", + address: "partner@external", + connectorBindings: [{ kind: "relay", connectorRef: "r2", secretsRef: `file:${envFile2}`, role: "primary", handle: "p1" }], + }; + + // 1. Unregistered human in empty home must not fall back to single-human evaluation + const verifyScopes = vi.fn(); + const unregisteredReady = await resolveHumanDeliveryReadiness( + { human: h1, config: unconfiguredSlack, gatewayState: "active", botToken: null, home: dir }, + { verifyScopes }, + ); + expect(unregisteredReady.ready).toBe(false); + expect(unregisteredReady.state).toBe("not-ready"); + expect(verifyScopes).not.toHaveBeenCalled(); + + // 2. Full registry on disk with cross-profile ambiguous inbound token remains not-ready + expect(addHumanFragment(h1 as unknown as Record, dir).ok).toBe(true); + expect(addHumanFragment(h2 as unknown as Record, dir).ok).toBe(true); + const fromHomeAmbiguous = await resolveHumanDeliveryReadiness( + { human: h1, config: unconfiguredSlack, gatewayState: "active", botToken: null, home: dir }, + { verifyScopes }, + ); + expect(fromHomeAmbiguous.ready).toBe(false); + expect(fromHomeAmbiguous.state).toBe("not-ready"); + expect(fromHomeAmbiguous.reason).toMatch(/ambiguous.*OPENRIG_RELAY_INBOUND_TOKEN/i); + + // 3. Corrupt registry projection fails closed (never ready) + writeFileSync(projectionPath(dir), "corrupted: hand-edited\n"); + const corruptReady = await resolveHumanDeliveryReadiness( + { human: h1, config: unconfiguredSlack, gatewayState: "active", botToken: null, home: dir }, + { verifyScopes }, + ); + expect(corruptReady.ready).toBe(false); + expect(corruptReady.state).toBe("not-ready"); + expect(verifyScopes).not.toHaveBeenCalled(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/daemon/test/gateway-human-registry.test.ts b/packages/daemon/test/gateway-human-registry.test.ts index d02a4b5bf..216e9cc38 100644 --- a/packages/daemon/test/gateway-human-registry.test.ts +++ b/packages/daemon/test/gateway-human-registry.test.ts @@ -6,6 +6,8 @@ import { stringify as stringifyYaml } from "yaml"; import { validateHumanFragment, addHumanFragment, + setHumanField, + parseBindingSpec, projectHumans, writeProjection, loadHumanRegistry, @@ -50,6 +52,55 @@ describe("A3 human-fragment validation (add-time == load-time)", () => { expect(r.ok).toBe(false); }); + it("accepts a relay connector binding and preserves kind=relay", () => { + const r = validateHumanFragment({ + ...fragment(), + connectorBindings: [ + { kind: "relay", connectorRef: "bridge-main", secretsRef: "file:/etc/openrig/relay.env", role: "primary", handle: "mike" }, + ], + }); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.fragment.connectorBindings[0]).toEqual({ + kind: "relay", + connectorRef: "bridge-main", + secretsRef: "file:/etc/openrig/relay.env", + role: "primary", + handle: "mike", + }); + } + }); + + it("parseBindingSpec supports relay file: syntax and rejects relative file: paths", () => { + const ok = parseBindingSpec("relay:bridge-main:file:/etc/openrig/relay.env:primary:handle=mike"); + expect(ok).toEqual({ + ok: true, + binding: { + kind: "relay", + connectorRef: "bridge-main", + secretsRef: "file:/etc/openrig/relay.env", + role: "primary", + handle: "mike", + }, + }); + expect(parseBindingSpec("relay:bridge-main:file:relative/relay.env:primary:handle=mike").ok).toBe(false); + expect(parseBindingSpec("relay:bridge-main:file::primary:handle=mike").ok).toBe(false); + }); + + it("parseBindingSpec preserves Slack relative-file backward-compatibility", () => { + const parsed = parseBindingSpec("slack:slack-main:file:relative/secrets.env:primary:handle=mike"); + expect(parsed).toEqual({ + ok: true, + binding: { + kind: "slack", + connectorRef: "slack-main", + secretsRef: "file:relative/secrets.env", + role: "primary", + handle: "mike", + }, + }); + }); + it("accepts a person with no connector binding, as an empty list or left out, and rejects a value that isn't a list", () => { expect(validateHumanFragment({ ...fragment(), connectorBindings: [] })).toMatchObject({ ok: true, fragment: { connectorBindings: [] } }); const { connectorBindings: _none, ...withoutBindings } = fragment(); @@ -238,6 +289,35 @@ describe("A3 proof-5 — fragments -> generated projection; re-project; hand-edi const loaded = loadHumanRegistry(home); expect(loaded.ok && loaded.entities[0]!.displayName).toBe("Founder Renamed"); }); + + it("setHumanField updates binding. to a relay file binding and enforces connectorRef+handle uniqueness", () => { + expect(addHumanFragment(fragment({ entityId: "mike", address: "mike@external" }), home).ok).toBe(true); + expect(addHumanFragment(fragment({ entityId: "founder", displayName: "Founder", address: "founder@external" }), home).ok).toBe(true); + + const setMike = setHumanField( + "mike", + "binding.0", + "relay:bridge-1:file:/etc/openrig/relay1.env:primary:handle=shared-alias", + home, + ); + expect(setMike.ok).toBe(true); + + const conflictSameRelay = setHumanField( + "founder", + "binding.0", + "relay:bridge-1:file:/etc/openrig/relay1.env:primary:handle=shared-alias", + home, + ); + expect(conflictSameRelay.ok).toBe(false); + + const okOtherRelay = setHumanField( + "founder", + "binding.0", + "relay:bridge-2:file:/etc/openrig/relay2.env:primary:handle=shared-alias", + home, + ); + expect(okOtherRelay.ok).toBe(true); + }); }); describe("A3 pt2 — hardening (r1 pooled notes)", () => { diff --git a/packages/daemon/test/gateway-relay-inbound.test.ts b/packages/daemon/test/gateway-relay-inbound.test.ts new file mode 100644 index 000000000..a79b33286 --- /dev/null +++ b/packages/daemon/test/gateway-relay-inbound.test.ts @@ -0,0 +1,2392 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { serve, type ServerType } from "@hono/node-server"; +import { createHash } from "node:crypto"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type Database from "better-sqlite3"; +import type { Hono } from "hono"; +import { createTestApp } from "./helpers/test-app.js"; +import { createDb } from "../src/db/connection.js"; +import { migrate } from "../src/db/migrate.js"; +import { ALL_MIGRATIONS } from "../src/db/all-migrations.js"; +import { EventBus } from "../src/domain/event-bus.js"; +import { QueueRepository } from "../src/domain/queue-repository.js"; +import { archiveAgedTerminalTransitions } from "../src/domain/queue-retention.js"; +import { OutboxHandler } from "../src/domain/outbox-handler.js"; +import { MissionControlActionLog } from "../src/domain/mission-control/mission-control-action-log.js"; +import { MissionControlWriteContract } from "../src/domain/mission-control/mission-control-write-contract.js"; +import { GatewaySubsystem } from "../src/domain/gateway/gateway-subsystem.js"; +import { buildSlackGatewayWire, makeHumanReplyResolver } from "../src/domain/gateway/slack/slack-subsystem.js"; +import { addHumanFragment, loadHumanRegistry } from "../src/domain/gateway/human-registry.js"; +import { OUTBOUND_OP } from "../src/domain/gateway/slack/outbound-driver.js"; +import { + encodeRelayReplyRef, + hashRelayReplyRef, + prepareRelayDecision, + type RelayReplyRefPayload, +} from "../src/domain/gateway/relay-outbound.js"; +import { DispatchBuffer } from "../src/domain/gateway/dispatch-buffer.js"; +import { SeenStore } from "../src/domain/gateway/slack/state-store.js"; +import { + RELAY_INBOUND_SEEN_FILE, + computeRelayEventFingerprint, + computeRelayEventKey, + createRelayInboundHandler, +} from "../src/domain/gateway/relay-inbound.js"; + +interface DaemonHandles { + app: Hono; + db: Database.Database; + queueRepo: QueueRepository; + missionControlActionLog: MissionControlActionLog; + missionControlWriteContract: MissionControlWriteContract; + gatewaySubsystem: GatewaySubsystem; +} + +interface RelayFixture { + home: string; + db: Database.Database; + daemon: DaemonHandles; + server: ServerType; + baseUrl: string; + inboundTokenMain: string; + outboundTokenMain: string; + inboundTokenSecondary: string; + outboundTokenSecondary: string; +} + +const fixtures: RelayFixture[] = []; +const savedOpenRigHome = process.env.OPENRIG_HOME; + +afterEach(async () => { + if (savedOpenRigHome === undefined) { + delete process.env.OPENRIG_HOME; + } else { + process.env.OPENRIG_HOME = savedOpenRigHome; + } + while (fixtures.length > 0) { + const f = fixtures.pop()!; + f.daemon.gatewaySubsystem.stop(); + await new Promise((resolve) => f.server.close(() => resolve())); + f.db.close(); + rmSync(f.home, { recursive: true, force: true }); + } +}); + +function writeRelayEnvFile( + home: string, + fileName: string, + vars: Record, + mode = 0o600, +): string { + const secretsDir = join(home, "secrets"); + mkdirSync(secretsDir, { recursive: true }); + const filePath = join(secretsDir, fileName); + const content = Object.entries(vars) + .map(([k, v]) => `${k}=${v}`) + .join("\n") + "\n"; + writeFileSync(filePath, content, { mode }); + chmodSync(filePath, mode); + return filePath; +} + +async function createRelayFixture(opts?: { + configureMainSecret?: boolean; + mainSecretMode?: number; + mainVars?: Record; + registerPartner?: boolean; + registerSecondaryRelay?: boolean; + secondaryVars?: Record; + missionControlBearerToken?: string | null; +}): Promise { + const home = mkdtempSync(join(tmpdir(), "relay-inbound-test-")); + writeFileSync(join(home, ".openrig-fixture"), ""); + process.env.OPENRIG_HOME = home; + + const inboundTokenMain = "relay-in-secret-main-token-001"; + const outboundTokenMain = "relay-out-secret-main-token-001"; + const inboundTokenSecondary = "relay-in-secret-sec-token-002"; + const outboundTokenSecondary = "relay-out-secret-sec-token-002"; + const mainEnvPath = join(home, "secrets", "relay-main.env"); + const secEnvPath = join(home, "secrets", "relay-sec.env"); + + if (opts?.configureMainSecret !== false) { + writeRelayEnvFile( + home, + "relay-main.env", + opts?.mainVars ?? { + OPENRIG_RELAY_URL: "http://127.0.0.1:19876/outbound", + OPENRIG_RELAY_OUTBOUND_TOKEN: outboundTokenMain, + OPENRIG_RELAY_INBOUND_TOKEN: inboundTokenMain, + }, + opts?.mainSecretMode ?? 0o600, + ); + } + + addHumanFragment( + { + entityId: "founder", + class: "human", + displayName: "Founder", + address: "founder@external", + connectorBindings: [ + { + kind: "relay", + connectorRef: "relay-main", + handle: "founder-handle", + secretsRef: `file:${mainEnvPath}`, + role: "primary", + }, + ], + prefs: { deliveryClass: "A" }, + }, + home, + ); + + if (opts?.registerPartner !== false) { + addHumanFragment( + { + entityId: "partner", + class: "human", + displayName: "Partner", + address: "partner@external", + connectorBindings: [ + { + kind: "relay", + connectorRef: "relay-main", + handle: "partner-handle", + secretsRef: `file:${mainEnvPath}`, + role: "primary", + }, + ], + prefs: { deliveryClass: "A" }, + }, + home, + ); + } + + if (opts?.registerSecondaryRelay) { + writeRelayEnvFile( + home, + "relay-sec.env", + opts.secondaryVars ?? { + OPENRIG_RELAY_URL: "http://127.0.0.1:19877/outbound", + OPENRIG_RELAY_OUTBOUND_TOKEN: outboundTokenSecondary, + OPENRIG_RELAY_INBOUND_TOKEN: inboundTokenSecondary, + }, + 0o600, + ); + addHumanFragment( + { + entityId: "ops", + class: "human", + displayName: "Ops", + address: "ops@external", + connectorBindings: [ + { + kind: "relay", + connectorRef: "relay-sec", + handle: "ops-handle", + secretsRef: `file:${secEnvPath}`, + role: "primary", + }, + ], + prefs: { deliveryClass: "A" }, + }, + home, + ); + } + + const db = createDb(); + migrate(db, ALL_MIGRATIONS); + const eventBus = new EventBus(db); + const queueRepo = new QueueRepository(db, eventBus, { + validateRig: () => true, + loadHumanRegistry: () => loadHumanRegistry(home), + }); + const outbox = new OutboxHandler(db); + queueRepo.attachOutbox(outbox); + const missionControlActionLog = new MissionControlActionLog(db); + const missionControlWriteContract = new MissionControlWriteContract({ + db, + eventBus, + queueRepo, + actionLog: missionControlActionLog, + }); + const gatewaySubsystem = new GatewaySubsystem({ + home, + wire: () => + buildSlackGatewayWire({ + home, + queueRepo, + resolveHumanReply: makeHumanReplyResolver(queueRepo, missionControlWriteContract, home), + }), + }); + gatewaySubsystem.start(); + const relayInboundHandler = createRelayInboundHandler({ + home, + queueRepo, + missionControlWriteContract, + missionControlActionLog, + }); + const { app } = createTestApp(db, { + appDeps: { + queueRepo, + outboxHandler: outbox, + missionControlActionLog, + missionControlWriteContract, + gatewaySubsystem, + relayInboundHandler, + missionControlBearerToken: opts?.missionControlBearerToken ?? null, + }, + }); + const daemon: DaemonHandles = { + app, + db, + queueRepo, + missionControlActionLog, + missionControlWriteContract, + gatewaySubsystem, + }; + + let server!: ServerType; + const port = await new Promise((resolve) => { + server = serve( + { + fetch: daemon.app.fetch, + hostname: "127.0.0.1", + port: 0, + }, + (info: AddressInfo) => resolve(info.port), + ); + }); + + const fixture: RelayFixture = { + home, + db, + daemon, + server, + baseUrl: `http://127.0.0.1:${port}`, + inboundTokenMain, + outboundTokenMain, + inboundTokenSecondary, + outboundTokenSecondary, + }; + fixtures.push(fixture); + return fixture; +} + +async function createAskWithAcceptedRelayRef( + f: RelayFixture, + opts?: { + destinationSession?: string; + connectorRef?: string; + summary?: string; + body?: string; + humanIntent?: "decision" | "update"; + recordViaPendingBufferOnly?: boolean; + }, +): Promise<{ qitemId: string; replyRef: string; payload: RelayReplyRefPayload }> { + const destinationSession = opts?.destinationSession ?? "founder@external"; + const connectorRef = opts?.connectorRef ?? "relay-main"; + const isUpdate = opts?.humanIntent === "update"; + const created = await f.daemon.queueRepo.create({ + sourceSession: "architect@rig", + destinationSession, + summary: opts?.summary ?? "Architecture sign-off needed", + body: opts?.body ?? "Should we proceed with Option A or Option B?", + evidenceRef: "docs/arch-decision.md", + humanIntent: opts?.humanIntent ?? "decision", + ...(isUpdate + ? {} + : { + humanAlert: true, + humanQuestions: [ + { + id: "q-arch", + header: "Architecture", + question: "Which option should we ship?", + options: [ + { id: "opt-a", label: "Option A", description: "Strict local boundary" }, + { id: "opt-b", label: "Option B", description: "Deferred boundary" }, + ], + }, + ], + }), + nudge: false, + }); + + const item = f.daemon.queueRepo.getById(created.qitemId)!; + const transitions = f.daemon.queueRepo.listTransitions(created.qitemId); + const fallbackTransition = transitions[transitions.length - 1]!; + const episodeKey = isUpdate + ? `${created.qitemId}:${fallbackTransition.transitionId}` + : f.daemon.queueRepo.currentHumanRequiredEpisode(created.qitemId)!.key; + const decisionRevision = f.daemon.queueRepo.decisionRevision(item); + const decisionId = `dec-${episodeKey.replace(":", "-")}`; + + const payload: RelayReplyRefPayload = { + v: 1, + qitemId: created.qitemId, + episodeKey, + decisionRevision, + decisionId, + connectorRef, + mode: "ask", + }; + const replyRef = encodeRelayReplyRef(payload); + + if (opts?.recordViaPendingBufferOnly) { + const buffer = new DispatchBuffer(f.home); + const prepared = prepareRelayDecision( + { + kind: "outbound_decision", + decisionId, + op: OUTBOUND_OP, + entityBindingRef: destinationSession, + payload: { + qitemId: created.qitemId, + notificationKey: episodeKey, + destinationSession, + summary: item.summary, + body: item.body, + humanIntent: item.humanIntent, + ownerNotificationKind: isUpdate ? "human-update" : "human-required", + }, + enqueuedAt: new Date().toISOString(), + }, + { + loadHumanRegistry: () => loadHumanRegistry(f.home), + queueRepo: f.daemon.queueRepo, + resolveOutcome: () => "interrupt", + }, + ); + buffer.enqueue(prepared); + } else { + f.daemon.queueRepo.recordRelayOwnerNotificationAccepted({ + qitemId: created.qitemId, + actorSession: "relay-connector", + notificationKey: episodeKey, + connectorRef, + deliveryId: decisionId, + replyRef, + replyRefHash: hashRelayReplyRef(replyRef), + revision: decisionRevision, + level: "ALERT", + kind: isUpdate ? "human-update" : "human-required", + closeUpdate: false, + }); + } + + return { qitemId: created.qitemId, replyRef, payload }; +} + +async function createParkedItemWithAcceptedRelayRef( + f: RelayFixture, + opts?: { + blockedOn?: string; + connectorRef?: string; + summary?: string; + body?: string; + withHumanQuestions?: boolean; + }, +): Promise<{ qitemId: string; replyRef: string; payload: RelayReplyRefPayload }> { + const blockedOn = opts?.blockedOn ?? "founder@external"; + const connectorRef = opts?.connectorRef ?? "relay-main"; + const created = await f.daemon.queueRepo.create({ + sourceSession: "architect@rig", + destinationSession: "builder@rig", + summary: opts?.summary ?? "Release approval gate", + body: opts?.body ?? "Waiting on founder sign-off for release cut.", + evidenceRef: "docs/release-plan.md", + nudge: false, + }); + + if (opts?.withHumanQuestions) { + f.daemon.db + .prepare("UPDATE queue_items SET human_questions = ? WHERE qitem_id = ?") + .run( + JSON.stringify([ + { + id: "q-release", + header: "Release", + question: "Can we cut the release?", + options: [{ id: "yes", label: "Yes", description: "Cut now" }], + }, + ]), + created.qitemId, + ); + } + + f.daemon.queueRepo.claim({ + qitemId: created.qitemId, + destinationSession: "builder@rig", + }); + f.daemon.queueRepo.update({ + qitemId: created.qitemId, + actorSession: "builder@rig", + state: "blocked", + closureReason: "blocked_on", + closureTarget: blockedOn, + blockedOn, + transitionNote: "Parked on founder for release sign-off", + }); + + const item = f.daemon.queueRepo.getById(created.qitemId)!; + const episodeKey = f.daemon.queueRepo.currentHumanRequiredEpisode(created.qitemId)!.key; + const decisionRevision = f.daemon.queueRepo.decisionRevision(item); + const decisionId = `dec-${episodeKey.replace(":", "-")}`; + + const payload: RelayReplyRefPayload = { + v: 1, + qitemId: created.qitemId, + episodeKey, + decisionRevision, + decisionId, + connectorRef, + mode: "park", + }; + const replyRef = encodeRelayReplyRef(payload); + + f.daemon.queueRepo.recordRelayOwnerNotificationAccepted({ + qitemId: created.qitemId, + actorSession: "relay-connector", + notificationKey: episodeKey, + connectorRef, + deliveryId: decisionId, + replyRef, + replyRefHash: hashRelayReplyRef(replyRef), + revision: decisionRevision, + level: "ALERT", + kind: "human-required", + }); + + return { qitemId: created.qitemId, replyRef, payload }; +} + +describe("POST /api/gateway/relay/inbound — boundary, auth, semantics, durability & composition", () => { + it("enforces real TCP loopback peer and rejects in-process requests without a socket, non-loopback peers, forwarding headers, and browser origins", async () => { + const f = await createRelayFixture(); + const { qitemId, replyRef } = await createAskWithAcceptedRelayRef(f); + const validBody = { + eventId: "evt-loopback-1", + sender: { handle: "founder-handle" }, + replyRef, + text: "Approve Option A", + ordering: "after-question", + }; + + // 1. In-process app.request has no real TCP socket remoteAddress -> must be rejected (403) + const inMemoryRes = await f.daemon.app.request("/api/gateway/relay/inbound", { + method: "POST", + headers: { + Host: "127.0.0.1:7433", + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(validBody), + }); + expect(inMemoryRes.status).toBe(403); + + // 2. Non-loopback TCP socket peer is rejected even if Host claims 127.0.0.1 + const nonLoopbackRes = await f.daemon.app.fetch( + new Request("http://127.0.0.1:7433/api/gateway/relay/inbound", { + method: "POST", + headers: { + Host: "127.0.0.1:7433", + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(validBody), + }), + { incoming: { socket: { remoteAddress: "100.64.0.12" } } } as never, + ); + expect(nonLoopbackRes.status).toBe(403); + + // 3. Browser boundary rejects cross-origin and untrusted Host requests (403) + const crossOriginRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + Origin: "http://evil.example", + "Content-Type": "application/json", + }, + body: JSON.stringify(validBody), + }); + expect(crossOriginRes.status).toBe(403); + + // Verify qitem is still untouched (pending) after all boundary rejections + expect(f.daemon.queueRepo.getById(qitemId)?.state).toBe("pending"); + + // 4. Forwarding / proxy headers (Via, X-Forwarded-*, Forwarded, X-Real-Ip) over a direct loopback TCP peer are ignored + const withForwardingHeadersRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + Via: "1.1 local-bridge", + "X-Forwarded-For": "203.0.113.9", + "X-Forwarded-Host": "relay.example", + "X-Forwarded-Proto": "https", + Forwarded: "for=203.0.113.9;proto=https", + "X-Real-Ip": "203.0.113.9", + }, + body: JSON.stringify(validBody), + }); + expect(withForwardingHeadersRes.status).toBe(200); + expect(f.daemon.queueRepo.getById(qitemId)?.state).toBe("done"); + }); + + it("enforces explicit relay bearer token authentication and never allows null-token bypass when unready", async () => { + // Unconfigured secret file -> unready profile, never bypasses auth + const unconfigured = await createRelayFixture({ configureMainSecret: false }); + const { qitemId: unreadyQitem, replyRef: unreadyRef } = await createAskWithAcceptedRelayRef(unconfigured); + + const noHeaderOnUnready = await fetch(`${unconfigured.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + eventId: "evt-auth-1", + sender: { handle: "founder-handle" }, + replyRef: unreadyRef, + text: "Bypass attempt", + }), + }); + expect(noHeaderOnUnready.status).toBe(401); + expect(unconfigured.daemon.queueRepo.getById(unreadyQitem)?.state).toBe("pending"); + + // Missing inbound token in env file -> unready profile, rejects and never leaks outbound secret + const missingInbound = await createRelayFixture({ + mainVars: { + OPENRIG_RELAY_URL: "http://127.0.0.1:19876/outbound", + OPENRIG_RELAY_OUTBOUND_TOKEN: "relay-out-secret-main-token-001", + }, + }); + const { replyRef: missingInboundRef } = await createAskWithAcceptedRelayRef(missingInbound); + const missingInboundRes = await fetch(`${missingInbound.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${missingInbound.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-auth-2", + sender: { handle: "founder-handle" }, + replyRef: missingInboundRef, + text: "Should fail due to missing inbound token", + }), + }); + expect(missingInboundRes.status).toBe(401); + const missingInboundBodyText = await missingInboundRes.text(); + expect(missingInboundBodyText).not.toContain(missingInbound.inboundTokenMain); + expect(missingInboundBodyText).not.toContain(missingInbound.outboundTokenMain); + + // Configured ready profile: test missing bearer, wrong bearer, and outbound token used on inbound + const f = await createRelayFixture({ + registerSecondaryRelay: true, + // Duplicate inbound token on secondary profile to test ambiguous token rejection + secondaryVars: { + OPENRIG_RELAY_URL: "http://127.0.0.1:19877/outbound", + OPENRIG_RELAY_OUTBOUND_TOKEN: "unique-out-sec", + OPENRIG_RELAY_INBOUND_TOKEN: "relay-in-secret-main-token-001", + }, + }); + const { replyRef: dupRef } = await createAskWithAcceptedRelayRef(f); + const ambiguousRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-auth-ambiguous", + sender: { handle: "founder-handle" }, + replyRef: dupRef, + text: "Ambiguous token", + }), + }); + expect(ambiguousRes.status).toBe(401); + + const ready = await createRelayFixture(); + const { replyRef: readyRef } = await createAskWithAcceptedRelayRef(ready); + for (const badAuth of [ + undefined, + "Basic Zm9vOmJhcg==", + "Bearer ", + "Bearer wrong-token", + `Bearer ${ready.outboundTokenMain}`, // Outbound token must be rejected on inbound + ]) { + const res = await fetch(`${ready.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + ...(badAuth ? { Authorization: badAuth } : {}), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-auth-3", + sender: { handle: "founder-handle" }, + replyRef: readyRef, + text: "Attempt", + }), + }); + expect(res.status, `auth=${String(badAuth)}`).toBe(401); + const errText = await res.text(); + expect(errText).not.toContain(ready.inboundTokenMain); + expect(errText).not.toContain(ready.outboundTokenMain); + } + }); + + it("preserves existing /api/gateway/slack/forwarded hub protocol alongside /api/gateway/relay/inbound", async () => { + const f = await createRelayFixture({ missionControlBearerToken: "hub-forward-token-999" }); + + const unauthForward = await fetch(`${f.baseUrl}/api/gateway/slack/forwarded`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type: "event_callback" }), + }); + expect(unauthForward.status).toBe(401); + + const authForward = await fetch(`${f.baseUrl}/api/gateway/slack/forwarded`, { + method: "POST", + headers: { + Authorization: "Bearer hub-forward-token-999", + "Content-Type": "application/json", + }, + body: JSON.stringify({ envelope: { type: "event_callback" } }), + }); + // Slack forwarding isn't active in a relay-only fixture so subsystem returns 503 not-active, proving route & token still work + expect(authForward.status).toBe(503); + }); + + it("validates bounded JSON schema and size limits while accepting and ignoring harmless extra top-level and sender fields", async () => { + const f = await createRelayFixture(); + const { qitemId, replyRef } = await createAskWithAcceptedRelayRef(f); + + // Malformed JSON + const malformed = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: "{bad json", + }); + expect(malformed.status).toBe(400); + + // Invalid ordering value + const badOrdering = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-schema-3", + sender: { handle: "founder-handle" }, + replyRef, + text: "Hello", + ordering: "verified", + }), + }); + expect(badOrdering.status).toBe(400); + + // Oversized body (> 64 KiB) + const oversized = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-schema-big", + sender: { handle: "founder-handle" }, + replyRef, + text: "x".repeat(70_000), + }), + }); + expect(oversized.status).toBe(413); + + // Harmless extra top-level and sender fields are accepted and ignored (never overriding handle attribution) + const withExtras = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-schema-extras-ok", + sender: { + handle: "founder-handle", + displayName: "Founder Extra", + entityId: "partner", + }, + replyRef, + text: "Approve Option A with extra fields ignored", + ordering: "after-question", + actorSession: "partner@external", + bridgeMeta: { bridgeVersion: "1.0.0" }, + }), + }); + expect(withExtras.status).toBe(200); + expect(await withExtras.json()).toMatchObject({ + ok: true, + qitemId, + actorSession: "founder@external", + deduplicated: false, + }); + expect(f.daemon.queueRepo.getById(qitemId)?.state).toBe("done"); + }); + + it("answers a direct ask and resolves a parked item with after-question ordering, including pending-buffer replyRef acceptance", async () => { + const f = await createRelayFixture(); + + // 1. Direct ask whose outbound decision is still in the pending DispatchBuffer (race before ack) + const ask = await createAskWithAcceptedRelayRef(f, { recordViaPendingBufferOnly: true }); + const askRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-ask-1", + sender: { handle: "founder-handle" }, + replyRef: ask.replyRef, + text: "We should ship Option A with strict local boundaries.", + ordering: "after-question", + }), + }); + expect(askRes.status).toBe(200); + const askJson = (await askRes.json()) as Record; + expect(askJson).toMatchObject({ + ok: true, + qitemId: ask.qitemId, + verb: "answer", + actorSession: "founder@external", + deduplicated: false, + }); + + const updatedAsk = f.daemon.queueRepo.getById(ask.qitemId)!; + expect(updatedAsk.state).toBe("done"); + expect(updatedAsk.humanAnswers).toEqual({ + "q-arch": { + kind: "typed-reply", + placement: "first-unanswered", + text: "We should ship Option A with strict local boundaries.", + unansweredCount: 0, + }, + }); + const replyItem = f.daemon.queueRepo.getHumanAnswer(ask.qitemId, "founder@external"); + expect(replyItem).not.toBeNull(); + expect(replyItem?.sourceSession).toBe("founder@external"); + expect(replyItem?.destinationSession).toBe("architect@rig"); + + // 2. Parked item resolved with ordering="after-question" records typed answer and transitions blocked -> in-progress + const park = await createParkedItemWithAcceptedRelayRef(f); + const parkRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-park-1", + sender: { handle: "founder-handle" }, + replyRef: park.replyRef, + text: "Release approved; proceed with tag v1.2.0.", + ordering: "after-question", + }), + }); + expect(parkRes.status).toBe(200); + const parkJson = (await parkRes.json()) as Record; + expect(parkJson).toMatchObject({ + ok: true, + qitemId: park.qitemId, + verb: "resolve", + actorSession: "founder@external", + deduplicated: false, + }); + + const updatedPark = f.daemon.queueRepo.getById(park.qitemId)!; + expect(updatedPark.state).toBe("in-progress"); + expect(updatedPark.blockedOn).toBeNull(); + }); + + it("allows another registered person on the same relay to answer with real attribution and preserves first-responder winner finality", async () => { + const f = await createRelayFixture({ registerSecondaryRelay: true }); + const ask = await createAskWithAcceptedRelayRef(f, { destinationSession: "founder@external" }); + + // Cross-profile handle (ops-handle is bound to relay-sec, not relay-main) -> rejected (403) + const crossHandleRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-cross-handle", + sender: { handle: "ops-handle" }, + replyRef: ask.replyRef, + text: "Cross profile attempt", + ordering: "after-question", + }), + }); + expect(crossHandleRes.status).toBe(403); + + // Cross-profile token (relay-sec token trying to answer a relay-main replyRef) -> rejected (403) + const crossRefRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenSecondary}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-cross-ref", + sender: { handle: "ops-handle" }, + replyRef: ask.replyRef, + text: "Cross profile ref attempt", + ordering: "after-question", + }), + }); + expect(crossRefRes.status).toBe(403); + + // Partner on the SAME relay-main answers founder's ask -> succeeds with partner@external attribution + const partnerRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-partner-answer", + sender: { handle: "partner-handle" }, + replyRef: ask.replyRef, + text: "Partner stepping in: use Option A.", + ordering: "after-question", + }), + }); + expect(partnerRes.status).toBe(200); + expect(await partnerRes.json()).toMatchObject({ + ok: true, + actorSession: "partner@external", + qitemId: ask.qitemId, + }); + + const auditActions = f.daemon.missionControlActionLog.listForQitem(ask.qitemId, 5); + expect(auditActions[0]?.actorSession).toBe("partner@external"); + expect(auditActions[0]?.auditNotes).toMatchObject({ + asked: "founder@external", + }); + const partnerReply = f.daemon.queueRepo.getHumanAnswer(ask.qitemId, "partner@external"); + expect(partnerReply?.sourceSession).toBe("partner@external"); + expect(partnerReply?.body).toContain("Answered by partner@external, a registered person, on founder@external's ask"); + + // Founder replies later to the same ask -> rejected (409), partner's winning answer stands + const lateFounderRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-late-founder", + sender: { handle: "founder-handle" }, + replyRef: ask.replyRef, + text: "Founder late answer: Option B.", + ordering: "after-question", + }), + }); + expect(lateFounderRes.status).toBe(409); + expect(f.daemon.queueRepo.getById(ask.qitemId)?.humanAnswers).toEqual({ + "q-arch": { + kind: "typed-reply", + placement: "first-unanswered", + text: "Partner stepping in: use Option A.", + unansweredCount: 0, + }, + }); + }); + + it("rejects forged replyRefs, update/notice replies, and stale revision or re-parked episode references", async () => { + const f = await createRelayFixture(); + + // 1. Forged replyRef that was never offered in DispatchBuffer or queue_transitions + const ask = await createAskWithAcceptedRelayRef(f); + const forgedPayload: RelayReplyRefPayload = { + ...ask.payload, + decisionId: "dec-forged-never-offered", + }; + const forgedRef = encodeRelayReplyRef(forgedPayload); + const forgedRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-forged-1", + sender: { handle: "founder-handle" }, + replyRef: forgedRef, + text: "Forged reply", + ordering: "after-question", + }), + }); + expect(forgedRes.status).toBe(409); + expect(f.daemon.queueRepo.getById(ask.qitemId)?.state).toBe("pending"); + + // 2. Informational update item cannot be answered even if a receipt is present + const updateItem = await createAskWithAcceptedRelayRef(f, { humanIntent: "update" }); + const updateRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-update-1", + sender: { handle: "founder-handle" }, + replyRef: updateItem.replyRef, + text: "Trying to answer an update notice", + ordering: "after-question", + }), + }); + expect(updateRes.status).toBe(409); + + // 3. Stale park episode: item is resolved via terminal, then re-parked on founder in a new episode + const park = await createParkedItemWithAcceptedRelayRef(f); + await f.daemon.missionControlWriteContract.act({ + verb: "resolve", + qitemId: park.qitemId, + actorSession: "founder@external", + decision: "Resolved episode 1 via CLI", + identityProvenance: "transport:v1", + notify: false, + }); + // Re-park into episode 2 + f.daemon.queueRepo.update({ + qitemId: park.qitemId, + actorSession: "builder@rig", + state: "blocked", + closureReason: "blocked_on", + closureTarget: "founder@external", + blockedOn: "founder@external", + transitionNote: "Parked again for episode 2 question", + }); + + // Stale replyRef from episode 1 must NOT resolve episode 2! + const staleParkRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-stale-park-ep1", + sender: { handle: "founder-handle" }, + replyRef: park.replyRef, + text: "Late reply to episode 1", + ordering: "after-question", + }), + }); + expect(staleParkRes.status).toBe(409); + expect(f.daemon.queueRepo.getById(park.qitemId)?.state).toBe("blocked"); + + // 4. Verification that receipt matching requires canonical full 64-hex SHA256 when replyRef is omitted from receipt + const askHashMatch = await createAskWithAcceptedRelayRef(f, { recordViaPendingBufferOnly: true }); + // Record receipt with replyRef = null and only canonical full 64-hex replyRefHash for this actual current episode + f.daemon.queueRepo.recordRelayOwnerNotificationAccepted({ + qitemId: askHashMatch.qitemId, + actorSession: "relay-connector", + notificationKey: askHashMatch.payload.episodeKey, + connectorRef: askHashMatch.payload.connectorRef, + deliveryId: askHashMatch.payload.decisionId, + replyRef: null, + replyRefHash: hashRelayReplyRef(askHashMatch.replyRef), + revision: askHashMatch.payload.decisionRevision, + level: "ALERT", + kind: "human-required", + }); + // Drain/ack dispatch buffer so the hash-only receipt is the sole offer path + new DispatchBuffer(f.home).ack(askHashMatch.payload.decisionId); + + // Inbound reply with original askHashMatch.replyRef succeeds matching canonical full SHA-256 + const hashMatchRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-hash-match-1", + sender: { handle: "founder-handle" }, + replyRef: askHashMatch.replyRef, + text: "Answered via full-hash receipt match", + ordering: "after-question", + }), + }); + expect(hashMatchRes.status, `unexpected status for full-hash match`).toBe(200); + expect(f.daemon.queueRepo.getById(askHashMatch.qitemId)?.state).toBe("done"); + + // If receipt has a truncated 24-hex hash on a fresh active ask, inbound reply is rejected (409) + const askTruncated = await createAskWithAcceptedRelayRef(f, { recordViaPendingBufferOnly: true }); + f.daemon.queueRepo.recordRelayOwnerNotificationAccepted({ + qitemId: askTruncated.qitemId, + actorSession: "relay-connector", + notificationKey: askTruncated.payload.episodeKey, + connectorRef: askTruncated.payload.connectorRef, + deliveryId: askTruncated.payload.decisionId, + replyRef: null, + replyRefHash: hashRelayReplyRef(askTruncated.replyRef).slice(0, 24), // truncated 24 hex + revision: askTruncated.payload.decisionRevision, + level: "ALERT", + kind: "human-required", + }); + new DispatchBuffer(f.home).ack(askTruncated.payload.decisionId); + + const truncatedRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-hash-truncated-1", + sender: { handle: "founder-handle" }, + replyRef: askTruncated.replyRef, + text: "Should fail because 24-hex shortHash compatibility is removed", + ordering: "after-question", + }), + }); + expect(truncatedRes.status).toBe(409); + const truncatedBody = (await truncatedRes.json()) as { error?: string }; + expect(truncatedBody.error).toBe("unrecognized_reply_ref"); + expect(f.daemon.queueRepo.getById(askTruncated.qitemId)?.state).toBe("pending"); + }); + + it("records uncertain ordering with explicit timing uncertainty context and never populates verified answerText on blocked items", async () => { + const f = await createRelayFixture(); + const park = await createParkedItemWithAcceptedRelayRef(f, { withHumanQuestions: true }); + + const res = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-uncertain-1", + sender: { handle: "founder-handle" }, + replyRef: park.replyRef, + text: "Hold until staging smoke tests finish.", + // ordering omitted -> defaults to "uncertain" + }), + }); + expect(res.status).toBe(200); + + const afterItem = f.daemon.queueRepo.getById(park.qitemId)!; + expect(afterItem.state).toBe("in-progress"); + // Must NOT populate verified humanAnswers on blocked item when ordering is uncertain + expect(afterItem.humanAnswers ?? null).toBeNull(); + + const transitions = f.daemon.queueRepo.listTransitions(park.qitemId); + const resolveTransition = transitions[transitions.length - 1]!; + expect(resolveTransition.transitionNote).toContain("Hold until staging smoke tests finish."); + expect(resolveTransition.transitionNote).toMatch(/ordering=uncertain/i); + }); + + it("enforces SeenStore event idempotency, rejects equivocation across text/ref/handle/qitem, and stores no plaintext reply text in the seen ledger", async () => { + const f = await createRelayFixture(); + const ask1 = await createAskWithAcceptedRelayRef(f); + const ask2 = await createAskWithAcceptedRelayRef(f); + const secretReplyText = "CONFIDENTIAL-DECISION-TEXT-DO-NOT-LOG-IN-SEEN-STORE"; + + // First delivery + const first = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-idem-1", + sender: { handle: "founder-handle" }, + replyRef: ask1.replyRef, + text: secretReplyText, + ordering: "after-question", + }), + }); + expect(first.status).toBe(200); + expect(await first.json()).toMatchObject({ ok: true, deduplicated: false }); + + // Exact duplicate delivery -> 200 idempotent no-op + const second = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-idem-1", + sender: { handle: "founder-handle" }, + replyRef: ask1.replyRef, + text: secretReplyText, + ordering: "after-question", + }), + }); + expect(second.status).toBe(200); + expect(await second.json()).toMatchObject({ ok: true, deduplicated: true }); + + // Only one audit action recorded + expect(f.daemon.missionControlActionLog.listForQitem(ask1.qitemId, 10)).toHaveLength(1); + + // SeenStore file contains only hashed metadata, never plaintext reply text or raw tokens + const seenRaw = readFileSync(join(f.home, "state", RELAY_INBOUND_SEEN_FILE), "utf8"); + expect(seenRaw).not.toContain(secretReplyText); + expect(seenRaw).not.toContain(f.inboundTokenMain); + + // Equivocation 1: same eventId with different text -> 409 conflict + const diffText = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-idem-1", + sender: { handle: "founder-handle" }, + replyRef: ask1.replyRef, + text: "Changed text under same eventId", + ordering: "after-question", + }), + }); + expect(diffText.status).toBe(409); + + // Equivocation 2: same eventId with different sender handle -> 409 conflict + const diffSender = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-idem-1", + sender: { handle: "partner-handle" }, + replyRef: ask1.replyRef, + text: secretReplyText, + ordering: "after-question", + }), + }); + expect(diffSender.status).toBe(409); + + // Equivocation 3: same eventId reused on a DIFFERENT qitem's replyRef -> 409 conflict + const diffQitem = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-idem-1", + sender: { handle: "founder-handle" }, + replyRef: ask2.replyRef, + text: secretReplyText, + ordering: "after-question", + }), + }); + expect(diffQitem.status).toBe(409); + expect(f.daemon.queueRepo.getById(ask2.qitemId)?.state).toBe("pending"); + }); + + it("recovers cleanly from mid-flight crashes (received-before-effect, answer-row-before-audit, and resolve-before-processed even after re-park) and refuses 2xx when seen-store is unreadable", async () => { + const f = await createRelayFixture(); + const seenPath = join(f.home, "state", RELAY_INBOUND_SEEN_FILE); + const seen = new SeenStore(seenPath); + + // Case A: Crash after 'received' reservation before any Mission Control effect + const askA = await createAskWithAcceptedRelayRef(f); + const bodyA = { + eventId: "evt-crash-a", + sender: { handle: "founder-handle" }, + replyRef: askA.replyRef, + text: "Recovered answer A", + ordering: "after-question" as const, + }; + const origAct = f.daemon.missionControlWriteContract.act.bind(f.daemon.missionControlWriteContract); + let failOnce = true; + f.daemon.missionControlWriteContract.act = async (input) => { + if (failOnce) { + failOnce = false; + throw new Error("simulated crash before MC effect"); + } + return origAct(input); + }; + + const failedAttemptA = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(bodyA), + }); + expect(failedAttemptA.status).toBe(500); + const keyA = computeRelayEventKey("relay-main", "evt-crash-a"); + expect(seen.statuses().get(keyA)).toMatch(/^received:/); + + const retryA = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(bodyA), + }); + expect(retryA.status).toBe(200); + expect(seen.statuses().get(keyA)).toMatch(/^processed:/); + expect(f.daemon.queueRepo.getById(askA.qitemId)?.state).toBe("done"); + + // Case B: Crash after createHumanAnswer (reply row created) before resolveDirectHumanReply & audit commit + const askB = await createAskWithAcceptedRelayRef(f); + const bodyB = { + eventId: "evt-crash-b", + sender: { handle: "founder-handle" }, + replyRef: askB.replyRef, + text: "Recovered answer B after partial write", + ordering: "after-question" as const, + }; + const origCreateHumanAnswer = f.daemon.queueRepo.createHumanAnswer.bind(f.daemon.queueRepo); + let failAfterCreateReplyForQitem: string | null = askB.qitemId; + f.daemon.queueRepo.createHumanAnswer = async (input) => { + const createdReply = await origCreateHumanAnswer(input); + if (failAfterCreateReplyForQitem && input.inboundReplyTo === failAfterCreateReplyForQitem) { + failAfterCreateReplyForQitem = null; + throw new Error("simulated crash after createHumanAnswer before resolveDirectHumanReply"); + } + return createdReply; + }; + + const failedAttemptB = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(bodyB), + }); + expect(failedAttemptB.status).toBe(409); + expect(await failedAttemptB.json()).toMatchObject({ + error: "answer_not_closed", + }); + // Reply row was already created on first attempt, but askB is still pending and no audit row committed yet + expect(f.daemon.queueRepo.getHumanAnswer(askB.qitemId, "founder@external")).not.toBeNull(); + expect(f.daemon.queueRepo.getById(askB.qitemId)?.state).toBe("pending"); + expect(f.daemon.missionControlActionLog.listForQitem(askB.qitemId, 5)).toHaveLength(0); + + const retryB = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(bodyB), + }); + expect(retryB.status).toBe(200); + expect(f.daemon.queueRepo.getById(askB.qitemId)?.state).toBe("done"); + expect(f.daemon.missionControlActionLog.listForQitem(askB.qitemId, 5)).toHaveLength(1); + const keyB = computeRelayEventKey("relay-main", "evt-crash-b"); + expect(seen.statuses().get(keyB)).toMatch(/^processed:/); + + // Case B-negative: Crash after createHumanAnswer AND another edit/question mutation happens before retry -> MUST reject (409) + const askBNeg = await createAskWithAcceptedRelayRef(f); + const bodyBNeg = { + eventId: "evt-crash-b-neg", + sender: { handle: "founder-handle" }, + replyRef: askBNeg.replyRef, + text: "Partial answer before concurrent question edit", + ordering: "after-question" as const, + }; + failAfterCreateReplyForQitem = askBNeg.qitemId; + const failedAttemptBNeg = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(bodyBNeg), + }); + expect(failedAttemptBNeg.status).toBe(409); + expect(await failedAttemptBNeg.json()).toMatchObject({ + error: "answer_not_closed", + }); + expect(f.daemon.queueRepo.getHumanAnswer(askBNeg.qitemId, "founder@external")).not.toBeNull(); + + // Concurrent edit to askBNeg's question body before retry arrives + f.daemon.db + .prepare("UPDATE queue_items SET body = ? WHERE qitem_id = ?") + .run("Edited question after partial reply — old revision must not close this", askBNeg.qitemId); + + const retryBNeg = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(bodyBNeg), + }); + expect(retryBNeg.status).toBe(409); + expect(await retryBNeg.json()).toMatchObject({ + error: "decision_revision_changed", + }); + expect(f.daemon.queueRepo.getById(askBNeg.qitemId)?.state).toBe("pending"); + const keyBNeg = computeRelayEventKey("relay-main", "evt-crash-b-neg"); + expect(seen.statuses().get(keyBNeg)).toMatch(/^received:/); + f.daemon.queueRepo.createHumanAnswer = origCreateHumanAnswer; + + // Case B2: Crash between ask close (resolveDirectHumanReply) and audit record (missionControlActionLog.record), + // then retry the same received event through a fresh handler -> one answer, one close/resume transition, one audit row. + const askB2 = await createAskWithAcceptedRelayRef(f); + const bodyB2 = { + eventId: "evt-crash-b2-close-before-audit", + sender: { handle: "founder-handle" }, + replyRef: askB2.replyRef, + text: "Recovered answer B2 after close-before-audit crash", + ordering: "after-question" as const, + }; + const origRecord = f.daemon.missionControlActionLog.record.bind(f.daemon.missionControlActionLog); + let failAuditOnce = true; + f.daemon.missionControlActionLog.record = (entry) => { + if (failAuditOnce && entry.qitemId === askB2.qitemId) { + failAuditOnce = false; + throw new Error("simulated crash after ask close before audit record"); + } + return origRecord(entry); + }; + + const failedAttemptB2 = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(bodyB2), + }); + expect(failedAttemptB2.status).toBe(500); + f.daemon.missionControlActionLog.record = origRecord; + + const keyB2 = computeRelayEventKey("relay-main", "evt-crash-b2-close-before-audit"); + expect(seen.statuses().get(keyB2)).toMatch(/^received:/); + // Close rolled back atomically with the failed audit; only the 1 reply row is present + expect(f.daemon.queueRepo.getById(askB2.qitemId)?.state).toBe("pending"); + expect(f.daemon.missionControlActionLog.listForQitem(askB2.qitemId, 5)).toHaveLength(0); + + // Retry the same received event through a freshly constructed RelayInboundHandler + const freshHandler = createRelayInboundHandler({ + home: f.home, + queueRepo: f.daemon.queueRepo, + missionControlWriteContract: f.daemon.missionControlWriteContract, + missionControlActionLog: f.daemon.missionControlActionLog, + }); + const retryB2 = await freshHandler.handle({ + authorizationHeader: `Bearer ${f.inboundTokenMain}`, + rawBody: JSON.stringify(bodyB2), + }); + expect(retryB2.status).toBe(200); + expect(seen.statuses().get(keyB2)).toMatch(/^processed:/); + expect(f.daemon.queueRepo.getById(askB2.qitemId)?.state).toBe("done"); + expect(f.daemon.missionControlActionLog.listForQitem(askB2.qitemId, 5)).toHaveLength(1); + const b2Replies = f.daemon.db + .prepare("SELECT count(*) as n FROM queue_items WHERE reply_to = ?") + .get(askB2.qitemId) as { n: number }; + expect(b2Replies.n).toBe(1); + const b2CloseTransitions = f.daemon.queueRepo + .listTransitions(askB2.qitemId) + .filter((t) => t.ownerNotificationKind === "human-decision-resolved"); + expect(b2CloseTransitions).toHaveLength(1); + + // Case C: Crash after resolve commit before processed mark, AND qitem is subsequently re-parked in a new episode! + // Retry of the original event must reconcile its OWN committed resolve and mark processed without unparking the new episode. + const parkC = await createParkedItemWithAcceptedRelayRef(f); + const bodyC = { + eventId: "evt-crash-c", + sender: { handle: "founder-handle" }, + replyRef: parkC.replyRef, + text: "Approved episode 1 before crash", + ordering: "after-question" as const, + }; + // Simulate that attempt 1 reserved 'received' and committed MC resolve, then crashed before marking 'processed' + f.daemon.missionControlWriteContract.act = async (input) => { + const res = await origAct(input); + throw new Error("simulated crash after MC resolve commit before processed mark"); + return res; + }; + const failedAttemptC = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(bodyC), + }); + expect(failedAttemptC.status).toBe(500); + expect(f.daemon.queueRepo.getById(parkC.qitemId)?.state).toBe("in-progress"); + + // Restore normal act() and re-park the item into Episode 2 + f.daemon.missionControlWriteContract.act = origAct; + f.daemon.queueRepo.update({ + qitemId: parkC.qitemId, + actorSession: "builder@rig", + state: "blocked", + closureReason: "blocked_on", + closureTarget: "founder@external", + blockedOn: "founder@external", + transitionNote: "Re-parked into episode 2 while retry of episode 1 is in flight", + }); + + // Retry of bodyC reconciles its own episode 1 operation, marks processed, returns 200, and leaves Episode 2 blocked! + const retryC = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(bodyC), + }); + expect(retryC.status).toBe(200); + const keyC = computeRelayEventKey("relay-main", "evt-crash-c"); + expect(seen.statuses().get(keyC)).toMatch(/^processed:/); + expect(f.daemon.queueRepo.getById(parkC.qitemId)?.state).toBe("blocked"); + + // Case D: Seen-ledger scoping: + // D1. Keyed records with valid received:/processed: still vouch for identity/result even with + // missing/invalid ts or extra diagnostic metadata; truly missing/unknown status or conflicting fingerprints + // refuse ONLY that key (503, never unseen), while unrelated keys remain available. + const askProcessedNoTs = await createAskWithAcceptedRelayRef(f); + const eventProcessedNoTs = "evt-processed-missing-ts"; + const keyProcessedNoTs = computeRelayEventKey("relay-main", eventProcessedNoTs); + const fpProcessedNoTs = computeRelayEventFingerprint({ + connectorRef: "relay-main", + eventId: eventProcessedNoTs, + senderHandle: "founder-handle", + actorSession: "founder@external", + replyRef: askProcessedNoTs.replyRef, + qitemId: askProcessedNoTs.qitemId, + episodeKey: askProcessedNoTs.payload.episodeKey, + decisionRevision: askProcessedNoTs.payload.decisionRevision, + mode: askProcessedNoTs.payload.mode, + ordering: "after-question", + text: "Already processed with missing ts", + }); + + const askReceivedNoTs = await createAskWithAcceptedRelayRef(f); + const eventReceivedNoTs = "evt-received-missing-ts"; + const keyReceivedNoTs = computeRelayEventKey("relay-main", eventReceivedNoTs); + const fpReceivedNoTs = computeRelayEventFingerprint({ + connectorRef: "relay-main", + eventId: eventReceivedNoTs, + senderHandle: "founder-handle", + actorSession: "founder@external", + replyRef: askReceivedNoTs.replyRef, + qitemId: askReceivedNoTs.qitemId, + episodeKey: askReceivedNoTs.payload.episodeKey, + decisionRevision: askReceivedNoTs.payload.decisionRevision, + mode: askReceivedNoTs.payload.mode, + ordering: "after-question", + text: "Reserved received with missing ts", + }); + + const suspectEventId1 = "evt-suspect-unknown-status"; + const suspectKey1 = computeRelayEventKey("relay-main", suspectEventId1); + const suspectEventId2 = "evt-suspect-missing-status"; + const suspectKey2 = computeRelayEventKey("relay-main", suspectEventId2); + const suspectEventId3 = "evt-suspect-conflicting-fps"; + const suspectKey3 = computeRelayEventKey("relay-main", suspectEventId3); + const existingSeenRaw = readFileSync(seenPath, "utf8"); + writeFileSync( + seenPath, + existingSeenRaw + + JSON.stringify({ id: keyProcessedNoTs, status: `processed:${fpProcessedNoTs}`, extraDiag: 123 }) + + "\n" + + JSON.stringify({ id: keyReceivedNoTs, ts: null, status: `received:${fpReceivedNoTs}`, note: "diag" }) + + "\n" + + JSON.stringify({ id: suspectKey1, ts: "2026-11-07T00:00:00.000Z", status: "unknown:status" }) + + "\n" + + JSON.stringify({ id: suspectKey2, ts: "2026-11-07T00:00:00.000Z" }) + + "\n" + + JSON.stringify({ id: suspectKey3, status: `received:${"a".repeat(64)}` }) + + "\n" + + JSON.stringify({ id: suspectKey3, status: `processed:${"b".repeat(64)}` }) + + "\n", + "utf8", + ); + + // Valid processed record missing ts still deduplicates safely (200, zero new effects) and rejects conflict (409) + const processedNoTsRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: eventProcessedNoTs, + sender: { handle: "founder-handle" }, + replyRef: askProcessedNoTs.replyRef, + text: "Already processed with missing ts", + ordering: "after-question", + }), + }); + expect(processedNoTsRes.status).toBe(200); + expect(await processedNoTsRes.json()).toMatchObject({ ok: true, deduplicated: true }); + expect(f.daemon.queueRepo.getById(askProcessedNoTs.qitemId)?.state).toBe("pending"); + expect(f.daemon.missionControlActionLog.listForQitem(askProcessedNoTs.qitemId, 5)).toHaveLength(0); + + const processedNoTsConflict = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: eventProcessedNoTs, + sender: { handle: "founder-handle" }, + replyRef: askProcessedNoTs.replyRef, + text: "Different text under processed missing-ts key", + ordering: "after-question", + }), + }); + expect(processedNoTsConflict.status).toBe(409); + + // Valid received record missing ts can safely complete/reconcile (200) + const receivedNoTsRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: eventReceivedNoTs, + sender: { handle: "founder-handle" }, + replyRef: askReceivedNoTs.replyRef, + text: "Reserved received with missing ts", + ordering: "after-question", + }), + }); + expect(receivedNoTsRes.status).toBe(200); + expect(f.daemon.queueRepo.getById(askReceivedNoTs.qitemId)?.state).toBe("done"); + expect(seen.statuses().get(keyReceivedNoTs)).toBe(`processed:${fpReceivedNoTs}`); + + // Unrelated already-processed keyA still deduplicates (200) and rejects equivocation (409) + const dupA = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(bodyA), + }); + expect(dupA.status).toBe(200); + expect(await dupA.json()).toMatchObject({ ok: true, deduplicated: true }); + + const conflictA = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ ...bodyA, text: "Conflicting text for keyA" }), + }); + expect(conflictA.status).toBe(409); + + // Unrelated brand-new key on a fresh ask still succeeds (200) + const askUnrelated = await createAskWithAcceptedRelayRef(f); + const unrelatedRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-unrelated-healthy", + sender: { handle: "founder-handle" }, + replyRef: askUnrelated.replyRef, + text: "Unrelated key succeeds despite keyed suspect records", + ordering: "after-question", + }), + }); + expect(unrelatedRes.status).toBe(200); + expect(f.daemon.queueRepo.getById(askUnrelated.qitemId)?.state).toBe("done"); + + // Request for the keyed suspect eventIds (unknown status, missing status, conflicting fingerprints) is refused with 503 (never treated as unseen!) + const askSuspect = await createAskWithAcceptedRelayRef(f); + for (const badEventId of [suspectEventId1, suspectEventId2, suspectEventId3]) { + const suspectRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: badEventId, + sender: { handle: "founder-handle" }, + replyRef: askSuspect.replyRef, + text: "Should be refused because its keyed seen record is unvouched", + ordering: "after-question", + }), + }); + expect(suspectRes.status, `eventId=${badEventId}`).toBe(503); + const suspectJson = (await suspectRes.json()) as Record; + expect(suspectJson).toMatchObject({ + error: "seen_store_unavailable", + seenPath, + }); + expect(String(suspectJson.message)).toContain(seenPath); + } + expect(f.daemon.queueRepo.getById(askSuspect.qitemId)?.state).toBe("pending"); + + // D2. Unkeyable record (missing id) and unparseable JSONL line refuse with scoped 503 even for unseen keys + writeFileSync( + seenPath, + existingSeenRaw + JSON.stringify({ ts: "2026-11-07T00:00:00.000Z", status: `processed:${"b".repeat(64)}` }) + "\n", + "utf8", + ); + const unkeyableRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-unseen-during-unkeyable-history", + sender: { handle: "founder-handle" }, + replyRef: askSuspect.replyRef, + text: "Must not count as unseen when history has unkeyable line", + ordering: "after-question", + }), + }); + expect(unkeyableRes.status).toBe(503); + expect(await unkeyableRes.json()).toMatchObject({ + error: "seen_store_unavailable", + seenPath, + }); + expect(f.daemon.queueRepo.getById(askSuspect.qitemId)?.state).toBe("pending"); + + writeFileSync(seenPath, "{corrupt-jsonl-line\n", "utf8"); + const corruptRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(bodyA), + }); + expect(corruptRes.status).toBe(503); + expect(await corruptRes.json()).toMatchObject({ + error: "seen_store_unavailable", + seenPath, + }); + }); + + it("distinguishes normal noHumans (401) from broken or thrown registry reads (503) and preserves receipt-first verification when dispatch buffer is unreadable (503 for un-receipted refs)", async () => { + const f = await createRelayFixture(); + const receiptedAsk = await createAskWithAcceptedRelayRef(f); + const unreceiptedAsk = await createAskWithAcceptedRelayRef(f, { recordViaPendingBufferOnly: true }); + + // 1. Normal noHumans (empty registry directory with zero fragments) -> 401 unauthorized (not 503 outage) + const emptyHome = mkdtempSync(join(tmpdir(), "relay-empty-humans-")); + try { + const emptyHandler = createRelayInboundHandler({ + home: emptyHome, + queueRepo: f.daemon.queueRepo, + missionControlWriteContract: f.daemon.missionControlWriteContract, + missionControlActionLog: f.daemon.missionControlActionLog, + }); + const noHumansRes = await emptyHandler.handle({ + authorizationHeader: `Bearer ${f.inboundTokenMain}`, + rawBody: JSON.stringify({ + eventId: "evt-no-humans-1", + sender: { handle: "founder-handle" }, + replyRef: receiptedAsk.replyRef, + text: "No humans configured", + }), + }); + expect(noHumansRes.status).toBe(401); + expect(noHumansRes.body.error).toBe("unauthorized"); + } finally { + rmSync(emptyHome, { recursive: true, force: true }); + } + + // 2. Returned broken registry (ok: false without noHumans) -> 503 human_registry_unavailable + const brokenRegistryHandler = createRelayInboundHandler({ + home: f.home, + queueRepo: f.daemon.queueRepo, + missionControlWriteContract: f.daemon.missionControlWriteContract, + missionControlActionLog: f.daemon.missionControlActionLog, + loadEntities: () => ({ ok: false, error: "corrupt human fragment YAML" }), + }); + const brokenRegRes = await brokenRegistryHandler.handle({ + authorizationHeader: `Bearer ${f.inboundTokenMain}`, + rawBody: JSON.stringify({ + eventId: "evt-broken-reg-1", + sender: { handle: "founder-handle" }, + replyRef: receiptedAsk.replyRef, + text: "Broken registry", + }), + }); + expect(brokenRegRes.status).toBe(503); + expect(brokenRegRes.body.error).toBe("human_registry_unavailable"); + + // 3. Thrown registry read exception -> 503 human_registry_unavailable + const throwingRegistryHandler = createRelayInboundHandler({ + home: f.home, + queueRepo: f.daemon.queueRepo, + missionControlWriteContract: f.daemon.missionControlWriteContract, + missionControlActionLog: f.daemon.missionControlActionLog, + loadEntities: () => { + throw new Error("EIO: disk read failure on human registry"); + }, + }); + const thrownRegRes = await throwingRegistryHandler.handle({ + authorizationHeader: `Bearer ${f.inboundTokenMain}`, + rawBody: JSON.stringify({ + eventId: "evt-thrown-reg-1", + sender: { handle: "founder-handle" }, + replyRef: receiptedAsk.replyRef, + text: "Thrown registry", + }), + }); + expect(thrownRegRes.status).toBe(503); + expect(thrownRegRes.body.error).toBe("human_registry_unavailable"); + + // 4. Corrupt the local DispatchBuffer file: + // - A replyRef already vouched for by a durable accepted receipt still succeeds (200 OK) + // - An un-receipted replyRef that requires reading DispatchBuffer returns 503 dispatch_buffer_unavailable + // with zero seen-store reservation and zero action side effects. + const bufferPath = join(f.home, "gateway", "dispatch-buffer.json"); + writeFileSync(bufferPath, "{corrupt-dispatch-buffer-json\n", "utf8"); + + const receiptedRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-receipt-wins-over-corrupt-buffer", + sender: { handle: "founder-handle" }, + replyRef: receiptedAsk.replyRef, + text: "Durable receipt vouches for this replyRef despite corrupt buffer", + ordering: "after-question", + }), + }); + expect(receiptedRes.status).toBe(200); + expect(f.daemon.queueRepo.getById(receiptedAsk.qitemId)?.state).toBe("done"); + + const unreceiptedEventId = "evt-unreceipted-corrupt-buffer"; + const unreceiptedRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: unreceiptedEventId, + sender: { handle: "founder-handle" }, + replyRef: unreceiptedAsk.replyRef, + text: "Should return 503 because buffer cannot be read to verify un-receipted ref", + ordering: "after-question", + }), + }); + expect(unreceiptedRes.status).toBe(503); + expect(await unreceiptedRes.json()).toMatchObject({ + error: "dispatch_buffer_unavailable", + }); + const seen = new SeenStore(join(f.home, "state", RELAY_INBOUND_SEEN_FILE)); + expect(seen.statuses().get(computeRelayEventKey("relay-main", unreceiptedEventId))).toBeUndefined(); + expect(f.daemon.queueRepo.getById(unreceiptedAsk.qitemId)?.state).toBe("pending"); + expect(f.daemon.missionControlActionLog.listForQitem(unreceiptedAsk.qitemId, 5)).toHaveLength(0); + }); + + it("exposes sanitized relay readiness on /api/gateway/human/:entityId/readiness and /api/gateway/connections without leaking secrets", async () => { + const f = await createRelayFixture(); + + const readinessRes = await fetch(`${f.baseUrl}/api/gateway/human/founder/readiness`); + expect(readinessRes.status).toBe(200); + const readinessBody = (await readinessRes.json()) as Record; + expect(readinessBody).toMatchObject({ + ok: true, + readiness: { + state: "ready", + configured: true, + enabled: true, + active: true, + ready: true, + connector: { + kind: "relay", + ref: "relay-main", + }, + }, + }); + const readinessText = JSON.stringify(readinessBody); + expect(readinessText).not.toContain(f.inboundTokenMain); + expect(readinessText).not.toContain(f.outboundTokenMain); + + const connectionsRes = await fetch(`${f.baseUrl}/api/gateway/connections`); + expect(connectionsRes.status).toBe(200); + const connectionsBody = (await connectionsRes.json()) as Record; + const connectionsText = JSON.stringify(connectionsBody); + expect(connectionsText).not.toContain(f.inboundTokenMain); + expect(connectionsText).not.toContain(f.outboundTokenMain); + expect(connectionsBody.relay).toMatchObject({ + configured: true, + ready: true, + }); + }); + + it("keeps generated replyRef fixed-size (<= 4096 chars and fitting 64 KiB body) for arbitrary high-entropy qitemId, connectorRef, episodeKey, and decisionId across pending DispatchBuffer, active/archived accepted receipts, digest items, and 503/dedup target guards", async () => { + const f = await createRelayFixture({ registerSecondaryRelay: true }); + + const highEntropyStream = (label: string, blocks: number) => + Array.from({ length: blocks }, (_, idx) => + createHash("sha512").update(`${label}:${idx}`).digest("base64url"), + ).join("-"); + + // Register a relay connector profile with a > 19,000-char high-entropy connectorRef and two bound humans + const hugeConnectorRef = `relay-${highEntropyStream("connector-ref", 220)}`; + const hugeInboundToken = "in-tok-huge-connector-654321"; + const hugeOutboundToken = "out-tok-huge-connector-123456"; + const hugeEnvPath = join(f.home, "secrets", "relay-huge.env"); + writeFileSync( + hugeEnvPath, + [ + "OPENRIG_RELAY_URL=http://127.0.0.1:43199/outbound", + `OPENRIG_RELAY_OUTBOUND_TOKEN=${hugeOutboundToken}`, + `OPENRIG_RELAY_INBOUND_TOKEN=${hugeInboundToken}`, + ].join("\n") + "\n", + { mode: 0o600 }, + ); + chmodSync(hugeEnvPath, 0o600); + + addHumanFragment( + { + entityId: "longfounder", + class: "human", + displayName: "Long Founder", + address: "longfounder@external", + connectorBindings: [ + { + kind: "relay", + connectorRef: hugeConnectorRef, + handle: "longfounder-handle", + secretsRef: `file:${hugeEnvPath}`, + role: "primary", + }, + ], + prefs: { deliveryClass: "A" }, + }, + f.home, + ); + addHumanFragment( + { + entityId: "longpartner", + class: "human", + displayName: "Long Partner", + address: "longpartner@external", + connectorBindings: [ + { + kind: "relay", + connectorRef: hugeConnectorRef, + handle: "longpartner-handle", + secretsRef: `file:${hugeEnvPath}`, + role: "primary", + }, + ], + prefs: { deliveryClass: "A" }, + }, + f.home, + ); + + const rekeyQitemId = (oldId: string, newId: string) => { + f.db.pragma("foreign_keys = OFF"); + try { + f.db.prepare("UPDATE queue_items SET qitem_id = ? WHERE qitem_id = ?").run(newId, oldId); + f.db.prepare("UPDATE queue_transitions SET qitem_id = ? WHERE qitem_id = ?").run(newId, oldId); + f.db.prepare("UPDATE outbox_entries SET audit_pointer = ? WHERE audit_pointer = ?").run(newId, oldId); + } finally { + f.db.pragma("foreign_keys = ON"); + } + }; + + // 1. Pending DispatchBuffer round-trip with > 19,000-char qitemId, episodeKey, connectorRef, and decisionId + // (old inline base64url encoding > 100,000 chars > 4096 chars and > 64 KiB body; new token is exactly 246 chars) + const seededPending = await f.daemon.queueRepo.create({ + sourceSession: "architect@rig", + destinationSession: "longfounder@external", + summary: "High-entropy pending ask", + body: "Approve high-entropy pending deployment?", + humanIntent: "decision", + humanAlert: true, + humanQuestions: [ + { + id: "q-huge-pending", + header: "Deploy", + question: "Ship high-entropy pending build?", + options: [ + { id: "yes", label: "Yes", description: "Proceed" }, + { id: "no", label: "No", description: "Hold" }, + ], + }, + ], + nudge: false, + }); + const hugeQitemIdPending = `qi-${highEntropyStream("qitem-pending", 220)}`; + rekeyQitemId(seededPending.qitemId, hugeQitemIdPending); + + const hugeEpisodeKeyPending = f.daemon.queueRepo.currentHumanRequiredEpisode(hugeQitemIdPending)!.key; + const hugeDecisionIdPending = `dec-${highEntropyStream("decision-pending", 220)}`; + expect(hugeQitemIdPending.length).toBeGreaterThan(19_000); + expect(hugeConnectorRef.length).toBeGreaterThan(19_000); + expect(hugeEpisodeKeyPending.length).toBeGreaterThan(19_000); + expect(hugeDecisionIdPending.length).toBeGreaterThan(19_000); + + const preparedPendingHuge = prepareRelayDecision( + { + kind: "outbound_decision", + decisionId: hugeDecisionIdPending, + op: OUTBOUND_OP, + entityBindingRef: "longfounder@external", + payload: { + qitemId: hugeQitemIdPending, + notificationKey: hugeEpisodeKeyPending, + destinationSession: "longfounder@external", + summary: "High-entropy pending ask", + body: "Approve high-entropy pending deployment?", + humanIntent: "decision", + ownerNotificationKind: "human-required", + }, + enqueuedAt: new Date().toISOString(), + }, + { + loadHumanRegistry: () => loadHumanRegistry(f.home), + queueRepo: f.daemon.queueRepo, + resolveOutcome: () => "interrupt", + }, + ); + const pendingHugeEnv = (preparedPendingHuge.payload as { relayEnvelope?: { replyRef: string | null } }).relayEnvelope!; + expect(pendingHugeEnv.replyRef).not.toBeNull(); + expect(pendingHugeEnv.replyRef!.length).toBeLessThanOrEqual(4096); + expect(pendingHugeEnv.replyRef!.length).toBe(246); + + const buffer = new DispatchBuffer(f.home); + buffer.enqueue(preparedPendingHuge); + + // Cross-connector guard: relay-main token rejected (403) when trying to answer hugeConnectorRef's replyRef + const crossConnRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-huge-cross-conn-1", + sender: { handle: "founder-handle" }, + replyRef: pendingHugeEnv.replyRef, + text: "Cross connector attempt", + ordering: "after-question", + }), + }); + expect(crossConnRes.status).toBe(403); + expect(await crossConnRes.json()).toMatchObject({ error: "connector_ref_mismatch" }); + + const inboundPendingBody = JSON.stringify({ + eventId: "evt-huge-pending-1", + sender: { handle: "longfounder-handle" }, + replyRef: pendingHugeEnv.replyRef, + text: "Yes, ship from pending buffer.", + ordering: "after-question", + }); + expect(Buffer.byteLength(inboundPendingBody, "utf8")).toBeLessThanOrEqual(65_536); + + const pendingRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${hugeInboundToken}`, + "Content-Type": "application/json", + }, + body: inboundPendingBody, + }); + expect(pendingRes.status).toBe(200); + expect(await pendingRes.json()).toMatchObject({ + ok: true, + qitemId: hugeQitemIdPending, + verb: "answer", + actorSession: "longfounder@external", + deduplicated: false, + }); + expect(f.daemon.queueRepo.getById(hugeQitemIdPending)?.state).toBe("done"); + buffer.ack(hugeDecisionIdPending); + + // 2. Accepted receipt round-trip after buffer drain (with corrupt dispatch-buffer.json + fresh handler + registered partner attribution + archive) + const seededReceipt = await f.daemon.queueRepo.create({ + sourceSession: "architect@rig", + destinationSession: "longfounder@external", + summary: "High-entropy receipt ask", + body: "Approve high-entropy receipt deployment?", + humanIntent: "decision", + humanAlert: true, + humanQuestions: [ + { + id: "q-huge-receipt", + header: "Deploy Receipt", + question: "Ship high-entropy receipt build?", + options: [ + { id: "yes", label: "Yes", description: "Proceed" }, + { id: "no", label: "No", description: "Hold" }, + ], + }, + ], + nudge: false, + }); + const hugeQitemIdReceipt = `qi-${highEntropyStream("qitem-receipt", 220)}`; + rekeyQitemId(seededReceipt.qitemId, hugeQitemIdReceipt); + + const receiptHugeRow = f.daemon.queueRepo.getById(hugeQitemIdReceipt)!; + const hugeEpisodeKeyReceipt = f.daemon.queueRepo.currentHumanRequiredEpisode(hugeQitemIdReceipt)!.key; + const hugeRevisionReceipt = f.daemon.queueRepo.decisionRevision(receiptHugeRow); + const hugeDecisionIdReceipt = `dec-${highEntropyStream("decision-receipt", 220)}`; + + const preparedReceiptHuge = prepareRelayDecision( + { + kind: "outbound_decision", + decisionId: hugeDecisionIdReceipt, + op: OUTBOUND_OP, + entityBindingRef: "longfounder@external", + payload: { + qitemId: hugeQitemIdReceipt, + notificationKey: hugeEpisodeKeyReceipt, + destinationSession: "longfounder@external", + summary: "High-entropy receipt ask", + body: "Approve high-entropy receipt deployment?", + humanIntent: "decision", + ownerNotificationKind: "human-required", + }, + enqueuedAt: new Date().toISOString(), + }, + { + loadHumanRegistry: () => loadHumanRegistry(f.home), + queueRepo: f.daemon.queueRepo, + resolveOutcome: () => "interrupt", + }, + ); + const receiptHugeEnv = (preparedReceiptHuge.payload as { relayEnvelope?: { replyRef: string | null } }).relayEnvelope!; + expect(receiptHugeEnv.replyRef).not.toBeNull(); + expect(receiptHugeEnv.replyRef!.length).toBeLessThanOrEqual(4096); + expect(receiptHugeEnv.replyRef!.length).toBe(246); + + // Record durable accepted receipt (no pending buffer entry) and corrupt dispatch-buffer.json on disk + f.daemon.queueRepo.recordRelayOwnerNotificationAccepted({ + qitemId: hugeQitemIdReceipt, + actorSession: "relay-connector", + notificationKey: hugeEpisodeKeyReceipt, + connectorRef: hugeConnectorRef, + deliveryId: hugeDecisionIdReceipt, + replyRef: receiptHugeEnv.replyRef, + replyRefHash: hashRelayReplyRef(receiptHugeEnv.replyRef!), + revision: hugeRevisionReceipt, + episodeKey: hugeEpisodeKeyReceipt, + level: "ALERT", + kind: "human-required", + closeUpdate: false, + }); + const bufferPath = join(f.home, "gateway", "dispatch-buffer.json"); + writeFileSync(bufferPath, "{corrupt-buffer-while-receipt-valid\n", "utf8"); + + const inboundReceiptBody = JSON.stringify({ + eventId: "evt-huge-receipt-partner-1", + sender: { handle: "longpartner-handle" }, + replyRef: receiptHugeEnv.replyRef, + text: "Partner approves from durable receipt with corrupt buffer.", + ordering: "after-question", + }); + expect(Buffer.byteLength(inboundReceiptBody, "utf8")).toBeLessThanOrEqual(65_536); + + // Fresh handler instance (simulating restart) resolves from durable accepted receipt despite corrupt buffer + const restartedHandler = createRelayInboundHandler({ + home: f.home, + queueRepo: f.daemon.queueRepo, + missionControlWriteContract: f.daemon.missionControlWriteContract, + missionControlActionLog: f.daemon.missionControlActionLog, + }); + const receiptPartnerRes = await restartedHandler.handle({ + authorizationHeader: `Bearer ${hugeInboundToken}`, + rawBody: inboundReceiptBody, + }); + expect(receiptPartnerRes.status).toBe(200); + expect(receiptPartnerRes.body).toMatchObject({ + ok: true, + qitemId: hugeQitemIdReceipt, + verb: "answer", + actorSession: "longpartner@external", + deduplicated: false, + }); + expect(f.daemon.queueRepo.getById(hugeQitemIdReceipt)?.state).toBe("done"); + + // Restore valid empty dispatch-buffer.json, archive the terminal transitions into queue_transitions_archive, and verify HTTP replay + writeFileSync(bufferPath, "[]\n", "utf8"); + const oldIso = "2025-01-01T00:00:00.000Z"; + f.db.prepare("UPDATE queue_items SET ts_updated = ? WHERE qitem_id = ?").run(oldIso, hugeQitemIdReceipt); + f.db.prepare("UPDATE queue_transitions SET ts = ? WHERE qitem_id = ?").run(oldIso, hugeQitemIdReceipt); + expect( + archiveAgedTerminalTransitions(f.db, { nowIso: "2026-11-07T00:00:00.000Z" }).archivedQitems, + ).toBeGreaterThanOrEqual(1); + + const archivedReplayRes = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${hugeInboundToken}`, + "Content-Type": "application/json", + }, + body: inboundReceiptBody, + }); + expect(archivedReplayRes.status).toBe(200); + expect(await archivedReplayRes.json()).toMatchObject({ + ok: true, + qitemId: hugeQitemIdReceipt, + actorSession: "longpartner@external", + deduplicated: true, + }); + + // 3. Processed-event dedup runs BEFORE current-target getById/listTransitions verification: + // - When getById returns null (target missing) or throws (target DB read error) while receipt + processed seen ledger exist: + // * exact duplicate replay still returns 200 OK (deduplicated: true) without touching getById + // * fresh new eventId with the same replyRef returns 404 (when missing) or 503 (when throwing) with zero seen reservation + let getByIdCalls = 0; + const missingTargetRepo = new Proxy(f.daemon.queueRepo, { + get(target, prop, receiver) { + if (prop === "getById") { + return () => { + getByIdCalls++; + return null; + }; + } + return Reflect.get(target, prop, receiver); + }, + }); + const missingTargetHandler = createRelayInboundHandler({ + home: f.home, + queueRepo: missingTargetRepo, + missionControlWriteContract: f.daemon.missionControlWriteContract, + missionControlActionLog: f.daemon.missionControlActionLog, + }); + const dedupWhenMissingRes = await missingTargetHandler.handle({ + authorizationHeader: `Bearer ${hugeInboundToken}`, + rawBody: inboundReceiptBody, + }); + expect(dedupWhenMissingRes.status).toBe(200); + expect(dedupWhenMissingRes.body).toMatchObject({ + ok: true, + qitemId: hugeQitemIdReceipt, + deduplicated: true, + }); + expect(getByIdCalls).toBe(0); + + const seen = new SeenStore(join(f.home, "state", RELAY_INBOUND_SEEN_FILE)); + const freshMissingEventId = "evt-fresh-when-target-missing-1"; + const freshWhenMissingRes = await missingTargetHandler.handle({ + authorizationHeader: `Bearer ${hugeInboundToken}`, + rawBody: JSON.stringify({ + eventId: freshMissingEventId, + sender: { handle: "longpartner-handle" }, + replyRef: receiptHugeEnv.replyRef, + text: "Fresh event when target row is gone", + ordering: "after-question", + }), + }); + expect(freshWhenMissingRes.status).toBe(404); + expect(freshWhenMissingRes.body.error).toBe("qitem_not_found"); + expect(seen.statuses().get(computeRelayEventKey(hugeConnectorRef, freshMissingEventId))).toBeUndefined(); + + const throwingTargetRepo = new Proxy(f.daemon.queueRepo, { + get(target, prop, receiver) { + if (prop === "getById") { + return () => { + throw new Error("SQLITE_IOERR: simulated getById failure"); + }; + } + return Reflect.get(target, prop, receiver); + }, + }); + const throwingTargetHandler = createRelayInboundHandler({ + home: f.home, + queueRepo: throwingTargetRepo, + missionControlWriteContract: f.daemon.missionControlWriteContract, + missionControlActionLog: f.daemon.missionControlActionLog, + }); + const dedupWhenThrowingRes = await throwingTargetHandler.handle({ + authorizationHeader: `Bearer ${hugeInboundToken}`, + rawBody: inboundReceiptBody, + }); + expect(dedupWhenThrowingRes.status).toBe(200); + expect(dedupWhenThrowingRes.body).toMatchObject({ + ok: true, + qitemId: hugeQitemIdReceipt, + deduplicated: true, + }); + + const freshThrowingEventId = "evt-fresh-when-target-throws-1"; + const freshWhenThrowingRes = await throwingTargetHandler.handle({ + authorizationHeader: `Bearer ${hugeInboundToken}`, + rawBody: JSON.stringify({ + eventId: freshThrowingEventId, + sender: { handle: "longpartner-handle" }, + replyRef: receiptHugeEnv.replyRef, + text: "Fresh event when target getById throws", + ordering: "after-question", + }), + }); + expect(freshWhenThrowingRes.status).toBe(503); + expect(freshWhenThrowingRes.body.error).toBe("queue_store_unavailable"); + expect(seen.statuses().get(computeRelayEventKey(hugeConnectorRef, freshThrowingEventId))).toBeUndefined(); + + // 4. Multi-item digest with per-item fixed-size replyRef tokens (264 chars for ask-digest) scopes each reply to its individual question + const digAsk1 = await f.daemon.queueRepo.create({ + sourceSession: "architect@rig", + destinationSession: "founder@external", + summary: "Digest ask 1", + body: "Question 1?", + humanIntent: "decision", + humanAlert: true, + nudge: false, + }); + const digAsk2 = await f.daemon.queueRepo.create({ + sourceSession: "architect@rig", + destinationSession: "founder@external", + summary: "Digest ask 2", + body: "Question 2?", + humanIntent: "decision", + humanAlert: true, + nudge: false, + }); + const ep1 = f.daemon.queueRepo.currentHumanRequiredEpisode(digAsk1.qitemId)!.key; + const ep2 = f.daemon.queueRepo.currentHumanRequiredEpisode(digAsk2.qitemId)!.key; + const digestDecisionId = `digest:${highEntropyStream("digest-id", 60)}`; + const preparedDigest = prepareRelayDecision( + { + kind: "outbound_decision", + decisionId: digestDecisionId, + op: OUTBOUND_OP, + entityBindingRef: "founder@external", + payload: { + deliveryDigestPost: true, + digestId: "multi-huge", + qitemId: digAsk1.qitemId, + destinationSession: "founder@external", + summary: "Digest summary", + body: "• Question 1?\n• Question 2?", + memberReceipts: [ + { qitemId: digAsk1.qitemId, notificationKey: ep1, level: "ALERT", kind: "human-required" }, + { qitemId: digAsk2.qitemId, notificationKey: ep2, level: "ALERT", kind: "human-required" }, + ], + }, + enqueuedAt: new Date().toISOString(), + }, + { + loadHumanRegistry: () => loadHumanRegistry(f.home), + queueRepo: f.daemon.queueRepo, + resolveOutcome: () => "digest", + }, + ); + const digestEnv = ( + preparedDigest.payload as { + relayEnvelope?: { replyRef: string | null; items?: Array<{ qitemId: string; replyRef: string | null }> }; + } + ).relayEnvelope!; + expect(digestEnv.replyRef).toBeNull(); + const itemRef1 = digestEnv.items![0]!.replyRef!; + const itemRef2 = digestEnv.items![1]!.replyRef!; + expect(itemRef1.length).toBeLessThanOrEqual(4096); + expect(itemRef2.length).toBeLessThanOrEqual(4096); + expect(itemRef1.length).toBe(264); + expect(itemRef2.length).toBe(264); + + // Record accepted receipts for both digest member items + f.daemon.queueRepo.recordRelayOwnerNotificationAccepted({ + qitemId: digAsk1.qitemId, + actorSession: "relay-connector", + notificationKey: ep1, + connectorRef: "relay-main", + deliveryId: digestDecisionId, + replyRef: itemRef1, + replyRefHash: hashRelayReplyRef(itemRef1), + revision: f.daemon.queueRepo.decisionRevision(digAsk1), + episodeKey: ep1, + level: "ALERT", + kind: "human-required", + }); + f.daemon.queueRepo.recordRelayOwnerNotificationAccepted({ + qitemId: digAsk2.qitemId, + actorSession: "relay-connector", + notificationKey: ep2, + connectorRef: "relay-main", + deliveryId: digestDecisionId, + replyRef: itemRef2, + replyRefHash: hashRelayReplyRef(itemRef2), + revision: f.daemon.queueRepo.decisionRevision(digAsk2), + episodeKey: ep2, + level: "ALERT", + kind: "human-required", + }); + + const digReply1 = await fetch(`${f.baseUrl}/api/gateway/relay/inbound`, { + method: "POST", + headers: { + Authorization: `Bearer ${f.inboundTokenMain}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + eventId: "evt-digest-item-1", + sender: { handle: "founder-handle" }, + replyRef: itemRef1, + text: "Answer for digest item 1 only", + ordering: "after-question", + }), + }); + expect(digReply1.status).toBe(200); + expect(f.daemon.queueRepo.getById(digAsk1.qitemId)?.state).toBe("done"); + expect(f.daemon.queueRepo.getById(digAsk2.qitemId)?.state).toBe("pending"); + + // 5. Unavailable DB read during receipt lookup returns 503 queue_store_unavailable with zero seen-store reservation or action side effects + const dbThrowingRepo = new Proxy(f.daemon.queueRepo, { + get(target, prop, receiver) { + if (prop === "findRelayOwnerNotificationReceiptByReplyRef") { + return () => { + throw new Error("SQLITE_IOERR: simulated receipt lookup failure"); + }; + } + return Reflect.get(target, prop, receiver); + }, + }); + const dbFailureHandler = createRelayInboundHandler({ + home: f.home, + queueRepo: dbThrowingRepo, + missionControlWriteContract: f.daemon.missionControlWriteContract, + missionControlActionLog: f.daemon.missionControlActionLog, + }); + const dbFailEventId = "evt-db-unavailable-1"; + const dbFailRes = await dbFailureHandler.handle({ + authorizationHeader: `Bearer ${f.inboundTokenMain}`, + rawBody: JSON.stringify({ + eventId: dbFailEventId, + sender: { handle: "founder-handle" }, + replyRef: itemRef2, + text: "Should fail 503 before seen reservation", + ordering: "after-question", + }), + }); + expect(dbFailRes.status).toBe(503); + expect(dbFailRes.body.error).toBe("queue_store_unavailable"); + expect(seen.statuses().get(computeRelayEventKey("relay-main", dbFailEventId))).toBeUndefined(); + expect(f.daemon.queueRepo.getById(digAsk2.qitemId)?.state).toBe("pending"); + expect(f.daemon.missionControlActionLog.listForQitem(digAsk2.qitemId, 5)).toHaveLength(0); + }); +}); diff --git a/packages/daemon/test/gateway-relay-integration.test.ts b/packages/daemon/test/gateway-relay-integration.test.ts new file mode 100644 index 000000000..29809d933 --- /dev/null +++ b/packages/daemon/test/gateway-relay-integration.test.ts @@ -0,0 +1,1261 @@ +/** + * Whole-Capability Production HTTP Integration Tests for Gateway Relay (Capability 3 / #2329) + * + * Justification for dedicated integration file: + * Existing unit/route tests (`relay-config.test.ts`, `relay-outbound.test.ts`, + * `gateway-relay-inbound.test.ts`) exercise individual layers or in-memory DBs. + * This suite proves the whole-capability operational seam that unit tests lack: + * 1. Production `createDaemon` composition over a file-backed SQLite DB (`dbPath`) + * with `test/hermetic-env.setup.ts` fixture-home isolation (`.openrig-fixture` preserved). + * 2. Post-bind network discipline (`startServices()` after TCP bind) + real loopback + * Hono HTTP server (`127.0.0.1:0`) paired with `./fixtures/recording-relay.js`. + * 3. Durable bridge custody before HTTP `2xx`, intentional lost-ACK fault injection, + * and actual daemon + bridge restart over the same SQLite DB and custody file. + */ + +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { serve, type ServerType } from "@hono/node-server"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import type { AddressInfo } from "node:net"; +import { join } from "node:path"; +import { OPENRIG_HOME } from "../src/openrig-compat.js"; +import { createDaemon } from "../src/startup.js"; +import { parseRelayOwnerNotificationTransportFailed } from "../src/domain/queue-transition-log.js"; +import { + addHumanFragment, + loadHumanRegistry, + parseBindingSpec, + setHumanField, +} from "../src/domain/gateway/human-registry.js"; +import { setDefaultPerson } from "../src/domain/gateway/participants.js"; +import { makeOperatorDeliveryEngine } from "../src/domain/gateway/operator-delivery-engine.js"; +import { SlackOutboundDriver } from "../src/domain/gateway/slack/outbound-driver.js"; +import { makeQueuePorts } from "../src/domain/gateway/slack/queue-access.js"; +import { loadConfig } from "../src/domain/gateway/slack/config.js"; +import { SeenStore } from "../src/domain/gateway/slack/state-store.js"; +import { DispatchBuffer } from "../src/domain/gateway/dispatch-buffer.js"; +import { encodeRelayReplyRef } from "../src/domain/gateway/relay-outbound.js"; +import { + computeRelayEventKey, + RELAY_INBOUND_SEEN_FILE, +} from "../src/domain/gateway/relay-inbound.js"; +import { + startRecordingRelay, + type RecordingRelayHandle, +} from "./fixtures/recording-relay.js"; + +type DaemonInstance = Awaited>; + +interface RunningDaemon { + daemon: DaemonInstance; + server: ServerType; + baseUrl: string; + dbPath: string; + stop: () => Promise; +} + +const activeDaemons: RunningDaemon[] = []; +const activeBridges: RecordingRelayHandle[] = []; +let testScratchDir = ""; + +function cleanFixtureGatewayState(): void { + // Preserve `.openrig-fixture` and runtime stubs established by `test/hermetic-env.setup.ts` + for (const sub of ["gateway", "state"]) { + rmSync(join(OPENRIG_HOME, sub), { recursive: true, force: true }); + } +} + +async function waitForCondition( + predicate: () => boolean | Promise, + timeoutMs = 4000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await predicate()) return; + await new Promise((r) => setTimeout(r, 20)); + } + throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`); +} + +function writeRelaySecretsFile( + filePath: string, + vars: { url: string; outboundToken: string; inboundToken: string }, + mode = 0o600, +): string { + const body = [ + `OPENRIG_RELAY_URL=${vars.url}`, + `OPENRIG_RELAY_OUTBOUND_TOKEN=${vars.outboundToken}`, + `OPENRIG_RELAY_INBOUND_TOKEN=${vars.inboundToken}`, + "", + ].join("\n"); + writeFileSync(filePath, body, { encoding: "utf8", mode }); + chmodSync(filePath, mode); + return filePath; +} + +function registerRelayPerson(opts: { + entityId: string; + displayName: string; + connectorRef: string; + envFilePath: string; + handle: string; +}): void { + const spec = `relay:${opts.connectorRef}:file:${opts.envFilePath}:primary:handle=${opts.handle}`; + const parsed = parseBindingSpec(spec); + expect(parsed.ok, `parseBindingSpec(${spec}) must succeed`).toBe(true); + if (!parsed.ok) throw new Error(parsed.error); + + const res = addHumanFragment( + { + entityId: opts.entityId, + class: "human", + displayName: opts.displayName, + address: `${opts.entityId}@external`, + connectorBindings: [ + { + kind: "relay", + connectorRef: parsed.binding.connectorRef, + secretsRef: parsed.binding.secretsRef, + role: "primary", + handle: parsed.binding.handle, + }, + ], + prefs: { deliveryClass: "A" }, + }, + OPENRIG_HOME, + ); + expect(res.ok, `addHumanFragment(${opts.entityId}) must succeed`).toBe(true); +} + +async function bootProductionDaemon(opts?: { + dbPath?: string; + startPostBind?: boolean; +}): Promise { + const dbPath = opts?.dbPath ?? join(testScratchDir, "daemon-e2e.sqlite"); + const daemon = await createDaemon({ + dbPath, + // Labeled fake terminal adapters: no live tmux/cmux server or native provider is touched. + tmuxExec: async () => "", + cmuxFactory: async () => { + throw Object.assign( + new Error("fake-cmux-adapter: no live terminal socket in relay integration test"), + { code: "ENOENT" }, + ); + }, + }); + + if (daemon.deps.sessionTransport) { + daemon.deps.sessionTransport.send = async (sessionName: string) => ({ + ok: true, + sessionName, + sent: true, + verified: true, + }); + } + + let server!: ServerType; + const port = await new Promise((resolve) => { + server = serve( + { fetch: daemon.app.fetch, hostname: "127.0.0.1", port: 0 }, + (info: AddressInfo) => resolve(info.port), + ); + }); + + if (opts?.startPostBind !== false) { + daemon.deps.gatewaySubsystem?.startServices(); + } + + let stopped = false; + const stop = async (): Promise => { + if (stopped) return; + stopped = true; + try { daemon.contextMonitor.stop(); } catch {} + try { daemon.eventLoopMonitor.stop(); } catch {} + try { daemon.deps.watchdogScheduler?.stop(); } catch {} + try { daemon.deps.modelDivergenceMonitor?.stopPolling(); } catch {} + try { daemon.deps.gatewaySubsystem?.stop(); } catch {} + await new Promise((resolve) => server.close(() => resolve())); + try { daemon.db.close(); } catch {} + }; + + const handle: RunningDaemon = { + daemon, + server, + baseUrl: `http://127.0.0.1:${port}`, + dbPath, + stop, + }; + activeDaemons.push(handle); + return handle; +} + +async function sweepOutboundOnce(daemon: DaemonInstance): Promise { + const queueRepo = daemon.deps.queueRepo!; + const subsystem = daemon.deps.gatewaySubsystem!; + const cfg = loadConfig(OPENRIG_HOME); + const seen = new SeenStore( + join(OPENRIG_HOME, "state", "slack-outbound-seen.jsonl"), + ); + const driver = new SlackOutboundDriver({ + home: OPENRIG_HOME, + queue: makeQueuePorts(queueRepo, { + loadHumanRegistry: () => loadHumanRegistry(OPENRIG_HOME), + }), + seen, + filter: { minimumLevel: cfg.minimumLevelThatPosts }, + dispatch: (op, ref, payload) => subsystem.dispatch(op, ref, payload), + }); + await driver.sweepOnce(); +} + +describe("Capability 3 gateway relay — whole-capability production HTTP integration", () => { + beforeAll(() => { + process.env.OPENRIG_NO_KERNEL = "1"; + }); + + afterAll(() => { + delete process.env.OPENRIG_NO_KERNEL; + cleanFixtureGatewayState(); + }); + + beforeEach(() => { + cleanFixtureGatewayState(); + const userHome = process.env.HOME ?? OPENRIG_HOME; + mkdirSync(userHome, { recursive: true }); + testScratchDir = mkdtempSync(join(userHome, "relay-int-")); + }); + + afterEach(async () => { + while (activeDaemons.length > 0) { + await activeDaemons.pop()!.stop(); + } + while (activeBridges.length > 0) { + await activeBridges.pop()!.stop(); + } + if (testScratchDir) { + rmSync(testScratchDir, { recursive: true, force: true }); + testScratchDir = ""; + } + cleanFixtureGatewayState(); + }); + + it( + "proves post-bind network discipline, single-POST > 64 KiB CJK/emoji ordinary ask outbound custody via real daemon poller, probe filtering, and inbound reply with ignored proxy headers/extras and real attribution", + async () => { + const outboundToken = "sec-relay-out-integration-01"; + const inboundToken = "sec-relay-in-integration-01"; + const custodyFilePath = join(testScratchDir, "bridge-custody.jsonl"); + + const bridge = await startRecordingRelay({ + custodyFilePath, + outboundToken, + inboundToken, + }); + activeBridges.push(bridge); + + const envFilePath = writeRelaySecretsFile( + join(testScratchDir, "relay-local.env"), + { url: bridge.outboundUrl, outboundToken, inboundToken }, + 0o600, + ); + + registerRelayPerson({ + entityId: "alice-ops", + displayName: "Alice Ops", + connectorRef: "local-bridge", + envFilePath, + handle: "alice_ops", + }); + + // Configure alice-ops's Slack subscription to urgent-only; primary binding stays relay + const subRes = setHumanField("alice-ops", "slack-subscription", "urgent-only", OPENRIG_HOME); + expect(subRes.ok, "setHumanField slack-subscription urgent-only must succeed").toBe(true); + + const running = await bootProductionDaemon({ startPostBind: false }); + const { daemon } = running; + daemon.deps.rigRepo.createRig("ops-rig"); + + const longSummary = + "Production rollout gate — " + + "日本語確認🧪".repeat(165) + + " [SUMMARY-TAIL-MARKER-901]"; + expect(longSummary.length).toBeGreaterThan(1000); + + const longBody = + "Confirm whether we should promote release candidate v2.4.\n" + + "段落ブロック🚀漢字テスト-alpha-beta-gamma\n".repeat(2400) + + "[BODY-TAIL-MARKER-902]"; + const longDetail = + "Canary telemetry detail: p99=42ms, error_rate=0.00% 🛰️ [DETAIL-TAIL-MARKER-903]"; + + const ask = await daemon.deps.queueRepo!.create({ + sourceSession: "planner@ops-rig", + destinationSession: "alice-ops@external", + summary: longSummary, + body: longBody, + humanIntent: "decision", + humanDetail: longDetail, + humanQuestions: [ + { + id: "q-promote", + question: "Promote release candidate v2.4? [QUESTION-TAIL-MARKER-904]", + options: [ + { + id: "yes", + label: "Promote now [OPT-YES-TAIL-905]", + recommended: true, + }, + { + id: "hold", + label: "Hold [OPT-HOLD-TAIL-906]", + }, + ], + }, + ], + nudge: false, + }); + + // Composition alone must never dial the bridge; GET / probes are ignored. + expect(daemon.deps.gatewaySubsystem?.status().state).toBe("active"); + expect(bridge.getSanitizedStats().relayPostCount).toBe(0); + const probeRes = await fetch(`${bridge.baseUrl}/`, { method: "GET" }); + expect(probeRes.status).toBe(404); + // The bridge fixture ignores non-POST probes; assert own probe was 404, + // zero actual relay POSTs before start, and ignoredProbeCount >= 1 in case + // host/environment sends unrelated probes to the local port. + expect(bridge.getSanitizedStats().ignoredProbeCount).toBeGreaterThanOrEqual(1); + expect(bridge.getSanitizedStats().relayPostCount).toBe(0); + + // Start post-bind services: do NOT call manual sweepOutboundOnce for this first ask. + // Wait for actual composition-owned default 30-second poller to produce the single complete > 64 KiB bridge custody record. + // We budget 38,000ms (38s) for the ~30s production polling cadence to fire. + daemon.deps.gatewaySubsystem?.startServices(); + await waitForCondition( + () => + bridge.getCustodyRecords().length === 1 && + daemon.deps.queueRepo!.hasRelayOwnerNotificationReceipt(ask.qitemId), + 38_000, + ); + + const stats = bridge.getSanitizedStats(); + expect(stats.relayPostCount).toBe(1); + expect(stats.acceptedCount).toBe(1); + expect(stats.custodyCount).toBe(1); + + const custodyRecords = bridge.getCustodyRecords(); + expect(custodyRecords).toHaveLength(1); + + const receipt = daemon.deps.queueRepo!.getRelayOwnerNotificationReceipt(ask.qitemId); + expect(receipt?.connectorRef).toBe("local-bridge"); + const parentDeliveryId = receipt!.deliveryId; + + const loneSurrogateRe = + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?).part).toBeUndefined(); + expect(rec.envelope.summary.length).toBeLessThanOrEqual(1000); + expect(rec.envelope.summary).not.toMatch(loneSurrogateRe); + expect(rec.envelope.body).not.toMatch(loneSurrogateRe); + + const deliveredEnvelope = rec.envelope; + expect(deliveredEnvelope).toMatchObject({ + v: 1, + deliveryId: parentDeliveryId, + recipient: { handle: "alice_ops" }, + intent: "decision", + outcome: "interrupt", + }); + expect(typeof deliveredEnvelope.replyRef).toBe("string"); + expect(deliveredEnvelope.body).toContain(longSummary); + expect(deliveredEnvelope.body).toContain("[SUMMARY-TAIL-MARKER-901]"); + expect(deliveredEnvelope.body).toContain(longBody); + expect(deliveredEnvelope.body).toContain("[BODY-TAIL-MARKER-902]"); + expect(deliveredEnvelope.body).toContain(longDetail); + expect(deliveredEnvelope.body).toContain("[DETAIL-TAIL-MARKER-903]"); + expect(deliveredEnvelope.body).toContain( + "Promote release candidate v2.4? [QUESTION-TAIL-MARKER-904]", + ); + expect(deliveredEnvelope.body).toContain( + "- Promote now [OPT-YES-TAIL-905] (recommended)", + ); + expect(deliveredEnvelope.body).toContain( + "- Hold [OPT-HOLD-TAIL-906]", + ); + // Receipt records bridge custody acceptance only; the ask remains pending until an inbound reply settles it. + expect(daemon.deps.queueRepo!.getById(ask.qitemId)?.state).toBe("pending"); + + // Custody log and sanitized diagnostics must never contain tokens. + const rawCustody = readFileSync(custodyFilePath, "utf8"); + expect(rawCustody).not.toContain(outboundToken); + expect(rawCustody).not.toContain(inboundToken); + expect(JSON.stringify(stats)).not.toContain(outboundToken); + expect(JSON.stringify(stats)).not.toContain(inboundToken); + + // Inbound reply with forwarding/proxy headers and harmless extra fields: + // proxy headers are ignored, extra fields are ignored and cannot spoof sender/actor, + // and event fingerprint is unchanged when replayed without extras. + const rawInboundRes = await fetch( + `${running.baseUrl}/api/gateway/relay/inbound`, + { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${inboundToken}`, + via: "1.1 local-bridge", + "x-forwarded-for": "203.0.113.9", + forwarded: "for=203.0.113.9;proto=http", + "x-real-ip": "203.0.113.9", + }, + body: JSON.stringify({ + eventId: "evt-ordinary-ask-1", + sender: { + handle: "alice_ops", + displayName: "Spoofed Display Name", + externalId: "ext-999", + }, + replyRef: deliveredEnvelope.replyRef!, + text: "Promote now — canary metrics are clean.", + ordering: "after-question", + actorSession: "spoofed-actor@external", + bridgeMetadata: { traceId: "trace-001" }, + }), + }, + ); + expect(rawInboundRes.status).toBe(200); + const replyBody = (await rawInboundRes.json()) as Record; + expect(replyBody).toMatchObject({ + ok: true, + qitemId: ask.qitemId, + verb: "answer", + actorSession: "alice-ops@external", + deduplicated: false, + }); + + const replayWithoutExtras = await bridge.sendInboundReply(running.baseUrl, { + eventId: "evt-ordinary-ask-1", + sender: { handle: "alice_ops" }, + replyRef: deliveredEnvelope.replyRef!, + text: "Promote now — canary metrics are clean.", + ordering: "after-question", + }); + expect(replayWithoutExtras.status).toBe(200); + expect(replayWithoutExtras.body).toMatchObject({ + ok: true, + qitemId: ask.qitemId, + actorSession: "alice-ops@external", + deduplicated: true, + }); + + const settledAsk = daemon.deps.queueRepo!.getById(ask.qitemId)!; + expect(settledAsk.state).toBe("done"); + expect(settledAsk.humanAnswers).toEqual({ + "q-promote": { + kind: "typed-reply", + placement: "first-unanswered", + text: "Promote now — canary metrics are clean.", + unansweredCount: 0, + }, + }); + + const seenRaw = readFileSync( + join(OPENRIG_HOME, "state", RELAY_INBOUND_SEEN_FILE), + "utf8", + ); + expect(seenRaw).not.toContain("Promote now — canary metrics are clean."); + expect(seenRaw).not.toContain(inboundToken); + expect(seenRaw).not.toContain(outboundToken); + }, + 50_000, + ); + + it("delivers operator notices without closing underlying blocked work, exposes accepted outcome on delivered human updates API, and rejects replies to informational updates", async () => { + const outboundToken = "sec-relay-out-integration-02"; + const inboundToken = "sec-relay-in-integration-02"; + const custodyFilePath = join(testScratchDir, "bridge-custody-2.jsonl"); + + const bridge = await startRecordingRelay({ + custodyFilePath, + outboundToken, + inboundToken, + }); + activeBridges.push(bridge); + + const envFilePath = writeRelaySecretsFile( + join(testScratchDir, "relay-local-2.env"), + { url: bridge.outboundUrl, outboundToken, inboundToken }, + 0o600, + ); + + registerRelayPerson({ + entityId: "alice-ops", + displayName: "Alice Ops", + connectorRef: "local-bridge", + envFilePath, + handle: "alice_ops", + }); + setDefaultPerson("alice-ops", OPENRIG_HOME); + + const running = await bootProductionDaemon({ startPostBind: true }); + const { daemon } = running; + daemon.deps.rigRepo.createRig("ops-rig"); + + const stuckBaton = await daemon.deps.queueRepo!.create({ + sourceSession: "planner@ops-rig", + destinationSession: "worker@ops-rig", + summary: "Worker baton stuck on wake", + body: "Need operator attention on worker@ops-rig.", + nudge: false, + }); + + const operatorEngine = makeOperatorDeliveryEngine({ + home: OPENRIG_HOME, + queueRepo: daemon.deps.queueRepo!, + gatewayState: () => + String(daemon.deps.gatewaySubsystem?.status().state ?? "unavailable"), + dispatch: (op, ref, payload, opts) => + daemon.deps.gatewaySubsystem!.dispatch(op, ref, payload, opts), + }); + + const opDispatch = await operatorEngine.dispatchEscalation( + stuckBaton, + "wake-ladder-exhausted", + ); + expect(opDispatch.dispatched).toBe(true); + + await waitForCondition(() => bridge.getCustodyRecords().length === 1); + const opRecord = bridge.getCustodyRecords()[0]!; + expect(opRecord.envelope.recipient.handle).toBe("alice_ops"); + expect(opRecord.envelope.replyRef).toBeNull(); + + await waitForCondition(() => + daemon.deps.queueRepo!.hasRelayOwnerNotificationReceipt(stuckBaton.qitemId), + ); + expect(daemon.deps.queueRepo!.getById(stuckBaton.qitemId)?.state).toBe("pending"); + + const infoUpdate = await daemon.deps.queueRepo!.create({ + sourceSession: "planner@ops-rig", + destinationSession: "alice-ops@external", + summary: "Deployment completed", + body: "FYI: deployment finished with 0 errors.", + humanIntent: "update", + nudge: false, + }); + + await sweepOutboundOnce(daemon); + await waitForCondition(() => bridge.getCustodyRecords().length === 2); + + const updateRecord = bridge.getCustodyRecords()[1]!; + expect(updateRecord.envelope.intent).toBe("update"); + expect(updateRecord.envelope.replyRef).toBeNull(); + + await waitForCondition( + () => daemon.deps.queueRepo!.getById(infoUpdate.qitemId)?.state === "done", + ); + + // Real serialized delivered-updates HTTP endpoint exposes deliveryOutcome: "accepted" for relay-accepted updates + const deliveredUpdatesRes = await fetch( + `${running.baseUrl}/api/queue/human-updates`, + ); + expect(deliveredUpdatesRes.status).toBe(200); + const deliveredUpdatesJson = (await deliveredUpdatesRes.json()) as { + items: Array>; + }; + const deliveredInfoItem = deliveredUpdatesJson.items.find( + (it) => it.qitemId === infoUpdate.qitemId, + ); + expect(deliveredInfoItem).toBeDefined(); + expect(deliveredInfoItem).toMatchObject({ + qitemId: infoUpdate.qitemId, + summary: "Deployment completed", + deliveryOutcome: "accepted", + }); + expect(String(deliveredInfoItem?.deliveryReceipt ?? "")).toMatch( + /^relay-owner-notification-accepted /, + ); + + const updateTransitions = daemon.deps.queueRepo!.listTransitions(infoUpdate.qitemId); + const forgedUpdateRef = encodeRelayReplyRef({ + v: 1, + qitemId: infoUpdate.qitemId, + episodeKey: `${infoUpdate.qitemId}:${updateTransitions[0]!.transitionId}`, + decisionRevision: daemon.deps.queueRepo!.decisionRevision( + daemon.deps.queueRepo!.getById(infoUpdate.qitemId)!, + ), + decisionId: updateRecord.deliveryId, + connectorRef: "local-bridge", + mode: "ask", + }); + + const updateReplyRes = await bridge.sendInboundReply(running.baseUrl, { + eventId: "evt-update-reply-attempt", + sender: { handle: "alice_ops" }, + replyRef: forgedUpdateRef, + text: "Approved!", + ordering: "after-question", + }); + expect(updateReplyRes.status).toBe(409); + }); + + it("survives bridge lost ACK after durable custody commit, records relay-specific transport-failed note, and replays stable deliveryId/bytes across actual daemon + bridge restart", async () => { + const outboundToken = "sec-relay-out-integration-03"; + const inboundToken = "sec-relay-in-integration-03"; + const custodyFilePath = join(testScratchDir, "bridge-custody-restart.jsonl"); + const dbPath = join(testScratchDir, "daemon-restart.sqlite"); + + const bridge = await startRecordingRelay({ + custodyFilePath, + outboundToken, + inboundToken, + }); + activeBridges.push(bridge); + + const envFilePath = writeRelaySecretsFile( + join(testScratchDir, "relay-restart.env"), + { url: bridge.outboundUrl, outboundToken, inboundToken }, + 0o600, + ); + + registerRelayPerson({ + entityId: "alice-ops", + displayName: "Alice Ops", + connectorRef: "local-bridge", + envFilePath, + handle: "alice_ops", + }); + + const daemon1 = await bootProductionDaemon({ dbPath, startPostBind: true }); + daemon1.daemon.deps.rigRepo.createRig("ops-rig"); + + const ask = await daemon1.daemon.deps.queueRepo!.create({ + sourceSession: "planner@ops-rig", + destinationSession: "alice-ops@external", + summary: "Database migration approval", + body: "Approve schema migration 150?", + humanIntent: "decision", + humanAlert: true, + humanQuestions: [ + { + id: "q-mig", + header: "Migration", + question: "Run migration now?", + options: [ + { id: "run", label: "Run", description: "Execute migration" }, + { id: "defer", label: "Defer", description: "Hold until window" }, + ], + }, + ], + nudge: false, + }); + + bridge.setLoseNextAck(true); + await sweepOutboundOnce(daemon1.daemon); + await waitForCondition(() => bridge.getSanitizedStats().lostAckCount === 1); + await waitForCondition(() => + daemon1.daemon.deps + .queueRepo!.listTransitions(ask.qitemId) + .some((t) => + (t.transitionNote ?? "").startsWith( + "relay-owner-notification-transport-failed ", + ), + ), + ); + + const transitionNotes = daemon1.daemon.deps + .queueRepo!.listTransitions(ask.qitemId) + .map((t) => t.transitionNote ?? ""); + expect( + transitionNotes.some( + (n) => + n.startsWith("relay-owner-notification-transport-failed ") && + n.includes("connector_ref=local-bridge"), + ), + ).toBe(true); + expect( + transitionNotes + .map((n) => parseRelayOwnerNotificationTransportFailed(n)) + .find((f) => f !== null), + ).toMatchObject({ + connectorRef: "local-bridge", + failureClass: "http-502", + }); + expect( + transitionNotes.some((n) => + n.startsWith("slack-owner-notification-transport-failed"), + ), + ).toBe(false); + + const firstCustody = bridge.getCustodyRecords(); + expect(firstCustody).toHaveLength(1); + const firstDeliveryId = firstCustody[0]!.deliveryId; + const firstPayloadSha = firstCustody[0]!.payloadSha256; + const firstRawBytesSha = firstCustody[0]!.rawBytesSha256; + + expect( + new DispatchBuffer(OPENRIG_HOME).pending().map((d) => d.decisionId), + ).toContain(firstDeliveryId); + expect( + daemon1.daemon.deps.queueRepo!.hasRelayOwnerNotificationReceipt(ask.qitemId), + ).toBe(false); + + // Bridge rejects changed payload under the same deliveryId (409 conflict). + const mutatedRes = await fetch(bridge.outboundUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${outboundToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ...firstCustody[0]!.envelope, + body: "Mutated body under same deliveryId", + }), + }); + expect(mutatedRes.status).toBe(409); + expect(bridge.getSanitizedStats().conflictCount).toBe(1); + + // Shut down Daemon 1 AND restart the bridge server over the SAME custody file and port. + await daemon1.stop(); + activeDaemons.pop(); + await bridge.restart(); + + const daemon2 = await bootProductionDaemon({ dbPath, startPostBind: false }); + expect(bridge.getSanitizedStats().deduplicatedCount).toBe(0); + + daemon2.daemon.deps.gatewaySubsystem?.startServices(); + await waitForCondition(() => bridge.getSanitizedStats().deduplicatedCount === 1); + await waitForCondition(() => new DispatchBuffer(OPENRIG_HOME).pending().length === 0); + + const afterCustody = bridge.getCustodyRecords(); + expect(afterCustody).toHaveLength(1); + expect(afterCustody[0]!.deliveryId).toBe(firstDeliveryId); + expect(afterCustody[0]!.payloadSha256).toBe(firstPayloadSha); + expect(afterCustody[0]!.rawBytesSha256).toBe(firstRawBytesSha); + expect( + daemon2.daemon.deps.queueRepo!.hasRelayOwnerNotificationReceipt(ask.qitemId), + ).toBe(true); + }); + + it("enforces inbound idempotency, equivocation conflict, stale episode/repark rejection, second-person attribution, uncertain ordering context, atomic close+audit recovery, and scoped seen-ledger handling", async () => { + const outboundToken = "sec-relay-out-integration-04"; + const inboundToken = "sec-relay-in-integration-04"; + const custodyFilePath = join(testScratchDir, "bridge-custody-inbound.jsonl"); + + const bridge = await startRecordingRelay({ + custodyFilePath, + outboundToken, + inboundToken, + }); + activeBridges.push(bridge); + + const envFilePath = writeRelaySecretsFile( + join(testScratchDir, "relay-inbound-matrix.env"), + { url: bridge.outboundUrl, outboundToken, inboundToken }, + 0o600, + ); + + registerRelayPerson({ + entityId: "alice-ops", + displayName: "Alice Ops", + connectorRef: "local-bridge", + envFilePath, + handle: "alice_ops", + }); + registerRelayPerson({ + entityId: "bob-peer", + displayName: "Bob Peer", + connectorRef: "local-bridge", + envFilePath, + handle: "bob_peer", + }); + + const running = await bootProductionDaemon({ startPostBind: true }); + const { daemon } = running; + daemon.deps.rigRepo.createRig("ops-rig"); + + const parked = await daemon.deps.queueRepo!.create({ + sourceSession: "architect@ops-rig", + destinationSession: "builder@ops-rig", + summary: "Cutover verification gate", + body: "Waiting on human confirmation before cutover.", + evidenceRef: "docs/cutover.md", + nudge: false, + }); + daemon.deps.queueRepo!.claim({ + qitemId: parked.qitemId, + destinationSession: "builder@ops-rig", + actorSession: "builder@ops-rig", + }); + daemon.deps.queueRepo!.update({ + qitemId: parked.qitemId, + actorSession: "builder@ops-rig", + state: "blocked", + closureReason: "blocked_on", + closureTarget: "alice-ops@external", + blockedOn: "alice-ops@external", + transitionNote: "Parked on alice-ops for Episode 1", + ownerNotificationLevel: "ALERT", + ownerNotificationKind: "human-required", + }); + + await sweepOutboundOnce(daemon); + await waitForCondition(() => bridge.getCustodyRecords().length === 1); + const parkEp1Record = bridge.getCustodyRecords()[0]!; + expect(parkEp1Record.envelope.replyRef).not.toBeNull(); + + const uncertainRes = await bridge.sendInboundReply(running.baseUrl, { + eventId: "evt-park-uncertain-1", + sender: { handle: "alice_ops" }, + replyRef: parkEp1Record.envelope.replyRef!, + text: "Check staging logs first, then proceed.", + ordering: "uncertain", + }); + expect(uncertainRes.status).toBe(200); + + const resumedPark = daemon.deps.queueRepo!.getById(parked.qitemId)!; + expect(resumedPark.state).toBe("in-progress"); + const latestParkNote = + daemon.deps.queueRepo!.listTransitions(parked.qitemId).at(-1)?.transitionNote ?? ""; + expect(latestParkNote).toContain("Check staging logs first, then proceed."); + expect(latestParkNote).toMatch(/ordering=uncertain/i); + + // Re-park into Episode 2 -> Episode 1 replyRef rejected (409) + daemon.deps.queueRepo!.update({ + qitemId: parked.qitemId, + actorSession: "builder@ops-rig", + state: "blocked", + closureReason: "blocked_on", + closureTarget: "alice-ops@external", + blockedOn: "alice-ops@external", + transitionNote: "Re-parked on alice-ops for Episode 2", + ownerNotificationLevel: "ALERT", + ownerNotificationKind: "human-required", + }); + + const staleEp1Res = await bridge.sendInboundReply(running.baseUrl, { + eventId: "evt-park-stale-ep1", + sender: { handle: "alice_ops" }, + replyRef: parkEp1Record.envelope.replyRef!, + text: "Late reply to Episode 1", + ordering: "after-question", + }); + expect(staleEp1Res.status).toBe(409); + expect(daemon.deps.queueRepo!.getById(parked.qitemId)?.state).toBe("blocked"); + + // Second registered person (`bob_peer`) answers `alice_ops`'s ask with real attribution + const ask1 = await daemon.deps.queueRepo!.create({ + sourceSession: "architect@ops-rig", + destinationSession: "alice-ops@external", + summary: "Primary ask for Alice", + body: "Which region should we drain first?", + humanIntent: "decision", + humanAlert: true, + humanQuestions: [ + { + id: "q-region", + header: "Region", + question: "Which region?", + options: [ + { id: "us-east", label: "us-east", description: "East" }, + { id: "eu-west", label: "eu-west", description: "West" }, + ], + }, + ], + nudge: false, + }); + + const askAtomic = await daemon.deps.queueRepo!.create({ + sourceSession: "architect@ops-rig", + destinationSession: "alice-ops@external", + summary: "Atomic close+audit ask", + body: "Confirm atomic close and audit recovery?", + humanIntent: "decision", + humanAlert: true, + nudge: false, + }); + + const askLedger = await daemon.deps.queueRepo!.create({ + sourceSession: "architect@ops-rig", + destinationSession: "alice-ops@external", + summary: "Scoped seen-ledger ask", + body: "Confirm healthy key works alongside keyed suspect entry?", + humanIntent: "decision", + humanAlert: true, + nudge: false, + }); + + await sweepOutboundOnce(daemon); + await waitForCondition(() => { + const summaries = new Set( + bridge.getCustodyRecords().map((r) => r.envelope.summary), + ); + return ( + summaries.has("Primary ask for Alice") && + summaries.has("Atomic close+audit ask") && + summaries.has("Scoped seen-ledger ask") + ); + }); + const ask1Record = bridge + .getCustodyRecords() + .find((r) => r.envelope.summary === "Primary ask for Alice")!; + const askAtomicRecord = bridge + .getCustodyRecords() + .find((r) => r.envelope.summary === "Atomic close+audit ask")!; + const askLedgerRecord = bridge + .getCustodyRecords() + .find((r) => r.envelope.summary === "Scoped seen-ledger ask")!; + + const bobRes = await bridge.sendInboundReply(running.baseUrl, { + eventId: "evt-bob-answer-1", + sender: { handle: "bob_peer" }, + replyRef: ask1Record.envelope.replyRef!, + text: "Drain us-east first.", + ordering: "after-question", + }); + expect(bobRes.status).toBe(200); + expect(bobRes.body).toMatchObject({ + ok: true, + qitemId: ask1.qitemId, + actorSession: "bob-peer@external", + deduplicated: false, + }); + + const bobAnswerRow = daemon.deps.queueRepo!.getHumanAnswer( + ask1.qitemId, + "bob-peer@external", + ); + expect(bobAnswerRow?.sourceSession).toBe("bob-peer@external"); + expect(bobAnswerRow?.body).toContain( + "Answered by bob-peer@external, a registered person, on alice-ops@external's ask", + ); + + const bobDupRes = await bridge.sendInboundReply(running.baseUrl, { + eventId: "evt-bob-answer-1", + sender: { handle: "bob_peer" }, + replyRef: ask1Record.envelope.replyRef!, + text: "Drain us-east first.", + ordering: "after-question", + }); + expect(bobDupRes.status).toBe(200); + expect(bobDupRes.body.deduplicated).toBe(true); + + const eqTextRes = await bridge.sendInboundReply(running.baseUrl, { + eventId: "evt-bob-answer-1", + sender: { handle: "bob_peer" }, + replyRef: ask1Record.envelope.replyRef!, + text: "Drain eu-west instead.", + ordering: "after-question", + }); + expect(eqTextRes.status).toBe(409); + + // Atomic close+audit recovery: simulated audit write throw rolls back ask close while preserving + // the reply row and received:; retry under the original replyRef succeeds without duplicate reply rows. + const actionLog = daemon.deps.missionControlActionLog!; + const origRecord = actionLog.record.bind(actionLog); + let threwOnce = false; + actionLog.record = ((entry: Parameters[0]) => { + if (!threwOnce) { + threwOnce = true; + throw new Error("simulated audit failure inside atomic close+audit transaction"); + } + return origRecord(entry); + }) as typeof actionLog.record; + + try { + const crashRes = await bridge.sendInboundReply(running.baseUrl, { + eventId: "evt-atomic-crash-1", + sender: { handle: "alice_ops" }, + replyRef: askAtomicRecord.envelope.replyRef!, + text: "Approve atomic close.", + ordering: "after-question", + }); + expect(crashRes.status).toBe(500); + expect(daemon.deps.queueRepo!.getById(askAtomic.qitemId)?.state).toBe("pending"); + expect( + daemon.deps.queueRepo!.listHumanAnswers(askAtomic.qitemId, "alice-ops@external"), + ).toHaveLength(1); + } finally { + actionLog.record = origRecord; + } + + const recoveredRes = await bridge.sendInboundReply(running.baseUrl, { + eventId: "evt-atomic-crash-1", + sender: { handle: "alice_ops" }, + replyRef: askAtomicRecord.envelope.replyRef!, + text: "Approve atomic close.", + ordering: "after-question", + }); + expect(recoveredRes.status).toBe(200); + expect(recoveredRes.body).toMatchObject({ + ok: true, + qitemId: askAtomic.qitemId, + actorSession: "alice-ops@external", + deduplicated: false, + }); + expect(daemon.deps.queueRepo!.getById(askAtomic.qitemId)?.state).toBe("done"); + expect( + daemon.deps.queueRepo!.listHumanAnswers(askAtomic.qitemId, "alice-ops@external"), + ).toHaveLength(1); + + const postRecoveryDupRes = await bridge.sendInboundReply(running.baseUrl, { + eventId: "evt-atomic-crash-1", + sender: { handle: "alice_ops" }, + replyRef: askAtomicRecord.envelope.replyRef!, + text: "Approve atomic close.", + ordering: "after-question", + }); + expect(postRecoveryDupRes.status).toBe(200); + expect(postRecoveryDupRes.body.deduplicated).toBe(true); + + // Scoped seen-ledger handling: keyed suspect record scopes 503 to that eventId only; + // unrelated keys still succeed; unkeyable/corrupt JSONL returns 503. + const seenFilePath = join(OPENRIG_HOME, "state", RELAY_INBOUND_SEEN_FILE); + const suspectKey = computeRelayEventKey("local-bridge", "evt-suspect-only"); + writeFileSync( + seenFilePath, + `${readFileSync(seenFilePath, "utf8")}${JSON.stringify({ id: suspectKey, status: "corrupt-status" })}\n`, + "utf8", + ); + + const suspectRes = await bridge.sendInboundReply(running.baseUrl, { + eventId: "evt-suspect-only", + sender: { handle: "alice_ops" }, + replyRef: askLedgerRecord.envelope.replyRef!, + text: "Blocked by suspect record", + ordering: "after-question", + }); + expect(suspectRes.status).toBe(503); + expect(daemon.deps.queueRepo!.getById(askLedger.qitemId)?.state).toBe("pending"); + + const healthyRes = await bridge.sendInboundReply(running.baseUrl, { + eventId: "evt-ledger-healthy-1", + sender: { handle: "alice_ops" }, + replyRef: askLedgerRecord.envelope.replyRef!, + text: "Healthy event settles askLedger.", + ordering: "after-question", + }); + expect(healthyRes.status).toBe(200); + expect(healthyRes.body).toMatchObject({ + ok: true, + qitemId: askLedger.qitemId, + deduplicated: false, + }); + expect(daemon.deps.queueRepo!.getById(askLedger.qitemId)?.state).toBe("done"); + + writeFileSync( + seenFilePath, + `${readFileSync(seenFilePath, "utf8")}{corrupt-unparseable-jsonl\n`, + "utf8", + ); + const corruptLedgerRes = await bridge.sendInboundReply(running.baseUrl, { + eventId: "evt-ledger-after-corrupt", + sender: { handle: "alice_ops" }, + replyRef: askLedgerRecord.envelope.replyRef!, + text: "Refused due to corrupt ledger", + ordering: "after-question", + }); + expect(corruptLedgerRes.status).toBe(503); + }); + + it("warns on permissive 0644/shared-token relay config while remaining ready, localizes missing/ambiguous/invalid auth without secret leaks, and preserves default no-relay/Slack behavior", async () => { + const outboundToken = "sec-relay-out-integration-05"; + const inboundToken = "sec-relay-in-integration-05"; + const sharedToken = "sec-relay-shared-integration-05"; + const custodyFilePath = join(testScratchDir, "bridge-custody-auth.jsonl"); + + const bridge = await startRecordingRelay({ + custodyFilePath, + outboundToken, + inboundToken, + }); + activeBridges.push(bridge); + + // Default no-relay daemon boots with active subsystem and inert Slack connector (see slack-subsystem-cutover.test.ts). + const defaultDaemon = await bootProductionDaemon({ startPostBind: true }); + const healthRes = await fetch(`${defaultDaemon.baseUrl}/api/health-summary/gateway`); + expect(healthRes.status).toBe(200); + expect(await healthRes.json()).toMatchObject({ + state: "active", + connector: { platform: "slack", outboundReady: false }, + }); + await defaultDaemon.stop(); + activeDaemons.pop(); + + // Provision relay with 0644 permissions + shared directional token -> ready: true with redacted warnings, no secret leak. + const envPath = writeRelaySecretsFile( + join(testScratchDir, "relay-permissive.env"), + { url: bridge.outboundUrl, outboundToken: sharedToken, inboundToken: sharedToken }, + 0o644, + ); + registerRelayPerson({ + entityId: "alice-ops", + displayName: "Alice Ops", + connectorRef: "local-bridge", + envFilePath: envPath, + handle: "alice_ops", + }); + + const running = await bootProductionDaemon({ startPostBind: true }); + const permissiveRes = await fetch( + `${running.baseUrl}/api/gateway/human/alice-ops/readiness`, + ); + expect(permissiveRes.status).toBe(200); + const permissiveJson = (await permissiveRes.json()) as { + ok: boolean; + readiness: { state: string; ready: boolean; reason?: string }; + }; + expect(permissiveJson).toMatchObject({ + ok: true, + readiness: { state: "ready", ready: true }, + }); + expect(permissiveJson.readiness.reason ?? "").toMatch(/warning/i); + expect(permissiveJson.readiness.reason ?? "").toMatch(/0644/); + expect(permissiveJson.readiness.reason ?? "").toMatch(/same token|separate tokens/i); + expect(JSON.stringify(permissiveJson)).not.toContain(sharedToken); + + // Strict 0400 permissions + distinct tokens -> ready: true with no warning. + writeRelaySecretsFile( + envPath, + { url: bridge.outboundUrl, outboundToken, inboundToken }, + 0o400, + ); + const strict0400Res = await fetch( + `${running.baseUrl}/api/gateway/human/alice-ops/readiness`, + ); + expect(strict0400Res.status).toBe(200); + const strict0400Json = (await strict0400Res.json()) as { + ok: boolean; + readiness: { state: string; ready: boolean; reason?: string }; + }; + expect(strict0400Json).toMatchObject({ + ok: true, + readiness: { state: "ready", ready: true }, + }); + expect(strict0400Json.readiness.reason ?? "").not.toMatch(/warning/i); + + // Missing required credential key -> localized not-ready, no secret leak. + chmodSync(envPath, 0o600); + writeFileSync( + envPath, + `OPENRIG_RELAY_URL=${bridge.outboundUrl}\nOPENRIG_RELAY_OUTBOUND_TOKEN=${outboundToken}\n`, + { encoding: "utf8", mode: 0o600 }, + ); + const missingKeyRes = await fetch( + `${running.baseUrl}/api/gateway/human/alice-ops/readiness`, + ); + expect(missingKeyRes.status).toBe(200); + const missingKeyJson = (await missingKeyRes.json()) as Record; + expect(missingKeyJson).toMatchObject({ + ok: true, + readiness: { state: "not-ready", ready: false }, + }); + expect(JSON.stringify(missingKeyJson)).not.toContain(outboundToken); + + // Restore valid distinct credentials on local-bridge; register second connector profile reusing the same inbound token -> ambiguous not-ready + 401. + writeRelaySecretsFile( + envPath, + { url: bridge.outboundUrl, outboundToken, inboundToken }, + 0o600, + ); + const dupEnvPath = writeRelaySecretsFile( + join(testScratchDir, "relay-second-profile.env"), + { + url: bridge.outboundUrl, + outboundToken: "sec-relay-out-second-05", + inboundToken, + }, + 0o600, + ); + registerRelayPerson({ + entityId: "bob-dup", + displayName: "Bob Dup", + connectorRef: "second-bridge", + envFilePath: dupEnvPath, + handle: "bob_dup", + }); + + const ambigReadinessRes = await fetch( + `${running.baseUrl}/api/gateway/human/alice-ops/readiness`, + ); + expect(ambigReadinessRes.status).toBe(200); + expect(await ambigReadinessRes.json()).toMatchObject({ + ok: true, + readiness: { state: "not-ready", ready: false }, + }); + + const ambigInboundRes = await bridge.sendInboundReply(running.baseUrl, { + eventId: "evt-ambig-1", + sender: { handle: "alice_ops" }, + replyRef: "forged", + text: "Ambiguous inbound token must fail", + }); + expect(ambigInboundRes.status).toBe(401); + + // Give second-bridge its own distinct inbound token so local-bridge is unambiguous again. + writeRelaySecretsFile( + dupEnvPath, + { + url: bridge.outboundUrl, + outboundToken: "sec-relay-out-second-05", + inboundToken: "sec-relay-in-second-05", + }, + 0o600, + ); + + const unauthRes = await bridge.sendInboundReply( + running.baseUrl, + { + eventId: "evt-unauth-1", + sender: { handle: "alice_ops" }, + replyRef: "forged", + text: "Should fail", + }, + { tokenOverride: null }, + ); + expect(unauthRes.status).toBe(401); + + const wrongTokenRes = await bridge.sendInboundReply( + running.baseUrl, + { + eventId: "evt-unauth-2", + sender: { handle: "alice_ops" }, + replyRef: "forged", + text: "Should fail", + }, + { tokenOverride: outboundToken }, + ); + expect(wrongTokenRes.status).toBe(401); + expect(JSON.stringify(wrongTokenRes.body)).not.toContain(outboundToken); + expect(JSON.stringify(wrongTokenRes.body)).not.toContain(inboundToken); + + const badBridgePost = await fetch(bridge.outboundUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${inboundToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + v: 1, + deliveryId: "dec-unauth", + recipient: { handle: "alice_ops" }, + intent: "decision", + outcome: "interrupt", + summary: "Test", + body: "Test", + replyRef: null, + }), + }); + expect(badBridgePost.status).toBe(401); + expect(bridge.getSanitizedStats().unauthorizedCount).toBe(1); + expect(bridge.getCustodyRecords()).toHaveLength(0); + }); +}); diff --git a/packages/daemon/test/gateway-subsystem-activation.test.ts b/packages/daemon/test/gateway-subsystem-activation.test.ts index 922995c2c..8ea638801 100644 --- a/packages/daemon/test/gateway-subsystem-activation.test.ts +++ b/packages/daemon/test/gateway-subsystem-activation.test.ts @@ -84,4 +84,107 @@ describe("S10 gateway subsystem activation (RED at tip: no boot-time subsystem c rmSync(home, { recursive: true, force: true }); } }); + + it("activates relay-only outbound polling post-bind with no Slack credentials while keeping Slack status unconfigured", async () => { + const { buildSlackGatewayWire } = await import("../src/domain/gateway/slack/slack-subsystem.js"); + const { createDb } = await import("../src/db/connection.js"); + const { migrate } = await import("../src/db/migrate.js"); + const { ALL_MIGRATIONS } = await import("../src/db/all-migrations.js"); + const { EventBus } = await import("../src/domain/event-bus.js"); + const { QueueRepository } = await import("../src/domain/queue-repository.js"); + const { resolveSlackHandle } = await import("../src/domain/gateway/human-registry.js"); + const { writeFileSync } = await import("node:fs"); + + const home = mkdtempSync(join(tmpdir(), "s10-relay-only-")); + const db = createDb(); + migrate(db, ALL_MIGRATIONS); + const secretsFile = join(home, "relay.env"); + writeFileSync( + secretsFile, + [ + "OPENRIG_RELAY_URL=http://127.0.0.1:44100/outbound", + "OPENRIG_RELAY_OUTBOUND_TOKEN=out-secret-token-123456", + "OPENRIG_RELAY_INBOUND_TOKEN=in-secret-token-654321", + ].join("\n"), + { mode: 0o600 }, + ); + const registry = { + ok: true as const, + entities: [ + { + entityId: "sam", + class: "human" as const, + displayName: "Sam", + address: "sam@external", + connectorBindings: [ + { + kind: "relay" as const, + connectorRef: "wa-main", + secretsRef: `file:${secretsFile}`, + role: "primary" as const, + handle: "sam_wa", + }, + ], + prefs: { deliveryClass: "A" as const, availability: "available" as const }, + }, + ], + }; + const repo = new QueueRepository(db, new EventBus(db), { + validateRig: () => true, + loadHumanRegistry: () => registry, + }); + const row = await repo.create({ + sourceSession: "lead@rig", + destinationSession: "orch@rig", + body: "Please confirm.", + nudge: false, + }); + repo.update({ + qitemId: row.qitemId, + actorSession: "orch@rig", + state: "blocked", + blockedOn: "sam@kernel", + summary: "Approve deploy?", + evidenceRef: "/proof/deploy.md", + transitionNote: "parked", + }); + + const deliveredEnvelopes: Array> = []; + const wire = buildSlackGatewayWire({ + home, + queueRepo: repo, + registry: { loadHumanRegistry: () => registry, resolveSlackHandle }, + outboundIntervalMs: 15, + fetchImpl: async (_url, init) => { + deliveredEnvelopes.push(JSON.parse(String(init?.body ?? "{}")) as Record); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }, + }); + + try { + // Composition alone must never start network polling or HTTP calls + await new Promise((r) => setTimeout(r, 30)); + expect(deliveredEnvelopes).toHaveLength(0); + expect(wire.status()).toMatchObject({ + outboundReady: false, + inboundReady: false, + }); + + // Post-bind startServices() starts outbound polling and delivers to the relay bridge + wire.startServices?.(); + await new Promise((r) => setTimeout(r, 60)); + expect(deliveredEnvelopes).toHaveLength(1); + expect(deliveredEnvelopes[0]).toMatchObject({ + v: 1, + recipient: { handle: "sam_wa" }, + intent: "decision", + }); + const episode = repo.currentHumanRequiredEpisode(row.qitemId)!; + expect(repo.hasRelayOwnerNotificationReceipt(row.qitemId, episode.key)).toBe(true); + } finally { + wire.stop(); + db.close(); + rmSync(home, { recursive: true, force: true }); + } + }); }); diff --git a/packages/daemon/test/gateway-subsystem-wire.test.ts b/packages/daemon/test/gateway-subsystem-wire.test.ts index b3edf3e08..b7a391271 100644 --- a/packages/daemon/test/gateway-subsystem-wire.test.ts +++ b/packages/daemon/test/gateway-subsystem-wire.test.ts @@ -112,4 +112,181 @@ describe("S10 in-process gateway wire — durability contract receipts", () => { expect(subsystem.status().state).toBe("active"); expect(subsystem.dispatch("post_message", "mike#slack", {}).ok).toBe(true); }); + + it("prepare(decision) runs synchronously BEFORE durable enqueue and replay sends the exact frozen payload without re-running prepare", async () => { + let prepareCalls = 0; + let bufferedDuringPrepare = -1; + let releaseFirst: (o: SubsystemDeliveryOutcome) => void = () => {}; + const firstGate = new Promise((r) => { releaseFirst = r; }); + + const w1 = buildInProcessWire({ + home, + ops: ["post_message"], + prepare: (decision) => { + prepareCalls++; + bufferedDuringPrepare = new DispatchBuffer(home).pending().length; + const p = (decision.payload ?? {}) as Record; + return { + ...decision, + payload: { + ...p, + relayEnvelope: { + v: 1, + deliveryId: decision.decisionId, + replyRef: `frozen-ref-${prepareCalls}`, + }, + }, + }; + }, + deliver: () => firstGate, + }); + + const r1 = w1.dispatcher.dispatch("post_message", "sam@external", { qitemId: "q-freeze-1" }, { decisionId: "d-freeze-1" }); + expect(r1.ok).toBe(true); + expect(prepareCalls).toBe(1); + expect(bufferedDuringPrepare, "prepare must run BEFORE durable buffer.enqueue").toBe(0); + + // Durable buffer immediately holds the frozen payload before delivery resolves. + const pendingBefore = new DispatchBuffer(home).pending(); + expect(pendingBefore).toHaveLength(1); + expect((pendingBefore[0]!.payload as { relayEnvelope?: { replyRef?: string } }).relayEnvelope?.replyRef).toBe("frozen-ref-1"); + + // Duplicate dispatch of same decisionId while pending does not re-run prepare or overwrite frozen payload. + const dup = w1.dispatcher.dispatch("post_message", "sam@external", { qitemId: "q-freeze-1", mutated: true }, { decisionId: "d-freeze-1" }); + expect(dup.ok).toBe(true); + expect(prepareCalls).toBe(1); + + // Fail first delivery so it stays in the buffer for replay. + releaseFirst({ ok: false, class: "transport", detail: "bridge down" }); + await tick(); + expect(new DispatchBuffer(home).pending()).toHaveLength(1); + + // Run 2: replayPending sends the exact frozen payload from the buffer without calling prepare again. + const replayed: OutboundDecision[] = []; + const w2 = buildInProcessWire({ + home, + ops: ["post_message"], + prepare: (decision) => { + prepareCalls++; + return { + ...decision, + payload: { ...(decision.payload as object), relayEnvelope: { v: 1, deliveryId: decision.decisionId, replyRef: "recomputed-ref" } }, + }; + }, + deliver: async (d) => { + replayed.push(d); + return { ok: true }; + }, + }); + w2.startServices?.(); + await tick(); + + expect(prepareCalls, "replayPending must not re-run prepare").toBe(1); + expect(replayed).toHaveLength(1); + expect(replayed[0]!.decisionId).toBe("d-freeze-1"); + expect((replayed[0]!.payload as { relayEnvelope?: { replyRef?: string } }).relayEnvelope?.replyRef).toBe("frozen-ref-1"); + expect(new DispatchBuffer(home).pending()).toHaveLength(0); + }); + + describe("dispatcher prepare hook rails", () => { + it("preserves caller's advertised op, entityBindingRef, kind, and decisionId when prepare mutates argument in place and returns void", async () => { + let dispatchedDecision: OutboundDecision | undefined; + const wire = buildInProcessWire({ + home, + ops: ["post_message"], + prepare: (decision) => { + // Attempt malicious/accidental mutation of rails in place and return void + (decision as any).op = "unadvertised_bypassed_op"; + (decision as any).entityBindingRef = "hacked#channel"; + (decision as any).kind = "forged_kind"; + (decision as any).decisionId = "hacked_decision_id"; + // Valid payload preparation: + const p = (decision.payload ?? {}) as Record; + p.preparedPayloadField = "frozen_value"; + }, + deliver: async (d) => { + dispatchedDecision = d; + return { ok: true }; + }, + }); + + const res = wire.dispatcher.dispatch("post_message", "alice@external", { orig: 1 }, { decisionId: "d-fixed-1" }); + expect(res.ok).toBe(true); + expect((res as { decisionId: string }).decisionId).toBe("d-fixed-1"); + await tick(); + + expect(dispatchedDecision).toBeDefined(); + expect(dispatchedDecision!.op).toBe("post_message"); + expect(dispatchedDecision!.entityBindingRef).toBe("alice@external"); + expect(dispatchedDecision!.kind).toBe("outbound_decision"); + expect(dispatchedDecision!.decisionId).toBe("d-fixed-1"); + expect((dispatchedDecision!.payload as any).preparedPayloadField).toBe("frozen_value"); + expect((dispatchedDecision!.payload as any).orig).toBe(1); + + // Verify what was durably persisted in the buffer matches the rails + const bufferEntries = new DispatchBuffer(home).pending(); + expect(bufferEntries).toHaveLength(0); // Drained because delivery returned ok: true + }); + + it("preserves caller's advertised op, entityBindingRef, kind, and decisionId when prepare returns a mutated decision object", async () => { + let dispatchedDecision: OutboundDecision | undefined; + const wire = buildInProcessWire({ + home, + ops: ["post_message"], + prepare: (decision) => { + // Return new object attempting to alter op, entityBindingRef, kind, decisionId + return { + kind: "other_kind" as any, + decisionId: "hijacked_id", + op: "malicious_op", + entityBindingRef: "attacker#ref", + payload: { + ...(decision.payload as object), + frozenPayload: "persisted", + }, + }; + }, + deliver: async (d) => { + dispatchedDecision = d; + return { ok: true }; + }, + }); + + const res = wire.dispatcher.dispatch("post_message", "bob@external", { count: 42 }, { decisionId: "d-fixed-2" }); + expect(res.ok).toBe(true); + expect((res as { decisionId: string }).decisionId).toBe("d-fixed-2"); + await tick(); + + expect(dispatchedDecision).toBeDefined(); + expect(dispatchedDecision!.op).toBe("post_message"); + expect(dispatchedDecision!.entityBindingRef).toBe("bob@external"); + expect(dispatchedDecision!.kind).toBe("outbound_decision"); + expect(dispatchedDecision!.decisionId).toBe("d-fixed-2"); + expect((dispatchedDecision!.payload as any).frozenPayload).toBe("persisted"); + expect((dispatchedDecision!.payload as any).count).toBe(42); + }); + + it("refuses unadvertised original op before prepare or send is ever executed", async () => { + let prepareCalled = false; + let sendCalled = false; + const wire = buildInProcessWire({ + home, + ops: ["post_message"], + prepare: () => { + prepareCalled = true; + }, + deliver: async () => { + sendCalled = true; + return { ok: true }; + }, + }); + + const res = wire.dispatcher.dispatch("unsupported_op", "alice@external", {}); + expect(res.ok).toBe(false); + expect((res as { error: string }).error).toContain("not advertised"); + expect(prepareCalled).toBe(false); + expect(sendCalled).toBe(false); + expect(new DispatchBuffer(home).pending()).toHaveLength(0); + }); + }); }); diff --git a/packages/daemon/test/queue-list-delivery-ledger.test.ts b/packages/daemon/test/queue-list-delivery-ledger.test.ts index e0fac5cdc..b66728445 100644 --- a/packages/daemon/test/queue-list-delivery-ledger.test.ts +++ b/packages/daemon/test/queue-list-delivery-ledger.test.ts @@ -214,4 +214,89 @@ describe("queue list: delivery ledgers", () => { expect(awaiting.length).toBeGreaterThan(0); expect(listed.map((item) => item.waiting)).toEqual(listed.map((item) => repo.waitingView(item.qitemId))); }); + + it("distinguishes relay-accepted asks and updates ('accepted by the bridge') from Slack-posted asks and updates ('posted to the human') in waitingView and getById", async () => { + const relayAsk = await repo.create({ + sourceSession: "agent@pod.seat", + destinationSession: "human-founder@external", + body: "Approve bridge migration?", + summary: "Approve bridge migration?", + humanIntent: "decision", + nudge: false, + }); + const relayAskKey = latestKey(relayAsk.qitemId); + repo.recordRelayOwnerNotificationAccepted({ + qitemId: relayAsk.qitemId, + notificationKey: relayAskKey, + deliveryId: "dec-relay-ask-1", + connectorRef: "wa-primary", + replyRef: "rref-ask-1", + revision: repo.decisionRevision(relayAsk), + episodeKey: relayAskKey, + level: "ALERT", + kind: "human-required", + }); + + const relayUpdate = await repo.create({ + sourceSession: "agent@pod.seat", + destinationSession: "human-founder@external", + body: "Bridge migration complete.", + summary: "Bridge migration complete", + humanIntent: "update", + nudge: false, + }); + const relayUpdateKey = latestKey(relayUpdate.qitemId); + repo.recordRelayOwnerNotificationAccepted({ + qitemId: relayUpdate.qitemId, + notificationKey: relayUpdateKey, + deliveryId: "dec-relay-update-1", + connectorRef: "wa-primary", + replyRef: null, + episodeKey: relayUpdateKey, + level: "NOTICE", + kind: "human-update", + closeUpdate: false, + }); + + const slackAsk = await repo.create({ + sourceSession: "agent@pod.seat", + destinationSession: "human-founder@external", + body: "Approve Slack migration?", + summary: "Approve Slack migration?", + humanIntent: "decision", + nudge: false, + }); + receipt(slackAsk.qitemId, "posted"); + + const slackUpdate = await repo.create({ + sourceSession: "agent@pod.seat", + destinationSession: "human-founder@external", + body: "Slack migration complete.", + summary: "Slack migration complete", + humanIntent: "update", + nudge: false, + }); + receipt(slackUpdate.qitemId, "posted"); + + const expectedRelayAsk = "none (accepted by the bridge; awaiting their decision)"; + const expectedRelayUpdate = "none (accepted by the bridge; informational correspondence requires no claim)"; + const expectedSlackAsk = "none (posted to the human; awaiting their decision)"; + const expectedSlackUpdate = "none (posted to the human; informational correspondence requires no claim)"; + + expect(repo.waitingView(relayAsk.qitemId)?.nextBackstop.mechanism).toBe(expectedRelayAsk); + expect(repo.getById(relayAsk.qitemId)?.waiting?.nextBackstop.mechanism).toBe(expectedRelayAsk); + expect(repo.getById(relayAsk.qitemId)?.deliveryOutcome).toBe("accepted"); + + expect(repo.waitingView(relayUpdate.qitemId)?.nextBackstop.mechanism).toBe(expectedRelayUpdate); + expect(repo.getById(relayUpdate.qitemId)?.waiting?.nextBackstop.mechanism).toBe(expectedRelayUpdate); + expect(repo.getById(relayUpdate.qitemId)?.deliveryOutcome).toBe("accepted"); + + expect(repo.waitingView(slackAsk.qitemId)?.nextBackstop.mechanism).toBe(expectedSlackAsk); + expect(repo.getById(slackAsk.qitemId)?.waiting?.nextBackstop.mechanism).toBe(expectedSlackAsk); + expect(repo.getById(slackAsk.qitemId)?.deliveryOutcome).toBe("posted"); + + expect(repo.waitingView(slackUpdate.qitemId)?.nextBackstop.mechanism).toBe(expectedSlackUpdate); + expect(repo.getById(slackUpdate.qitemId)?.waiting?.nextBackstop.mechanism).toBe(expectedSlackUpdate); + expect(repo.getById(slackUpdate.qitemId)?.deliveryOutcome).toBe("posted"); + }); }); diff --git a/packages/daemon/test/queue-wake-ladder.test.ts b/packages/daemon/test/queue-wake-ladder.test.ts index e751a8a22..d025b7410 100644 --- a/packages/daemon/test/queue-wake-ladder.test.ts +++ b/packages/daemon/test/queue-wake-ladder.test.ts @@ -275,6 +275,25 @@ describe("S01 wake-or-escalate — retry ladder, named rungs, derived suspension expect(calls.some((c) => c.target === "worker@r")).toBe(false); }); + it("DELIVERY MACHINERY IS NOT PICKUP: a relay-owner-notification-transport-failed note on an unconfirmed or held baton does not count as pickup evidence", async () => { + const mod = await ladderMod(); + for (const [idx, result] of ["gateway-owned: virtual destination", "retained:typing_guard"].entries()) { + const baton = await mkBaton(`worker-${idx}@r`); + setNudgeResult(baton.qitemId, result, 45); + ageCreated(baton.qitemId, 45); + repo.transitionLog.append({ + qitemId: baton.qitemId, + state: "pending", + actorSession: "daemon@kernel", + transitionNote: `relay-owner-notification-transport-failed notification_key=${baton.qitemId}:1 connector_ref=wa-main class=http-503 error=http 503`, + }); + expect(mod.readWakeLadderBackstop(db, baton.qitemId)).not.toBeNull(); + await tick(); + expect(markersOf(baton.qitemId, LADDER_RUNG_PREFIX)).toHaveLength(1); + expect(markersOf(baton.qitemId, LADDER_RUNG_PREFIX)[0]).toMatch(/orchestrator/); + } + }); + // ── G4: F2 — SUSPENSION DERIVES FROM THE SWAP STATE ────────────────────────── it("SWAP SUSPENDS THE LADDER: a destination inside the post-swap grace gets ZERO wake attempts and one recorded suspend marker (the stale-wake-burst specimen)", async () => { @@ -507,6 +526,62 @@ describe("S01 wake-or-escalate — retry ladder, named rungs, derived suspension expect(markersOf(baton.qitemId, LADDER_RUNG_PREFIX).some((r) => /dispatched-to-engine/.test(r))).toBe(true); }); + it("OPERATOR RUNG RELAY RECEIPT: pending dispatch resolves on matching post-marker relay-owner-notification-accepted", async () => { + const baton = await exhaustToSelfSkip(); + const notificationKey = "notif-key-123"; + let dispatchCount = 0; + const pendingEngine = { + dispatchEscalation: async (row: QueueItem) => { + dispatchCount++; + return { decision: "post", resolved: false, dispatched: true, notificationKey }; + }, + }; + + // Pre-marker receipt in history (simulates receipt from a previous episode before dispatch): + // Must NOT resolve the upcoming dispatch. + repo.transitionLog.append({ + qitemId: baton.qitemId, + state: "pending", + actorSession: "daemon@kernel", + transitionNote: `relay-owner-notification-accepted notification_key=${notificationKey} delivery_id=d-pre`, + }); + + // 1. Initial tick with pending deliveryEngine outcome (resolved: false). + await tick({ resolveOrchestrator: () => null, deliveryEngine: pendingEngine }); + expect(dispatchCount).toBe(1); + expect(markersOf(baton.qitemId, LADDER_RUNG_PREFIX).some((r) => /dispatched-to-engine.*notification_key=notif-key-123/.test(r))).toBe(true); + // Pre-marker matching receipt did not resolve it; ladder not exhausted yet, underlying baton remains pending. + expect(markersOf(baton.qitemId, LADDER_EXHAUSTED_PREFIX)).toHaveLength(0); + expect(repo.getById(baton.qitemId)!.state).toBe("pending"); + + // 2. Wrong key post-marker relay receipt -> no resolution. + repo.transitionLog.append({ + qitemId: baton.qitemId, + state: "pending", + actorSession: "daemon@kernel", + transitionNote: "relay-owner-notification-accepted notification_key=wrong-key delivery_id=d-1", + }); + ageMarkers(baton.qitemId, 30); + await tick({ resolveOrchestrator: () => null, deliveryEngine: pendingEngine }); + expect(dispatchCount).toBe(1); // No second dispatch/resume! + expect(markersOf(baton.qitemId, LADDER_EXHAUSTED_PREFIX)).toHaveLength(0); + + // 3. Matching post-marker relay receipt -> exhausts operator rung without second dispatch and without closing pending work. + repo.transitionLog.append({ + qitemId: baton.qitemId, + state: "pending", + actorSession: "daemon@kernel", + transitionNote: `relay-owner-notification-accepted notification_key=${notificationKey} delivery_id=d-2`, + }); + ageMarkers(baton.qitemId, 30); + await tick({ resolveOrchestrator: () => null, deliveryEngine: pendingEngine }); + expect(dispatchCount).toBe(1); // Exactly-once dispatch preserved + expect(markersOf(baton.qitemId, LADDER_EXHAUSTED_PREFIX)).toHaveLength(1); + expect(markersOf(baton.qitemId, LADDER_EXHAUSTED_PREFIX)[0]).toContain("engine outcome resolved"); + // Underlying blocked/pending work is NOT closed/cancelled + expect(repo.getById(baton.qitemId)!.state).toBe("pending"); + }); + it("HUMAN RUNG EXPLICIT-ONLY: an agent-to-agent baton ends at the escalation row and nothing is posted to a person", async () => { const baton = await exhaustToSelfSkip(); const posted: string[] = []; diff --git a/packages/daemon/test/quiet-hold-custody.test.ts b/packages/daemon/test/quiet-hold-custody.test.ts index 5b467e161..98a334e93 100644 --- a/packages/daemon/test/quiet-hold-custody.test.ts +++ b/packages/daemon/test/quiet-hold-custody.test.ts @@ -38,17 +38,32 @@ describe("custody of asks held for quiet hours", () => { let failPosts = false; let rejectPosts = false; // the connector answers, but refuses the post const wires: Array> = []; + let useRelayBinding = false; + let relaySecretsPath = ""; const registry = () => ({ ok: true as const, entities: [{ entityId: "person", class: "human" as const, displayName: "Person", address: "person@external", prefs, - connectorBindings: [{ kind: "slack" as const, connectorRef: "primary", secretsRef: "env:SLACK_BOT_TOKEN", role: "primary" as const, handle: "UPERSON" }], + connectorBindings: useRelayBinding + ? [{ kind: "relay" as const, connectorRef: "relay-primary", secretsRef: `file:${relaySecretsPath}`, role: "primary" as const, handle: "person_relay" }] + : [{ kind: "slack" as const, connectorRef: "primary", secretsRef: "env:SLACK_BOT_TOKEN", role: "primary" as const, handle: "UPERSON" }], }] }); beforeEach(() => { db = createDb(); migrate(db, ALL_MIGRATIONS); prefs = { deliveryClass: "B", availability: "available" }; + useRelayBinding = false; repo = new QueueRepository(db, new EventBus(db), { validateRig: () => true, loadHumanRegistry: registry as never }); home = mkdtempSync(join(tmpdir(), "quiet-custody-")); + relaySecretsPath = join(home, "relay.env"); + writeFileSync( + relaySecretsPath, + [ + "OPENRIG_RELAY_URL=http://127.0.0.1:44199/outbound", + "OPENRIG_RELAY_OUTBOUND_TOKEN=out-secret-token-123456", + "OPENRIG_RELAY_INBOUND_TOKEN=in-secret-token-654321", + ].join("\n"), + { mode: 0o600 }, + ); quiet = new HumanQuietStore(db); posts.length = 0; failPosts = false; @@ -69,6 +84,12 @@ describe("custody of asks held for quiet hours", () => { if (rejectPosts) return new Response(JSON.stringify({ ok: false, error: "channel_not_found" }), { status: 200, headers: { "content-type": "application/json" } }); return new Response(JSON.stringify({ ok: true, ts: `1724.${100 + posts.length}` }), { status: 200, headers: { "content-type": "application/json" } }); } + if (String(url).includes("127.0.0.1:44199/outbound")) { + if (failPosts) throw new Error("relay bridge down before the post"); + posts.push(JSON.parse(String(init?.body ?? "{}")) as Record); + if (rejectPosts) return new Response(JSON.stringify({ ok: false, error: "bridge_rejected" }), { status: 503, headers: { "content-type": "application/json" } }); + return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "content-type": "application/json" } }); + } return new Response(JSON.stringify({ ok: true, messages: [] }), { status: 200, headers: { "content-type": "application/json" } }); }, }); @@ -438,4 +459,90 @@ describe("custody of asks held for quiet hours", () => { expect(findingFor(r, digest).length).toBeGreaterThan(0); expect(findingWhy(digest)).not.toMatch(/quiet hours/); }); + + it("Relay quiet release failure yields release-failed, named in waitingView and sweep, and the ladder takes it up", async () => { + useRelayBinding = true; + const failedRow = await held(); + asBaton(failedRow); + quiet.end("person", { endedBy: "t", now: new Date() }); + rejectPosts = true; + const w = wires[0]!; + const [job] = releaseJobs(); + await runQuietRelease({ + queueRepo: repo, + jobsRepo: new WatchdogJobsRepository(db), + home, + jobId: job!.jobId, + registry: { loadHumanRegistry: registry as never }, + dispatch: (op, ref, payload, opts) => w.dispatcher.dispatch(op, ref, payload, opts as never), + }); + await settle(); + expect(posts).toHaveLength(1); + expect( + repo.listTransitions(failedRow.qitemId).some((t) => + (t.transitionNote ?? "").startsWith("relay-owner-notification-transport-failed "), + ), + ).toBe(true); + expect(quietHoldCustody(db, failedRow.qitemId)).toMatchObject({ + state: "release-failed", + failure: "http-503", + }); + expect(String((repo.waitingView(failedRow.qitemId)!.nextBackstop as { note?: string }).note)).toMatch( + /^Released when quiet ended, but its post failed \(http-503\); the release stays queued for redelivery\./, + ); + const rFailed = await sweep(); + expect(findingFor(rFailed, failedRow)).toHaveLength(1); + expect(findingFor(rFailed, failedRow)[0]!.kind).toBe("undelivered-human-notice"); + expect(findingWhy(failedRow)).toMatch( + /released after quiet hours; its post failed \(http-503\); the release stays queued for redelivery/, + ); + await ladder(); + expect(ladderTouched(failedRow)).toBe(true); + }); + + it("Relay bridge acceptance resolves quiet-release ownership so the release job retires and custody ends without becoming orphaned", async () => { + useRelayBinding = true; + const acceptedRow = await held(); + quiet.end("person", { endedBy: "t", now: new Date() }); + const w = wires[0]!; + const [job] = releaseJobs(); + // Pass 1 dispatches the release to the relay bridge, which accepts it + expect( + await runQuietRelease({ + queueRepo: repo, + jobsRepo: new WatchdogJobsRepository(db), + home, + jobId: job!.jobId, + registry: { loadHumanRegistry: registry as never }, + dispatch: (op, ref, payload, opts) => w.dispatcher.dispatch(op, ref, payload, opts as never), + }), + ).toMatchObject({ state: "pending", released: 1 }); + await settle(); + expect(posts).toHaveLength(1); + expect( + repo.listTransitions(acceptedRow.qitemId).some((t) => + (t.transitionNote ?? "").startsWith("relay-owner-notification-accepted "), + ), + ).toBe(true); + // Pass 2 sees the relay acceptance receipt via resolved(), retires the release job, and leaves custody null (never orphaned) + expect( + await runQuietRelease({ + queueRepo: repo, + jobsRepo: new WatchdogJobsRepository(db), + home, + jobId: job!.jobId, + registry: { loadHumanRegistry: registry as never }, + dispatch: (op, ref, payload, opts) => w.dispatcher.dispatch(op, ref, payload, opts as never), + }), + ).toMatchObject({ state: "released", released: 0 }); + expect(releaseJobs()).toHaveLength(0); + expect(new WatchdogJobsRepository(db).getById(job!.jobId)?.terminalReason).toBe( + "quiet release complete: every held ask posted, accepted by its bridge, or given a disposition", + ); + expect(quietHoldCustody(db, acceptedRow.qitemId)).toBeNull(); + expect(repo.waitingView(acceptedRow.qitemId)!.nextBackstop).toMatchObject({ + mechanism: "none (accepted by the bridge; awaiting their decision)", + }); + expect(findingFor(await sweep(), acceptedRow)).toEqual([]); + }); }); diff --git a/packages/daemon/test/quiet-hold-release.test.ts b/packages/daemon/test/quiet-hold-release.test.ts index cc55815b9..ad7da98d9 100644 --- a/packages/daemon/test/quiet-hold-release.test.ts +++ b/packages/daemon/test/quiet-hold-release.test.ts @@ -1098,3 +1098,243 @@ describe("after review: the outbound sweep when a dispatch throws", () => { expect(buffer.pending().filter((d) => (d.payload as { qitemId?: string }).qitemId === "q-1")).toHaveLength(1); }); }); + +describe("Capability 3 — relay-bound human reuses quiet hold/release, digest, and deferral policies", () => { + let db: Database.Database; + let repo: QueueRepository; + let home: string; + let quiet: HumanQuietStore; + let prefs: Record; + const relayPosts: Array> = []; + + function relayRegistry(p: Record, secretsFile: string) { + return { + ok: true as const, + entities: [ + { + entityId: HUMAN, + class: "human" as const, + displayName: "Founder", + address: `${HUMAN}@external`, + connectorBindings: [ + { + kind: "relay" as const, + connectorRef: "wa-founder", + secretsRef: `file:${secretsFile}`, + role: "primary" as const, + handle: "founder_wa", + }, + ], + prefs: p, + }, + ], + }; + } + + beforeEach(() => { + db = createDb(); + migrate(db, ALL_MIGRATIONS); + home = mkdtempSync(join(tmpdir(), "quiet-relay-")); + const secretsFile = join(home, "relay.env"); + writeFileSync( + secretsFile, + [ + "OPENRIG_RELAY_URL=http://127.0.0.1:44102/outbound", + "OPENRIG_RELAY_OUTBOUND_TOKEN=out-secret-token-123456", + "OPENRIG_RELAY_INBOUND_TOKEN=in-secret-token-654321", + ].join("\n"), + { mode: 0o600 }, + ); + prefs = { deliveryClass: "B", availability: "available" }; + repo = new QueueRepository(db, new EventBus(db), { + validateRig: () => true, + loadHumanRegistry: () => relayRegistry(prefs, secretsFile), + }); + quiet = new HumanQuietStore(db); + relayPosts.length = 0; + }); + + afterEach(() => { + db.close(); + rmSync(home, { recursive: true, force: true }); + }); + + it("holds routine relay asks during quiet hours, releases them after quiet ends, and flushes digest asks with per-question replyRefs", async () => { + const secretsFile = join(home, "relay.env"); + const w = buildSlackGatewayWire({ + home, + queueRepo: repo, + registry: { loadHumanRegistry: () => relayRegistry(prefs, secretsFile), resolveSlackHandle }, + outboundIntervalMs: 60_000, + fetchImpl: async (_url, init) => { + relayPosts.push(JSON.parse(String(init?.body ?? "{}")) as Record); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }, + }); + + try { + w.startServices?.(); + const now = new Date(); + quiet.setUntil(HUMAN, { until: new Date(now.getTime() + 3_600_000), timezone: "UTC", setBy: "test", now }); + + const row = await repo.create({ + sourceSession: "dev-qa@r", + destinationSession: "orch-lead@r", + body: "Relay park body", + nudge: false, + }); + repo.update({ + qitemId: row.qitemId, + actorSession: "orch-lead@r", + state: "blocked", + blockedOn: `${HUMAN}@kernel`, + summary: "Approve relay park", + evidenceRef: "/proof/relay.md", + transitionNote: "parked", + }); + + const ports = makeQueuePorts(repo, { loadHumanRegistry: () => relayRegistry(prefs, secretsFile) } as never); + const alert = (await ports.listHumanAlerts({ minimumLevel: "NOTICE" })).find((a) => a.qitemId === row.qitemId)!; + + w.dispatcher.dispatch(OUTBOUND_OP, alert.destinationSession!, alert); + await settle(); + expect(relayPosts).toHaveLength(0); + expect(repo.listTransitions(row.qitemId).some((t) => (t.transitionNote ?? "").startsWith("delivery-decision: quiet-hold "))).toBe(true); + + // End quiet hours and run release -> delivers via relay transport and records relay-owner-notification-accepted + quiet.end(HUMAN, { endedBy: "test", now: new Date() }); + const jobs = new WatchdogJobsRepository(db).listActive().filter((j) => j.policy === QUIET_RELEASE_POLICY); + expect(jobs).toHaveLength(1); + const rel = await runQuietRelease({ + queueRepo: repo, + jobsRepo: new WatchdogJobsRepository(db), + home, + jobId: jobs[0]!.jobId, + now: new Date(), + registry: { loadHumanRegistry: () => relayRegistry(prefs, secretsFile) }, + dispatch: (op, ref, payload, opts) => w.dispatcher.dispatch(op, ref, payload, opts as never), + }); + expect(rel).toMatchObject({ released: 1 }); + await settle(); + expect(relayPosts).toHaveLength(1); + expect(relayPosts[0]).toMatchObject({ + v: 1, + recipient: { handle: "founder_wa" }, + intent: "decision", + outcome: "notify", + }); + expect(typeof relayPosts[0]!.replyRef).toBe("string"); + expect(repo.hasRelayOwnerNotificationReceipt(row.qitemId, alert.notificationKey!)).toBe(true); + } finally { + w.stop(); + } + }); + + it("reuses digest flush (with distinct per-question replyRefs) and away-escalation deferral for a relay-bound human", async () => { + const secretsFile = join(home, "relay.env"); + writeFileSync( + secretsFile, + [ + "OPENRIG_RELAY_URL=http://127.0.0.1:44100/outbound", + "OPENRIG_RELAY_OUTBOUND_TOKEN=out-secret-token-123456", + "OPENRIG_RELAY_INBOUND_TOKEN=in-secret-token-654321", + ].join("\n"), + { mode: 0o600 }, + ); + let prefs: Record = { deliveryClass: "C", availability: "available" }; + repo = new QueueRepository(db, new EventBus(db), { + validateRig: () => true, + loadHumanRegistry: () => relayRegistry(prefs, secretsFile), + }); + + const relayPosts: Array> = []; + const w = buildSlackGatewayWire({ + home, + queueRepo: repo, + registry: { loadHumanRegistry: () => relayRegistry(prefs, secretsFile), resolveSlackHandle }, + outboundIntervalMs: 60_000, + fetchImpl: async (_url, init) => { + relayPosts.push(JSON.parse(String(init?.body ?? "{}")) as Record); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }, + }); + + try { + w.startServices?.(); + const [r1, r2] = [ + await repo.create({ sourceSession: "dev@r", destinationSession: "orch@r", body: "Digest ask 1", nudge: false }), + await repo.create({ sourceSession: "dev@r", destinationSession: "orch@r", body: "Digest ask 2", nudge: false }), + ]; + repo.update({ qitemId: r1.qitemId, actorSession: "orch@r", state: "blocked", blockedOn: `${HUMAN}@kernel`, summary: "Ask 1", evidenceRef: "/proof/1.md", transitionNote: "parked" }); + repo.update({ qitemId: r2.qitemId, actorSession: "orch@r", state: "blocked", blockedOn: `${HUMAN}@kernel`, summary: "Ask 2", evidenceRef: "/proof/2.md", transitionNote: "parked" }); + + const ports = makeQueuePorts(repo, { loadHumanRegistry: () => relayRegistry(prefs, secretsFile) } as never); + const alerts = await ports.listHumanAlerts({ minimumLevel: "NOTICE" }); + const a1 = alerts.find((a) => a.qitemId === r1.qitemId)!; + const a2 = alerts.find((a) => a.qitemId === r2.qitemId)!; + + // Dispatch both as Class C available -> recorded into 4h digest, not posted immediately + w.dispatcher.dispatch(OUTBOUND_OP, a1.destinationSession!, a1); + w.dispatcher.dispatch(OUTBOUND_OP, a2.destinationSession!, a2); + await settle(); + expect(relayPosts).toHaveLength(0); + + // Flush 4h digest -> dispatches both asks as outcome="digest" with distinct per-question replyRefs + const flushed = await runDeliveryDigestFlush({ + queueRepo: repo, + registry: { loadHumanRegistry: () => relayRegistry(prefs, secretsFile) }, + home, + window: "4h", + dispatch: (op, ref, payload, opts) => w.dispatcher.dispatch(op, ref, payload, opts as never), + }); + await settle(); + expect(flushed.dispatched).toBe(2); + expect(relayPosts).toHaveLength(2); + expect(relayPosts[0]).toMatchObject({ + v: 1, + recipient: { handle: "founder_wa" }, + intent: "decision", + outcome: "digest", + }); + expect(relayPosts[1]).toMatchObject({ + v: 1, + recipient: { handle: "founder_wa" }, + intent: "decision", + outcome: "digest", + }); + expect(typeof relayPosts[0]!.replyRef).toBe("string"); + expect(typeof relayPosts[1]!.replyRef).toBe("string"); + expect(relayPosts[0]!.replyRef).not.toBe(relayPosts[1]!.replyRef); + expect(repo.hasRelayOwnerNotificationReceipt(r1.qitemId, a1.notificationKey!)).toBe(true); + expect(repo.hasRelayOwnerNotificationReceipt(r2.qitemId, a2.notificationKey!)).toBe(true); + + // Switch human to Class B + away, and dispatch an escalation -> deferred for 30 minutes + prefs = { deliveryClass: "B", availability: "away" }; + const urgent = await repo.create({ + sourceSession: "dev@r", + destinationSession: "orch@r", + body: "Urgent away ask", + tags: ["escalation"], + nudge: false, + }); + repo.update({ + qitemId: urgent.qitemId, + actorSession: "orch@r", + state: "blocked", + blockedOn: `${HUMAN}@kernel`, + summary: "Urgent ask", + evidenceRef: "/proof/urgent.md", + transitionNote: "parked", + }); + const urgentAlert = (await ports.listHumanAlerts({ minimumLevel: "NOTICE" })).find((a) => a.qitemId === urgent.qitemId)!; + w.dispatcher.dispatch(OUTBOUND_OP, urgentAlert.destinationSession!, urgentAlert); + await settle(); + expect(relayPosts).toHaveLength(2); + expect( + new WatchdogJobsRepository(db).listActive().some((j) => JSON.stringify(j).includes(urgent.qitemId)), + ).toBe(true); + } finally { + w.stop(); + } + }); +}); diff --git a/packages/daemon/test/relay-config.test.ts b/packages/daemon/test/relay-config.test.ts new file mode 100644 index 000000000..1926c9fdf --- /dev/null +++ b/packages/daemon/test/relay-config.test.ts @@ -0,0 +1,302 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync, chmodSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { stringify as stringifyYaml } from "yaml"; +import { + addHumanFragment, + projectionPath, + type HumanFragment, +} from "../src/domain/gateway/human-registry.js"; +import { + loadRelayProfiles, + validateRelayUrl, + parseRelaySecretsRef, + authenticateRelayInboundToken, +} from "../src/domain/gateway/relay-config.js"; + +function relayHuman( + entityId: string, + connectorRef: string, + secretsRef: string, + handle: string = entityId, +): Record { + return { + entityId, + class: "human", + displayName: entityId, + address: `${entityId}@external`, + connectorBindings: [ + { + kind: "relay", + connectorRef, + secretsRef, + role: "primary", + handle, + }, + ], + prefs: { deliveryClass: "B" }, + }; +} + +describe("relay-config: URL and secretsRef validation", () => { + it("accepts file: and rejects relative or non-file secretsRef", () => { + expect(parseRelaySecretsRef("file:/etc/openrig/relay.env")).toEqual({ + ok: true, + envFile: "/etc/openrig/relay.env", + }); + expect(parseRelaySecretsRef("/etc/openrig/relay.env").ok).toBe(false); + expect(parseRelaySecretsRef("file:relative/relay.env").ok).toBe(false); + expect(parseRelaySecretsRef("vault://slack/main").ok).toBe(false); + expect(parseRelaySecretsRef("file://localhost/etc/relay.env").ok).toBe(false); + }); + + it("validates http loopback URLs (127.0.0.0/8, ::1, localhost) and rejects non-http, non-loopback, or embedded credentials without echoing secrets", () => { + expect(validateRelayUrl("http://127.0.0.1:8787/relay").ok).toBe(true); + expect(validateRelayUrl("http://127.255.0.1:9000/hook").ok).toBe(true); + expect(validateRelayUrl("http://[::1]:8787/relay").ok).toBe(true); + expect(validateRelayUrl("http://localhost:8787/relay").ok).toBe(true); + + expect(validateRelayUrl("https://127.0.0.1:8787/relay").ok).toBe(false); + expect(validateRelayUrl("http://0.0.0.0:8787/relay").ok).toBe(false); + expect(validateRelayUrl("http://192.168.1.10:8787/relay").ok).toBe(false); + expect(validateRelayUrl("http://example.com:8787/relay").ok).toBe(false); + expect(validateRelayUrl("http://2130706433:8787/relay").ok).toBe(false); + + const credUrl = validateRelayUrl("http://admin:ultra-secret-pass@127.0.0.1:8787/relay"); + expect(credUrl.ok).toBe(false); + if (!credUrl.ok) { + expect(credUrl.reason).toMatch(/credentials/i); + expect(credUrl.reason).not.toContain("ultra-secret-pass"); + expect(credUrl.reason).not.toContain("admin"); + } + }); +}); + +describe("relay-config: loadRelayProfiles(home)", () => { + let home: string; + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "relay-cfg-")); + }); + afterEach(() => { + rmSync(home, { recursive: true, force: true }); + }); + + it("handles an empty human registry (noHumans) cleanly as ok with zero relay profiles", () => { + const loaded = loadRelayProfiles(home); + expect(loaded.ok).toBe(true); + expect(loaded.registryError).toBeNull(); + expect(loaded.profiles.size).toBe(0); + }); + + it("reports registryError when the human registry projection is corrupt", () => { + const envFile = join(home, "relay.env"); + writeFileSync( + envFile, + "OPENRIG_RELAY_URL=http://127.0.0.1:8787/out\nOPENRIG_RELAY_OUTBOUND_TOKEN=out-1\nOPENRIG_RELAY_INBOUND_TOKEN=in-1\n", + { mode: 0o600 }, + ); + expect(addHumanFragment(relayHuman("mike", "bridge-1", `file:${envFile}`), home).ok).toBe(true); + writeFileSync(projectionPath(home), "corrupted: hand-edited\n"); + + const loaded = loadRelayProfiles(home); + expect(loaded.ok).toBe(false); + expect(loaded.registryError).toMatch(/HAND-EDITED|DRIFT/i); + }); + + it("loads a valid 0600 relay profile, exposes private credentials only via non-enumerable property, and never serializes secrets", () => { + const envFile = join(home, "relay.env"); + writeFileSync( + envFile, + [ + "OPENRIG_RELAY_URL=http://127.0.0.1:8787/out", + "OPENRIG_RELAY_OUTBOUND_TOKEN=secret-outbound-token-xyz", + "OPENRIG_RELAY_INBOUND_TOKEN=secret-inbound-token-abc", + ].join("\n"), + { mode: 0o600 }, + ); + expect(addHumanFragment(relayHuman("mike", "bridge-1", `file:${envFile}`, "mike-alias"), home).ok).toBe(true); + + const loaded = loadRelayProfiles(home); + expect(loaded.ok).toBe(true); + const profile = loaded.get("bridge-1"); + expect(profile).toBeDefined(); + expect(profile!.ready).toBe(true); + expect(profile!.state).toBe("ready"); + expect(profile!.configured).toBe(true); + expect(profile!.endpointUrl).toBe("http://127.0.0.1:8787/out"); + expect(profile!.credentials).toEqual({ + url: "http://127.0.0.1:8787/out", + outboundToken: "secret-outbound-token-xyz", + inboundToken: "secret-inbound-token-abc", + }); + + const json = JSON.stringify({ loaded, profile }); + const yaml = stringifyYaml(profile); + expect(json).not.toContain("secret-outbound-token-xyz"); + expect(json).not.toContain("secret-inbound-token-abc"); + expect(yaml).not.toContain("secret-outbound-token-xyz"); + expect(yaml).not.toContain("secret-inbound-token-abc"); + expect(Object.keys(profile!)).not.toContain("credentials"); + + const authOk = authenticateRelayInboundToken(loaded, "secret-inbound-token-abc"); + expect(authOk.ok).toBe(true); + if (authOk.ok) expect(authOk.connectorRef).toBe("bridge-1"); + + expect(authenticateRelayInboundToken(loaded, "secret-outbound-token-xyz").ok).toBe(false); + expect(authenticateRelayInboundToken(loaded, "wrong").ok).toBe(false); + }); + + it("warns on group/world accessible secret file or identical directional tokens while keeping the profile ready (0600 and 0400 produce no warning), and still rejects missing keys/files without falling back to process.env", () => { + const mode0400File = join(home, "mode0400.env"); + writeFileSync( + mode0400File, + "OPENRIG_RELAY_URL=http://127.0.0.1:8787/out\nOPENRIG_RELAY_OUTBOUND_TOKEN=o-400\nOPENRIG_RELAY_INBOUND_TOKEN=i-400\n", + { mode: 0o400 }, + ); + chmodSync(mode0400File, 0o400); + + const insecureFile = join(home, "insecure.env"); + writeFileSync( + insecureFile, + "OPENRIG_RELAY_URL=http://127.0.0.1:8787/out\nOPENRIG_RELAY_OUTBOUND_TOKEN=o1-secret\nOPENRIG_RELAY_INBOUND_TOKEN=i1-secret\n", + { mode: 0o644 }, + ); + chmodSync(insecureFile, 0o644); + + const equalTokensFile = join(home, "equal.env"); + writeFileSync( + equalTokensFile, + "OPENRIG_RELAY_URL=http://127.0.0.1:8787/out\nOPENRIG_RELAY_OUTBOUND_TOKEN=same-secret-tok\nOPENRIG_RELAY_INBOUND_TOKEN=same-secret-tok\n", + { mode: 0o600 }, + ); + + const missingKeyFile = join(home, "missing-key.env"); + writeFileSync( + missingKeyFile, + "OPENRIG_RELAY_URL=http://127.0.0.1:8787/out\nOPENRIG_RELAY_OUTBOUND_TOKEN=only-out\n", + { mode: 0o600 }, + ); + + const savedUrl = process.env.OPENRIG_RELAY_URL; + const savedOut = process.env.OPENRIG_RELAY_OUTBOUND_TOKEN; + const savedIn = process.env.OPENRIG_RELAY_INBOUND_TOKEN; + process.env.OPENRIG_RELAY_URL = "http://127.0.0.1:9999/env-fallback"; + process.env.OPENRIG_RELAY_OUTBOUND_TOKEN = "env-out-token"; + process.env.OPENRIG_RELAY_INBOUND_TOKEN = "env-in-token"; + + try { + expect(addHumanFragment(relayHuman("u0", "r-0400", `file:${mode0400File}`), home).ok).toBe(true); + expect(addHumanFragment(relayHuman("u1", "r-insecure", `file:${insecureFile}`), home).ok).toBe(true); + expect(addHumanFragment(relayHuman("u2", "r-equal", `file:${equalTokensFile}`), home).ok).toBe(true); + expect(addHumanFragment(relayHuman("u3", "r-missing-key", `file:${missingKeyFile}`), home).ok).toBe(true); + expect(addHumanFragment(relayHuman("u4", "r-missing-file", `file:${join(home, "nope.env")}`), home).ok).toBe(true); + + const loaded = loadRelayProfiles(home); + + // 0400 has zero group/other bits -> ready with zero warnings + const p0400 = loaded.get("r-0400")!; + expect(p0400.ready).toBe(true); + expect(p0400.warnings).toEqual([]); + expect(p0400.reason).not.toMatch(/warning|insecure/i); + + // 0644 has group/other bits -> warns like Slack, does NOT make profile unready + const pInsecure = loaded.get("r-insecure")!; + expect(pInsecure.ready).toBe(true); + expect(pInsecure.state).toBe("ready"); + expect(pInsecure.credentials).toEqual({ + url: "http://127.0.0.1:8787/out", + outboundToken: "o1-secret", + inboundToken: "i1-secret", + }); + expect(pInsecure.warnings.length).toBeGreaterThan(0); + expect(pInsecure.warnings[0]).toMatch(/0644/); + expect(pInsecure.warnings[0]).toMatch(/group\/other/i); + expect(pInsecure.reason).toMatch(/warning.*0644/i); + expect(pInsecure.reason).not.toContain("o1-secret"); + expect(pInsecure.reason).not.toContain("i1-secret"); + + // Same token in both directions -> allowed and ready, with a useful redacted warning + const pEqual = loaded.get("r-equal")!; + expect(pEqual.ready).toBe(true); + expect(pEqual.state).toBe("ready"); + expect(pEqual.credentials).toEqual({ + url: "http://127.0.0.1:8787/out", + outboundToken: "same-secret-tok", + inboundToken: "same-secret-tok", + }); + expect(pEqual.warnings.length).toBeGreaterThan(0); + expect(pEqual.warnings[0]).toMatch(/OPENRIG_RELAY_OUTBOUND_TOKEN.*OPENRIG_RELAY_INBOUND_TOKEN|separate/i); + expect(pEqual.reason).toMatch(/warning/i); + expect(pEqual.reason).not.toContain("same-secret-tok"); + expect(authenticateRelayInboundToken(loaded, "same-secret-tok")).toMatchObject({ + ok: true, + connectorRef: "r-equal", + }); + + // Missing key or missing file still not-ready + expect(loaded.get("r-missing-key")!.ready).toBe(false); + expect(loaded.get("r-missing-key")!.reason).toMatch(/OPENRIG_RELAY_INBOUND_TOKEN/); + expect(loaded.get("r-missing-key")!.credentials).toBeNull(); + + expect(loaded.get("r-missing-file")!.ready).toBe(false); + expect(loaded.get("r-missing-file")!.reason).toMatch(/missing or unreadable/i); + expect(loaded.get("r-missing-file")!.credentials).toBeNull(); + } finally { + if (savedUrl === undefined) delete process.env.OPENRIG_RELAY_URL; + else process.env.OPENRIG_RELAY_URL = savedUrl; + if (savedOut === undefined) delete process.env.OPENRIG_RELAY_OUTBOUND_TOKEN; + else process.env.OPENRIG_RELAY_OUTBOUND_TOKEN = savedOut; + if (savedIn === undefined) delete process.env.OPENRIG_RELAY_INBOUND_TOKEN; + else process.env.OPENRIG_RELAY_INBOUND_TOKEN = savedIn; + } + }); + + it("enforces consistent shared profiles across people and rejects ambiguous reused inbound tokens across refs", () => { + const fileA = join(home, "a.env"); + const fileB = join(home, "b.env"); + const fileC = join(home, "c.env"); + writeFileSync( + fileA, + "OPENRIG_RELAY_URL=http://127.0.0.1:8787/a\nOPENRIG_RELAY_OUTBOUND_TOKEN=out-a\nOPENRIG_RELAY_INBOUND_TOKEN=dup-in-secret\n", + { mode: 0o600 }, + ); + writeFileSync( + fileB, + "OPENRIG_RELAY_URL=http://127.0.0.1:8788/b\nOPENRIG_RELAY_OUTBOUND_TOKEN=out-b\nOPENRIG_RELAY_INBOUND_TOKEN=dup-in-secret\n", + { mode: 0o600 }, + ); + writeFileSync( + fileC, + "OPENRIG_RELAY_URL=http://localhost:8789/c\nOPENRIG_RELAY_OUTBOUND_TOKEN=out-c\nOPENRIG_RELAY_INBOUND_TOKEN=unique-in-secret\n", + { mode: 0o600 }, + ); + + const h1 = relayHuman("mike", "shared-inconsistent", `file:${fileA}`, "mike") as unknown as HumanFragment; + const h2 = relayHuman("dana", "shared-inconsistent", `file:${fileB}`, "dana") as unknown as HumanFragment; + const inconsistent = loadRelayProfiles(home, { entities: [h1, h2] }); + expect(inconsistent.get("shared-inconsistent")!.ready).toBe(false); + expect(inconsistent.get("shared-inconsistent")!.reason).toMatch(/inconsistent provisioning/i); + + const a1 = relayHuman("mike", "relay-a", `file:${fileA}`, "mike") as unknown as HumanFragment; + const b1 = relayHuman("dana", "relay-b", `file:${fileB}`, "dana") as unknown as HumanFragment; + const c1 = relayHuman("alex", "relay-c", `file:${fileC}`, "alex") as unknown as HumanFragment; + const c2 = relayHuman("sam", "relay-c", `file:${fileC}`, "sam") as unknown as HumanFragment; + const ambiguous = loadRelayProfiles(home, { entities: [a1, b1, c1, c2] }); + + expect(ambiguous.get("relay-a")!.ready).toBe(false); + expect(ambiguous.get("relay-a")!.reason).toMatch(/ambiguous.*OPENRIG_RELAY_INBOUND_TOKEN/i); + expect(ambiguous.get("relay-a")!.reason).not.toContain("dup-in-secret"); + + expect(ambiguous.get("relay-b")!.ready).toBe(false); + expect(ambiguous.get("relay-b")!.reason).toMatch(/ambiguous.*OPENRIG_RELAY_INBOUND_TOKEN/i); + + expect(ambiguous.get("relay-c")!.ready).toBe(true); + expect(ambiguous.get("relay-c")!.humans.map((h) => h.entityId)).toEqual(["alex", "sam"]); + expect(authenticateRelayInboundToken(ambiguous, "dup-in-secret").ok).toBe(false); + expect(authenticateRelayInboundToken(ambiguous, "unique-in-secret")).toMatchObject({ + ok: true, + connectorRef: "relay-c", + }); + }); +}); diff --git a/packages/daemon/test/relay-outbound.test.ts b/packages/daemon/test/relay-outbound.test.ts new file mode 100644 index 000000000..835ee7f39 --- /dev/null +++ b/packages/daemon/test/relay-outbound.test.ts @@ -0,0 +1,1580 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { buildInProcessWire } from "../src/domain/gateway/gateway-subsystem.js"; +import { DispatchBuffer } from "../src/domain/gateway/dispatch-buffer.js"; +import { OUTBOUND_OP, type OutboundPostPayload } from "../src/domain/gateway/slack/outbound-driver.js"; +import { SeenStore, type StateFsOps } from "../src/domain/gateway/slack/state-store.js"; +import type { OutboundDecision } from "../src/domain/gateway/protocol.js"; +import { + RELAY_PROTOCOL_VERSION, + encodeRelayReplyRef, + decodeRelayReplyRef, + hashRelayConnectorRef, + hashRelayReplyRef, + hashRelayReplyRefPayload, + validateRelayOutboundEnvelope, + findPendingRelayReplyRef, + resolvePrimaryRelayBinding, + prepareRelayDecision, + postRelayEnvelope, + createRelayOutboundTransport, + type RelayAcceptedReceiptInfo, + type RelayOutboundEnvelope, + type RelayPayloadCarry, + type RelayReplyRefPayload, +} from "../src/domain/gateway/relay-outbound.js"; +import { startRecordingRelay } from "./fixtures/recording-relay.js"; + +function memFs(): StateFsOps & { files: Map; failReadsFor: Set } { + const files = new Map(); + const failReadsFor = new Set(); + return { + files, + failReadsFor, + readFileSync: (p) => { + if (failReadsFor.has(p)) { + throw Object.assign(new Error(`EACCES: permission denied, open '${p}'`), { code: "EACCES" }); + } + if (!files.has(p)) { + throw Object.assign(new Error(`ENOENT: no such file or directory, open '${p}'`), { code: "ENOENT" }); + } + return files.get(p)!; + }, + appendFileSync: (p, d) => files.set(p, (files.get(p) ?? "") + d), + writeFileSync: (p, d) => files.set(p, d), + rename: (a, b) => { + files.set(b, files.get(a) ?? ""); + files.delete(a); + }, + mkdirp: () => {}, + }; +} + +const tick = () => new Promise((r) => setTimeout(r, 10)); + +/** Recording in-memory bridge fixture modeling operator bridge custody + deliveryId deduplication. */ +function recordingBridgeFixture(opts?: { + failFirstHttp?: boolean; + commitBeforeDropOnFirst?: boolean; + redirectTo?: string; + statusCode?: number; + responseBody?: string; +}) { + const custodyByDeliveryId = new Map(); + const attempts: Array<{ url: string; auth: string | null; envelope: RelayOutboundEnvelope }> = []; + let callCount = 0; + + const fetchImpl = async (url: string, init?: RequestInit): Promise => { + callCount++; + if (opts?.redirectTo) { + if (init?.redirect === "error") { + throw new TypeError("fetch failed: redirect mode is set to error"); + } + return new Response("", { status: 302, headers: { location: opts.redirectTo } }); + } + const headers = new Headers(init?.headers); + const auth = headers.get("authorization"); + const envelope = JSON.parse(String(init?.body ?? "{}")) as RelayOutboundEnvelope; + attempts.push({ url, auth, envelope }); + + if (opts?.failFirstHttp && callCount === 1) { + if (opts.commitBeforeDropOnFirst) { + // Bridge committed custody of deliveryId, but the HTTP response was lost in flight. + if (!custodyByDeliveryId.has(envelope.deliveryId)) { + custodyByDeliveryId.set(envelope.deliveryId, envelope); + } + } + throw new Error("socket hang up after bridge custody commit"); + } + + if (opts?.statusCode && opts.statusCode >= 400) { + return new Response(opts.responseBody ?? JSON.stringify({ error: "bridge_error" }), { + status: opts.statusCode, + headers: { "content-type": "application/json" }, + }); + } + + // Bridge deduplicates by deliveryId and commits custody before returning 2xx. + const deduplicated = custodyByDeliveryId.has(envelope.deliveryId); + if (!deduplicated) { + custodyByDeliveryId.set(envelope.deliveryId, envelope); + } + return new Response(JSON.stringify({ ok: true, deliveryId: envelope.deliveryId, deduplicated }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + return { + fetchImpl, + custodyByDeliveryId, + attempts, + callCount: () => callCount, + }; +} + +describe("Capability 3 — relay-outbound protocol v1, replyRef, and pure transport", () => { + let home: string; + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "relay-outbound-")); + }); + afterEach(() => { + rmSync(home, { recursive: true, force: true }); + }); + + it("encodes, decodes, validates, and hashes fixed-size opaque base64url replyRef tokens with closed schema for arbitrary high-entropy ID lengths", () => { + const payload: RelayReplyRefPayload = { + v: RELAY_PROTOCOL_VERSION, + qitemId: "qitem-ask-1", + episodeKey: "qitem-ask-1:42", + decisionRevision: "dr1-abcdef0123456789abcdef01", + decisionId: "dec-101", + connectorRef: "wa-main", + mode: "park", + digest: true, + }; + const encoded = encodeRelayReplyRef(payload); + expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/); + expect(encoded.length).toBe(266); + expect(encoded.length).toBeLessThanOrEqual(4096); + + const decoded = decodeRelayReplyRef(encoded); + expect(decoded).toEqual({ + ok: true, + ref: { + v: RELAY_PROTOCOL_VERSION, + payloadHash: hashRelayReplyRefPayload(payload), + connectorHash: hashRelayConnectorRef(payload.connectorRef), + mode: "park", + digest: true, + }, + }); + expect(hashRelayReplyRef(encoded)).toMatch(/^[0-9a-f]{64}$/); + + // High-entropy IDs (> 6,000 chars each) that cannot compress below 4,096 chars still produce the exact same constant token length + const highEntropyBlock = Array.from({ length: 200 }, (_, i) => + hashRelayReplyRef(`entropy-seed-${i}-${i * 7919}`), + ).join(""); + expect(highEntropyBlock.length).toBe(12_800); + const hugePayload: RelayReplyRefPayload = { + v: RELAY_PROTOCOL_VERSION, + qitemId: `q-${highEntropyBlock}`, + episodeKey: `q-${highEntropyBlock}:99999`, + decisionRevision: "dr1-abcdef0123456789abcdef01", + decisionId: `dec-${highEntropyBlock}`, + connectorRef: `conn-${highEntropyBlock}`, + mode: "park", + digest: true, + }; + const hugeEncoded = encodeRelayReplyRef(hugePayload); + expect(hugeEncoded).toMatch(/^[A-Za-z0-9_-]+$/); + expect(hugeEncoded.length).toBe(encoded.length); + expect(hugeEncoded.length).toBeLessThanOrEqual(4096); + expect(decodeRelayReplyRef(hugeEncoded)).toEqual({ + ok: true, + ref: { + v: RELAY_PROTOCOL_VERSION, + payloadHash: hashRelayReplyRefPayload(hugePayload), + connectorHash: hashRelayConnectorRef(hugePayload.connectorRef), + mode: "park", + digest: true, + }, + }); + + // Closed schema: reject unknown keys, wrong version, bad hashes, bad mode, or non-base64url + const validTokenObj = { + v: RELAY_PROTOCOL_VERSION, + payloadHash: hashRelayReplyRefPayload(payload), + connectorHash: hashRelayConnectorRef(payload.connectorRef), + mode: "park", + digest: true, + }; + const withUnknownKey = Buffer.from(JSON.stringify({ ...validTokenObj, extra: "nope" }), "utf8").toString("base64url"); + expect(decodeRelayReplyRef(withUnknownKey).ok).toBe(false); + + const wrongVersion = Buffer.from(JSON.stringify({ ...validTokenObj, v: 2 }), "utf8").toString("base64url"); + expect(decodeRelayReplyRef(wrongVersion).ok).toBe(false); + + const shortHash = Buffer.from(JSON.stringify({ ...validTokenObj, payloadHash: "abc123" }), "utf8").toString("base64url"); + expect(decodeRelayReplyRef(shortHash).ok).toBe(false); + + expect(decodeRelayReplyRef("not valid json base64!").ok).toBe(false); + }); + + it("freezes protocol v1 closed envelope before enqueue: decision carries replyRef, update/notice has replyRef=null, digest carries per-question refs", () => { + const reg = { + ok: true as const, + entities: [ + { + entityId: "sam", + class: "human" as const, + displayName: "Sam", + address: "sam@external", + connectorBindings: [ + { + kind: "relay", + connectorRef: "wa-primary", + secretsRef: "file:/tmp/wa.env", + role: "primary" as const, + handle: "sam_wa", + }, + ], + prefs: { deliveryClass: "B" as const, availability: "available" as const }, + }, + ], + }; + + const items = new Map([ + ["q-park-1", { qitemId: "q-park-1", state: "blocked", humanIntent: "decision", revision: "dr1-rev-park-1", episodeKey: "q-park-1:7" }], + ["q-ask-2", { qitemId: "q-ask-2", state: "pending", humanIntent: "decision", revision: "dr1-rev-ask-2", episodeKey: "q-ask-2:3" }], + ["q-upd-1", { qitemId: "q-upd-1", state: "pending", humanIntent: "update", revision: "dr1-rev-upd-1", episodeKey: "q-upd-1:1" }], + ]); + + const prepareOpts = { + loadHumanRegistry: () => reg, + lookupQuestionState: (qitemId: string, fallbackKey?: string | null) => { + const found = items.get(qitemId); + if (!found) return null; + return { + qitemId, + mode: (found.state === "blocked" ? "park" : "ask") as "park" | "ask", + episodeKey: fallbackKey ?? found.episodeKey, + decisionRevision: found.revision, + humanIntent: found.humanIntent ?? null, + }; + }, + }; + + // 1. Decision ask/park -> replyRef non-null, structured questions rendered as plain text, secrets redacted + const parkDecision: OutboundDecision = { + kind: "outbound_decision", + decisionId: "dec-park-1", + op: OUTBOUND_OP, + entityBindingRef: "sam@external", + payload: { + qitemId: "q-park-1", + notificationKey: "q-park-1:7", + ownerNotificationKind: "human-required", + ownerNotificationLevel: "ALERT", + humanIntent: "decision", + summary: "Approve release?", + body: "Token xoxb-1234567890-abcdef should be redacted.", + humanDetail: "Extra detail line.", + humanQuestions: [ + { + id: "q1", + question: "Which target?", + options: [ + { id: "staging", label: "Staging", recommended: true }, + { id: "prod", label: "Production" }, + ], + }, + ], + destinationSession: "sam@external", + sourceSession: "lead@rig", + state: "blocked", + } satisfies OutboundPostPayload, + }; + + const preparedPark = prepareRelayDecision(parkDecision, { ...prepareOpts, resolveOutcome: () => "interrupt" }); + const envPark = (preparedPark.payload as { relayEnvelope?: RelayOutboundEnvelope }).relayEnvelope!; + expect(envPark).toBeDefined(); + expect(validateRelayOutboundEnvelope(envPark)).toEqual({ ok: true, envelope: envPark }); + expect(envPark.deliveryId).toBe("dec-park-1"); + expect(envPark.recipient).toEqual({ handle: "sam_wa" }); + expect(envPark.intent).toBe("decision"); + expect(envPark.outcome).toBe("interrupt"); + expect(envPark.body).toContain("[redacted-secret]"); + expect(envPark.body).not.toContain("xoxb-1234567890-abcdef"); + expect(envPark.body).toContain("Which target?"); + expect(envPark.body).toContain("Staging (recommended)"); + expect(envPark.body).toContain("Extra detail line."); + expect(envPark.replyRef).not.toBeNull(); + const decodedRef = decodeRelayReplyRef(envPark.replyRef!); + const expectedParkPayload: RelayReplyRefPayload = { + v: 1, + qitemId: "q-park-1", + episodeKey: "q-park-1:7", + decisionRevision: "dr1-rev-park-1", + decisionId: "dec-park-1", + connectorRef: "wa-primary", + mode: "park", + }; + expect(decodedRef).toEqual({ + ok: true, + ref: { + v: 1, + payloadHash: hashRelayReplyRefPayload(expectedParkPayload), + connectorHash: hashRelayConnectorRef("wa-primary"), + mode: "park", + }, + }); + expect((preparedPark.payload as { relayReplyRefs?: Record }).relayReplyRefs?.[envPark.replyRef!]).toEqual( + expectedParkPayload, + ); + + // 2. Informational update -> replyRef is strictly null + const updateDecision: OutboundDecision = { + kind: "outbound_decision", + decisionId: "dec-upd-1", + op: OUTBOUND_OP, + entityBindingRef: "sam@external", + payload: { + qitemId: "q-upd-1", + notificationKey: "q-upd-1:1", + ownerNotificationKind: "human-update", + ownerNotificationLevel: "NOTICE", + humanIntent: "update", + summary: "FYI build green", + body: "All checks passed.", + destinationSession: "sam@external", + } satisfies OutboundPostPayload, + }; + const preparedUpdate = prepareRelayDecision(updateDecision, { ...prepareOpts, resolveOutcome: () => "notify" }); + const envUpdate = (preparedUpdate.payload as { relayEnvelope?: RelayOutboundEnvelope }).relayEnvelope!; + expect(envUpdate.intent).toBe("update"); + expect(envUpdate.outcome).toBe("notify"); + expect(envUpdate.replyRef).toBeNull(); + + // 3. Multi-item digest -> top-level replyRef is null; per-question refs in items[] prevent one reply from approving several + const digestDecision: OutboundDecision = { + kind: "outbound_decision", + decisionId: "digest:multi-1", + op: OUTBOUND_OP, + entityBindingRef: "sam@external", + payload: { + deliveryDigestPost: true, + digestId: "multi-1", + qitemId: "q-park-1", + destinationSession: "sam@external", + summary: "Delivery digest (4h) — 2 item(s)", + body: "• Approve release? [q-park-1]\n• Confirm API? [q-ask-2]", + memberReceipts: [ + { qitemId: "q-park-1", notificationKey: "q-park-1:7", level: "ALERT", kind: "human-required" }, + { qitemId: "q-ask-2", notificationKey: "q-ask-2:3", level: "ALERT", kind: "human-required" }, + ], + }, + }; + const preparedDigest = prepareRelayDecision(digestDecision, { ...prepareOpts, resolveOutcome: () => "digest" }); + const envDigest = (preparedDigest.payload as { relayEnvelope?: RelayOutboundEnvelope }).relayEnvelope!; + expect(envDigest.outcome).toBe("digest"); + expect(envDigest.replyRef, "multi-question digest top-level replyRef must be null so one reply cannot resolve both").toBeNull(); + expect(envDigest.items).toHaveLength(2); + expect(envDigest.items![0]!.replyRef).not.toBeNull(); + expect(envDigest.items![1]!.replyRef).not.toBeNull(); + expect(envDigest.items![0]!.replyRef).not.toBe(envDigest.items![1]!.replyRef); + const dRef1 = decodeRelayReplyRef(envDigest.items![0]!.replyRef!); + const dRef2 = decodeRelayReplyRef(envDigest.items![1]!.replyRef!); + const digestReplyRefs = (preparedDigest.payload as { relayReplyRefs?: Record }).relayReplyRefs!; + expect(dRef1.ok && dRef1.ref.digest === true && dRef1.ref.mode === "park").toBe(true); + expect(dRef2.ok && dRef2.ref.digest === true && dRef2.ref.mode === "ask").toBe(true); + expect(digestReplyRefs[envDigest.items![0]!.replyRef!]?.qitemId).toBe("q-park-1"); + expect(digestReplyRefs[envDigest.items![1]!.replyRef!]?.qitemId).toBe("q-ask-2"); + + // 4. Missing queue row or noncurrent episode NEVER mints a synthetic replyRef + const missingRowDecision: OutboundDecision = { + kind: "outbound_decision", + decisionId: "dec-missing-row", + op: OUTBOUND_OP, + entityBindingRef: "sam@external", + payload: { + qitemId: "q-missing-row", + notificationKey: "q-missing-row:1", + ownerNotificationKind: "human-required", + ownerNotificationLevel: "ALERT", + humanIntent: "decision", + summary: "Missing queue row", + body: "Should not get a synthetic dr1 revision", + destinationSession: "sam@external", + } satisfies OutboundPostPayload, + }; + const preparedMissing = prepareRelayDecision(missingRowDecision, { + loadHumanRegistry: () => reg, + queueRepo: { + getById: () => null, + currentHumanRequiredEpisode: () => null, + decisionRevision: () => "dr1-never", + }, + resolveOutcome: () => "interrupt", + }); + const envMissing = (preparedMissing.payload as { relayEnvelope?: RelayOutboundEnvelope }).relayEnvelope!; + expect(envMissing.replyRef, "unavailable/noncurrent question state must never mint a replyRef").toBeNull(); + }); + + it("recovers exact offered replyRef from pending DispatchBuffer before acceptance receipt lands", async () => { + let releaseDeliver: () => void = () => {}; + const gate = new Promise<{ ok: true }>((r) => { + releaseDeliver = () => r({ ok: true }); + }); + + const pendingPayload: RelayReplyRefPayload = { + v: 1, + qitemId: "q-pending-1", + episodeKey: "q-pending-1:2", + decisionRevision: "dr1-pending-rev-1", + decisionId: "dec-pending-1", + connectorRef: "wa-main", + mode: "park", + }; + const offeredRef = encodeRelayReplyRef(pendingPayload); + + const wire = buildInProcessWire({ + home, + ops: [OUTBOUND_OP], + prepare: (d) => ({ + ...d, + payload: { + ...(d.payload as object), + relayConnectorRef: "wa-main", + relayReplyRefs: { + [offeredRef]: pendingPayload, + }, + relayEnvelope: { + v: 1, + deliveryId: d.decisionId, + recipient: { handle: "sam_wa" }, + intent: "decision", + outcome: "interrupt", + summary: "Pending ask", + body: "Pending body", + replyRef: offeredRef, + } satisfies RelayOutboundEnvelope, + }, + }), + deliver: () => gate, + }); + + wire.dispatcher.dispatch(OUTBOUND_OP, "sam@external", { qitemId: "q-pending-1" }, { decisionId: "dec-pending-1" }); + + // Exact offered replyRef is recoverable from the pending buffer + const found = findPendingRelayReplyRef(home, offeredRef); + expect(found).toEqual({ + found: true, + decisionId: "dec-pending-1", + connectorRef: "wa-main", + qitemId: "q-pending-1", + episodeKey: "q-pending-1:2", + decisionRevision: "dr1-pending-rev-1", + mode: "park", + }); + + // Forged ref with valid structure but not actually offered is rejected + const forgedRef = encodeRelayReplyRef({ + v: 1, + qitemId: "q-pending-1", + episodeKey: "q-pending-1:2", + decisionRevision: "dr1-forged-rev", + decisionId: "dec-pending-1", + connectorRef: "wa-main", + mode: "park", + }); + expect(findPendingRelayReplyRef(home, forgedRef)).toEqual({ found: false }); + + releaseDeliver(); + await tick(); + expect(findPendingRelayReplyRef(home, offeredRef)).toEqual({ found: false }); + }); + + it("enforces loopback http endpoint, rejects redirects, bounds timeouts, and sanitizes secret tokens on error", async () => { + const sampleEnvelope: RelayOutboundEnvelope = { + v: 1, + deliveryId: "dec-sec-1", + recipient: { handle: "sam_wa" }, + intent: "decision", + outcome: "interrupt", + summary: "Test", + body: "Body", + replyRef: null, + }; + + // 1. Non-loopback or non-http or URL credentials rejected without calling fetch + let fetchCalled = false; + const neverFetch = async () => { + fetchCalled = true; + return new Response("{}", { status: 200 }); + }; + const nonLoopback = await postRelayEnvelope({ + url: "http://192.168.1.50:8080/outbound", + outboundToken: "secret-outbound-token-12345", + envelope: sampleEnvelope, + fetchImpl: neverFetch, + }); + expect(nonLoopback.ok).toBe(false); + expect(nonLoopback.class).toBe("invalid-endpoint"); + expect(fetchCalled).toBe(false); + + const urlCreds = await postRelayEnvelope({ + url: "http://user:pass@127.0.0.1:8080/outbound", + outboundToken: "secret-outbound-token-12345", + envelope: sampleEnvelope, + fetchImpl: neverFetch, + }); + expect(urlCreds.ok).toBe(false); + expect(urlCreds.class).toBe("invalid-endpoint"); + expect(fetchCalled).toBe(false); + + // 2. Redirect rejected + const redirectBridge = recordingBridgeFixture({ redirectTo: "http://example.com/steal" }); + const redirected = await postRelayEnvelope({ + url: "http://127.0.0.1:9090/outbound", + outboundToken: "secret-outbound-token-12345", + envelope: sampleEnvelope, + fetchImpl: redirectBridge.fetchImpl, + }); + expect(redirected.ok).toBe(false); + + // 3. Error message or bridge HTTP response body is never echoed; fixed sanitized status/reason only + const secretToken = "relay-secret-token-9988776655"; + const leakyFetch = async () => { + throw new Error(`request failed with raw credential ${secretToken} and Authorization: Bearer ${secretToken}`); + }; + const leaked = await postRelayEnvelope({ + url: "http://localhost:9090/outbound", + outboundToken: secretToken, + envelope: sampleEnvelope, + fetchImpl: leakyFetch, + }); + expect(leaked.ok).toBe(false); + expect(leaked.detail).not.toContain(secretToken); + + const bridge502 = recordingBridgeFixture({ + statusCode: 502, + responseBody: `upstream failed with inboundToken=${secretToken} and extra details`, + }); + const badStatus = await postRelayEnvelope({ + url: "http://127.0.0.1:9090/outbound", + outboundToken: secretToken, + envelope: sampleEnvelope, + fetchImpl: bridge502.fetchImpl, + }); + expect(badStatus).toEqual({ + ok: false, + status: 502, + class: "http-502", + detail: "http 502", + }); + }); + + it("proves durable-before-network, stable replay on lost ACK with recording fake bridge, and no plaintext answers/secrets in SeenStore metadata", async () => { + const fsx = memFs(); + const attempted = new SeenStore("/state/relay-attempted-decisions.jsonl", fsx); + const delivered = new SeenStore("/state/relay-delivered-decisions.jsonl", fsx); + const outboundSeen = new SeenStore("/state/slack-outbound-seen.jsonl", fsx); + + const bridge = recordingBridgeFixture({ failFirstHttp: true, commitBeforeDropOnFirst: true }); + const receipts: RelayAcceptedReceiptInfo[] = []; + const failures: Array<{ failureClass: string; detail: string }> = []; + + let mutableRevision = "dr1-initial-rev-001"; + const reg = { + ok: true as const, + entities: [ + { + entityId: "sam", + class: "human" as const, + displayName: "Sam", + address: "sam@external", + connectorBindings: [ + { + kind: "relay", + connectorRef: "wa-main", + secretsRef: "file:/tmp/wa.env", + role: "primary" as const, + handle: "sam_wa", + }, + ], + prefs: { deliveryClass: "A" as const, availability: "available" as const }, + }, + ], + }; + + const transport = createRelayOutboundTransport({ + attempted, + delivered, + outboundSeen, + fetchImpl: bridge.fetchImpl, + resolveProfile: (connectorRef) => + connectorRef === "wa-main" + ? { ok: true, connectorRef, url: "http://127.0.0.1:43110/outbound", outboundToken: "out-tok-secret-123456" } + : { ok: false, reason: "unknown profile" }, + onAccepted: (info) => { + receipts.push(info); + }, + onTransportFailed: (_p, failureClass, detail) => { + failures.push({ failureClass, detail }); + }, + }); + + const wire = buildInProcessWire({ + home, + ops: [OUTBOUND_OP], + prepare: (d) => + prepareRelayDecision(d, { + loadHumanRegistry: () => reg, + lookupQuestionState: (qitemId, fallbackKey) => ({ + qitemId, + mode: "park", + episodeKey: fallbackKey ?? `${qitemId}:1`, + decisionRevision: mutableRevision, + humanIntent: "decision", + }), + resolveOutcome: () => "interrupt", + }), + deliver: (d) => transport.deliver(d), + }); + + const payload: OutboundPostPayload = { + qitemId: "q-replay-1", + notificationKey: "q-replay-1:1", + ownerNotificationKind: "human-required", + ownerNotificationLevel: "ALERT", + humanIntent: "decision", + summary: "Deploy v2?", + body: "Sensitive body text with Bearer secret-token-abcdef123456", + destinationSession: "sam@external", + sourceSession: "lead@rig", + state: "blocked", + }; + + // Dispatch: prepare freezes envelope, enqueues to DispatchBuffer, then attempts HTTP 1 (which drops after bridge custody commit) + const disp = wire.dispatcher.dispatch(OUTBOUND_OP, "sam@external", payload, { decisionId: "dec-replay-1" }); + expect(disp.ok).toBe(true); + await tick(); + + // Bridge has custody of 1 message, but OpenRig lost the ACK -> retained in DispatchBuffer, attempted marked, NOT delivered + expect(bridge.custodyByDeliveryId.size).toBe(1); + expect(bridge.callCount()).toBe(1); + expect(attempted.load().has("dec-replay-1")).toBe(true); + expect(delivered.load().has("dec-replay-1")).toBe(false); + expect(receipts).toHaveLength(0); + expect(failures).toHaveLength(1); + expect(new DispatchBuffer(home).pending()).toHaveLength(1); + + // Mutate current revision before replay to prove replay sends the EXACT frozen envelope and does NOT recompute refs + mutableRevision = "dr1-mutated-later-rev-999"; + + // Replay: sends identical deliveryId + identical replyRef; bridge deduplicates; receipt written; buffer drained + wire.startServices?.(); + await tick(); + + expect(bridge.callCount()).toBe(2); + expect(bridge.custodyByDeliveryId.size, "bridge deduplicates same deliveryId").toBe(1); + expect(bridge.attempts[1]!.envelope).toEqual(bridge.attempts[0]!.envelope); + expect(bridge.attempts[1]!.envelope.replyRef).toBe( + encodeRelayReplyRef({ + v: 1, + qitemId: "q-replay-1", + episodeKey: "q-replay-1:1", + decisionRevision: "dr1-initial-rev-001", + decisionId: "dec-replay-1", + connectorRef: "wa-main", + mode: "park", + }), + ); + + expect(receipts).toHaveLength(1); + expect(receipts[0]).toMatchObject({ + qitemId: "q-replay-1", + notificationKey: "q-replay-1:1", + connectorRef: "wa-main", + deliveryId: "dec-replay-1", + refHash: hashRelayReplyRef(bridge.attempts[0]!.envelope.replyRef!), + revision: "dr1-initial-rev-001", + }); + + expect(delivered.load().has("dec-replay-1")).toBe(true); + expect(outboundSeen.load().has("q-replay-1:1")).toBe(true); + expect(new DispatchBuffer(home).pending()).toHaveLength(0); + + // Verify no secrets or plaintext body/answers in SeenStore metadata files + for (const content of fsx.files.values()) { + expect(content).not.toContain("out-tok-secret-123456"); + expect(content).not.toContain("Sensitive body text"); + expect(content).not.toContain("secret-token-abcdef123456"); + } + }); + + it("retains decision in DispatchBuffer on profile failure, receipt write failure, or unreadable strict SeenStore", async () => { + const fsx = memFs(); + const attempted = new SeenStore("/state/relay-attempted-decisions.jsonl", fsx); + const delivered = new SeenStore("/state/relay-delivered-decisions.jsonl", fsx); + const outboundSeen = new SeenStore("/state/slack-outbound-seen.jsonl", fsx); + const bridge = recordingBridgeFixture(); + + let profileReady = false; + let failReceiptWrite = true; + const receipts: RelayAcceptedReceiptInfo[] = []; + + const transport = createRelayOutboundTransport({ + attempted, + delivered, + outboundSeen, + fetchImpl: bridge.fetchImpl, + resolveProfile: (connectorRef) => + profileReady + ? { ok: true, connectorRef, url: "http://127.0.0.1:43110/outbound", outboundToken: "out-tok-valid-123456" } + : { ok: false, reason: "relay secrets file missing or permissions insecure" }, + onAccepted: (info) => { + if (failReceiptWrite) throw new Error("sqlite busy: receipt write failed"); + receipts.push(info); + }, + }); + + const decision: OutboundDecision = { + kind: "outbound_decision", + decisionId: "dec-retain-1", + op: OUTBOUND_OP, + entityBindingRef: "sam@external", + payload: { + qitemId: "q-retain-1", + notificationKey: "q-retain-1:1", + ownerNotificationKind: "human-required", + ownerNotificationLevel: "ALERT", + humanIntent: "decision", + destinationSession: "sam@external", + relayConnectorRef: "wa-main", + relayEnvelope: { + v: 1, + deliveryId: "dec-retain-1", + recipient: { handle: "sam_wa" }, + intent: "decision", + outcome: "interrupt", + summary: "Check retention", + body: "Body", + replyRef: encodeRelayReplyRef({ + v: 1, + qitemId: "q-retain-1", + episodeKey: "q-retain-1:1", + decisionRevision: "dr1-ret-1", + decisionId: "dec-retain-1", + connectorRef: "wa-main", + mode: "park", + }), + } satisfies RelayOutboundEnvelope, + }, + }; + + const wire = buildInProcessWire({ + home, + ops: [OUTBOUND_OP], + deliver: (d) => transport.deliver(d), + }); + + // 1. Profile unready -> retained in buffer, zero HTTP calls + wire.dispatcher.dispatch(OUTBOUND_OP, "sam@external", decision.payload, { decisionId: decision.decisionId }); + await tick(); + expect(bridge.callCount()).toBe(0); + expect(new DispatchBuffer(home).pending()).toHaveLength(1); + + // 2. Profile ready, but receipt write throws after HTTP 200 -> retained in buffer, not marked delivered + profileReady = true; + wire.startServices?.(); + await tick(); + expect(bridge.callCount()).toBe(1); + expect(receipts).toHaveLength(0); + expect(delivered.load().has("dec-retain-1")).toBe(false); + expect(new DispatchBuffer(home).pending()).toHaveLength(1); + + // 3. Strict SeenStore unreadable -> retained in buffer, no blind action + fsx.failReadsFor.add("/state/relay-attempted-decisions.jsonl"); + const unreadableOutcome = await transport.deliver(decision); + expect(unreadableOutcome.ok).toBe(false); + expect(unreadableOutcome.class).toBe("relay-state-unreadable"); + fsx.failReadsFor.delete("/state/relay-attempted-decisions.jsonl"); + + // 4. Receipt write succeeds on next replay -> receipt persisted, buffer drained + failReceiptWrite = false; + const w2 = buildInProcessWire({ + home, + ops: [OUTBOUND_OP], + deliver: (d) => transport.deliver(d), + }); + w2.startServices?.(); + await tick(); + expect(receipts).toHaveLength(1); + expect(delivered.load().has("dec-retain-1")).toBe(true); + expect(new DispatchBuffer(home).pending()).toHaveLength(0); + }); + + it("repairs failed outboundSeen mark/release on retry using durable receipt without reposting or leaving poll guard inconsistent", async () => { + const fsx = memFs(); + let failOutboundSeenAppend = true; + const baseAppend = fsx.appendFileSync.bind(fsx); + fsx.appendFileSync = (p, d) => { + if (failOutboundSeenAppend && p === "/state/slack-outbound-seen.jsonl") { + throw new Error("simulated disk error writing outbound-seen"); + } + baseAppend(p, d); + }; + + const attempted = new SeenStore("/state/relay-attempted-decisions.jsonl", fsx); + const delivered = new SeenStore("/state/relay-delivered-decisions.jsonl", fsx); + const outboundSeen = new SeenStore("/state/slack-outbound-seen.jsonl", fsx); + const bridge = recordingBridgeFixture(); + const acceptedKeys = new Set(); + const releasedKeys: string[] = []; + + const transport = createRelayOutboundTransport({ + attempted, + delivered, + outboundSeen, + fetchImpl: bridge.fetchImpl, + resolveProfile: (connectorRef) => ({ + ok: true, + connectorRef, + url: "http://127.0.0.1:43110/outbound", + outboundToken: "out-tok-valid-123456", + }), + hasAcceptedReceipt: ({ qitemId, notificationKey }) => acceptedKeys.has(`${qitemId}:${notificationKey}`), + onAccepted: (info) => { + acceptedKeys.add(`${info.qitemId}:${info.notificationKey}`); + }, + release: (key) => { + releasedKeys.push(key); + }, + }); + + const decision: OutboundDecision = { + kind: "outbound_decision", + decisionId: "dec-seen-repair-1", + op: OUTBOUND_OP, + entityBindingRef: "sam@external", + payload: { + qitemId: "q-seen-1", + notificationKey: "q-seen-1:1", + ownerNotificationKind: "human-required", + ownerNotificationLevel: "ALERT", + humanIntent: "decision", + destinationSession: "sam@external", + relayConnectorRef: "wa-main", + relayEnvelope: { + v: 1, + deliveryId: "dec-seen-repair-1", + recipient: { handle: "sam_wa" }, + intent: "decision", + outcome: "interrupt", + summary: "Check seen repair", + body: "Body", + replyRef: null, + } satisfies RelayOutboundEnvelope, + }, + }; + + // Attempt 1: HTTP POST + onAccepted succeed, but outboundSeen.mark throws -> must NOT mark delivered or ack + const first = await transport.deliver(decision); + expect(first.ok).toBe(false); + expect(bridge.callCount()).toBe(1); + expect(acceptedKeys.has("q-seen-1:q-seen-1:1")).toBe(true); + expect(outboundSeen.load().has("q-seen-1:1")).toBe(false); + expect(delivered.load().has("dec-seen-repair-1")).toBe(false); + expect(releasedKeys).toEqual([]); + + // Attempt 2: outboundSeen write repaired -> reconciles from durable receipt without a second HTTP POST + failOutboundSeenAppend = false; + const second = await transport.deliver(decision); + expect(second.ok).toBe(true); + expect(bridge.callCount(), "durable receipt reconciles metadata without repost").toBe(1); + expect(outboundSeen.load().has("q-seen-1:1")).toBe(true); + expect(delivered.load().has("dec-seen-repair-1")).toBe(true); + expect(releasedKeys).toEqual(["q-seen-1:1"]); + }); + + describe("resolvePrimaryRelayBinding exact address requirements", () => { + const reg = { + ok: true as const, + entities: [ + { + entityId: "alice", + class: "human" as const, + displayName: "Alice", + address: "alice@external", + connectorBindings: [ + { + kind: "relay" as const, + connectorRef: "relay-alice", + secretsRef: "file:/tmp/alice.env", + role: "primary" as const, + handle: "alice_relay", + }, + ], + prefs: { deliveryClass: "A" as const }, + }, + { + entityId: "bob", + class: "human" as const, + displayName: "Bob", + address: "bob@external", + connectorBindings: [ + { + kind: "relay" as const, + connectorRef: "relay-bob", + secretsRef: "file:/tmp/bob.env", + role: "primary" as const, + }, + ], + prefs: { deliveryClass: "A" as const }, + }, + ], + }; + + it("resolves valid stable registered person's address exactly", () => { + const resolved = resolvePrimaryRelayBinding("alice@external", reg); + expect(resolved).toEqual({ + entity: reg.entities[0], + connectorRef: "relay-alice", + handle: "alice_relay", + secretsRef: "file:/tmp/alice.env", + }); + }); + + it("allows exact bare entityId as fallback per existing caller contract", () => { + const resolved = resolvePrimaryRelayBinding("alice", reg); + expect(resolved).toEqual({ + entity: reg.entities[0], + connectorRef: "relay-alice", + handle: "alice_relay", + secretsRef: "file:/tmp/alice.env", + }); + }); + + it("rejects different domain / same-name agent / different rig destination", () => { + expect(resolvePrimaryRelayBinding("alice@different-rig", reg)).toBeNull(); + expect(resolvePrimaryRelayBinding("alice@some-rig", reg)).toBeNull(); + expect(resolvePrimaryRelayBinding("alice@externalx", reg)).toBeNull(); + }); + + it("rejects shared channel ref with #", () => { + expect(resolvePrimaryRelayBinding("alice#slack", reg)).toBeNull(); + expect(resolvePrimaryRelayBinding("alice#general", reg)).toBeNull(); + }); + + it("leaves binding without handle as outbound-only fallback entityId per current contract", () => { + const resolved = resolvePrimaryRelayBinding("bob@external", reg); + expect(resolved).toEqual({ + entity: reg.entities[1], + connectorRef: "relay-bob", + handle: "bob", + secretsRef: "file:/tmp/bob.env", + }); + }); + }); + + describe("R3 — single complete JSON envelope (> 64 KiB), full content preservation, whole-decision replay, and stopSignal handling", () => { + const reg = { + ok: true as const, + entities: [ + { + entityId: "sam", + class: "human" as const, + displayName: "Sam", + address: "sam@external", + connectorBindings: [ + { + kind: "relay" as const, + connectorRef: "wa-main", + secretsRef: "file:/tmp/wa.env", + role: "primary" as const, + handle: "sam_wa", + }, + ], + prefs: { deliveryClass: "A" as const, availability: "available" as const }, + }, + ], + }; + + it("freezes and POSTs a single complete > 64 KiB JSON envelope with full ASCII, multibyte CJK, emoji, summary overflow, humanDetail, and humanQuestions/options without multipart splitting or clipping", async () => { + const asciiBlock = "ASCII-LINE-0123456789-abcdefghijklmnopqrstuvwxyz\n".repeat(900) + "ASCII-TAIL-MARKER"; + const cjkBlock = "日本語と中国語と한국어の長文テスト検証データ".repeat(1400) + "CJK-TAIL-終端"; + const emojiBlock = "🚀👩‍💻🎉🔥🧪".repeat(1200) + "EMOJI-TAIL-🏁"; + const detailBlock = "DETAIL-SECTION-" + "D".repeat(12_000) + "-DETAIL-TAIL"; + + const prepared = prepareRelayDecision( + { + kind: "outbound_decision", + decisionId: "dec-long-cjk-1", + op: OUTBOUND_OP, + entityBindingRef: "sam@external", + payload: { + qitemId: "q-long-1", + notificationKey: "q-long-1:1", + ownerNotificationKind: "human-required", + ownerNotificationLevel: "ALERT", + humanIntent: "decision", + destinationSession: "sam@external", + state: "blocked", + summary: "S".repeat(1200) + "-SUMMARY-TAIL", + body: `${asciiBlock}\n${cjkBlock}\n${emojiBlock}`, + humanDetail: detailBlock, + humanQuestions: [ + { + id: "q-rollout", + header: "Deployment Gate", + question: "Which rollout strategy should we execute for the multi-region cluster?", + options: [ + { + id: "canary", + label: "Canary 5%", + description: "Roll out to 5% of edge nodes first and monitor p99 latency for 30m", + recommended: true, + }, + { + id: "blue-green", + label: "Blue/Green Cutover", + description: "Switch full traffic to green standby with instant DNS rollback", + }, + ], + }, + ], + } satisfies OutboundPostPayload, + }, + { + loadHumanRegistry: () => reg, + lookupQuestionState: () => ({ + qitemId: "q-long-1", + mode: "park", + episodeKey: "q-long-1:1", + decisionRevision: "dr1-long-1", + humanIntent: "decision", + }), + }, + ); + + const carry = prepared.payload as RelayPayloadCarry & Record; + expect(carry.relayEnvelopes).toBeUndefined(); + + const fullEnvelope = carry.relayEnvelope!; + expect(fullEnvelope).toBeDefined(); + expect(fullEnvelope.deliveryId).toBe("dec-long-cjk-1"); + expect((fullEnvelope as Record).part).toBeUndefined(); + expect(fullEnvelope.summary.length).toBeLessThanOrEqual(1000); + expect(fullEnvelope.summary).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? 64 KiB whole-decision envelope across bridge and transport restart with identical deliveryId and bytes without duplicate custody", async () => { + const fsx = memFs(); + const relayDir = mkdtempSync(join(tmpdir(), "openrig-relay-whole-")); + const relay = await startRecordingRelay({ + custodyFilePath: join(relayDir, "bridge-state.jsonl"), + outboundToken: "out-tok-valid-123456", + inboundToken: "in-tok-valid-654321", + }); + + try { + const attempted = new SeenStore("/state/relay-attempted-decisions.jsonl", fsx); + const delivered = new SeenStore("/state/relay-delivered-decisions.jsonl", fsx); + const outboundSeen = new SeenStore("/state/slack-outbound-seen.jsonl", fsx); + const receipts: RelayAcceptedReceiptInfo[] = []; + const releasedKeys: string[] = []; + + const cjkBody = + "第一段内容:多字节UTF-8编码边界与整包恢复测试。".repeat(950) + + "\nMIDDLE-MARKER-中間標記\n" + + "第二段内容:确保在整包丢失ACK后重启不会丢失尾部也不会重复接管。".repeat(950) + + "\nFINAL-TAIL-MARKER-最終尾部"; + + const prepared = prepareRelayDecision( + { + kind: "outbound_decision", + decisionId: "dec-whole-ack-lost-1", + op: OUTBOUND_OP, + entityBindingRef: "sam@external", + payload: { + qitemId: "q-whole-1", + notificationKey: "q-whole-1:1", + ownerNotificationKind: "human-required", + ownerNotificationLevel: "ALERT", + humanIntent: "decision", + destinationSession: "sam@external", + state: "blocked", + summary: "Whole-decision lost ACK > 64 KiB test", + body: cjkBody, + } satisfies OutboundPostPayload, + }, + { + loadHumanRegistry: () => reg, + lookupQuestionState: () => ({ + qitemId: "q-whole-1", + mode: "park", + episodeKey: "q-whole-1:1", + decisionRevision: "dr1-whole-1", + humanIntent: "decision", + }), + }, + ); + + const carry = prepared.payload as RelayPayloadCarry & Record; + expect(carry.relayEnvelopes).toBeUndefined(); + const frozenEnvelope = carry.relayEnvelope!; + expect(Buffer.byteLength(JSON.stringify(frozenEnvelope), "utf8")).toBeGreaterThan(65_536); + + // Fail HTTP ACK on the first whole-envelope POST AFTER the bridge persists custody + relay.setLoseNextAck(true); + + const transport1 = createRelayOutboundTransport({ + attempted, + delivered, + outboundSeen, + fetchImpl: fetch, + resolveProfile: (connectorRef) => ({ + ok: true, + connectorRef, + url: relay.outboundUrl, + outboundToken: "out-tok-valid-123456", + }), + onAccepted: (info) => receipts.push(info), + release: (key) => releasedKeys.push(key), + }); + + const wire1 = buildInProcessWire({ + home, + ops: [OUTBOUND_OP], + deliver: (d) => transport1.deliver(d), + }); + + wire1.dispatcher.dispatch(OUTBOUND_OP, "sam@external", prepared.payload, { + decisionId: "dec-whole-ack-lost-1", + }); + for (let i = 0; i < 40 && relay.getSanitizedStats().lostAckCount === 0; i++) { + await tick(); + } + await tick(); + + // Single POST committed custody on bridge then returned 502 (lost ACK) + expect(relay.getSanitizedStats().relayPostCount).toBe(1); + expect(relay.getSanitizedStats().custodyCount).toBe(1); + expect(relay.getSanitizedStats().lostAckCount).toBe(1); + expect(receipts, "logical custody receipt must NOT fire when HTTP ACK was lost").toHaveLength(0); + expect(outboundSeen.load().has("q-whole-1:1")).toBe(false); + expect(delivered.load().has("dec-whole-ack-lost-1")).toBe(false); + expect(releasedKeys).toEqual([]); + expect(new DispatchBuffer(home).pending()).toHaveLength(1); + + // Pending replyRef lookup correlates parent decisionId while buffered + const parentReplyRef = frozenEnvelope.replyRef!; + const pendingRef = findPendingRelayReplyRef(home, parentReplyRef); + expect(pendingRef.found).toBe(true); + expect(pendingRef.decisionId).toBe("dec-whole-ack-lost-1"); + + // Restart the recording relay server (reloads persisted custody from disk) and create fresh transport/wire + await relay.restart(); + + const transport2 = createRelayOutboundTransport({ + attempted, + delivered, + outboundSeen, + fetchImpl: fetch, + resolveProfile: (connectorRef) => ({ + ok: true, + connectorRef, + url: relay.outboundUrl, + outboundToken: "out-tok-valid-123456", + }), + onAccepted: (info) => receipts.push(info), + release: (key) => releasedKeys.push(key), + }); + + const wire2 = buildInProcessWire({ + home, + ops: [OUTBOUND_OP], + deliver: (d) => transport2.deliver(d), + }); + wire2.startServices?.(); + for (let i = 0; i < 40 && receipts.length === 0; i++) { + await tick(); + } + await tick(); + + // Replay sent the exact same whole deliveryId and bytes, deduplicated cleanly by bridge + expect(relay.getSanitizedStats().custodyCount).toBe(1); + expect(relay.getSanitizedStats().deduplicatedCount).toBe(1); + expect(receipts).toHaveLength(1); + expect(receipts[0]?.deliveryId).toBe("dec-whole-ack-lost-1"); + expect(outboundSeen.load().has("q-whole-1:1")).toBe(true); + expect(delivered.load().has("dec-whole-ack-lost-1")).toBe(true); + expect(releasedKeys).toEqual(["q-whole-1:1"]); + expect(new DispatchBuffer(home).pending()).toHaveLength(0); + + const committedRecords = relay.getCustodyRecords(); + expect(committedRecords).toHaveLength(1); + const committed = committedRecords[0]!.envelope; + expect(committed.deliveryId).toBe("dec-whole-ack-lost-1"); + expect((committed as Record).part).toBeUndefined(); + expect(committed.body).toBe(cjkBody); + expect(committed.body).toContain("MIDDLE-MARKER-中間標記"); + expect(committed.body).toContain("FINAL-TAIL-MARKER-最終尾部"); + } finally { + await relay.stop(); + rmSync(relayDir, { recursive: true, force: true }); + } + }); + + it("delivers an oversized > 64 KiB multi-item digest in a single POST with full item content and records all member receipts on acceptance", async () => { + const fsx = memFs(); + const attempted = new SeenStore("/state/relay-attempted-decisions.jsonl", fsx); + const delivered = new SeenStore("/state/relay-delivered-decisions.jsonl", fsx); + const outboundSeen = new SeenStore("/state/slack-outbound-seen.jsonl", fsx); + const bridge = recordingBridgeFixture(); + const receipts: RelayAcceptedReceiptInfo[] = []; + + const item1Body = "DIGEST-ITEM-1-" + "甲乙丙丁戊己庚辛壬癸".repeat(2500) + "-ITEM-1-TAIL"; + const item2Body = "DIGEST-ITEM-2-" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ".repeat(1600) + "-ITEM-2-TAIL"; + + const prepared = prepareRelayDecision( + { + kind: "outbound_decision", + decisionId: "dec-digest-long-1", + op: OUTBOUND_OP, + entityBindingRef: "sam@external", + payload: { + deliveryDigestPost: true, + digestId: "long-1", + qitemId: "q-dig-1", + notificationKey: "digest:sam@external:long", + ownerNotificationKind: "human-required", + ownerNotificationLevel: "INFO", + humanIntent: "update", + destinationSession: "sam@external", + summary: "Digest summary header", + body: `${item1Body}\n\n${item2Body}`, + memberReceipts: [ + { + qitemId: "q-dig-1", + notificationKey: "q-dig-1:1", + level: "ALERT", + kind: "human-required", + summary: "Item 1 summary", + body: item1Body, + }, + { + qitemId: "q-dig-2", + notificationKey: "q-dig-2:1", + level: "ALERT", + kind: "human-required", + summary: "Item 2 summary", + body: item2Body, + }, + ], + } as OutboundPostPayload, + }, + { + loadHumanRegistry: () => reg, + lookupQuestionState: (qitemId) => ({ + qitemId, + mode: "ask", + episodeKey: `${qitemId}:1`, + decisionRevision: `dr1-${qitemId}`, + humanIntent: "decision", + summary: qitemId === "q-dig-1" ? "Item 1 summary" : "Item 2 summary", + body: qitemId === "q-dig-1" ? item1Body : item2Body, + }), + }, + ); + + const carry = prepared.payload as RelayPayloadCarry & Record; + expect(carry.relayEnvelopes).toBeUndefined(); + const digestEnvelope = carry.relayEnvelope!; + expect((digestEnvelope as Record).part).toBeUndefined(); + expect(Buffer.byteLength(JSON.stringify(digestEnvelope), "utf8")).toBeGreaterThan(65_536); + + const transport = createRelayOutboundTransport({ + attempted, + delivered, + outboundSeen, + fetchImpl: bridge.fetchImpl, + resolveProfile: (connectorRef) => ({ + ok: true, + connectorRef, + url: "http://127.0.0.1:43110/outbound", + outboundToken: "out-tok-valid-123456", + }), + onAccepted: (info) => receipts.push(info), + }); + + const outcome = await transport.deliver({ + kind: "outbound_decision", + decisionId: "dec-digest-long-1", + op: OUTBOUND_OP, + entityBindingRef: "sam@external", + payload: prepared.payload, + }); + + expect(outcome.ok).toBe(true); + expect(bridge.callCount()).toBe(1); + expect(bridge.custodyByDeliveryId.size).toBe(1); + expect(receipts.map((r) => `${r.qitemId}:${r.notificationKey}`)).toEqual([ + "q-dig-1:q-dig-1:1", + "q-dig-2:q-dig-2:1", + ]); + + const deliveredEnvelope = bridge.custodyByDeliveryId.get("dec-digest-long-1")!; + expect(deliveredEnvelope).toBeDefined(); + expect((deliveredEnvelope as Record).part).toBeUndefined(); + expect(deliveredEnvelope.body).toContain("ITEM-1-TAIL"); + expect(deliveredEnvelope.body).toContain("ITEM-2-TAIL"); + expect(deliveredEnvelope.items).toHaveLength(2); + expect(deliveredEnvelope.items?.[0]?.body).toContain("ITEM-1-TAIL"); + expect(deliveredEnvelope.items?.[1]?.body).toContain("ITEM-2-TAIL"); + const ref1 = decodeRelayReplyRef(deliveredEnvelope.items?.[0]?.replyRef ?? ""); + const ref2 = decodeRelayReplyRef(deliveredEnvelope.items?.[1]?.replyRef ?? ""); + expect(ref1.ok && ref1.ref.digest === true && ref1.ref.mode === "ask").toBe(true); + expect(ref2.ok && ref2.ref.digest === true && ref2.ref.mode === "ask").toBe(true); + expect(receipts[0]?.replyRef).toBe(deliveredEnvelope.items?.[0]?.replyRef); + expect(receipts[1]?.replyRef).toBe(deliveredEnvelope.items?.[1]?.replyRef); + }); + + it("distinguishes stopSignal abort from genuine timeout on a whole > 64 KiB envelope: in-flight stop suppresses failure ledger, records no false acceptance, retains pending decision, replays identical ID/bytes on restart, while genuine timeout records failure", async () => { + const fsx = memFs(); + const attempted = new SeenStore("/state/relay-attempted-decisions.jsonl", fsx); + const delivered = new SeenStore("/state/relay-delivered-decisions.jsonl", fsx); + const outboundSeen = new SeenStore("/state/slack-outbound-seen.jsonl", fsx); + const bridge = recordingBridgeFixture(); + const receipts: RelayAcceptedReceiptInfo[] = []; + const failures: Array<{ failureClass: string; detail: string }> = []; + + const longBody = "STOP-RESUME-BLOCK-".repeat(4500) + "\nSTOP-RESUME-TAIL"; + const prepared = prepareRelayDecision( + { + kind: "outbound_decision", + decisionId: "dec-stop-whole-1", + op: OUTBOUND_OP, + entityBindingRef: "sam@external", + payload: { + qitemId: "q-stop-1", + notificationKey: "q-stop-1:1", + ownerNotificationKind: "human-required", + ownerNotificationLevel: "ALERT", + humanIntent: "decision", + destinationSession: "sam@external", + state: "blocked", + summary: "Stop during in-flight whole-envelope send", + body: longBody, + } satisfies OutboundPostPayload, + }, + { + loadHumanRegistry: () => reg, + lookupQuestionState: () => ({ + qitemId: "q-stop-1", + mode: "park", + episodeKey: "q-stop-1:1", + decisionRevision: "dr1-stop-1", + humanIntent: "decision", + }), + }, + ); + + const carry = prepared.payload as RelayPayloadCarry & Record; + expect(carry.relayEnvelopes).toBeUndefined(); + const frozenEnvelope = carry.relayEnvelope!; + const frozenBodyJson = JSON.stringify(frozenEnvelope); + expect(Buffer.byteLength(frozenBodyJson, "utf8")).toBeGreaterThan(65_536); + + const stopCtl1 = new AbortController(); + let postCalls = 0; + let firstAttemptRawBody = ""; + const stopInFlightFetch = async (_url: string, init?: RequestInit): Promise => { + postCalls += 1; + firstAttemptRawBody = typeof init?.body === "string" ? init.body : ""; + return await new Promise((_resolve, reject) => { + const sig = init?.signal; + const onAbort = () => { + reject(sig?.reason ?? new DOMException("The operation was aborted", "AbortError")); + }; + if (sig?.aborted) { + onAbort(); + return; + } + sig?.addEventListener("abort", onAbort, { once: true }); + // Simulate subsystem stop while the single whole-envelope HTTP POST is in flight + queueMicrotask(() => stopCtl1.abort(new Error("subsystem stop requested"))); + }); + }; + + const transport1 = createRelayOutboundTransport({ + attempted, + delivered, + outboundSeen, + fetchImpl: stopInFlightFetch, + stopSignal: stopCtl1.signal, + resolveProfile: (connectorRef) => ({ + ok: true, + connectorRef, + url: "http://127.0.0.1:43110/outbound", + outboundToken: "out-tok-valid-123456", + }), + onAccepted: (info) => receipts.push(info), + onTransportFailed: (_p, failureClass, detail) => failures.push({ failureClass, detail }), + }); + + const wire1 = buildInProcessWire({ + home, + ops: [OUTBOUND_OP], + deliver: (d) => transport1.deliver(d), + }); + + wire1.dispatcher.dispatch(OUTBOUND_OP, "sam@external", prepared.payload, { + decisionId: "dec-stop-whole-1", + }); + await tick(); + + // Whole POST was aborted in flight by stopSignal -> NO failure ledger entry, NO acceptance, decision retained + expect(postCalls).toBe(1); + expect(firstAttemptRawBody).toBe(frozenBodyJson); + expect(bridge.custodyByDeliveryId.size).toBe(0); + expect(attempted.load().has("dec-stop-whole-1")).toBe(true); + expect(delivered.load().has("dec-stop-whole-1")).toBe(false); + expect(failures, "stopSignal abort must not record transport failure").toEqual([]); + expect(receipts, "stopSignal abort must not record false acceptance").toEqual([]); + expect(new DispatchBuffer(home).pending()).toHaveLength(1); + + // Pre-aborted stopSignal also returns stopped without calling fetch or recording failure + const callsBeforePreAborted = postCalls; + const preAbortedOutcome = await transport1.deliver({ + kind: "outbound_decision", + decisionId: "dec-stop-whole-1", + op: OUTBOUND_OP, + entityBindingRef: "sam@external", + payload: prepared.payload, + }); + expect(preAbortedOutcome).toEqual({ + ok: false, + class: "stopped", + detail: "relay transport stopped", + }); + expect(postCalls).toBe(callsBeforePreAborted); + expect(failures).toEqual([]); + + // Restart with a fresh transport/wire: replays the exact same whole deliveryId and bytes in 1 POST + const stopCtl2 = new AbortController(); + const transport2 = createRelayOutboundTransport({ + attempted, + delivered, + outboundSeen, + fetchImpl: bridge.fetchImpl, + stopSignal: stopCtl2.signal, + resolveProfile: (connectorRef) => ({ + ok: true, + connectorRef, + url: "http://127.0.0.1:43110/outbound", + outboundToken: "out-tok-valid-123456", + }), + onAccepted: (info) => receipts.push(info), + onTransportFailed: (_p, failureClass, detail) => failures.push({ failureClass, detail }), + }); + + const wire2 = buildInProcessWire({ + home, + ops: [OUTBOUND_OP], + deliver: (d) => transport2.deliver(d), + }); + wire2.startServices?.(); + await tick(); + + expect(bridge.callCount()).toBe(1); + expect(bridge.custodyByDeliveryId.size).toBe(1); + expect(bridge.attempts[0]!.envelope.deliveryId).toBe("dec-stop-whole-1"); + expect(bridge.attempts[0]!.envelope).toEqual(frozenEnvelope); + expect(JSON.stringify(bridge.attempts[0]!.envelope)).toBe(firstAttemptRawBody); + expect(receipts).toHaveLength(1); + expect(failures).toEqual([]); + expect(delivered.load().has("dec-stop-whole-1")).toBe(true); + expect(new DispatchBuffer(home).pending()).toHaveLength(0); + + // Genuine timeout (when stopSignal is NOT aborted) still reports class "timeout" and invokes onTransportFailed + const hangingFetch = async (_url: string, init?: RequestInit): Promise => + await new Promise((_resolve, reject) => { + const sig = init?.signal; + sig?.addEventListener( + "abort", + () => reject(sig.reason ?? new DOMException("The operation was aborted", "AbortError")), + { once: true }, + ); + }); + + const timeoutTransport = createRelayOutboundTransport({ + attempted, + delivered, + outboundSeen, + fetchImpl: hangingFetch, + timeoutMs: 20, + stopSignal: stopCtl2.signal, + resolveProfile: (connectorRef) => ({ + ok: true, + connectorRef, + url: "http://127.0.0.1:43110/outbound", + outboundToken: "out-tok-valid-123456", + }), + onAccepted: (info) => receipts.push(info), + onTransportFailed: (_p, failureClass, detail) => failures.push({ failureClass, detail }), + }); + + const timeoutOutcome = await timeoutTransport.deliver({ + kind: "outbound_decision", + decisionId: "dec-genuine-timeout-1", + op: OUTBOUND_OP, + entityBindingRef: "sam@external", + payload: { + qitemId: "q-timeout-1", + notificationKey: "q-timeout-1:1", + ownerNotificationKind: "human-required", + ownerNotificationLevel: "ALERT", + humanIntent: "decision", + destinationSession: "sam@external", + relayConnectorRef: "wa-main", + relayEnvelope: { + v: 1, + deliveryId: "dec-genuine-timeout-1", + recipient: { handle: "sam_wa" }, + intent: "decision", + outcome: "interrupt", + summary: "Timeout test", + body: "Body", + replyRef: null, + }, + }, + }); + + expect(timeoutOutcome).toEqual({ + ok: false, + class: "timeout", + detail: "relay request timed out after 20ms", + }); + expect(failures).toEqual([ + { + failureClass: "timeout", + detail: "relay request timed out after 20ms", + }, + ]); + }); + }); +}); diff --git a/packages/daemon/test/slack-post-open-asks.test.ts b/packages/daemon/test/slack-post-open-asks.test.ts index 919b6ba31..3609b6851 100644 --- a/packages/daemon/test/slack-post-open-asks.test.ts +++ b/packages/daemon/test/slack-post-open-asks.test.ts @@ -19,6 +19,7 @@ import { OUTBOUND_OP, toPayload } from "../src/domain/gateway/slack/outbound-dri import { DispatchBuffer, dispatchBufferPath } from "../src/domain/gateway/dispatch-buffer.js"; import { SeenStore } from "../src/domain/gateway/slack/state-store.js"; import { resolveSlackHandle, addHumanFragment } from "../src/domain/gateway/human-registry.js"; +import { setSharedChannel } from "../src/domain/gateway/participants.js"; import { WatchdogJobsRepository } from "../src/domain/watchdog-jobs-repository.js"; import { HumanQuietStore } from "../src/domain/gateway/human-quiet-windows.js"; import { QUIET_RELEASE_POLICY, runQuietRelease } from "../src/domain/policies/quiet-release.js"; @@ -331,6 +332,146 @@ describe("rig slack post-open-asks through the gateway", () => { expect(notes(superseded)).toContain(`slack-re-present-disposition notification_key=${items.get(superseded.qitemId)!.notificationKey} reason=superseded`); expect(notes(closed)).toContain(`slack-re-present-disposition notification_key=${items.get(closed.qitemId)!.notificationKey} reason=row-not-active`); }); + + it("ignores Relay-primary asks even if seen state marks them seeded, while re-presenting Slack-primary and shared-channel asks", async () => { + expect( + addHumanFragment( + { + entityId: HUMAN, + class: "human", + displayName: "Alex", + address: `${HUMAN}@external`, + connectorBindings: [ + { + kind: "slack", + connectorRef: "primary", + secretsRef: "env:SLACK_BOT_TOKEN", + role: "primary", + handle: "UALEX", + }, + ], + prefs: { deliveryClass: "B", availability: "available" }, + }, + home, + ).ok, + ).toBe(true); + expect( + addHumanFragment( + { + entityId: "human-sam", + class: "human", + displayName: "Sam", + address: "human-sam@external", + connectorBindings: [ + { + kind: "relay", + connectorRef: "wa-main", + secretsRef: `file:${join(home, "relay.env")}`, + role: "primary", + handle: "sam_wa", + }, + ], + prefs: { deliveryClass: "B", availability: "available" }, + }, + home, + ).ok, + ).toBe(true); + expect( + setSharedChannel( + "ops-room", + { + displayName: "Ops Room", + channel: { + connector: "slack", + connectorRef: "primary", + secretsRef: "env:SLACK_BOT_TOKEN", + channelId: "C-SHARED", + }, + }, + {}, + home, + ).ok, + ).toBe(true); + + registry = { + ok: true as const, + entities: [ + { + entityId: HUMAN, + class: "human" as const, + displayName: "Alex", + address: `${HUMAN}@external`, + connectorBindings: [ + { + kind: "slack" as const, + connectorRef: "primary", + secretsRef: "env:SLACK_BOT_TOKEN", + role: "primary" as const, + handle: "UALEX", + }, + ], + prefs: { deliveryClass: "B", availability: "available" }, + }, + { + entityId: "human-sam", + class: "human" as const, + displayName: "Sam", + address: "human-sam@external", + connectorBindings: [ + { + kind: "relay" as const, + connectorRef: "wa-main", + secretsRef: `file:${join(home, "relay.env")}`, + role: "primary" as const, + handle: "sam_wa", + }, + ], + prefs: { deliveryClass: "B", availability: "available" }, + }, + ], + }; + + const slackAsk = await park(); + const channelAsk = await repo.create({ + sourceSession: "dev-qa@r", + destinationSession: "ops-room@external", + summary: "Shared channel ask", + body: "shared channel body", + evidenceRef: "/proof/shared.md", + nudge: false, + }); + db.prepare( + `INSERT INTO queue_transitions(qitem_id,ts,state,actor_session,owner_notification_level,owner_notification_kind) + VALUES (?, '2026-01-01', 'pending', 'dev-qa@r', 'ALERT', 'human-required')`, + ).run(channelAsk.qitemId); + const relayAsk = await repo.create({ + sourceSession: "dev-qa@r", + destinationSession: "orch-lead@r", + body: "relay decision body", + nudge: false, + }); + repo.update({ + qitemId: relayAsk.qitemId, + actorSession: "orch-lead@r", + state: "blocked", + blockedOn: "human-sam@kernel", + summary: "Relay choice", + evidenceRef: "/proof/relay.md", + transitionNote: "parked on relay human", + }); + + // Even if all three episodes (including the Relay ask) are marked seeded-at-enable in seen store + new SeenStore(seenPath()).seed([keyOf(slackAsk), keyOf(channelAsk), keyOf(relayAsk)], "seeded-at-enable"); + + const w = wire(); + const result = await run(w); + expect(result.asks.map((a) => a.qitemId).sort()).toEqual([channelAsk.qitemId, slackAsk.qitemId].sort()); + expect(resultFor(result, relayAsk)).toBeUndefined(); + expect(resultFor(result, slackAsk)).toMatchObject({ outcome: "posted", reshown: true, recipient: `${HUMAN}@external` }); + expect(resultFor(result, channelAsk)).toMatchObject({ outcome: "posted", reshown: true, recipient: "ops-room@external" }); + expect(posts).toHaveLength(2); + expect(notes(relayAsk).some((n) => n.includes("slack-re-present-"))).toBe(false); + }); }); describe("POST /slack/post-open-asks", () => { diff --git a/packages/daemon/test/slack-subsystem-cutover.test.ts b/packages/daemon/test/slack-subsystem-cutover.test.ts index cde3e330e..0012b5fcd 100644 --- a/packages/daemon/test/slack-subsystem-cutover.test.ts +++ b/packages/daemon/test/slack-subsystem-cutover.test.ts @@ -352,3 +352,413 @@ describe("P28 — ignore-path telemetry discriminates the rejection branch (unch expect(out).not.toContain("secret body text"); }); }); + +describe("Capability 3 — single-wire relay port isolation and attempted-replay bypass", () => { + let home: string; + beforeEach(() => { home = mkdtempSync(join(tmpdir(), "s10-relay-cut-")); }); + afterEach(() => { rmSync(home, { recursive: true, force: true }); }); + + it("never falls through to Slack when a human's primary binding is relay and the relay profile is unready, and bypasses re-decision on attempted replay", async () => { + const { writeFileSync } = await import("node:fs"); + const { createDb } = await import("../src/db/connection.js"); + const { migrate } = await import("../src/db/migrate.js"); + const { ALL_MIGRATIONS } = await import("../src/db/all-migrations.js"); + const { EventBus } = await import("../src/domain/event-bus.js"); + const { QueueRepository } = await import("../src/domain/queue-repository.js"); + const { DEFAULT_CONFIG, saveConfig } = await import("../src/domain/gateway/slack/config.js"); + const { buildSlackGatewayWire } = await import("../src/domain/gateway/slack/slack-subsystem.js"); + const { resolveSlackHandle } = await import("../src/domain/gateway/human-registry.js"); + const { DispatchBuffer } = await import("../src/domain/gateway/dispatch-buffer.js"); + const { HumanQuietStore } = await import("../src/domain/gateway/human-quiet-windows.js"); + + const db = createDb(); + migrate(db, ALL_MIGRATIONS); + + // Configure Slack as READY so a fallthrough bug would hit Slack chat.postMessage + const slackSecrets = join(home, "slack.env"); + writeFileSync(slackSecrets, "SLACK_BOT_TOKEN=xoxb-EXAMPLE-fake\n", { mode: 0o600 }); + saveConfig({ ...DEFAULT_CONFIG, enabled: true, channel: "C-FALLTHROUGH", secretsEnvFile: slackSecrets }, home); + + const relaySecrets = join(home, "relay.env"); + // Start with missing relay file -> unready relay profile + const registry = { + ok: true as const, + entities: [ + { + entityId: "sam", + class: "human" as const, + displayName: "Sam", + address: "sam@external", + connectorBindings: [ + { + kind: "relay" as const, + connectorRef: "wa-main", + secretsRef: `file:${relaySecrets}`, + role: "primary" as const, + handle: "sam_wa", + }, + { + kind: "slack" as const, + connectorRef: "slack-secondary", + secretsRef: `file:${slackSecrets}`, + role: "secondary" as const, + handle: "USAM", + }, + ], + prefs: { deliveryClass: "A" as const, availability: "available" as const }, + }, + ], + }; + + const repo = new QueueRepository(db, new EventBus(db), { + validateRig: () => true, + loadHumanRegistry: () => registry, + }); + const ask = await repo.create({ + sourceSession: "lead@rig", + destinationSession: "orch@rig", + body: "Must never fall through to Slack", + nudge: false, + }); + repo.update({ + qitemId: ask.qitemId, + actorSession: "orch@rig", + state: "blocked", + blockedOn: "sam@kernel", + summary: "Relay-bound question", + evidenceRef: "/proof/relay-ask.md", + transitionNote: "parked", + }); + const askEpisodeKey = `${ask.qitemId}:${repo.transitionLog.latestOwnerNotificationForQitem(ask.qitemId)!.transitionId}`; + // Seed an earlier Slack-named transport-failed note for the same episode (as baseline 5d188a43b wrote before the fix); + // a subsequent Relay failure for this episode must still record a relay-owner-notification-transport-failed note. + repo.update({ + qitemId: ask.qitemId, + actorSession: "daemon@kernel", + transitionNote: `slack-owner-notification-transport-failed notification_key=${askEpisodeKey} class=relay-profile-unready error=legacy-slack-named-failure`, + }); + + const slackCalls: string[] = []; + const relayCalls: Array> = []; + let failFirstRelayHttp = true; + + const makeWire = () => + buildSlackGatewayWire({ + home, + queueRepo: repo, + registry: { loadHumanRegistry: () => registry, resolveSlackHandle }, + outboundIntervalMs: 15, + fetchImpl: async (url, init) => { + if (String(url).includes("slack.com")) { + slackCalls.push(String(url)); + return new Response(JSON.stringify({ ok: true, ts: "1724.0001" }), { status: 200 }); + } + relayCalls.push(JSON.parse(String(init?.body ?? "{}")) as Record); + if (failFirstRelayHttp) { + failFirstRelayHttp = false; + throw new Error("simulated connection drop after attempt"); + } + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }, + }); + + const w1 = makeWire(); + try { + w1.startServices?.(); + await new Promise((r) => setTimeout(r, 50)); + // Unready relay profile is retained in DispatchBuffer and NEVER falls through to Slack + expect(slackCalls).toHaveLength(0); + expect(relayCalls).toHaveLength(0); + expect(new DispatchBuffer(home).pending()).toHaveLength(1); + const w1Notes = repo.listTransitions(ask.qitemId).map((t) => t.transitionNote ?? ""); + const relayFailureNotes = w1Notes.filter((n) => n.startsWith("relay-owner-notification-transport-failed ")); + expect(relayFailureNotes).toHaveLength(1); + expect(relayFailureNotes[0]).toContain("connector_ref=wa-main"); + expect(relayFailureNotes[0]).toContain("class=relay-profile-unready"); + expect(repo.getById(ask.qitemId)).toMatchObject({ + deliveryOutcome: "transport-failed", + deliveryFailureDetail: expect.stringContaining("relay-owner-notification-transport-failed"), + }); + expect(repo.findUndelivered().map((q) => q.qitemId)).toContain(ask.qitemId); + } finally { + w1.stop(); + } + + // If the relay connectorRef changes while still failing, it must record a new note naming the new connector rather than suppressing + registry.entities[0]!.connectorBindings[0]!.connectorRef = "wa-failover"; + const originalPending = new DispatchBuffer(home).pending()[0]!; + const { + relayConnectorRef: _oldRef, + relayEnvelope: _oldEnv, + ...unpreparedPayload + } = (originalPending.payload ?? {}) as Record; + const w1b = makeWire(); + try { + w1b.dispatcher.dispatch("post_message", "sam@kernel", unpreparedPayload, { + decisionId: "failover-probe", + }); + await new Promise((r) => setTimeout(r, 30)); + const w1bRelayFailureNotes = repo + .listTransitions(ask.qitemId) + .map((t) => t.transitionNote ?? "") + .filter((n) => n.startsWith("relay-owner-notification-transport-failed ")); + expect(w1bRelayFailureNotes).toHaveLength(2); + expect(w1bRelayFailureNotes[1]).toContain("connector_ref=wa-failover"); + expect(repo.getById(ask.qitemId)?.deliveryFailureDetail).toContain("connector_ref=wa-failover"); + } finally { + new DispatchBuffer(home).ack("failover-probe"); + w1b.stop(); + registry.entities[0]!.connectorBindings[0]!.connectorRef = "wa-main"; + } + + // Write valid relay secrets and replay: first HTTP attempt drops after recording attempt metadata + writeFileSync( + relaySecrets, + [ + "OPENRIG_RELAY_URL=http://127.0.0.1:44101/outbound", + "OPENRIG_RELAY_OUTBOUND_TOKEN=out-secret-token-123456", + "OPENRIG_RELAY_INBOUND_TOKEN=in-secret-token-654321", + ].join("\n"), + { mode: 0o600 }, + ); + + const w2 = makeWire(); + try { + w2.startServices?.(); + await new Promise((r) => setTimeout(r, 50)); + expect(slackCalls).toHaveLength(0); + expect(relayCalls).toHaveLength(1); + expect(new DispatchBuffer(home).pending()).toHaveLength(1); + } finally { + w2.stop(); + } + + // Turn ON quiet hours for sam BEFORE the next replay: because the decision was already attempted, + // replay must bypass policy re-decision (must NOT hold for quiet hours) and deliver the exact frozen envelope + const now = new Date(); + new HumanQuietStore(db).setUntil("sam", { + until: new Date(now.getTime() + 3_600_000), + timezone: "UTC", + setBy: "test", + now, + }); + + const w3 = makeWire(); + try { + w3.startServices?.(); + await new Promise((r) => setTimeout(r, 50)); + expect(slackCalls).toHaveLength(0); + expect(relayCalls).toHaveLength(2); + expect(relayCalls[1]).toEqual(relayCalls[0]); + expect(new DispatchBuffer(home).pending()).toHaveLength(0); + const episode = repo.currentHumanRequiredEpisode(ask.qitemId)!; + expect(repo.hasRelayOwnerNotificationReceipt(ask.qitemId, episode.key)).toBe(true); + } finally { + w3.stop(); + db.close(); + } + }); + + it("polls and delivers Relay-primary alerts when Slack is disabled without minting new Slack buffer decisions, then enables Slack without replaying unbuffered Slack backlog while preserving earlier durable Slack buffer entries", async () => { + const { writeFileSync } = await import("node:fs"); + const { createDb } = await import("../src/db/connection.js"); + const { migrate } = await import("../src/db/migrate.js"); + const { ALL_MIGRATIONS } = await import("../src/db/all-migrations.js"); + const { EventBus } = await import("../src/domain/event-bus.js"); + const { QueueRepository } = await import("../src/domain/queue-repository.js"); + const { DEFAULT_CONFIG, saveConfig } = await import("../src/domain/gateway/slack/config.js"); + const { buildSlackGatewayWire } = await import("../src/domain/gateway/slack/slack-subsystem.js"); + const { resolveSlackHandle } = await import("../src/domain/gateway/human-registry.js"); + const { DispatchBuffer } = await import("../src/domain/gateway/dispatch-buffer.js"); + const { makeQueuePorts, seedBacklogAsHistory } = await import("../src/domain/gateway/slack/queue-access.js"); + const { toPayload } = await import("../src/domain/gateway/slack/outbound-driver.js"); + + const db = createDb(); + migrate(db, ALL_MIGRATIONS); + + const slackSecrets = join(home, "slack.env"); + writeFileSync(slackSecrets, "SLACK_BOT_TOKEN=xoxb-EXAMPLE-fake\n", { mode: 0o600 }); + // Slack starts DISABLED + saveConfig({ ...DEFAULT_CONFIG, enabled: false, channel: "C-MIXED", secretsEnvFile: slackSecrets }, home); + + const relaySecrets = join(home, "relay.env"); + writeFileSync( + relaySecrets, + [ + "OPENRIG_RELAY_URL=http://127.0.0.1:44102/outbound", + "OPENRIG_RELAY_OUTBOUND_TOKEN=out-secret-token-123456", + "OPENRIG_RELAY_INBOUND_TOKEN=in-secret-token-654321", + ].join("\n"), + { mode: 0o600 }, + ); + + const registry = { + ok: true as const, + entities: [ + { + entityId: "alex", + class: "human" as const, + displayName: "Alex", + address: "alex@external", + connectorBindings: [ + { + kind: "slack" as const, + connectorRef: "slack-main", + secretsRef: `file:${slackSecrets}`, + role: "primary" as const, + handle: "UALEX", + }, + ], + prefs: { deliveryClass: "B" as const, availability: "available" as const }, + }, + { + entityId: "sam", + class: "human" as const, + displayName: "Sam", + address: "sam@external", + connectorBindings: [ + { + kind: "relay" as const, + connectorRef: "wa-main", + secretsRef: `file:${relaySecrets}`, + role: "primary" as const, + handle: "sam_wa", + }, + ], + prefs: { deliveryClass: "B" as const, availability: "available" as const }, + }, + ], + }; + + const repo = new QueueRepository(db, new EventBus(db), { + validateRig: () => true, + loadHumanRegistry: () => registry, + }); + + // 1. Earlier durable Slack ask already in DispatchBuffer before Slack was disabled + const earlierSlackRow = await repo.create({ + sourceSession: "lead@rig", + destinationSession: "orch@rig", + body: "Earlier durable Slack ask body", + nudge: false, + }); + repo.update({ + qitemId: earlierSlackRow.qitemId, + actorSession: "orch@rig", + state: "blocked", + blockedOn: "alex@kernel", + summary: "Earlier durable Slack ask", + evidenceRef: "/proof/earlier-slack.md", + transitionNote: "parked earlier", + }); + const [earlierAlert] = await makeQueuePorts(repo, { loadHumanRegistry: () => registry }).listHumanAlerts({ + minimumLevel: "NOTICE", + }); + new DispatchBuffer(home).enqueue({ + kind: "outbound_decision", + decisionId: "earlier-durable-slack-1", + op: OUTBOUND_OP, + entityBindingRef: "alex@external", + payload: toPayload(earlierAlert!), + }); + + // 2. Fresh Slack-primary ask and fresh Relay-primary ask created while Slack is disabled + const freshSlackRow = await repo.create({ + sourceSession: "lead@rig", + destinationSession: "orch@rig", + body: "Fresh Slack backlog while disabled", + nudge: false, + }); + repo.update({ + qitemId: freshSlackRow.qitemId, + actorSession: "orch@rig", + state: "blocked", + blockedOn: "alex@kernel", + summary: "Slack backlog ask", + evidenceRef: "/proof/fresh-slack.md", + transitionNote: "parked while slack disabled", + }); + + const relayRow = await repo.create({ + sourceSession: "lead@rig", + destinationSession: "orch@rig", + body: "Fresh Relay ask while Slack disabled", + nudge: false, + }); + repo.update({ + qitemId: relayRow.qitemId, + actorSession: "orch@rig", + state: "blocked", + blockedOn: "sam@kernel", + summary: "Relay active ask", + evidenceRef: "/proof/relay-active.md", + transitionNote: "parked for relay", + }); + + const slackPosts: Array> = []; + const relayPosts: Array> = []; + + const makeWire = () => + buildSlackGatewayWire({ + home, + queueRepo: repo, + registry: { loadHumanRegistry: () => registry, resolveSlackHandle }, + outboundIntervalMs: 15, + fetchImpl: async (url, init) => { + if (String(url).includes("slack.com")) { + slackPosts.push(JSON.parse(String(init?.body ?? "{}")) as Record); + return new Response(JSON.stringify({ ok: true, ts: "1724.0002" }), { status: 200 }); + } + relayPosts.push(JSON.parse(String(init?.body ?? "{}")) as Record); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }, + }); + + const w1 = makeWire(); + try { + w1.startServices?.(); + await new Promise((r) => setTimeout(r, 60)); + // Relay ask delivered; Slack did not post + expect(relayPosts).toHaveLength(1); + expect(relayPosts[0]).toMatchObject({ + recipient: { handle: "sam_wa" }, + summary: "Relay active ask", + }); + expect(slackPosts).toHaveLength(0); + // Fresh Slack ask was NOT dispatched into the buffer while Slack was disabled; + // only the pre-existing durable Slack decision remains in the buffer. + expect(new DispatchBuffer(home).pending().map((d) => d.decisionId)).toEqual(["earlier-durable-slack-1"]); + } finally { + w1.stop(); + } + + // Now enable Slack: seed Slack-primary backlog as history, flip enabled=true, and restart wire + const slackOnlyRegistry = { + ...registry, + entities: registry.entities.filter((e) => + e.connectorBindings.some((b) => b.role === "primary" && b.kind === "slack"), + ), + }; + await seedBacklogAsHistory({ + queue: makeQueuePorts(repo, { loadHumanRegistry: () => slackOnlyRegistry }), + seen: new SeenStore(join(home, "state", "slack-outbound-seen.jsonl")), + filter: { minimumLevel: "NOTICE" }, + buffer: new DispatchBuffer(home), + }); + saveConfig({ ...DEFAULT_CONFIG, enabled: true, channel: "C-MIXED", secretsEnvFile: slackSecrets }, home); + + const w2 = makeWire(); + try { + w2.startServices?.(); + await new Promise((r) => setTimeout(r, 60)); + // Only the pre-existing durable Slack decision replays and posts; the fresh Slack backlog ask was seeded and never buffered + expect(slackPosts).toHaveLength(1); + expect(JSON.stringify(slackPosts[0])).toContain("Earlier durable Slack ask"); + expect(JSON.stringify(slackPosts)).not.toContain("Fresh Slack backlog while disabled"); + expect(new DispatchBuffer(home).pending()).toHaveLength(0); + } finally { + w2.stop(); + db.close(); + } + }); +}); diff --git a/packages/daemon/test/steering-only-asks.test.ts b/packages/daemon/test/steering-only-asks.test.ts index 12499958e..81a8d0d16 100644 --- a/packages/daemon/test/steering-only-asks.test.ts +++ b/packages/daemon/test/steering-only-asks.test.ts @@ -28,25 +28,68 @@ import { missionControlRoutes } from "../src/routes/mission-control.js"; import { MissionControlWriteContract } from "../src/domain/mission-control/mission-control-write-contract.js"; import { MissionControlActionLog } from "../src/domain/mission-control/mission-control-action-log.js"; import { runWakeLadderTick } from "../src/domain/queue-wake-ladder.js"; -import type { AttentionRead } from "../src/attention-surface.js"; +import { makeQueuePorts } from "../src/domain/gateway/slack/queue-access.js"; const HUMAN = "human-founder"; /** The people, with their Slack subscription's preset (absent = the default). */ -const registryWith = (preset?: "every-ask" | "urgent-only") => ({ - ok: true as const, - entities: [{ - entityId: HUMAN, class: "human" as const, displayName: "Founder", address: `${HUMAN}@external`, - connectorBindings: [{ kind: "slack" as const, connectorRef: "primary", secretsRef: "env:SLACK_BOT_TOKEN", role: "primary" as const, handle: "UFOUNDER" }], - prefs: { deliveryClass: "B" as const, availability: "available" as const }, - ...(preset ? { subscriptions: [{ destination: { connector: "slack" as const }, filter: { preset } }] } : {}), - }, { - // A registered person addressed as a kernel alias, which no human-* name pattern matches. - entityId: "operator-admin", class: "human" as const, displayName: "Operator", address: "operator-admin@external", - connectorBindings: [{ kind: "slack" as const, connectorRef: "primary", secretsRef: "env:SLACK_BOT_TOKEN", role: "primary" as const, handle: "UOPERATOR" }], - prefs: { deliveryClass: "B" as const, availability: "available" as const }, - ...(preset ? { subscriptions: [{ destination: { connector: "slack" as const }, filter: { preset } }] } : {}), - }], -}); +const registryWith = ( + preset?: "every-ask" | "urgent-only", + options?: { primaryKind?: "slack" | "relay"; secondaryKind?: "slack" | "relay" }, +) => { + const primaryKind = options?.primaryKind ?? "slack"; + const bindings: any[] = []; + if (primaryKind === "relay") { + bindings.push({ + kind: "relay" as const, + connectorRef: "relay-main", + secretsRef: "file:/tmp/relay.env", + role: "primary" as const, + handle: "founder-relay", + }); + } else { + bindings.push({ + kind: "slack" as const, + connectorRef: "primary", + secretsRef: "env:SLACK_BOT_TOKEN", + role: "primary" as const, + handle: "UFOUNDER", + }); + } + + if (options?.secondaryKind === "relay") { + bindings.push({ + kind: "relay" as const, + connectorRef: "relay-sec", + secretsRef: "file:/tmp/relay.env", + role: "secondary" as const, + handle: "founder-relay-sec", + }); + } else if (options?.secondaryKind === "slack") { + bindings.push({ + kind: "slack" as const, + connectorRef: "slack-sec", + secretsRef: "env:SLACK_BOT_TOKEN", + role: "secondary" as const, + handle: "UFOUNDER_SEC", + }); + } + + return { + ok: true as const, + entities: [{ + entityId: HUMAN, class: "human" as const, displayName: "Founder", address: `${HUMAN}@external`, + connectorBindings: bindings, + prefs: { deliveryClass: "B" as const, availability: "available" as const }, + ...(preset ? { subscriptions: [{ destination: { connector: "slack" as const }, filter: { preset } }] } : {}), + }, { + // A registered person addressed as a kernel alias, which no human-* name pattern matches. + entityId: "operator-admin", class: "human" as const, displayName: "Operator", address: "operator-admin@external", + connectorBindings: [{ kind: "slack" as const, connectorRef: "primary", secretsRef: "env:SLACK_BOT_TOKEN", role: "primary" as const, handle: "UOPERATOR" }], + prefs: { deliveryClass: "B" as const, availability: "available" as const }, + ...(preset ? { subscriptions: [{ destination: { connector: "slack" as const }, filter: { preset } }] } : {}), + }], + }; +}; // Most cases here are a person whose Slack subscription is urgent-only (plain asks wait on Steering); the default cases reset it. let registry = registryWith("urgent-only"); const QUESTION = [{ id: "q1", question: "A or B?", options: [{ id: "a", label: "A" }, { id: "b", label: "B" }] }]; @@ -545,4 +588,39 @@ describe("an ask to a person is on Steering and the TUI; Slack gets it by their expect(post).toBeDefined(); expect(JSON.stringify(post)).not.toContain("<@UOPERATOR>"); }); + + it("proves relay primary uses every-ask default independently of authored Slack urgent-only subscription", async () => { + // 1. Relay primary with authored Slack urgent-only: plain ask must classify as ALERT and be eligible through real queue ports + registry = registryWith("urgent-only", { primaryKind: "relay", secondaryKind: "slack" }); + const relayPlain = await ask(); + expect(owner(relayPlain)).toMatchObject({ + ownerNotificationKind: "human-required", + ownerNotificationLevel: "ALERT", + }); + const relayPorts = makeQueuePorts(repo, { loadHumanRegistry: () => registry }); + const relayAlerts = await relayPorts.listHumanAlerts({}); + expect(relayAlerts.some((item) => item.qitemId === relayPlain.qitemId)).toBe(true); + + // 2. Slack primary with authored Slack urgent-only: plain ask stays RECORD + registry = registryWith("urgent-only", { primaryKind: "slack" }); + const slackPlain = await ask(); + expect(owner(slackPlain)).toMatchObject({ + ownerNotificationKind: "human-required", + ownerNotificationLevel: "RECORD", + }); + const slackPorts = makeQueuePorts(repo, { loadHumanRegistry: () => registry }); + const slackAlerts = await slackPorts.listHumanAlerts({}); + expect(slackAlerts.some((item) => item.qitemId === slackPlain.qitemId)).toBe(false); + + // 3. Slack primary with secondary Relay: does not change primary routing, retains Slack urgent-only behavior + registry = registryWith("urgent-only", { primaryKind: "slack", secondaryKind: "relay" }); + const slackSecRelayPlain = await ask(); + expect(owner(slackSecRelayPlain)).toMatchObject({ + ownerNotificationKind: "human-required", + ownerNotificationLevel: "RECORD", + }); + const slackSecRelayPorts = makeQueuePorts(repo, { loadHumanRegistry: () => registry }); + const slackSecAlerts = await slackSecRelayPorts.listHumanAlerts({}); + expect(slackSecAlerts.some((item) => item.qitemId === slackSecRelayPlain.qitemId)).toBe(false); + }); }); diff --git a/packages/tui/src/attention/attention-model.ts b/packages/tui/src/attention/attention-model.ts index b97607bdc..8ae3eaf0f 100644 --- a/packages/tui/src/attention/attention-model.ts +++ b/packages/tui/src/attention/attention-model.ts @@ -2,34 +2,49 @@ import type { AttentionRead, AttentionItem } from "@openrig/daemon/attention"; import type { Action, FleetSnapshot, ViewState } from "../types.js"; import { listItem, wrapDetailLines, type ContentLine } from "../detail.js"; -/** The bounded, confirmed-delivery projection served by S02; no client classification. */ +/** The bounded, confirmed-delivery or bridge-acceptance projection served by S02; no client classification. */ export interface DeliveredHumanUpdates { items: Array<{ qitemId: string; summary: string | null; body: string; humanDetail: string | null; destinationSession: string; sourceSession: string; tags: string[] | null; evidenceRef: string | null; - deliveredAt: string; deliveryReceipt: string }>; + deliveredAt: string; deliveryReceipt: string; deliveryOutcome?: "posted" | "accepted" | null }>; limit: number; truncated: boolean; } +function isBridgeAcceptedUpdate(q: DeliveredHumanUpdates["items"][number]): boolean { + return q.deliveryOutcome === "accepted" || q.deliveryReceipt.startsWith("relay-owner-notification-accepted"); +} + export function composeHumanUpdates(attention: AttentionRead | null, updates: DeliveredHumanUpdates | null, wanted?: string | null): AttentionRead | null { if (!attention && !updates) return null; const read: AttentionRead = attention ? { ...attention, items: [...attention.items], sources: [...attention.sources] } : { scope: "instance", readAt: new Date().toISOString(), items: [], detail: null, detailError: null, sources: [{ source: "queue", state: "unavailable", detail: "Human requests have not answered." }, { source: "outcomes and health", state: "unavailable", detail: "Feed source unavailable." }], }; + const hasAccepted = Boolean(updates?.items.some(isBridgeAcceptedUpdate)); + const hasPosted = Boolean(updates?.items.some(q => !isBridgeAcceptedUpdate(q))); + const sourceDetail = !updates + ? "Delivered updates have not answered." + : hasAccepted && !hasPosted + ? `Latest ${updates.limit} updates accepted by the bridge; retained receipt window${updates.truncated ? ", more omitted" : ""}.` + : hasAccepted + ? `Latest ${updates.limit} confirmed delivered or accepted-by-bridge updates; retained receipt window${updates.truncated ? ", more omitted" : ""}.` + : `Latest ${updates.limit} confirmed delivered updates; retained receipt window${updates.truncated ? ", more omitted" : ""}.`; read.sources.push({ source: "delivered updates", state: !updates ? "unavailable" : updates.truncated ? "partial" : "available", - detail: updates ? `Latest ${updates.limit} confirmed delivered updates; retained receipt window${updates.truncated ? ", more omitted" : ""}.` : "Delivered updates have not answered." }); + detail: sourceDetail }); for (const q of updates?.items ?? []) { + const accepted = isBridgeAcceptedUpdate(q); const projects = [...new Set((q.tags ?? []).filter(t => t.startsWith("project:")).map(t => t.slice(8)))]; - const item: AttentionItem = { id: `human-update:${q.qitemId}`, kind: "update", summary: q.summary || q.body.trim().split(/\r?\n/).find(Boolean) || "Delivered update", + const baseScope = projects.length === 1 ? `project ${projects[0]} (queue tag)` : "instance · project unknown"; + const item: AttentionItem = { id: `human-update:${q.qitemId}`, kind: "update", summary: q.summary || q.body.trim().split(/\r?\n/).find(Boolean) || (accepted ? "Accepted by bridge" : "Delivered update"), recipient: q.destinationSession, urgency: "update", unblocks: null, at: q.deliveredAt, - scope: projects.length === 1 ? `project ${projects[0]} (queue tag)` : "instance · project unknown", project: null, + scope: accepted ? `${baseScope} · accepted by bridge` : baseScope, project: null, source: `/api/queue/${encodeURIComponent(q.qitemId)}` }; if (!read.items.some(current => current.id === `queue-update:${q.qitemId}`)) read.items.push(item); if (wanted === item.id && read.detail?.item.id !== item.id) { read.detailError = null; read.detail = { item, lines: [q.body, ...(q.humanDetail ? ["Supplemental detail:", q.humanDetail] : []), - `From: ${q.sourceSession}`, `Delivered: ${q.deliveredAt}`, `Delivery receipt: ${q.deliveryReceipt}`, `Queue: ${q.qitemId}`, `Evidence: ${q.evidenceRef ?? "none recorded"}`], + `From: ${q.sourceSession}`, accepted ? `Accepted by bridge: ${q.deliveredAt}` : `Delivered: ${q.deliveredAt}`, `Delivery receipt: ${q.deliveryReceipt}`, `Queue: ${q.qitemId}`, `Evidence: ${q.evidenceRef ?? "none recorded"}`], files: q.evidenceRef?.startsWith("/") ? [{ label: "Update evidence", path: q.evidenceRef }] : [] }; } } diff --git a/packages/tui/test/feed-open-human-updates.test.ts b/packages/tui/test/feed-open-human-updates.test.ts index 2da04f5ee..2f4d32620 100644 --- a/packages/tui/test/feed-open-human-updates.test.ts +++ b/packages/tui/test/feed-open-human-updates.test.ts @@ -133,3 +133,45 @@ it("leaves health episodes ordered by date when hydrating queue updates and deli expect(merged.items.filter(i => i.id.startsWith("health:")).map(i => i.id)).toEqual(["health:new-active", "health:old-cleared"]); expect(merged.items.filter(i => i.id.startsWith("queue-update:")).map(i => i.id)).toEqual(["queue-update:critical", "queue-update:urgent"]); }); + +it("labels relay bridge acceptance as accepted by the bridge rather than Delivered while preserving Slack delivery wording", () => { + const bridgeUpdates: DeliveredHumanUpdates = { + limit: 20, + truncated: false, + items: [ + { + qitemId: "bridge-fyi", + summary: "Bridge FYI ready", + body: "Bridge accepted this update.", + humanDetail: "Bridge detail", + destinationSession: "human@kernel", + sourceSession: "author@fixture", + tags: ["project:relay"], + evidenceRef: "/relay/proof.md", + deliveredAt: "2026-10-08T14:00:00Z", + deliveryReceipt: "relay-owner-notification-accepted notification_key=bridge-fyi:1 delivery_id=d-1 connector_ref=wa-main level=NOTICE kind=human-update", + deliveryOutcome: "accepted", + }, + ], + }; + const openedBridge = composeHumanUpdates(read([]), bridgeUpdates, "human-update:bridge-fyi")!; + const bridgeDetailLines = openedBridge.detail?.lines.join("\n") ?? ""; + expect(bridgeDetailLines).toContain("Accepted by bridge: 2026-10-08T14:00:00Z"); + expect(bridgeDetailLines).not.toContain("Delivered:"); + expect(openedBridge.sources.find(s => s.source === "delivered updates")?.detail).toContain("accepted by the bridge"); + expect(openedBridge.sources.find(s => s.source === "delivered updates")?.detail).not.toContain("confirmed delivered updates"); + + const listState = { ...createViewState({ instanceId: "owned-fixture" }).get(), section: "needs" as const }; + const groupedText = attentionLines(listState, { ...emptySnapshot(), attentionRead: openedBridge }, 140).map(l => l.text).join("\n"); + expect(groupedText).toContain("accepted by bridge"); + expect(groupedText).not.toContain("confirmed delivered updates"); + + // Slack delivered wording remains unchanged + const openedSlack = composeHumanUpdates(read([]), delivered("slack-fyi"), "human-update:slack-fyi")!; + const slackDetailLines = openedSlack.detail?.lines.join("\n") ?? ""; + expect(slackDetailLines).toContain("Delivered: 2026-10-08T12:00:00Z"); + expect(slackDetailLines).not.toContain("Accepted by bridge:"); + expect(openedSlack.sources.find(s => s.source === "delivered updates")?.detail).toBe( + "Latest 20 confirmed delivered updates; retained receipt window.", + ); +}); From f5b62726ff759a857eeb76e2cbd654493fedba2f Mon Sep 17 00:00:00 2001 From: Mike Schwarz <171890339+mvschwarz@users.noreply.github.com> Date: Sat, 7 Nov 2026 03:26:18 -0800 Subject: [PATCH 11/14] feat(transport): transport.draft_delivery lets ordinary sends reach a seat with a draft (#2645) Adds transport.draft_delivery (deliver or refuse; default deliver) to the CLI config store, the daemon's settings store and the TUI config model, with OPENRIG_TRANSPORT_DRAFT_DELIVERY taken first when it is exactly deliver or refuse. The daemon reads it at each send that meets a person's draft: only a readable refuse opts out, and a value or read that fails falls back to deliver. Under deliver, SessionTransport pastes the message at the current cursor into the draft and sends Enter, so the combined input is submitted. Under refuse, the send is refused as before and a work notice is retried once the prompt is clear. Menus, permission prompts and hook-reported requests still refuse, a per-seat typing guard still holds, and managed compaction still waits for an empty prompt. rig config help describes both values and that a draft OpenRig can't detect isn't mentioned in the result. --- packages/cli/src/commands/config.ts | 1 + packages/cli/src/config-store.ts | 10 ++ .../cli/test/config-store-extended.test.ts | 5 +- .../daemon/src/domain/session-transport.ts | 33 ++++- .../domain/user-settings/settings-store.ts | 9 ++ packages/daemon/src/startup.ts | 5 +- .../daemon/test/session-transport.test.ts | 136 +++++++++++++++++- .../daemon/test/user-settings-routes.test.ts | 4 +- .../daemon/test/user-settings-store.test.ts | 1 + packages/tui/src/config/config-model.ts | 1 + 10 files changed, 199 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index 8545d557c..ce5b46483 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -130,6 +130,7 @@ Keys: usage.pi_openrouter_charge with usage.pi_cost_facts on, Pi seats look up OpenRouter's actual charge for their requests, shown beside the estimate (default off; changes take effect when a Pi seat next launches or resumes; earlier outcomes stay visible) transport.verify_input_box with --verify, a send to a Claude Code or Codex seat (also queue wakes) reads verified only when the seat was proven idle before the paste and its input box showed the exact text before Enter and was empty after it, on the same pane process; a busy or unknown seat, and anything else, reads unconfirmed, not failed. Reporting only (default off) transport.local_user_guard on a Mac, the CLI refuses a local daemon whose reported user, checked with ps, isn't yours; an explicit OPENRIG_URL skips the check. A mitigation for reaching the wrong daemon, not authentication (default off) + transport.draft_delivery deliver (default): a rig send or work notice to a seat whose prompt holds a person's draft goes through: the message is pasted at the current cursor into the draft (normally its end), then Enter is sent to submit the combined input, and the send's result says whether that was confirmed. OpenRig can't read every prompt layout: where it doesn't detect the draft, the result doesn't mention it, and refuse doesn't stop the send. refuse: such a send is refused, and a work notice retried once the prompt is clear, where OpenRig detects the draft. Either way, menus and permission prompts are refused, a per-seat typing guard still holds, managed compaction waits for an empty prompt, and a send with --wait-for-idle still waits decisions.enabled typed decisions (OpenRouter + Jev) for the consumers in decisions.consumers, with OPENROUTER_API_KEY from the rig process that runs them; off makes no provider calls (default off). rig project decisions status shows the state decisions.consumers comma-separated consumers that may call (supported: stream; default none). An unknown name makes no calls diff --git a/packages/cli/src/config-store.ts b/packages/cli/src/config-store.ts index 8729f7584..a1b127f5a 100644 --- a/packages/cli/src/config-store.ts +++ b/packages/cli/src/config-store.ts @@ -199,6 +199,8 @@ export interface RiggedConfig { transport: { verifyInputBox: boolean; localUserGuard: boolean; + // Whether an ordinary send or work notice reaches a seat whose prompt holds a person's draft (default deliver; refuse opts out). + draftDelivery: "refuse" | "deliver"; }; // Slice 22 (OPR.0.7.2.22) — typed decisions (OpenRouter + Jev), off until enabled; read by the // invoking rig process. templateDir holds optional .yaml templates. Lockstep with the daemon twin. @@ -387,6 +389,7 @@ const DEFAULTS = { transport: { verifyInputBox: false, localUserGuard: false, + draftDelivery: "deliver", }, // Slice 22 — typed decisions OFF, no consumers, shipped templates only. decisions: { @@ -536,6 +539,7 @@ export const VALID_KEYS = [ "usage.pi_openrouter_charge", "transport.verify_input_box", "transport.local_user_guard", + "transport.draft_delivery", // Slice 22 — typed decisions (OpenRouter + Jev), off by default; lockstep with the daemon twin. "decisions.enabled", "decisions.consumers", @@ -657,6 +661,7 @@ export const ENV_MAP: Record = { "usage.pi_openrouter_charge": { primary: "OPENRIG_USAGE_PI_OPENROUTER_CHARGE" }, "transport.local_user_guard": { primary: "OPENRIG_TRANSPORT_LOCAL_USER_GUARD" }, "transport.verify_input_box": { primary: "OPENRIG_TRANSPORT_VERIFY_INPUT_BOX" }, + "transport.draft_delivery": { primary: "OPENRIG_TRANSPORT_DRAFT_DELIVERY" }, "decisions.enabled": { primary: "OPENRIG_DECISIONS_ENABLED" }, "decisions.consumers": { primary: "OPENRIG_DECISIONS_CONSUMERS" }, "decisions.template_dir": { primary: "OPENRIG_DECISIONS_TEMPLATE_DIR" }, @@ -759,6 +764,7 @@ const KEY_TO_PATH: Record = { "usage.pi_openrouter_charge": ["usage", "piOpenRouterCharge"], "transport.local_user_guard": ["transport", "localUserGuard"], "transport.verify_input_box": ["transport", "verifyInputBox"], + "transport.draft_delivery": ["transport", "draftDelivery"], "decisions.enabled": ["decisions", "enabled"], "decisions.consumers": ["decisions", "consumers"], "decisions.template_dir": ["decisions", "templateDir"], @@ -983,6 +989,9 @@ const KEY_CONSTRAINTS: Partial { if (raw !== "warn" && raw !== "stop") throw new Error("daemon.db_conflict must be warn or stop"); }, + "transport.draft_delivery": (raw) => { + if (raw !== "refuse" && raw !== "deliver") throw new Error(`Invalid value for transport.draft_delivery: must be "refuse" or "deliver", got "${raw}"`); + }, "transcripts.lines": (raw, value) => { if (!/^\d+$/.test(raw.trim()) || typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > 1_000_000) { throw new Error("Invalid transcripts.lines: must be an integer in [1, 1000000]"); @@ -1386,6 +1395,7 @@ export class ConfigStore { transport: { localUserGuard: v("transport.local_user_guard") as boolean, verifyInputBox: v("transport.verify_input_box") as boolean, + draftDelivery: v("transport.draft_delivery") as "refuse" | "deliver", }, decisions: { enabled: v("decisions.enabled") as boolean, diff --git a/packages/cli/test/config-store-extended.test.ts b/packages/cli/test/config-store-extended.test.ts index 17389d5b1..fb458e5e0 100644 --- a/packages/cli/test/config-store-extended.test.ts +++ b/packages/cli/test/config-store-extended.test.ts @@ -202,6 +202,7 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => { "usage.pi_openrouter_charge", "transport.verify_input_box", "transport.local_user_guard", + "transport.draft_delivery", // Slice 22 (OPR.0.7.2.22) — typed decisions, off by default. "decisions.enabled", "decisions.consumers", @@ -329,11 +330,11 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => { it("transport.verify_input_box defaults off, resolves its env twin, and appears in the resolved config", () => { const store = new ConfigStore(configPath); expect(store.resolveWithSource("transport.verify_input_box")).toMatchObject({ value: false, source: "default", defaultValue: false }); - expect(store.resolve().transport).toEqual({ verifyInputBox: false, localUserGuard: false }); + expect(store.resolve().transport).toEqual({ verifyInputBox: false, localUserGuard: false, draftDelivery: "deliver" }); try { process.env.OPENRIG_TRANSPORT_VERIFY_INPUT_BOX = "1"; expect(store.resolveWithSource("transport.verify_input_box")).toMatchObject({ value: true, source: "env" }); - expect(store.resolve().transport).toEqual({ verifyInputBox: true, localUserGuard: false }); + expect(store.resolve().transport).toEqual({ verifyInputBox: true, localUserGuard: false, draftDelivery: "deliver" }); } finally { delete process.env.OPENRIG_TRANSPORT_VERIFY_INPUT_BOX; } diff --git a/packages/daemon/src/domain/session-transport.ts b/packages/daemon/src/domain/session-transport.ts index 9884657c2..a38c9fbeb 100644 --- a/packages/daemon/src/domain/session-transport.ts +++ b/packages/daemon/src/domain/session-transport.ts @@ -1047,6 +1047,21 @@ function safeRecord(sink: CaptureObserverSink, input: ObservationInput): void { try { sink.record(input); } catch { /* observer failure never reaches the caller */ } } +/** transport.draft_delivery as the daemon reads it. deliver is the product default, so only a readable `refuse` opts out: + * an unset, unreadable or invalid value follows the default, as other settings fall back to theirs. */ +export function draftDeliveryAllows(read: () => unknown): boolean { + try { return read() !== "refuse"; } catch { return true; } +} + +/** The setting's value, taking a valid OPENRIG_TRANSPORT_DRAFT_DELIVERY first: the environment outranks the file, and the + * settings store reads the file before its environment override, so an unreadable config.json would otherwise turn an + * explicit environment refuse into the default. Valid means exactly what the store accepts (no trimming); anything else + * comes from the store, which drops an invalid environment value for the file's. */ +export function readDraftDeliverySetting(resolve: () => unknown, env: NodeJS.ProcessEnv = process.env): unknown { + const fromEnv = env.OPENRIG_TRANSPORT_DRAFT_DELIVERY; + return fromEnv === "refuse" || fromEnv === "deliver" ? fromEnv : resolve(); +} + export function mapPaneState(state: PaneActivityClassification["state"]): AgentActivity["state"] { if (state === "agent_active") return "running"; if (state === "attention") return "needs_input"; @@ -2069,6 +2084,9 @@ interface SessionTransportDeps { runtimeAdapters?: Record; /** transport.verify_input_box, read per verified Claude Code or Codex send. Absent means off. */ verifyInputBox?: () => boolean; + /** Whether transport.draft_delivery lets this send through a person's draft, read per send that meets one. The daemon + * wires draftDeliveryAllows (deliver by default); a transport built without it keeps refusing. */ + deliverOverDraft?: () => boolean; } interface SessionRow { node_id: string; session_name: string; } @@ -2122,6 +2140,7 @@ export class SessionTransport { private runtimeAdapters: Record; private inFlightReconciliations = new Map>(); private verifyInputBox?: () => boolean; + private deliverOverDraft?: () => boolean; constructor(deps: SessionTransportDeps) { this.contextUsageStore = deps.contextUsageStore; @@ -2141,6 +2160,7 @@ export class SessionTransport { this.listProcesses = deps.listProcesses; this.runtimeAdapters = deps.runtimeAdapters ?? {}; this.verifyInputBox = deps.verifyInputBox; + this.deliverOverDraft = deps.deliverOverDraft; const guard = this.tmuxAdapter.deliveryGuard; if (guard && (this.db.prepare("PRAGMA table_info(outbox_entries)").all() as Array<{ name: string }>).some(c => c.name === "guard_delivery")) this.guardedDelivery = new DraftAwareDelivery(this.db, guard, (session, text, opts) => this.sendUnguarded(session, text, opts)); @@ -3319,7 +3339,9 @@ export class SessionTransport { if (!audit.ok) sendAdvisory = "prompt-override: audit unavailable (" + audit.reason + "); input sent anyway."; } if (readiness.state === "needs_input") { - if (!promptOverride) { + if (!promptOverride && this.deliversOverDraft(readiness, opts)) { + sendAdvisory = [sendAdvisory, `'${sessionName}' had a draft at its prompt and transport.draft_delivery is deliver: this message was pasted at the current cursor into that draft (normally its end), then Enter was sent to submit the combined input. The delivery result says whether that was confirmed.`].filter(Boolean).join(" "); + } else if (!promptOverride) { return { ok: false, sessionName, @@ -3806,6 +3828,15 @@ export class SessionTransport { return paneCommand.replace(/^-/, ""); } + /** transport.draft_delivery = deliver (the default): a person's draft at the prompt is the one needs_input an ordinary send or work + * notice goes through. Menus, permission prompts and hook-reported requests still refuse, and so does a send that must + * not join a draft (preserveInput: managed compaction). A send that waits for idle keeps waiting. */ + private deliversOverDraft(readiness: AgentActivity, opts?: SendOpts): boolean { + if (readiness.state !== "needs_input" || readiness.reason !== "prompt_draft" || readiness.evidenceSource !== "pane_heuristic") return false; + if (opts?.preserveInput) return false; + try { return this.deliverOverDraft?.() === true; } catch { return false; } + } + private async classifySendReadiness(input: { sessionName: string; runtime: string | null; diff --git a/packages/daemon/src/domain/user-settings/settings-store.ts b/packages/daemon/src/domain/user-settings/settings-store.ts index 4b01c861e..096e59992 100644 --- a/packages/daemon/src/domain/user-settings/settings-store.ts +++ b/packages/daemon/src/domain/user-settings/settings-store.ts @@ -206,6 +206,8 @@ export const SETTINGS_VALID_KEYS = [ // A verified Claude Code or Codex send reports on input-box evidence; daemon-read, twin of the CLI key. "transport.verify_input_box", "transport.local_user_guard", + // Whether an ordinary send or work notice reaches a seat whose prompt holds a person's draft; daemon-read, twin of the CLI key. + "transport.draft_delivery", // Slice 22 (OPR.0.7.2.22) — typed decisions (OpenRouter + Jev), off by default; read by the invoking // rig process, never by the daemon. Twin of the CLI keys. "decisions.enabled", @@ -335,6 +337,7 @@ const ENV_MAP: Record = "usage.pi_openrouter_charge": { primary: "OPENRIG_USAGE_PI_OPENROUTER_CHARGE" }, "transport.local_user_guard": { primary: "OPENRIG_TRANSPORT_LOCAL_USER_GUARD" }, "transport.verify_input_box": { primary: "OPENRIG_TRANSPORT_VERIFY_INPUT_BOX" }, + "transport.draft_delivery": { primary: "OPENRIG_TRANSPORT_DRAFT_DELIVERY" }, "decisions.enabled": { primary: "OPENRIG_DECISIONS_ENABLED" }, "decisions.consumers": { primary: "OPENRIG_DECISIONS_CONSUMERS" }, "decisions.template_dir": { primary: "OPENRIG_DECISIONS_TEMPLATE_DIR" }, @@ -436,6 +439,7 @@ const KEY_TO_PATH: Record = { "usage.pi_openrouter_charge": ["usage", "piOpenRouterCharge"], "transport.local_user_guard": ["transport", "localUserGuard"], "transport.verify_input_box": ["transport", "verifyInputBox"], + "transport.draft_delivery": ["transport", "draftDelivery"], "decisions.enabled": ["decisions", "enabled"], "decisions.consumers": ["decisions", "consumers"], "decisions.template_dir": ["decisions", "templateDir"], @@ -749,6 +753,8 @@ function getDefaultValue(key: SettingsValidKey, workspaceRoot: string): string | // Input-box evidence for verified Claude Code and Codex sends; off until the switch is on. case "transport.local_user_guard": return false; case "transport.verify_input_box": return false; + // Ordinary sends reach a seat with a draft at its prompt unless the setting says refuse. + case "transport.draft_delivery": return "deliver"; // Slice 22 — typed decisions off, no consumers, shipped templates only. case "decisions.enabled": return false; case "decisions.consumers": return ""; @@ -831,6 +837,9 @@ const KEY_CONSTRAINTS: Partial { if (raw !== "warn" && raw !== "stop") throw new Error("daemon.db_conflict must be warn or stop"); }, + "transport.draft_delivery": (raw) => { + if (raw !== "refuse" && raw !== "deliver") throw new Error(`Invalid value for transport.draft_delivery: must be "refuse" or "deliver", got "${raw}"`); + }, "transcripts.lines": (raw, value) => { if (!/^\d+$/.test(raw.trim()) || typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > 1_000_000) { throw new Error("Invalid transcripts.lines: must be an integer in [1, 1000000]"); diff --git a/packages/daemon/src/startup.ts b/packages/daemon/src/startup.ts index fb2f68633..e3e44e519 100644 --- a/packages/daemon/src/startup.ts +++ b/packages/daemon/src/startup.ts @@ -118,7 +118,7 @@ import { ResumeMetadataRefresher } from "./domain/resume-metadata-refresher.js"; import { observeClaudePaneStartedAt } from "./domain/native-process-lineage.js"; import { TranscriptStore } from "./domain/transcript-store.js"; import { resumeRunningTranscriptCaptures, wireTranscriptCaptureStops } from "./domain/transcript-capture.js"; -import { probeSessionActivity, SessionTransport, inspectComposerInput } from "./domain/session-transport.js"; +import { probeSessionActivity, SessionTransport, inspectComposerInput, draftDeliveryAllows, readDraftDeliverySetting } from "./domain/session-transport.js"; import { AgentActivityStore } from "./domain/agent-activity-store.js"; import { hookOrPaneActivity, nodeExecutorConfirmedStopped } from "./domain/node-inventory.js"; import { HistoryQuery } from "./domain/history-query.js"; @@ -1583,6 +1583,9 @@ export async function createDaemon(opts?: DaemonOptions): Promise verifyInputBox: () => { try { return new ContextPackSettingsStore().resolveOne("transport.verify_input_box").value === true; } catch { return false; } }, + // transport.draft_delivery, read at each send that meets a person's draft: only a readable refuse opts out. + deliverOverDraft: () => draftDeliveryAllows(() => + readDraftDeliverySetting(() => new ContextPackSettingsStore().resolveOne("transport.draft_delivery").value)), }); // PL-004 Phase A revision (R1): wire QueueRepository's wake-path so // create / handoff / handoff-and-complete nudge by default. diff --git a/packages/daemon/test/session-transport.test.ts b/packages/daemon/test/session-transport.test.ts index 70383dd48..9c2461da4 100644 --- a/packages/daemon/test/session-transport.test.ts +++ b/packages/daemon/test/session-transport.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import type Database from "better-sqlite3"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { SettingsStore } from "../src/domain/user-settings/settings-store.js"; import { createDb } from "../src/db/connection.js"; import { migrate } from "../src/db/migrate.js"; import { coreSchema } from "../src/db/migrations/001_core_schema.js"; @@ -15,7 +19,7 @@ import { agentspecRebootSchema } from "../src/db/migrations/014_agentspec_reboot import { externalCliAttachmentSchema } from "../src/db/migrations/019_external_cli_attachment.js"; import { RigRepository } from "../src/domain/rig-repository.js"; import { SessionRegistry } from "../src/domain/session-registry.js"; -import { classifyPaneActivity, classifyStyledFrame, hasExpectedStagedText, inspectStartupStagedText, probeSessionActivity, promptTextIsFaint, SessionTransport, type TransportReadinessChecker } from "../src/domain/session-transport.js"; +import { classifyPaneActivity, classifyStyledFrame, draftDeliveryAllows, readDraftDeliverySetting, hasExpectedStagedText, inspectStartupStagedText, probeSessionActivity, promptTextIsFaint, SessionTransport, type TransportReadinessChecker } from "../src/domain/session-transport.js"; import { SeatDeliveryGuard, resolveGuardTarget, type GuardTarget } from "../src/domain/seat-delivery-guard.js"; import { ALL_MIGRATIONS } from "../src/db/all-migrations.js"; import { outboxEntriesSchema } from "../src/db/migrations/027_outbox_entries.js"; @@ -575,6 +579,7 @@ describe("SessionTransport", () => { now?: () => Date; eventBus?: EventBus; runtimeAdapters?: Record; + deliverOverDraft?: () => boolean; }) { return new SessionTransport({ db, @@ -671,6 +676,135 @@ describe("SessionTransport", () => { expect(callOrder).toEqual(["sendText", "sendKeys:Enter"]); }); + describe("transport.draft_delivery: a person's draft at the prompt", () => { + const DRAFT = ["❯ I am still typing a message", " ⏵⏵ accept edits on (shift+tab to cycle)"].join("\n"); + function draftPane(capture = DRAFT) { + const callOrder: string[] = []; + const tmux = mockTmux({ + capturePaneContent: async () => capture, + sendText: async (_t, text) => { callOrder.push(`sendText:${text}`); return { ok: true }; }, + sendKeys: async (_t, keys) => { callOrder.push(`sendKeys:${keys.join(",")}`); return { ok: true }; }, + }); + return { tmux, callOrder }; + } + + it("refuses when the setting is refuse, the opt-out", async () => { + seedCanonicalRig(); + const { tmux, callOrder } = draftPane(); + const result = await createTransport(tmux, { deliverOverDraft: () => false }).send("dev-impl@my-rig", "hello"); + expect(result).toMatchObject({ ok: false, reason: "target_needs_input", sent: false, activity: { reason: "prompt_draft" } }); + expect(callOrder).toEqual([]); + }); + + it("keeps refusing in a transport built without the daemon's setting reader", async () => { + seedCanonicalRig(); + const { tmux, callOrder } = draftPane(); + const result = await createTransport(tmux).send("dev-impl@my-rig", "hello"); + expect(result).toMatchObject({ ok: false, reason: "target_needs_input", sent: false, activity: { reason: "prompt_draft" } }); + expect(callOrder).toEqual([]); + }); + + it("pastes into the draft at the cursor and sends Enter for the combined input when the setting is deliver", async () => { + seedCanonicalRig(); + const { tmux, callOrder } = draftPane(); + const result = await createTransport(tmux, { deliverOverDraft: () => true }).send("dev-impl@my-rig", "hello"); + expect(result).toMatchObject({ ok: true }); + expect(callOrder).toEqual(["sendText:hello", "sendKeys:Enter"]); + expect(result.warning).toContain("transport.draft_delivery is deliver"); + }); + + it("still refuses a numbered choice under deliver: a message never answers a menu", async () => { + seedCanonicalRig(); + const { tmux, callOrder } = draftPane(["› 1. Yes, continue", " 2. Yes, allow all tools", " 3. No, cancel"].join("\n")); + const result = await createTransport(tmux, { deliverOverDraft: () => true }).send("dev-impl@my-rig", "hello"); + expect(result).toMatchObject({ ok: false, reason: "target_needs_input", activity: { state: "needs_input", reason: "selection_prompt" } }); + expect(callOrder).toEqual([]); + }); + + it("still refuses a hook-reported permission prompt under deliver, even over a draft-shaped pane", async () => { + seedCanonicalRig(); + const eventBus = new EventBus(db); + const agentActivityStore = new AgentActivityStore({ db, eventBus }); + agentActivityStore.recordHookEvent({ runtime: "claude-code", sessionName: "dev-impl@my-rig", hookEvent: "Notification", subtype: "permission_prompt" }); + const { tmux, callOrder } = draftPane(); + const result = await createTransport(tmux, { agentActivityStore, deliverOverDraft: () => true }).send("dev-impl@my-rig", "hello"); + expect(result).toMatchObject({ ok: false, reason: "target_needs_input", activity: { state: "needs_input", evidenceSource: "runtime_hook" } }); + expect(callOrder).toEqual([]); + }); + + it("keeps refusing a send that must not join a draft (managed compaction's preserveInput)", async () => { + seedCanonicalRig(); + const { tmux, callOrder } = draftPane(); + const result = await createTransport(tmux, { deliverOverDraft: () => true }).send("dev-impl@my-rig", "/compact", { preserveInput: true }); + expect(result).toMatchObject({ ok: false, reason: "target_needs_input", activity: { reason: "prompt_draft" } }); + expect(callOrder).toEqual([]); + }); + + it("keeps waiting for idle when the caller asked to wait", async () => { + seedCanonicalRig(); + const { tmux, callOrder } = draftPane(); + const transport = createTransport(tmux, { deliverOverDraft: () => true, sleep: async () => undefined, waitForIdlePollMs: 1 }); + const result = await transport.send("dev-impl@my-rig", "hello", { waitForIdleMs: 50 }); + expect(result).toMatchObject({ ok: false, reason: "target_needs_input", sent: false }); + expect(callOrder).toEqual([]); + }); + + it("reads the setting as the daemon does: only a readable refuse opts out of the deliver default", () => { + expect(draftDeliveryAllows(() => "refuse")).toBe(false); + expect(draftDeliveryAllows(() => "deliver")).toBe(true); + expect(draftDeliveryAllows(() => undefined)).toBe(true); + expect(draftDeliveryAllows(() => "something-else")).toBe(true); + expect(draftDeliveryAllows(() => { throw new Error("settings unreadable"); })).toBe(true); + }); + + it("honours an explicit environment refuse when config.json can't be parsed (the real settings reader)", () => { + const dir = mkdtempSync(join(tmpdir(), "draft-delivery-settings-")); + try { + const malformed = join(dir, "config.json"); + writeFileSync(malformed, "{ not json"); + const store = new SettingsStore(malformed); + const fromStore = () => store.resolveOne("transport.draft_delivery").value; + // The store reads the file before its environment override, so it throws here whatever the environment says. + expect(fromStore).toThrow(/malformed/); + expect(draftDeliveryAllows(() => readDraftDeliverySetting(fromStore, { OPENRIG_TRANSPORT_DRAFT_DELIVERY: "refuse" }))).toBe(false); + // A padded value isn't one the store accepts, so it goes to the store, as the store's own resolver would. + expect(draftDeliveryAllows(() => readDraftDeliverySetting(fromStore, { OPENRIG_TRANSPORT_DRAFT_DELIVERY: " refuse " }))).toBe(true); + // No valid opt-out: an unreadable file and an invalid environment value both follow the deliver default. + expect(draftDeliveryAllows(() => readDraftDeliverySetting(fromStore, {}))).toBe(true); + expect(draftDeliveryAllows(() => readDraftDeliverySetting(fromStore, { OPENRIG_TRANSPORT_DRAFT_DELIVERY: "maybe" }))).toBe(true); + + // A readable file's refuse still applies without an environment value, and the environment outranks it. + const readable = join(dir, "readable.json"); + writeFileSync(readable, JSON.stringify({ transport: { draftDelivery: "refuse" } })); + const fileStore = new SettingsStore(readable); + const fromFile = () => fileStore.resolveOne("transport.draft_delivery").value; + expect(draftDeliveryAllows(() => readDraftDeliverySetting(fromFile, {}))).toBe(false); + expect(draftDeliveryAllows(() => readDraftDeliverySetting(fromFile, { OPENRIG_TRANSPORT_DRAFT_DELIVERY: "deliver" }))).toBe(true); + // A padded environment value doesn't defeat the file's refuse: the store's own resolver rejects it (it reads the + // real environment) and reads the file, and the early read defers to it. + const saved = process.env.OPENRIG_TRANSPORT_DRAFT_DELIVERY; + process.env.OPENRIG_TRANSPORT_DRAFT_DELIVERY = " deliver "; + try { + expect(fileStore.resolveOne("transport.draft_delivery")).toMatchObject({ value: "refuse", source: "file" }); + expect(draftDeliveryAllows(() => readDraftDeliverySetting(fromFile))).toBe(false); + } finally { + if (saved === undefined) delete process.env.OPENRIG_TRANSPORT_DRAFT_DELIVERY; + else process.env.OPENRIG_TRANSPORT_DRAFT_DELIVERY = saved; + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("keeps refusing when a transport's own reader throws (the daemon's reader never does)", async () => { + seedCanonicalRig(); + const { tmux, callOrder } = draftPane(); + const result = await createTransport(tmux, { deliverOverDraft: () => { throw new Error("settings unreadable"); } }).send("dev-impl@my-rig", "hello"); + expect(result).toMatchObject({ ok: false, reason: "target_needs_input" }); + expect(callOrder).toEqual([]); + }); + }); + describe("#331: pane in tmux copy mode", () => { // A pane that stays in its mode until cancelPaneMode runs, recording every call in order. function modalPane(opts: { leaves: boolean; callOrder: string[]; capture?: string }) { diff --git a/packages/daemon/test/user-settings-routes.test.ts b/packages/daemon/test/user-settings-routes.test.ts index a87235153..07f7ee49b 100644 --- a/packages/daemon/test/user-settings-routes.test.ts +++ b/packages/daemon/test/user-settings-routes.test.ts @@ -134,8 +134,10 @@ describe("config routes (User Settings v0)", () => { // + 1 specs.builtins_visible (show built-in library entries by default). // + 1 stream.intake (issue #1856 queue intake routing; default queue.worker). // + 1 launch.codex_login_shell (defer to Codex's own login-shell setting; default off). - expect(Object.keys(body.settings).length).toBe(96); + // + 1 transport.draft_delivery (whether ordinary sends reach a seat whose prompt holds a draft; default deliver). + expect(Object.keys(body.settings).length).toBe(97); expect(body.settings["launch.codex_login_shell"]).toMatchObject({ value: false, source: "default", defaultValue: false }); + expect(body.settings["transport.draft_delivery"]).toMatchObject({ value: "deliver", source: "default", defaultValue: "deliver" }); expect(body.settings["specs.builtins_visible"]).toMatchObject({ value: true, source: "default", defaultValue: true }); expect(body.settings["stream.intake"]).toMatchObject({ value: "queue.worker", source: "default", defaultValue: "queue.worker" }); expect(body.settings["retention.stream_days"]).toMatchObject({ value: 30, source: "default", defaultValue: 30 }); diff --git a/packages/daemon/test/user-settings-store.test.ts b/packages/daemon/test/user-settings-store.test.ts index 93ef12d6f..53d1c6f0f 100644 --- a/packages/daemon/test/user-settings-store.test.ts +++ b/packages/daemon/test/user-settings-store.test.ts @@ -184,6 +184,7 @@ describe("SettingsStore (User Settings v0)", () => { "usage.pi_openrouter_charge", "transport.verify_input_box", "transport.local_user_guard", + "transport.draft_delivery", // Slice 22 (OPR.0.7.2.22) — typed decisions, off by default. "decisions.enabled", "decisions.consumers", diff --git a/packages/tui/src/config/config-model.ts b/packages/tui/src/config/config-model.ts index bb89d8aca..9c28174a7 100644 --- a/packages/tui/src/config/config-model.ts +++ b/packages/tui/src/config/config-model.ts @@ -73,6 +73,7 @@ const LABELS: Record = { "usage.pi_cost_facts": "Pi cost from persisted entries", "usage.pi_openrouter_charge": "OpenRouter charge for Pi requests", "transport.verify_input_box": "Verify by input box (Claude Code, Codex)", + "transport.draft_delivery": "Send to a seat with a draft at its prompt", "terminal.history_limit": "tmux scrollback rows", "ui.enabled": "Web UI", "health.context_pressure.warning_percent": "Context warning threshold", From 6fa0ad5c6efdfbad880636edf24ba78c52790736 Mon Sep 17 00:00:00 2001 From: Mike Schwarz <171890339+mvschwarz@users.noreply.github.com> Date: Sat, 7 Nov 2026 04:37:10 -0800 Subject: [PATCH 12/14] feat(cli): rig acp serve asks the person the team's questions and reopens sessions (#2651) rig acp serve's agent server asks the person: during an open turn it polls the caller's open asks and puts one to the app as session/request_permission only when its tags carry this chat's exact acp-session:; an untagged ask from a seat the turn's work reached is named, never asked. A choice posts the existing Mission Control answer verb, so it is recorded by the same answer contract Slack and the TUI use, with the actor taken from the caller's session header; a refused answer is shown with the daemon's reason. On cancel the turn waits up to 2 s for questions the app is settling, then finishes them before returning cancelled. Sessions are the caller's acp-serve rows tagged acp-session:: session/list groups them, session/load replays each root through the same chain follower a live turn uses, and a later prompt carries the session tag and names the earlier roots. session/load of an unknown session returns JSON-RPC -32602. The answer POST sends OPENRIG_AUTH_BEARER_TOKEN only as its Authorization header. docs: acp-agents.md describes questions and sessions. --- docs/reference/acp-agents.md | 11 +- packages/cli/src/acp/agent-server.ts | 310 +++++++++++++++++++++------ packages/cli/src/commands/acp.ts | 39 +++- packages/cli/test/acp-serve.test.ts | 198 ++++++++++++++++- 4 files changed, 479 insertions(+), 79 deletions(-) diff --git a/docs/reference/acp-agents.md b/docs/reference/acp-agents.md index 6244a4340..3c7dde88b 100644 --- a/docs/reference/acp-agents.md +++ b/docs/reference/acp-agents.md @@ -199,6 +199,14 @@ rig acp serve --to turn ending says only that the turn ended: read the reply for how the work ended. - **A follow-up** in the same session goes to `--to` as a new row whose body names the earlier rows and their states, and the app first shows any earlier row that's still open. +- **A question the team asks you** while a turn is open comes to the app as a permission request, with the ask's own + choices, when its row is tagged for that chat (`acp-session:`; the task's row tells the team how). An untagged + question from a seat the work reached is named in the chat with where to answer it, because it could be another + chat's. Your choice is recorded as your answer, exactly as a click in Slack or the TUI would be, and the seat that + asked gets it. Dismissing it, or cancelling the turn, records nothing: the question stays open in the TUI and Slack. + A question that wants an answer in words is shown with where to answer it. +- **Sessions are kept in the queue:** the app can list your earlier sessions (`session/list`) and reopen one + (`session/load`). Reopening replays each prompt, its work and the reply as they stand now, and a new prompt continues it. - **Cancel** stops `rig acp serve` waiting. The team's work carries on, and the reply says so. - **Work handed to a seat on another machine** can't be followed from here; the reply says where it went. @@ -219,7 +227,6 @@ Other apps take the same command and arguments wherever they configure a custom **Limits:** - One receiver per agent command; the app has no team picker. -- The team's questions to you, and reopening an earlier session, aren't served: a question addressed to you waits in - your usual places (the TUI, Slack). +- Questions show only while a turn is open; one asked while no turn is open waits in your usual places (the TUI, Slack). - Prompts are text. Links and embedded text files are passed on as written; images and audio aren't accepted. - Progress is read every 2 seconds while a turn is open (`--poll-seconds`). diff --git a/packages/cli/src/acp/agent-server.ts b/packages/cli/src/acp/agent-server.ts index 70c5596af..5428ba9f0 100644 --- a/packages/cli/src/acp/agent-server.ts +++ b/packages/cli/src/acp/agent-server.ts @@ -2,12 +2,18 @@ import { randomBytes } from "node:crypto"; import type { JsonRpcPeer } from "./json-rpc-stdio.js"; // OpenRig as an ACP agent (rig acp serve): an ACP app's prompt becomes a queue row to the team's receiver, the row's -// transitions and handoffs come back as progress, and its closing resolution is the reply. The queue is the only work -// store; this process keeps nothing but which rows each open session sent. A turn's end_turn says only that the turn -// ended: the reply says whether the team closed the item, and a cancel stops this process waiting, never the team. +// transitions and handoffs come back as progress, and its closing resolution is the reply. A question the team asks the +// caller while it works comes to the app as a permission request, and the choice goes back through the person's answer +// path. The queue is the only work store: a session is the rows tagged with its id, so it can be listed and reopened. +// A turn's end_turn says only that the turn ended: the reply says whether the team closed the item, and a cancel stops +// this process waiting, never the team. export const ACP_PROTOCOL_VERSION = 1; export const AUTH_METHOD_ID = "openrig-local"; +export const SESSION_TAG = "acp-session:"; +export const CWD_TAG = "acp-cwd:"; +/** Where the caller's prompt ends in a row's body; the rest is the note rig acp serve adds for the team. */ +const FOOTER = "\n\n---\nSent by "; /** One queue row as this adapter reads it. */ export interface TeamRow { @@ -30,6 +36,28 @@ export interface TeamTransition { actorSession: string; } +/** An open row to the caller that asks for a decision, with the choices it offers (none: words only). */ +export interface TeamAsk { + qitemId: string; + sourceSession: string; + tsCreated: string; + summary: string | null; + body: string; + tags: string[]; + questions: Array<{ id: string; question: string; options: Array<{ id: string; label: string; recommended?: boolean }> }>; + answered: string[]; +} + +/** A row the caller sent through rig acp serve. */ +export interface SentRow { + qitemId: string; + tsCreated: string; + tsUpdated: string; + summary: string | null; + body: string; + tags: string[]; +} + /** The daemon answered a create and refused it, so nothing was written. Any other failure of a create leaves it unknown. */ export class TeamRefused extends Error {} @@ -44,6 +72,12 @@ export interface TeamQueue { transitions(qitemId: string): Promise; /** The row a handoff of `qitemId` created on this machine, if any. */ successor(qitemId: string): Promise; + /** Open asks addressed to `person`. */ + openAsks(person: string): Promise; + /** Record the caller's choice on an ask, through the person's answer path; a refusal comes back with its reason. */ + answer(qitemId: string, questionId: string, optionId: string): Promise<{ ok: true } | { ok: false; reason: string }>; + /** Rows the caller sent through rig acp serve. */ + sent(caller: string): Promise; } /** What a session works through: the queue as the caller, and the seat that receives the caller's tasks (when set). */ @@ -65,6 +99,8 @@ export interface AcpAgentOptions { } const ACTIVE_STATES = new Set(["pending", "in-progress", "blocked"]); +/** How long a cancel waits for the app to settle questions it was showing (ACP has the client answer them cancelled). */ +const QUESTION_SETTLE_MS = 2000; const AUTH_REQUIRED = -32000; const INVALID_PARAMS = -32602; @@ -74,13 +110,23 @@ class AcpError extends Error { interface Session { id: string; + cwd: string; /** The first row each prompt created, oldest first, with the last row its work reached. */ - sent: Array<{ root: string; tail: string; closed: boolean }>; + sent: Array<{ root: string; tail: string }>; turn?: { cancel: () => void }; } type ContentBlock = { type: string; text?: string; uri?: string; name?: string; resource?: { uri?: string; text?: string } }; +/** The questions one turn has put to the app. Once the turn has ended, nothing from them reaches the app or the queue. */ +interface TurnQuestions { + finished: boolean; + /** Questions the app is still showing, by tool call id. */ + outstanding: Set; + tasks: Set>; +} +type Update = Record; + /** The prompt as plain text for the row's body: text as written, links and embedded text resources named. */ export function promptText(prompt: ContentBlock[]): string { const parts: string[] = []; @@ -108,6 +154,64 @@ const toolStatus = (state: string) => const transitionLine = (t: TeamTransition) => `${t.ts.slice(11, 19)}Z ${t.actorSession} · ${t.state}${t.transitionNote ? `: ${t.transitionNote}` : ""}`; +const textContent = (text: string) => [{ type: "content", content: { type: "text", text } }]; + +/** Where one prompt's work stands after a read of its rows. */ +type ChainState = { kind: "open"; tail: TeamRow } | { kind: "ended"; tail: TeamRow; reply: string }; + +/** Follows one prompt's work through its rows: each row is a step, and its new notes are emitted as they appear. */ +class ChainFollower { + private readonly chain: string[]; + private readonly seen = new Map(); + /** The seats the work has reached; a question from one of them during the turn belongs to it. */ + readonly seats = new Set(); + + constructor(private readonly queue: TeamQueue, private readonly emit: (update: Update) => void, readonly root: string) { + this.chain = [root]; + } + + async read(): Promise { + for (;;) { + let tail!: TeamRow; + for (const qitemId of this.chain) { + const row = await this.queue.show(qitemId); + const transitions = await this.queue.transitions(qitemId); + tail = row; + this.seats.add(row.destinationSession); + const status = toolStatus(row.state); + let entry = this.seen.get(qitemId); + if (!entry) { + entry = { lines: [], lastId: 0, status }; + this.seen.set(qitemId, entry); + this.emit({ + sessionUpdate: "tool_call", toolCallId: qitemId, kind: "other", status, + title: qitemId === this.root ? `Sent to ${row.destinationSession} (${qitemId})` : `Handed to ${row.destinationSession} (${qitemId})`, + rawInput: { qitemId, destination: row.destinationSession }, + }); + } + const fresh = transitions.filter((t) => t.transitionId > entry!.lastId); + if (fresh.length > 0 || status !== entry.status) { + for (const t of fresh) { entry.lines.push(transitionLine(t)); entry.lastId = Math.max(entry.lastId, t.transitionId); } + entry.status = status; + this.emit({ sessionUpdate: "tool_call_update", toolCallId: qitemId, status, ...(entry.lines.length > 0 ? { content: textContent(entry.lines.join("\n")) } : {}) }); + } + } + // Both handoff forms pass the work on: `handoff` leaves the row handed-off, `handoff-and-complete` closes it done + // with closure reason handed_off_to. Either way the answer is the successor's, not this row's. + if (tail.state === "handed-off" || (tail.state === "done" && tail.closureReason === "handed_off_to")) { + const next = await this.queue.successor(tail.qitemId); + if (next) { this.chain.push(next); continue; } + return { kind: "ended", tail, reply: `${tail.qitemId} was handed to ${tail.handedOffTo ?? tail.closureTarget ?? "another seat"}, whose row isn't on this machine, so this app can't follow it further. Ask your team for its result.` }; + } + if (ACTIVE_STATES.has(tail.state)) return { kind: "open", tail }; + // A done row's closure reason says how it ended (canceled is a done close too); the ordinary one goes unsaid. + const reason = tail.closureReason && tail.closureReason !== "no-follow-on" ? ` (${tail.closureReason})` : ""; + const how = tail.state === "done" ? `done${reason}` : `as ${tail.state}${reason}`; + return { kind: "ended", tail, reply: `${tail.closer ?? tail.destinationSession} closed ${tail.qitemId} ${how}.\n\n${tail.resolution?.trim() || "(No resolution was written.)"}` }; + } + } +} + export function serveAcpAgent(peer: JsonRpcPeer, options: AcpAgentOptions): void { const sessions = new Map(); const sleep = options.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); @@ -124,8 +228,55 @@ export function serveAcpAgent(peer: JsonRpcPeer, options: AcpAgentOptions): void return result; }; - const update = (sessionId: string, body: Record) => peer.notify("session/update", { sessionId, update: body }); + const update = (sessionId: string, body: Update) => peer.notify("session/update", { sessionId, update: body }); const say = (sessionId: string, text: string) => update(sessionId, { sessionUpdate: "agent_message_chunk", content: { type: "text", text } }); + const newSessionId = () => `acps-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(4).toString("hex")}`; + + /** Each of the caller's prompts in a session, oldest first, by the rows tagged with its id. */ + const sessionRoots = async (team: TeamConnection, sessionId: string) => + (await team.queue.sent(team.caller)).filter((row) => row.tags.includes(`${SESSION_TAG}${sessionId}`)) + .sort((a, b) => a.tsCreated.localeCompare(b.tsCreated)); + + /** Put one ask's open questions to the person as permission requests, one at a time, and record each choice. A reply + * that arrives after the turn ended answers nothing here: the question stays open in the TUI and Slack. */ + const askPerson = async (team: TeamConnection, sessionId: string, ask: TeamAsk, turn: TurnQuestions) => { + const emit = (body: Update) => { if (!turn.finished) update(sessionId, body); }; + if (ask.questions.length === 0) { + emit({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: `\n\n${ask.sourceSession} asks you: ${ask.summary ?? oneLine(ask.body, 200)} (${ask.qitemId}). It wants an answer in words, which this app can't send: answer it in the OpenRig TUI or Slack.\n\n` } }); + return; + } + for (const q of ask.questions.filter((question) => !ask.answered.includes(question.id))) { + if (turn.finished) return; + const toolCallId = `${ask.qitemId}#${q.id}`; + emit({ sessionUpdate: "tool_call", toolCallId, kind: "other", status: "pending", title: `${ask.sourceSession} asks: ${q.question}`, content: textContent(ask.body) }); + let outcome: { outcome?: string; optionId?: string } | undefined; + turn.outstanding.add(toolCallId); + try { + const result = (await peer.request("session/request_permission", { + sessionId, + toolCall: { toolCallId, title: `${ask.sourceSession} asks: ${q.question}`, kind: "other", status: "pending" }, + options: q.options.map((o) => ({ optionId: o.id, name: o.recommended ? `${o.label} (recommended)` : o.label, kind: "allow_once" })), + })) as { outcome?: { outcome?: string; optionId?: string } } | undefined; + outcome = result?.outcome; + } catch (error) { + emit({ sessionUpdate: "tool_call_update", toolCallId, content: textContent(`This app couldn't show the question (${(error as Error).message}). It's still open: answer it in the OpenRig TUI or Slack.`) }); + return; + } finally { + turn.outstanding.delete(toolCallId); + } + if (turn.finished) return; + const chosen = outcome?.outcome === "selected" ? q.options.find((o) => o.id === outcome?.optionId) : undefined; + if (!chosen) { + emit({ sessionUpdate: "tool_call_update", toolCallId, content: textContent("Not answered here. The question is still open in the OpenRig TUI and Slack.") }); + return; + } + const recorded = await team.queue.answer(ask.qitemId, q.id, chosen.id).catch((error: Error) => ({ ok: false as const, reason: error.message })); + emit(recorded.ok + ? { sessionUpdate: "tool_call_update", toolCallId, status: "completed", content: textContent(`You answered: ${chosen.label}. ${ask.sourceSession} gets it as your answer.`) } + : { sessionUpdate: "tool_call_update", toolCallId, status: "failed", content: textContent(`Your choice (${chosen.label}) wasn't recorded: ${recorded.reason}`) }); + log(`session ${sessionId}: ${recorded.ok ? "answered" : "couldn't answer"} ${ask.qitemId} ${q.id}`); + } + }; peer.onRequest("initialize", async (params) => { const p = (params ?? {}) as { clientInfo?: { name?: string; title?: string } }; @@ -133,9 +284,10 @@ export function serveAcpAgent(peer: JsonRpcPeer, options: AcpAgentOptions): void return { protocolVersion: ACP_PROTOCOL_VERSION, agentCapabilities: { - loadSession: false, + loadSession: true, promptCapabilities: { image: false, audio: false, embeddedContext: true }, mcpCapabilities: { http: false, sse: false }, + sessionCapabilities: { list: {} }, }, authMethods: [{ id: AUTH_METHOD_ID, @@ -154,13 +306,50 @@ export function serveAcpAgent(peer: JsonRpcPeer, options: AcpAgentOptions): void return {}; }); - peer.onRequest("session/new", async () => { + peer.onRequest("session/new", async (params) => { await connected(); - const id = `acps-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(4).toString("hex")}`; - sessions.set(id, { id, sent: [] }); + const id = newSessionId(); + sessions.set(id, { id, cwd: String((params as { cwd?: unknown } | undefined)?.cwd ?? ""), sent: [] }); return { sessionId: id }; }); + peer.onRequest("session/list", async (params) => { + const team = await connected(); + const cwd = (params as { cwd?: unknown } | undefined)?.cwd; + const byId = new Map(); + for (const row of (await team.queue.sent(team.caller)).sort((a, b) => a.tsCreated.localeCompare(b.tsCreated))) { + const sessionId = row.tags.find((t) => t.startsWith(SESSION_TAG))?.slice(SESSION_TAG.length); + if (!sessionId) continue; + const known = byId.get(sessionId); + if (known) { if (row.tsUpdated > known.updatedAt) known.updatedAt = row.tsUpdated; continue; } + byId.set(sessionId, { + sessionId, cwd: row.tags.find((t) => t.startsWith(CWD_TAG))?.slice(CWD_TAG.length) ?? "", + title: oneLine(row.body.split(FOOTER)[0] ?? "", 100), updatedAt: row.tsUpdated, + }); + } + const listed = [...byId.values()].filter((s) => typeof cwd !== "string" || s.cwd === cwd).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + return { sessions: listed }; + }); + + peer.onRequest("session/load", async (params) => { + const p = (params ?? {}) as { sessionId?: string; cwd?: string }; + const team = await connected(); + const sessionId = String(p.sessionId ?? ""); + const roots = await sessionRoots(team, sessionId); + if (roots.length === 0) throw new AcpError(INVALID_PARAMS, `No session ${sessionId} from ${team.caller} on this machine`); + const session: Session = { id: sessionId, cwd: String(p.cwd ?? ""), sent: [] }; + // Replay: each prompt as the caller wrote it, then its work as it stands now. + for (const row of roots) { + update(sessionId, { sessionUpdate: "user_message_chunk", content: { type: "text", text: row.body.split(FOOTER)[0] ?? "" } }); + const follower = new ChainFollower(team.queue, (u) => update(sessionId, u), row.qitemId); + const state = await follower.read(); + session.sent.push({ root: row.qitemId, tail: state.tail.qitemId }); + say(sessionId, state.kind === "ended" ? state.reply : `${state.tail.qitemId} is still ${state.tail.state} with ${state.tail.destinationSession}.`); + } + sessions.set(sessionId, session); + return {}; + }); + peer.onNotification("session/cancel", (params) => { const sessionId = (params as { sessionId?: string } | undefined)?.sessionId; if (sessionId) sessions.get(sessionId)?.turn?.cancel(); @@ -169,7 +358,7 @@ export function serveAcpAgent(peer: JsonRpcPeer, options: AcpAgentOptions): void peer.onRequest("session/prompt", async (params) => { const p = (params ?? {}) as { sessionId?: string; prompt?: ContentBlock[] }; const session = p.sessionId ? sessions.get(p.sessionId) : undefined; - if (!session) throw new AcpError(INVALID_PARAMS, `No session ${String(p.sessionId)} in this agent; start one with session/new`); + if (!session) throw new AcpError(INVALID_PARAMS, `No session ${String(p.sessionId)} in this agent; start one with session/new or session/load`); if (session.turn) throw new AcpError(INVALID_PARAMS, "This session already has a turn running; wait for it or cancel it first"); const team = await connected(); const text = promptText(Array.isArray(p.prompt) ? p.prompt : []); @@ -184,6 +373,7 @@ export function serveAcpAgent(peer: JsonRpcPeer, options: AcpAgentOptions): void let cancelled = false; let wake: () => void = () => {}; + const questions: TurnQuestions = { finished: false, outstanding: new Set(), tasks: new Set() }; session.turn = { cancel: () => { cancelled = true; wake(); } }; let root: string | undefined; try { @@ -195,11 +385,17 @@ export function serveAcpAgent(peer: JsonRpcPeer, options: AcpAgentOptions): void const open = earlier.filter((s) => s.row && ACTIVE_STATES.has(s.row.state)); if (open.length > 0) say(session.id, `${open.map((s) => `${s.row!.qitemId} is still ${s.row!.state} with ${s.row!.destinationSession}`).join("; ")}. Sending this as a follow-up.\n\n`); const lineage = earlier.length === 0 ? "" : `\nFollows up on ${earlier.map((s) => `${s.root} (${s.tail === s.root ? "" : `reached ${s.tail}, `}${s.state})`).join(", ")}.`; - const body = `${text}\n\n---\nSent by ${team.caller} from ${clientName} through rig acp serve (session ${session.id}).${lineage}\n` + - `The app shows this item's notes and handoffs as progress, and its closing resolution as the answer: close it with a resolution written for ${team.caller}.`; + const body = `${text}${FOOTER}${team.caller} from ${clientName} through rig acp serve (session ${session.id}).${lineage}\n` + + `The app shows this item's notes and handoffs as progress, and its closing resolution as the answer: close it with a resolution written for ${team.caller}. ` + + `A question with choices you ask ${team.caller} while this is open, tagged for this chat, appears in the app as a choice: ` + + `rig queue create --destination ${team.caller} --human-questions-file … --tags ${SESSION_TAG}${session.id}.`; + const turnStarted = new Date().toISOString(); const qitemId = newQitemId(); try { - await team.queue.create({ qitemId, destination: team.receiver, body, summary: `ACP: ${oneLine(text, 100)}`, tags: ["acp-serve", `acp-session:${session.id}`] }); + await team.queue.create({ + qitemId, destination: team.receiver, body, summary: `ACP: ${oneLine(text, 100)}`, + tags: ["acp-serve", `${SESSION_TAG}${session.id}`, ...(session.cwd ? [`${CWD_TAG}${session.cwd}`] : [])], + }); } catch (error) { const reason = (error as Error).message; // A refusal the daemon answered with wrote nothing, so it can be said plainly. @@ -207,70 +403,57 @@ export function serveAcpAgent(peer: JsonRpcPeer, options: AcpAgentOptions): void // No answer: the request may still land after any readback, so only a row that reads back settles it. const landed = await team.queue.find(qitemId).catch(() => undefined); if (!landed) { - session.sent.push({ root: qitemId, tail: qitemId, closed: false }); + session.sent.push({ root: qitemId, tail: qitemId }); say(session.id, `Sending ${qitemId} to ${team.receiver} got no answer (${reason}), and ${landed === null ? "it wasn't found on the immediate readback" : "it couldn't be read back"}: delivery is unconfirmed, so it may still reach the team. A new prompt in this session names it, and \`rig queue show ${qitemId}\` shows it if it landed.`); throw new AcpError(-32603, `Couldn't confirm ${qitemId}: ${reason}`); } log(`session ${session.id}: ${qitemId}'s create got no answer, but it reads back`); } root = qitemId; - const sent = { root, tail: root, closed: false }; + const sent = { root, tail: root }; session.sent.push(sent); log(`session ${session.id}: sent ${root} to ${team.receiver}`); - // ponytail: polls the local daemon for the chain's rows and transitions; the daemon's event stream is the upgrade - // path if a poll per open turn ever costs too much. - const chain: string[] = [root]; - const seen = new Map(); + // ponytail: polls the local daemon for the chain's rows, their transitions and the caller's open asks; the + // daemon's event stream is the upgrade path if a poll per open turn ever costs too much. + const follower = new ChainFollower(team.queue, (u) => update(session.id, u), root); + const seen = new Set(); + /** Close this turn's questions: any the app still shows are named as open elsewhere, then nothing more comes from them. */ + const finishQuestions = (why: string) => { + for (const toolCallId of questions.outstanding) { + update(session.id, { sessionUpdate: "tool_call_update", toolCallId, content: textContent(`${why} The question is still open in the OpenRig TUI and Slack.`) }); + } + questions.finished = true; + }; for (;;) { - let tail!: TeamRow; - for (const qitemId of chain) { - const row = await team.queue.show(qitemId); - const transitions = await team.queue.transitions(qitemId); - tail = row; - const status = toolStatus(row.state); - let entry = seen.get(qitemId); - if (!entry) { - entry = { lines: [], lastId: 0, status }; - seen.set(qitemId, entry); - update(session.id, { - sessionUpdate: "tool_call", toolCallId: qitemId, kind: "other", status, - title: qitemId === root ? `Sent to ${row.destinationSession} (${qitemId})` : `Handed to ${row.destinationSession} (${qitemId})`, - rawInput: { qitemId, destination: row.destinationSession }, - }); - } - const fresh = transitions.filter((t) => t.transitionId > entry!.lastId); - if (fresh.length > 0 || status !== entry.status) { - for (const t of fresh) { entry.lines.push(transitionLine(t)); entry.lastId = Math.max(entry.lastId, t.transitionId); } - entry.status = status; - update(session.id, { - sessionUpdate: "tool_call_update", toolCallId: qitemId, status, - ...(entry.lines.length > 0 ? { content: [{ type: "content", content: { type: "text", text: entry.lines.join("\n") } }] } : {}), - }); - } - } - - sent.tail = tail.qitemId; - // Both handoff forms pass the work on: `handoff` leaves the row handed-off, `handoff-and-complete` closes it done - // with closure reason handed_off_to. Either way the answer is the successor's, not this row's. - if (tail.state === "handed-off" || (tail.state === "done" && tail.closureReason === "handed_off_to")) { - const next = await team.queue.successor(tail.qitemId); - if (next) { chain.push(next); continue; } - sent.closed = true; - say(session.id, `${tail.qitemId} was handed to ${tail.handedOffTo ?? tail.closureTarget ?? "another seat"}, whose row isn't on this machine, so this app can't follow it further. Ask your team for its result.`); + const state = await follower.read(); + sent.tail = state.tail.qitemId; + if (state.kind === "ended") { + finishQuestions("The work closed before this was answered here."); + say(session.id, state.reply); return { stopReason: "end_turn" }; } - if (!ACTIVE_STATES.has(tail.state)) { - sent.closed = true; - const who = tail.closer ?? tail.destinationSession; - // A done row's closure reason says how it ended (canceled is a done close too); the ordinary one goes unsaid. - const reason = tail.closureReason && tail.closureReason !== "no-follow-on" ? ` (${tail.closureReason})` : ""; - const how = tail.state === "done" ? `done${reason}` : `as ${tail.state}${reason}`; - say(session.id, `${who} closed ${tail.qitemId} ${how}.\n\n${tail.resolution?.trim() || "(No resolution was written.)"}`); - return { stopReason: "end_turn" }; + for (const ask of await team.queue.openAsks(team.caller)) { + if (seen.has(ask.qitemId)) continue; + // An ask tagged for a chat belongs to that chat alone. An untagged one from a seat this work reached might be + // another chat's, so it isn't asked here; it's named, with where to answer it. + const chats = ask.tags.filter((tag) => tag.startsWith(SESSION_TAG)); + if (chats.includes(`${SESSION_TAG}${session.id}`)) { + seen.add(ask.qitemId); + const task: Promise = askPerson(team, session.id, ask, questions) + .catch((error: Error) => log(`session ${session.id}: asking about ${ask.qitemId} failed: ${error.message}`)) + .finally(() => questions.tasks.delete(task)); + questions.tasks.add(task); + } else if (chats.length === 0 && follower.seats.has(ask.sourceSession) && ask.tsCreated >= turnStarted) { + seen.add(ask.qitemId); + say(session.id, `\n\n${ask.sourceSession}, which this work reached, has a question for you that isn't tagged for this chat (${ask.qitemId}: ${ask.summary ?? oneLine(ask.body, 120)}). Answer it in the OpenRig TUI or Slack.\n\n`); + } } if (cancelled) { - say(session.id, `Stopped waiting. ${tail.qitemId} is still ${tail.state} with ${tail.destinationSession}: cancelling here doesn't stop the team's work. A new prompt in this session goes to the team as a follow-up and shows where this stands.`); + // ACP has the app answer the questions it was showing as cancelled; let those answers land before the turn ends. + if (questions.tasks.size > 0) await Promise.race([Promise.allSettled([...questions.tasks]), sleep(QUESTION_SETTLE_MS)]); + finishQuestions("Not answered here: the turn was cancelled."); + say(session.id, `Stopped waiting. ${state.tail.qitemId} is still ${state.tail.state} with ${state.tail.destinationSession}: cancelling here doesn't stop the team's work. A new prompt in this session goes to the team as a follow-up and shows where this stands.`); return { stopReason: "cancelled" }; } await Promise.race([sleep(options.pollMs), new Promise((resolve) => { wake = resolve; })]); @@ -280,6 +463,7 @@ export function serveAcpAgent(peer: JsonRpcPeer, options: AcpAgentOptions): void say(session.id, `Lost track of ${root}: ${(error as Error).message}. The item stays with the team; its state is unknown from here.`); throw new AcpError(-32603, `Couldn't follow ${root}: ${(error as Error).message}`); } finally { + questions.finished = true; session.turn = undefined; } }); diff --git a/packages/cli/src/commands/acp.ts b/packages/cli/src/commands/acp.ts index 662b45acf..648ff9bca 100644 --- a/packages/cli/src/commands/acp.ts +++ b/packages/cli/src/commands/acp.ts @@ -9,7 +9,7 @@ import { processIdentity } from "../local-daemon-owner.js"; import { getOpenRigHome, readOpenRigEnv } from "../openrig-compat.js"; import { answerCommand, runAcpAssignment, type AcpQueue } from "../acp/connector.js"; import { findRunProcesses } from "../acp/run-processes.js"; -import { serveAcpAgent, TeamRefused, type TeamConnection, type TeamQueue } from "../acp/agent-server.js"; +import { serveAcpAgent, TeamRefused, type TeamAsk, type TeamConnection, type TeamQueue } from "../acp/agent-server.js"; import { StreamJsonRpcPeer } from "../acp/json-rpc-stdio.js"; import { CLI_VERSION } from "../version.js"; import { realDeps } from "./daemon.js"; @@ -62,6 +62,10 @@ export function daemonTeamQueue(client: DaemonClient): TeamQueue { closer: text((row.closingTransition as { actorSession?: unknown } | null | undefined)?.actorSession), }; }; + const list = async (query: string, what: string): Promise>> => { + const data = ok(await client.get(`/api/queue/list?${query}`), what); + return (Array.isArray(data) ? data : ((data as { items?: unknown[] }).items ?? [])) as Array>; + }; return { async create(row) { const response = await client.post("/api/queue/create", { qitemId: row.qitemId, destinationSession: row.destination, body: row.body, summary: row.summary, tags: row.tags }); @@ -85,9 +89,33 @@ export function daemonTeamQueue(client: DaemonClient): TeamQueue { })); }, async successor(qitemId) { - const data = ok(await client.get(`/api/queue/list?handedOffFrom=${encodeURIComponent(qitemId)}&limit=1`), `queue successor of ${qitemId}`); - const rows = (Array.isArray(data) ? data : ((data as { items?: unknown[] }).items ?? [])) as Array<{ qitemId?: string }>; - return rows[0]?.qitemId; + return (await list(`handedOffFrom=${encodeURIComponent(qitemId)}&limit=1`, `queue successor of ${qitemId}`))[0]?.qitemId as string | undefined; + }, + async openAsks(person) { + const rows = await list(`destinationSession=${encodeURIComponent(person)}&state=pending&limit=500`, `open asks to ${person}`); + return rows.filter((row) => row.humanIntent !== "update" && (Array.isArray(row.humanQuestions) || row.humanIntent === "decision")).map((row) => ({ + qitemId: String(row.qitemId), sourceSession: String(row.sourceSession), tsCreated: String(row.tsCreated), + summary: typeof row.summary === "string" ? row.summary : null, body: typeof row.body === "string" ? row.body : "", + tags: Array.isArray(row.tags) ? (row.tags as string[]) : [], + questions: Array.isArray(row.humanQuestions) ? (row.humanQuestions as TeamAsk["questions"]) : [], + answered: Object.keys((row.humanAnswers as Record | null) ?? {}), + })); + }, + async answer(qitemId, questionId, optionId) { + // The person's answer path, as the TUI records one: the daemon takes the actor from this client's caller. + const bearer = process.env.OPENRIG_AUTH_BEARER_TOKEN; + const response = await client.post>("/api/mission-control/action", { verb: "answer", qitemId, questionId, optionId }, + bearer ? { headers: { Authorization: `Bearer ${bearer}` } } : undefined); + if (response.status < 400) return { ok: true }; + const data = (response.data ?? {}) as { message?: unknown; error?: unknown }; + return { ok: false, reason: String(data.message ?? data.error ?? `HTTP ${response.status}`) }; + }, + async sent(caller) { + const rows = await list(`sourceSession=${encodeURIComponent(caller)}&limit=2000`, `rows from ${caller}`); + return rows.filter((row) => Array.isArray(row.tags) && (row.tags as string[]).includes("acp-serve")).map((row) => ({ + qitemId: String(row.qitemId), tsCreated: String(row.tsCreated), tsUpdated: String(row.tsUpdated), + summary: typeof row.summary === "string" ? row.summary : null, body: typeof row.body === "string" ? row.body : "", tags: row.tags as string[], + })); }, }; } @@ -205,7 +233,8 @@ Guide: rig context get reference/acp-agents.md`) .addHelpText("after", ` An ACP app launches this command and talks to it on stdin and stdout. Each prompt becomes a queue row from you to --to. The app shows the row's notes and handoffs as progress, and the closing resolution as the reply. A later -prompt in the same session goes to --to as a follow-up that names the earlier items. +prompt in the same session goes to --to as a follow-up that names the earlier items. A question the working seats +ask you meanwhile comes to the app as a choice, recorded as your answer. The app can list and reopen your sessions. The turn ends when the team closes the item, or when you cancel. Cancelling stops this process waiting; the team's work carries on. A turn that ended doesn't mean the work was accepted: the reply says how the item was closed. diff --git a/packages/cli/test/acp-serve.test.ts b/packages/cli/test/acp-serve.test.ts index 802eb849f..405bcab93 100644 --- a/packages/cli/test/acp-serve.test.ts +++ b/packages/cli/test/acp-serve.test.ts @@ -1,4 +1,4 @@ -// rig acp serve against the real queue: the daemon's queueRoutes and QueueRepository over a fully migrated test DB, on +// rig acp serve against the real queue: the daemon's queue and answer routes over a database with every migration, on // loopback, driven by a scripted ACP client over a stream pair. The team's moves go through the same HTTP routes a seat's // CLI uses, each as its own seat. import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; @@ -6,10 +6,15 @@ import { serve } from "@hono/node-server"; import { Hono } from "hono"; import { PassThrough } from "node:stream"; import type Database from "better-sqlite3"; -import { createFullTestDb } from "../../daemon/test/helpers/test-app.js"; +import { createDb } from "../../daemon/src/db/connection.js"; +import { migrate } from "../../daemon/src/db/migrate.js"; +import { ALL_MIGRATIONS } from "../../daemon/src/db/all-migrations.js"; import { EventBus } from "../../daemon/src/domain/event-bus.js"; import { QueueRepository } from "../../daemon/src/domain/queue-repository.js"; import { queueRoutes } from "../../daemon/src/routes/queue.js"; +import { missionControlRoutes } from "../../daemon/src/routes/mission-control.js"; +import { MissionControlWriteContract } from "../../daemon/src/domain/mission-control/mission-control-write-contract.js"; +import { MissionControlActionLog } from "../../daemon/src/domain/mission-control/mission-control-action-log.js"; import { DaemonClient } from "../src/client.js"; import { daemonTeamQueue } from "../src/commands/acp.js"; import { AUTH_METHOD_ID, serveAcpAgent, type AcpAgentOptions, type TeamConnection, type TeamQueue } from "../src/acp/agent-server.js"; @@ -27,12 +32,21 @@ const ambient = ["OPENRIG_URL", "RIGGED_URL", "OPENRIG_PORT", "OPENRIG_SESSION_N beforeAll(async () => { for (const [key] of ambient) delete process.env[key]; - db = createFullTestDb(); + db = createDb(); + migrate(db, ALL_MIGRATIONS); const bus = new EventBus(db); - const repo = new QueueRepository(db, bus, { validateRig: (session) => !session.endsWith("@gone-rig") }); + // The caller is a registered person, so the team's asks reach them and their answers record as theirs. + const registry = { ok: true as const, entities: [{ entityId: "human-ada", class: "human" as const, displayName: "Ada", address: CALLER, connectorBindings: [], + prefs: { deliveryClass: "B" as const, availability: "available" as const } }] }; + const repo = new QueueRepository(db, bus, { validateRig: (session) => !session.endsWith("@gone-rig"), loadHumanRegistry: () => registry as never }); + const writeContract = new MissionControlWriteContract({ db, eventBus: bus, queueRepo: repo, actionLog: new MissionControlActionLog(db) }); const app = new Hono(); - app.use("*", async (c, next) => { c.set("eventBus" as never, bus); c.set("queueRepo" as never, repo); await next(); }); + app.use("*", async (c, next) => { + c.set("eventBus" as never, bus); c.set("queueRepo" as never, repo); c.set("missionControlWriteContract" as never, writeContract as never); + await next(); + }); app.route("/api/queue", queueRoutes()); + app.route("/api/mission-control", missionControlRoutes()); await new Promise((resolve) => { server = serve({ fetch: app.fetch, hostname: "127.0.0.1", port: 0 }, (info: { port: number }) => { port = info.port; resolve(); }); }); }); @@ -55,10 +69,11 @@ function connect(overrides: Partial = {}, receiver: string | nu const team: TeamConnection = { queue: wrap(daemonTeamQueue(new DaemonClient(url(), { sessionName: CALLER }))), caller: CALLER, receiver: receiver ?? undefined }; const agent = new StreamJsonRpcPeer(toAgent, fromAgent); serveAcpAgent(agent, { connect: async () => team, version: "9.9.9-test", pollMs: 10, closed: agent.closed, ...overrides }); - const client = new StreamJsonRpcPeer(fromAgent, toAgent); + const inbound: Array> = []; + const client = new StreamJsonRpcPeer(fromAgent, toAgent, (direction, packet) => { if (direction === "in") inbound.push(packet as Record); }); const updates: Array> = []; client.onNotification("session/update", (params) => updates.push((params as { update: Record }).update)); - return { client, updates, out }; + return { client, updates, out, inbound }; } async function open(client: StreamJsonRpcPeer): Promise { @@ -95,7 +110,8 @@ describe("rig acp serve", () => { expect(init.authMethods).toEqual([expect.objectContaining({ id: AUTH_METHOD_ID, name: "OpenRig on this machine" })]); expect(init.authMethods[0].type).toBeUndefined(); expect(init.agentInfo).toEqual({ name: "openrig", title: "OpenRig", version: "9.9.9-test" }); - expect(init.agentCapabilities.loadSession).toBe(false); + expect(init.agentCapabilities.loadSession).toBe(true); + expect(init.agentCapabilities.sessionCapabilities).toEqual({ list: {} }); expect(connected).toBe(0); for (const line of out.join("").split("\n").filter(Boolean)) expect(JSON.parse(line).jsonrpc).toBe("2.0"); }); @@ -122,7 +138,7 @@ describe("rig acp serve", () => { expect(task.state).toBe("pending"); expect(task.body).toMatch(/^Please fix the login bug\nIt fails on Safari\.\n\n---\nSent by human-ada@external from Zed through rig acp serve \(session acps-/); expect(task.body).toContain("close it with a resolution written for human-ada@external"); - expect(JSON.parse(task.tags)).toEqual(["acp-serve", `acp-session:${sessionId}`]); + expect(JSON.parse(task.tags)).toEqual(["acp-serve", `acp-session:${sessionId}`, "acp-cwd:/tmp"]); await move(LEAD, `/api/queue/${task.qitem_id}/claim`, {}); await move(LEAD, `/api/queue/${task.qitem_id}/update`, { state: "in-progress", transitionNote: "reproduced on Safari 19" }); @@ -276,6 +292,170 @@ describe("rig acp serve", () => { expect(messages(updates).at(-1)).toContain("Add `--to `"); }); + it("a question tagged for this chat comes as a permission request and the choice is recorded as the caller's answer; an untagged one is only named", async () => { + const { client, updates } = connect({}, "asker@team"); + const asked: Array> = []; + client.onRequest("session/request_permission", async (params) => { + asked.push(params as Record); + return { outcome: { outcome: "selected", optionId: "b" } }; + }); + const sessionId = await open(client); + // Asked before this turn by a seat the work never reaches: not this turn's question. + await move("bystander@team", "/api/queue/create", { qitemId: "qitem-unrelated-ask", destinationSession: CALLER, body: "unrelated", summary: "Unrelated", nudge: false, + humanQuestions: [{ id: "q1", question: "Unrelated?", options: [{ id: "y", label: "Yes" }, { id: "n", label: "No" }] }] }); + const turn = client.request("session/prompt", prompt(sessionId, "Pick a database for the cache")); + const task = await until(() => rowsTo("asker@team")[0], "the work row"); + await move("asker@team", `/api/queue/${task.qitem_id}/claim`, {}); + // Untagged, from the seat working on it: it could be another chat's, so it's named here, never asked. + await move("asker@team", "/api/queue/create", { qitemId: "qitem-ask-untagged", destinationSession: CALLER, body: "Which region?", summary: "Region?", nudge: false, + humanQuestions: [{ id: "q1", question: "Which region?", options: [{ id: "eu", label: "EU" }, { id: "us", label: "US" }] }] }); + await move("asker@team", "/api/queue/create", { qitemId: "qitem-ask-db", destinationSession: CALLER, body: "Redis is faster; SQLite is already here.", summary: "Which store?", nudge: false, + tags: [`acp-session:${sessionId}`], humanQuestions: [{ id: "q1", question: "Which store?", options: [{ id: "a", label: "Redis" }, { id: "b", label: "SQLite", recommended: true }] }] }); + const answered = await until(() => { + const row = db.prepare("SELECT human_answers FROM queue_items WHERE qitem_id = 'qitem-ask-db'").get() as { human_answers: string | null }; + return row.human_answers ? JSON.parse(row.human_answers) : undefined; + }, "the recorded answer"); + expect(Object.keys(answered)).toEqual(["q1"]); + expect(JSON.stringify(answered)).toContain("b"); + await move("asker@team", `/api/queue/${task.qitem_id}/update`, { state: "done", closureReason: "no-follow-on", resolution: "SQLite, as you chose." }); + expect(await turn).toEqual({ stopReason: "end_turn" }); + + expect(asked).toHaveLength(1); + expect(asked[0]!.toolCall).toMatchObject({ toolCallId: "qitem-ask-db#q1", title: "asker@team asks: Which store?" }); + expect(asked[0]!.options).toEqual([{ optionId: "a", name: "Redis", kind: "allow_once" }, { optionId: "b", name: "SQLite (recommended)", kind: "allow_once" }]); + const result = updates.filter((u) => u.toolCallId === "qitem-ask-db#q1").at(-1)!; + expect(result.status).toBe("completed"); + expect((result.content as Array<{ content: { text: string } }>)[0]!.content.text).toBe("You answered: SQLite. asker@team gets it as your answer."); + // "asker@team gets it as your answer": the answer path's reply row, from the caller to the seat that asked. + const replies = db.prepare("SELECT body FROM queue_items WHERE source_session = ? AND destination_session = 'asker@team' AND reply_to = 'qitem-ask-db'").all(CALLER) as Array<{ body: string }>; + expect(replies).toHaveLength(1); + expect(replies[0]!.body).toContain("SQLite"); + const unrelated = db.prepare("SELECT state, human_answers FROM queue_items WHERE qitem_id = 'qitem-unrelated-ask'").get() as { state: string; human_answers: string | null }; + expect(unrelated).toEqual({ state: "pending", human_answers: null }); + expect(messages(updates)).toContain("\n\nasker@team, which this work reached, has a question for you that isn't tagged for this chat (qitem-ask-untagged: Region?). Answer it in the OpenRig TUI or Slack.\n\n"); + expect(db.prepare("SELECT human_answers FROM queue_items WHERE qitem_id = 'qitem-ask-untagged'").get()).toEqual({ human_answers: null }); + expect(task.body).toContain(`--tags acp-session:${sessionId}`); + }); + + it("a question tagged for one chat is asked in that chat only, though another chat's work reached the same seat", async () => { + const chatA = connect({}, "shared@team"); + const chatB = connect({}, "shared@team"); + const askedIn: Record = { a: [], b: [] }; + chatA.client.onRequest("session/request_permission", async (params) => { askedIn.a.push((params as any).toolCall.toolCallId); return { outcome: { outcome: "cancelled" } }; }); + chatB.client.onRequest("session/request_permission", async (params) => { askedIn.b.push((params as any).toolCall.toolCallId); return { outcome: { outcome: "selected", optionId: "y" } }; }); + const a = await open(chatA.client); + const b = await open(chatB.client); + const turnA = chatA.client.request("session/prompt", prompt(a, "Chat A's task")); + const turnB = chatB.client.request("session/prompt", prompt(b, "Chat B's task")); + const [rowA, rowB] = await until(() => { const rows = rowsTo("shared@team"); return rows.length === 2 ? rows : undefined; }, "both rows"); + await move("shared@team", "/api/queue/create", { qitemId: "qitem-ask-for-b", destinationSession: CALLER, body: "For B only", summary: "B?", nudge: false, + tags: [`acp-session:${b}`], humanQuestions: [{ id: "q1", question: "Ship B?", options: [{ id: "y", label: "Yes" }, { id: "n", label: "No" }] }] }); + await until(() => (askedIn.b.length > 0 ? true : undefined), "B's question"); + for (const row of [rowA!, rowB!]) { + await move("shared@team", `/api/queue/${row.qitem_id}/claim`, {}); + await move("shared@team", `/api/queue/${row.qitem_id}/update`, { state: "done", closureReason: "no-follow-on", resolution: "Done." }); + } + await Promise.all([turnA, turnB]); + expect(askedIn).toEqual({ a: [], b: ["qitem-ask-for-b#q1"] }); + expect(chatA.updates.some((u) => String(u.toolCallId ?? "").startsWith("qitem-ask-for-b"))).toBe(false); + expect(messages(chatA.updates).some((m) => m.includes("qitem-ask-for-b"))).toBe(false); + }); + + it("cancel waits for the app to settle the question it's showing, and nothing about it follows the cancelled answer", async () => { + const { client, updates, inbound } = connect({}, "asker3@team"); + let answerCancelled!: () => void; + client.onRequest("session/request_permission", () => new Promise((resolve) => { + answerCancelled = () => setTimeout(() => resolve({ outcome: { outcome: "cancelled" } }), 50); + })); + const sessionId = await open(client); + const turn = client.request("session/prompt", prompt(sessionId, "Choose a theme")); + await until(() => rowsTo("asker3@team")[0], "the work row"); + await move("asker3@team", "/api/queue/create", { qitemId: "qitem-ask-theme", destinationSession: CALLER, body: "Light or dark", summary: "Theme?", nudge: false, + tags: [`acp-session:${sessionId}`], humanQuestions: [{ id: "q1", question: "Which theme?", options: [{ id: "l", label: "Light" }, { id: "d", label: "Dark" }] }] }); + await until(() => (answerCancelled ? true : undefined), "the question on screen"); + // As ACP has it: the app sends the cancel, then answers its open question as cancelled. + client.notify("session/cancel", { sessionId }); + answerCancelled(); + expect(await turn).toEqual({ stopReason: "cancelled" }); + await new Promise((resolve) => setTimeout(resolve, 100)); + const end = inbound.findIndex((p) => p.result?.stopReason === "cancelled"); + const about = inbound.map((p, i) => ({ p, i })).filter(({ p }) => p.params?.update?.toolCallId === "qitem-ask-theme#q1"); + expect(about.at(-1)!.i).toBeLessThan(end); + expect(about.at(-1)!.p.params.update.content[0].content.text).toBe("Not answered here. The question is still open in the OpenRig TUI and Slack."); + expect(updates.filter((u) => u.toolCallId === "qitem-ask-theme#q1")).toHaveLength(2); + expect(db.prepare("SELECT human_answers FROM queue_items WHERE qitem_id = 'qitem-ask-theme'").get()).toEqual({ human_answers: null }); + }); + + it("when the work closes with a question still on screen, the app is told before the turn ends, and a later reply answers nothing", async () => { + const { client, inbound } = connect({}, "asker4@team"); + let answerLate!: () => void; + client.onRequest("session/request_permission", () => new Promise((resolve) => { answerLate = () => resolve({ outcome: { outcome: "selected", optionId: "x" } }); })); + const sessionId = await open(client); + const turn = client.request("session/prompt", prompt(sessionId, "Plan the offsite")); + const task = await until(() => rowsTo("asker4@team")[0], "the work row"); + await move("asker4@team", "/api/queue/create", { qitemId: "qitem-ask-venue", destinationSession: CALLER, body: "Two venues", summary: "Venue?", nudge: false, + tags: [`acp-session:${sessionId}`], humanQuestions: [{ id: "q1", question: "Which venue?", options: [{ id: "x", label: "Lisbon" }, { id: "z", label: "Oslo" }] }] }); + await until(() => (answerLate ? true : undefined), "the question on screen"); + await move("asker4@team", `/api/queue/${task.qitem_id}/claim`, {}); + await move("asker4@team", `/api/queue/${task.qitem_id}/update`, { state: "done", closureReason: "no-follow-on", resolution: "Booked without it." }); + expect(await turn).toEqual({ stopReason: "end_turn" }); + answerLate(); + await new Promise((resolve) => setTimeout(resolve, 150)); + const end = inbound.findIndex((p) => p.result?.stopReason === "end_turn"); + const about = inbound.map((p, i) => ({ p, i })).filter(({ p }) => p.params?.update?.toolCallId === "qitem-ask-venue#q1"); + expect(about.at(-1)!.i).toBeLessThan(end); + expect(about.at(-1)!.p.params.update.content[0].content.text).toBe("The work closed before this was answered here. The question is still open in the OpenRig TUI and Slack."); + expect(db.prepare("SELECT human_answers FROM queue_items WHERE qitem_id = 'qitem-ask-venue'").get()).toEqual({ human_answers: null }); + }); + + it("a question the caller dismisses stays open and unanswered", async () => { + const { client, updates } = connect({}, "asker2@team"); + client.onRequest("session/request_permission", async () => ({ outcome: { outcome: "cancelled" } })); + const sessionId = await open(client); + const turn = client.request("session/prompt", prompt(sessionId, "Rename the service")); + const task = await until(() => rowsTo("asker2@team")[0], "the work row"); + await move("asker2@team", "/api/queue/create", { qitemId: "qitem-ask-name", destinationSession: CALLER, body: "Two names", summary: "Name?", nudge: false, + tags: [`acp-session:${sessionId}`], humanQuestions: [{ id: "q1", question: "Which name?", options: [{ id: "a", label: "Atlas" }, { id: "b", label: "Beacon" }] }] }); + await until(() => updates.find((u) => u.toolCallId === "qitem-ask-name#q1" && u.sessionUpdate === "tool_call_update"), "the dismissal"); + await move("asker2@team", `/api/queue/${task.qitem_id}/claim`, {}); + await move("asker2@team", `/api/queue/${task.qitem_id}/update`, { state: "done", closureReason: "no-follow-on", resolution: "Kept the old name." }); + expect(await turn).toEqual({ stopReason: "end_turn" }); + const dismissed = updates.filter((u) => u.toolCallId === "qitem-ask-name#q1").at(-1)!; + expect(dismissed.status).toBeUndefined(); + expect((dismissed.content as Array<{ content: { text: string } }>)[0]!.content.text).toBe("Not answered here. The question is still open in the OpenRig TUI and Slack."); + expect(db.prepare("SELECT state, human_answers FROM queue_items WHERE qitem_id = 'qitem-ask-name'").get()).toEqual({ state: "pending", human_answers: null }); + }); + + it("a session is listed and reopened from its rows: the prompt, its work and the reply replay, and a follow-up continues it", async () => { + const first = connect({}, "keeper@team"); + const sessionId = await open(first.client); + const turn = first.client.request("session/prompt", prompt(sessionId, "Summarize yesterday's incidents")); + const task = await until(() => rowsTo("keeper@team")[0], "the row"); + await move("keeper@team", `/api/queue/${task.qitem_id}/claim`, {}); + await move("keeper@team", `/api/queue/${task.qitem_id}/update`, { state: "done", closureReason: "no-follow-on", resolution: "Two incidents, both closed." }); + await turn; + + // A new process, as after the app restarts. + const second = connect({}, "keeper@team"); + await second.client.request("initialize", { protocolVersion: 1, clientCapabilities: {} }); + const listed = (await second.client.request("session/list", {})) as { sessions: Array> }; + expect(listed.sessions.find((s) => s.sessionId === sessionId)).toMatchObject({ cwd: "/tmp", title: "Summarize yesterday's incidents" }); + expect(((await second.client.request("session/list", { cwd: "/elsewhere" })) as { sessions: unknown[] }).sessions.find((s) => (s as { sessionId: string }).sessionId === sessionId)).toBeUndefined(); + expect(await second.client.request("session/load", { sessionId, cwd: "/tmp", mcpServers: [] })).toEqual({}); + expect(second.updates.map((u) => u.sessionUpdate)).toEqual(["user_message_chunk", "tool_call", "tool_call_update", "agent_message_chunk"]); + expect((second.updates[0]!.content as { text: string }).text).toBe("Summarize yesterday's incidents"); + expect(messages(second.updates).at(-1)).toBe(`keeper@team closed ${task.qitem_id} done.\n\nTwo incidents, both closed.`); + + const followUp = second.client.request("session/prompt", prompt(sessionId, "And the week before?")); + const next = await until(() => rowsTo("keeper@team")[1], "the follow-up row"); + expect(next.body).toContain(`Follows up on ${task.qitem_id} (done).`); + expect(JSON.parse(next.tags)).toContain(`acp-session:${sessionId}`); + second.client.notify("session/cancel", { sessionId }); + expect(await followUp).toEqual({ stopReason: "cancelled" }); + const missing = await second.client.request("session/load", { sessionId: "acps-nope", cwd: "/tmp", mcpServers: [] }).then(() => undefined, (e: unknown) => e); + expect((missing as JsonRpcRequestError).error.code).toBe(-32602); + }); + it("an unknown method gets JSON-RPC's method-not-found", async () => { const { client } = connect(); const error = await client.request("session/fork", { sessionId: "x" }).then(() => undefined, (e: unknown) => e); From c26af88dc7df655f31e14985465d76292bfe9b37 Mon Sep 17 00:00:00 2001 From: NIKHIL Date: Sat, 7 Nov 2026 18:10:13 +0530 Subject: [PATCH 13/14] fix(transport): read Claude's own-row "Restart to update" banner as a status line (#2662) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE_STATUS_WARNINGS, the exact rows that may sit under Claude's mode footer, gains one alternative: "✔ Update installed · Restart to update", the wording Claude 2.1.319 prints on its own row in a narrow pane. The pattern stays anchored at both ends and still matches "/restart to apply" and "Restart to apply" exactly, so the footer search reaches the mode footer and the draft-aware reader, activity classification and send readiness read the prompt as before; a near miss still reads unknown. The versioned banner and the /rc row were already handled. Tests add the row to the warning-footer readiness matrix and an empty, typed and near-miss composer case. --- packages/daemon/src/domain/session-transport.ts | 5 +++-- .../test/claude-warning-footer-readiness.test.ts | 6 ++++-- packages/daemon/test/composer-input.test.ts | 15 +++++++++++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/daemon/src/domain/session-transport.ts b/packages/daemon/src/domain/session-transport.ts index a38c9fbeb..984e03f07 100644 --- a/packages/daemon/src/domain/session-transport.ts +++ b/packages/daemon/src/domain/session-transport.ts @@ -256,8 +256,9 @@ const CLAUDE_STATUS_WARNINGS = [ /^Update available! Run: \S.*$/, // The context hint, as Claude prints it; reported as the last line below the mode bar on one seat. /^new task\? \/clear to save \d+(?:\.\d+)?k tokens$/, - // After Claude installs an update: "/restart to apply" (a Claude seat, 2026-10-30) or "Restart to apply". - /^✔ Update installed · (?:\/restart|Restart) to apply$/, + // After Claude installs an update: "/restart to apply" (a Claude seat, 2026-10-30), "Restart to apply", or + // "Restart to update" on its own row (Claude 2.1.319, #2605). + /^✔ Update installed · (?:\/restart to apply|Restart to (?:apply|update))$/, ]; // Claude can draw noninteractive UI rows after its mode footer. They do not // replace the composer and should only be skipped when they match a known row. diff --git a/packages/daemon/test/claude-warning-footer-readiness.test.ts b/packages/daemon/test/claude-warning-footer-readiness.test.ts index 93068ccfd..5abf9560a 100644 --- a/packages/daemon/test/claude-warning-footer-readiness.test.ts +++ b/packages/daemon/test/claude-warning-footer-readiness.test.ts @@ -214,8 +214,10 @@ const clearHint = "new task? /clear to save 324.1k tokens"; // (2026-10-30) and "Restart to apply" in startup-submit-confirmation's screen. const installedSlash = "✔ Update installed · /restart to apply"; const installedWord = "✔ Update installed · Restart to apply"; +// Claude 2.1.319's wording, on its own row under the footer in a 49-column pane (#2605). +const installedUpdate = "✔ Update installed · Restart to update"; const warningSets = [[update], [focus], [weekly], [update, focus], [updateBanner], [clearHint], [update, updateBanner], - [installedSlash], [installedWord], [update, installedSlash]]; + [installedSlash], [installedWord], [installedUpdate], [update, installedSlash]]; // Native, from a Claude seat running a hook mid-turn (2026-10-05): the timer follows a hook label. const hookRow = "✽ Sketching… (running PostToolUse hook · 3m 12s · ↓ 7.9k tokens)"; function modePane(hint: string, trailers: string[], composer = "❯\u00a0", before = "● Ready.\n\n✻ Crunched for 2s") { @@ -286,7 +288,7 @@ describe("#808: Claude permission-mode footers below status warnings", () => { // The installed-update footer last, as a Claude seat showed it (2026-10-30): an empty prompt under it read unknown, // so managed compaction never saw the idle seat. Only the exact line is a status line; a near miss stays unknown. - it.each([installedSlash, installedWord])("reads an empty prompt under the installed-update footer as idle: %s", footer => { + it.each([installedSlash, installedWord, installedUpdate])("reads an empty prompt under the installed-update footer as idle: %s", footer => { const content = modePane("⏵⏵ auto mode on (shift+tab to cycle)", [footer]); for (const options of [{ runtime: "claude-code" }, {}]) { expect(classifyPaneActivity(content, options)).toMatchObject({ state: "agent_idle", reason: "idle_prompt", evidence: "❯" }); diff --git a/packages/daemon/test/composer-input.test.ts b/packages/daemon/test/composer-input.test.ts index 2539da5d7..90c80cc31 100644 --- a/packages/daemon/test/composer-input.test.ts +++ b/packages/daemon/test/composer-input.test.ts @@ -53,6 +53,21 @@ describe("cursor-bound composer input", () => { expect(inspectComposerInput(frame, "codex").state).toBe("unknown"); }); + // #2605: Claude 2.1.319 prints "✔ Update installed · Restart to update" on its own row under the footer in a narrow + // pane. The draft-aware guard read every idle prompt there as unknown and held each wake as draft_input_unknown. + it("reads the input under Claude's own-row Restart to update banner, and nothing like it (#2605)", () => { + const banner = (body: string, row = "✔ Update installed · Restart to update") => { + const frame = claude(body); + frame.screen = frame.screen.replace("? for shortcuts\n", `? for shortcuts\n${row}\n`); + return frame; + }; + expect(inspectComposerInput(banner(""), "claude-code").state).toBe("empty"); + expect(inspectComposerInput(banner("a real draft"), "claude-code").state).toBe("text"); + for (const nearMiss of ["✔ Update installed · Restart to update now", "Update installed · Restart to update", "✔ Update downloaded · Restart to update"]) { + expect(inspectComposerInput(banner("", nearMiss), "claude-code")).toMatchObject({ state: "unknown", unknownReason: "unrecognized_composer" }); + } + }); + it("keeps nested prompt examples inside the current draft, including when its head is clipped", () => { const body = "intro\n────────────────────\n❯ \n────────────────────\n? for shortcuts\n────────────────────\n❯ "; const snapshot = claude(body, { x: 4, y: 8 }); From 772fa5c5fb64ecbee931a7cfe8b27f3b8fd3f729 Mon Sep 17 00:00:00 2001 From: Mike Schwarz <171890339+mvschwarz@users.noreply.github.com> Date: Sat, 7 Nov 2026 04:44:19 -0800 Subject: [PATCH 14/14] feat(plugins): an openrig plugin for a person's own coding agent (#2647) Adds packages/daemon/assets/plugins/openrig beside openrig-core, shipped in the npm package. Its Claude Code manifest runs rig mcp serve --front-door with OPENRIG_SESSION_NAME from the plugin's seat setting, the app's address on the person's team as a hosted member; there is no root .mcp.json and no hooks. A marketplace manifest named openrig-app installs it from the package already on the machine, and a Codex manifest connects it with one command. One new skill, working-with-an-openrig-team, describes the loop around the existing MCP tools; its home is the spec copy, and mirror-skills generates the canonical and plugin copies and routes it in the skills index. rig mcp serve --front-door gives the plugin's seat front-door instructions (the person's questions and words, with no member work-loop authority), and rig_whoami answers a declared hosted seat with no session when its rig exists; the default instruction modes and tool exposure are unchanged. --- packages/cli/src/commands/mcp.ts | 11 +- packages/cli/src/mcp-http.ts | 4 +- packages/cli/src/mcp-server.ts | 30 ++- packages/cli/test/mcp-server.test.ts | 45 +++- .../skills/openrig-skills/SKILL.md | 1 + .../openrig/.claude-plugin/marketplace.json | 15 ++ .../openrig/.claude-plugin/plugin.json | 32 +++ .../plugins/openrig/.codex-plugin/plugin.json | 23 ++ .../daemon/assets/plugins/openrig/LICENSE | 200 ++++++++++++++++++ .../daemon/assets/plugins/openrig/README.md | 64 ++++++ .../working-with-an-openrig-team/SKILL.md | 87 ++++++++ .../skills/core/openrig-skills/SKILL.md | 1 + .../working-with-an-openrig-team/SKILL.md | 87 ++++++++ scripts/mirror-skills.mjs | 7 +- scripts/mirror-skills.test.mjs | 25 +++ scripts/product-public-skills.generated.json | 3 +- scripts/skill-edge-digests.generated.json | 11 +- scripts/skill-edge-layout.generated.json | 12 ++ .../_canonical/core/openrig-skills/SKILL.md | 1 + .../working-with-an-openrig-team/SKILL.md | 87 ++++++++ 20 files changed, 726 insertions(+), 20 deletions(-) create mode 100644 packages/daemon/assets/plugins/openrig/.claude-plugin/marketplace.json create mode 100644 packages/daemon/assets/plugins/openrig/.claude-plugin/plugin.json create mode 100644 packages/daemon/assets/plugins/openrig/.codex-plugin/plugin.json create mode 100644 packages/daemon/assets/plugins/openrig/LICENSE create mode 100644 packages/daemon/assets/plugins/openrig/README.md create mode 100644 packages/daemon/assets/plugins/openrig/skills/working-with-an-openrig-team/SKILL.md create mode 100644 packages/daemon/specs/agents/shared/skills/core/working-with-an-openrig-team/SKILL.md create mode 100644 skills/_canonical/core/working-with-an-openrig-team/SKILL.md diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts index c37fafd02..8c8b24b32 100644 --- a/packages/cli/src/commands/mcp.ts +++ b/packages/cli/src/commands/mcp.ts @@ -28,6 +28,7 @@ export function mcpCommand(depsOverride?: StatusDeps): Command { .option("--task-destination ", "With --http --grant-service: operator-configured local team seat for external requests that omit destination") .option("--oauth-resource ", "With --grant-service and --oauth-issuer: the public https URL clients reach this server as (its path ends in /mcp), for OAuth discovery") .option("--oauth-issuer ", "With --oauth-resource: the OAuth authorization server that issues the keys --grant-service checks") + .option("--front-door", "Serve a person's agent in their own app: it hands the team work and brings the team's questions to the person, instead of working its inbox as a member") .addHelpText("after", ` Both MCP protocol revisions are served, over stdio and --http alike: clients on 2026-07-28 (which may start with server/discover) and clients that open with 2025's initialize handshake. @@ -57,7 +58,7 @@ server that issues the keys --grant-service checks. GET /.well-known/oauth-prote with both, exactly as given, without a credential or a call to the grant service, and every 401 points there. Both must be https with no userinfo, query or fragment, written as a URL writes them (a lowercase host, no default port). /mcp stays the endpoint; a proxy in front forwards the metadata path unchanged.`) - .action(async (opts: { port?: string; http?: boolean; bind?: string; grantService?: string; grantServiceHeaderFile?: string; taskDestination?: string; oauthResource?: string; oauthIssuer?: string }) => { + .action(async (opts: { port?: string; http?: boolean; bind?: string; grantService?: string; grantServiceHeaderFile?: string; taskDestination?: string; oauthResource?: string; oauthIssuer?: string; frontDoor?: boolean }) => { const deps = getDepsF(); let daemonUrl: string; @@ -105,7 +106,7 @@ stays the endpoint; a proxy in front forwards the metadata path unchanged.`) return; } if (opts.http) { - await serveHttp(daemonUrl, opts.bind ?? "127.0.0.1:7437", opts.grantService, opts.grantServiceHeaderFile, oauth, opts.taskDestination); + await serveHttp(daemonUrl, opts.bind ?? "127.0.0.1:7437", opts.grantService, opts.grantServiceHeaderFile, oauth, opts.taskDestination, opts.frontDoor === true); return; } const client = deps.clientFactory(daemonUrl); @@ -115,7 +116,7 @@ stays the endpoint; a proxy in front forwards the metadata path unchanged.`) process.once("SIGINT", finish); process.once("SIGTERM", finish); // Serves the protocol revision the client opens with: 2026-07-28 per request, or 2025's initialize handshake. - const served = serveStdio(() => createMcpServer(client), { onerror: (err) => console.error(`rig mcp serve: ${err.message}`) }); + const served = serveStdio(() => createMcpServer(client, { frontDoor: opts.frontDoor === true }), { onerror: (err) => console.error(`rig mcp serve: ${err.message}`) }); try { // EOF is a normal stdio disconnect, not an unresolved top-level await. if (process.stdin.readableEnded) finish(); @@ -182,7 +183,7 @@ stays the endpoint; a proxy in front forwards the metadata path unchanged.`) } async function serveHttp(daemonUrl: string, bind: string, grantService?: string, grantServiceHeaderFile?: string, - oauth?: { resource: string; issuer: string }, taskDestination?: string): Promise { + oauth?: { resource: string; issuer: string }, taskDestination?: string, frontDoor = false): Promise { const match = /^(\[[^\]]+\]|[^:]+):(\d+)$/.exec(bind); if (!match) { console.error(`--bind takes host:port (for example 127.0.0.1:7437 or [::1]:7437), not ${bind}`); @@ -194,7 +195,7 @@ async function serveHttp(daemonUrl: string, bind: string, grantService?: string, let served; try { const header = grantServiceHeaderFile ? readGrantServiceHeader(grantServiceHeaderFile) : undefined; - served = await startMcpHttpServer({ daemonUrl, host, port: Number(match[2]), tokenFile, taskDestination, + served = await startMcpHttpServer({ daemonUrl, host, port: Number(match[2]), tokenFile, taskDestination, frontDoor, ...(grantService ? { grantService: { url: grantService, ...(header ? { header } : {}) } } : {}), ...(oauth ? { oauth } : {}) }); } catch (err) { console.error(err instanceof McpHttpConfigError ? err.message : `Can't serve on ${bind}: ${(err as Error).message}`); diff --git a/packages/cli/src/mcp-http.ts b/packages/cli/src/mcp-http.ts index 465078dc6..3c6a7ba8e 100644 --- a/packages/cli/src/mcp-http.ts +++ b/packages/cli/src/mcp-http.ts @@ -312,6 +312,8 @@ export type McpHttpOptions = { grantService?: GrantServiceOptions; /** Operator-configured receiver for external callers omitting a task destination; never an authority grant. */ taskDestination?: string; + /** rig mcp serve --front-door: a caller that isn't external gets the front-door instructions, not the member workflow. */ + frontDoor?: boolean; /** With grantService: OAuth discovery for clients that sign in with OAuth. The public resource this server is reached * as and the authorization server that issues its keys, both served exactly as given. */ oauth?: { resource: string; issuer: string }; @@ -391,7 +393,7 @@ export async function startMcpHttpServer(opts: McpHttpOptions): Promise<{ url: s // Never a default seat: a request reaches the handler only with its caller decided. if (!caller) throw new Error("a request reached the MCP handler without its caller"); return createMcpServer(makeClient(opts.daemonUrl, caller.seat, caller.principal), { seat: caller.seat, access: caller.access, - taskDestination: opts.taskDestination, presenceFile: () => presenceFile }); + taskDestination: opts.taskDestination, presenceFile: () => presenceFile, frontDoor: opts.frontDoor === true }); }); const serveMcp = toNodeHandler(mcpHandler); const bearer = (req: http.IncomingMessage): string | undefined => /^Bearer\s+(\S+)\s*$/i.exec(req.headers.authorization ?? "")?.[1]; diff --git a/packages/cli/src/mcp-server.ts b/packages/cli/src/mcp-server.ts index 222639ead..0d22a4209 100644 --- a/packages/cli/src/mcp-server.ts +++ b/packages/cli/src/mcp-server.ts @@ -572,6 +572,17 @@ const SHARED_INSTRUCTIONS = [ "A question reserved for a person is theirs to answer: don't answer it on their behalf. rig_queue_decide records a decision only inside the decision room of the mode they have on, beside their answers and never as one.", ]; const INSTRUCTIONS = [...SHARED_INSTRUCTIONS, MEMBER_WORKFLOW].join("\n"); + +/** rig mcp serve --front-door: the seat is a person's agent in their own app, the front door to their team, not a member + * that works its inbox. The team's questions to it are the person's to answer. */ +export const FRONT_DOOR_WORKFLOW_VERSION = "front-door/v1"; +export const FRONT_DOOR_WORKFLOW = [ + `Front door (${FRONT_DOOR_WORKFLOW_VERSION}). This seat is a person's agent in their own app: you hand the team their work and bring back what it produced, and you aren't one of its members.`, + "Items addressed to your seat (rig_queue_list with owned set) are usually the team's questions about work you handed it. They're the person's to answer, not yours: show each to the person with the team's draft, and record only what the person tells you.", + "To record an answer, claim the item (rig_queue_claim), then close it done with closure reason no-follow-on and the person's words in resolution, saying they're the person's (rig_queue_update). If the person isn't there, leave the item open and tell them it's waiting. Never choose an answer for them, and don't claim an item you can't close with their words.", + "Nothing wakes this conversation when the team finishes: check your work and your items when the person asks or comes back.", +].join(" "); +const FRONT_DOOR_INSTRUCTIONS = [...SHARED_INSTRUCTIONS, FRONT_DOOR_WORKFLOW].join("\n"); const EXTERNAL_INSTRUCTIONS = [ "This connection sends requests to an OpenRig team as the caller named by its grant. The daemon attributes requests to that caller. Use only the tools this connection offers.", "To ask the connected team to do work, use rig_queue_create with the task in body. You may omit destination to use the receiver configured by the server operator. If no receiver is available, ask the operator to configure it; don't guess an address. An explicit destination still must be within this connection's grant.", @@ -584,8 +595,8 @@ const EXTERNAL_INBOX_INSTRUCTIONS = [...SHARED_INSTRUCTIONS, "Items addressed to your seat are yours: rig_queue_list with owned set lists them. For each, claim it (rig_queue_claim), do it, then close it done with closure reason no-follow-on and the answer in resolution (rig_queue_update); the sender reads that answer on the same item. A message is an item too: read it, close it done, and reply, when it needs one, with a new item to its sender. Nothing promises to wake you, so check your items again.", ].join("\n"); -function instructionsFor(access: McpAccess | undefined): string { - if (!access?.external) return INSTRUCTIONS; +function instructionsFor(access: McpAccess | undefined, frontDoor = false): string { + if (!access?.external) return frontDoor ? FRONT_DOOR_INSTRUCTIONS : INSTRUCTIONS; const tools = externalTools(access.external.objects); return tools.has("rig_queue_claim") && tools.has("rig_queue_update") ? EXTERNAL_INBOX_INSTRUCTIONS : EXTERNAL_INSTRUCTIONS; } @@ -656,11 +667,11 @@ function mapResult( * @param client - DaemonClient connected to the daemon * @returns McpServer instance (not yet connected to a transport) */ -export function createMcpServer(client: DaemonClient, options: { seat?: string; access?: McpAccess; taskDestination?: string; rostersFolder?: () => string; presenceFile?: () => string } = {}): McpServer { +export function createMcpServer(client: DaemonClient, options: { seat?: string; access?: McpAccess; taskDestination?: string; rostersFolder?: () => string; presenceFile?: () => string; frontDoor?: boolean } = {}): McpServer { const server = new McpServer({ name: "openrig", version: CLI_VERSION, - }, { instructions: instructionsFor(options.access) }); + }, { instructions: instructionsFor(options.access, options.frontDoor === true) }); const { access } = options; const rostersFolder = options.rostersFolder ?? (() => nodePath.join(new ConfigStore().get("workspace.root") as string, "rosters")); const presenceFile = options.presenceFile ?? (() => mcpPresenceFilePath()); @@ -1541,6 +1552,17 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string; message: "This seat has no session here: it's the seat this connection's grant names, such as a hosted member's. rig_team shows its rig and hosted members, and rig_queue_list with owned set lists its work." }; return { content: [{ type: "text" as const, text: JSON.stringify(member) }], structuredContent: member }; } + // A front door (--front-door) usually has no session either: its seat is the address the person picked for their + // app, declared in OPENRIG_SESSION_NAME. Answer with it when its rig exists here, still marked declared. + if (res.status === 404 && !access && options.frontDoor === true) { + const at = seat.indexOf("@"); + const rigName = at > 0 ? seat.slice(at + 1) : ""; + if (rigName && (await resolveRigHandle(client, rigName, { strict: true })).kind === "resolved") { + const frontDoor = { ok: true, seat, rig: rigName, session: null, caller, + message: "This seat has no session here: it's the front-door address this server was configured with (OPENRIG_SESSION_NAME), declared, not verified. rig_team shows its rig and lead, and rig_queue_list with owned set lists the team's questions to it." }; + return { content: [{ type: "text" as const, text: JSON.stringify(frontDoor) }], structuredContent: frontDoor }; + } + } if (res.status >= 400) return mapResult(res); return mapResult({ ...res, data: { ...(res.data as Record), caller } }, undefined, { structured: true }); } catch (err) { diff --git a/packages/cli/test/mcp-server.test.ts b/packages/cli/test/mcp-server.test.ts index a9eb33c58..0fbddaf60 100644 --- a/packages/cli/test/mcp-server.test.ts +++ b/packages/cli/test/mcp-server.test.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import { createMcpServer, MEMBER_WORKFLOW, MEMBER_WORKFLOW_VERSION, type McpAccess } from "../src/mcp-server.js"; +import { createMcpServer, FRONT_DOOR_WORKFLOW, FRONT_DOOR_WORKFLOW_VERSION, MEMBER_WORKFLOW, MEMBER_WORKFLOW_VERSION, type McpAccess } from "../src/mcp-server.js"; import { DaemonClient, DaemonConnectionError, type DaemonResponse } from "../src/client.js"; function mockClient(overrides?: { @@ -24,9 +24,9 @@ describe("MCP Server", () => { let daemonClient: DaemonClient; let cleanup: () => Promise; - async function setup(clientOverrides?: Parameters[0]) { + async function setup(clientOverrides?: Parameters[0], serverOptions?: Parameters[1]) { daemonClient = mockClient(clientOverrides); - const server = createMcpServer(daemonClient); + const server = createMcpServer(daemonClient, serverOptions); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); mcpClient = new Client({ name: "test-client", version: "1.0.0" }); @@ -327,6 +327,45 @@ describe("MCP Server", () => { } }); + it("a front door gets front-door instructions, not the member workflow, and its declared seat with no session reads as itself", async () => { + try { + vi.stubEnv("OPENRIG_SESSION_NAME", "claude-app@my-rig"); + vi.stubEnv("RIGGED_SESSION_NAME", ""); + vi.stubEnv("OPENRIG_TARGET_REPO", ""); + vi.stubEnv("RIGGED_TARGET_REPO", ""); + // A hosted address has no session, so whoami answers 404; the rig itself exists. + const getFn = vi.fn(async (p: string) => p.startsWith("/api/whoami") ? { status: 404, data: { error: "session not found" } } + : p.startsWith("/api/rigs/summary") ? { status: 200, data: [{ id: "rig-1", name: "my-rig", archivedAt: null }] } : { status: 404, data: {} }); + await setup({ get: getFn }, { frontDoor: true }); + const instructions = mcpClient.getInstructions() ?? ""; + expect(FRONT_DOOR_WORKFLOW).toContain(`(${FRONT_DOOR_WORKFLOW_VERSION})`); + expect(instructions).toContain(FRONT_DOOR_WORKFLOW); + // The person's harm first: nothing tells this agent to work its inbox without asking. + expect(instructions).not.toContain(MEMBER_WORKFLOW); + expect(instructions).not.toContain("without asking whether you may"); + for (const part of ["the person's to answer", "record only what the person tells you", "Never choose an answer for them"]) expect(FRONT_DOOR_WORKFLOW).toContain(part); + const front = await mcpClient.callTool({ name: "rig_whoami", arguments: {} }); + expect(front.isError).toBeFalsy(); + expect(front.structuredContent).toMatchObject({ ok: true, seat: "claude-app@my-rig", rig: "my-rig", session: null, + caller: { authenticated: false, identity: "declared" } }); + await cleanup(); + + // Without --front-door, the same server is a member: the member workflow, and whoami's 404 as today. + await setup({ get: getFn }); + expect(mcpClient.getInstructions() ?? "").toContain(MEMBER_WORKFLOW); + expect((await mcpClient.callTool({ name: "rig_whoami", arguments: {} })).isError).toBe(true); + await cleanup(); + + // A front door whose rig isn't here is a misconfiguration, still answered with the 404. + vi.stubEnv("OPENRIG_SESSION_NAME", "claude-app@no-such-rig"); + await setup({ get: getFn }, { frontDoor: true }); + expect((await mcpClient.callTool({ name: "rig_whoami", arguments: {} })).isError).toBe(true); + await cleanup(); + } finally { + vi.unstubAllEnvs(); + } + }); + it("the queue tools return structured content; every error a tool returns carries one shape and keeps its old text", async () => { // The queue's missing-item read answers with its code alone, as GET /api/queue/:qitemId does. const missing = { error: "qitem_not_found" }; diff --git a/packages/daemon/assets/plugins/openrig-core/skills/openrig-skills/SKILL.md b/packages/daemon/assets/plugins/openrig-core/skills/openrig-skills/SKILL.md index fbc191bf4..78e5bc06b 100644 --- a/packages/daemon/assets/plugins/openrig-core/skills/openrig-skills/SKILL.md +++ b/packages/daemon/assets/plugins/openrig-core/skills/openrig-skills/SKILL.md @@ -87,6 +87,7 @@ These ship in the OpenRig repo and reach a seat when its profile selects them. T - **watchdog** — monitoring or recovering a seat (health, restore, stuck state). - **session-source-fork** — forking a session's source/context. - **context-engineering** — designing context systems. Provisional and non-normative: current OpenRig skills, explicit rulings, and measured practice outrank it on any conflict. +- **working-with-an-openrig-team** — a person's own agent in an app (Claude Code, Codex, Cursor, VS Code) works with their team through `rig mcp serve`: hand work over, follow it, answer the team's questions, read results. The `openrig` app plugin loads it; a seat helping someone connect an app can open it with `rig context get skills/core/working-with-an-openrig-team/SKILL.md`. Pod handbooks (load when you're in that pod): - **orchestration-team** — you're orchestrating a rig: dispatching, monitoring, keeping the loop moving. diff --git a/packages/daemon/assets/plugins/openrig/.claude-plugin/marketplace.json b/packages/daemon/assets/plugins/openrig/.claude-plugin/marketplace.json new file mode 100644 index 000000000..792b805a3 --- /dev/null +++ b/packages/daemon/assets/plugins/openrig/.claude-plugin/marketplace.json @@ -0,0 +1,15 @@ +{ + "name": "openrig-app", + "description": "The OpenRig plugin, installed from your OpenRig package: work with your OpenRig team from this app.", + "owner": { + "name": "Mike Schwarz", + "url": "https://github.com/mvschwarz" + }, + "plugins": [ + { + "name": "openrig", + "source": "./", + "description": "Work with your OpenRig team from your coding agent: hand it work, follow that work, answer its questions and read what it returns." + } + ] +} diff --git a/packages/daemon/assets/plugins/openrig/.claude-plugin/plugin.json b/packages/daemon/assets/plugins/openrig/.claude-plugin/plugin.json new file mode 100644 index 000000000..ce0702c3c --- /dev/null +++ b/packages/daemon/assets/plugins/openrig/.claude-plugin/plugin.json @@ -0,0 +1,32 @@ +{ + "name": "openrig", + "version": "0.1.0", + "description": "Work with your OpenRig team from your coding agent: hand it work, follow that work, answer its questions and read what it returns.", + "author": { + "name": "Mike Schwarz", + "url": "https://github.com/mvschwarz" + }, + "homepage": "https://github.com/mvschwarz/openrig", + "repository": "github:mvschwarz/openrig", + "license": "Apache-2.0", + "keywords": ["openrig", "agents", "team", "delegation", "mcp"], + "skills": "./skills", + "mcpServers": { + "openrig": { + "command": "rig", + "args": ["mcp", "serve", "--front-door"], + "env": { + "OPENRIG_SESSION_NAME": "${user_config.seat}" + } + } + }, + "userConfig": { + "seat": { + "type": "string", + "title": "OpenRig seat", + "description": "This app's address on your OpenRig team, such as cursor@my-team. Your requests are recorded as coming from it, and the team's questions about them come back to it.", + "required": false, + "default": "" + } + } +} diff --git a/packages/daemon/assets/plugins/openrig/.codex-plugin/plugin.json b/packages/daemon/assets/plugins/openrig/.codex-plugin/plugin.json new file mode 100644 index 000000000..80a2f183b --- /dev/null +++ b/packages/daemon/assets/plugins/openrig/.codex-plugin/plugin.json @@ -0,0 +1,23 @@ +{ + "name": "openrig", + "version": "0.1.0", + "description": "Work with your OpenRig team from your coding agent: hand it work, follow that work, answer its questions and read what it returns.", + "author": { + "name": "Mike Schwarz", + "url": "https://github.com/mvschwarz" + }, + "homepage": "https://github.com/mvschwarz/openrig", + "repository": "github:mvschwarz/openrig", + "license": "Apache-2.0", + "keywords": ["openrig", "agents", "team", "delegation", "mcp"], + "skills": "./skills", + "interface": { + "displayName": "OpenRig", + "shortDescription": "Work with your OpenRig team", + "longDescription": "Hand your OpenRig team work from this agent, follow it, answer the team's questions and read what it returns, through OpenRig's MCP server. Tools and guidance only: it adds no panel, and nothing wakes a chat when the team finishes.", + "developerName": "Mike Schwarz", + "category": "Productivity", + "capabilities": ["Read"], + "websiteURL": "https://github.com/mvschwarz/openrig" + } +} diff --git a/packages/daemon/assets/plugins/openrig/LICENSE b/packages/daemon/assets/plugins/openrig/LICENSE new file mode 100644 index 000000000..ed767a2ae --- /dev/null +++ b/packages/daemon/assets/plugins/openrig/LICENSE @@ -0,0 +1,200 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for describing the origin of the Work and + reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Support. While redistributing the Work or Derivative Works thereof, + You may choose to offer, and charge a fee for, acceptance of support, + warranty, indemnity, or other liability obligations and/or rights + consistent with this License. However, in accepting such obligations, + You may act only on Your own behalf and on Your sole responsibility, + not on behalf of any other Contributor, and only if You agree to + indemnify, defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or support. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Mike Schwarz + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/daemon/assets/plugins/openrig/README.md b/packages/daemon/assets/plugins/openrig/README.md new file mode 100644 index 000000000..314176725 --- /dev/null +++ b/packages/daemon/assets/plugins/openrig/README.md @@ -0,0 +1,64 @@ +# openrig + +Work with your OpenRig team from the coding agent you already use. Your agent can hand the team work, follow that work, +bring you the team's questions and read what it returns, through OpenRig's MCP server. + +This plugin is for a person's own agent in an app such as Claude Code or Codex. The agents on an OpenRig team load +`openrig-core` instead. + +## What it ships + +- **The MCP server entry**, in the Claude Code manifest only: `rig mcp serve --front-door`, acting as the seat you set in + the plugin's settings. Claude Code substitutes that seat into the server's environment. Codex doesn't, so the plugin + ships no shared MCP file, and Codex gets the server from one command (below). +- **`--front-door`** tells your agent the team's questions to its seat are yours to answer: it shows them to you and + records only what you say, instead of working them itself as a team member would. +- **One skill,** `working-with-an-openrig-team`: the loop around the tools. It covers handing work to the team's lead, + following it along its handoffs, answering the team's questions with your own words, reading the result and coming + back to it later. +- **No hooks.** Nothing runs on your agent's prompts or sessions. + +## Before you install + +1. OpenRig is installed and runs a team on this machine: `rig ps` lists it. +2. Pick an address for this app on that team, such as `cursor@my-team`. The queue takes work for it; nothing is + launched. To tell the team what it's for, add it to a roster as a hosted member (`docs/reference/hosted-members.md`). + +## Install + +The plugin ships inside the OpenRig package, so you install it from there. Its marketplace is named `openrig-app`, because +OpenRig already uses `openrig` for the marketplace its own Codex seats load `openrig-core` from. + +The commands below use `$(npm root -g)`, npm's global folder. If you installed OpenRig with another prefix, use that +prefix's `lib/node_modules` instead. + +**Claude Code** + +```sh +claude plugin marketplace add "$(npm root -g)/@openrig/cli/daemon/assets/plugins/openrig" +claude plugin install openrig@openrig-app +``` + +Set the plugin's **OpenRig seat** to your address (`/plugin`, then configure): type the address into the row, then save. +Pressing Enter on the empty row closes it with no change. Then start a new session. + +**Codex** + +Install the plugin for its skill, then connect the MCP server with your address: + +```sh +codex plugin marketplace add "$(npm root -g)/@openrig/cli/daemon/assets/plugins/openrig" +codex plugin add openrig@openrig-app +codex mcp add openrig --env OPENRIG_SESSION_NAME=
-- rig mcp serve --front-door +``` + +- **A non-default OpenRig home:** Codex does not forward every shell environment variable to MCP servers by default. If + your team runs on a non-default OpenRig home, add `--env OPENRIG_HOME=` to the `codex mcp add` line. +- **Approvals:** Codex may ask you to approve tools according to its approval settings. Create, claim and update change + team state; tools that only read are marked read-only. + +## What it doesn't do + +- It adds no panel to the app, and nothing wakes your chat when the team finishes: ask your agent to check. +- Everyone using the app on this machine acts as the seat it names. +- It reaches a team on this machine. diff --git a/packages/daemon/assets/plugins/openrig/skills/working-with-an-openrig-team/SKILL.md b/packages/daemon/assets/plugins/openrig/skills/working-with-an-openrig-team/SKILL.md new file mode 100644 index 000000000..c05590edb --- /dev/null +++ b/packages/daemon/assets/plugins/openrig/skills/working-with-an-openrig-team/SKILL.md @@ -0,0 +1,87 @@ +--- +name: working-with-an-openrig-team +description: >- + Use when you are a person's coding agent in an app such as Claude Code, Codex, Cursor or VS Code, connected through the + OpenRig MCP server, and the person wants their OpenRig team to do some work, wants to know how that work is going, + has a question from the team to answer, or wants to read what the team returned. +--- + +# Working with an OpenRig team from your app + +The person runs a team of agents with OpenRig: each agent has its own terminal, and they keep working after this chat ends. +You aren't one of those terminals. You're the person's front door to the team: you hand it work, follow that work, bring +the team's questions to the person, and bring back what it produced. The MCP server's own instructions say how its tools +behave; this skill is the loop around them. + +## Check the connection first + +- Call `rig_whoami`. It names the seat this connection acts as. Every item you create is recorded as coming from that seat, + and the team's questions about your work come back to it. +- If it has no seat, stop and help the person set one (the last section). Without one, your requests are unattributed and you + can't list your own work. +- Call `rig_team` with the rig's name: it shows the team's seats, what each is for, and its lead. Work goes to the lead + unless the person names a seat. +- If `lead` is null (its `leadNote` says why), and the team has exactly one seat, send work there. Otherwise, ask the person + which seat should take it. + +## Hand the team work + +- Use `rig_queue_create` with `destination` (the lead, or the seat the person chose) and `body`. Write the body for a + capable colleague: the goal, why it matters, what done looks like, and where to look. Add a one-line `summary`. +- Tell the person the `qitemId` it returns. It's how both of you find this work again. +- If the call times out, check `rig_queue_show` with that same `qitemId` before trying again. If you retry, pass the same id + as `qitemId`. Never retry with a new one: that creates the work twice. + +## Follow it + +- `rig_queue_show` with the `qitemId` gives its state, its current owner and its transitions. +- When a seat hands the work on, the item names the next one. Follow the chain to the newest item before you report. +- Report what the record says. An accepted or claimed item isn't finished work, and a quiet one isn't stuck: say what you + saw and when it last changed. +- Nothing wakes this chat when the team finishes. Check again when the person asks, or when they come back. + +## The team's questions + +- Questions about your work come to your seat: `rig_queue_list` with `owned` lists them. +- Show the person the question and the team's draft, in their own words where the item gives them. Then ask for an answer. +- Record only what the person actually tells you. Claim the item (`rig_queue_claim`), then close it with + `rig_queue_update`: `state` `done`, `closureReason` `no-follow-on`, and the person's answer in `resolution`, saying it's + theirs. Put only their words there: no notes, paths or summaries of your own. If the person isn't there to answer, leave + the item open and tell them it's waiting. Never choose an answer for them. +- Some questions go to the person directly, outside your seat. Those wait for them where they already answer the team, + such as the OpenRig TUI or Slack. You can mention that they're waiting; answering them isn't yours. + +## Read the result + +- When the item is `done`, its `resolution` is the answer, and its transitions hold the notes the team recorded. +- If it names an `evidenceRef`, read it with `rig_artifact_read`, passing the item's `qitemId`, not the path. The tool + reads the item's `evidenceRef` within the shared roots this connection may read. For another file in a shared root, + pass the root's name and `path`. If the connection can't read it, give the person the path. +- To ask for changes, create a new item with `revises` set to the earlier `qitemId`, so the revision stays with the + original work. + +## Come back later + +- `rig_queue_list` with `mine` lists what this seat sent or owns. Add `all` to include finished work, or `since` to see + what changed after a time. +- A new chat with the same seat reaches the same work. Items outlive conversations. + +## Set it up (once, with the person) + +1. OpenRig runs on this machine with a team: `rig ps` lists the person's rigs. +2. Pick an address for this app on one of those rigs, for example `cursor@my-team`. It's an address the queue takes items + for; nothing is launched for it. To tell the team what it's for, the person can add it to a roster as a hosted member + (`docs/reference/hosted-members.md` in the OpenRig package). +3. Connect the app to that address, then start a new session so the MCP server starts with it: + - **Claude Code:** set it as the plugin's OpenRig seat in the plugin's settings. + - **Codex:** run `codex mcp add openrig --env OPENRIG_SESSION_NAME=
-- rig mcp serve --front-door`. Codex + does not forward every shell environment variable to MCP servers by default, so if the team's OpenRig home isn't the + default, add `--env OPENRIG_HOME=` too. + - **Another app:** add an MCP server that runs `rig mcp serve --front-door` with `OPENRIG_SESSION_NAME` set to the + address. + +## What this doesn't do + +- It doesn't add a panel to the app, and nothing wakes this chat when the team finishes. +- Anyone using this app on this machine acts as the seat the plugin names. +- It reaches a team on this machine. A team hosted elsewhere needs that host's own connection. diff --git a/packages/daemon/specs/agents/shared/skills/core/openrig-skills/SKILL.md b/packages/daemon/specs/agents/shared/skills/core/openrig-skills/SKILL.md index fbc191bf4..78e5bc06b 100644 --- a/packages/daemon/specs/agents/shared/skills/core/openrig-skills/SKILL.md +++ b/packages/daemon/specs/agents/shared/skills/core/openrig-skills/SKILL.md @@ -87,6 +87,7 @@ These ship in the OpenRig repo and reach a seat when its profile selects them. T - **watchdog** — monitoring or recovering a seat (health, restore, stuck state). - **session-source-fork** — forking a session's source/context. - **context-engineering** — designing context systems. Provisional and non-normative: current OpenRig skills, explicit rulings, and measured practice outrank it on any conflict. +- **working-with-an-openrig-team** — a person's own agent in an app (Claude Code, Codex, Cursor, VS Code) works with their team through `rig mcp serve`: hand work over, follow it, answer the team's questions, read results. The `openrig` app plugin loads it; a seat helping someone connect an app can open it with `rig context get skills/core/working-with-an-openrig-team/SKILL.md`. Pod handbooks (load when you're in that pod): - **orchestration-team** — you're orchestrating a rig: dispatching, monitoring, keeping the loop moving. diff --git a/packages/daemon/specs/agents/shared/skills/core/working-with-an-openrig-team/SKILL.md b/packages/daemon/specs/agents/shared/skills/core/working-with-an-openrig-team/SKILL.md new file mode 100644 index 000000000..c05590edb --- /dev/null +++ b/packages/daemon/specs/agents/shared/skills/core/working-with-an-openrig-team/SKILL.md @@ -0,0 +1,87 @@ +--- +name: working-with-an-openrig-team +description: >- + Use when you are a person's coding agent in an app such as Claude Code, Codex, Cursor or VS Code, connected through the + OpenRig MCP server, and the person wants their OpenRig team to do some work, wants to know how that work is going, + has a question from the team to answer, or wants to read what the team returned. +--- + +# Working with an OpenRig team from your app + +The person runs a team of agents with OpenRig: each agent has its own terminal, and they keep working after this chat ends. +You aren't one of those terminals. You're the person's front door to the team: you hand it work, follow that work, bring +the team's questions to the person, and bring back what it produced. The MCP server's own instructions say how its tools +behave; this skill is the loop around them. + +## Check the connection first + +- Call `rig_whoami`. It names the seat this connection acts as. Every item you create is recorded as coming from that seat, + and the team's questions about your work come back to it. +- If it has no seat, stop and help the person set one (the last section). Without one, your requests are unattributed and you + can't list your own work. +- Call `rig_team` with the rig's name: it shows the team's seats, what each is for, and its lead. Work goes to the lead + unless the person names a seat. +- If `lead` is null (its `leadNote` says why), and the team has exactly one seat, send work there. Otherwise, ask the person + which seat should take it. + +## Hand the team work + +- Use `rig_queue_create` with `destination` (the lead, or the seat the person chose) and `body`. Write the body for a + capable colleague: the goal, why it matters, what done looks like, and where to look. Add a one-line `summary`. +- Tell the person the `qitemId` it returns. It's how both of you find this work again. +- If the call times out, check `rig_queue_show` with that same `qitemId` before trying again. If you retry, pass the same id + as `qitemId`. Never retry with a new one: that creates the work twice. + +## Follow it + +- `rig_queue_show` with the `qitemId` gives its state, its current owner and its transitions. +- When a seat hands the work on, the item names the next one. Follow the chain to the newest item before you report. +- Report what the record says. An accepted or claimed item isn't finished work, and a quiet one isn't stuck: say what you + saw and when it last changed. +- Nothing wakes this chat when the team finishes. Check again when the person asks, or when they come back. + +## The team's questions + +- Questions about your work come to your seat: `rig_queue_list` with `owned` lists them. +- Show the person the question and the team's draft, in their own words where the item gives them. Then ask for an answer. +- Record only what the person actually tells you. Claim the item (`rig_queue_claim`), then close it with + `rig_queue_update`: `state` `done`, `closureReason` `no-follow-on`, and the person's answer in `resolution`, saying it's + theirs. Put only their words there: no notes, paths or summaries of your own. If the person isn't there to answer, leave + the item open and tell them it's waiting. Never choose an answer for them. +- Some questions go to the person directly, outside your seat. Those wait for them where they already answer the team, + such as the OpenRig TUI or Slack. You can mention that they're waiting; answering them isn't yours. + +## Read the result + +- When the item is `done`, its `resolution` is the answer, and its transitions hold the notes the team recorded. +- If it names an `evidenceRef`, read it with `rig_artifact_read`, passing the item's `qitemId`, not the path. The tool + reads the item's `evidenceRef` within the shared roots this connection may read. For another file in a shared root, + pass the root's name and `path`. If the connection can't read it, give the person the path. +- To ask for changes, create a new item with `revises` set to the earlier `qitemId`, so the revision stays with the + original work. + +## Come back later + +- `rig_queue_list` with `mine` lists what this seat sent or owns. Add `all` to include finished work, or `since` to see + what changed after a time. +- A new chat with the same seat reaches the same work. Items outlive conversations. + +## Set it up (once, with the person) + +1. OpenRig runs on this machine with a team: `rig ps` lists the person's rigs. +2. Pick an address for this app on one of those rigs, for example `cursor@my-team`. It's an address the queue takes items + for; nothing is launched for it. To tell the team what it's for, the person can add it to a roster as a hosted member + (`docs/reference/hosted-members.md` in the OpenRig package). +3. Connect the app to that address, then start a new session so the MCP server starts with it: + - **Claude Code:** set it as the plugin's OpenRig seat in the plugin's settings. + - **Codex:** run `codex mcp add openrig --env OPENRIG_SESSION_NAME=
-- rig mcp serve --front-door`. Codex + does not forward every shell environment variable to MCP servers by default, so if the team's OpenRig home isn't the + default, add `--env OPENRIG_HOME=` too. + - **Another app:** add an MCP server that runs `rig mcp serve --front-door` with `OPENRIG_SESSION_NAME` set to the + address. + +## What this doesn't do + +- It doesn't add a panel to the app, and nothing wakes this chat when the team finishes. +- Anyone using this app on this machine acts as the seat the plugin names. +- It reaches a team on this machine. A team hosted elsewhere needs that host's own connection. diff --git a/scripts/mirror-skills.mjs b/scripts/mirror-skills.mjs index ab4ef336e..8b289a9e3 100644 --- a/scripts/mirror-skills.mjs +++ b/scripts/mirror-skills.mjs @@ -906,12 +906,13 @@ function validateAuthoringLayout(layout) { } const edgeNames = Object.keys(layout.edges).sort(); const requiredEdges = ["canonical", "plugin", "spec"]; + // A further edge is another shipped plugin's copy of skills authored on spec or plugin (the openrig bundle's). if ( - edgeNames.length !== requiredEdges.length || - edgeNames.some((edge, index) => edge !== requiredEdges[index]) + requiredEdges.some((edge) => !edgeNames.includes(edge)) || + edgeNames.some((edge) => !requiredEdges.includes(edge) && layout.edges[edge]?.layout !== "flat") ) { throw new Error( - "Layout control must contain exactly canonical, plugin, and spec edges", + "Layout control must contain canonical, plugin, and spec edges; any other edge must be a flat plugin copy", ); } if (!isRecord(layout.skills) || Object.keys(layout.skills).length === 0) { diff --git a/scripts/mirror-skills.test.mjs b/scripts/mirror-skills.test.mjs index ed3288df8..a23275103 100644 --- a/scripts/mirror-skills.test.mjs +++ b/scripts/mirror-skills.test.mjs @@ -922,6 +922,31 @@ test("authoring regeneration writes each generated copy from the skill's one hom } }); +test("a further flat edge is another plugin's copy, written from the skill's home; other extra edges and missing ones refuse", async () => { + const mirror = await import("./mirror-skills.mjs"); + const root = mkdtempSync(join(tmpdir(), "openrig-authoring-bundle-")); + try { + const { repoRoot, inputs } = homeFixture(root); + const layout = { + ...inputs.layout, + edges: { ...inputs.layout.edges, bundle: { path: "bundle", layout: "flat" } }, + skills: { ...inputs.layout.skills, two: { edges: ["bundle", "canonical", "spec"], category: "core" } }, + }; + const first = await mirror.regeneratePublicSkills({ ...inputs, layout }); + assert.deepEqual(first.undeclared, []); + assert.deepEqual(snapshotTree(join(repoRoot, "bundle/two")), snapshotTree(join(repoRoot, "spec/core/two"))); + assert.equal(existsSync(join(repoRoot, "bundle/three")), false); + + const nested = { ...layout, edges: { ...layout.edges, bundle: { path: "bundle", layout: "categorized" } } }; + await assert.rejects(mirror.regeneratePublicSkills({ ...inputs, layout: nested }), /any other edge must be a flat plugin copy/); + const { plugin: _plugin, ...withoutPlugin } = inputs.layout.edges; + await assert.rejects(mirror.regeneratePublicSkills({ ...inputs, layout: { ...inputs.layout, edges: withoutPlugin } }), + /must contain canonical, plugin, and spec edges/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("authoring regeneration repairs permission-only drift in generated copies from the home's modes", async () => { const mirror = await import("./mirror-skills.mjs"); const root = mkdtempSync(join(tmpdir(), "openrig-authoring-modes-")); diff --git a/scripts/product-public-skills.generated.json b/scripts/product-public-skills.generated.json index 6b4f022da..903f36655 100644 --- a/scripts/product-public-skills.generated.json +++ b/scripts/product-public-skills.generated.json @@ -16,7 +16,8 @@ "refocusing", "delegating-work", "context-engineering", - "openrig-software-factory" + "openrig-software-factory", + "working-with-an-openrig-team" ], "ship_after_fix": [ "openrig-user", diff --git a/scripts/skill-edge-digests.generated.json b/scripts/skill-edge-digests.generated.json index 15807250a..084909832 100644 --- a/scripts/skill-edge-digests.generated.json +++ b/scripts/skill-edge-digests.generated.json @@ -1,6 +1,9 @@ { "version": 1, "edges": { + "bundle": { + "working-with-an-openrig-team/SKILL.md": "4b3e6e8eecb710abdb95d604c419599566dbc77b019b49175e936e713b65cf6f" + }, "canonical": { "core/agent-starters/SKILL.md": "8e8961f44fccb812f39e01174a331dab24f153ceaba91925f1e42371836cdec0", "core/agent-startup-and-context-ingestion/SKILL.md": "347f28a9567b4b8d8669aff6946229f53144e0c139aca2cbe77e0eb93a87f1b1", @@ -10,7 +13,7 @@ "core/openrig-architect/SKILL.md": "affa65df8087efca711e429e36e8756c711419dccc801d26d9e51a746b56391d", "core/openrig-cmux/SKILL.md": "73a86eac45d68b16a38fc0a4438713c634d86061073dac96a4d9e656ea5d34dc", "core/openrig-herdr/SKILL.md": "cb39b51f954ca89e8d7ec0f8b3f326476d5c4dd3bc17dbd47e6379aa630e7b22", - "core/openrig-skills/SKILL.md": "bdd6594ae1108455103239d39694c865cc40d3f7d9f24cd3566a18397df270da", + "core/openrig-skills/SKILL.md": "6d7bf933ac40531f11757a31a93d26dcbda617f1b85f48a21865b3a5c5e2ddaa", "core/openrig-software-factory/SKILL.md": "7a57815f3c6865d5cc42c063cccdca998d6ee100a82812362d0c81f314465b77", "core/openrig-software-factory/references/worked-example.md": "64dec0e832692b655ffc1ca9f72e96f221f78b8bc97f73775198f188b502f147", "core/openrig-upgrade/SKILL.md": "bd1765d94e4d462b9c8b08198238c1239c70ff68228c3d575438a7bc9a326ac6", @@ -24,6 +27,7 @@ "core/specification-system/SKILL.md": "5226d6e8b1362df89ba3d5afd82e4b4add355870132ca4b140d7f9f4324ffdc1", "core/topology-mutation-and-seat-management/SKILL.md": "527ca074432ce6513fb25cd27e8c709dabe0c91361b8999e09b272f3813abe62", "core/watchdog/SKILL.md": "24b2e756e3e9d067fa93a4b5fa5f48a5cce511e8b1a5b360b1c39bc09bc95d82", + "core/working-with-an-openrig-team/SKILL.md": "4b3e6e8eecb710abdb95d604c419599566dbc77b019b49175e936e713b65cf6f", "pm/backlog-capture/SKILL.md": "345c2b47fc85e48c15c0d42bbb48fb27dd3b0ceec36bf59dc6ed20daaea47a84", "pm/context-builder/SKILL.md": "0134c9b0a9f196565109f7f51b6ec78d5c3e4b5982a0d7105a93b29952625b3c", "pm/exec-summary/SKILL.md": "ada0f42337920e7acdd94e24cad4f5c269b057e750f4a7fc627ea9efbc02fcb6", @@ -88,7 +92,7 @@ "openrig-operating-model/templates/PLAYBOOK.md": "ff6f9dc8caab06dc7b9ae21977310ddefa23e925453bd54629ce0a669403bfb0", "openrig-operating-model/templates/SOP.md": "1eaf0daf4f9bbf0715e6a5aaf0f29f030af5fe8b8a10232488ee97c507afa6dc", "openrig-operating-model/templates/SPEC.md": "f126536daa59cea3f1cfb0898d83c7cdf99f6392f6f155ea64fe55187dd0f5f1", - "openrig-skills/SKILL.md": "bdd6594ae1108455103239d39694c865cc40d3f7d9f24cd3566a18397df270da", + "openrig-skills/SKILL.md": "6d7bf933ac40531f11757a31a93d26dcbda617f1b85f48a21865b3a5c5e2ddaa", "openrig-user/SKILL.md": "0776fe4e197d347614f72eec817f881debfcae372c4a1f912f2e0fbbf91c67da", "orienting-to-an-inherited-seat/CHANGELOG.md": "4d0970e75e5c15b00a48d6a7150376d646257777264fd1b9a191165a8d5a6e84", "orienting-to-an-inherited-seat/SKILL.md": "252fed402f19027056bbba241df3305be88de3175b67dc8fe051143bb0d4412b", @@ -114,7 +118,7 @@ "core/openrig-architect/SKILL.md": "affa65df8087efca711e429e36e8756c711419dccc801d26d9e51a746b56391d", "core/openrig-cmux/SKILL.md": "73a86eac45d68b16a38fc0a4438713c634d86061073dac96a4d9e656ea5d34dc", "core/openrig-herdr/SKILL.md": "cb39b51f954ca89e8d7ec0f8b3f326476d5c4dd3bc17dbd47e6379aa630e7b22", - "core/openrig-skills/SKILL.md": "bdd6594ae1108455103239d39694c865cc40d3f7d9f24cd3566a18397df270da", + "core/openrig-skills/SKILL.md": "6d7bf933ac40531f11757a31a93d26dcbda617f1b85f48a21865b3a5c5e2ddaa", "core/openrig-software-factory/SKILL.md": "7a57815f3c6865d5cc42c063cccdca998d6ee100a82812362d0c81f314465b77", "core/openrig-software-factory/references/worked-example.md": "64dec0e832692b655ffc1ca9f72e96f221f78b8bc97f73775198f188b502f147", "core/openrig-upgrade/SKILL.md": "bd1765d94e4d462b9c8b08198238c1239c70ff68228c3d575438a7bc9a326ac6", @@ -128,6 +132,7 @@ "core/specification-system/SKILL.md": "5226d6e8b1362df89ba3d5afd82e4b4add355870132ca4b140d7f9f4324ffdc1", "core/topology-mutation-and-seat-management/SKILL.md": "527ca074432ce6513fb25cd27e8c709dabe0c91361b8999e09b272f3813abe62", "core/watchdog/SKILL.md": "24b2e756e3e9d067fa93a4b5fa5f48a5cce511e8b1a5b360b1c39bc09bc95d82", + "core/working-with-an-openrig-team/SKILL.md": "4b3e6e8eecb710abdb95d604c419599566dbc77b019b49175e936e713b65cf6f", "pm/backlog-capture/SKILL.md": "345c2b47fc85e48c15c0d42bbb48fb27dd3b0ceec36bf59dc6ed20daaea47a84", "pm/context-builder/SKILL.md": "0134c9b0a9f196565109f7f51b6ec78d5c3e4b5982a0d7105a93b29952625b3c", "pm/exec-summary/SKILL.md": "ada0f42337920e7acdd94e24cad4f5c269b057e750f4a7fc627ea9efbc02fcb6", diff --git a/scripts/skill-edge-layout.generated.json b/scripts/skill-edge-layout.generated.json index 96486c962..524931e3f 100644 --- a/scripts/skill-edge-layout.generated.json +++ b/scripts/skill-edge-layout.generated.json @@ -1,6 +1,10 @@ { "version": 0, "edges": { + "bundle": { + "path": "packages/daemon/assets/plugins/openrig/skills", + "layout": "flat" + }, "canonical": { "path": "skills/_canonical", "layout": "mirror-of-spec" @@ -363,6 +367,14 @@ "spec" ], "category": "core" + }, + "working-with-an-openrig-team": { + "edges": [ + "bundle", + "canonical", + "spec" + ], + "category": "core" } } } diff --git a/skills/_canonical/core/openrig-skills/SKILL.md b/skills/_canonical/core/openrig-skills/SKILL.md index fbc191bf4..78e5bc06b 100644 --- a/skills/_canonical/core/openrig-skills/SKILL.md +++ b/skills/_canonical/core/openrig-skills/SKILL.md @@ -87,6 +87,7 @@ These ship in the OpenRig repo and reach a seat when its profile selects them. T - **watchdog** — monitoring or recovering a seat (health, restore, stuck state). - **session-source-fork** — forking a session's source/context. - **context-engineering** — designing context systems. Provisional and non-normative: current OpenRig skills, explicit rulings, and measured practice outrank it on any conflict. +- **working-with-an-openrig-team** — a person's own agent in an app (Claude Code, Codex, Cursor, VS Code) works with their team through `rig mcp serve`: hand work over, follow it, answer the team's questions, read results. The `openrig` app plugin loads it; a seat helping someone connect an app can open it with `rig context get skills/core/working-with-an-openrig-team/SKILL.md`. Pod handbooks (load when you're in that pod): - **orchestration-team** — you're orchestrating a rig: dispatching, monitoring, keeping the loop moving. diff --git a/skills/_canonical/core/working-with-an-openrig-team/SKILL.md b/skills/_canonical/core/working-with-an-openrig-team/SKILL.md new file mode 100644 index 000000000..c05590edb --- /dev/null +++ b/skills/_canonical/core/working-with-an-openrig-team/SKILL.md @@ -0,0 +1,87 @@ +--- +name: working-with-an-openrig-team +description: >- + Use when you are a person's coding agent in an app such as Claude Code, Codex, Cursor or VS Code, connected through the + OpenRig MCP server, and the person wants their OpenRig team to do some work, wants to know how that work is going, + has a question from the team to answer, or wants to read what the team returned. +--- + +# Working with an OpenRig team from your app + +The person runs a team of agents with OpenRig: each agent has its own terminal, and they keep working after this chat ends. +You aren't one of those terminals. You're the person's front door to the team: you hand it work, follow that work, bring +the team's questions to the person, and bring back what it produced. The MCP server's own instructions say how its tools +behave; this skill is the loop around them. + +## Check the connection first + +- Call `rig_whoami`. It names the seat this connection acts as. Every item you create is recorded as coming from that seat, + and the team's questions about your work come back to it. +- If it has no seat, stop and help the person set one (the last section). Without one, your requests are unattributed and you + can't list your own work. +- Call `rig_team` with the rig's name: it shows the team's seats, what each is for, and its lead. Work goes to the lead + unless the person names a seat. +- If `lead` is null (its `leadNote` says why), and the team has exactly one seat, send work there. Otherwise, ask the person + which seat should take it. + +## Hand the team work + +- Use `rig_queue_create` with `destination` (the lead, or the seat the person chose) and `body`. Write the body for a + capable colleague: the goal, why it matters, what done looks like, and where to look. Add a one-line `summary`. +- Tell the person the `qitemId` it returns. It's how both of you find this work again. +- If the call times out, check `rig_queue_show` with that same `qitemId` before trying again. If you retry, pass the same id + as `qitemId`. Never retry with a new one: that creates the work twice. + +## Follow it + +- `rig_queue_show` with the `qitemId` gives its state, its current owner and its transitions. +- When a seat hands the work on, the item names the next one. Follow the chain to the newest item before you report. +- Report what the record says. An accepted or claimed item isn't finished work, and a quiet one isn't stuck: say what you + saw and when it last changed. +- Nothing wakes this chat when the team finishes. Check again when the person asks, or when they come back. + +## The team's questions + +- Questions about your work come to your seat: `rig_queue_list` with `owned` lists them. +- Show the person the question and the team's draft, in their own words where the item gives them. Then ask for an answer. +- Record only what the person actually tells you. Claim the item (`rig_queue_claim`), then close it with + `rig_queue_update`: `state` `done`, `closureReason` `no-follow-on`, and the person's answer in `resolution`, saying it's + theirs. Put only their words there: no notes, paths or summaries of your own. If the person isn't there to answer, leave + the item open and tell them it's waiting. Never choose an answer for them. +- Some questions go to the person directly, outside your seat. Those wait for them where they already answer the team, + such as the OpenRig TUI or Slack. You can mention that they're waiting; answering them isn't yours. + +## Read the result + +- When the item is `done`, its `resolution` is the answer, and its transitions hold the notes the team recorded. +- If it names an `evidenceRef`, read it with `rig_artifact_read`, passing the item's `qitemId`, not the path. The tool + reads the item's `evidenceRef` within the shared roots this connection may read. For another file in a shared root, + pass the root's name and `path`. If the connection can't read it, give the person the path. +- To ask for changes, create a new item with `revises` set to the earlier `qitemId`, so the revision stays with the + original work. + +## Come back later + +- `rig_queue_list` with `mine` lists what this seat sent or owns. Add `all` to include finished work, or `since` to see + what changed after a time. +- A new chat with the same seat reaches the same work. Items outlive conversations. + +## Set it up (once, with the person) + +1. OpenRig runs on this machine with a team: `rig ps` lists the person's rigs. +2. Pick an address for this app on one of those rigs, for example `cursor@my-team`. It's an address the queue takes items + for; nothing is launched for it. To tell the team what it's for, the person can add it to a roster as a hosted member + (`docs/reference/hosted-members.md` in the OpenRig package). +3. Connect the app to that address, then start a new session so the MCP server starts with it: + - **Claude Code:** set it as the plugin's OpenRig seat in the plugin's settings. + - **Codex:** run `codex mcp add openrig --env OPENRIG_SESSION_NAME=
-- rig mcp serve --front-door`. Codex + does not forward every shell environment variable to MCP servers by default, so if the team's OpenRig home isn't the + default, add `--env OPENRIG_HOME=` too. + - **Another app:** add an MCP server that runs `rig mcp serve --front-door` with `OPENRIG_SESSION_NAME` set to the + address. + +## What this doesn't do + +- It doesn't add a panel to the app, and nothing wakes this chat when the team finishes. +- Anyone using this app on this machine acts as the seat the plugin names. +- It reaches a team on this machine. A team hosted elsewhere needs that host's own connection.