@remnic/plugin-codex 9.45.5 → 9.46.1
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 +20 -3
- package/hooks/bin/remnic-codex-hook.cjs +23 -3
- package/hooks/bin/remnic-codex-hook.test.cjs +188 -7
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -107,9 +107,26 @@ export REMNIC_DAEMON_URL="http://macstudio.tail-XXXX.ts.net:4318"
|
|
|
107
107
|
namespace to the compaction flush (parity with `@remnic/plugin-pi`'s
|
|
108
108
|
`config.namespace`). Unset → the daemon resolves the default namespace.
|
|
109
109
|
|
|
110
|
-
The bearer token
|
|
111
|
-
|
|
112
|
-
`
|
|
110
|
+
The bearer token is resolved in this order:
|
|
111
|
+
|
|
112
|
+
1. The per-plugin token store — `remnic connectors install codex-cli` writes
|
|
113
|
+
`~/.remnic/tokens.json`; legacy `~/.engram/tokens.json` is read as a
|
|
114
|
+
fallback.
|
|
115
|
+
2. `OPENCLAW_REMNIC_ACCESS_TOKEN`, then `REMNIC_AUTH_TOKEN`.
|
|
116
|
+
3. Legacy aliases: `OPENCLAW_ENGRAM_ACCESS_TOKEN`, then `ENGRAM_AUTH_TOKEN`.
|
|
117
|
+
|
|
118
|
+
Current names outrank legacy ones, so a leftover pre-rename value cannot
|
|
119
|
+
shadow the credential the daemon is actually running with. `REMNIC_AUTH_TOKEN`
|
|
120
|
+
covers the standalone-server setup, which authenticates the daemon with that
|
|
121
|
+
variable and never mints a connector token. Against an auth-gated
|
|
122
|
+
daemon the hook needs one of these: every route, including
|
|
123
|
+
`/engram/v1/health`, returns 401 without a bearer, so an unauthenticated hook
|
|
124
|
+
reports `daemon not running` and silently skips auto-recall and auto-observe.
|
|
125
|
+
|
|
126
|
+
`REMNIC_HOOK_TOKEN` is not part of this chain — it is an internal channel the
|
|
127
|
+
foreground hook uses to hand its already-resolved token to the detached
|
|
128
|
+
observe worker, so the worker does not re-read the token store. Nothing in
|
|
129
|
+
the foreground path reads it.
|
|
113
130
|
|
|
114
131
|
## Hook trust (one-time review)
|
|
115
132
|
|
|
@@ -220,6 +220,13 @@ function onPath(bin) {
|
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
// ── token resolution (per-plugin token store, then env) ────────────────────
|
|
223
|
+
// Env precedence is primary-before-legacy per AGENTS.md §9: the two current
|
|
224
|
+
// names (OPENCLAW_REMNIC_ACCESS_TOKEN, REMNIC_AUTH_TOKEN) before the two
|
|
225
|
+
// legacy aliases (OPENCLAW_ENGRAM_ACCESS_TOKEN, ENGRAM_AUTH_TOKEN), so a
|
|
226
|
+
// stale leftover from a pre-rename install cannot outrank the credential the
|
|
227
|
+
// daemon is actually running with. REMNIC_AUTH_TOKEN matters because the
|
|
228
|
+
// documented standalone-server setup authenticates the daemon with it alone
|
|
229
|
+
// and never mints a connector token.
|
|
223
230
|
function resolveToken() {
|
|
224
231
|
for (const file of [
|
|
225
232
|
path.join(HOME, ".remnic", "tokens.json"),
|
|
@@ -245,7 +252,9 @@ function resolveToken() {
|
|
|
245
252
|
}
|
|
246
253
|
return (
|
|
247
254
|
process.env.OPENCLAW_REMNIC_ACCESS_TOKEN ||
|
|
255
|
+
process.env.REMNIC_AUTH_TOKEN ||
|
|
248
256
|
process.env.OPENCLAW_ENGRAM_ACCESS_TOKEN ||
|
|
257
|
+
process.env.ENGRAM_AUTH_TOKEN ||
|
|
249
258
|
""
|
|
250
259
|
);
|
|
251
260
|
}
|
|
@@ -334,7 +343,17 @@ function httpPost(urlPath, token, bodyObj, timeoutMs) {
|
|
|
334
343
|
});
|
|
335
344
|
}
|
|
336
345
|
|
|
337
|
-
|
|
346
|
+
// `token` is the caller's already-resolved credential, NOT a second
|
|
347
|
+
// resolveToken() call: the probe must authenticate with the exact bearer the
|
|
348
|
+
// operation it gates will send. Re-resolving could pick up a rotated
|
|
349
|
+
// tokens.json (or an inherited REMNIC_HOOK_TOKEN the foreground handlers
|
|
350
|
+
// ignore) and green-light a probe whose recall then 401s.
|
|
351
|
+
//
|
|
352
|
+
// When the daemon has an auth token configured, every route — including
|
|
353
|
+
// /engram/v1/health — returns 401 to unauthenticated requests, so an
|
|
354
|
+
// unauthenticated probe makes the hook wrongly report "daemon not running"
|
|
355
|
+
// and skip recall/observe. Unauthenticated daemons ignore the header.
|
|
356
|
+
function httpHealthy(timeoutMs, token) {
|
|
338
357
|
if (DAEMON_URL === null) return Promise.resolve(false);
|
|
339
358
|
const transport = DAEMON_URL.protocol === "https:" ? https : http;
|
|
340
359
|
return new Promise((resolve) => {
|
|
@@ -345,6 +364,7 @@ function httpHealthy(timeoutMs) {
|
|
|
345
364
|
port: DAEMON_URL.port || (DAEMON_URL.protocol === "https:" ? 443 : 80),
|
|
346
365
|
path: DAEMON_BASE_PATH + "/engram/v1/health",
|
|
347
366
|
method: "GET",
|
|
367
|
+
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
348
368
|
},
|
|
349
369
|
(res) => {
|
|
350
370
|
res.resume();
|
|
@@ -658,7 +678,7 @@ async function handleSessionStart(input, token, log) {
|
|
|
658
678
|
log(`session=${sessionId} project=${projectName} coding-context=${codingContext ? "yes" : ""}`);
|
|
659
679
|
|
|
660
680
|
// Health check — start daemon if not running.
|
|
661
|
-
if (!(await httpHealthy(2000))) {
|
|
681
|
+
if (!(await httpHealthy(2000, token))) {
|
|
662
682
|
log("daemon not responding, attempting start...");
|
|
663
683
|
// Try `remnic` first, fall through to legacy `engram` when only the older
|
|
664
684
|
// CLI is on PATH. spawn() emits ENOENT *asynchronously* via 'error', so we
|
|
@@ -683,7 +703,7 @@ async function handleSessionStart(input, token, log) {
|
|
|
683
703
|
}
|
|
684
704
|
}
|
|
685
705
|
await new Promise((r) => setTimeout(r, 2000));
|
|
686
|
-
if (!(await httpHealthy(2000))) {
|
|
706
|
+
if (!(await httpHealthy(2000, token))) {
|
|
687
707
|
log("daemon still not responding after start attempt");
|
|
688
708
|
emit({
|
|
689
709
|
continue: true,
|
|
@@ -48,12 +48,12 @@ function mkHome() {
|
|
|
48
48
|
// stays free to answer the runner's requests while it runs.
|
|
49
49
|
function runHook(event, input, { port, home, env = {} } = {}) {
|
|
50
50
|
return new Promise((resolve) => {
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
51
|
+
// Every env name resolveToken() consults is pinned below — including the
|
|
52
|
+
// canonical REMNIC_AUTH_TOKEN / ENGRAM_AUTH_TOKEN pair — so a value from
|
|
53
|
+
// the parent process (a real dev shell, or CI authenticating a local
|
|
54
|
+
// daemon) cannot leak through `...process.env` and make the no-token
|
|
55
|
+
// assertions fail outside CI (#1571 test-harness hygiene). Tests that want
|
|
56
|
+
// a specific name set it via env.extra, which spreads last.
|
|
57
57
|
const noToken = env.token === null;
|
|
58
58
|
const child = spawn(process.execPath, [RUNNER, event], {
|
|
59
59
|
env: {
|
|
@@ -65,7 +65,12 @@ function runHook(event, input, { port, home, env = {} } = {}) {
|
|
|
65
65
|
REMNIC_PORT: String(port),
|
|
66
66
|
REMNIC_CODEX_MATERIALIZE: "0",
|
|
67
67
|
OPENCLAW_REMNIC_ACCESS_TOKEN: noToken ? "" : env.token || "test-token",
|
|
68
|
-
OPENCLAW_ENGRAM_ACCESS_TOKEN:
|
|
68
|
+
OPENCLAW_ENGRAM_ACCESS_TOKEN: "",
|
|
69
|
+
REMNIC_AUTH_TOKEN: "",
|
|
70
|
+
ENGRAM_AUTH_TOKEN: "",
|
|
71
|
+
// Internal worker-propagation channel, not a user credential. Pinned
|
|
72
|
+
// so an inherited value cannot reach the detached observe worker.
|
|
73
|
+
REMNIC_HOOK_TOKEN: "",
|
|
69
74
|
// Clear daemon URL env so a developer shell with REMNIC_DAEMON_URL set
|
|
70
75
|
// can't route tests away from the mock server. Tests that WANT a daemon
|
|
71
76
|
// URL override it via env.extra (which spreads last).
|
|
@@ -1092,3 +1097,179 @@ test("pre-compact: REMNIC_PRECOMPACT_LOCK_RETRIES=0 still takes a FREE lock (doe
|
|
|
1092
1097
|
fs.rmSync(home, { recursive: true, force: true });
|
|
1093
1098
|
}
|
|
1094
1099
|
});
|
|
1100
|
+
|
|
1101
|
+
// ── daemon auth: health probe + canonical env credentials ──────────────────
|
|
1102
|
+
// An auth-gated daemon 401s EVERY route, including /engram/v1/health. An
|
|
1103
|
+
// unauthenticated probe therefore makes the hook report "daemon not running"
|
|
1104
|
+
// and skip recall/observe entirely. The credential itself has to be findable:
|
|
1105
|
+
// the documented standalone-server setup authenticates the daemon with
|
|
1106
|
+
// REMNIC_AUTH_TOKEN and never mints a connector token, so resolveToken() must
|
|
1107
|
+
// accept the canonical names as well as the connector-scoped OPENCLAW_* pair.
|
|
1108
|
+
|
|
1109
|
+
function authServer() {
|
|
1110
|
+
const seen = { health: "unset", recall: "unset" };
|
|
1111
|
+
return startServer((req, res) => {
|
|
1112
|
+
if (req.url === "/engram/v1/health") {
|
|
1113
|
+
seen.health = req.headers.authorization || null;
|
|
1114
|
+
return res.writeHead(200).end("ok");
|
|
1115
|
+
}
|
|
1116
|
+
if (req.url === "/engram/v1/recall") {
|
|
1117
|
+
seen.recall = req.headers.authorization || null;
|
|
1118
|
+
return res
|
|
1119
|
+
.writeHead(200, { "Content-Type": "application/json" })
|
|
1120
|
+
.end(JSON.stringify({ context: "ctx", count: 1, mode: "auto" }));
|
|
1121
|
+
}
|
|
1122
|
+
res.writeHead(404).end();
|
|
1123
|
+
}).then((started) => ({ ...started, seen }));
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
test("session-start: health probe carries the bearer token (auth-gated daemons)", async () => {
|
|
1127
|
+
const home = mkHome();
|
|
1128
|
+
const { server, port, seen } = await authServer();
|
|
1129
|
+
try {
|
|
1130
|
+
await runHook("session-start", { session_id: "sAuth", cwd: home }, { port, home });
|
|
1131
|
+
assert.equal(seen.health, "Bearer test-token");
|
|
1132
|
+
} finally {
|
|
1133
|
+
server.close();
|
|
1134
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
1135
|
+
}
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
for (const name of ["REMNIC_AUTH_TOKEN", "ENGRAM_AUTH_TOKEN"]) {
|
|
1139
|
+
test(`token resolution: ${name} authenticates health and recall (no token store)`, async () => {
|
|
1140
|
+
const home = mkHome();
|
|
1141
|
+
const { server, port, seen } = await authServer();
|
|
1142
|
+
try {
|
|
1143
|
+
const res = await runHook(
|
|
1144
|
+
"session-start",
|
|
1145
|
+
{ session_id: "sEnv", cwd: home },
|
|
1146
|
+
{ port, home, env: { token: null, extra: { [name]: "operator-secret" } } },
|
|
1147
|
+
);
|
|
1148
|
+
assert.equal(seen.health, "Bearer operator-secret");
|
|
1149
|
+
assert.equal(seen.recall, "Bearer operator-secret");
|
|
1150
|
+
assert.doesNotMatch(
|
|
1151
|
+
res.json.hookSpecificOutput.additionalContext,
|
|
1152
|
+
/daemon not running/,
|
|
1153
|
+
"an authenticated daemon must not be reported as down",
|
|
1154
|
+
);
|
|
1155
|
+
} finally {
|
|
1156
|
+
server.close();
|
|
1157
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
1158
|
+
}
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
test("token resolution: OPENCLAW_REMNIC_ACCESS_TOKEN outranks REMNIC_AUTH_TOKEN", async () => {
|
|
1163
|
+
const home = mkHome();
|
|
1164
|
+
const { server, port, seen } = await authServer();
|
|
1165
|
+
try {
|
|
1166
|
+
await runHook(
|
|
1167
|
+
"session-start",
|
|
1168
|
+
{ session_id: "sPrec", cwd: home },
|
|
1169
|
+
{ port, home, env: { token: "connector-tok", extra: { REMNIC_AUTH_TOKEN: "operator-secret" } } },
|
|
1170
|
+
);
|
|
1171
|
+
// Both are current names; the connector-scoped one stays first, so an
|
|
1172
|
+
// install that already worked keeps its existing credential.
|
|
1173
|
+
assert.equal(seen.health, "Bearer connector-tok");
|
|
1174
|
+
} finally {
|
|
1175
|
+
server.close();
|
|
1176
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
1177
|
+
}
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
test("token resolution: REMNIC_AUTH_TOKEN outranks the legacy OPENCLAW_ENGRAM_ACCESS_TOKEN", async () => {
|
|
1181
|
+
const home = mkHome();
|
|
1182
|
+
const { server, port, seen } = await authServer();
|
|
1183
|
+
try {
|
|
1184
|
+
await runHook(
|
|
1185
|
+
"session-start",
|
|
1186
|
+
{ session_id: "sLegacyPrec", cwd: home },
|
|
1187
|
+
{
|
|
1188
|
+
port,
|
|
1189
|
+
home,
|
|
1190
|
+
env: {
|
|
1191
|
+
token: null,
|
|
1192
|
+
extra: {
|
|
1193
|
+
OPENCLAW_ENGRAM_ACCESS_TOKEN: "stale-legacy-tok",
|
|
1194
|
+
REMNIC_AUTH_TOKEN: "operator-secret",
|
|
1195
|
+
},
|
|
1196
|
+
},
|
|
1197
|
+
},
|
|
1198
|
+
);
|
|
1199
|
+
// Primary-before-legacy (AGENTS.md §9). A migrated deployment often still
|
|
1200
|
+
// exports the pre-rename alias; if that stale value outranked the token
|
|
1201
|
+
// the daemon actually runs with, the probe would 401 and land back on
|
|
1202
|
+
// "daemon not running".
|
|
1203
|
+
assert.equal(seen.health, "Bearer operator-secret");
|
|
1204
|
+
} finally {
|
|
1205
|
+
server.close();
|
|
1206
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
1207
|
+
}
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
test("token resolution: tokens.json still outranks REMNIC_AUTH_TOKEN", async () => {
|
|
1211
|
+
const home = mkHome();
|
|
1212
|
+
fs.mkdirSync(path.join(home, ".remnic"), { recursive: true });
|
|
1213
|
+
fs.writeFileSync(
|
|
1214
|
+
path.join(home, ".remnic", "tokens.json"),
|
|
1215
|
+
JSON.stringify({ tokens: [{ connector: "codex-cli", token: "codex-tok" }] }),
|
|
1216
|
+
);
|
|
1217
|
+
const { server, port, seen } = await authServer();
|
|
1218
|
+
try {
|
|
1219
|
+
await runHook(
|
|
1220
|
+
"session-start",
|
|
1221
|
+
{ session_id: "sStore", cwd: home },
|
|
1222
|
+
{ port, home, env: { token: null, extra: { REMNIC_AUTH_TOKEN: "operator-secret" } } },
|
|
1223
|
+
);
|
|
1224
|
+
assert.equal(seen.health, "Bearer codex-tok");
|
|
1225
|
+
} finally {
|
|
1226
|
+
server.close();
|
|
1227
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
1228
|
+
}
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
test("token resolution: REMNIC_AUTH_TOKEN outranks ENGRAM_AUTH_TOKEN when both are set", async () => {
|
|
1232
|
+
const home = mkHome();
|
|
1233
|
+
const { server, port, seen } = await authServer();
|
|
1234
|
+
try {
|
|
1235
|
+
await runHook(
|
|
1236
|
+
"session-start",
|
|
1237
|
+
{ session_id: "sBothCanonical", cwd: home },
|
|
1238
|
+
{
|
|
1239
|
+
port,
|
|
1240
|
+
home,
|
|
1241
|
+
env: {
|
|
1242
|
+
token: null,
|
|
1243
|
+
extra: { REMNIC_AUTH_TOKEN: "current-tok", ENGRAM_AUTH_TOKEN: "legacy-tok" },
|
|
1244
|
+
},
|
|
1245
|
+
},
|
|
1246
|
+
);
|
|
1247
|
+
assert.equal(seen.health, "Bearer current-tok");
|
|
1248
|
+
assert.equal(seen.recall, "Bearer current-tok");
|
|
1249
|
+
} finally {
|
|
1250
|
+
server.close();
|
|
1251
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
1252
|
+
}
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
test("health probe uses the same bearer as the operation it gates", async () => {
|
|
1256
|
+
const home = mkHome();
|
|
1257
|
+
const { server, port, seen } = await authServer();
|
|
1258
|
+
try {
|
|
1259
|
+
// REMNIC_HOOK_TOKEN is the detached observe worker's internal propagation
|
|
1260
|
+
// channel; the foreground handlers never read it. If the probe consulted
|
|
1261
|
+
// it, an inherited value would authenticate health with one bearer while
|
|
1262
|
+
// recall sent another — a false "healthy" followed by a 401, or the
|
|
1263
|
+
// reverse. Probe and operation must carry one snapshot.
|
|
1264
|
+
await runHook(
|
|
1265
|
+
"session-start",
|
|
1266
|
+
{ session_id: "sSnapshot", cwd: home },
|
|
1267
|
+
{ port, home, env: { extra: { REMNIC_HOOK_TOKEN: "inherited-worker-tok" } } },
|
|
1268
|
+
);
|
|
1269
|
+
assert.equal(seen.health, "Bearer test-token");
|
|
1270
|
+
assert.equal(seen.recall, seen.health);
|
|
1271
|
+
} finally {
|
|
1272
|
+
server.close();
|
|
1273
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
1274
|
+
}
|
|
1275
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remnic/plugin-codex",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.46.1",
|
|
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.
|
|
33
|
+
"@remnic/core": "^9.46.1"
|
|
34
34
|
}
|
|
35
35
|
}
|