@twinsearth/w2m-dsh-plugin 2.0.4 → 2.0.5

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.
Files changed (69) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +8 -0
  4. package/bin/w2m-joint.mjs +69 -7
  5. package/codex/mcp-servers.toml +1 -1
  6. package/docs/acceptance/2.0.5/RELEASE-205.json +21 -0
  7. package/docs/acceptance/2.0.5/RELEASE-2050.json +22 -0
  8. package/docs/adr/0001-collaboration-crdt.md +59 -0
  9. package/lib/client.js +146 -8
  10. package/package.json +2 -2
  11. package/plugin.json +1 -1
  12. package/src/agent/agent.mjs +97 -36
  13. package/src/agent/artifact-delivery.mjs +89 -0
  14. package/src/agent/caps.mjs +16 -2
  15. package/src/agent/sinks/git-sink.mjs +78 -0
  16. package/src/agent/sinks/ssh-sink.mjs +83 -0
  17. package/src/agent/sse-cursor.mjs +16 -0
  18. package/src/agent/sse-watchdog.mjs +71 -0
  19. package/src/plugin/dataplane-sink-route.mjs +113 -0
  20. package/src/plugin/fleet-chat-route.mjs +11 -0
  21. package/src/plugin/joint-dev/dispatch.mjs +5 -1
  22. package/src/plugin/joint-dev/merge-approval.mjs +115 -0
  23. package/src/plugin/joint-dev/progress.mjs +107 -0
  24. package/src/plugin/joint-dev/relay-dispatch.mjs +52 -22
  25. package/src/plugin/project-stream-route.mjs +58 -0
  26. package/src/plugin/tools-joint.mjs +6 -1
  27. package/src/plugin/tools.mjs +25 -5
  28. package/src/relay/fleet-messages.mjs +48 -24
  29. package/src/relay/project-room.mjs +47 -2
  30. package/src/relay/server.mjs +11 -5
  31. package/src/relay/state.mjs +63 -3
  32. package/src/util/chat-crypto.mjs +70 -1
  33. package/docs/COLLAB/COLLABORATION-MODEL.md +0 -240
  34. package/docs/COLLAB/EXECUTION-AUTH-MODEL.md +0 -361
  35. package/docs/COLLAB/M1-ACCEPTANCE-RUNBOOK.md +0 -70
  36. package/docs/COLLAB/M1-MAINTENANCE.md +0 -313
  37. package/docs/COLLAB/M2-TASK-GRANT.md +0 -231
  38. package/docs/COLLAB/M3-GIT-NATIVE-TASKS.md +0 -291
  39. package/docs/COLLAB/M4-PROJECT-ROOM.md +0 -260
  40. package/docs/COLLAB/M5-MULTI-CLIENT-PRIMITIVES.md +0 -263
  41. package/docs/COLLAB/M6-CROSS-NETWORK-RELIABILITY.md +0 -231
  42. package/docs/COLLAB/M7-METRICS-ACCEPTANCE.md +0 -263
  43. package/docs/COLLAB/TASK-MODEL.md +0 -281
  44. package/docs/acceptance/1.9.10/RELEASE-1910.json +0 -21
  45. package/docs/acceptance/1.9.11/RELEASE-1911.json +0 -20
  46. package/docs/acceptance/1.9.12/RELEASE-1912.json +0 -20
  47. package/docs/acceptance/1.9.13/RELEASE-1913.json +0 -20
  48. package/docs/acceptance/1.9.14/RELEASE-1914.json +0 -20
  49. package/docs/acceptance/1.9.15/RELEASE-1915.json +0 -22
  50. package/docs/acceptance/1.9.16/RELEASE-1916.json +0 -21
  51. package/docs/acceptance/1.9.17/RELEASE-1917.json +0 -20
  52. package/docs/acceptance/1.9.19/RELEASE-1919.json +0 -21
  53. package/docs/acceptance/1.9.20/RELEASE-1920.json +0 -22
  54. package/docs/acceptance/1.9.4/RELEASE-194.json +0 -23
  55. package/docs/acceptance/1.9.5/RELEASE-195.json +0 -23
  56. package/docs/acceptance/1.9.6/RELEASE-196.json +0 -22
  57. package/docs/acceptance/1.9.7/RELEASE-197.json +0 -21
  58. package/docs/acceptance/1.9.8/RELEASE-198.json +0 -21
  59. package/docs/acceptance/1.9.9/RELEASE-199.json +0 -21
  60. package/lib/client.js.bak2 +0 -640
  61. package/src/plugin/dataflow.mjs +0 -72
  62. package/src/plugin/fleet-target.mjs.bak-arch2 +0 -812
  63. package/src/plugin/fleet-target.mjs.bak-archive +0 -812
  64. package/src/plugin/tofuture/report.mjs +0 -44
  65. package/src/plugin/tools.mjs.bak-c7 +0 -7029
  66. package/src/relay/tofuture-whitelist.mjs +0 -46
  67. package/src/util/data-plane.mjs +0 -180
  68. package/src/util/sealed-object.mjs +0 -141
  69. package/src/util/tofuture-hmac.mjs +0 -30
@@ -191,7 +191,10 @@ import {
191
191
  import { registerChatRoutes } from './fleet-chat-route.mjs';
192
192
  import { registerExecCapabilityRoutes } from './exec-capability-route.mjs';
193
193
  import { registerPluginUpdateRoutes } from './update-route.mjs';
194
- import { registerOperatorTokenRoute } from './operator-token-route.mjs';
194
+ import { registerOperatorTokenRoute } from './operator-token-route.mjs';
195
+ import { registerDataplaneSinkRoute } from './dataplane-sink-route.mjs';
196
+ import { registerProjectStreamRoute } from './project-stream-route.mjs';
197
+ import { planProgress } from './joint-dev/progress.mjs';
195
198
  import { registerJointTools } from './tools-joint.mjs';
196
199
  import { fingerprintOf, readOperatorToken } from './operator-token.mjs';
197
200
  import { registerCaptureRoutes } from './capture-route.mjs';
@@ -286,7 +289,7 @@ const MAX_STAGES = 16;
286
289
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
287
290
  * overrides it at runtime for tests and packagers.
288
291
  */
289
- const PLUGIN_VERSION = '2.0.4';
292
+ const PLUGIN_VERSION = '2.0.5';
290
293
 
291
294
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
292
295
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -3586,6 +3589,10 @@ export async function apply(ctx, config = {}) {
3586
3589
  // verified updater (SHA-256 checked, old package backed up, restart required afterwards).
3587
3590
  const _updateRoutes = registerPluginUpdateRoutes(ctx, { profileDir: () => cfg.profileDir });
3588
3591
  const _operatorTokenRoute = registerOperatorTokenRoute(ctx, { configuredToken: () => cfg.operatorToken });
3592
+ // A3/S4: local-user authorization of the artifact sink. Not reachable from any tool schema.
3593
+ const _dataplaneSinkRoute = registerDataplaneSinkRoute(ctx, { stateDir: () => resolveStateDir(cfg.stateDir, 'joint-dev') });
3594
+ // A6: read-only project SSE proxy (device token stays in the host process).
3595
+ const _projectStreamRoute = registerProjectStreamRoute(ctx, { relayUrl: () => cfg.rabbitUrl, deviceToken: () => resolveToken(cfg) });
3589
3596
 
3590
3597
  // v1.7.1: host-side screenshot + clipboard fallbacks. The DSH desktop shell does not grant the
3591
3598
  // renderer getDisplayMedia/navigator.clipboard, so the chat panel needs a host route that does not
@@ -3628,8 +3635,21 @@ export async function apply(ctx, config = {}) {
3628
3635
  // -------------------------------------------------------------------------------------------
3629
3636
  // 1. w2m_device - read-only
3630
3637
  // -------------------------------------------------------------------------------------------
3631
- registerJointTools(ctx, { defineTool, stateDir: cfg.stateDir, machineName: cfg.machineName, output: jsonOutput });
3632
-
3638
+ registerJointTools(ctx, {
3639
+ defineTool,
3640
+ stateDir: resolveStateDir(cfg.stateDir, 'joint-dev'),
3641
+ machineName: cfg.machineName,
3642
+ output: jsonOutput,
3643
+ progress: async ({ planSha }) => {
3644
+ try {
3645
+ const token = await resolveToken(cfg);
3646
+ return await planProgress({ stateDir: resolveStateDir(cfg.stateDir, 'joint-dev'), planSha, relayUrl: cfg.rabbitUrl, deviceToken: token });
3647
+ } catch {
3648
+ return { ok: false, code: 'PROGRESS_UNAVAILABLE' };
3649
+ }
3650
+ },
3651
+ });
3652
+
3633
3653
  ctx.tools.register(defineTool({
3634
3654
  name: 'w2m_devices',
3635
3655
  description:
@@ -7309,7 +7329,7 @@ hint: 'the local fleet-memory.md is unchanged; action read/show still reads loca
7309
7329
  const base = requireRabbit(cfg);
7310
7330
  const taskId = typeof args?.task_id === 'string' ? args.task_id.trim() : '';
7311
7331
  if (taskId === '') throw new W2MError('W2M_BAD_TASK_ID', '`task_id` must be a non-empty string');
7312
- const token = await resolveToken(cfg);
7332
+ const token = requireOperatorToken(cfg);
7313
7333
  const cancelled = await request({
7314
7334
  rabbitUrl: base,
7315
7335
  token,
@@ -35,6 +35,11 @@ export const RETENTION_DAYS = 7;
35
35
  /** One message. Text is cheap; a 4 KiB cap keeps a single paste from eating the budget. */
36
36
  export const MAX_MESSAGE_BYTES = 4 * 1024;
37
37
 
38
+ /** The only cipher the relay accepts in an `enc` envelope (it never holds a key). */
39
+ const CHAT_ALG = 'aes-256-gcm';
40
+ /** AEAD v2: adds an AAD object binding the ciphertext to session/machine/message id. */
41
+ const CHAT_ENC_V2 = 2;
42
+
38
43
  const DAY_MS = 24 * 60 * 60 * 1000;
39
44
 
40
45
  /**
@@ -101,10 +106,10 @@ export function createFleetMessages(options = {}) {
101
106
  if (sessionId === '') return { ok: false, code: 'BAD_SESSION' };
102
107
  const machineId = typeof input?.machine_id === 'string' ? input.machine_id.trim() : '';
103
108
  if (machineId === '') return { ok: false, code: 'BAD_MACHINE' };
104
- // v1.9.18: chat carries text and slash-command text only; images never travel over the relay.
105
- const MEDIA_FIELDS = ['image', 'image_url', 'image_base64', 'image_bytes', 'picture', 'photo', 'thumb', 'thumbnail', 'attachment', 'attachments', 'file', 'files', 'data', 'base64', 'blob', 'binary', 'buffer', 'media', 'sticker_image', 'sticker_data', 'gif', 'png', 'jpeg', 'webp'];
106
- const mediaField = Object.keys(input ?? {}).find((key) => MEDIA_FIELDS.includes(key.toLowerCase()));
107
- if (mediaField !== undefined) return { ok: false, code: 'CHAT_FIELD_FORBIDDEN', detail: { field: mediaField } };
109
+ // v1.9.18: chat carries text and slash-command text only; images never travel over the relay.
110
+ const MEDIA_FIELDS = ['image', 'image_url', 'image_base64', 'image_bytes', 'picture', 'photo', 'thumb', 'thumbnail', 'attachment', 'attachments', 'file', 'files', 'data', 'base64', 'blob', 'binary', 'buffer', 'media', 'sticker_image', 'sticker_data', 'gif', 'png', 'jpeg', 'webp'];
111
+ const mediaField = Object.keys(input ?? {}).find((key) => MEDIA_FIELDS.includes(key.toLowerCase()));
112
+ if (mediaField !== undefined) return { ok: false, code: 'CHAT_FIELD_FORBIDDEN', detail: { field: mediaField } };
108
113
  if (typeof input?.text !== 'string') return { ok: false, code: 'NOT_TEXT' };
109
114
  const text = input.text;
110
115
  if (text.trim() === '') return { ok: false, code: 'EMPTY_TEXT' };
@@ -128,30 +133,49 @@ export function createFleetMessages(options = {}) {
128
133
  const rawEnc = input?.enc;
129
134
  let enc = null;
130
135
  if (rawEnc !== undefined && rawEnc !== null) {
136
+ if (typeof rawEnc !== 'object' || Array.isArray(rawEnc)) return { ok: false, code: 'BAD_ENCRYPTION' };
131
137
  const ivOk = typeof rawEnc.iv === 'string' && rawEnc.iv.length > 0 && rawEnc.iv.length <= 64;
132
138
  const tagOk = typeof rawEnc.tag === 'string' && rawEnc.tag.length > 0 && rawEnc.tag.length <= 64;
133
- if (rawEnc.v !== 1 || rawEnc.alg !== 'aes-256-gcm' || !ivOk || !tagOk) return { ok: false, code: 'BAD_ENCRYPTION' };
134
- enc = { v: 1, alg: 'aes-256-gcm', iv: rawEnc.iv, tag: rawEnc.tag };
139
+ if (rawEnc.alg !== CHAT_ALG || !ivOk || !tagOk) return { ok: false, code: 'BAD_ENCRYPTION' };
140
+ if (rawEnc.v === 1) {
141
+ enc = { v: 1, alg: CHAT_ALG, iv: rawEnc.iv, tag: rawEnc.tag };
142
+ } else if (rawEnc.v === CHAT_ENC_V2) {
143
+ // AEAD v2 binds the ciphertext to its context. The AAD travels in the clear so every
144
+ // reader can rebuild it, and the GCM tag authenticates those exact bytes. The relay knows
145
+ // session_id/machine_id, so it refuses clear AAD naming another session or machine; it never
146
+ // sees a key and never inspects the ciphertext.
147
+ const aad = rawEnc.aad;
148
+ if (aad === null || typeof aad !== 'object' || Array.isArray(aad)) return { ok: false, code: 'CHAT_AAD_MISSING' };
149
+ const aadSession = typeof aad.session_id === 'string' ? aad.session_id : '';
150
+ const aadMachine = typeof aad.machine_id === 'string' ? aad.machine_id : '';
151
+ const aadMessage = typeof aad.message_id === 'string' ? aad.message_id : '';
152
+ if (aadSession !== sessionId || aadMachine !== machineId) return { ok: false, code: 'CHAT_AAD_MISMATCH' };
153
+ // eslint-disable-next-line no-control-regex
154
+ if (aadMessage === '' || aadMessage.length > 64 || /[\u0000-\u001F]/.test(aadMessage)) return { ok: false, code: 'CHAT_AAD_INVALID' };
155
+ enc = { v: CHAT_ENC_V2, alg: CHAT_ALG, iv: rawEnc.iv, tag: rawEnc.tag, aad: { session_id: aadSession, machine_id: aadMachine, message_id: aadMessage } };
156
+ } else {
157
+ return { ok: false, code: 'BAD_ENCRYPTION' };
158
+ }
159
+ }
160
+
161
+ // v1.9.18: a plain-text message may not smuggle an image payload. Encrypted text is exempt:
162
+ // it is base64 ciphertext by design and the relay cannot (and must not) inspect it.
163
+ if (enc === null) {
164
+ if (/data:(?:image|video|audio)\//i.test(text) || /!\[[^\]]*\]\(/i.test(text)) {
165
+ return { ok: false, code: 'TEXT_IMAGE_FORBIDDEN' };
166
+ }
167
+ const block = text.match(/[A-Za-z0-9+/]{256,}={0,2}/);
168
+ if (block !== null) {
169
+ try {
170
+ const head = Buffer.from(block[0].slice(0, 32), 'base64');
171
+ const magic = head.slice(0, 4).toString('latin1');
172
+ if ((head[0] === 0x89 && head.slice(1, 4).toString('latin1') === 'PNG') || (head[0] === 0xff && head[1] === 0xd8) || magic.startsWith('GIF8') || magic.startsWith('RIFF')) {
173
+ return { ok: false, code: 'TEXT_BINARY_FORBIDDEN' };
174
+ }
175
+ } catch { /* not base64 that matters */ }
176
+ }
135
177
  }
136
178
 
137
- // v1.9.18: a plain-text message may not smuggle an image payload. Encrypted text is exempt:
138
- // it is base64 ciphertext by design and the relay cannot (and must not) inspect it.
139
- if (enc === null) {
140
- if (/data:(?:image|video|audio)\//i.test(text) || /!\[[^\]]*\]\(/i.test(text)) {
141
- return { ok: false, code: 'TEXT_IMAGE_FORBIDDEN' };
142
- }
143
- const block = text.match(/[A-Za-z0-9+/]{256,}={0,2}/);
144
- if (block !== null) {
145
- try {
146
- const head = Buffer.from(block[0].slice(0, 32), 'base64');
147
- const magic = head.slice(0, 4).toString('latin1');
148
- if ((head[0] === 0x89 && head.slice(1, 4).toString('latin1') === 'PNG') || (head[0] === 0xff && head[1] === 0xd8) || magic.startsWith('GIF8') || magic.startsWith('RIFF')) {
149
- return { ok: false, code: 'TEXT_BINARY_FORBIDDEN' };
150
- }
151
- } catch { /* not base64 that matters */ }
152
- }
153
- }
154
-
155
179
  // v1.2.5 idempotency: replay the first message instead of storing the text twice.
156
180
  const idempotencyKey = typeof input?.idempotency_key === 'string' && input.idempotency_key.trim() !== ''
157
181
  ? input.idempotency_key.trim().slice(0, 64)
@@ -147,8 +147,20 @@ export function createProjectRoom({ now = () => Date.now(), onEvent = null } = {
147
147
  /** Incremental events for one project, seq strictly greater than sinceSeq. */
148
148
  events({ projectId, sinceSeq = 0, limit = 200 }) {
149
149
  requireProject(projectId);
150
- const slice = events.filter((event) => event.project_id === projectId && event.seq > sinceSeq).slice(0, limit);
151
- return { events: slice.map((event) => ({ ...event, payload: { ...event.payload } })), next_seq: slice.length > 0 ? slice[slice.length - 1].seq : sinceSeq };
150
+ const projectEvents = events.filter((event) => event.project_id === projectId);
151
+ const available = projectEvents.filter((event) => event.seq > sinceSeq);
152
+ const slice = available.slice(0, Math.max(0, limit));
153
+ const latestSeq = projectEvents.length > 0 ? projectEvents[projectEvents.length - 1].seq : sinceSeq;
154
+ return {
155
+ events: slice.map((event) => ({ ...event, payload: { ...event.payload } })),
156
+ next_seq: slice.length > 0 ? slice[slice.length - 1].seq : sinceSeq,
157
+ latest_seq: latestSeq,
158
+ oldest_available_seq: available.length > 0 ? available[0].seq : null,
159
+ // More events exist than this page returned: the client must page with next_seq instead of
160
+ // assuming the replay is complete (the old shape made an over-limit page look final).
161
+ truncated: available.length > slice.length,
162
+ remaining: Math.max(0, available.length - slice.length),
163
+ };
152
164
  },
153
165
  /** Projects a member belongs to. */
154
166
  listProjects({ member }) {
@@ -158,6 +170,39 @@ export function createProjectRoom({ now = () => Date.now(), onEvent = null } = {
158
170
  requireProject(projectId);
159
171
  return { ok: true, event: emit(projectId, type, actor, payload) };
160
172
  },
173
+ /**
174
+ * A6: self-contained state for persistence. Projects and the event log are plain data, so the
175
+ * snapshot is the exact object that <stateDir>/projects can be rebuilt from.
176
+ */
177
+ toSnapshot() {
178
+ return {
179
+ v: 1,
180
+ counter,
181
+ seq,
182
+ last_event_seq: seq,
183
+ projects: [...projects.values()].map((project) => JSON.parse(JSON.stringify(project))),
184
+ events: events.map((event) => JSON.parse(JSON.stringify(event))),
185
+ };
186
+ },
187
+ /** Restore a snapshot produced by toSnapshot; refuses anything not shaped like one. */
188
+ restore(snapshot) {
189
+ if (snapshot === null || typeof snapshot !== 'object' || snapshot.v !== 1 || !Array.isArray(snapshot.projects) || !Array.isArray(snapshot.events)) {
190
+ throw new ProjectRoomError('BAD_SNAPSHOT', 'project room snapshot is not shaped like v1');
191
+ }
192
+ projects.clear();
193
+ for (const project of snapshot.projects) {
194
+ if (typeof project?.project_id !== 'string' || !Array.isArray(project.members)) throw new ProjectRoomError('BAD_SNAPSHOT', 'project entry is invalid');
195
+ projects.set(project.project_id, project);
196
+ }
197
+ events.length = 0;
198
+ for (const event of snapshot.events) {
199
+ if (event === null || typeof event !== 'object' || !Number.isInteger(event.seq) || typeof event.project_id !== 'string') throw new ProjectRoomError('BAD_SNAPSHOT', 'event entry is invalid');
200
+ events.push(event);
201
+ }
202
+ seq = Math.max(Number.isInteger(snapshot.seq) ? snapshot.seq : 0, Number.isInteger(snapshot.last_event_seq) ? snapshot.last_event_seq : 0);
203
+ counter = Number.isInteger(snapshot.counter) ? snapshot.counter : snapshot.projects.length;
204
+ return { ok: true, projects: projects.size, events: events.length, last_event_seq: seq };
205
+ },
161
206
  size() { return { projects: projects.size, events: events.length, last_seq: seq }; },
162
207
  };
163
208
  }
@@ -31,8 +31,9 @@ export const RELAY_FEATURES = Object.freeze([
31
31
  'target-machines',
32
32
  'result-projection',
33
33
  'guard-v1',
34
- 'relay-version-v1',
34
+ 'relay-version-v1',
35
35
  'chat-text-only-v1',
36
+ 'chat-enc-v2',
36
37
  ]);
37
38
  import fs from 'node:fs';
38
39
  import os from 'node:os';
@@ -1101,8 +1102,13 @@ export class RelayServer {
1101
1102
  const frame = (entry) => {
1102
1103
  try { res.write('id: ' + entry.seq + '\nevent: project.event\ndata: ' + JSON.stringify({ project_id: entry.project_id, seq: entry.seq, type: entry.type, payload: entry.payload }) + '\n\n'); } catch { /* closed */ }
1103
1104
  };
1104
- const replayed = this.state.projects.events({ projectId, sinceSeq });
1105
- res.write('event: ready\ndata: ' + JSON.stringify({ protocol_version: PROTOCOL_VERSION, project_id: projectId, last_event_seq: replayed.next_seq }) + '\n\n');
1105
+ const replayed = this.state.projects.events({ projectId, sinceSeq, limit: 500 });
1106
+ if (replayed.truncated === true) {
1107
+ // An aborted stream that missed more events than one page holds must be told so explicitly;
1108
+ // otherwise it believes ready.last_event_seq is the whole story.
1109
+ res.write('event: notice\ndata: ' + JSON.stringify({ code: 'PROJECT_REPLAY_TRUNCATED', project_id: projectId, next_seq: replayed.next_seq, latest_seq: replayed.latest_seq, remaining: replayed.remaining }) + '\n\n');
1110
+ }
1111
+ res.write('event: ready\ndata: ' + JSON.stringify({ protocol_version: PROTOCOL_VERSION, project_id: projectId, last_event_seq: replayed.latest_seq ?? replayed.next_seq, replayed_through: replayed.next_seq }) + '\n\n');
1106
1112
  for (const entry of replayed.events) frame(entry);
1107
1113
  const unsubscribe = this.state.onProjectEvent((entry) => { if (entry.project_id === projectId) frame(entry); });
1108
1114
  const keepalive = setInterval(() => { try { res.write(': keepalive\n\n'); } catch { /* closed */ } }, this.keepaliveMs);
@@ -1250,8 +1256,8 @@ export class RelayServer {
1250
1256
  if (method === 'POST' && path === '/v1/result') {
1251
1257
  const body = takeJson();
1252
1258
  // B1 方案 A:window 模式接收旧信封(含 argv/head)并投影后存储;enforce 才硬拒。
1253
- const dataPlaneGuardMode = String(process.env.W2M_DATA_PLANE_GUARD ?? 'window');
1254
- // decision 5: enforce is not allowed. The flag is read and reported, but never changes behaviour.
1259
+ const dataPlaneGuardMode = String(process.env.W2M_DATA_PLANE_GUARD ?? 'window');
1260
+ // decision 5: enforce is not allowed. The flag is read and reported, but never changes behaviour.
1255
1261
  if (dataPlaneGuardMode === 'enforce' && this.allowDataPlaneGuardEnforce === true) {
1256
1262
  const resultGuard = guardResultEnvelope(body);
1257
1263
  if (resultGuard.ok !== true) {
@@ -24,6 +24,8 @@ import { deviceFleetFields } from '../chatops/fleet-wiring.mjs';
24
24
  import { scopeFleetId } from '../chatops/fleet-wiring.mjs';
25
25
  // v0.8.1: the account-derived fleet key, so a machine can join with no pairing code.
26
26
  import { planJoin } from '../chatops/fleet-join.mjs';
27
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
28
+ import { dirname, join } from 'node:path';
27
29
  import { validateTaskDependencies } from './task-deps.mjs';
28
30
  import { buildMembers, isMemberRole, setMemberRole as applyMemberRole } from './fleet-members.mjs';
29
31
  import { canonicalSignedPayload, publicKeyFromBase64 } from '../util/envelope-signature.mjs';
@@ -269,9 +271,22 @@ export const P2P_MODES = Object.freeze(['auto', 'direct', 'relay']);
269
271
  */
270
272
  export const TRANSPORT_VALUES = Object.freeze(['p2p', 'relay']);
271
273
 
274
+ /**
275
+ * Canonical form of a relative working directory. Both the relay and the agent hash this string, so
276
+ * it is normalized in exactly one place per side and the two definitions are asserted to agree:
277
+ * backslashes become slashes, './' and repeated/trailing slashes disappear, empty means '.'.
278
+ */
279
+ export function normalizeCwdRel(value) {
280
+ if (value === null || value === undefined) return '.';
281
+ let out = String(value).replace(/\\/g, '/');
282
+ while (out.startsWith('./')) out = out.slice(2);
283
+ out = out.replace(/\/{2,}/g, '/').replace(/\/+$/, '');
284
+ return out === '' ? '.' : out;
285
+ }
286
+
272
287
  /** §4.4 helper: command_hash = sha256(JCS(argv)+"|"+shell_id+"|"+cwd_rel). */
273
288
  export function computeCommandHash(commandArgv, shellId = SHELL_ID_DIRECT, cwdRel = '.') {
274
- return sha256Hex(`${jcs(commandArgv)}|${shellId}|${cwdRel}`);
289
+ return sha256Hex(`${jcs(commandArgv)}|${shellId}|${normalizeCwdRel(cwdRel)}`);
275
290
  }
276
291
 
277
292
  /**
@@ -288,7 +303,7 @@ export function computeCommandHash(commandArgv, shellId = SHELL_ID_DIRECT, cwdRe
288
303
  export function computePipelineCommandHash(stages, shellId = SHELL_ID_DIRECT) {
289
304
  const canonical = stages.map((s) => ({
290
305
  command_argv: s.command_argv,
291
- cwd_rel: s.cwd_rel ?? '.',
306
+ cwd_rel: normalizeCwdRel(s.cwd_rel),
292
307
  continue_on_failure: s.continue_on_failure === true,
293
308
  }));
294
309
  return sha256Hex(`${jcs(canonical)}|${shellId}|pipeline`);
@@ -560,6 +575,7 @@ export class RabbitState {
560
575
  const metricsEvent = (event) => {
561
576
  try { this.metrics.inc('wm_project_events_total', { type: event.type }); } catch { /* metrics never break the room */ }
562
577
  for (const listener of this.projectSubscribers) { try { listener(event); } catch { /* a subscriber never breaks the room */ } }
578
+ if (typeof this._persistProjects === 'function') this._persistProjects();
563
579
  const notified = NOTIFIED[event.type];
564
580
  if (notified !== undefined) {
565
581
  try {
@@ -568,6 +584,18 @@ export class RabbitState {
568
584
  }
569
585
  };
570
586
  this.projects = createProjectRoom({ now: () => this.nowMs(), onEvent: metricsEvent });
587
+ // A6: the room snapshot is the durable form of projects/members/events. Written after every room
588
+ // event (0600, tmp+rename) and restored before the relay answers anything, so a restart keeps the
589
+ // project object and its cursor instead of silently starting from an empty room.
590
+ this._projectsFile = typeof this.persistence?.dir === 'string' && this.persistence.dir !== ''
591
+ ? join(this.persistence.dir, 'projects', 'room-snapshot.json')
592
+ : null;
593
+ if (this._projectsFile !== null) {
594
+ try {
595
+ const snapshot = JSON.parse(readFileSync(this._projectsFile, 'utf8').replace(/^\uFEFF/, ''));
596
+ this.projects.restore(snapshot);
597
+ } catch { /* absent or corrupt: start empty rather than failing to boot */ }
598
+ }
571
599
  this.defaultProjectId = 'default';
572
600
  // M5 primitives share the project room's event stream: a lock, review or notification is a project event with the
573
601
  // module's own sequence attached, so one reader can reconstruct "what happened" from a single cursor.
@@ -622,6 +650,17 @@ export class RabbitState {
622
650
  /** Inject/replace the persistence backend (also used by tests). */
623
651
  setPersistence(persistence) { this._persist = persistence ?? null; return this._persist; }
624
652
 
653
+ /** A6: atomic write of the project room snapshot (0600). */
654
+ _persistProjects() {
655
+ if (typeof this._projectsFile !== 'string' || this._projectsFile === '') return;
656
+ try {
657
+ mkdirSync(dirname(this._projectsFile), { recursive: true, mode: 0o700 });
658
+ const tmp = this._projectsFile + '.tmp-' + process.pid + '-' + Date.now().toString(36);
659
+ writeFileSync(tmp, JSON.stringify(this.projects.toSnapshot()), { encoding: 'utf8', mode: 0o600 });
660
+ renameSync(tmp, this._projectsFile);
661
+ } catch { /* a failed snapshot write must not break the live room */ }
662
+ }
663
+
625
664
  /* ---------------- v1.0.7: the content-addressed blob channel (#1) ---------------- */
626
665
 
627
666
  /**
@@ -1788,7 +1827,7 @@ export class RabbitState {
1788
1827
  }
1789
1828
 
1790
1829
  const now = this.nowMs();
1791
- const cwdRel = typeof input.cwd_rel === 'string' && input.cwd_rel ? input.cwd_rel : '.';
1830
+ const cwdRel = normalizeCwdRel(input.cwd_rel);
1792
1831
  const baseTree = input.base_tree ?? null;
1793
1832
  // v2 R5: a joint dispatch must be auditable; a malformed binding is refused, never nulled.
1794
1833
  for (const key of ['plan_sha256', 'approval_sha256']) {
@@ -2825,6 +2864,26 @@ export class RabbitState {
2825
2864
  * of nothing -- a fake "the work is still being done" signal, which is the most
2826
2865
  * dangerous lie this relay can tell.
2827
2866
  */
2867
+ /**
2868
+ * Merge a small, typed bag of machine-advertised capabilities into the device row.
2869
+ *
2870
+ * Only scalars are copied (strings <= 64 chars, booleans, null) and only a bounded number of
2871
+ * keys, so a heartbeat cannot turn the roster into an unbounded side channel.
2872
+ */
2873
+ _mergeAdvertisedCaps(device, caps) {
2874
+ if (device === null || typeof device !== 'object') return;
2875
+ if (caps === null || typeof caps !== 'object' || Array.isArray(caps)) return;
2876
+ const next = { ...(device.caps ?? {}) };
2877
+ let count = 0;
2878
+ for (const [key, value] of Object.entries(caps)) {
2879
+ if (count >= 32) break;
2880
+ if (typeof key !== 'string' || key.length === 0 || key.length > 32) continue;
2881
+ if (typeof value === 'string' && value.length <= 64) { next[key] = value; count += 1; continue; }
2882
+ if (typeof value === 'boolean' || value === null) { next[key] = value; count += 1; }
2883
+ }
2884
+ device.caps = next;
2885
+ }
2886
+
2828
2887
  diagnosticHeartbeat(input = {}) {
2829
2888
  const machineId = typeof input.machine_id === 'string' && input.machine_id !== ''
2830
2889
  ? input.machine_id : null;
@@ -2837,6 +2896,7 @@ export class RabbitState {
2837
2896
  this._validatePhaseProgress(input);
2838
2897
 
2839
2898
  const device = this.touchDevice(machineId);
2899
+ this._mergeAdvertisedCaps(device, input.caps);
2840
2900
  this.recordHeartbeatMetrics(machineId, input);
2841
2901
  return {
2842
2902
  // No lease is involved, and the response says so instead of inventing one.
@@ -71,7 +71,7 @@ export function encryptChatText(key, text) {
71
71
  * @param {{text?: string, enc?: object|null}} message
72
72
  * @returns {{ok: true, text: string} | {ok: false, code: string}}
73
73
  */
74
- export function decryptChatMessage(key, message = {}) {
74
+ function decryptV1Message(key, message = {}) {
75
75
  const enc = message?.enc ?? null;
76
76
  if (enc === null || enc === undefined) {
77
77
  // Plaintext: the pre-encryption shape, still readable by everyone.
@@ -92,6 +92,75 @@ export function decryptChatMessage(key, message = {}) {
92
92
  }
93
93
  }
94
94
 
95
+
96
+ /** Chat cipher v2: same AES-256-GCM, with AAD binding the message to its session and sender. */
97
+ export const CHAT_CIPHER_V2 = Object.freeze({ version: 2, alg: 'aes-256-gcm' });
98
+
99
+ /**
100
+ * AAD = w2m-chat-v2 | session_id | from_machine | message_id
101
+ *
102
+ * The AAD is transmitted in the clear inside `enc.aad` so every reader can rebuild it, but the GCM
103
+ * tag authenticates those exact bytes: changing any of them makes decryption fail. A caller that
104
+ * knows the expected context must pass it, which is what turns a whole encrypted message copied into
105
+ * another session into a refusal instead of a successfully decrypted but mis-attributed message.
106
+ */
107
+ export function chatAadV2({ sessionId, machineId, messageId }) {
108
+ return Buffer.from('w2m-chat-v2|' + String(sessionId) + '|' + String(machineId) + '|' + String(messageId), 'utf8');
109
+ }
110
+
111
+ export function encryptChatTextV2(key, text, context = {}) {
112
+ if (key === null || !Buffer.isBuffer(key) || key.length !== KEY_BYTES) return { ok: false, code: 'CHAT_NO_KEY' };
113
+ if (typeof text !== 'string' || text === '') return { ok: false, code: 'CHAT_EMPTY_TEXT' };
114
+ const sessionId = typeof context.sessionId === 'string' ? context.sessionId : '';
115
+ const machineId = typeof context.machineId === 'string' ? context.machineId : '';
116
+ const messageId = typeof context.messageId === 'string' ? context.messageId : '';
117
+ if (sessionId === '' || machineId === '' || messageId === '') return { ok: false, code: 'CHAT_AAD_CONTEXT_REQUIRED' };
118
+ const aad = chatAadV2({ sessionId, machineId, messageId });
119
+ const iv = randomBytes(IV_BYTES);
120
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
121
+ cipher.setAAD(aad);
122
+ const ciphertext = Buffer.concat([cipher.update(Buffer.from(text, 'utf8')), cipher.final()]);
123
+ return {
124
+ ok: true,
125
+ text: ciphertext.toString('base64'),
126
+ enc: { v: CHAT_CIPHER_V2.version, alg: CHAT_CIPHER_V2.alg, iv: iv.toString('base64'), tag: cipher.getAuthTag().toString('base64'), aad: { session_id: sessionId, machine_id: machineId, message_id: messageId } },
127
+ };
128
+ }
129
+
130
+ export function decryptChatMessageV2(key, message = {}, context = {}) {
131
+ if (key === null || !Buffer.isBuffer(key) || key.length !== KEY_BYTES) return { ok: false, code: 'CHAT_NO_KEY' };
132
+ const enc = message?.enc ?? null;
133
+ if (enc === null || typeof enc !== 'object' || enc.v !== CHAT_CIPHER_V2.version || enc.alg !== CHAT_CIPHER_V2.alg) return { ok: false, code: 'CHAT_UNSUPPORTED_CIPHER' };
134
+ const aad = enc.aad ?? {};
135
+ const expectedSession = typeof context.sessionId === 'string' ? context.sessionId : null;
136
+ const expectedMachine = typeof context.machineId === 'string' ? context.machineId : null;
137
+ if (expectedSession !== null && String(aad.session_id ?? '') !== expectedSession) return { ok: false, code: 'CHAT_AAD_MISMATCH', detail: { field: 'session_id' } };
138
+ if (expectedMachine !== null && String(aad.machine_id ?? '') !== expectedMachine) return { ok: false, code: 'CHAT_AAD_MISMATCH', detail: { field: 'machine_id' } };
139
+ try {
140
+ const iv = Buffer.from(String(enc.iv ?? ''), 'base64');
141
+ const tag = Buffer.from(String(enc.tag ?? ''), 'base64');
142
+ if (iv.length !== IV_BYTES || tag.length !== 16) return { ok: false, code: 'CHAT_BAD_ENCRYPTION' };
143
+ const decipher = createDecipheriv('aes-256-gcm', key, iv);
144
+ decipher.setAAD(chatAadV2({ sessionId: aad.session_id, machineId: aad.machine_id, messageId: aad.message_id }));
145
+ decipher.setAuthTag(tag);
146
+ const plain = Buffer.concat([decipher.update(Buffer.from(String(message.text ?? ''), 'base64')), decipher.final()]);
147
+ return { ok: true, text: plain.toString('utf8'), version: 2 };
148
+ } catch {
149
+ return { ok: false, code: 'CHAT_DECRYPT_FAILED' };
150
+ }
151
+ }
152
+
153
+ /**
154
+ * v1 is read-only compatible forever; v2 requires the expected context to bind the message to its
155
+ * session/sender. Unknown versions are refused rather than treated as v1.
156
+ */
157
+ export function decryptChatMessage(key, message = {}, context = {}) {
158
+ const enc = message?.enc ?? null;
159
+ if (enc !== null && typeof enc === 'object' && enc.v === CHAT_CIPHER_V2.version) return decryptChatMessageV2(key, message, context);
160
+ if (enc !== null && typeof enc === 'object' && enc.v !== CHAT_CIPHER.version) return { ok: false, code: 'CHAT_UNSUPPORTED_CIPHER' };
161
+ return decryptV1Message(key, message);
162
+ }
163
+
95
164
  /** True when a message carries the encryption metadata, whatever its text looks like. */
96
165
  export function isEncrypted(message) {
97
166
  return message?.enc !== null && message?.enc !== undefined && typeof message.enc === 'object';