@remnic/plugin-claude-code 9.3.687 → 9.3.689
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/hooks/bin/remnic-cc-hook.cjs +917 -0
- package/hooks/bin/remnic-cc-hook.ps1 +10 -0
- package/hooks/bin/remnic-cc-hook.sh +7 -0
- package/hooks/bin/remnic-cc-hook.test.cjs +581 -0
- package/hooks/hooks.json +15 -3
- package/package.json +1 -1
- package/hooks/bin/post-tool-observe.sh +0 -279
- package/hooks/bin/session-end.sh +0 -38
- package/hooks/bin/session-start.sh +0 -254
- package/hooks/bin/user-prompt-recall.sh +0 -113
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env pwsh
|
|
2
|
+
# Thin PowerShell launcher for the unified Remnic Claude Code hook runner (#1518).
|
|
3
|
+
# All logic lives in remnic-cc-hook.cjs. We resolve the runner relative to
|
|
4
|
+
# this script's own location and exec node, inheriting stdin (the hook payload)
|
|
5
|
+
# directly so the JSON is passed through byte-for-byte with no re-encoding.
|
|
6
|
+
$ErrorActionPreference = 'Stop'
|
|
7
|
+
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
8
|
+
$runner = Join-Path $scriptDir 'remnic-cc-hook.cjs'
|
|
9
|
+
& node $runner @args
|
|
10
|
+
exit $LASTEXITCODE
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# Thin POSIX launcher for the unified Remnic Claude Code hook runner (#1518).
|
|
3
|
+
# All logic lives in remnic-cc-hook.cjs; this just resolves the runner
|
|
4
|
+
# relative to its own location and execs node with the event name + stdin.
|
|
5
|
+
set -eu
|
|
6
|
+
SCRIPT_DIR="$(cd -- "$(dirname -- "$0")" && pwd)"
|
|
7
|
+
exec node "$SCRIPT_DIR/remnic-cc-hook.cjs" "$@"
|
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Integration tests for the unified Claude Code hook runner (issue #1518).
|
|
4
|
+
// Each test spawns the real remnic-cc-hook.cjs against a mock Remnic HTTP
|
|
5
|
+
// server, with an isolated HOME/XDG_STATE_HOME, and asserts both the emitted
|
|
6
|
+
// hook JSON and the cursor/observe side effects. These tests cover the
|
|
7
|
+
// cross-platform failure classes called out in #1518:
|
|
8
|
+
// - shell-metacharacter session ids are rejected (no shell injection vector)
|
|
9
|
+
// - short prompts skipped, no-token skipped, dead-daemon guidance
|
|
10
|
+
// - cursor retention on failed final flush (no data loss)
|
|
11
|
+
// - payload via stdin (not env) so large edits do not E2BIG on Windows
|
|
12
|
+
// - hooks.json uses exec form (command + args) so Windows works without
|
|
13
|
+
// Git Bash and shell metacharacters in the plugin root cannot inject
|
|
14
|
+
|
|
15
|
+
const assert = require("node:assert/strict");
|
|
16
|
+
const test = require("node:test");
|
|
17
|
+
const http = require("node:http");
|
|
18
|
+
const fs = require("node:fs");
|
|
19
|
+
const os = require("node:os");
|
|
20
|
+
const path = require("node:path");
|
|
21
|
+
const { spawn } = require("node:child_process");
|
|
22
|
+
|
|
23
|
+
const RUNNER = path.join(__dirname, "remnic-cc-hook.cjs");
|
|
24
|
+
|
|
25
|
+
function startServer(handler) {
|
|
26
|
+
const calls = [];
|
|
27
|
+
const server = http.createServer((req, res) => {
|
|
28
|
+
let body = "";
|
|
29
|
+
req.on("data", (c) => (body += c));
|
|
30
|
+
req.on("end", () => {
|
|
31
|
+
let parsed = null;
|
|
32
|
+
try {
|
|
33
|
+
parsed = body ? JSON.parse(body) : null;
|
|
34
|
+
} catch {
|
|
35
|
+
parsed = body;
|
|
36
|
+
}
|
|
37
|
+
calls.push({ method: req.method, url: req.url, body: parsed });
|
|
38
|
+
handler(req, res, parsed);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
server.listen(0, "127.0.0.1", () => {
|
|
43
|
+
resolve({ server, port: server.address().port, calls });
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function mkHome() {
|
|
49
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "remnic-cc-test-"));
|
|
50
|
+
return dir;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Async spawn (NOT spawnSync) so the in-process mock HTTP server's event loop
|
|
54
|
+
// stays free to answer the runner's requests while it runs.
|
|
55
|
+
function runHook(event, input, { port, home, env = {} } = {}) {
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
const child = spawn(process.execPath, [RUNNER, event], {
|
|
58
|
+
env: {
|
|
59
|
+
...process.env,
|
|
60
|
+
HOME: home,
|
|
61
|
+
USERPROFILE: home,
|
|
62
|
+
XDG_STATE_HOME: path.join(home, "state"),
|
|
63
|
+
REMNIC_HOST: "127.0.0.1",
|
|
64
|
+
REMNIC_PORT: String(port),
|
|
65
|
+
// Default to an env token unless a test overrides it. When a test
|
|
66
|
+
// asks for "no token", explicitly clear BOTH legacy env vars so a
|
|
67
|
+
// value inherited from the parent shell (e.g.
|
|
68
|
+
// OPENCLAW_ENGRAM_ACCESS_TOKEN in a developer's environment) cannot
|
|
69
|
+
// leak through `...process.env` and make the no-token path take a
|
|
70
|
+
// token (#1518 test isolation).
|
|
71
|
+
OPENCLAW_REMNIC_ACCESS_TOKEN: env.token === null ? "" : env.token || "test-token",
|
|
72
|
+
OPENCLAW_ENGRAM_ACCESS_TOKEN: env.token === null ? "" : "",
|
|
73
|
+
...env.extra,
|
|
74
|
+
},
|
|
75
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
76
|
+
});
|
|
77
|
+
let stdout = "";
|
|
78
|
+
let stderr = "";
|
|
79
|
+
child.stdout.on("data", (d) => (stdout += d));
|
|
80
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
81
|
+
child.on("close", () => {
|
|
82
|
+
let json = null;
|
|
83
|
+
try {
|
|
84
|
+
json = JSON.parse(stdout.trim().split("\n").filter(Boolean).pop());
|
|
85
|
+
} catch {
|
|
86
|
+
/* leave null */
|
|
87
|
+
}
|
|
88
|
+
resolve({ stdout, stderr, json });
|
|
89
|
+
});
|
|
90
|
+
child.stdin.end(typeof input === "string" ? input : JSON.stringify(input));
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function transcript(home, messages) {
|
|
95
|
+
const file = path.join(home, "transcript.jsonl");
|
|
96
|
+
const lines = messages.map((m) => JSON.stringify({ type: m.role, message: { role: m.role, content: m.content } }));
|
|
97
|
+
fs.writeFileSync(file, lines.join("\n") + "\n");
|
|
98
|
+
return file;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function cursorPath(home, sessionId) {
|
|
102
|
+
return path.join(home, "state", "remnic", "hooks", `remnic-cursor-${sessionId}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── session-start ──────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
test("session-start: healthy server returns recall context with codingContext cleared outside a repo", async () => {
|
|
108
|
+
const home = mkHome();
|
|
109
|
+
const { server, port, calls } = await startServer((req, res) => {
|
|
110
|
+
if (req.url === "/engram/v1/health") return res.writeHead(200).end("ok");
|
|
111
|
+
if (req.url === "/engram/v1/recall") {
|
|
112
|
+
return res.writeHead(200, { "Content-Type": "application/json" }).end(
|
|
113
|
+
JSON.stringify({ context: "remembered preferences", count: 3, mode: "auto" }),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
res.writeHead(404).end();
|
|
117
|
+
});
|
|
118
|
+
try {
|
|
119
|
+
const { json } = await runHook("session-start", { session_id: "s1", cwd: home }, { port, home });
|
|
120
|
+
assert.equal(json.continue, true);
|
|
121
|
+
assert.match(json.hookSpecificOutput.additionalContext, /Remnic Memory Recall — 3 memories/);
|
|
122
|
+
assert.match(json.hookSpecificOutput.additionalContext, /remembered preferences/);
|
|
123
|
+
const recall = calls.find((c) => c.url === "/engram/v1/recall");
|
|
124
|
+
assert.ok(recall, "recall was called");
|
|
125
|
+
assert.equal(recall.body.mode, "auto");
|
|
126
|
+
assert.equal(recall.body.topK, 12);
|
|
127
|
+
// claude-code client id header
|
|
128
|
+
assert.equal(recall.method, "POST");
|
|
129
|
+
// Outside a git repo, codingContext is explicitly null (clears stale routing).
|
|
130
|
+
assert.ok("codingContext" in recall.body);
|
|
131
|
+
assert.equal(recall.body.codingContext, null);
|
|
132
|
+
} finally {
|
|
133
|
+
server.close();
|
|
134
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("session-start: falls back to minimal mode when full recall fails", async () => {
|
|
139
|
+
const home = mkHome();
|
|
140
|
+
let recallHits = 0;
|
|
141
|
+
const { server, port, calls } = await startServer((req, res) => {
|
|
142
|
+
if (req.url === "/engram/v1/health") return res.writeHead(200).end("ok");
|
|
143
|
+
if (req.url === "/engram/v1/recall") {
|
|
144
|
+
recallHits += 1;
|
|
145
|
+
if (recallHits === 1) return res.writeHead(500).end("boom");
|
|
146
|
+
return res.writeHead(200).end(JSON.stringify({ context: "fallback ctx", count: 1, mode: "minimal" }));
|
|
147
|
+
}
|
|
148
|
+
res.writeHead(404).end();
|
|
149
|
+
});
|
|
150
|
+
try {
|
|
151
|
+
const { json } = await runHook("session-start", { session_id: "s1", cwd: home }, { port, home });
|
|
152
|
+
assert.match(json.hookSpecificOutput.additionalContext, /minimal mode/);
|
|
153
|
+
const recalls = calls.filter((c) => c.url === "/engram/v1/recall");
|
|
154
|
+
assert.equal(recalls.length, 2);
|
|
155
|
+
assert.equal(recalls[1].body.mode, "minimal");
|
|
156
|
+
assert.equal(recalls[1].body.topK, 8);
|
|
157
|
+
} finally {
|
|
158
|
+
server.close();
|
|
159
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("session-start: no token → claude-code install hint, no recall call", async () => {
|
|
164
|
+
const home = mkHome();
|
|
165
|
+
const { server, port, calls } = await startServer((req, res) => {
|
|
166
|
+
if (req.url === "/engram/v1/health") return res.writeHead(200).end("ok");
|
|
167
|
+
res.writeHead(200).end("{}");
|
|
168
|
+
});
|
|
169
|
+
try {
|
|
170
|
+
const { json } = await runHook("session-start", { session_id: "s1", cwd: home }, { port, home, env: { token: null } });
|
|
171
|
+
assert.match(json.hookSpecificOutput.additionalContext, /no auth token/);
|
|
172
|
+
assert.match(json.hookSpecificOutput.additionalContext, /claude-code/);
|
|
173
|
+
assert.equal(calls.filter((c) => c.url === "/engram/v1/recall").length, 0);
|
|
174
|
+
} finally {
|
|
175
|
+
server.close();
|
|
176
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("session-start: dead daemon → distinct daemon-not-running message", async () => {
|
|
181
|
+
const home = mkHome();
|
|
182
|
+
// Use a port with no listener to simulate a dead daemon.
|
|
183
|
+
const dead = await startServer(() => {});
|
|
184
|
+
const port = dead.port;
|
|
185
|
+
dead.server.close();
|
|
186
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
187
|
+
const { json } = await runHook("session-start", { session_id: "s1", cwd: home }, { port, home });
|
|
188
|
+
assert.match(json.hookSpecificOutput.additionalContext, /daemon not running/);
|
|
189
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// ── user-prompt-recall ─────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
test("user-prompt-recall: short prompt is skipped with bare continue", async () => {
|
|
195
|
+
const home = mkHome();
|
|
196
|
+
const { server, port, calls } = await startServer((req, res) => res.writeHead(200).end("{}"));
|
|
197
|
+
try {
|
|
198
|
+
const { json } = await runHook("user-prompt-recall", { session_id: "s1", prompt: "hi there" }, { port, home });
|
|
199
|
+
assert.deepEqual(json, { continue: true });
|
|
200
|
+
assert.equal(calls.length, 0);
|
|
201
|
+
} finally {
|
|
202
|
+
server.close();
|
|
203
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("user-prompt-recall: no token → bare continue, no banner, no call", async () => {
|
|
208
|
+
const home = mkHome();
|
|
209
|
+
const { server, port, calls } = await startServer((req, res) => res.writeHead(200).end("{}"));
|
|
210
|
+
try {
|
|
211
|
+
const { json } = await runHook(
|
|
212
|
+
"user-prompt-recall",
|
|
213
|
+
{ session_id: "s1", prompt: "this is a sufficiently long prompt" },
|
|
214
|
+
{ port, home, env: { token: null } },
|
|
215
|
+
);
|
|
216
|
+
assert.deepEqual(json, { continue: true });
|
|
217
|
+
assert.equal(calls.length, 0);
|
|
218
|
+
} finally {
|
|
219
|
+
server.close();
|
|
220
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("user-prompt-recall: long prompt injects <remnic-memory> context", async () => {
|
|
225
|
+
const home = mkHome();
|
|
226
|
+
const { server, port, calls } = await startServer((req, res) => {
|
|
227
|
+
if (req.url === "/engram/v1/recall") {
|
|
228
|
+
return res.writeHead(200).end(JSON.stringify({ context: "rel ctx", count: 2 }));
|
|
229
|
+
}
|
|
230
|
+
res.writeHead(404).end();
|
|
231
|
+
});
|
|
232
|
+
try {
|
|
233
|
+
const { json } = await runHook(
|
|
234
|
+
"user-prompt-recall",
|
|
235
|
+
{ session_id: "s1", prompt: "please recall the deployment decisions we made" },
|
|
236
|
+
{ port, home },
|
|
237
|
+
);
|
|
238
|
+
assert.match(json.hookSpecificOutput.additionalContext, /<remnic-memory count="2">/);
|
|
239
|
+
assert.equal(calls[0].body.mode, "minimal");
|
|
240
|
+
} finally {
|
|
241
|
+
server.close();
|
|
242
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// ── post-tool-observe ──────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
test("observe worker: advances cursor only after a successful observe", async () => {
|
|
249
|
+
const home = mkHome();
|
|
250
|
+
const { server, port, calls } = await startServer((req, res) => res.writeHead(200).end("{}"));
|
|
251
|
+
try {
|
|
252
|
+
const tpath = transcript(home, [
|
|
253
|
+
{ role: "user", content: "first" },
|
|
254
|
+
{ role: "assistant", content: "second" },
|
|
255
|
+
]);
|
|
256
|
+
await runHook(
|
|
257
|
+
"__observe-worker__",
|
|
258
|
+
JSON.stringify({ session_id: "sObs", transcript_path: tpath }),
|
|
259
|
+
{ port, home },
|
|
260
|
+
);
|
|
261
|
+
const observe = calls.find((c) => c.url === "/engram/v1/observe");
|
|
262
|
+
assert.ok(observe, "observe was called");
|
|
263
|
+
assert.equal(observe.body.messages.length, 2);
|
|
264
|
+
assert.equal(fs.readFileSync(cursorPath(home, "sObs"), "utf8").trim(), "2");
|
|
265
|
+
} finally {
|
|
266
|
+
server.close();
|
|
267
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("observe worker: does NOT advance the cursor when observe fails", async () => {
|
|
272
|
+
const home = mkHome();
|
|
273
|
+
const { server, port } = await startServer((req, res) => {
|
|
274
|
+
if (req.url === "/engram/v1/observe") return res.writeHead(500).end("boom");
|
|
275
|
+
res.writeHead(200).end("{}");
|
|
276
|
+
});
|
|
277
|
+
try {
|
|
278
|
+
const tpath = transcript(home, [{ role: "user", content: "only" }]);
|
|
279
|
+
await runHook(
|
|
280
|
+
"__observe-worker__",
|
|
281
|
+
JSON.stringify({ session_id: "sFail", transcript_path: tpath }),
|
|
282
|
+
{ port, home },
|
|
283
|
+
);
|
|
284
|
+
assert.equal(fs.existsSync(cursorPath(home, "sFail")), false, "cursor must not be written on failure");
|
|
285
|
+
} finally {
|
|
286
|
+
server.close();
|
|
287
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("observe worker: shell-metacharacter session id is rejected (no injection vector)", async () => {
|
|
292
|
+
// A session id containing shell metacharacters must NOT reach the observe
|
|
293
|
+
// endpoint or the cursor filesystem path. The runner validates the id
|
|
294
|
+
// charset so a malicious payload field can never become a path traversal
|
|
295
|
+
// or a shell argument even if a future caller regresses to shell=true.
|
|
296
|
+
const home = mkHome();
|
|
297
|
+
const { server, port, calls } = await startServer((req, res) => res.writeHead(200).end("{}"));
|
|
298
|
+
try {
|
|
299
|
+
const tpath = transcript(home, [{ role: "user", content: "x" }]);
|
|
300
|
+
await runHook(
|
|
301
|
+
"__observe-worker__",
|
|
302
|
+
JSON.stringify({ session_id: "s; rm -rf /", transcript_path: tpath }),
|
|
303
|
+
{ port, home },
|
|
304
|
+
);
|
|
305
|
+
assert.equal(calls.filter((c) => c.url === "/engram/v1/observe").length, 0, "no observe for invalid session id");
|
|
306
|
+
assert.equal(
|
|
307
|
+
fs.existsSync(cursorPath(home, "s; rm -rf /")),
|
|
308
|
+
false,
|
|
309
|
+
"no cursor file written for invalid session id",
|
|
310
|
+
);
|
|
311
|
+
} finally {
|
|
312
|
+
server.close();
|
|
313
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("post-tool-observe: foreground emits continue immediately", async () => {
|
|
318
|
+
const home = mkHome();
|
|
319
|
+
const { server, port } = await startServer((req, res) => res.writeHead(200).end("{}"));
|
|
320
|
+
try {
|
|
321
|
+
const tpath = transcript(home, [{ role: "user", content: "x" }]);
|
|
322
|
+
const { json } = await runHook("post-tool-observe", { session_id: "sPt", transcript_path: tpath }, { port, home });
|
|
323
|
+
assert.deepEqual(json, { continue: true });
|
|
324
|
+
} finally {
|
|
325
|
+
server.close();
|
|
326
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("post-tool-observe: worker payload travels via STDIN, not the environment", async () => {
|
|
331
|
+
// Windows caps the environment block at ~32 KB. Large PostToolUse payloads
|
|
332
|
+
// (big file edits) would E2BIG; the worker reads stdin instead. We assert
|
|
333
|
+
// the source rather than running an E2BIG payload because reproducing the
|
|
334
|
+
// limit cross-platform is impractical.
|
|
335
|
+
const src = fs.readFileSync(RUNNER, "utf8");
|
|
336
|
+
assert.doesNotMatch(
|
|
337
|
+
src,
|
|
338
|
+
/REMNIC_HOOK_INPUT:\s*rawInput/,
|
|
339
|
+
"foreground hook must NOT propagate the payload via env (E2BIG on Windows)",
|
|
340
|
+
);
|
|
341
|
+
assert.match(
|
|
342
|
+
src,
|
|
343
|
+
/child\.stdin\.end\(rawInput\)/,
|
|
344
|
+
"foreground hook must write the payload to the worker's stdin",
|
|
345
|
+
);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("unknown event fails open with continue", async () => {
|
|
349
|
+
const home = mkHome();
|
|
350
|
+
const { server, port } = await startServer((req, res) => res.writeHead(200).end("{}"));
|
|
351
|
+
try {
|
|
352
|
+
const { json } = await runHook("bogus-event", {}, { port, home });
|
|
353
|
+
assert.deepEqual(json, { continue: true });
|
|
354
|
+
} finally {
|
|
355
|
+
server.close();
|
|
356
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// ── session-end ────────────────────────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
test("session-end: retains the cursor when the final flush fails (no data loss)", async () => {
|
|
363
|
+
const home = mkHome();
|
|
364
|
+
const { server, port } = await startServer((req, res) => {
|
|
365
|
+
if (req.url === "/engram/v1/observe") return res.writeHead(503).end("down");
|
|
366
|
+
res.writeHead(200).end("{}");
|
|
367
|
+
});
|
|
368
|
+
try {
|
|
369
|
+
const tpath = transcript(home, [{ role: "user", content: "pending tail" }]);
|
|
370
|
+
fs.mkdirSync(path.join(home, "state", "remnic", "hooks"), { recursive: true });
|
|
371
|
+
fs.writeFileSync(cursorPath(home, "sEnd"), "0\n");
|
|
372
|
+
const { json } = await runHook("session-end", { session_id: "sEnd", transcript_path: tpath }, { port, home });
|
|
373
|
+
assert.equal(json.continue, true);
|
|
374
|
+
// Cursor must be RETAINED for retry — the regression this fixes.
|
|
375
|
+
assert.equal(fs.existsSync(cursorPath(home, "sEnd")), true, "cursor retained after failed flush");
|
|
376
|
+
assert.equal(fs.readFileSync(cursorPath(home, "sEnd"), "utf8").trim(), "0");
|
|
377
|
+
} finally {
|
|
378
|
+
server.close();
|
|
379
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("session-end: removes the cursor after a successful final flush", async () => {
|
|
384
|
+
const home = mkHome();
|
|
385
|
+
const { server, port, calls } = await startServer((req, res) => res.writeHead(200).end("{}"));
|
|
386
|
+
try {
|
|
387
|
+
const tpath = transcript(home, [{ role: "user", content: "pending tail" }]);
|
|
388
|
+
fs.mkdirSync(path.join(home, "state", "remnic", "hooks"), { recursive: true });
|
|
389
|
+
fs.writeFileSync(cursorPath(home, "sEnd2"), "0\n");
|
|
390
|
+
await runHook("session-end", { session_id: "sEnd2", transcript_path: tpath }, { port, home });
|
|
391
|
+
assert.ok(calls.find((c) => c.url === "/engram/v1/observe"), "final flush observed");
|
|
392
|
+
assert.equal(fs.existsSync(cursorPath(home, "sEnd2")), false, "cursor cleared after successful flush");
|
|
393
|
+
} finally {
|
|
394
|
+
server.close();
|
|
395
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("session-end: skips final flush via a symlinked cursor (state hardening)", async () => {
|
|
400
|
+
const home = mkHome();
|
|
401
|
+
const { server, port, calls } = await startServer((req, res) => res.writeHead(200).end("{}"));
|
|
402
|
+
try {
|
|
403
|
+
const stateDir = path.join(home, "state", "remnic", "hooks");
|
|
404
|
+
fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
405
|
+
const cursor = cursorPath(home, "sUnsafe");
|
|
406
|
+
const symTarget = path.join(home, "target.txt");
|
|
407
|
+
fs.writeFileSync(symTarget, "unchanged\n");
|
|
408
|
+
fs.symlinkSync(symTarget, cursor);
|
|
409
|
+
const tpath = transcript(home, [{ role: "user", content: "would-be-pending" }]);
|
|
410
|
+
await runHook("session-end", { session_id: "sUnsafe", transcript_path: tpath }, { port, home });
|
|
411
|
+
assert.equal(calls.filter((c) => c.url === "/engram/v1/observe").length, 0);
|
|
412
|
+
assert.equal(fs.readFileSync(symTarget, "utf8"), "unchanged\n");
|
|
413
|
+
} finally {
|
|
414
|
+
server.close();
|
|
415
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// ── token resolution ───────────────────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
test("token resolution: claude-code connector wins over openclaw in tokens.json", async () => {
|
|
422
|
+
const home = mkHome();
|
|
423
|
+
fs.mkdirSync(path.join(home, ".remnic"), { recursive: true });
|
|
424
|
+
fs.writeFileSync(
|
|
425
|
+
path.join(home, ".remnic", "tokens.json"),
|
|
426
|
+
JSON.stringify({
|
|
427
|
+
tokens: [
|
|
428
|
+
{ connector: "openclaw", token: "openclaw-tok" },
|
|
429
|
+
{ connector: "claude-code", token: "cc-tok" },
|
|
430
|
+
],
|
|
431
|
+
}),
|
|
432
|
+
);
|
|
433
|
+
const { server, port, calls } = await startServer((req, res) => {
|
|
434
|
+
if (req.url === "/engram/v1/health") return res.writeHead(200).end("ok");
|
|
435
|
+
if (req.url === "/engram/v1/recall") return res.writeHead(200).end("{}");
|
|
436
|
+
res.writeHead(200).end("{}");
|
|
437
|
+
});
|
|
438
|
+
try {
|
|
439
|
+
// No env token — must come from tokens.json.
|
|
440
|
+
await runHook(
|
|
441
|
+
"session-start",
|
|
442
|
+
{ session_id: "sTok", cwd: home },
|
|
443
|
+
{ port, home, env: { token: null } },
|
|
444
|
+
);
|
|
445
|
+
const recall = calls.find((c) => c.url === "/engram/v1/recall");
|
|
446
|
+
assert.ok(recall, "recall was called using the tokens.json credential");
|
|
447
|
+
} finally {
|
|
448
|
+
server.close();
|
|
449
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test("token resolution: legacy ~/.engram/tokens.json is the read fallback", async () => {
|
|
454
|
+
const home = mkHome();
|
|
455
|
+
fs.mkdirSync(path.join(home, ".engram"), { recursive: true });
|
|
456
|
+
fs.writeFileSync(
|
|
457
|
+
path.join(home, ".engram", "tokens.json"),
|
|
458
|
+
JSON.stringify({ tokens: [{ connector: "openclaw", token: "legacy-tok" }] }),
|
|
459
|
+
);
|
|
460
|
+
const { server, port, calls } = await startServer((req, res) => {
|
|
461
|
+
if (req.url === "/engram/v1/health") return res.writeHead(200).end("ok");
|
|
462
|
+
if (req.url === "/engram/v1/recall") return res.writeHead(200).end("{}");
|
|
463
|
+
res.writeHead(200).end("{}");
|
|
464
|
+
});
|
|
465
|
+
try {
|
|
466
|
+
await runHook(
|
|
467
|
+
"session-start",
|
|
468
|
+
{ session_id: "sLeg", cwd: home },
|
|
469
|
+
{ port, home, env: { token: null } },
|
|
470
|
+
);
|
|
471
|
+
const recall = calls.find((c) => c.url === "/engram/v1/recall");
|
|
472
|
+
assert.ok(recall, "recall was called using the legacy engram token store");
|
|
473
|
+
} finally {
|
|
474
|
+
server.close();
|
|
475
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// ── hooks.json Windows parity ─────────────────────────────────────────────
|
|
480
|
+
|
|
481
|
+
test("hooks.json: every event uses cross-platform exec form with ${CLAUDE_PLUGIN_ROOT} (#1518)", () => {
|
|
482
|
+
const cfg = JSON.parse(
|
|
483
|
+
fs.readFileSync(path.join(__dirname, "..", "hooks.json"), "utf8"),
|
|
484
|
+
);
|
|
485
|
+
const eventToHookName = (event) => {
|
|
486
|
+
switch (event) {
|
|
487
|
+
case "SessionStart": return "session-start";
|
|
488
|
+
case "PostToolUse": return "post-tool-observe";
|
|
489
|
+
case "UserPromptSubmit": return "user-prompt-recall";
|
|
490
|
+
default: throw new Error(`unexpected event ${event}`);
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
for (const event of ["SessionStart", "PostToolUse", "UserPromptSubmit"]) {
|
|
494
|
+
const matchers = cfg.hooks[event] || [];
|
|
495
|
+
for (const matcher of matchers) {
|
|
496
|
+
for (const hook of matcher.hooks) {
|
|
497
|
+
// Claude Code hooks do not support `commandWindows` (that field is
|
|
498
|
+
// specific to the Codex plugin loader). The documented cross-
|
|
499
|
+
// platform shape is exec form: `command: "node"` with an `args`
|
|
500
|
+
// vector pointing at the runner. `node.exe` is a real binary on
|
|
501
|
+
// Windows, so the same entry works on every platform without a
|
|
502
|
+
// shell, without Git Bash, and without `.sh`/`.ps1` dispatch.
|
|
503
|
+
// Each `args` element is passed verbatim (no shell tokenization),
|
|
504
|
+
// so a plugin root containing spaces or metacharacters such as `$`
|
|
505
|
+
// or backticks cannot be re-interpreted by a shell.
|
|
506
|
+
assert.equal(
|
|
507
|
+
hook.command,
|
|
508
|
+
"node",
|
|
509
|
+
`${event}.command must be the bare executable "node" (exec form)`,
|
|
510
|
+
);
|
|
511
|
+
assert.ok(
|
|
512
|
+
Array.isArray(hook.args) && hook.args.length >= 2,
|
|
513
|
+
`${event} must declare an args vector [runner, event-name]`,
|
|
514
|
+
);
|
|
515
|
+
assert.ok(
|
|
516
|
+
typeof hook.args[0] === "string" &&
|
|
517
|
+
hook.args[0].startsWith("${CLAUDE_PLUGIN_ROOT}/hooks/bin/remnic-cc-hook.cjs"),
|
|
518
|
+
`${event}.args[0] must point at the bundled runner via \${CLAUDE_PLUGIN_ROOT}, got: ${hook.args && hook.args[0]}`,
|
|
519
|
+
);
|
|
520
|
+
assert.equal(hook.args[1], eventToHookName(event),
|
|
521
|
+
`${event}.args[1] must be the hook event name`);
|
|
522
|
+
// The shell-form fields must NOT be present: no `commandWindows`
|
|
523
|
+
// (unsupported by Claude Code), and `command` must not be a shell
|
|
524
|
+
// string that would let a shell re-tokenize the plugin root.
|
|
525
|
+
assert.ok(
|
|
526
|
+
hook.commandWindows === undefined,
|
|
527
|
+
`${event} must not declare commandWindows (Claude Code ignores it; exec form is the cross-platform path)`,
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
test("runner source: payload fields never reach a shell — spawn uses fixed argv (#1518 guard shell interpolation)", () => {
|
|
535
|
+
const src = fs.readFileSync(RUNNER, "utf8");
|
|
536
|
+
// Every spawn/spawnSync must use a fixed literal argument array, never a
|
|
537
|
+
// string command, so a payload value (session id, cwd, transcript path,
|
|
538
|
+
// prompt, tool name) cannot achieve command injection.
|
|
539
|
+
assert.doesNotMatch(
|
|
540
|
+
src,
|
|
541
|
+
/spawn(Sync)?\(\s*`/m,
|
|
542
|
+
"spawn/spawnSync must never take a template-literal command (shell injection risk)",
|
|
543
|
+
);
|
|
544
|
+
assert.doesNotMatch(
|
|
545
|
+
src,
|
|
546
|
+
/execFileSync\(\s*`|exec\(\s*`/m,
|
|
547
|
+
"exec/execFileSync must never take a template-literal command",
|
|
548
|
+
);
|
|
549
|
+
// Confirm the daemon-start + migration loops pre-check PATH with onPath()
|
|
550
|
+
// (.cmd/.exe-aware) so the remnic→engram fallthrough happens, and launch
|
|
551
|
+
// through a shell on Windows so `.cmd` npm shims actually run.
|
|
552
|
+
const onPathHits = (src.match(/onPath\(bin\)/g) || []).length;
|
|
553
|
+
assert.ok(onPathHits >= 2, "both migration and daemon-start loops must PATH-gate with onPath()");
|
|
554
|
+
assert.match(
|
|
555
|
+
src,
|
|
556
|
+
/shell:\s*process\.platform === "win32"/,
|
|
557
|
+
"CLI launches must use a shell on Windows so .cmd shims run",
|
|
558
|
+
);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test("runner source: stdin is the single payload source — no env-var override", () => {
|
|
562
|
+
const src = fs.readFileSync(RUNNER, "utf8");
|
|
563
|
+
assert.doesNotMatch(
|
|
564
|
+
src,
|
|
565
|
+
/process\.env\.REMNIC_HOOK_INPUT/,
|
|
566
|
+
"readStdin must not consult REMNIC_HOOK_INPUT (env-leak override risk)",
|
|
567
|
+
);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test("runner source: path inputs are type-validated before use (#1518 validate path types)", () => {
|
|
571
|
+
const src = fs.readFileSync(RUNNER, "utf8");
|
|
572
|
+
// cwd / transcript_path are coerced via `input.X || ""` so an unexpected
|
|
573
|
+
// non-string payload field (number, object, array) cannot reach fs / git
|
|
574
|
+
// and throw an opaque error or, worse, be coerced by Node to a path.
|
|
575
|
+
assert.match(src, /const cwd = input\.cwd \|\| ""/, "cwd must be defaulted to empty string");
|
|
576
|
+
assert.match(
|
|
577
|
+
src,
|
|
578
|
+
/const transcriptPath = input\.transcript_path \|\| ""/,
|
|
579
|
+
"transcript_path must be defaulted to empty string",
|
|
580
|
+
);
|
|
581
|
+
});
|
package/hooks/hooks.json
CHANGED
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
"hooks": [
|
|
7
7
|
{
|
|
8
8
|
"type": "command",
|
|
9
|
-
"command": "
|
|
9
|
+
"command": "node",
|
|
10
|
+
"args": [
|
|
11
|
+
"${CLAUDE_PLUGIN_ROOT}/hooks/bin/remnic-cc-hook.cjs",
|
|
12
|
+
"session-start"
|
|
13
|
+
],
|
|
10
14
|
"timeout": 45000
|
|
11
15
|
}
|
|
12
16
|
]
|
|
@@ -18,7 +22,11 @@
|
|
|
18
22
|
"hooks": [
|
|
19
23
|
{
|
|
20
24
|
"type": "command",
|
|
21
|
-
"command": "
|
|
25
|
+
"command": "node",
|
|
26
|
+
"args": [
|
|
27
|
+
"${CLAUDE_PLUGIN_ROOT}/hooks/bin/remnic-cc-hook.cjs",
|
|
28
|
+
"post-tool-observe"
|
|
29
|
+
],
|
|
22
30
|
"timeout": 10000
|
|
23
31
|
}
|
|
24
32
|
]
|
|
@@ -30,7 +38,11 @@
|
|
|
30
38
|
"hooks": [
|
|
31
39
|
{
|
|
32
40
|
"type": "command",
|
|
33
|
-
"command": "
|
|
41
|
+
"command": "node",
|
|
42
|
+
"args": [
|
|
43
|
+
"${CLAUDE_PLUGIN_ROOT}/hooks/bin/remnic-cc-hook.cjs",
|
|
44
|
+
"user-prompt-recall"
|
|
45
|
+
],
|
|
34
46
|
"timeout": 20000
|
|
35
47
|
}
|
|
36
48
|
]
|