@remnic/plugin-claude-code 9.45.5 → 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/.claude-plugin/plugin.json +4 -2
- package/README.md +32 -0
- package/hooks/bin/remnic-cc-hook.cjs +45 -5
- package/hooks/bin/remnic-cc-hook.test.cjs +325 -7
- package/package.json +1 -1
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "remnic",
|
|
3
3
|
"description": "Universal memory for AI agents — automatic recall, observation, and cross-agent knowledge sharing",
|
|
4
|
-
"version": "9.
|
|
5
|
-
"author":
|
|
4
|
+
"version": "9.46.0",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Joshua Warren"
|
|
7
|
+
},
|
|
6
8
|
"homepage": "https://github.com/joshuaswarren/remnic",
|
|
7
9
|
"repository": "https://github.com/joshuaswarren/remnic"
|
|
8
10
|
}
|
package/README.md
CHANGED
|
@@ -68,6 +68,38 @@ The plugin expects a Remnic daemon reachable at `http://localhost:4318/mcp` with
|
|
|
68
68
|
|
|
69
69
|
Replace `{{REMNIC_TOKEN}}` with a token minted via `remnic token generate <connector-id>`.
|
|
70
70
|
|
|
71
|
+
### Hook credentials
|
|
72
|
+
|
|
73
|
+
The hook runner resolves its own bearer token independently of the MCP block
|
|
74
|
+
above, in this order:
|
|
75
|
+
|
|
76
|
+
1. `~/.remnic/tokens.json`, then legacy `~/.engram/tokens.json` — the
|
|
77
|
+
`claude-code` connector entry first, then `openclaw`.
|
|
78
|
+
2. `OPENCLAW_REMNIC_ACCESS_TOKEN`, then `REMNIC_AUTH_TOKEN`.
|
|
79
|
+
3. Legacy aliases: `OPENCLAW_ENGRAM_ACCESS_TOKEN`, then `ENGRAM_AUTH_TOKEN`.
|
|
80
|
+
|
|
81
|
+
Current names outrank legacy ones, so a leftover pre-rename value cannot
|
|
82
|
+
shadow the credential the daemon is actually running with. `REMNIC_AUTH_TOKEN`
|
|
83
|
+
covers the standalone-server setup, which authenticates the daemon with that
|
|
84
|
+
variable and never mints a connector token. Against an auth-gated daemon the
|
|
85
|
+
hook needs one of these: every route, including `/engram/v1/health`, returns
|
|
86
|
+
401 without a bearer, so an unauthenticated hook reports `daemon not running`
|
|
87
|
+
and silently skips auto-recall and auto-observe.
|
|
88
|
+
|
|
89
|
+
`REMNIC_HOOK_TOKEN` is not part of this chain — it is an internal channel the
|
|
90
|
+
foreground hook uses to hand its already-resolved token to the detached
|
|
91
|
+
observe worker, so the worker does not re-read the token store. Nothing in
|
|
92
|
+
the foreground path reads it.
|
|
93
|
+
|
|
94
|
+
### Namespace targeting
|
|
95
|
+
|
|
96
|
+
`REMNIC_NAMESPACE`, then legacy `ENGRAM_NAMESPACE` — optional. When set, the
|
|
97
|
+
hook adds the namespace to the **request body** of recall and observe (the
|
|
98
|
+
REST surface reads it from the body, not a header). An explicit
|
|
99
|
+
`body.namespace` already on the request wins. Unset → the daemon resolves the
|
|
100
|
+
namespace for the `claude-code` client id, which on a namespaced daemon is the
|
|
101
|
+
adapter's own empty namespace, so recall returns nothing.
|
|
102
|
+
|
|
71
103
|
## Agent note
|
|
72
104
|
|
|
73
105
|
If you're an AI agent scaffolding a Claude Code integration: **do not** hand-edit hook scripts in a user's `~/.claude/` tree. The full setup has two components:
|
|
@@ -169,6 +169,15 @@ function onPath(bin) {
|
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
// ── token resolution (per-plugin token store, then env) ────────────────────
|
|
172
|
+
// Env precedence is primary-before-legacy per AGENTS.md §9: the two current
|
|
173
|
+
// names (OPENCLAW_REMNIC_ACCESS_TOKEN, REMNIC_AUTH_TOKEN) before the two
|
|
174
|
+
// legacy aliases (OPENCLAW_ENGRAM_ACCESS_TOKEN, ENGRAM_AUTH_TOKEN), so a
|
|
175
|
+
// stale leftover from a pre-rename install cannot outrank the credential the
|
|
176
|
+
// daemon is actually running with. REMNIC_AUTH_TOKEN matters because the
|
|
177
|
+
// documented standalone-server setup authenticates the daemon with it alone
|
|
178
|
+
// and never mints a connector token — without it the health probe 401s and
|
|
179
|
+
// the hook wrongly reports "daemon not running", silently skipping
|
|
180
|
+
// recall/observe.
|
|
172
181
|
function resolveToken() {
|
|
173
182
|
for (const file of [
|
|
174
183
|
path.join(HOME, ".remnic", "tokens.json"),
|
|
@@ -192,7 +201,9 @@ function resolveToken() {
|
|
|
192
201
|
}
|
|
193
202
|
return (
|
|
194
203
|
process.env.OPENCLAW_REMNIC_ACCESS_TOKEN ||
|
|
204
|
+
process.env.REMNIC_AUTH_TOKEN ||
|
|
195
205
|
process.env.OPENCLAW_ENGRAM_ACCESS_TOKEN ||
|
|
206
|
+
process.env.ENGRAM_AUTH_TOKEN ||
|
|
196
207
|
""
|
|
197
208
|
);
|
|
198
209
|
}
|
|
@@ -203,7 +214,19 @@ function httpPost(urlPath, token, bodyObj, timeoutMs) {
|
|
|
203
214
|
return new Promise((resolve) => {
|
|
204
215
|
let data;
|
|
205
216
|
try {
|
|
206
|
-
|
|
217
|
+
// Namespace targeting for namespaced daemons: when REMNIC_NAMESPACE (or
|
|
218
|
+
// ENGRAM_NAMESPACE) is set, include it in the request body. On the REST
|
|
219
|
+
// surface the namespace is read from the body, not a header, and the
|
|
220
|
+
// "claude-code" client id otherwise resolves to the adapter's own
|
|
221
|
+
// (empty) namespace — so recall/observe silently return nothing. Opt-in:
|
|
222
|
+
// when the env var is unset this is a no-op and behaviour is unchanged.
|
|
223
|
+
// An explicit bodyObj.namespace still takes precedence.
|
|
224
|
+
const ns = process.env.REMNIC_NAMESPACE || process.env.ENGRAM_NAMESPACE;
|
|
225
|
+
const outBody =
|
|
226
|
+
ns && bodyObj && typeof bodyObj === "object" && !Array.isArray(bodyObj)
|
|
227
|
+
? { namespace: ns, ...bodyObj }
|
|
228
|
+
: bodyObj;
|
|
229
|
+
data = Buffer.from(JSON.stringify(outBody), "utf8");
|
|
207
230
|
} catch {
|
|
208
231
|
resolve({ ok: false, status: 0, body: "" });
|
|
209
232
|
return;
|
|
@@ -246,10 +269,27 @@ function httpPost(urlPath, token, bodyObj, timeoutMs) {
|
|
|
246
269
|
});
|
|
247
270
|
}
|
|
248
271
|
|
|
249
|
-
|
|
272
|
+
// `token` is the caller's already-resolved credential, NOT a second
|
|
273
|
+
// resolveToken() call: the probe must authenticate with the exact bearer the
|
|
274
|
+
// operation it gates will send. Re-resolving could pick up a rotated
|
|
275
|
+
// tokens.json (or an inherited REMNIC_HOOK_TOKEN the foreground handlers
|
|
276
|
+
// ignore) and green-light a probe whose recall then 401s.
|
|
277
|
+
//
|
|
278
|
+
// When the daemon has an auth token configured (REMNIC_AUTH_TOKEN), every
|
|
279
|
+
// route — including /engram/v1/health — returns 401 to unauthenticated
|
|
280
|
+
// requests, so an unauthenticated probe makes the hook wrongly report
|
|
281
|
+
// "daemon not running" and skip recall/observe. Unauthenticated daemons
|
|
282
|
+
// ignore the header.
|
|
283
|
+
function httpHealthy(timeoutMs, token) {
|
|
250
284
|
return new Promise((resolve) => {
|
|
251
285
|
const req = http.request(
|
|
252
|
-
{
|
|
286
|
+
{
|
|
287
|
+
host: HOST,
|
|
288
|
+
port: PORT,
|
|
289
|
+
path: "/engram/v1/health",
|
|
290
|
+
method: "GET",
|
|
291
|
+
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
292
|
+
},
|
|
253
293
|
(res) => {
|
|
254
294
|
res.resume();
|
|
255
295
|
resolve(res.statusCode >= 200 && res.statusCode < 300);
|
|
@@ -551,7 +591,7 @@ async function handleSessionStart(input, token, log) {
|
|
|
551
591
|
log(`session=${sessionId} project=${projectName} coding-context=${codingContext ? "yes" : ""}`);
|
|
552
592
|
|
|
553
593
|
// Health check — start daemon if not running.
|
|
554
|
-
if (!(await httpHealthy(2000))) {
|
|
594
|
+
if (!(await httpHealthy(2000, token))) {
|
|
555
595
|
log("daemon not responding, attempting start...");
|
|
556
596
|
// Try `remnic` first, fall through to legacy `engram` when only the older
|
|
557
597
|
// CLI is on PATH. spawn() emits ENOENT *asynchronously* via 'error', so we
|
|
@@ -575,7 +615,7 @@ async function handleSessionStart(input, token, log) {
|
|
|
575
615
|
}
|
|
576
616
|
}
|
|
577
617
|
await new Promise((r) => setTimeout(r, 2000));
|
|
578
|
-
if (!(await httpHealthy(2000))) {
|
|
618
|
+
if (!(await httpHealthy(2000, token))) {
|
|
579
619
|
log("daemon still not responding after start attempt");
|
|
580
620
|
emit({
|
|
581
621
|
continue: true,
|
|
@@ -62,14 +62,20 @@ function runHook(event, input, { port, home, env = {} } = {}) {
|
|
|
62
62
|
XDG_STATE_HOME: path.join(home, "state"),
|
|
63
63
|
REMNIC_HOST: "127.0.0.1",
|
|
64
64
|
REMNIC_PORT: String(port),
|
|
65
|
-
// Default to an env token unless a test overrides it.
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
// leak through `...process.env` and make the
|
|
70
|
-
// token (#1518 test isolation).
|
|
65
|
+
// Default to an env token unless a test overrides it. Every env name
|
|
66
|
+
// resolveToken() consults is pinned here — including the canonical
|
|
67
|
+
// REMNIC_AUTH_TOKEN / ENGRAM_AUTH_TOKEN pair — so a value inherited
|
|
68
|
+
// from the parent shell (a developer or CI runner that authenticates
|
|
69
|
+
// a local daemon) cannot leak through `...process.env` and make the
|
|
70
|
+
// no-token path take a token (#1518 test isolation). Tests that want
|
|
71
|
+
// a specific name set it through `env.extra`, which is spread last.
|
|
71
72
|
OPENCLAW_REMNIC_ACCESS_TOKEN: env.token === null ? "" : env.token || "test-token",
|
|
72
|
-
OPENCLAW_ENGRAM_ACCESS_TOKEN:
|
|
73
|
+
OPENCLAW_ENGRAM_ACCESS_TOKEN: "",
|
|
74
|
+
REMNIC_AUTH_TOKEN: "",
|
|
75
|
+
ENGRAM_AUTH_TOKEN: "",
|
|
76
|
+
// Internal worker-propagation channel, not a user credential. Pinned
|
|
77
|
+
// so an inherited value cannot reach the detached observe worker.
|
|
78
|
+
REMNIC_HOOK_TOKEN: "",
|
|
73
79
|
...env.extra,
|
|
74
80
|
},
|
|
75
81
|
stdio: ["pipe", "pipe", "pipe"],
|
|
@@ -135,6 +141,139 @@ test("session-start: healthy server returns recall context with codingContext cl
|
|
|
135
141
|
}
|
|
136
142
|
});
|
|
137
143
|
|
|
144
|
+
test("session-start: health probe carries the bearer token (auth-gated daemons)", async () => {
|
|
145
|
+
const home = mkHome();
|
|
146
|
+
let healthAuth = "unset";
|
|
147
|
+
const { server, port } = await startServer((req, res) => {
|
|
148
|
+
if (req.url === "/engram/v1/health") {
|
|
149
|
+
healthAuth = req.headers.authorization || null;
|
|
150
|
+
return res.writeHead(200).end("ok");
|
|
151
|
+
}
|
|
152
|
+
if (req.url === "/engram/v1/recall") {
|
|
153
|
+
return res
|
|
154
|
+
.writeHead(200, { "Content-Type": "application/json" })
|
|
155
|
+
.end(JSON.stringify({ context: "ctx", count: 1, mode: "auto" }));
|
|
156
|
+
}
|
|
157
|
+
res.writeHead(404).end();
|
|
158
|
+
});
|
|
159
|
+
try {
|
|
160
|
+
await runHook("session-start", { session_id: "s1", cwd: home }, { port, home });
|
|
161
|
+
// Without this header an auth-gated daemon 401s the probe and the hook
|
|
162
|
+
// reports "daemon not running", silently skipping recall/observe.
|
|
163
|
+
assert.equal(healthAuth, "Bearer test-token");
|
|
164
|
+
} finally {
|
|
165
|
+
server.close();
|
|
166
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("recall body targets REMNIC_NAMESPACE when set, and omits namespace when unset", async () => {
|
|
171
|
+
const mk = () =>
|
|
172
|
+
startServer((req, res) => {
|
|
173
|
+
if (req.url === "/engram/v1/health") return res.writeHead(200).end("ok");
|
|
174
|
+
if (req.url === "/engram/v1/recall") {
|
|
175
|
+
return res
|
|
176
|
+
.writeHead(200, { "Content-Type": "application/json" })
|
|
177
|
+
.end(JSON.stringify({ context: "ctx", count: 1, mode: "auto" }));
|
|
178
|
+
}
|
|
179
|
+
res.writeHead(404).end();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// set → namespace travels in the request body (REST reads it from the body,
|
|
183
|
+
// not a header; the "claude-code" client id otherwise resolves to the
|
|
184
|
+
// adapter's own empty namespace and recall returns nothing).
|
|
185
|
+
{
|
|
186
|
+
const home = mkHome();
|
|
187
|
+
const { server, port, calls } = await mk();
|
|
188
|
+
try {
|
|
189
|
+
await runHook(
|
|
190
|
+
"session-start",
|
|
191
|
+
{ session_id: "s1", cwd: home },
|
|
192
|
+
{ port, home, env: { extra: { REMNIC_NAMESPACE: "team-shared" } } },
|
|
193
|
+
);
|
|
194
|
+
const recall = calls.find((c) => c.url === "/engram/v1/recall");
|
|
195
|
+
assert.ok(recall, "recall was called");
|
|
196
|
+
assert.equal(recall.body.namespace, "team-shared");
|
|
197
|
+
} finally {
|
|
198
|
+
server.close();
|
|
199
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// unset → opt-in no-op, no namespace field (behaviour unchanged for existing users).
|
|
204
|
+
// Explicitly clear both env vars so a value inherited from the developer's
|
|
205
|
+
// shell (`...process.env`) can't leak in and make this path look "set".
|
|
206
|
+
{
|
|
207
|
+
const home = mkHome();
|
|
208
|
+
const { server, port, calls } = await mk();
|
|
209
|
+
try {
|
|
210
|
+
await runHook(
|
|
211
|
+
"session-start",
|
|
212
|
+
{ session_id: "s1", cwd: home },
|
|
213
|
+
{ port, home, env: { extra: { REMNIC_NAMESPACE: "", ENGRAM_NAMESPACE: "" } } },
|
|
214
|
+
);
|
|
215
|
+
const recall = calls.find((c) => c.url === "/engram/v1/recall");
|
|
216
|
+
assert.ok(recall, "recall was called");
|
|
217
|
+
assert.equal("namespace" in recall.body, false);
|
|
218
|
+
} finally {
|
|
219
|
+
server.close();
|
|
220
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("namespace targeting: ENGRAM_NAMESPACE fallback (recall) and observe body both honor it", async () => {
|
|
226
|
+
// ENGRAM_NAMESPACE is the fallback when REMNIC_NAMESPACE is unset → recall body carries it.
|
|
227
|
+
{
|
|
228
|
+
const home = mkHome();
|
|
229
|
+
const { server, port, calls } = await startServer((req, res) => {
|
|
230
|
+
if (req.url === "/engram/v1/health") return res.writeHead(200).end("ok");
|
|
231
|
+
if (req.url === "/engram/v1/recall") {
|
|
232
|
+
return res
|
|
233
|
+
.writeHead(200, { "Content-Type": "application/json" })
|
|
234
|
+
.end(JSON.stringify({ context: "ctx", count: 1, mode: "auto" }));
|
|
235
|
+
}
|
|
236
|
+
res.writeHead(404).end();
|
|
237
|
+
});
|
|
238
|
+
try {
|
|
239
|
+
await runHook(
|
|
240
|
+
"session-start",
|
|
241
|
+
{ session_id: "s1", cwd: home },
|
|
242
|
+
{ port, home, env: { extra: { REMNIC_NAMESPACE: "", ENGRAM_NAMESPACE: "legacy-ns" } } },
|
|
243
|
+
);
|
|
244
|
+
const recall = calls.find((c) => c.url === "/engram/v1/recall");
|
|
245
|
+
assert.ok(recall, "recall was called");
|
|
246
|
+
assert.equal(recall.body.namespace, "legacy-ns");
|
|
247
|
+
} finally {
|
|
248
|
+
server.close();
|
|
249
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// observe uses the same httpPost path, so its body carries the namespace too.
|
|
254
|
+
{
|
|
255
|
+
const home = mkHome();
|
|
256
|
+
const { server, port, calls } = await startServer((req, res) => res.writeHead(200).end("{}"));
|
|
257
|
+
try {
|
|
258
|
+
const tpath = transcript(home, [
|
|
259
|
+
{ role: "user", content: "first" },
|
|
260
|
+
{ role: "assistant", content: "second" },
|
|
261
|
+
]);
|
|
262
|
+
await runHook(
|
|
263
|
+
"__observe-worker__",
|
|
264
|
+
JSON.stringify({ session_id: "sObsNs", transcript_path: tpath }),
|
|
265
|
+
{ port, home, env: { extra: { REMNIC_NAMESPACE: "team-shared", ENGRAM_NAMESPACE: "" } } },
|
|
266
|
+
);
|
|
267
|
+
const observe = calls.find((c) => c.url === "/engram/v1/observe");
|
|
268
|
+
assert.ok(observe, "observe was called");
|
|
269
|
+
assert.equal(observe.body.namespace, "team-shared");
|
|
270
|
+
} finally {
|
|
271
|
+
server.close();
|
|
272
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
138
277
|
test("session-start: falls back to minimal mode when full recall fails", async () => {
|
|
139
278
|
const home = mkHome();
|
|
140
279
|
let recallHits = 0;
|
|
@@ -476,6 +615,122 @@ test("token resolution: legacy ~/.engram/tokens.json is the read fallback", asyn
|
|
|
476
615
|
}
|
|
477
616
|
});
|
|
478
617
|
|
|
618
|
+
// The documented standalone-server setup (docs/guides/standalone-server.md,
|
|
619
|
+
// README) authenticates the daemon with REMNIC_AUTH_TOKEN and never mints a
|
|
620
|
+
// connector token, so nothing lands in ~/.remnic/tokens.json and none of the
|
|
621
|
+
// OPENCLAW_* names are set. Before the canonical pair was added to
|
|
622
|
+
// resolveToken(), that shape sent an unauthenticated health probe, took a 401,
|
|
623
|
+
// and reported "daemon not running" — silently skipping recall and observe.
|
|
624
|
+
function authEnvServer() {
|
|
625
|
+
const seen = { health: "unset", recall: "unset" };
|
|
626
|
+
return startServer((req, res) => {
|
|
627
|
+
if (req.url === "/engram/v1/health") {
|
|
628
|
+
seen.health = req.headers.authorization || null;
|
|
629
|
+
return res.writeHead(200).end("ok");
|
|
630
|
+
}
|
|
631
|
+
if (req.url === "/engram/v1/recall") {
|
|
632
|
+
seen.recall = req.headers.authorization || null;
|
|
633
|
+
return res
|
|
634
|
+
.writeHead(200, { "Content-Type": "application/json" })
|
|
635
|
+
.end(JSON.stringify({ context: "ctx", count: 1, mode: "auto" }));
|
|
636
|
+
}
|
|
637
|
+
res.writeHead(404).end();
|
|
638
|
+
}).then((started) => ({ ...started, seen }));
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
for (const name of ["REMNIC_AUTH_TOKEN", "ENGRAM_AUTH_TOKEN"]) {
|
|
642
|
+
test(`token resolution: ${name} authenticates health and recall (no token store)`, async () => {
|
|
643
|
+
const home = mkHome();
|
|
644
|
+
const { server, port, seen } = await authEnvServer();
|
|
645
|
+
try {
|
|
646
|
+
const res = await runHook(
|
|
647
|
+
"session-start",
|
|
648
|
+
{ session_id: "sEnv", cwd: home },
|
|
649
|
+
{ port, home, env: { token: null, extra: { [name]: "operator-secret" } } },
|
|
650
|
+
);
|
|
651
|
+
assert.equal(seen.health, "Bearer operator-secret");
|
|
652
|
+
assert.equal(seen.recall, "Bearer operator-secret");
|
|
653
|
+
assert.doesNotMatch(
|
|
654
|
+
res.json.hookSpecificOutput.additionalContext,
|
|
655
|
+
/daemon not running/,
|
|
656
|
+
"an authenticated daemon must not be reported as down",
|
|
657
|
+
);
|
|
658
|
+
} finally {
|
|
659
|
+
server.close();
|
|
660
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
test("token resolution: OPENCLAW_REMNIC_ACCESS_TOKEN outranks REMNIC_AUTH_TOKEN", async () => {
|
|
666
|
+
const home = mkHome();
|
|
667
|
+
const { server, port, seen } = await authEnvServer();
|
|
668
|
+
try {
|
|
669
|
+
await runHook(
|
|
670
|
+
"session-start",
|
|
671
|
+
{ session_id: "sPrec", cwd: home },
|
|
672
|
+
{ port, home, env: { token: "connector-tok", extra: { REMNIC_AUTH_TOKEN: "operator-secret" } } },
|
|
673
|
+
);
|
|
674
|
+
// Both are current names; the connector-scoped one stays first, so an
|
|
675
|
+
// install that already worked keeps its existing credential.
|
|
676
|
+
assert.equal(seen.health, "Bearer connector-tok");
|
|
677
|
+
} finally {
|
|
678
|
+
server.close();
|
|
679
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
test("token resolution: REMNIC_AUTH_TOKEN outranks the legacy OPENCLAW_ENGRAM_ACCESS_TOKEN", async () => {
|
|
684
|
+
const home = mkHome();
|
|
685
|
+
const { server, port, seen } = await authEnvServer();
|
|
686
|
+
try {
|
|
687
|
+
await runHook(
|
|
688
|
+
"session-start",
|
|
689
|
+
{ session_id: "sLegacyPrec", cwd: home },
|
|
690
|
+
{
|
|
691
|
+
port,
|
|
692
|
+
home,
|
|
693
|
+
env: {
|
|
694
|
+
token: null,
|
|
695
|
+
extra: {
|
|
696
|
+
OPENCLAW_ENGRAM_ACCESS_TOKEN: "stale-legacy-tok",
|
|
697
|
+
REMNIC_AUTH_TOKEN: "operator-secret",
|
|
698
|
+
},
|
|
699
|
+
},
|
|
700
|
+
},
|
|
701
|
+
);
|
|
702
|
+
// Primary-before-legacy (AGENTS.md §9). A migrated deployment often still
|
|
703
|
+
// exports the pre-rename alias; if that stale value outranked the token
|
|
704
|
+
// the daemon actually runs with, the probe would 401 and land back on
|
|
705
|
+
// "daemon not running".
|
|
706
|
+
assert.equal(seen.health, "Bearer operator-secret");
|
|
707
|
+
} finally {
|
|
708
|
+
server.close();
|
|
709
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
test("token resolution: tokens.json still outranks REMNIC_AUTH_TOKEN", async () => {
|
|
714
|
+
const home = mkHome();
|
|
715
|
+
fs.mkdirSync(path.join(home, ".remnic"), { recursive: true });
|
|
716
|
+
fs.writeFileSync(
|
|
717
|
+
path.join(home, ".remnic", "tokens.json"),
|
|
718
|
+
JSON.stringify({ tokens: [{ connector: "claude-code", token: "cc-tok" }] }),
|
|
719
|
+
);
|
|
720
|
+
const { server, port, seen } = await authEnvServer();
|
|
721
|
+
try {
|
|
722
|
+
await runHook(
|
|
723
|
+
"session-start",
|
|
724
|
+
{ session_id: "sStore", cwd: home },
|
|
725
|
+
{ port, home, env: { token: null, extra: { REMNIC_AUTH_TOKEN: "operator-secret" } } },
|
|
726
|
+
);
|
|
727
|
+
assert.equal(seen.health, "Bearer cc-tok");
|
|
728
|
+
} finally {
|
|
729
|
+
server.close();
|
|
730
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
|
|
479
734
|
// ── hooks.json Windows parity ─────────────────────────────────────────────
|
|
480
735
|
|
|
481
736
|
test("hooks.json: every event uses cross-platform exec form with ${CLAUDE_PLUGIN_ROOT} (#1518)", () => {
|
|
@@ -531,6 +786,23 @@ test("hooks.json: every event uses cross-platform exec form with ${CLAUDE_PLUGIN
|
|
|
531
786
|
}
|
|
532
787
|
});
|
|
533
788
|
|
|
789
|
+
test("plugin.json: manifest conforms to the Claude Code plugin schema (author is an object)", () => {
|
|
790
|
+
const manifest = JSON.parse(
|
|
791
|
+
fs.readFileSync(path.join(__dirname, "..", "..", ".claude-plugin", "plugin.json"), "utf8"),
|
|
792
|
+
);
|
|
793
|
+
// Claude Code's plugin-manifest validator rejects a string `author`
|
|
794
|
+
// ("Invalid input: expected object, received string"), which blocks
|
|
795
|
+
// installing this plugin from a Claude Code marketplace. `author` must be an
|
|
796
|
+
// object carrying at least a `name`.
|
|
797
|
+
assert.equal(typeof manifest.author, "object", "author must be an object, not a string");
|
|
798
|
+
assert.ok(manifest.author !== null && !Array.isArray(manifest.author), "author must be a plain object");
|
|
799
|
+
assert.equal(typeof manifest.author.name, "string", "author.name must be a string");
|
|
800
|
+
assert.ok(manifest.author.name.length > 0, "author.name must be non-empty");
|
|
801
|
+
// Sanity-check the other required identity fields while we're here.
|
|
802
|
+
assert.equal(typeof manifest.name, "string", "name must be a string");
|
|
803
|
+
assert.equal(typeof manifest.version, "string", "version must be a string");
|
|
804
|
+
});
|
|
805
|
+
|
|
534
806
|
test("runner source: payload fields never reach a shell — spawn uses fixed argv (#1518 guard shell interpolation)", () => {
|
|
535
807
|
const src = fs.readFileSync(RUNNER, "utf8");
|
|
536
808
|
// Every spawn/spawnSync must use a fixed literal argument array, never a
|
|
@@ -579,3 +851,49 @@ test("runner source: path inputs are type-validated before use (#1518 validate p
|
|
|
579
851
|
"transcript_path must be defaulted to empty string",
|
|
580
852
|
);
|
|
581
853
|
});
|
|
854
|
+
|
|
855
|
+
test("token resolution: REMNIC_AUTH_TOKEN outranks ENGRAM_AUTH_TOKEN when both are set", async () => {
|
|
856
|
+
const home = mkHome();
|
|
857
|
+
const { server, port, seen } = await authEnvServer();
|
|
858
|
+
try {
|
|
859
|
+
await runHook(
|
|
860
|
+
"session-start",
|
|
861
|
+
{ session_id: "sBothCanonical", cwd: home },
|
|
862
|
+
{
|
|
863
|
+
port,
|
|
864
|
+
home,
|
|
865
|
+
env: {
|
|
866
|
+
token: null,
|
|
867
|
+
extra: { REMNIC_AUTH_TOKEN: "current-tok", ENGRAM_AUTH_TOKEN: "legacy-tok" },
|
|
868
|
+
},
|
|
869
|
+
},
|
|
870
|
+
);
|
|
871
|
+
assert.equal(seen.health, "Bearer current-tok");
|
|
872
|
+
assert.equal(seen.recall, "Bearer current-tok");
|
|
873
|
+
} finally {
|
|
874
|
+
server.close();
|
|
875
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
876
|
+
}
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
test("health probe uses the same bearer as the operation it gates", async () => {
|
|
880
|
+
const home = mkHome();
|
|
881
|
+
const { server, port, seen } = await authEnvServer();
|
|
882
|
+
try {
|
|
883
|
+
// REMNIC_HOOK_TOKEN is the detached observe worker's internal propagation
|
|
884
|
+
// channel; the foreground handlers never read it. If the probe consulted
|
|
885
|
+
// it, an inherited value would authenticate health with one bearer while
|
|
886
|
+
// recall sent another — a false "healthy" followed by a 401, or the
|
|
887
|
+
// reverse. Probe and operation must carry one snapshot.
|
|
888
|
+
await runHook(
|
|
889
|
+
"session-start",
|
|
890
|
+
{ session_id: "sSnapshot", cwd: home },
|
|
891
|
+
{ port, home, env: { extra: { REMNIC_HOOK_TOKEN: "inherited-worker-tok" } } },
|
|
892
|
+
);
|
|
893
|
+
assert.equal(seen.health, "Bearer test-token");
|
|
894
|
+
assert.equal(seen.recall, seen.health);
|
|
895
|
+
} finally {
|
|
896
|
+
server.close();
|
|
897
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
898
|
+
}
|
|
899
|
+
});
|