Skip to content

Commit 6d27afc

Browse files
authored
Merge pull request #48 from openagentlock/fix/daemon-down-ux
Fix/daemon down ux
2 parents ae1b203 + da5eb6d commit 6d27afc

18 files changed

Lines changed: 590 additions & 173 deletions

cli/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@openagentlock/cli",
3-
"version": "0.1.14",
3+
"version": "0.1.15",
44
"type": "module",
55
"license": "SEE LICENSE IN LICENSE",
66
"description": "OpenAgentLock CLI — a firewall for AI coding agents. Detects local agent harnesses (Claude Code, Codex CLI, Cursor, OpenCode, Cline, Gemini CLI, Continue, Copilot), gates risky tool calls via a Go control plane, anchors decisions in a Rust Merkle ledger.",
@@ -43,7 +43,8 @@
4343
"detect": "bun run src/index.ts detect",
4444
"install-into": "bun run src/index.ts install",
4545
"typecheck": "tsc --noEmit",
46-
"build": "bun build src/index.ts --target=bun --outdir=dist"
46+
"build": "bun build src/index.ts --target=bun --outdir=dist",
47+
"prepublishOnly": "test -x ./agentlock && npm pack --dry-run --json | node -e \"const m=JSON.parse(require('fs').readFileSync(0,'utf8'));if(!m[0].files.some(f=>f.path==='agentlock'))throw new Error('agentlock wrapper missing from tarball — see 0.1.12 regression');\""
4748
},
4849
"dependencies": {
4950
"@noble/ed25519": "^3.1.0",

cli/src/commands/hook-claude-code.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@
66
// as `type: "http"` directly at the daemon, every hook fire became a
77
// browser-style fetch from inside Claude. A daemon outage rendered as a
88
// red "PreToolUse:Bash hook error / ECONNREFUSED" banner on every tool
9-
// call. Routing through this shim lets us fail-open on transport errors
10-
// (exit 0) and emit a one-time friendly nudge instead — matching the UX
11-
// codex and cursor already get.
9+
// call. Routing through this shim lets us fail-open silently on transport
10+
// errors (exit 0). The user-visible "daemon is down" signal is provided
11+
// out-of-band by Claude Code's `statusLine` config, which the installer
12+
// wires to <agentlockHome>/bin/agentlock-status — that surface lives
13+
// outside the model's input stream so it doesn't read as a prompt-
14+
// injection attempt the way `additionalContext` did in earlier designs.
1215
//
1316
// Wire shape (Claude Code → daemon → Claude Code):
1417
// stdin: Claude's native event JSON (session_id, hook_event_name,
@@ -19,9 +22,8 @@
1922
// hook was HTTP-typed
2023
// stdout: on deny, the JSON envelope (Claude Code accepts either an
2124
// exit code OR JSON, we emit both for safety)
22-
// exit: 0 on allow / observability, 2 on deny
25+
// exit: 0 on allow / observability / fail-open, 2 on deny
2326

24-
import { clearDaemonDownMarker, warnDaemonDownOnce } from "../util/daemon-warn.ts";
2527

2628
const ALLOWED_EVENTS = new Set([
2729
"session-start",
@@ -73,8 +75,6 @@ export async function runHookClaudeCode(argv: string[]): Promise<void> {
7375
process.exit(0);
7476
}
7577

76-
// Validate JSON locally so a malformed body doesn't waste a daemon
77-
// round trip. Pass through verbatim if it parses.
7878
try {
7979
JSON.parse(raw);
8080
} catch (e) {
@@ -94,14 +94,13 @@ export async function runHookClaudeCode(argv: string[]): Promise<void> {
9494
headers: { "Content-Type": "application/json" },
9595
body: raw,
9696
});
97-
} catch (e) {
98-
warnDaemonDownOnce("claude-code", url, e as Error);
99-
process.exit(0); // fail-open
97+
} catch {
98+
// Daemon unreachable. Silent fail-open — never write to stdout
99+
// (Claude would read it as model-input text). The statusLine UI
100+
// element is the user-visible "daemon offline" signal.
101+
process.exit(0);
100102
}
101103

102-
// Successful round-trip — re-arm the nudge for the next outage.
103-
clearDaemonDownMarker();
104-
105104
if (!res.ok) {
106105
process.stderr.write(
107106
`agentlock hook claude-code ${event}: daemon returned ${res.status}\n`,

cli/src/commands/hook-codex.ts

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,17 @@
1515
// daemon outage from soft-bricking the user's coding session. The
1616
// daemon-side ledger is the source of truth; if it can't be reached,
1717
// monitor mode is the safer default than blocking everything.
18-
19-
import { clearDaemonDownMarker, warnDaemonDownOnce } from "../util/daemon-warn.ts";
18+
//
19+
// Codex has no `statusLine` analog (Claude's persistent UI element under
20+
// the chat) and no chat-injection field that's safe from prompt-injection
21+
// flags. Empirically Codex also hides hook stderr on exit-0 — it only
22+
// surfaces hook output as a "(failed)" banner when the hook exits non-
23+
// zero. Which means there's no in-Codex surface we can write a live or
24+
// once-per-session OAL status indicator into without making it look like
25+
// an error. We stay silent here; users wanting a live indicator wire the
26+
// `agentlock-status` script into their shell prompt (visible when they
27+
// start/stop a Codex session) or rely on Claude Code's statusLine in
28+
// parallel chats.
2029

2130
const ALLOWED_EVENTS = new Set([
2231
"session-start",
@@ -68,8 +77,6 @@ export async function runHookCodex(argv: string[]): Promise<void> {
6877
process.exit(0);
6978
}
7079

71-
// Validate JSON locally so a malformed body doesn't waste a daemon
72-
// round trip. Pass through verbatim if it parses.
7380
try {
7481
JSON.parse(raw);
7582
} catch (e) {
@@ -88,14 +95,14 @@ export async function runHookCodex(argv: string[]): Promise<void> {
8895
headers: { "Content-Type": "application/json" },
8996
body: raw,
9097
});
91-
} catch (e) {
92-
warnDaemonDownOnce("codex", url, e as Error);
93-
process.exit(0); // fail-open
98+
} catch {
99+
// Daemon unreachable — silent fail-open. No stdout (would land in
100+
// model input). No stderr (Codex would render exit-0 stderr as a
101+
// failed-hook banner if it surfaced it at all, and on success it's
102+
// hidden — so the line would either be alarming or invisible).
103+
process.exit(0);
94104
}
95105

96-
// Successful round-trip — re-arm the nudge for the next outage.
97-
clearDaemonDownMarker();
98-
99106
if (!res.ok) {
100107
process.stderr.write(
101108
`agentlock hook codex ${event}: daemon returned ${res.status}\n`,

cli/src/commands/hook-cursor.ts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,16 @@
1616
// a fixed reason pointing at the dashboard.
1717
//
1818
// Failure modes are fail-open (exit 0). The daemon's ledger is the source
19-
// of truth; a missing daemon should not soft-brick the user's IDE.
19+
// of truth; a missing daemon should not soft-brick the user's IDE. On a
20+
// transport failure we keep stdout to a plain `{permission: "allow"}`
21+
// envelope — Cursor's hook spec offers no UI surface that's outside the
22+
// model's input stream (no statusLine equivalent), so users wanting a
23+
// live "daemon offline" indicator should rely on Claude Code's statusLine
24+
// or a shell-prompt integration of <agentlockHome>/bin/agentlock-status.
2025
// Operators who want fail-closed semantics can install with
2126
// `failClosed: true` in the wired hook entries — Cursor will then treat
2227
// any exit-code error as a deny regardless of what we write to stdout.
2328

24-
import { clearDaemonDownMarker, warnDaemonDownOnce } from "../util/daemon-warn.ts";
25-
2629
const ALLOWED_EVENTS = new Set([
2730
"session-start",
2831
"pre-tool-use",
@@ -89,8 +92,6 @@ export async function runHookCursor(argv: string[]): Promise<void> {
8992
process.exit(0);
9093
}
9194

92-
// Validate JSON locally so a malformed body doesn't waste a daemon
93-
// round trip. Pass through verbatim if it parses.
9495
try {
9596
JSON.parse(raw);
9697
} catch (e) {
@@ -110,14 +111,12 @@ export async function runHookCursor(argv: string[]): Promise<void> {
110111
headers: { "Content-Type": "application/json" },
111112
body: raw,
112113
});
113-
} catch (e) {
114-
warnDaemonDownOnce("cursor", url, e as Error);
115-
process.exit(0); // fail-open
114+
} catch {
115+
// Daemon unreachable — silent fail-open. Cursor has no UI surface
116+
// outside the model's input stream we can write to safely.
117+
emitAllow();
116118
}
117119

118-
// Successful round-trip — re-arm the nudge for the next outage.
119-
clearDaemonDownMarker();
120-
121120
if (!res.ok) {
122121
process.stderr.write(
123122
`agentlock hook cursor ${event}: daemon returned ${res.status}\n`,

cli/src/commands/install.ts

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
// For multi-harness dev runs prefer AGENTLOCK_DEV_HOME=./dev which
2626
// re-roots every detector AND the daemon's apply paths.
2727

28-
import { existsSync } from "node:fs";
28+
import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
2929
import { join, resolve } from "node:path";
3030

3131
import { detectAll } from "../detect/index.ts";
@@ -38,18 +38,58 @@ import {
3838
executeUninstallOps,
3939
readExistingFiles,
4040
} from "../util/install-fs.ts";
41-
import { home } from "../util/paths.ts";
41+
import { binDir, home, isWin } from "../util/paths.ts";
4242
import { mintAttestedSession, type AttestedTier } from "../util/session-mint.ts";
4343

44-
// Source-tree default: cli/agentlock is a bash wrapper that does
45-
// `exec bun run cli/src/index.ts "$@"`, so harnesses can spawn
46-
// `agentlock hook codex <event>` without needing a compiled binary.
47-
// `process.execPath` alone points at `bun`, which crashes (exit 1) when
48-
// invoked as `bun hook codex pre-tool-use` because `hook` isn't a script.
49-
function defaultAgentlockBinary(): string {
50-
const wrapper = resolve(import.meta.dir, "..", "..", "agentlock");
51-
if (existsSync(wrapper)) return wrapper;
52-
return process.execPath;
44+
// Stable wrapper path: <agentlockHome>/bin/agentlock. Lives in our state dir,
45+
// not in the package manager's volatile node_modules tree, so package
46+
// upgrades / reinstalls don't strand the wired hook command at a path the
47+
// shell can't spawn (which renders as red "PreToolUse hook error" banners
48+
// in Claude Code, and similar in Cursor / Codex). Re-running `agentlock
49+
// install` rewrites the wrapper, picking up any new index.ts location.
50+
//
51+
// The wrapper itself is bash-only — Windows wiring lands separately.
52+
export function installAndResolveAgentlockBinary(): string {
53+
if (isWin()) {
54+
throw new Error(
55+
"agentlock install: Windows wrapper not yet supported. Use macOS/Linux for now.",
56+
);
57+
}
58+
const indexPath = resolve(import.meta.dir, "..", "index.ts");
59+
const dir = binDir();
60+
const wrapper = join(dir, "agentlock");
61+
const body = `#!/usr/bin/env bash\nexec bun run "${indexPath}" "$@"\n`;
62+
mkdirSync(dir, { recursive: true });
63+
writeFileSync(wrapper, body, { flag: "w" });
64+
chmodSync(wrapper, 0o755);
65+
return wrapper;
66+
}
67+
68+
// Tiny health-check script wired into Claude Code's `statusLine` config.
69+
// Output renders as a UI element under the chat — never injected into the
70+
// model's input stream — so the user sees live "is the daemon up?" without
71+
// a prompt-injection vector. Curl with a 200ms timeout keeps the status
72+
// line snappy; a hung daemon fails to "offline" instead of stalling the UI.
73+
export function installStatusLineScript(): string {
74+
if (isWin()) {
75+
throw new Error(
76+
"agentlock install: Windows status-line not yet supported. Use macOS/Linux for now.",
77+
);
78+
}
79+
const dir = binDir();
80+
const script = join(dir, "agentlock-status");
81+
const body = `#!/usr/bin/env bash
82+
url="\${AGENTLOCK_DAEMON_URL:-http://127.0.0.1:7878}"
83+
if curl --max-time 1 -fs "$url/v1/health" >/dev/null 2>&1; then
84+
printf 'OpenAgentLock \\xe2\\x9c\\x93'
85+
else
86+
printf 'OpenAgentLock \\xe2\\x9a\\xa0 daemon offline'
87+
fi
88+
`;
89+
mkdirSync(dir, { recursive: true });
90+
writeFileSync(script, body, { flag: "w" });
91+
chmodSync(script, 0o755);
92+
return script;
5393
}
5494

5595
type InstallTier = "unattested" | AttestedTier;
@@ -352,16 +392,22 @@ export async function runInstall(argv: string[] = []): Promise<void> {
352392
cursorHooks,
353393
]);
354394

395+
// Write the status-line script alongside the binary wrapper. Daemon
396+
// wires this path into ~/.claude/settings.json `statusLine` so users
397+
// see live OAL health without any chat injection.
398+
const statusLineScript = installStatusLineScript();
399+
355400
const planReq = {
356401
session_id: sessionId,
357402
harnesses: chosen,
358403
daemon_url: daemonUrl,
359404
config_dir_override: flags.configDirOverride,
360405
// Pass an absolute path so Codex's command-hook spawn doesn't depend
361-
// on PATH at hook-fire time. Source-tree dev runs use the
362-
// `cli/agentlock` wrapper; AGENTLOCK_BINARY lets release builds
363-
// override (e.g. point at the compiled single-file binary).
364-
agentlock_binary: process.env.AGENTLOCK_BINARY ?? defaultAgentlockBinary(),
406+
// on PATH at hook-fire time. The wrapper lives under agentlockHome()
407+
// so it survives package-manager upgrades; AGENTLOCK_BINARY lets
408+
// release builds override (e.g. point at a compiled single-file binary).
409+
agentlock_binary: process.env.AGENTLOCK_BINARY ?? installAndResolveAgentlockBinary(),
410+
status_line_script: statusLineScript,
365411
harness_config_dirs: hostConfigDirs,
366412
existing_files: existingFiles,
367413
};

cli/src/util/daemon-warn.ts

Lines changed: 0 additions & 60 deletions
This file was deleted.

cli/src/util/paths.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ export function agentlockHome(): string {
4444
return process.env.AGENTLOCK_HOME ?? join(appSupport(), "OpenAgentLock");
4545
}
4646

47+
/** Stable wrapper-script home; survives package upgrades that move node_modules. */
48+
export function binDir(): string {
49+
return join(agentlockHome(), "bin");
50+
}
51+
4752
/** VS Code user dir (extension globalStorage lives under this). */
4853
export function vscodeUserDir(): string | null {
4954
if (isMac()) return join(home(), "Library", "Application Support", "Code", "User");

0 commit comments

Comments
 (0)