Skip to content

Commit d0ae972

Browse files
grypezclaude
andcommitted
fix(caprock): break @Endo dependency chain in hook process
The hook process must not run SES lockdown because full lockdown freezes native prototypes and breaks tree-sitter's C++ bindings. @Endo modules call harden() and assert() at module-evaluation time, so any transitive import of @endo/* would crash the hook with 'harden is not defined' or 'Cannot initialize @endo/errors'. - Add harden-shim.ts: installs a no-op identity harden before any @Endo import can evaluate (ESM depth-first import order guarantees this runs first) - Inline sendCommand/readLine/writeLine/connectSocket into rpc.ts using only node:crypto and node:net, removing the import of @metamask/kernel-node-runtime/daemon which transitively pulled in @endo/promise-kit and @endo/errors - Add kernel-utils/session/provision lockdown-free subpath that re-exports only from types.ts and provision.ts (no channel.ts, no @endo/promise-kit); hook.ts imports from this subpath - Remove @metamask/kernel-node-runtime from caprock dependencies and tsconfig references - Fix hooks.json and scripts to use dist/bin/hook.mjs (ts-bridge with rootDir:. outputs bin/ under dist/bin/) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ebd5074 commit d0ae972

13 files changed

Lines changed: 250 additions & 22 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/*
2+
* No-op harden shim for the hook process.
3+
*
4+
* The hook is not a vat — it must not run SES lockdown because full lockdown
5+
* is incompatible with native tree-sitter bindings. @endo modules call
6+
* harden() at module-evaluation time, so we install a benign identity
7+
* function as the global before any @endo import evaluates.
8+
*
9+
* ESM evaluates modules depth-first in import order, so placing this as
10+
* the first import in hook.ts guarantees it runs before @endo/promise-kit.
11+
*/
12+
(globalThis as { harden?: <T>(value: T) => T }).harden ??= (value) => value;

packages/caprock/bin/hook.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77
* to the appropriate handler, writes control JSON to stdout if needed.
88
*/
99

10+
import './harden-shim.ts';
11+
1012
import type {
1113
ParsedInvocation,
1214
Provision,
13-
} from '@metamask/kernel-utils/session';
14-
import { invocationToProvision } from '@metamask/kernel-utils/session';
15+
} from '@metamask/kernel-utils/session/provision';
16+
import { invocationToProvision } from '@metamask/kernel-utils/session/provision';
1517
import { isJsonRpcFailure } from '@metamask/utils';
1618
import { spawn } from 'node:child_process';
1719
import { createHash } from 'node:crypto';
@@ -532,6 +534,7 @@ async function onPreToolUse(payload: PreToolUsePayload): Promise<void> {
532534
SOCKET_PATH,
533535
state.kernelSessionId,
534536
description,
537+
invocations === null ? undefined : { invocations },
535538
);
536539
} catch (error) {
537540
const errorStr = String(error);

packages/caprock/hooks/hooks.json

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"hooks": [
66
{
77
"type": "command",
8-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/hook.js\""
8+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/bin/hook.mjs\""
99
}
1010
]
1111
}
@@ -16,7 +16,7 @@
1616
"hooks": [
1717
{
1818
"type": "command",
19-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/hook.js\""
19+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/bin/hook.mjs\""
2020
}
2121
]
2222
}
@@ -26,7 +26,7 @@
2626
"hooks": [
2727
{
2828
"type": "command",
29-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/hook.js\""
29+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/bin/hook.mjs\""
3030
}
3131
]
3232
}
@@ -37,7 +37,7 @@
3737
"hooks": [
3838
{
3939
"type": "command",
40-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/hook.js\""
40+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/bin/hook.mjs\""
4141
}
4242
]
4343
}
@@ -47,7 +47,7 @@
4747
"hooks": [
4848
{
4949
"type": "command",
50-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/hook.js\""
50+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/bin/hook.mjs\""
5151
}
5252
]
5353
}
@@ -58,7 +58,7 @@
5858
"hooks": [
5959
{
6060
"type": "command",
61-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/hook.js\""
61+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/bin/hook.mjs\""
6262
}
6363
]
6464
}
@@ -68,7 +68,7 @@
6868
"hooks": [
6969
{
7070
"type": "command",
71-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/hook.js\""
71+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/bin/hook.mjs\""
7272
}
7373
]
7474
}

packages/caprock/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@
5050
},
5151
"dependencies": {
5252
"@endo/patterns": "^1.7.0",
53-
"@metamask/kernel-node-runtime": "workspace:^",
5453
"@metamask/kernel-utils": "workspace:^",
5554
"@metamask/sheaves": "workspace:^",
5655
"@metamask/utils": "^11.9.0",

packages/caprock/scripts/setup.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
#!/usr/bin/env bash
22
PLUGIN_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
3-
node "${PLUGIN_ROOT}/dist/setup.js" "$@"
3+
node "${PLUGIN_ROOT}/dist/bin/setup.mjs" "$@"

packages/caprock/scripts/status.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
#!/usr/bin/env bash
22
PLUGIN_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
3-
node "${PLUGIN_ROOT}/dist/status.js" "$@"
3+
node "${PLUGIN_ROOT}/dist/bin/status.mjs" "$@"

packages/caprock/src/paths/ocap-kernel.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
/* eslint-disable n/no-process-env */
22

3-
import { getSocketPath } from '@metamask/kernel-node-runtime/daemon';
43
import { getOcapHome } from '@metamask/kernel-utils/nodejs';
54
import { join } from 'node:path';
65

76
import { getPluginDataDir } from './plugin.ts';
87

9-
export { getOcapHome, getSocketPath };
8+
export { getOcapHome };
9+
10+
/**
11+
* Get the default daemon socket path.
12+
*
13+
* @returns The socket path.
14+
*/
15+
export function getSocketPath(): string {
16+
return join(getOcapHome(), 'daemon.sock');
17+
}
1018

1119
/**
1220
* Absolute path to the `~/.ocap/caprock/` state directory.

packages/caprock/src/rpc.ts

Lines changed: 175 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,171 @@
1-
import { sendCommand } from '@metamask/kernel-node-runtime/daemon';
2-
import { isJsonRpcFailure } from '@metamask/utils';
1+
import type { ParsedInvocation } from '@metamask/kernel-utils/session/provision';
2+
import type { JsonRpcResponse } from '@metamask/utils';
3+
import { assertIsJsonRpcResponse, isJsonRpcFailure } from '@metamask/utils';
4+
import { randomUUID } from 'node:crypto';
5+
import { createConnection } from 'node:net';
6+
import type { Socket } from 'node:net';
37

48
import type { CapData, Decision } from './types.ts';
59

6-
export { sendCommand };
10+
// ─── Minimal socket-RPC client (no @endo dependencies) ───────────────────────
11+
12+
/**
13+
* Options for {@link sendCommand}.
14+
*/
15+
export type SendCommandOptions = {
16+
/** The UNIX socket path. */
17+
socketPath: string;
18+
/** The RPC method name. */
19+
method: string;
20+
/** Optional method parameters. */
21+
params?: Record<string, unknown> | unknown[] | undefined;
22+
/** Read timeout in milliseconds (default: no timeout). */
23+
timeoutMs?: number | undefined;
24+
};
25+
26+
/**
27+
* @param socketPath - The socket path to connect to.
28+
* @returns A connected socket.
29+
*/
30+
async function connectSocket(socketPath: string): Promise<Socket> {
31+
return new Promise((resolve, reject) => {
32+
const socket = createConnection(socketPath, () => {
33+
socket.removeListener('error', reject);
34+
resolve(socket);
35+
});
36+
socket.on('error', reject);
37+
});
38+
}
39+
40+
/**
41+
* @param socket - The socket to write to.
42+
* @param line - The line to write (without trailing newline).
43+
*/
44+
async function writeLine(socket: Socket, line: string): Promise<void> {
45+
return new Promise((resolve, reject) => {
46+
socket.write(`${line}\n`, (error) => {
47+
if (error) {
48+
reject(error);
49+
} else {
50+
resolve();
51+
}
52+
});
53+
});
54+
}
55+
56+
/**
57+
* @param socket - The socket to read from.
58+
* @param timeoutMs - Optional timeout in milliseconds.
59+
* @returns The line read (without trailing newline).
60+
*/
61+
async function readLine(socket: Socket, timeoutMs?: number): Promise<string> {
62+
return new Promise((resolve, reject) => {
63+
let buffer = '';
64+
let timer: ReturnType<typeof setTimeout> | undefined;
65+
66+
if (timeoutMs !== undefined) {
67+
timer = setTimeout(() => {
68+
cleanup();
69+
reject(new Error('Socket read timed out'));
70+
}, timeoutMs);
71+
}
72+
73+
const onData = (data: Buffer): void => {
74+
buffer += data.toString();
75+
const idx = buffer.indexOf('\n');
76+
if (idx !== -1) {
77+
cleanup();
78+
resolve(buffer.slice(0, idx));
79+
}
80+
};
81+
82+
const onError = (error: Error): void => {
83+
cleanup();
84+
reject(error);
85+
};
86+
87+
const onEnd = (): void => {
88+
cleanup();
89+
reject(new Error('Socket closed before response received'));
90+
};
91+
92+
const onClose = (): void => {
93+
cleanup();
94+
reject(new Error('Socket closed before response received'));
95+
};
96+
97+
/** Remove listeners registered by this call and clear the timeout. */
98+
function cleanup(): void {
99+
if (timer !== undefined) {
100+
clearTimeout(timer);
101+
}
102+
socket.removeListener('data', onData);
103+
socket.removeListener('error', onError);
104+
socket.removeListener('end', onEnd);
105+
socket.removeListener('close', onClose);
106+
}
107+
108+
socket.on('data', onData);
109+
socket.once('error', onError);
110+
socket.once('end', onEnd);
111+
socket.once('close', onClose);
112+
});
113+
}
114+
115+
/**
116+
* Send a JSON-RPC request to the daemon over a UNIX socket and return the response.
117+
*
118+
* Opens a connection, writes one JSON-RPC request line, reads one JSON-RPC
119+
* response line, then closes the connection. Retries once after a short delay
120+
* if the connection is rejected.
121+
*
122+
* @param options - Command options.
123+
* @param options.socketPath - The UNIX socket path.
124+
* @param options.method - The RPC method name.
125+
* @param options.params - Optional method parameters.
126+
* @param options.timeoutMs - Read timeout in milliseconds (default: no timeout).
127+
* @returns The parsed JSON-RPC response.
128+
*/
129+
export async function sendCommand({
130+
socketPath,
131+
method,
132+
params,
133+
timeoutMs,
134+
}: SendCommandOptions): Promise<JsonRpcResponse> {
135+
const id = randomUUID();
136+
const request = {
137+
jsonrpc: '2.0',
138+
id,
139+
method,
140+
...(params === undefined ? {} : { params }),
141+
};
142+
143+
const attempt = async (): Promise<JsonRpcResponse> => {
144+
const socket = await connectSocket(socketPath);
145+
try {
146+
await writeLine(socket, JSON.stringify(request));
147+
const responseLine = await readLine(socket, timeoutMs);
148+
const parsed: unknown = JSON.parse(responseLine);
149+
assertIsJsonRpcResponse(parsed);
150+
return parsed;
151+
} finally {
152+
socket.destroy();
153+
}
154+
};
155+
156+
try {
157+
return await attempt();
158+
} catch (error: unknown) {
159+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
160+
if (code !== 'ECONNREFUSED' && code !== 'ECONNRESET') {
161+
throw error;
162+
}
163+
await new Promise((resolve) => setTimeout(resolve, 100));
164+
return attempt();
165+
}
166+
}
167+
168+
// ─── RPC helpers ──────────────────────────────────────────────────────────────
7169

8170
/**
9171
* Check whether the daemon is running.
@@ -56,16 +218,21 @@ export async function createKernelSession(
56218
* @param socketPath - The UNIX socket path.
57219
* @param kernelSessionId - The kernel session to route the request through.
58220
* @param description - Human-readable description of the requested operation.
59-
* @param options - Optional reason and client-side timeout.
221+
* @param options - Optional request metadata.
60222
* @param options.reason - Optional reason for the request.
61223
* @param options.timeoutMs - Optional client-side timeout in milliseconds.
224+
* @param options.invocations - Parsed invocations to forward to the TUI for the provision editor.
62225
* @returns The TUI's decision.
63226
*/
64227
export async function authorizeRequest(
65228
socketPath: string,
66229
kernelSessionId: string,
67230
description: string,
68-
options?: { reason?: string; timeoutMs?: number },
231+
options?: {
232+
reason?: string;
233+
timeoutMs?: number;
234+
invocations?: ParsedInvocation[];
235+
},
69236
): Promise<Decision> {
70237
const params: Record<string, unknown> = {
71238
sessionId: kernelSessionId,
@@ -77,6 +244,9 @@ export async function authorizeRequest(
77244
if (options?.timeoutMs !== undefined) {
78245
params.timeoutMs = options.timeoutMs;
79246
}
247+
if (options?.invocations !== undefined) {
248+
params.invocations = options.invocations;
249+
}
80250
const response = await sendCommand({
81251
socketPath,
82252
method: 'session.authorize',

packages/caprock/tsconfig.build.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
},
1010
"references": [
1111
{ "path": "../kernel-utils/tsconfig.build.json" },
12-
{ "path": "../kernel-node-runtime/tsconfig.build.json" },
1312
{ "path": "../sheaves/tsconfig.build.json" }
1413
],
1514
"files": [],

packages/caprock/tsconfig.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
"references": [
99
{ "path": "../repo-tools" },
1010
{ "path": "../kernel-utils" },
11-
{ "path": "../kernel-node-runtime" },
1211
{ "path": "../sheaves" }
1312
],
1413
"include": [

0 commit comments

Comments
 (0)