@twinsearth/w2m-dsh-plugin 2.0.4 → 2.0.6
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/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +1795 -1779
- package/bin/w2m-joint.mjs +69 -7
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/2.0.5/RELEASE-205.json +21 -0
- package/docs/acceptance/2.0.5/RELEASE-2050.json +22 -0
- package/docs/acceptance/2.0.6/RELEASE-2060.json +22 -0
- package/docs/adr/0001-collaboration-crdt.md +59 -0
- package/lib/client.js +215 -19
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/src/agent/agent.mjs +97 -36
- package/src/agent/artifact-delivery.mjs +89 -0
- package/src/agent/caps.mjs +16 -2
- package/src/agent/sinks/git-sink.mjs +78 -0
- package/src/agent/sinks/ssh-sink.mjs +83 -0
- package/src/agent/sse-cursor.mjs +16 -0
- package/src/agent/sse-watchdog.mjs +71 -0
- package/src/plugin/dataplane-sink-route.mjs +118 -0
- package/src/plugin/fleet-chat-route.mjs +11 -0
- package/src/plugin/joint-dev/dispatch.mjs +5 -1
- package/src/plugin/joint-dev/merge-approval.mjs +115 -0
- package/src/plugin/joint-dev/progress.mjs +107 -0
- package/src/plugin/joint-dev/relay-dispatch.mjs +52 -22
- package/src/plugin/project-stream-route.mjs +58 -0
- package/src/plugin/tools-joint.mjs +6 -1
- package/src/plugin/tools.mjs +25 -5
- package/src/relay/fleet-messages.mjs +48 -24
- package/src/relay/project-room.mjs +47 -2
- package/src/relay/server.mjs +11 -5
- package/src/relay/state.mjs +63 -3
- package/src/util/chat-crypto.mjs +70 -1
- package/docs/COLLAB/COLLABORATION-MODEL.md +0 -240
- package/docs/COLLAB/EXECUTION-AUTH-MODEL.md +0 -361
- package/docs/COLLAB/M1-ACCEPTANCE-RUNBOOK.md +0 -70
- package/docs/COLLAB/M1-MAINTENANCE.md +0 -313
- package/docs/COLLAB/M2-TASK-GRANT.md +0 -231
- package/docs/COLLAB/M3-GIT-NATIVE-TASKS.md +0 -291
- package/docs/COLLAB/M4-PROJECT-ROOM.md +0 -260
- package/docs/COLLAB/M5-MULTI-CLIENT-PRIMITIVES.md +0 -263
- package/docs/COLLAB/M6-CROSS-NETWORK-RELIABILITY.md +0 -231
- package/docs/COLLAB/M7-METRICS-ACCEPTANCE.md +0 -263
- package/docs/COLLAB/TASK-MODEL.md +0 -281
- package/docs/acceptance/1.9.10/RELEASE-1910.json +0 -21
- package/docs/acceptance/1.9.11/RELEASE-1911.json +0 -20
- package/docs/acceptance/1.9.12/RELEASE-1912.json +0 -20
- package/docs/acceptance/1.9.13/RELEASE-1913.json +0 -20
- package/docs/acceptance/1.9.14/RELEASE-1914.json +0 -20
- package/docs/acceptance/1.9.15/RELEASE-1915.json +0 -22
- package/docs/acceptance/1.9.16/RELEASE-1916.json +0 -21
- package/docs/acceptance/1.9.17/RELEASE-1917.json +0 -20
- package/docs/acceptance/1.9.19/RELEASE-1919.json +0 -21
- package/docs/acceptance/1.9.20/RELEASE-1920.json +0 -22
- package/docs/acceptance/1.9.4/RELEASE-194.json +0 -23
- package/docs/acceptance/1.9.5/RELEASE-195.json +0 -23
- package/docs/acceptance/1.9.6/RELEASE-196.json +0 -22
- package/docs/acceptance/1.9.7/RELEASE-197.json +0 -21
- package/docs/acceptance/1.9.8/RELEASE-198.json +0 -21
- package/docs/acceptance/1.9.9/RELEASE-199.json +0 -21
- package/lib/client.js.bak2 +0 -640
- package/src/plugin/dataflow.mjs +0 -72
- package/src/plugin/fleet-target.mjs.bak-arch2 +0 -812
- package/src/plugin/fleet-target.mjs.bak-archive +0 -812
- package/src/plugin/tofuture/report.mjs +0 -44
- package/src/plugin/tools.mjs.bak-c7 +0 -7029
- package/src/relay/tofuture-whitelist.mjs +0 -46
- package/src/util/data-plane.mjs +0 -180
- package/src/util/sealed-object.mjs +0 -141
- package/src/util/tofuture-hmac.mjs +0 -30
package/src/plugin/tools.mjs
CHANGED
|
@@ -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.
|
|
292
|
+
const PLUGIN_VERSION = '2.0.6';
|
|
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, {
|
|
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 =
|
|
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.
|
|
134
|
-
|
|
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
|
|
151
|
-
|
|
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
|
}
|
package/src/relay/server.mjs
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/src/relay/state.mjs
CHANGED
|
@@ -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 =
|
|
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.
|
package/src/util/chat-crypto.mjs
CHANGED
|
@@ -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
|
-
|
|
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';
|