Skip to content

Commit a9b1442

Browse files
committed
Merge branch 'codex/project-bun-lock-precommit'
# Conflicts: # cli/src/commands/install.ts # cli/src/detect/agentlock-state.ts # docs/status.md
2 parents af167f0 + 32d6795 commit a9b1442

24 files changed

Lines changed: 942 additions & 188 deletions

cli/src/commands/hook-codex.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,18 @@ const ALLOWED_EVENTS = new Set([
3434
"stop",
3535
]);
3636

37+
const ALLOWED_HARNESSES = new Set(["codex", "codex-desktop", "codex-auto"]);
38+
39+
function resolveCodexHookHarness(hookHarness: string): string {
40+
if (hookHarness !== "codex-auto") return hookHarness;
41+
const bundleID = process.env.__CFBundleIdentifier ?? "";
42+
const xpcService = process.env.XPC_SERVICE_NAME ?? "";
43+
if (bundleID === "com.openai.codex" || xpcService.includes("com.openai.codex")) {
44+
return "codex-desktop";
45+
}
46+
return "codex";
47+
}
48+
3749
interface CodexHookSpecifics {
3850
hookEventName?: string;
3951
permissionDecision?: "allow" | "deny";
@@ -62,11 +74,15 @@ async function readStdin(): Promise<string> {
6274
return Buffer.concat(chunks).toString("utf8");
6375
}
6476

65-
export async function runHookCodex(argv: string[]): Promise<void> {
77+
export async function runHookCodex(argv: string[], hookHarness = "codex"): Promise<void> {
78+
if (!ALLOWED_HARNESSES.has(hookHarness)) {
79+
process.stderr.write(`unsupported codex hook harness: ${hookHarness}\n`);
80+
process.exit(2);
81+
}
6682
const event = argv[0];
6783
if (!event || !ALLOWED_EVENTS.has(event)) {
6884
process.stderr.write(
69-
`usage: agentlock hook codex <session-start|pre-tool-use|post-tool-use|stop>\n`,
85+
`usage: agentlock hook ${hookHarness} <session-start|pre-tool-use|post-tool-use|stop>\n`,
7086
);
7187
process.exit(2);
7288
}
@@ -81,13 +97,16 @@ export async function runHookCodex(argv: string[]): Promise<void> {
8197
JSON.parse(raw);
8298
} catch (e) {
8399
process.stderr.write(
84-
`agentlock hook codex ${event}: invalid JSON on stdin: ${(e as Error).message}\n`,
100+
`agentlock hook ${hookHarness} ${event}: invalid JSON on stdin: ${(e as Error).message}\n`,
85101
);
86102
// Fail-open: invalid payload is the harness's bug, not policy.
87103
process.exit(0);
88104
}
89105

90-
const url = defaultDaemonUrl().replace(/\/+$/, "") + `/v1/hooks/codex/${event}`;
106+
const routeHarness = resolveCodexHookHarness(hookHarness);
107+
const url =
108+
defaultDaemonUrl().replace(/\/+$/, "") +
109+
`/v1/hooks/${encodeURIComponent(routeHarness)}/${encodeURIComponent(event)}`;
91110
let res: Response;
92111
try {
93112
res = await fetch(url, {
@@ -105,7 +124,7 @@ export async function runHookCodex(argv: string[]): Promise<void> {
105124

106125
if (!res.ok) {
107126
process.stderr.write(
108-
`agentlock hook codex ${event}: daemon returned ${res.status}\n`,
127+
`agentlock hook ${hookHarness} ${event}: daemon returned ${res.status}\n`,
109128
);
110129
process.exit(0); // fail-open
111130
}
@@ -115,7 +134,7 @@ export async function runHookCodex(argv: string[]): Promise<void> {
115134
parsed = (await res.json()) as DaemonResponse;
116135
} catch (e) {
117136
process.stderr.write(
118-
`agentlock hook codex ${event}: malformed daemon response: ${(e as Error).message}\n`,
137+
`agentlock hook ${hookHarness} ${event}: malformed daemon response: ${(e as Error).message}\n`,
119138
);
120139
process.exit(0);
121140
}

cli/src/commands/install.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,15 +59,20 @@ export function installAndResolveAgentlockBinary(): string {
5959
);
6060
}
6161
const indexPath = resolve(import.meta.dir, "..", "index.ts");
62+
const bunPath = resolve(process.execPath);
6263
const dir = binDir();
6364
const wrapper = join(dir, "agentlock");
64-
const body = `#!/usr/bin/env bash\nexec bun run "${indexPath}" "$@"\n`;
65+
const body = `#!/usr/bin/env bash\nexec ${shellQuote(bunPath)} run ${shellQuote(indexPath)} "$@"\n`;
6566
mkdirSync(dir, { recursive: true });
6667
writeFileSync(wrapper, body, { flag: "w" });
6768
chmodSync(wrapper, 0o755);
6869
return wrapper;
6970
}
7071

72+
function shellQuote(value: string): string {
73+
return `'${value.replaceAll("'", "'\\''")}'`;
74+
}
75+
7176
// Tiny health-check script wired into Claude Code's `statusLine` config.
7277
// Output renders as a UI element under the chat — never injected into the
7378
// model's input stream — so the user sees live "is the daemon up?" without
@@ -172,13 +177,15 @@ export async function runInstall(argv: string[] = []): Promise<void> {
172177
"claude-code": flags.configDirOverride,
173178
"claude-desktop": flags.configDirOverride,
174179
codex: flags.configDirOverride,
180+
"codex-desktop": flags.configDirOverride,
175181
cursor: flags.configDirOverride,
176182
gemini: flags.configDirOverride,
177183
}
178184
: {
179185
"claude-code": resolve(join(home(), ".claude")),
180186
"claude-desktop": claudeDesktopDir,
181187
codex: resolve(join(home(), ".codex")),
188+
"codex-desktop": resolve(join(home(), ".codex")),
182189
cursor: resolve(join(home(), ".cursor")),
183190
gemini: resolve(join(home(), ".gemini")),
184191
};
@@ -190,6 +197,7 @@ export async function runInstall(argv: string[] = []): Promise<void> {
190197
id === "claude-code" ||
191198
id === "claude-desktop" ||
192199
id === "codex" ||
200+
id === "codex-desktop" ||
193201
id === "cursor" ||
194202
id === "gemini";
195203
const options = results.map((r) => {
@@ -216,7 +224,11 @@ export async function runInstall(argv: string[] = []): Promise<void> {
216224
// is idempotent for claude and re-stamps the dev marker).
217225
checked: enabled && !!r.agentlockInstalled,
218226
disabled: !enabled,
219-
disabledReason: enabled ? undefined : "MVP: claude-code + codex + cursor only",
227+
disabledReason: enabled
228+
? undefined
229+
: r.id === "codex-desktop"
230+
? "Codex Desktop is supported through the shared Codex hooks; install Codex and trust the hook from /hooks"
231+
: "MVP: claude-code + claude-desktop + codex + cursor + gemini only",
220232
};
221233
});
222234

@@ -358,7 +370,7 @@ export async function runInstall(argv: string[] = []): Promise<void> {
358370
// unwind the wrap on uninstall.
359371
const bundlesDir = resolve(join(dir, "Claude Extensions"));
360372
uninstallPaths.push(...(await listExtensionBundleManifests(bundlesDir)));
361-
} else if (id === "codex" || id === "cursor") {
373+
} else if (id === "codex" || id === "codex-desktop" || id === "cursor") {
362374
uninstallPaths.push(resolve(join(dir, "hooks.json")));
363375
} else if (id === "gemini") {
364376
// Gemini stuffs hook entries into the same settings.json as

cli/src/detect/agentlock-state.ts

Lines changed: 47 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Helpers for figuring out whether agentlock is already wired into a
22
// harness on disk, and which daemon URL it currently points at. Used by
33
// detectors so the install picker can pre-check rows that are already
4-
// installed and show "wired http://..." next to them.
4+
// installed and show "wired -> http://..." next to them.
55

66
import { existsSync, readFileSync } from "node:fs";
77
import { join } from "node:path";
@@ -14,10 +14,6 @@ export interface AgentlockState {
1414

1515
const NOT_INSTALLED: AgentlockState = { installed: false };
1616

17-
// originOf returns scheme://host[:port] for a parseable URL, or the
18-
// original string if URL parsing fails. Lets us collapse a per-hook
19-
// URL like "http://127.0.0.1:7878/v1/hooks/claude-code/pre-tool-use"
20-
// down to "http://127.0.0.1:7878" for the picker sub-line.
2117
function originOf(u: string): string {
2218
try {
2319
return new URL(u).origin;
@@ -26,10 +22,6 @@ function originOf(u: string): string {
2622
}
2723
}
2824

29-
// claudeAgentlockState reads a Claude Code settings.json and reports
30-
// whether agentlock-tagged hook entries (the `_agentlock: true` marker
31-
// applyClaudeCode writes) are present, plus the daemon URL if we can
32-
// pull one out of any embedded HTTP hook.
3325
export function claudeAgentlockState(settingsPath: string): AgentlockState {
3426
if (!existsSync(settingsPath)) return NOT_INSTALLED;
3527
let raw: string;
@@ -54,10 +46,6 @@ export function claudeAgentlockState(settingsPath: string): AgentlockState {
5446
if (!entry || typeof entry !== "object") continue;
5547
const e = entry as Record<string, unknown>;
5648
if (e._agentlock !== true) continue;
57-
// Prefer the URL from the first nested HTTP hook so the picker can
58-
// surface "wired → http://...:7878" without rendering all of them.
59-
// Strip the per-hook path (`/v1/hooks/...`) so the picker only
60-
// shows the daemon origin — that's the part the user is choosing.
6149
const inner = Array.isArray(e.hooks) ? (e.hooks as unknown[]) : [];
6250
for (const h of inner) {
6351
if (h && typeof h === "object") {
@@ -73,12 +61,6 @@ export function claudeAgentlockState(settingsPath: string): AgentlockState {
7361
return NOT_INSTALLED;
7462
}
7563

76-
// claudeDesktopAgentlockState reads a Claude Desktop config file
77-
// (claude_desktop_config.json) and reports whether agentlock is wired in
78-
// as an MCP server. Claude Desktop has no PreToolUse hook surface; the
79-
// only install we can do is registering an MCP server entry under
80-
// mcpServers. We mark our entry with `_agentlock: true` (an unknown key
81-
// MCP ignores) so uninstall can find it without name-collision risk.
8264
export function claudeDesktopAgentlockState(configPath: string): AgentlockState {
8365
if (!existsSync(configPath)) return NOT_INSTALLED;
8466
let raw: string;
@@ -101,8 +83,6 @@ export function claudeDesktopAgentlockState(configPath: string): AgentlockState
10183
if (!entry || typeof entry !== "object") continue;
10284
const e = entry as Record<string, unknown>;
10385
if (e._agentlock !== true) continue;
104-
// Daemon URL lives in `env.AGENTLOCK_DAEMON_URL` — same convention as
105-
// the Claude Code shim. No nested HTTP hook on this surface.
10686
const env = (e.env as Record<string, unknown> | undefined) ?? undefined;
10787
const url = env && typeof env.AGENTLOCK_DAEMON_URL === "string"
10888
? env.AGENTLOCK_DAEMON_URL
@@ -112,10 +92,52 @@ export function claudeDesktopAgentlockState(configPath: string): AgentlockState
11292
return NOT_INSTALLED;
11393
}
11494

115-
// devStubAgentlockState reads `.agentlock-dev.json` from a non-claude
116-
// harness's dev sandbox dir. The daemon's apply pipeline writes that
117-
// marker for harnesses without a real installer yet. Presence + the
118-
// stamped daemon_url are enough for the picker.
95+
export function codexAgentlockState(hooksPath: string): AgentlockState {
96+
if (!existsSync(hooksPath)) return NOT_INSTALLED;
97+
let raw: string;
98+
try {
99+
raw = readFileSync(hooksPath, "utf8");
100+
} catch {
101+
return NOT_INSTALLED;
102+
}
103+
let parsed: unknown;
104+
try {
105+
parsed = JSON.parse(raw);
106+
} catch {
107+
return NOT_INSTALLED;
108+
}
109+
const root =
110+
parsed && typeof parsed === "object" && !Array.isArray(parsed)
111+
? (parsed as Record<string, unknown>)
112+
: {};
113+
const hooks =
114+
root.hooks && typeof root.hooks === "object"
115+
? (root.hooks as Record<string, unknown>)
116+
: root;
117+
for (const list of Object.values(hooks)) {
118+
if (!Array.isArray(list)) continue;
119+
for (const entry of list) {
120+
if (!entry || typeof entry !== "object") continue;
121+
const e = entry as Record<string, unknown>;
122+
if (e._agentlock !== true) continue;
123+
const inner = Array.isArray(e.hooks) ? (e.hooks as unknown[]) : [];
124+
for (const h of inner) {
125+
if (!h || typeof h !== "object") continue;
126+
const hook = h as { env?: unknown };
127+
const env = hook.env;
128+
if (env && typeof env === "object") {
129+
const url = (env as { AGENTLOCK_DAEMON_URL?: unknown }).AGENTLOCK_DAEMON_URL;
130+
if (typeof url === "string" && url.length > 0) {
131+
return { installed: true, daemonURL: originOf(url) };
132+
}
133+
}
134+
}
135+
return { installed: true };
136+
}
137+
}
138+
return NOT_INSTALLED;
139+
}
140+
119141
export function devStubAgentlockState(harnessDir: string): AgentlockState {
120142
const marker = join(harnessDir, ".agentlock-dev.json");
121143
if (!existsSync(marker)) return NOT_INSTALLED;
@@ -128,17 +150,10 @@ export function devStubAgentlockState(harnessDir: string): AgentlockState {
128150
const url = typeof parsed.daemon_url === "string" ? parsed.daemon_url : undefined;
129151
return { installed: true, daemonURL: url };
130152
} catch {
131-
// Marker exists but is unparseable — treat as installed without a
132-
// URL so the picker still flags it. Better than hiding partial
133-
// state from the user.
134153
return { installed: true };
135154
}
136155
}
137156

138-
// devStubStateForHarness mirrors the daemon's devStubDir layout: every
139-
// non-claude harness writes its marker under `<home>/.<harnessId>/`.
140-
// Detectors call this with their HarnessId to get the same answer the
141-
// install picker needs without hand-rolling paths.
142157
export function devStubStateForHarness(harnessID: string): AgentlockState {
143158
return devStubAgentlockState(join(home(), "." + harnessID));
144159
}

cli/src/detect/codex-desktop.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { existsSync } from "node:fs";
2+
import { delimiter, join } from "node:path";
3+
import { appSupport, home, isMac, isWin, xdgConfigHome } from "../util/paths.ts";
4+
import { codexAgentlockState } from "./agentlock-state.ts";
5+
import type { Detection, DetectedScope, Detector } from "./types.ts";
6+
7+
function desktopAppCandidates(): string[] {
8+
if (process.env.AGENTLOCK_CODEX_DESKTOP_PATHS) {
9+
return process.env.AGENTLOCK_CODEX_DESKTOP_PATHS.split(delimiter)
10+
.map((p) => p.trim())
11+
.filter(Boolean);
12+
}
13+
if (isMac()) {
14+
return [
15+
"/Applications/Codex.app",
16+
join(home(), "Applications", "Codex.app"),
17+
join(appSupport(), "Codex"),
18+
];
19+
}
20+
if (isWin()) {
21+
return [
22+
join(process.env.LOCALAPPDATA ?? join(home(), "AppData", "Local"), "Programs", "Codex"),
23+
join(appSupport(), "Codex"),
24+
];
25+
}
26+
return [
27+
join(xdgConfigHome(), "Codex"),
28+
join(home(), ".local", "share", "Codex"),
29+
];
30+
}
31+
32+
// OpenAI Codex Desktop is a separate harness from the Codex CLI in the
33+
// picker. Current Desktop builds share ~/.codex/{config.toml,hooks.json}
34+
// with the CLI, so the installer has to treat CLI-only, Desktop-only,
35+
// and shared installs deliberately.
36+
export const codexDesktop: Detector = {
37+
id: "codex-desktop",
38+
displayName: "Codex Desktop (OpenAI)",
39+
40+
async detect(): Promise<Detection> {
41+
const candidates = desktopAppCandidates();
42+
const desktopEvidence = candidates
43+
.filter((p) => existsSync(p))
44+
.map((p) => `found ${p}`);
45+
const evidence = [...desktopEvidence];
46+
47+
const codexDir = join(home(), ".codex");
48+
const configToml = join(codexDir, "config.toml");
49+
const hooksJson = join(codexDir, "hooks.json");
50+
if (existsSync(configToml)) evidence.push(`found shared ${configToml}`);
51+
if (existsSync(hooksJson)) evidence.push(`found shared ${hooksJson}`);
52+
53+
const scopes: DetectedScope[] = [
54+
{ kind: "global", path: configToml, exists: existsSync(configToml) },
55+
];
56+
57+
const al = codexAgentlockState(hooksJson);
58+
59+
return {
60+
id: this.id,
61+
displayName: this.displayName,
62+
installed: desktopEvidence.length > 0,
63+
evidence,
64+
scopes,
65+
surfaces: ["lifecycle-hooks", "mcp-stdio"],
66+
notes: [
67+
"Codex Desktop is detected separately from Codex CLI.",
68+
"Codex Desktop shares ~/.codex/hooks.json with Codex CLI; selecting both installs a shared routing shim.",
69+
],
70+
agentlockInstalled: al.installed,
71+
agentlockDaemonURL: al.daemonURL,
72+
};
73+
},
74+
};

0 commit comments

Comments
 (0)