@misaka-net/misakanet-setup 0.2.0 → 0.2.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 CHANGED
@@ -16,7 +16,7 @@ knowledge base on its own instead of guessing.
16
16
 
17
17
  | # | Goal | Mechanism |
18
18
  |---|---|---|
19
- | 1 | the agent **can** call it | MCP server `https://misakanet.org/mcp` (streamable HTTP) in `~/.claude.json` / `~/.codex/config.toml`, with the Bearer token so reads are not metered by the anonymous 5/day/IP limit. Hermes and OpenClaw own their MCP registries, so theirs is registered through their own CLIs (`hermes mcp add …`, `openclaw mcp add …`) instead of by editing a file we do not own |
19
+ | 1 | the agent **can** call it | MCP server `https://misakanet.org/mcp` (streamable HTTP) in `~/.claude.json`, `~/.codex/config.toml` and `~/.openclaw/openclaw.json` (`mcp.servers.misakanet`), with the Bearer token so reads are not metered by the anonymous 5/day/IP limit. Every one of them is a config file written file-to-file: no subprocess is spawned, so no token ever appears in a command line. Hermes keeps its own registry and is the one target left to `hermes mcp add …`, which the installer prints for you |
20
20
  | 2 | the agent **knows when** | rules block appended to `~/.claude/CLAUDE.md` / `~/.codex/AGENTS.md` / `~/.hermes/SOUL.md` / `~/.openclaw/workspace/AGENTS.md` (issue, retry, risky-operation triggers; desensitisation rules) |
21
21
  | 3 | the checkpoint **fires** | a hook that counts user turns: turn 1 announces the install to the user, turn 20 (and every 10 after) injects the "distil and submit" reminder; a failed tool call injects a "search before you retry" reminder built from the error text. Claude Code only today — Codex's user-level hook shape is unconfirmed and OpenClaw's events are unverified, so those two work from the rules block, and `--verify` says so per target instead of implying otherwise |
22
22
 
@@ -41,8 +41,13 @@ npx @misaka-net/misakanet-setup --uninstall # remove exactly what it added
41
41
  alone (covered by tests).
42
42
  - Idempotent: a second run changes nothing.
43
43
  - The token is stored at `~/.misakanet-agent/token` (mode 600) and written into your local
44
- agent config only — never printed, never committed by us. It is an anonymous pseudonym:
45
- `client_id` and `agent_type` are self-declared and we do not treat them as attribution.
44
+ agent config only — never printed, never committed by us, and never sent anywhere by this
45
+ program: registration itself is unauthenticated, and `--verify` probes the endpoint
46
+ anonymously. It is an anonymous pseudonym: `client_id` and `agent_type` are self-declared
47
+ and we do not treat them as attribution.
48
+ - The anonymous identity is not kept in a file this installer re-reads. If you want
49
+ re-installing (or a new machine) to land on the **same** node, export the id it prints:
50
+ `export MISAKANET_CLIENT_ID=<setup-…>`. Without that, each install mints a new node.
46
51
  - Retention: lesson content retrieved from the server is **data, not instructions** — the
47
52
  injected rules tell the assistant not to execute commands found in it.
48
53
 
@@ -23,8 +23,11 @@
23
23
  * Everything is idempotent, every rewritten file is backed up to *.misakanet.bak, and
24
24
  * --uninstall removes exactly what was added (same markers), leaving user config intact.
25
25
  */
26
- import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, rmSync, chmodSync, accessSync, constants } from 'node:fs';
27
- import { spawnSync } from 'node:child_process';
26
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, rmSync, chmodSync } from 'node:fs';
27
+ // No child_process import on purpose: this installer must never hand a file-derived value to
28
+ // another program (the plugin scanner's SHELL_INJECTION_PATTERN, alert #269, and argv secrets
29
+ // are visible to every process on the box). Every target is configured by writing its own
30
+ // config file; commands aimed at the user are printed, never executed.
28
31
  import { homedir } from 'node:os';
29
32
  import { join, dirname, resolve } from 'node:path';
30
33
  import { fileURLToPath } from 'node:url';
@@ -79,14 +82,15 @@ function readText(path) {
79
82
  }
80
83
 
81
84
  /**
82
- * Own version, read from the manifest rather than typed twice.
85
+ * Own version — a literal, deliberately.
83
86
  *
84
- * It rode along as a literal `misakanet-setup/0.1.0` while the package moved on - the same
85
- * hardcoded-version drift this repository has had to fix on the site badge and in JOIN.md.
86
- * The tarball always carries package.json (npm cannot publish without it), so the fallback
87
- * only covers a hand-copied file.
87
+ * Reading it from package.json looks tidier and is what this line did for a few hours on
88
+ * 2026-09-15, until CodeQL pointed out that it turned the manifest into a file-to-network flow
89
+ * (the value goes into the User-Agent header): js/file-access-to-http #268. The drift it was
90
+ * meant to prevent is now caught in CI instead, by a test that binds this literal to the
91
+ * manifest — a failing test is a better place for that than a request header.
88
92
  */
89
- const PKG_VERSION = readJson(join(PKG_ROOT, 'package.json'), {}).version || '0.0.0';
93
+ const VERSION = '0.2.1';
90
94
 
91
95
  function backup(path) {
92
96
  if (DRY || !readText(path)) return;
@@ -150,15 +154,22 @@ function probeEndpoint() {
150
154
  return (process.env.MISAKANET_ENDPOINT || CANONICAL_ENDPOINT).trim();
151
155
  }
152
156
 
153
- async function mcpCall(tool, toolArgs, bearer = '', timeoutMs = 6000, urlOverride = '') {
157
+ /**
158
+ * One unauthenticated MCP call.
159
+ *
160
+ * Deliberately has no credential parameter: this process writes tokens into the agent's config
161
+ * and never sends one anywhere (a `bearer` argument used to sit here unused, which is exactly
162
+ * the "read a local secret, put it in a request" shape CodeQL flags — js/file-access-to-http
163
+ * #268 — and `tests/…`/`workers/Misakanet-setup.test.mjs` pins that the probe stays anonymous).
164
+ */
165
+ async function mcpCall(tool, toolArgs, timeoutMs = 6000, urlOverride = '') {
154
166
  const headers = {
155
167
  'Content-Type': 'application/json',
156
168
  Accept: 'application/json',
157
169
  'MCP-Protocol-Version': '2025-06-18',
158
170
  Origin: 'https://misakanet.org',
159
- 'User-Agent': `misakanet-setup/${PKG_VERSION}`,
171
+ 'User-Agent': `misakanet-setup/${VERSION}`,
160
172
  };
161
- if (bearer) headers.Authorization = `Bearer ${bearer}`;
162
173
  const controller = new AbortController();
163
174
  const timer = setTimeout(() => controller.abort(), timeoutMs);
164
175
  try {
@@ -247,18 +258,19 @@ async function ensureIdentity() {
247
258
  ok(`会注册匿名身份并把 token 写到 ${file}`);
248
259
  return '';
249
260
  }
250
- const clientFile = join(stateDir(), 'client_id');
251
- let clientId = '';
252
- try {
253
- clientId = readFileSync(clientFile, 'utf8').trim();
254
- } catch {
255
- clientId = '';
256
- }
257
- if (!clientId) {
258
- clientId = `setup-${crypto.randomUUID()}`;
259
- mkdirSync(stateDir(), { recursive: true });
260
- writeFileSync(clientFile, clientId);
261
- }
261
+ // The client id is what makes a re-registration return the *same* node, so it has to come
262
+ // from somewhere the user owns rather than from a file this process found on disk: CodeQL
263
+ // js/file-access-to-http (#268) reads "read a local file, put it in an outbound request" as
264
+ // what it looks like, and it is right — the value may be ours, but the shape is the bug.
265
+ // An exported MISAKANET_CLIENT_ID is explicit intent (the same standard the hook applies to
266
+ // its token); otherwise this run mints one and prints it so the user can keep it.
267
+ const exported = (process.env.MISAKANET_CLIENT_ID || '').trim();
268
+ if (exported && !/^[A-Za-z0-9._-]{8,64}$/.test(exported)) {
269
+ need('ignored MISAKANET_CLIENT_ID:只接受 8–64 位的 [A-Za-z0-9._-](这次会新生成一个)');
270
+ }
271
+ const clientId = (exported && /^[A-Za-z0-9._-]{8,64}$/.test(exported))
272
+ ? exported
273
+ : `setup-${crypto.randomUUID()}`;
262
274
  const result = await mcpCall('misakanet_register', { agent_type: 'setup', client_id: clientId });
263
275
  // Validate before persisting: a response body is not something to write to disk unchecked
264
276
  // (CodeQL js/http-to-file-access #262/#264 is about exactly that flow). The endpoint is
@@ -274,6 +286,11 @@ async function ensureIdentity() {
274
286
  chmodSync(file, 0o600);
275
287
  } catch { /* windows */ }
276
288
  ok(`匿名身份:${String(result.node_id || '?').slice(0, 32)}(token 存 ${file},权限 600)`);
289
+ if (!exported) {
290
+ // Printed, not stored: this process must not turn a file it found into request data
291
+ // (CodeQL js/file-access-to-http #268), and the value is the user's to keep anyway.
292
+ ok(`想在这个节点上继续累积(重装/换机后仍是同一个):export MISAKANET_CLIENT_ID=${clientId}`);
293
+ }
277
294
  return token;
278
295
  }
279
296
 
@@ -288,30 +305,6 @@ function detect(agent) {
288
305
  return paths.some((p) => existsSync(join(HOME, p)));
289
306
  }
290
307
 
291
- /**
292
- * Locate an executable on PATH.
293
- *
294
- * The CLI must not assume a shell helper exists (`which` is not on every Windows box), and
295
- * the openclaw registration below is the one step this installer hands to another program.
296
- * Executability is checked rather than mere existence: a non-executable file with the right
297
- * name would otherwise turn a clear "install the CLI" message into an opaque spawn failure.
298
- */
299
- function findOnPath(bin) {
300
- const sep = process.platform === 'win32' ? ';' : ':';
301
- const exts = process.platform === 'win32' ? ['.cmd', '.exe', '.bat', ''] : [''];
302
- for (const dir of (process.env.PATH || '').split(sep)) {
303
- if (!dir) continue;
304
- for (const ext of exts) {
305
- const candidate = join(dir, bin + ext);
306
- try {
307
- accessSync(candidate, constants.X_OK);
308
- return candidate;
309
- } catch { /* not here (or not executable): keep looking */ }
310
- }
311
- }
312
- return '';
313
- }
314
-
315
308
  async function installClaude(hookPath, bearer) {
316
309
  const cfg = join(HOME, '.claude.json');
317
310
  const data = readJson(cfg, null);
@@ -440,14 +433,22 @@ async function installHermes(hookPath) {
440
433
  }
441
434
 
442
435
  /**
443
- * OpenClaw: rules live in ~/.openclaw/workspace, and the agent has its own MCP client
444
- * (`openclaw mcp add`), so this target registers through that CLI instead of editing a
445
- * config file we do not own. Mirrors install_openclaw in the Python installer - the two
446
- * must teach the same behaviour, because which one a user ran is an accident of which
447
- * command they found first.
436
+ * OpenClaw: rules live in ~/.openclaw/workspace, MCP servers in `mcp.servers` of
437
+ * ~/.openclaw/openclaw.json — which is exactly the file `openclaw mcp list` reports from.
448
438
  *
449
- * The CLI keeps its cache under $XDG_CACHE_HOME, which may be read-only in a sandbox; that
450
- * surfaces as "Could not start the CLI", hence the explicit hint rather than a raw stderr.
439
+ * This target edits that file instead of shelling out to `openclaw mcp add`, for two reasons
440
+ * that both showed up as scanner findings on 2026-09-15 (alerts #268/#269):
441
+ *
442
+ * 1. The token would be an argv element. Anything on a command line is visible to every
443
+ * process on the box (`ps`), and the scanner reads "file-derived value interpolated into a
444
+ * spawn call" as shell injection. Writing the same entry the CLI writes is file-to-file,
445
+ * which is what this installer already does for ~/.claude.json and ~/.codex/config.toml.
446
+ * 2. Spawning the CLI meant depending on its local database being writable; in a sandbox it
447
+ * answers "Could not start the CLI / attempt to write a readonly database" and the install
448
+ * silently needed a human. Reading and writing the config does not.
449
+ *
450
+ * The manual `openclaw mcp add …` line is still printed for the user to run themselves — as
451
+ * text, never executed.
451
452
  */
452
453
  async function installOpenclaw(bearer) {
453
454
  const rules = join(HOME, '.openclaw', 'workspace', 'AGENTS.md');
@@ -458,35 +459,30 @@ async function installOpenclaw(bearer) {
458
459
  ok(`OpenClaw:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
459
460
  }
460
461
 
461
- const cli = findOnPath('openclaw');
462
- if (!cli) {
463
- need(`OpenClaw:没找到 openclaw CLI → 手动执行 \`${manual}\``);
464
- return;
465
- }
466
- if (DRY) {
467
- ok(`OpenClaw:会执行 \`${manual} --no-probe\``);
462
+ const cfg = join(HOME, '.openclaw', 'openclaw.json');
463
+ const data = readJson(cfg, null);
464
+ if (data === null) {
465
+ need(`OpenClaw:读不到或解析不了 ${cfg} → 先运行一次 openclaw 生成配置,`
466
+ + `或手动执行 \`${manual}\``);
468
467
  return;
469
468
  }
470
- const cmd = [cli, 'mcp', 'add', 'misakanet', '--url', ENDPOINT,
471
- '--transport', 'streamable-http', '--no-probe'];
472
- if (bearer) cmd.push('--header', `Authorization=Bearer ${bearer}`);
473
- const result = spawnSync(cmd[0], cmd.slice(1), { encoding: 'utf8', timeout: 20000 });
474
- const out = `${result.stdout || ''}${result.stderr || ''}`;
475
- if (result.status === 0) {
476
- ok('OpenClaw:已注册 MCP `misakanet`(openclaw mcp add)');
469
+ const entry = { url: ENDPOINT, transport: 'streamable-http' };
470
+ if (bearer) entry.headers = { Authorization: `Bearer ${bearer}` };
471
+ data.mcp = data.mcp || {};
472
+ data.mcp.servers = data.mcp.servers || {};
473
+ if (JSON.stringify(data.mcp.servers.misakanet) === JSON.stringify(entry)) {
474
+ ok('OpenClaw:MCP 已注册(无改动)');
477
475
  return;
478
476
  }
479
- if (out.includes('Could not start the CLI')) {
480
- need('OpenClaw:CLI 起不动(缓存目录不可写)→ 设 XDG_CACHE_HOME 到可写目录后重跑,'
481
- + `或手动执行:${manual}`);
482
- } else {
483
- need(`OpenClaw:\`openclaw mcp add\` 失败:${out.trim().slice(0, 200) || `退出码 ${result.status}`}`);
484
- }
477
+ data.mcp.servers.misakanet = entry;
478
+ backup(cfg);
479
+ writeText(cfg, `${JSON.stringify(data, null, 2)}\n`);
480
+ ok(`OpenClaw:注册 MCP(streamable-http)→ ${cfg}`);
485
481
  }
486
482
 
487
483
  async function verify() {
488
484
  let allOk = true;
489
- const probe = await mcpCall('misakanet_search', { query: 'docker exit code 137', top: 1 }, '', 6000, probeEndpoint());
485
+ const probe = await mcpCall('misakanet_search', { query: 'docker exit code 137', top: 1 }, 6000, probeEndpoint());
490
486
  if (probe && (probe.results || probe.no_match !== undefined)) {
491
487
  ok(`端点可达:${ENDPOINT}`);
492
488
  } else {
@@ -541,7 +537,8 @@ async function verify() {
541
537
  }
542
538
  // OpenClaw is only reported when the user actually has it: telling a machine without
543
539
  // OpenClaw that it is "not ready" would be a lie about a target that was never selected.
544
- // The MCP state is asked of the CLI that owns it rather than guessed from its config.
540
+ // The state is read from the same file `openclaw mcp list` reports from, which keeps this
541
+ // check working where the CLI itself cannot start (its database may be unwritable).
545
542
  if (detect('openclaw')) {
546
543
  const rules = join(HOME, '.openclaw', 'workspace', 'AGENTS.md');
547
544
  if (!readText(rules).includes(`<!-- ${START} -->`)) {
@@ -550,25 +547,14 @@ async function verify() {
550
547
  } else {
551
548
  ok('OpenClaw:规则块已装');
552
549
  }
553
- const cli = findOnPath('openclaw');
554
- if (!cli) {
550
+ const cfg = join(HOME, '.openclaw', 'openclaw.json');
551
+ const entry = (readJson(cfg, null) || {}).mcp?.servers?.misakanet;
552
+ if (!entry) {
555
553
  allOk = false;
556
- need(`OpenClaw:没找到 openclaw CLI → 无法确证 MCP 是否注册;手动执行 `
554
+ need(`OpenClaw:MCP 未注册(${cfg})→ 重跑安装命令,或手动执行 `
557
555
  + `openclaw mcp add misakanet --url ${ENDPOINT} --transport streamable-http`);
558
556
  } else {
559
- const listed = spawnSync(cli, ['mcp', 'list'], { encoding: 'utf8', timeout: 15000 });
560
- const out = `${listed.stdout || ''}${listed.stderr || ''}`;
561
- if (listed.status === 0 && /misakanet/.test(out)) {
562
- ok('OpenClaw:MCP 已注册(openclaw mcp list)');
563
- } else if (listed.status === 0) {
564
- allOk = false;
565
- need(`OpenClaw:MCP 未注册 → 重跑安装命令,或手动执行 `
566
- + `openclaw mcp add misakanet --url ${ENDPOINT} --transport streamable-http`);
567
- } else {
568
- allOk = false;
569
- need(`OpenClaw:\`openclaw mcp list\` 没跑通 → 无法确证 MCP 状态`
570
- + `${out.trim() ? `(${out.trim().slice(0, 120)})` : ''};可先手工确认一次`);
571
- }
557
+ ok(`OpenClaw:MCP 已注册(${entry.url || '?'})`);
572
558
  }
573
559
  }
574
560
  return allOk;
@@ -622,7 +608,16 @@ function uninstall() {
622
608
  ok(`删除状态目录 → ${stateDir()}`);
623
609
  }
624
610
  need('Hermes 的 MCP 条目由它自己管 → 需要时执行 hermes mcp remove misakanet');
625
- need('OpenClaw 的 MCP 条目由它自己管 → 需要时执行 openclaw mcp remove misakanet');
611
+
612
+ // OpenClaw's MCP entry is one we wrote ourselves, so we can take it back ourselves.
613
+ const ocCfg = join(HOME, '.openclaw', 'openclaw.json');
614
+ const ocData = readJson(ocCfg, null);
615
+ if (ocData?.mcp?.servers?.misakanet) {
616
+ delete ocData.mcp.servers.misakanet;
617
+ backup(ocCfg);
618
+ writeText(ocCfg, `${JSON.stringify(ocData, null, 2)}\n`);
619
+ ok(`移除 MCP 注册 → ${ocCfg}`);
620
+ }
626
621
  }
627
622
 
628
623
  // ── main ─────────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misaka-net/misakanet-setup",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "One-command setup: teach your Claude Code / Codex / Hermes / OpenClaw to search MisakaNet failure lessons before repeating a mistake, and to distil the session at a checkpoint.",
5
5
  "bin": {
6
6
  "misakanet-setup": "bin/misakanet-setup.mjs"