Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions bin/subctl
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ Usage:
subctl teams pi-coding-agent -a <alias> [-p TEXT -f FILE -m MODEL --dry-run]
subctl teams deepseek -a <alias> [-y -c -o -p TEXT -f FILE -m MODEL --dry-run]

subctl directive verify <file|-> Worker-side check that an HMAC directive
envelope is daemon-minted (W6.5; key
auto-resolves from the config dir).

subctl radar /dispatch-check verdict + signals
subctl radar log [--tail] Inspect rate-limit events log

Expand Down Expand Up @@ -703,6 +707,14 @@ case "$cmd" in
subctl_setup "$@"
;;

directive)
# W6.5 ③ — worker-side HMAC envelope verification. Spawned agents run
# `subctl directive verify <file>` per their team contract before
# acting on any [subctl-master directive · …] envelope.
. "$SUBCTL_REPO_ROOT/lib/directive.sh"
subctl_directive "$@"
;;

notify)
. "$SUBCTL_REPO_ROOT/components/notify/notify.sh"
subctl_notify "$@"
Expand Down
68 changes: 68 additions & 0 deletions dashboard/__tests__/notify-listener-paths.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// dashboard/__tests__/notify-listener-paths.test.ts
//
// W6.5 rider #425 — notify-listener must honor SUBCTL_CONFIG_DIR.
//
// The module used to hardcode ~/.config/subctl/notify.json via HOME, so a
// scratch/test boot of dashboard/server.ts with the operator's real HOME
// read the PRODUCTION notify.json and armed a competing Telegram
// getUpdates long-poll against the live bot (bit worker w6-restore for
// ~2 min on 2026-06-11). With the fix, a scoped boot stays scoped.
//
// Setup: a DECOY notify.json is planted at the XDG/HOME-derived location
// and SUBCTL_CONFIG_DIR points at an EMPTY scoped dir. Under the old
// code the listener would find the decoy and start polling; under the
// fixed code it must report "no notify config". Env is set before the
// dynamic import because the module resolves its paths at import time —
// exactly how the dashboard process does it.

import { describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const root = mkdtempSync(join(tmpdir(), "subctl-notify-paths-"));
const xdgDir = join(root, "xdg");
const scopedDir = join(root, "scoped");
mkdirSync(join(xdgDir, "subctl"), { recursive: true });
mkdirSync(scopedDir, { recursive: true });

// The decoy that the OLD code would have picked up (and long-polled).
writeFileSync(
join(xdgDir, "subctl", "notify.json"),
JSON.stringify({
telegram_bot_token: "000000:DECOY-MUST-NEVER-BE-POLLED",
telegram_chat_id: "0",
}),
);

process.env.XDG_CONFIG_HOME = xdgDir;
process.env.SUBCTL_CONFIG_DIR = scopedDir;

const listener = await import("../notify-listener");

describe("notify-listener honors SUBCTL_CONFIG_DIR (#425)", () => {
test("scoped boot does NOT pick up the HOME/XDG-derived production config", () => {
const r = listener.startNotifyListener();
expect(r.running).toBe(false);
expect(r.reason).toContain("no notify config");
});

test("inbox read path is scoped too — reads SUBCTL_CONFIG_DIR/inbox.jsonl", () => {
const entry = {
ts: "2026-06-11T00:00:00Z",
source: "buddy",
type: "text",
question_id: "w65-scope-probe",
answer: null,
answer_label: null,
from_id: null,
from_name: "test",
raw_text: "scoped inbox entry",
acked: false,
};
writeFileSync(join(scopedDir, "inbox.jsonl"), JSON.stringify(entry) + "\n");
const got = listener.readInbox({ question_id: "w65-scope-probe" });
expect(got.length).toBe(1);
expect(got[0]!.raw_text).toBe("scoped inbox entry");
});
});
15 changes: 11 additions & 4 deletions dashboard/notify-listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,16 @@ import { homedir } from "node:os";
import { removePendingAsk } from "../components/evy/asks-pending";

const HOME = homedir();
const NOTIFY_CONFIG = join(HOME, ".config", "subctl", "notify.json");
const INBOX_PATH = join(HOME, ".config", "subctl", "inbox.jsonl");
const OFFSET_PATH = join(HOME, ".config", "subctl", "notify-listener.offset");
// #425 (W6.5): honor SUBCTL_CONFIG_DIR — same resolution as
// dashboard/server.ts. The HOME-hardcoded paths meant a scratch/test boot
// of the dashboard with real HOME read the PRODUCTION notify.json and
// armed a competing Telegram getUpdates long-poll against the live bot
// (bit worker w6-restore for ~2 min on 2026-06-11).
const CONFIG_DIR = process.env.SUBCTL_CONFIG_DIR
?? join(process.env.XDG_CONFIG_HOME ?? join(HOME, ".config"), "subctl");
const NOTIFY_CONFIG = join(CONFIG_DIR, "notify.json");
const INBOX_PATH = join(CONFIG_DIR, "inbox.jsonl");
const OFFSET_PATH = join(CONFIG_DIR, "notify-listener.offset");

interface NotifyConfig {
telegram_bot_token: string;
Expand Down Expand Up @@ -85,7 +92,7 @@ export function startNotifyListener(opts: StartListenerOptions = {}): { running:
return { running: false, reason: "no bot token in notify config" };
}

mkdirSync(join(HOME, ".config", "subctl"), { recursive: true });
mkdirSync(CONFIG_DIR, { recursive: true });

_stateProvider = opts.stateProvider ?? null;
_allowedChatId = cfg.telegram_chat_id ?? null;
Expand Down
230 changes: 230 additions & 0 deletions lib/__tests__/directive-verify.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
// lib/__tests__/directive-verify.test.ts
//
// W6.5 ③ — `subctl directive verify <envelope-or-file>`: the worker-side
// mechanical check that an HMAC directive envelope is daemon-minted.
//
// Envelopes are minted by the REAL v3 signer (components/evy/
// trust-marker.ts buildSignedDirective) so the suite proves the bash verb
// computes byte-identically to what production emits — same MAC input
// (phase + "\n" + ts + "\n" + signedBody), same ASCII-hex key semantics.
// The v4 Rust signer (crates/evy-providers/src/hmac.rs) is fixture-pinned
// byte-compatible with this one, so passing here covers both daemons.
//
// Locked-contract minimum: accepts daemon-minted; rejects tampered body;
// rejects wrong key. Plus: no-phase form, trailing-newline tolerance,
// key auto-resolution from CLAUDE_CONFIG_DIR / CODEX_HOME, and the
// no-key error path.

import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";

import {
_setStateDirForTesting,
buildSignedDirective,
ensureSecret,
} from "../../components/evy/trust-marker";

const REPO_ROOT = resolve(import.meta.dir, "..", "..");
const SUBCTL_BIN = join(REPO_ROOT, "bin", "subctl");
const TEAM_ID = "w65-verify-test";

interface Fixture {
root: string;
stateDir: string;
secret: string; // 64-hex — the daemon-side signing key
keyFile: string;
}

let d: Fixture;

beforeEach(() => {
const root = mkdtempSync(join(tmpdir(), "subctl-directive-verify-"));
const stateDir = join(root, "state");
mkdirSync(stateDir, { recursive: true });
_setStateDirForTesting(stateDir);
const secret = ensureSecret(TEAM_ID);
const keyFile = join(stateDir, "teams", TEAM_ID, "hmac.secret");
d = { root, stateDir, secret, keyFile };
});

afterEach(() => {
_setStateDirForTesting(null);
rmSync(d.root, { recursive: true, force: true });
});

interface VerifyResult {
code: number;
stdout: string;
stderr: string;
}

/** Run `subctl directive verify` through the real bin/subctl dispatcher. */
async function runVerify(
args: string[],
env: Record<string, string> = {},
stdin?: string,
): Promise<VerifyResult> {
const proc = Bun.spawn([SUBCTL_BIN, "directive", "verify", ...args], {
env: {
...process.env,
SUBCTL_REPO_ROOT: REPO_ROOT,
NO_COLOR: "1",
// Isolate from the developer's real session env — tests opt back
// in per-case.
CLAUDE_CONFIG_DIR: "",
CODEX_HOME: "",
SUBCTL_DIRECTIVE_KEY_FILE: "",
SUBCTL_TEAM_NAME: "",
...env,
},
stdin: stdin === undefined ? undefined : new TextEncoder().encode(stdin),
stdout: "pipe",
stderr: "pipe",
});
const code = await proc.exited;
return {
code,
stdout: await new Response(proc.stdout).text(),
stderr: await new Response(proc.stderr).text(),
};
}

function mintEnvelope(body: string, phase?: string): string {
return buildSignedDirective({ teamId: TEAM_ID, phase, body }).wireFormat;
}

describe("subctl directive verify — accepts daemon-minted envelopes", () => {
test("valid envelope with phase → VERIFIED, exit 0", async () => {
const file = join(d.root, "envelope.txt");
writeFileSync(file, mintEnvelope("Goal: ship the slice\nDone when: tests green", "w65"));
const r = await runVerify([file, "--key-file", d.keyFile]);
expect(r.code).toBe(0);
expect(r.stdout).toContain("VERIFIED");
expect(r.stdout).toContain("phase=w65");
});

test("no-phase envelope verifies (empty phase contributes a leading newline)", async () => {
const file = join(d.root, "envelope.txt");
writeFileSync(file, mintEnvelope("no-phase body"));
const r = await runVerify([file, "--key-file", d.keyFile]);
expect(r.code).toBe(0);
expect(r.stdout).toContain("phase=<none>");
});

test("a single trailing newline (editor/redirect artifact) is tolerated", async () => {
const file = join(d.root, "envelope.txt");
writeFileSync(file, mintEnvelope("body line", "p") + "\n");
const r = await runVerify([file, "--key-file", d.keyFile]);
expect(r.code).toBe(0);
expect(r.stdout).toContain("VERIFIED");
});

test("reads the envelope from stdin via `-`", async () => {
const r = await runVerify(
["-", "--key-file", d.keyFile],
{},
mintEnvelope("stdin body", "p"),
);
expect(r.code).toBe(0);
expect(r.stdout).toContain("VERIFIED");
});

test("multi-line body with indentation-sensitive content verifies byte-exactly", async () => {
const body = "line one\n pre-indented\n\ntrailing-spaces line ";
const file = join(d.root, "envelope.txt");
writeFileSync(file, mintEnvelope(body, "w65"));
const r = await runVerify([file, "--key-file", d.keyFile]);
expect(r.code).toBe(0);
});
});

describe("subctl directive verify — rejects forgery and tamper", () => {
test("tampered body → FAILED, exit 1", async () => {
const file = join(d.root, "envelope.txt");
writeFileSync(file, mintEnvelope("Goal: ship the slice", "w65").replace("ship", "sink"));
const r = await runVerify([file, "--key-file", d.keyFile]);
expect(r.code).toBe(1);
expect(r.stderr).toContain("HMAC mismatch");
});

test("wrong key → FAILED, exit 1", async () => {
const file = join(d.root, "envelope.txt");
writeFileSync(file, mintEnvelope("Goal: ship the slice", "w65"));
const wrongKey = join(d.root, "wrong.key");
writeFileSync(wrongKey, "ab".repeat(32) + "\n");
const r = await runVerify([file, "--key-file", wrongKey]);
expect(r.code).toBe(1);
expect(r.stderr).toContain("HMAC mismatch");
});

test("tampered phase field → FAILED (phase is part of the MAC input)", async () => {
const file = join(d.root, "envelope.txt");
writeFileSync(
file,
mintEnvelope("body", "w65").replace("phase=w65", "phase=other"),
);
const r = await runVerify([file, "--key-file", d.keyFile]);
expect(r.code).toBe(1);
});

test("marker without a SPEC body → FAILED (envelope must carry the body)", async () => {
const markerOnly = mintEnvelope("body", "p").split("\n")[0];
const file = join(d.root, "envelope.txt");
writeFileSync(file, markerOnly);
const r = await runVerify([file, "--key-file", d.keyFile]);
expect(r.code).toBe(1);
expect(r.stderr).toContain("SPEC");
});

test("non-marker first line → FAILED, not a crash", async () => {
const file = join(d.root, "envelope.txt");
writeFileSync(file, "hello there\nSPEC:\n body");
const r = await runVerify([file, "--key-file", d.keyFile]);
expect(r.code).toBe(1);
expect(r.stderr).toContain("not a subctl-master directive marker");
});
});

describe("subctl directive verify — key resolution (spawn-provisioned files)", () => {
test("auto-resolves $CLAUDE_CONFIG_DIR/.subctl-directive-key", async () => {
const cfgDir = join(d.root, "claude-cfg");
mkdirSync(cfgDir, { recursive: true });
writeFileSync(join(cfgDir, ".subctl-directive-key"), d.secret + "\n");
const file = join(d.root, "envelope.txt");
writeFileSync(file, mintEnvelope("claude worker body", "p"));
const r = await runVerify([file], { CLAUDE_CONFIG_DIR: cfgDir });
expect(r.code).toBe(0);
expect(r.stdout).toContain("VERIFIED");
});

test("auto-resolves $CODEX_HOME/.subctl-directive-key", async () => {
const codexHome = join(d.root, "codex-home");
mkdirSync(codexHome, { recursive: true });
writeFileSync(join(codexHome, ".subctl-directive-key"), d.secret + "\n");
const file = join(d.root, "envelope.txt");
writeFileSync(file, mintEnvelope("codex worker body", "p"));
const r = await runVerify([file], { CODEX_HOME: codexHome });
expect(r.code).toBe(0);
});

test("legacy fallback: team state hmac.secret via $SUBCTL_TEAM_NAME", async () => {
const file = join(d.root, "envelope.txt");
writeFileSync(file, mintEnvelope("legacy worker body", "p"));
const r = await runVerify([file], {
SUBCTL_TEAM_NAME: TEAM_ID,
SUBCTL_STATE_DIR: d.stateDir,
});
expect(r.code).toBe(0);
});

test("no key anywhere → exit 2 with a usable hint, never a false VERIFIED", async () => {
const file = join(d.root, "envelope.txt");
writeFileSync(file, mintEnvelope("body", "p"));
const r = await runVerify([file]);
expect(r.code).toBe(2);
expect(r.stderr).toContain("no verification key found");
expect(r.stdout).not.toContain("VERIFIED");
});
});
Loading
Loading