@twinsearth/w2m-dsh-plugin 1.6.0 → 1.6.2

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.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "The w2m fleet plugins: one account, many machines, any of Windows, macOS or Linux.",
8
- "version": "1.6.0"
8
+ "version": "1.6.2"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "w2m-fleet",
13
13
  "source": "./",
14
14
  "description": "Read fleet state and dispatch commands across the machines registered to one account. Read-only MCP surface: the five exposed tools cannot change anything.",
15
- "version": "1.6.0",
15
+ "version": "1.6.2",
16
16
  "author": {
17
17
  "name": "TwinsEarth"
18
18
  },
@@ -26,5 +26,5 @@
26
26
  ]
27
27
  }
28
28
  ],
29
- "version": "1.6.0"
29
+ "version": "1.6.2"
30
30
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "w2m-fleet",
3
3
  "description": "Drive the same project on online Windows, macOS and Linux machines from one account: read fleet state, dispatch commands and read results. The MCP surface is deliberately read-only.",
4
- "version": "1.6.0",
4
+ "version": "1.6.2",
5
5
  "author": {
6
6
  "name": "TwinsEarth"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -7,6 +7,35 @@
7
7
 
8
8
  All notable changes to this project are documented here.
9
9
 
10
+ ## [1.6.2] — 2026-10-24
11
+
12
+ **relay 全服概览:多少个编队、多少台机器在线(healthz + operator /v1/fleets + 面板一行显示)。**
13
+
14
+ - `state.fleetSummary()`:按 fleet 聚合设备数/在线数,返回 `fleets_total`、`devices_online`、
15
+ `devices_total`、`unfleeted_devices` 与 per-fleet 明细。
16
+ - `GET /healthz`(公开)新增上述**聚合计数**(不暴露 per-fleet 明细);
17
+ - `GET /v1/fleets`(operator token):返回 per-fleet 明细(其他账号的编队名不向普通设备暴露)。
18
+ - 面板 relay 折叠区新增一行:`全服:N 个编队 · M/T 台在线`(fleet-target probe 读取 healthz 计数)。
19
+ - relay 主机将升级到 1.6.2(包含 1.6.1 的 Node 执行仅人工/内存态与 allow-list 解释器身份修复)。
20
+ - 测试:relay 200/200、fleet-target 49/49、client-bundle 26/26;证据
21
+ `docs/acceptance/1.6.2/RELAY-FLEET-OVERVIEW.json`(fleet task `01M5ANYMVLRYXSN`)。
22
+ ## [1.6.1] — 2026-10-24
23
+
24
+ **Node 执行开关改为「仅人工 UI 手动、进程内存态」:文件/远程/自动化一律无法授权。**
25
+
26
+ 本次实机验证暴露了一个必须修掉的现实:grant 任务可以远程写 `~/.w2m/exec-capability.json`,
27
+ 从而远程把机器变成可执行任意 Node 代码——与"最高权限只允许人工手动选中"直接冲突。
28
+
29
+ - 能力状态改为**进程内存**唯一权威:`readExecCapability` 不再读磁盘;旧文件(含 permanent)被忽略;
30
+ 只有 host 路由 `/api/w2m/capability`(composer 人工点击)能写。
31
+ - **不随重启保留**:进程退出即回到 denied("永久"= 本次进程生命周期);
32
+ `describe` 明确返回 `survives_restart: false`、`manual_only: true`、`file_ignored: true`。
33
+ - 三态语义不变:denied / temporary(默认 24h,上限 7 天,过期即失效)/ permanent(本进程内)。
34
+ - 测试重写:`test/exec-capability.test.mjs` 6/6(含"磁盘写 permanent 仍 denied""路由是唯一 setter"
35
+ "重启即撤销");agent.test 161 项通过(PATH 环境问题已在本机修复,非代码缺陷)。
36
+ - 修复 allow-list 解释器身份回归:当 argv[0] 是 **运行 agent 的同一个 node(process.execPath)** 时,
37
+ bare 条目 `node` 应放行(此前机器 PATH 里 node 指向别的程序时会误拒,实测 12 个 e2e 失败)。
38
+ - 证据 `docs/acceptance/1.6.1/MANUAL-ONLY-CAPABILITY.json`(fleet task `01M5ANYMVLDYWBH`)。
10
39
  ## [1.6.0] — 2026-10-24
11
40
 
12
41
  **一台 relay 承载 N 个编队:每个 DeepSeek 账号一个编队,共享 relay 但互相隔离。**
@@ -12,6 +12,6 @@
12
12
 
13
13
  [mcp_servers.w2m-fleet]
14
14
  command = "npx"
15
- args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.6.0", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.6.2", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
@@ -0,0 +1,22 @@
1
+ {
2
+ "version": "1.6.1",
3
+ "case": "MANUAL-ONLY-CAPABILITY",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-23T19:51:58.929Z",
9
+ "executor": "lead",
10
+ "requirement": "the highest-privilege Node execution switch can only be changed by a human UI action: it lives in host process memory, disk files are ignored, remote tasks/automation cannot enable it, and a restart revokes it",
11
+ "task_id": "01M5ANYMVLDYWBH",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "capability_policy_suite": "6/6 pass",
16
+ "disk_permanent_file_ignored": "true",
17
+ "route_is_single_setter": "true",
18
+ "restart_revokes": "true"
19
+ }
20
+ },
21
+ "note": "suite ran as a real fleet task; policy invariant pinned by test"
22
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "version": "1.6.2",
3
+ "case": "RELAY-FLEET-OVERVIEW",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-24T02:23:58.829Z",
9
+ "executor": "lead",
10
+ "requirement": "the relay reports how many fleets it serves and how many machines are online: aggregate counts on the public health probe, per-fleet rows only for the operator",
11
+ "task_id": "01M5ANYMVLRYXSN",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "relay_suite": "200/200 pass",
16
+ "fleet_summary": "fleets_total/devices_online/devices_total",
17
+ "operator_route": "GET /v1/fleets",
18
+ "panel_line": "globalSummary rendered"
19
+ }
20
+ },
21
+ "note": "relay and fleet-target suites ran as a real fleet task"
22
+ }
package/lib/client.js CHANGED
@@ -112,6 +112,7 @@ window.__ModuleLoader__.load({
112
112
  relayFold: '另有 {count} 个已折叠,点击展开',
113
113
  relayUnfold: '收起折叠的 relay',
114
114
  relayTemporary: '临时 Relay 节点(回退配置)',
115
+ globalSummary: '全服:{fleets} 个编队 · {online}/{total} 台在线',
115
116
  capLabel: 'Node 执行:{state}',
116
117
  capDenied: '拒绝',
117
118
  capTemporary: '临时 24 小时',
@@ -143,6 +144,7 @@ window.__ModuleLoader__.load({
143
144
  relayFold: '{count} more folded — expand',
144
145
  relayUnfold: 'collapse the folded relays',
145
146
  relayTemporary: 'temporary relay nodes (fallback config)',
147
+ globalSummary: '{fleets} fleets · {online}/{total} machines online',
146
148
  capLabel: 'Node exec: {state}',
147
149
  capDenied: 'denied',
148
150
  capTemporary: 'temporary 24 h',
@@ -631,6 +633,11 @@ window.__ModuleLoader__.load({
631
633
  h('span', { key: 'url', className: cls('relayUrl') }, String(item.url ?? '').replace(/^https?:\/\//, '').replace(/\/$/, '')),
632
634
  h('span', { key: 'rtt', className: cls('relayRtt') }, item.rtt_ms === null || item.rtt_ms === undefined ? '—' : `${item.rtt_ms} ms`),
633
635
  ])),
636
+ relayFold.server === null || relayFold.server === undefined ? null : h('div', { key: 'global', className: cls('relayHint') }, t('globalSummary', {
637
+ fleets: relayFold.server.fleets_total ?? '—',
638
+ online: relayFold.server.devices_online ?? '—',
639
+ total: relayFold.server.devices_total ?? '—',
640
+ })),
634
641
  relayFold.hidden_count > 0 && relayOpen === false
635
642
  ? h('div', { key: 'hint', className: cls('relayHint') }, t('relayFold', { count: relayFold.hidden_count }))
636
643
  : null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "1.6.0",
3
+ "version": "1.6.2",
4
4
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
5
5
  "type": "module",
6
6
  "exports": {
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "twinsearth-w2m-dsh",
4
- "version": "1.6.0",
4
+ "version": "1.6.2",
5
5
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -491,6 +491,16 @@ function executableIdentity(argv0, prefix0, options) {
491
491
  const bundled = bundledExecutable(prefix0, options.env ?? process.env);
492
492
  if (bundled !== null && sameFile(argv0, bundled)) return { kind: 'match', detail: null };
493
493
  }
494
+ // v1.6.1: the interpreter executing this agent is the tool a bare entry names.
495
+ // Measured on a real machine (2026-10-24): DSH runs on its bundled node, a grant runner passes
496
+ // [process.execPath, '-e', ...], and the entry is the bare name `node` -- but PATH resolves `node`
497
+ // to an unrelated application binary. process.execPath needs no dependencies-directory lookup:
498
+ // it is the running interpreter, by definition the program the node entry names. A copy of the
499
+ // binary elsewhere is still refused (this compares identity, not the bare name).
500
+ if (!prefixIsPath && argvIsPath && sameFile(argv0, process.execPath)) {
501
+ const base = (value) => String(value).split(/[\\/]/).pop().replace(/\.exe$/i, '').toLowerCase();
502
+ if (base(argv0) === base(prefix0)) return { kind: 'match', detail: null };
503
+ }
494
504
 
495
505
  return {
496
506
  kind: 'path-mismatch',
@@ -503,7 +503,16 @@ export async function probeRelays(urls, options = {}) {
503
503
  if (!response.ok || body?.ok !== true) {
504
504
  return { url, ok: false, rtt_ms: null, error: `HTTP ${response.status}` };
505
505
  }
506
- return { url, ok: true, rtt_ms: Date.now() - started, relay_id: body.relay_id ?? null };
506
+ return {
507
+ url, ok: true, rtt_ms: Date.now() - started, relay_id: body.relay_id ?? null,
508
+ // v1.6.2: Whole-relay totals, shown in the panel as "N fleets · M machines online".
509
+ // Counts only: per-fleet membership stays behind the fleet-scoped endpoints.
510
+ stats: {
511
+ fleets_total: Number.isInteger(body.fleets_total) ? body.fleets_total : null,
512
+ devices_online: Number.isInteger(body.devices_online) ? body.devices_online : null,
513
+ devices_total: Number.isInteger(body.devices_total) ? body.devices_total : null,
514
+ },
515
+ };
507
516
  } catch (error) {
508
517
  return { url, ok: false, rtt_ms: null, error: error instanceof Error ? error.message : String(error) };
509
518
  }
@@ -573,6 +582,8 @@ export function buildUiState(deps) {
573
582
  primary_url: relays.shown.find((item) => item.ok === true)?.url ?? null,
574
583
  // v1.5.2: a fallback relay list is never presented as settled; the panel renders it red.
575
584
  temporary: relays.temporary === true,
585
+ // v1.6.2: whole-relay overview (fleets + online machines) for the panel header line.
586
+ server: relays.server ?? null,
576
587
  },
577
588
  };
578
589
  }
@@ -795,8 +806,10 @@ function mountTargetRoutes(connection, deps, status, logger) {
795
806
  return { ...item, role: reachableSeen === 1 ? 'primary' : 'standby' };
796
807
  });
797
808
  const folded = foldRelayItems(items, 3);
809
+ // v1.6.2: the primary relay's own view of the whole server, if it reported one.
810
+ const primaryStats = items.find((item) => item.ok === true)?.stats ?? null;
798
811
  // v1.5.2: a fallback list is marked so the panel can render it in red, not as a settled config.
799
- const withMark = { ...folded, temporary: deps.cfg()?.relayTemporary === true };
812
+ const withMark = { ...folded, temporary: deps.cfg()?.relayTemporary === true, server: primaryStats };
800
813
  relayCache = { at: now, value: withMark };
801
814
  return withMark;
802
815
  };
@@ -277,7 +277,7 @@ const MAX_STAGES = 16;
277
277
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
278
278
  * overrides it at runtime for tests and packagers.
279
279
  */
280
- const PLUGIN_VERSION = '1.6.0';
280
+ const PLUGIN_VERSION = '1.6.2';
281
281
 
282
282
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
283
283
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -853,6 +853,17 @@ export class RelayServer {
853
853
  * every task that carried a grant, with the argv hash and key id it was authorised under -- and deliberately returns
854
854
  * only operator-readable data, never result bodies.
855
855
  */
856
+ /**
857
+ * v1.6.2: whole-relay fleet overview for an operator. Aggregate counts are public on /healthz;
858
+ * the per-fleet rows are not, because they name other accounts fleets.
859
+ */
860
+ if (method === 'GET' && path === '/v1/fleets') {
861
+ const auth = this.authorizeTaskDispatch(req);
862
+ if (auth.role !== 'operator') throw new ProtocolError('OPERATOR_REQUIRED', 'listing fleets is an operator view');
863
+ const summary = this.state.fleetSummary();
864
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, ...summary });
865
+ }
866
+
856
867
  if (path === '/v1/grants/revoke' || path === '/v1/grants') {
857
868
  const auth = this.authorizeTaskDispatch(req);
858
869
  if (auth.role !== 'operator') throw new ProtocolError('OPERATOR_REQUIRED', 'only the operator may administer grants');
@@ -3245,12 +3245,40 @@ export class RabbitState {
3245
3245
 
3246
3246
  /* ---------------- serialization ---------------- */
3247
3247
 
3248
+ /**
3249
+ * v1.6.2: the whole-relay view: how many fleets this relay serves and how many machines are
3250
+ * online. Counts only — the per-fleet member lists stay behind the fleet-scoped endpoints, so
3251
+ * this is safe to expose on the public health probe.
3252
+ */
3253
+ fleetSummary(nowMs = this.nowMs()) {
3254
+ const byFleet = new Map();
3255
+ let online = 0;
3256
+ for (const device of this.devices.values()) {
3257
+ const key = typeof device.fleet_id === 'string' && device.fleet_id !== '' ? device.fleet_id : null;
3258
+ const entry = byFleet.get(key) ?? { fleet_id: key, devices: 0, online: 0 };
3259
+ entry.devices += 1;
3260
+ if (this.isOnlineDevice(device, nowMs)) { entry.online += 1; online += 1; }
3261
+ byFleet.set(key, entry);
3262
+ }
3263
+ const fleets = [...byFleet.values()].sort((a, b) => b.devices - a.devices);
3264
+ return {
3265
+ fleets_total: fleets.filter((f) => f.fleet_id !== null).length,
3266
+ unfleeted_devices: byFleet.get(null)?.devices ?? 0,
3267
+ devices_total: this.devices.size,
3268
+ devices_online: online,
3269
+ fleets,
3270
+ };
3271
+ }
3248
3272
  stats() {
3249
3273
  return {
3250
3274
  protocol_version: PROTOCOL_VERSION,
3251
3275
  seq: this.seq,
3252
3276
  buffered_events: this.buffer.length,
3253
3277
  devices: this.devices.size,
3278
+ // v1.6.2: whole-relay totals on the public health probe.
3279
+ fleets_total: this.fleetSummary().fleets_total,
3280
+ devices_online: this.fleetSummary().devices_online,
3281
+ devices_total: this.devices.size,
3254
3282
  tasks: this.tasks.size,
3255
3283
  results: this.results.size,
3256
3284
  pairing_codes: [...this.pairingCodes.values()].filter((c) => !c.used && this.nowMs() < c.expires_at_ms).length,
@@ -1,21 +1,32 @@
1
1
  /**
2
- * Node-code-execution capability: the machine-local switch behind the plugin UI.
2
+ * Node-code-execution capability (v1.6.1 hardening): a **manual, in-process** switch.
3
3
  *
4
- * The allow-list is a standing decision made by the machine owner in a config file. This module is the
5
- * *UI-activated* form of the same decision, scoped to one dangerous ability -- running arbitrary Node code
6
- * (`node -e`) -- with an explicit state: denied, temporary (24 h by default) or permanent. The DSH plugin
7
- * client renders the switch; the host route writes this file; the agent reads it before every task and
8
- * treats an active capability exactly like an additional allow-list prefix.
4
+ * ## Policy
9
5
  *
10
- * Fail-closed rules:
11
- * * a missing, unreadable or malformed file means `denied` (the standing default), never "on";
12
- * * `temporary` whose `expires_at_ms` has passed reads as `denied` and says `expired`, so an expiry
13
- * cannot be mistaken for a configuration error;
14
- * * the only prefix a capability can ever add is `node -e` -- a fixed constant, not caller input.
6
+ * This is the machine's highest-privilege switch: it allows `node -e`, i.e. arbitrary code. The policy
7
+ * is deliberately stricter than "a file the operator can toggle":
8
+ *
9
+ * 1. **Only a human UI action may change it.** The capability lives in the host process's memory and
10
+ * is written exclusively by the same-origin host route (`/api/w2m/capability`) that the composer
11
+ * control calls. There is no file that can be edited, no relay field, no task/document that can
12
+ * grant it.
13
+ * 2. **Nothing a machine or automation runs can enable it.** Agent tasks (grant or not) run in a
14
+ * child process; they can write any file they like, because files are no longer consulted. A
15
+ * remote caller, a scheduled task, a bootstrap answer, or another process on the machine cannot
16
+ * reach this module's memory.
17
+ * 3. **It does not survive a restart.** "Permanent" means "until this host process exits", never
18
+ * "forever": a machine left unattended must not stay remote-code-executable across reboots.
19
+ *
20
+ * Old builds stored the state in `~/.w2m/exec-capability.json`. That file is **ignored** by this
21
+ * implementation, and the UI reports the in-memory truth, so a stale permanent file from an older
22
+ * version cannot silently re-grant execution.
23
+ *
24
+ * Fail-closed rules stay as before: absent/invalid state is `denied`; an expired `temporary` reads
25
+ * `denied` with reason `expired`; the only prefix an active capability can add is the fixed
26
+ * `node -e` constant.
15
27
  */
16
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
17
28
  import { homedir } from 'node:os';
18
- import { dirname, join } from 'node:path';
29
+ import { join } from 'node:path';
19
30
 
20
31
  export const EXEC_CAPABILITY_PROTOCOL = 'w2m-exec-capability/v1';
21
32
  export const EXEC_CAPABILITY_ROUTE_PATH = '/api/w2m/capability';
@@ -25,7 +36,10 @@ export const EXEC_CAPABILITY_STATES = Object.freeze(['denied', 'temporary', 'per
25
36
  export const DEFAULT_TEMPORARY_TTL_MS = 24 * 60 * 60 * 1000;
26
37
  export const MAX_TEMPORARY_TTL_MS = 7 * 24 * 60 * 60 * 1000;
27
38
  export const EXEC_CAPABILITY_WARNING = 'node code execution: allows any Node code on this machine (RCE)';
39
+ /** Why a capability file on disk never authorises anything. Surfaced by the UI/status. */
40
+ export const EXEC_CAPABILITY_MANUAL_ONLY = 'capability is manual-only: set through the host UI, held in process memory, ignored from disk, relay or tasks';
28
41
 
42
+ /** Path kept for compatibility with old builds; it is never read and never written. */
29
43
  export function defaultExecCapabilityFile(home = homedir()) {
30
44
  return join(home, '.w2m', 'exec-capability.json');
31
45
  }
@@ -35,46 +49,35 @@ export function normalizeExecCapabilityState(value) {
35
49
  return typeof value === 'string' && EXEC_CAPABILITY_STATES.includes(value) ? value : null;
36
50
  }
37
51
 
52
+ /** The authoritative state. Module-level on purpose: one process, one switch. */
53
+ let memory = Object.freeze({ state: 'denied', activated_at_ms: null, expires_at_ms: null, actor: null, changed_at_ms: null });
54
+
55
+ /** Test/support hook: drop back to the standing default without touching any file. */
56
+ export function resetExecCapability() {
57
+ memory = Object.freeze({ state: 'denied', activated_at_ms: null, expires_at_ms: null, actor: null, changed_at_ms: null });
58
+ }
59
+
38
60
  /**
39
- * Read the capability state. Never throws: a broken file is `denied` with reason `unreadable`/`invalid`.
61
+ * Read the capability state from process memory.
40
62
  *
41
- * @param {string} file
63
+ * @param {string} [_file] accepted for API compatibility; deliberately NOT read.
42
64
  * @param {number} [nowMs]
43
- * @returns {{state: string, effective: boolean, expires_at_ms: number|null, activated_at_ms: number|null, actor: object|null, reason: string|null}}
65
+ * @returns {{state: string, effective: boolean, expires_at_ms: number|null, activated_at_ms: number|null,
66
+ * actor: object|null, reason: string|null, source: 'process-memory', file_ignored: true}}
44
67
  */
45
- export function readExecCapability(file, nowMs = Date.now()) {
46
- const denied = (reason) => ({ state: 'denied', effective: false, expires_at_ms: null, activated_at_ms: null, actor: null, reason });
47
- let text;
48
- try {
49
- text = readFileSync(file, 'utf8');
50
- } catch (error) {
51
- return denied(error?.code === 'ENOENT' ? 'absent' : 'unreadable');
52
- }
53
- let raw;
54
- try {
55
- raw = JSON.parse(text);
56
- } catch {
57
- return denied('invalid');
58
- }
59
- if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return denied('invalid');
60
- const state = normalizeExecCapabilityState(raw.state);
61
- if (state === null) return denied('invalid');
62
- if (state === 'denied') return denied(null);
63
- const expiresAt = Number.isFinite(raw.expires_at_ms) ? raw.expires_at_ms : null;
64
- const activatedAt = Number.isFinite(raw.activated_at_ms) ? raw.activated_at_ms : null;
65
- if (state === 'temporary') {
66
- if (expiresAt === null) return denied('invalid');
67
- if (expiresAt <= nowMs) return { state: 'denied', effective: false, expires_at_ms: expiresAt, activated_at_ms: activatedAt, actor: null, reason: 'expired' };
68
- }
69
- if (state === 'permanent' && expiresAt !== null) return denied('invalid');
70
- return {
71
- state,
72
- effective: true,
73
- expires_at_ms: state === 'temporary' ? expiresAt : null,
74
- activated_at_ms: activatedAt,
75
- actor: typeof raw.actor === 'object' && raw.actor !== null ? { ...raw.actor } : null,
76
- reason: null,
68
+ export function readExecCapability(_file, nowMs = Date.now()) {
69
+ const base = {
70
+ state: 'denied', effective: false, expires_at_ms: null, activated_at_ms: null, actor: null,
71
+ reason: 'manual_default', source: 'process-memory', file_ignored: true,
77
72
  };
73
+ if (memory.state === 'denied') return base;
74
+ if (memory.state === 'temporary') {
75
+ if (memory.expires_at_ms !== null && memory.expires_at_ms <= nowMs) {
76
+ return { ...base, expires_at_ms: memory.expires_at_ms, activated_at_ms: memory.activated_at_ms, reason: 'expired' };
77
+ }
78
+ return { ...base, state: 'temporary', effective: true, reason: null, expires_at_ms: memory.expires_at_ms, activated_at_ms: memory.activated_at_ms, actor: memory.actor };
79
+ }
80
+ return { ...base, state: 'permanent', effective: true, reason: null, activated_at_ms: memory.activated_at_ms, actor: memory.actor };
78
81
  }
79
82
 
80
83
  /** The prefixes an active capability adds, or an empty array when it is denied/expired. */
@@ -83,21 +86,20 @@ export function effectiveExecCapabilityPrefixes(file, nowMs = Date.now()) {
83
86
  }
84
87
 
85
88
  /**
86
- * Write one capability decision. `temporary` requires a TTL and records an absolute expiry.
89
+ * Change the capability. This is the host route's write path and the only way the switch moves.
90
+ *
91
+ * The `_file` argument is accepted for compatibility and ignored: a caller that expected a disk write
92
+ * (an older route, a script, a test helper) still gets the value back, but the authority is memory.
87
93
  *
88
94
  * @returns {{ok: boolean, code?: string, detail?: string, capability?: object}}
89
95
  */
90
- export function writeExecCapability(file, input, nowMs = Date.now()) {
96
+ export function writeExecCapability(_file, input, nowMs = Date.now()) {
91
97
  const state = normalizeExecCapabilityState(input?.state);
92
98
  if (state === null) return { ok: false, code: 'CAPABILITY_STATE_INVALID', detail: 'state must be denied, temporary or permanent' };
99
+ const actor = typeof input?.actor === 'object' && input.actor !== null ? { ...input.actor } : null;
93
100
  if (state === 'denied') {
94
- try {
95
- mkdirSync(dirname(file), { recursive: true });
96
- writeFileSync(file, JSON.stringify({ protocol: EXEC_CAPABILITY_PROTOCOL, state: 'denied', activated_at_ms: nowMs, actor: input.actor ?? null }, null, 2) + '\n');
97
- } catch (error) {
98
- return { ok: false, code: 'CAPABILITY_WRITE_FAILED', detail: error?.message ?? String(error) };
99
- }
100
- return { ok: true, capability: readExecCapability(file, nowMs) };
101
+ memory = Object.freeze({ state: 'denied', activated_at_ms: nowMs, expires_at_ms: null, actor, changed_at_ms: nowMs });
102
+ return { ok: true, capability: describeExecCapability(undefined, nowMs) };
101
103
  }
102
104
  let ttlMs = input?.ttl_ms;
103
105
  if (state === 'temporary') {
@@ -105,23 +107,17 @@ export function writeExecCapability(file, input, nowMs = Date.now()) {
105
107
  if (ttlMs > MAX_TEMPORARY_TTL_MS) return { ok: false, code: 'CAPABILITY_TTL_TOO_LONG', detail: 'temporary activation is limited to ' + MAX_TEMPORARY_TTL_MS + ' ms' };
106
108
  ttlMs = Math.floor(ttlMs);
107
109
  }
108
- const record = {
109
- protocol: EXEC_CAPABILITY_PROTOCOL,
110
+ memory = Object.freeze({
110
111
  state,
111
112
  activated_at_ms: nowMs,
112
113
  expires_at_ms: state === 'temporary' ? nowMs + ttlMs : null,
113
- actor: typeof input?.actor === 'object' && input.actor !== null ? { ...input.actor } : null,
114
- };
115
- try {
116
- mkdirSync(dirname(file), { recursive: true });
117
- writeFileSync(file, JSON.stringify(record, null, 2) + '\n');
118
- } catch (error) {
119
- return { ok: false, code: 'CAPABILITY_WRITE_FAILED', detail: error?.message ?? String(error) };
120
- }
121
- return { ok: true, capability: readExecCapability(file, nowMs) };
114
+ actor,
115
+ changed_at_ms: nowMs,
116
+ });
117
+ return { ok: true, capability: describeExecCapability(undefined, nowMs) };
122
118
  }
123
119
 
124
- /** The JSON the UI renders: state, effective, remaining time and the one prefix it would allow. */
120
+ /** The JSON the UI renders: state, effective, remaining time, the fixed prefix, and the manual-only rule. */
125
121
  export function describeExecCapability(file, nowMs = Date.now()) {
126
122
  const capability = readExecCapability(file, nowMs);
127
123
  return {
@@ -134,5 +130,10 @@ export function describeExecCapability(file, nowMs = Date.now()) {
134
130
  remaining_ms: capability.state === 'temporary' && capability.expires_at_ms !== null ? Math.max(0, capability.expires_at_ms - nowMs) : null,
135
131
  allows: capability.effective === true ? EXEC_CAPABILITY_PREFIXES.map((prefix) => prefix.join(' ')) : [],
136
132
  warning: EXEC_CAPABILITY_WARNING,
133
+ manual_only: true,
134
+ source: 'process-memory',
135
+ file_ignored: true,
136
+ policy: EXEC_CAPABILITY_MANUAL_ONLY,
137
+ survives_restart: false,
137
138
  };
138
139
  }