Skip to content

Commit 32d6795

Browse files
committed
Support Codex Desktop shared hooks
1 parent ad8c2e5 commit 32d6795

24 files changed

Lines changed: 946 additions & 158 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: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,20 @@ export function installAndResolveAgentlockBinary(): string {
5656
);
5757
}
5858
const indexPath = resolve(import.meta.dir, "..", "index.ts");
59+
const bunPath = resolve(process.execPath);
5960
const dir = binDir();
6061
const wrapper = join(dir, "agentlock");
61-
const body = `#!/usr/bin/env bash\nexec bun run "${indexPath}" "$@"\n`;
62+
const body = `#!/usr/bin/env bash\nexec ${shellQuote(bunPath)} run ${shellQuote(indexPath)} "$@"\n`;
6263
mkdirSync(dir, { recursive: true });
6364
writeFileSync(wrapper, body, { flag: "w" });
6465
chmodSync(wrapper, 0o755);
6566
return wrapper;
6667
}
6768

69+
function shellQuote(value: string): string {
70+
return `'${value.replaceAll("'", "'\\''")}'`;
71+
}
72+
6873
// Tiny health-check script wired into Claude Code's `statusLine` config.
6974
// Output renders as a UI element under the chat — never injected into the
7075
// model's input stream — so the user sees live "is the daemon up?" without
@@ -163,12 +168,14 @@ export async function runInstall(argv: string[] = []): Promise<void> {
163168
? {
164169
"claude-code": flags.configDirOverride,
165170
codex: flags.configDirOverride,
171+
"codex-desktop": flags.configDirOverride,
166172
cursor: flags.configDirOverride,
167173
gemini: flags.configDirOverride,
168174
}
169175
: {
170176
"claude-code": resolve(join(home(), ".claude")),
171177
codex: resolve(join(home(), ".codex")),
178+
"codex-desktop": resolve(join(home(), ".codex")),
172179
cursor: resolve(join(home(), ".cursor")),
173180
gemini: resolve(join(home(), ".gemini")),
174181
};
@@ -177,7 +184,11 @@ export async function runInstall(argv: string[] = []): Promise<void> {
177184
const devMode = !!process.env.AGENTLOCK_DEV_HOME;
178185
const results = await detectAll();
179186
const isMvpEnabled = (id: HarnessId): boolean =>
180-
id === "claude-code" || id === "codex" || id === "cursor" || id === "gemini";
187+
id === "claude-code" ||
188+
id === "codex" ||
189+
id === "codex-desktop" ||
190+
id === "cursor" ||
191+
id === "gemini";
181192
const options = results.map((r) => {
182193
const enabled = devMode || isMvpEnabled(r.id);
183194
let sub: string;
@@ -202,7 +213,11 @@ export async function runInstall(argv: string[] = []): Promise<void> {
202213
// is idempotent for claude and re-stamps the dev marker).
203214
checked: enabled && !!r.agentlockInstalled,
204215
disabled: !enabled,
205-
disabledReason: enabled ? undefined : "MVP: claude-code + codex + cursor only",
216+
disabledReason: enabled
217+
? undefined
218+
: r.id === "codex-desktop"
219+
? "Codex Desktop is supported through the shared Codex hooks; install Codex and trust the hook from /hooks"
220+
: "MVP: claude-code + codex + cursor + gemini only",
206221
};
207222
});
208223

@@ -337,7 +352,7 @@ export async function runInstall(argv: string[] = []): Promise<void> {
337352
if (!dir) continue;
338353
if (id === "claude-code") {
339354
uninstallPaths.push(resolve(join(dir, "settings.json")));
340-
} else if (id === "codex" || id === "cursor") {
355+
} else if (id === "codex" || id === "codex-desktop" || id === "cursor") {
341356
uninstallPaths.push(resolve(join(dir, "hooks.json")));
342357
} else if (id === "gemini") {
343358
// Gemini stuffs hook entries into the same settings.json as

cli/src/detect/agentlock-state.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,52 @@ export function claudeAgentlockState(settingsPath: string): AgentlockState {
7373
return NOT_INSTALLED;
7474
}
7575

76+
export function codexAgentlockState(hooksPath: string): AgentlockState {
77+
if (!existsSync(hooksPath)) return NOT_INSTALLED;
78+
let raw: string;
79+
try {
80+
raw = readFileSync(hooksPath, "utf8");
81+
} catch {
82+
return NOT_INSTALLED;
83+
}
84+
let parsed: unknown;
85+
try {
86+
parsed = JSON.parse(raw);
87+
} catch {
88+
return NOT_INSTALLED;
89+
}
90+
const root =
91+
parsed && typeof parsed === "object" && !Array.isArray(parsed)
92+
? (parsed as Record<string, unknown>)
93+
: {};
94+
const hooks =
95+
root.hooks && typeof root.hooks === "object"
96+
? (root.hooks as Record<string, unknown>)
97+
: root;
98+
for (const list of Object.values(hooks)) {
99+
if (!Array.isArray(list)) continue;
100+
for (const entry of list) {
101+
if (!entry || typeof entry !== "object") continue;
102+
const e = entry as Record<string, unknown>;
103+
if (e._agentlock !== true) continue;
104+
const inner = Array.isArray(e.hooks) ? (e.hooks as unknown[]) : [];
105+
for (const h of inner) {
106+
if (!h || typeof h !== "object") continue;
107+
const hook = h as { env?: unknown };
108+
const env = hook.env;
109+
if (env && typeof env === "object") {
110+
const url = (env as { AGENTLOCK_DAEMON_URL?: unknown }).AGENTLOCK_DAEMON_URL;
111+
if (typeof url === "string" && url.length > 0) {
112+
return { installed: true, daemonURL: originOf(url) };
113+
}
114+
}
115+
}
116+
return { installed: true };
117+
}
118+
}
119+
return NOT_INSTALLED;
120+
}
121+
76122
// devStubAgentlockState reads `.agentlock-dev.json` from a non-claude
77123
// harness's dev sandbox dir. The daemon's apply pipeline writes that
78124
// marker for harnesses without a real installer yet. Presence + the

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+
};

cli/src/detect/codex.ts

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import { existsSync, readFileSync } from "node:fs";
22
import { join } from "node:path";
33
import { home } from "../util/paths.ts";
4-
import { devStubStateForHarness } from "./agentlock-state.ts";
4+
import { codexAgentlockState } from "./agentlock-state.ts";
55
import type { Detector, Detection, DetectedScope } from "./types.ts";
66

77
// OpenAI Codex CLI: ~/.codex/{config.toml, auth.json, hooks.json}.
88
// Lifecycle hooks (PreToolUse / PostToolUse / SessionStart / Stop) are
9-
// available behind `codex_hooks = true` in config.toml. PreToolUse only
10-
// fires for shell calls today — MCP coverage is tracked upstream.
9+
// available behind `[features].hooks = true` in config.toml. PreToolUse
10+
// only fires for shell calls today — MCP coverage is tracked upstream.
1111
export const codex: Detector = {
1212
id: "codex",
1313
displayName: "Codex CLI (OpenAI)",
@@ -29,16 +29,16 @@ export const codex: Detector = {
2929
if (existsSync(configToml)) {
3030
evidence.push(
3131
flagEnabled
32-
? "config.toml: codex_hooks = true"
33-
: "config.toml: codex_hooks not set (install will refuse until enabled)",
32+
? "config.toml: [features].hooks = true"
33+
: "config.toml: [features].hooks not set (install will enable it)",
3434
);
3535
}
3636

3737
const scopes: DetectedScope[] = [
3838
{ kind: "global", path: configToml, exists: existsSync(configToml) },
3939
];
4040

41-
const al = devStubStateForHarness(this.id);
41+
const al = codexAgentlockState(hooksJson);
4242

4343
return {
4444
id: this.id,
@@ -48,7 +48,7 @@ export const codex: Detector = {
4848
scopes,
4949
surfaces: ["lifecycle-hooks", "mcp-stdio"],
5050
notes: [
51-
"Codex CLI hooks require `codex_hooks = true` in ~/.codex/config.toml.",
51+
"Codex hooks require `[features].hooks = true` in ~/.codex/config.toml.",
5252
"Bash-only today: PreToolUse does not fire for MCP tool calls (tracked upstream).",
5353
],
5454
agentlockInstalled: al.installed,
@@ -57,10 +57,10 @@ export const codex: Detector = {
5757
},
5858
};
5959

60-
// codexHooksFlagEnabled returns true when ~/.codex/config.toml has a
61-
// top-level `codex_hooks = true` line. We avoid pulling in a TOML parser
62-
// for a single-key probe: the simple line scan is good enough for
63-
// detection (the install handler does the same check authoritatively).
60+
// codexHooksFlagEnabled returns true when ~/.codex/config.toml enables
61+
// current `[features].hooks` or the legacy top-level `codex_hooks` flag.
62+
// We avoid pulling in a TOML parser for a single-key probe: the simple
63+
// section-aware scan is good enough for detection.
6464
function codexHooksFlagEnabled(configTomlPath: string): boolean {
6565
if (!existsSync(configTomlPath)) return false;
6666
let body: string;
@@ -69,12 +69,30 @@ function codexHooksFlagEnabled(configTomlPath: string): boolean {
6969
} catch {
7070
return false;
7171
}
72+
let inFeatures = false;
73+
let seenSection = false;
7274
for (const raw of body.split(/\r?\n/)) {
7375
const line = raw.trim();
7476
if (!line || line.startsWith("#")) continue;
75-
if (line.startsWith("[")) break; // first section ends top-level keys
76-
const m = line.match(/^codex_hooks\s*=\s*(true|false)\b/);
77-
if (m) return m[1] === "true";
77+
if (line.startsWith("[")) {
78+
seenSection = true;
79+
const header = line.split("#", 1)[0]?.trim() ?? "";
80+
const name =
81+
header.startsWith("[") && header.endsWith("]")
82+
? header.slice(1, -1).trim()
83+
: "";
84+
inFeatures = name === "features";
85+
continue;
86+
}
87+
if (inFeatures) {
88+
const m = line.match(/^hooks\s*=\s*(true|false)\b/);
89+
if (m) return m[1] === "true";
90+
continue;
91+
}
92+
if (!seenSection) {
93+
const legacy = line.match(/^codex_hooks\s*=\s*(true|false)\b/);
94+
if (legacy) return legacy[1] === "true";
95+
}
7896
}
7997
return false;
8098
}

cli/src/detect/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { claudeCode } from "./claude-code.ts";
44
import { cline } from "./cline.ts";
55
import { codex } from "./codex.ts";
6+
import { codexDesktop } from "./codex-desktop.ts";
67
import { continueDev } from "./continue-dev.ts";
78
import { cursor } from "./cursor.ts";
89
import { gemini } from "./gemini.ts";
@@ -16,6 +17,7 @@ import type { Detection, Detector } from "./types.ts";
1617
export const ALL_DETECTORS: Detector[] = [
1718
claudeCode,
1819
codex,
20+
codexDesktop,
1921
opencode,
2022
cursor,
2123
vscodeCopilot,

cli/src/detect/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
export type HarnessId =
55
| "claude-code"
66
| "codex"
7+
| "codex-desktop"
78
| "opencode"
89
| "cursor"
910
| "cline"

0 commit comments

Comments
 (0)