@remnic/plugin-codex 9.3.613 → 9.3.614
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/README.md +2 -2
- package/hooks/bin/remnic-codex-hook.cjs +969 -0
- package/hooks/bin/remnic-codex-hook.ps1 +10 -0
- package/hooks/bin/remnic-codex-hook.sh +7 -0
- package/hooks/bin/remnic-codex-hook.test.cjs +512 -0
- package/hooks/hooks.json +8 -4
- package/package.json +2 -2
- package/hooks/bin/post-tool-observe.sh +0 -280
- package/hooks/bin/session-end.sh +0 -281
- package/hooks/bin/session-start.sh +0 -257
- package/hooks/bin/user-prompt-recall.sh +0 -114
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env pwsh
|
|
2
|
+
# Thin PowerShell launcher for the unified Remnic Codex hook runner (issue #1440).
|
|
3
|
+
# All logic lives in remnic-codex-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-codex-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 Codex hook runner (issue #1440).
|
|
3
|
+
# All logic lives in remnic-codex-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-codex-hook.cjs" "$@"
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Integration tests for the unified Codex hook runner (issue #1440).
|
|
4
|
+
// Each test spawns the real remnic-codex-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 — including the regressions
|
|
7
|
+
// fixed relative to the original PR (cursor retention on failed final flush).
|
|
8
|
+
|
|
9
|
+
const assert = require("node:assert/strict");
|
|
10
|
+
const test = require("node:test");
|
|
11
|
+
const http = require("node:http");
|
|
12
|
+
const fs = require("node:fs");
|
|
13
|
+
const os = require("node:os");
|
|
14
|
+
const path = require("node:path");
|
|
15
|
+
const { spawn } = require("node:child_process");
|
|
16
|
+
|
|
17
|
+
const RUNNER = path.join(__dirname, "remnic-codex-hook.cjs");
|
|
18
|
+
|
|
19
|
+
function startServer(handler) {
|
|
20
|
+
const calls = [];
|
|
21
|
+
const server = http.createServer((req, res) => {
|
|
22
|
+
let body = "";
|
|
23
|
+
req.on("data", (c) => (body += c));
|
|
24
|
+
req.on("end", () => {
|
|
25
|
+
let parsed = null;
|
|
26
|
+
try {
|
|
27
|
+
parsed = body ? JSON.parse(body) : null;
|
|
28
|
+
} catch {
|
|
29
|
+
parsed = body;
|
|
30
|
+
}
|
|
31
|
+
calls.push({ method: req.method, url: req.url, body: parsed });
|
|
32
|
+
handler(req, res, parsed);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
server.listen(0, "127.0.0.1", () => {
|
|
37
|
+
resolve({ server, port: server.address().port, calls });
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function mkHome() {
|
|
43
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "remnic-codex-test-"));
|
|
44
|
+
return dir;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Async spawn (NOT spawnSync) so the in-process mock HTTP server's event loop
|
|
48
|
+
// stays free to answer the runner's requests while it runs.
|
|
49
|
+
function runHook(event, input, { port, home, env = {} } = {}) {
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
const child = spawn(process.execPath, [RUNNER, event], {
|
|
52
|
+
env: {
|
|
53
|
+
...process.env,
|
|
54
|
+
HOME: home,
|
|
55
|
+
USERPROFILE: home,
|
|
56
|
+
XDG_STATE_HOME: path.join(home, "state"),
|
|
57
|
+
REMNIC_HOST: "127.0.0.1",
|
|
58
|
+
REMNIC_PORT: String(port),
|
|
59
|
+
REMNIC_CODEX_MATERIALIZE: "0",
|
|
60
|
+
// Default to an env token unless a test overrides it.
|
|
61
|
+
OPENCLAW_REMNIC_ACCESS_TOKEN: env.token === null ? "" : env.token || "test-token",
|
|
62
|
+
...env.extra,
|
|
63
|
+
},
|
|
64
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
65
|
+
});
|
|
66
|
+
let stdout = "";
|
|
67
|
+
let stderr = "";
|
|
68
|
+
child.stdout.on("data", (d) => (stdout += d));
|
|
69
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
70
|
+
child.on("close", () => {
|
|
71
|
+
let json = null;
|
|
72
|
+
try {
|
|
73
|
+
json = JSON.parse(stdout.trim().split("\n").filter(Boolean).pop());
|
|
74
|
+
} catch {
|
|
75
|
+
/* leave null */
|
|
76
|
+
}
|
|
77
|
+
resolve({ stdout, stderr, json });
|
|
78
|
+
});
|
|
79
|
+
child.stdin.end(typeof input === "string" ? input : JSON.stringify(input));
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function transcript(home, messages) {
|
|
84
|
+
const file = path.join(home, "transcript.jsonl");
|
|
85
|
+
const lines = messages.map((m) => JSON.stringify({ type: m.role, message: { role: m.role, content: m.content } }));
|
|
86
|
+
fs.writeFileSync(file, lines.join("\n") + "\n");
|
|
87
|
+
return file;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function cursorPath(home, sessionId) {
|
|
91
|
+
return path.join(home, "state", "remnic", "hooks", `remnic-cursor-${sessionId}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
test("session-start: healthy server returns recall context with codingContext cleared outside a repo", async () => {
|
|
95
|
+
const home = mkHome();
|
|
96
|
+
const { server, port, calls } = await startServer((req, res, body) => {
|
|
97
|
+
if (req.url === "/engram/v1/health") return res.writeHead(200).end("ok");
|
|
98
|
+
if (req.url === "/engram/v1/recall") {
|
|
99
|
+
return res.writeHead(200, { "Content-Type": "application/json" }).end(
|
|
100
|
+
JSON.stringify({ context: "remembered preferences", count: 3, mode: "auto" }),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
res.writeHead(404).end();
|
|
104
|
+
});
|
|
105
|
+
try {
|
|
106
|
+
const { json } = await runHook("session-start", { session_id: "s1", cwd: home }, { port, home });
|
|
107
|
+
assert.equal(json.continue, true);
|
|
108
|
+
assert.match(json.hookSpecificOutput.additionalContext, /Remnic Memory Recall — 3 memories/);
|
|
109
|
+
assert.match(json.hookSpecificOutput.additionalContext, /remembered preferences/);
|
|
110
|
+
const recall = calls.find((c) => c.url === "/engram/v1/recall");
|
|
111
|
+
assert.ok(recall, "recall was called");
|
|
112
|
+
assert.equal(recall.body.mode, "auto");
|
|
113
|
+
assert.equal(recall.body.topK, 12);
|
|
114
|
+
// Outside a git repo, codingContext is explicitly null (clears stale routing).
|
|
115
|
+
assert.ok("codingContext" in recall.body);
|
|
116
|
+
assert.equal(recall.body.codingContext, null);
|
|
117
|
+
} finally {
|
|
118
|
+
server.close();
|
|
119
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("session-start: falls back to minimal mode when full recall fails", async () => {
|
|
124
|
+
const home = mkHome();
|
|
125
|
+
let recallHits = 0;
|
|
126
|
+
const { server, port, calls } = await startServer((req, res) => {
|
|
127
|
+
if (req.url === "/engram/v1/health") return res.writeHead(200).end("ok");
|
|
128
|
+
if (req.url === "/engram/v1/recall") {
|
|
129
|
+
recallHits += 1;
|
|
130
|
+
if (recallHits === 1) return res.writeHead(500).end("boom");
|
|
131
|
+
return res.writeHead(200).end(JSON.stringify({ context: "fallback ctx", count: 1, mode: "minimal" }));
|
|
132
|
+
}
|
|
133
|
+
res.writeHead(404).end();
|
|
134
|
+
});
|
|
135
|
+
try {
|
|
136
|
+
const { json } = await runHook("session-start", { session_id: "s1", cwd: home }, { port, home });
|
|
137
|
+
assert.match(json.hookSpecificOutput.additionalContext, /minimal mode/);
|
|
138
|
+
const recalls = calls.filter((c) => c.url === "/engram/v1/recall");
|
|
139
|
+
assert.equal(recalls.length, 2);
|
|
140
|
+
assert.equal(recalls[1].body.mode, "minimal");
|
|
141
|
+
assert.equal(recalls[1].body.topK, 8);
|
|
142
|
+
} finally {
|
|
143
|
+
server.close();
|
|
144
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("session-start: no token → guidance message, no recall call", async () => {
|
|
149
|
+
const home = mkHome();
|
|
150
|
+
const { server, port, calls } = await startServer((req, res) => {
|
|
151
|
+
if (req.url === "/engram/v1/health") return res.writeHead(200).end("ok");
|
|
152
|
+
res.writeHead(200).end("{}");
|
|
153
|
+
});
|
|
154
|
+
try {
|
|
155
|
+
const { json } = await runHook("session-start", { session_id: "s1", cwd: home }, { port, home, env: { token: null } });
|
|
156
|
+
assert.match(json.hookSpecificOutput.additionalContext, /no auth token/);
|
|
157
|
+
assert.equal(calls.filter((c) => c.url === "/engram/v1/recall").length, 0);
|
|
158
|
+
} finally {
|
|
159
|
+
server.close();
|
|
160
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("session-start: dead daemon → distinct daemon-not-running message", async () => {
|
|
165
|
+
const home = mkHome();
|
|
166
|
+
// Use a port with no listener to simulate a dead daemon.
|
|
167
|
+
const dead = await startServer(() => {});
|
|
168
|
+
const port = dead.port;
|
|
169
|
+
dead.server.close();
|
|
170
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
171
|
+
const { json } = await runHook("session-start", { session_id: "s1", cwd: home }, { port, home });
|
|
172
|
+
assert.match(json.hookSpecificOutput.additionalContext, /daemon not running/);
|
|
173
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("user-prompt-recall: short prompt is skipped with bare continue", async () => {
|
|
177
|
+
const home = mkHome();
|
|
178
|
+
const { server, port, calls } = await startServer((req, res) => res.writeHead(200).end("{}"));
|
|
179
|
+
try {
|
|
180
|
+
const { json } = await runHook("user-prompt-recall", { session_id: "s1", prompt: "hi there" }, { port, home });
|
|
181
|
+
assert.deepEqual(json, { continue: true });
|
|
182
|
+
assert.equal(calls.length, 0);
|
|
183
|
+
} finally {
|
|
184
|
+
server.close();
|
|
185
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("user-prompt-recall: no token → bare continue, no banner, no call", async () => {
|
|
190
|
+
const home = mkHome();
|
|
191
|
+
const { server, port, calls } = await startServer((req, res) => res.writeHead(200).end("{}"));
|
|
192
|
+
try {
|
|
193
|
+
const { json } = await runHook(
|
|
194
|
+
"user-prompt-recall",
|
|
195
|
+
{ session_id: "s1", prompt: "this is a sufficiently long prompt" },
|
|
196
|
+
{ port, home, env: { token: null } },
|
|
197
|
+
);
|
|
198
|
+
assert.deepEqual(json, { continue: true });
|
|
199
|
+
assert.equal(calls.length, 0);
|
|
200
|
+
} finally {
|
|
201
|
+
server.close();
|
|
202
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("user-prompt-recall: long prompt injects <remnic-memory> context", async () => {
|
|
207
|
+
const home = mkHome();
|
|
208
|
+
const { server, port, calls } = await startServer((req, res) => {
|
|
209
|
+
if (req.url === "/engram/v1/recall") {
|
|
210
|
+
return res.writeHead(200).end(JSON.stringify({ context: "rel ctx", count: 2 }));
|
|
211
|
+
}
|
|
212
|
+
res.writeHead(404).end();
|
|
213
|
+
});
|
|
214
|
+
try {
|
|
215
|
+
const { json } = await runHook(
|
|
216
|
+
"user-prompt-recall",
|
|
217
|
+
{ session_id: "s1", prompt: "please recall the deployment decisions we made" },
|
|
218
|
+
{ port, home },
|
|
219
|
+
);
|
|
220
|
+
assert.match(json.hookSpecificOutput.additionalContext, /<remnic-memory count="2">/);
|
|
221
|
+
assert.equal(calls[0].body.mode, "minimal");
|
|
222
|
+
} finally {
|
|
223
|
+
server.close();
|
|
224
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("observe worker: advances cursor only after a successful observe", async () => {
|
|
229
|
+
const home = mkHome();
|
|
230
|
+
const { server, port, calls } = await startServer((req, res) => res.writeHead(200).end("{}"));
|
|
231
|
+
try {
|
|
232
|
+
const tpath = transcript(home, [
|
|
233
|
+
{ role: "user", content: "first" },
|
|
234
|
+
{ role: "assistant", content: "second" },
|
|
235
|
+
]);
|
|
236
|
+
// Run the worker mode (foreground spawns this detached), payload via stdin.
|
|
237
|
+
await runHook(
|
|
238
|
+
"__observe-worker__",
|
|
239
|
+
JSON.stringify({ session_id: "sObs", transcript_path: tpath }),
|
|
240
|
+
{ port, home },
|
|
241
|
+
);
|
|
242
|
+
const observe = calls.find((c) => c.url === "/engram/v1/observe");
|
|
243
|
+
assert.ok(observe, "observe was called");
|
|
244
|
+
assert.equal(observe.body.messages.length, 2);
|
|
245
|
+
assert.equal(fs.readFileSync(cursorPath(home, "sObs"), "utf8").trim(), "2");
|
|
246
|
+
} finally {
|
|
247
|
+
server.close();
|
|
248
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("observe worker: does NOT advance the cursor when observe fails", async () => {
|
|
253
|
+
const home = mkHome();
|
|
254
|
+
const { server, port } = await startServer((req, res) => {
|
|
255
|
+
if (req.url === "/engram/v1/observe") return res.writeHead(500).end("boom");
|
|
256
|
+
res.writeHead(200).end("{}");
|
|
257
|
+
});
|
|
258
|
+
try {
|
|
259
|
+
const tpath = transcript(home, [{ role: "user", content: "only" }]);
|
|
260
|
+
await runHook(
|
|
261
|
+
"__observe-worker__",
|
|
262
|
+
JSON.stringify({ session_id: "sFail", transcript_path: tpath }),
|
|
263
|
+
{ port, home },
|
|
264
|
+
);
|
|
265
|
+
assert.equal(fs.existsSync(cursorPath(home, "sFail")), false, "cursor must not be written on failure");
|
|
266
|
+
} finally {
|
|
267
|
+
server.close();
|
|
268
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("session-end: retains the cursor when the final flush fails (no data loss)", async () => {
|
|
273
|
+
const home = mkHome();
|
|
274
|
+
const { server, port } = await startServer((req, res) => {
|
|
275
|
+
if (req.url === "/engram/v1/observe") return res.writeHead(503).end("down");
|
|
276
|
+
res.writeHead(200).end("{}");
|
|
277
|
+
});
|
|
278
|
+
try {
|
|
279
|
+
// Seed a cursor at 0 with one pending message so a flush is attempted.
|
|
280
|
+
const tpath = transcript(home, [{ role: "user", content: "pending tail" }]);
|
|
281
|
+
fs.mkdirSync(path.join(home, "state", "remnic", "hooks"), { recursive: true });
|
|
282
|
+
fs.writeFileSync(cursorPath(home, "sEnd"), "0\n");
|
|
283
|
+
const { json } = await runHook("session-end", { session_id: "sEnd", transcript_path: tpath }, { port, home });
|
|
284
|
+
assert.equal(json.continue, true);
|
|
285
|
+
// Cursor must be RETAINED for retry — the regression this fixes.
|
|
286
|
+
assert.equal(fs.existsSync(cursorPath(home, "sEnd")), true, "cursor retained after failed flush");
|
|
287
|
+
assert.equal(fs.readFileSync(cursorPath(home, "sEnd"), "utf8").trim(), "0");
|
|
288
|
+
} finally {
|
|
289
|
+
server.close();
|
|
290
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("session-end: removes the cursor after a successful final flush", async () => {
|
|
295
|
+
const home = mkHome();
|
|
296
|
+
const { server, port, calls } = await startServer((req, res) => res.writeHead(200).end("{}"));
|
|
297
|
+
try {
|
|
298
|
+
const tpath = transcript(home, [{ role: "user", content: "pending tail" }]);
|
|
299
|
+
fs.mkdirSync(path.join(home, "state", "remnic", "hooks"), { recursive: true });
|
|
300
|
+
fs.writeFileSync(cursorPath(home, "sEnd2"), "0\n");
|
|
301
|
+
await runHook("session-end", { session_id: "sEnd2", transcript_path: tpath }, { port, home });
|
|
302
|
+
assert.ok(calls.find((c) => c.url === "/engram/v1/observe"), "final flush observed");
|
|
303
|
+
assert.equal(fs.existsSync(cursorPath(home, "sEnd2")), false, "cursor cleared after successful flush");
|
|
304
|
+
} finally {
|
|
305
|
+
server.close();
|
|
306
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("post-tool-observe: foreground emits continue immediately", async () => {
|
|
311
|
+
const home = mkHome();
|
|
312
|
+
const { server, port } = await startServer((req, res) => res.writeHead(200).end("{}"));
|
|
313
|
+
try {
|
|
314
|
+
const tpath = transcript(home, [{ role: "user", content: "x" }]);
|
|
315
|
+
const { json } = await runHook("post-tool-observe", { session_id: "sPt", transcript_path: tpath }, { port, home });
|
|
316
|
+
assert.deepEqual(json, { continue: true });
|
|
317
|
+
} finally {
|
|
318
|
+
server.close();
|
|
319
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("unknown event fails open with continue", async () => {
|
|
324
|
+
const home = mkHome();
|
|
325
|
+
const { server, port } = await startServer((req, res) => res.writeHead(200).end("{}"));
|
|
326
|
+
try {
|
|
327
|
+
const { json } = await runHook("bogus-event", {}, { port, home });
|
|
328
|
+
assert.deepEqual(json, { continue: true });
|
|
329
|
+
} finally {
|
|
330
|
+
server.close();
|
|
331
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// ── #1443 review fixes ──────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
test("hooks.json: every event resolves via ${PLUGIN_ROOT} and uses powershell (#1443 review)", () => {
|
|
338
|
+
const cfg = JSON.parse(
|
|
339
|
+
fs.readFileSync(path.join(__dirname, "..", "hooks.json"), "utf8"),
|
|
340
|
+
);
|
|
341
|
+
for (const event of ["SessionStart", "PostToolUse", "UserPromptSubmit", "Stop"]) {
|
|
342
|
+
for (const matcher of cfg.hooks[event]) {
|
|
343
|
+
for (const hook of matcher.hooks) {
|
|
344
|
+
// Codex runs plugin hooks from the session cwd via sh -lc / cmd /C and
|
|
345
|
+
// substitutes ${PLUGIN_ROOT} (openai/codex discovery.rs + command_runner.rs),
|
|
346
|
+
// so the path must be PLUGIN_ROOT-relative AND quoted — an unquoted path
|
|
347
|
+
// would word-split on a plugin root containing spaces (e.g.
|
|
348
|
+
// C:\Users\Jane Doe).
|
|
349
|
+
assert.ok(
|
|
350
|
+
hook.command.startsWith('"${PLUGIN_ROOT}/hooks/bin/'),
|
|
351
|
+
`${event}.command must resolve via a quoted \${PLUGIN_ROOT}, got: ${hook.command}`,
|
|
352
|
+
);
|
|
353
|
+
assert.match(
|
|
354
|
+
hook.command,
|
|
355
|
+
/^"\$\{PLUGIN_ROOT\}\/hooks\/bin\/remnic-codex-hook\.sh"\s/,
|
|
356
|
+
`${event}.command path must be wrapped in double quotes`,
|
|
357
|
+
);
|
|
358
|
+
assert.ok(hook.commandWindows, `${event} must declare commandWindows`);
|
|
359
|
+
assert.match(
|
|
360
|
+
hook.commandWindows,
|
|
361
|
+
/-File "\$\{PLUGIN_ROOT\}\\hooks\\bin\\remnic-codex-hook\.ps1"\s/,
|
|
362
|
+
`${event}.commandWindows must pass a quoted \${PLUGIN_ROOT} -File path`,
|
|
363
|
+
);
|
|
364
|
+
// Use `powershell` not `pwsh` so stock Windows 10/11 works without
|
|
365
|
+
// PowerShell 7 installed (#1443 review).
|
|
366
|
+
assert.match(
|
|
367
|
+
hook.commandWindows,
|
|
368
|
+
/^powershell\b/,
|
|
369
|
+
`${event}.commandWindows must invoke powershell (not pwsh) for stock Windows compatibility`,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("runner source: remnic→engram fallthrough is PATH-gated and Windows-shim aware (#1443 review)", () => {
|
|
377
|
+
const src = fs.readFileSync(path.join(__dirname, "remnic-codex-hook.cjs"), "utf8");
|
|
378
|
+
// Both the migration and daemon-start loops pre-check PATH with onPath()
|
|
379
|
+
// (.cmd/.exe-aware) so the remnic→engram fallthrough happens, and launch
|
|
380
|
+
// through a shell on Windows so `.cmd` npm shims actually run.
|
|
381
|
+
const onPathHits = (src.match(/onPath\(bin\)/g) || []).length;
|
|
382
|
+
assert.ok(onPathHits >= 2, "both migration and daemon-start loops must PATH-gate with onPath()");
|
|
383
|
+
assert.match(
|
|
384
|
+
src,
|
|
385
|
+
/shell:\s*process\.platform === "win32"/,
|
|
386
|
+
"CLI launches must use a shell on Windows so .cmd shims run",
|
|
387
|
+
);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("runner source: materialize child receives an explicit HOME (#1443 review)", () => {
|
|
391
|
+
const src = fs.readFileSync(path.join(__dirname, "remnic-codex-hook.cjs"), "utf8");
|
|
392
|
+
// The spawned materializer must get the runner's resolved HOME so Windows
|
|
393
|
+
// (HOME usually unset) resolves the same config home as the hook.
|
|
394
|
+
assert.match(
|
|
395
|
+
src,
|
|
396
|
+
/const childEnv = \{ \.\.\.process\.env, HOME \}/,
|
|
397
|
+
"runMaterialize must pass an explicit HOME to the materializer child",
|
|
398
|
+
);
|
|
399
|
+
assert.match(src, /env:\s*childEnv/, "materialize spawnSync must use childEnv");
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("runner source: stdin is the single payload source — no env-var override (#1443 review)", () => {
|
|
403
|
+
const src = fs.readFileSync(path.join(__dirname, "remnic-codex-hook.cjs"), "utf8");
|
|
404
|
+
// An inherited REMNIC_HOOK_INPUT must NOT be able to override the piped
|
|
405
|
+
// stdin payload, so readStdin must not read it.
|
|
406
|
+
assert.doesNotMatch(
|
|
407
|
+
src,
|
|
408
|
+
/process\.env\.REMNIC_HOOK_INPUT/,
|
|
409
|
+
"readStdin must not consult REMNIC_HOOK_INPUT (env-leak override risk)",
|
|
410
|
+
);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("post-tool-observe: worker payload travels via STDIN, not the environment (#1443 review)", () => {
|
|
414
|
+
// Windows caps the environment block at ~32 KB. Large PostToolUse payloads
|
|
415
|
+
// (big file edits, big command output) would E2BIG; the worker now reads
|
|
416
|
+
// stdin instead. We assert the source rather than running an E2BIG payload
|
|
417
|
+
// because reproducing the limit cross-platform is impractical.
|
|
418
|
+
const src = fs.readFileSync(path.join(__dirname, "remnic-codex-hook.cjs"), "utf8");
|
|
419
|
+
assert.doesNotMatch(
|
|
420
|
+
src,
|
|
421
|
+
/REMNIC_HOOK_INPUT:\s*rawInput/,
|
|
422
|
+
"foreground hook must NOT propagate the payload via env (E2BIG on Windows)",
|
|
423
|
+
);
|
|
424
|
+
assert.match(
|
|
425
|
+
src,
|
|
426
|
+
/child\.stdin\.end\(rawInput\)/,
|
|
427
|
+
"foreground hook must write the payload to the worker's stdin",
|
|
428
|
+
);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("session-end: skips final flush and leaves a symlinked cursor untouched (state hardening)", async () => {
|
|
432
|
+
const home = mkHome();
|
|
433
|
+
const { server, port, calls } = await startServer((req, res) => res.writeHead(200).end("{}"));
|
|
434
|
+
try {
|
|
435
|
+
const stateDir = path.join(home, "state", "remnic", "hooks");
|
|
436
|
+
fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
437
|
+
const cursor = cursorPath(home, "sUnsafe");
|
|
438
|
+
const symTarget = path.join(home, "target.txt");
|
|
439
|
+
fs.writeFileSync(symTarget, "unchanged\n");
|
|
440
|
+
fs.symlinkSync(symTarget, cursor);
|
|
441
|
+
const tpath = transcript(home, [{ role: "user", content: "would-be-pending" }]);
|
|
442
|
+
await runHook("session-end", { session_id: "sUnsafe", transcript_path: tpath }, { port, home });
|
|
443
|
+
// Final flush MUST NOT happen via a symlinked cursor file.
|
|
444
|
+
assert.equal(calls.filter((c) => c.url === "/engram/v1/observe").length, 0);
|
|
445
|
+
// The symlink target must be left exactly as we created it.
|
|
446
|
+
assert.equal(fs.readFileSync(symTarget, "utf8"), "unchanged\n");
|
|
447
|
+
} finally {
|
|
448
|
+
server.close();
|
|
449
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test("observe worker: adopts an os.tmpdir() cursor file and cleans it up", async () => {
|
|
454
|
+
const home = mkHome();
|
|
455
|
+
const { server, port } = await startServer((req, res) => res.writeHead(200).end("{}"));
|
|
456
|
+
try {
|
|
457
|
+
const sessionId = `mtmp-${process.pid}`;
|
|
458
|
+
// Place a tmp cursor at a value AHEAD of where the new cursor would be.
|
|
459
|
+
const tmpCursor = path.join(os.tmpdir(), `remnic-cursor-${sessionId}`);
|
|
460
|
+
fs.writeFileSync(tmpCursor, "5\n");
|
|
461
|
+
try {
|
|
462
|
+
// Transcript has 2 messages; without /tmp adoption the runner would
|
|
463
|
+
// observe both. With adoption (5 > 2 > 0), `slice(5)` is empty → no
|
|
464
|
+
// observe, and the cursor lands at the transcript length.
|
|
465
|
+
const tpath = transcript(home, [
|
|
466
|
+
{ role: "user", content: "a" },
|
|
467
|
+
{ role: "assistant", content: "b" },
|
|
468
|
+
]);
|
|
469
|
+
await runHook(
|
|
470
|
+
"__observe-worker__",
|
|
471
|
+
JSON.stringify({ session_id: sessionId, transcript_path: tpath }),
|
|
472
|
+
{ port, home },
|
|
473
|
+
);
|
|
474
|
+
// /tmp cursor must be cleaned up by the runner after adoption.
|
|
475
|
+
assert.equal(fs.existsSync(tmpCursor), false, "/tmp cursor must be removed after adoption");
|
|
476
|
+
} finally {
|
|
477
|
+
try {
|
|
478
|
+
fs.rmSync(tmpCursor, { force: true });
|
|
479
|
+
} catch {
|
|
480
|
+
/* ignore */
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
} finally {
|
|
484
|
+
server.close();
|
|
485
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test("post-tool worker reads its payload from STDIN end-to-end", async () => {
|
|
490
|
+
const home = mkHome();
|
|
491
|
+
const { server, port, calls } = await startServer((req, res) =>
|
|
492
|
+
res.writeHead(200).end("{}"),
|
|
493
|
+
);
|
|
494
|
+
try {
|
|
495
|
+
const tpath = transcript(home, [
|
|
496
|
+
{ role: "user", content: "stdin-payload-test" },
|
|
497
|
+
]);
|
|
498
|
+
// Pass the payload via stdin — the only channel the worker reads.
|
|
499
|
+
await runHook(
|
|
500
|
+
"__observe-worker__",
|
|
501
|
+
JSON.stringify({ session_id: "sStdin", transcript_path: tpath }),
|
|
502
|
+
{ port, home },
|
|
503
|
+
);
|
|
504
|
+
const observe = calls.find((c) => c.url === "/engram/v1/observe");
|
|
505
|
+
assert.ok(observe, "observe was called via stdin payload");
|
|
506
|
+
assert.equal(observe.body.messages.length, 1);
|
|
507
|
+
assert.equal(observe.body.messages[0].content, "stdin-payload-test");
|
|
508
|
+
} finally {
|
|
509
|
+
server.close();
|
|
510
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
511
|
+
}
|
|
512
|
+
});
|
package/hooks/hooks.json
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
"hooks": [
|
|
7
7
|
{
|
|
8
8
|
"type": "command",
|
|
9
|
-
"command": "
|
|
9
|
+
"command": "\"${PLUGIN_ROOT}/hooks/bin/remnic-codex-hook.sh\" session-start",
|
|
10
|
+
"commandWindows": "powershell -NoProfile -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}\\hooks\\bin\\remnic-codex-hook.ps1\" session-start",
|
|
10
11
|
"timeout": 45000
|
|
11
12
|
}
|
|
12
13
|
]
|
|
@@ -18,7 +19,8 @@
|
|
|
18
19
|
"hooks": [
|
|
19
20
|
{
|
|
20
21
|
"type": "command",
|
|
21
|
-
"command": "
|
|
22
|
+
"command": "\"${PLUGIN_ROOT}/hooks/bin/remnic-codex-hook.sh\" post-tool-observe",
|
|
23
|
+
"commandWindows": "powershell -NoProfile -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}\\hooks\\bin\\remnic-codex-hook.ps1\" post-tool-observe",
|
|
22
24
|
"timeout": 10000
|
|
23
25
|
}
|
|
24
26
|
]
|
|
@@ -30,7 +32,8 @@
|
|
|
30
32
|
"hooks": [
|
|
31
33
|
{
|
|
32
34
|
"type": "command",
|
|
33
|
-
"command": "
|
|
35
|
+
"command": "\"${PLUGIN_ROOT}/hooks/bin/remnic-codex-hook.sh\" user-prompt-recall",
|
|
36
|
+
"commandWindows": "powershell -NoProfile -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}\\hooks\\bin\\remnic-codex-hook.ps1\" user-prompt-recall",
|
|
34
37
|
"timeout": 20000
|
|
35
38
|
}
|
|
36
39
|
]
|
|
@@ -42,7 +45,8 @@
|
|
|
42
45
|
"hooks": [
|
|
43
46
|
{
|
|
44
47
|
"type": "command",
|
|
45
|
-
"command": "
|
|
48
|
+
"command": "\"${PLUGIN_ROOT}/hooks/bin/remnic-codex-hook.sh\" session-end",
|
|
49
|
+
"commandWindows": "powershell -NoProfile -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}\\hooks\\bin\\remnic-codex-hook.ps1\" session-end",
|
|
46
50
|
"timeout": 30000
|
|
47
51
|
}
|
|
48
52
|
]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remnic/plugin-codex",
|
|
3
|
-
"version": "9.3.
|
|
3
|
+
"version": "9.3.614",
|
|
4
4
|
"description": "Remnic memory plugin for Codex CLI — hooks, skills, MCP integration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,6 +30,6 @@
|
|
|
30
30
|
".mcp.json"
|
|
31
31
|
],
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@remnic/core": "^9.3.
|
|
33
|
+
"@remnic/core": "^9.3.614"
|
|
34
34
|
}
|
|
35
35
|
}
|