@remnic/plugin-codex 9.45.4 → 9.46.0

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 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 still comes from the per-plugin token store
111
- (`remnic connectors install codex-cli` writes `~/.remnic/tokens.json`) or the
112
- `OPENCLAW_REMNIC_ACCESS_TOKEN` / `OPENCLAW_ENGRAM_ACCESS_TOKEN` env vars.
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
- function httpHealthy(timeoutMs) {
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
- // When a test asks for "no token" (env.token === null) we must clear BOTH
52
- // token env vars — the runner's resolveToken() falls through from
53
- // OPENCLAW_REMNIC_ACCESS_TOKEN to OPENCLAW_ENGRAM_ACCESS_TOKEN, and the
54
- // parent process (a real dev shell) often has the latter set, which
55
- // previously leaked through `...process.env` and made the no-token
56
- // assertions fail outside CI (#1571 test-harness hygiene).
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: noToken ? "" : "",
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.45.4",
3
+ "version": "9.46.0",
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.45.4"
33
+ "@remnic/core": "^9.46.0"
34
34
  }
35
35
  }