@twinsearth/w2m-dsh-plugin 2.0.3 → 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.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +17 -0
- package/README.md +187 -462
- package/bin/w2m-joint.mjs +69 -7
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/2.0.4/RELEASE-2040.json +22 -0
- package/docs/acceptance/2.0.5/RELEASE-205.json +21 -0
- package/docs/acceptance/2.0.5/RELEASE-2050.json +22 -0
- package/docs/adr/0001-collaboration-crdt.md +59 -0
- package/lib/client.js +2754 -2599
- 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 +113 -0
- package/src/plugin/fleet-chat-route.mjs +11 -0
- package/src/plugin/gpw.mjs +99 -15
- 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
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A5 progress consumer: read the relay's per-task progress for a dispatched joint plan.
|
|
3
|
+
*
|
|
4
|
+
* The relay stores no argv/content on the projection either, and this module enforces the same on
|
|
5
|
+
* the way out: the projection is rebuilt from a small allow-list, and any forbidden key found in the
|
|
6
|
+
* relay's answer is stripped and reported (never echoed). A relay that predates the `progress` key
|
|
7
|
+
* (1.9.16) yields PROGRESS_UNSUPPORTED instead of an empty success.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
const FORBIDDEN = /^(?:argv|command|command_argv|command_hash|text|content|stdout|stderr|stdout_head|stderr_head|head|tail|patch|diff|env|token|secret|password|base64|blob|blobs|bytes|buffer|files?)$/i;
|
|
13
|
+
|
|
14
|
+
function sanitize(value, seen = new Set()) {
|
|
15
|
+
if (value === null || typeof value !== 'object') return value;
|
|
16
|
+
if (seen.has(value)) return null;
|
|
17
|
+
seen.add(value);
|
|
18
|
+
if (Array.isArray(value)) return value.map((entry) => sanitize(entry, seen));
|
|
19
|
+
const out = {};
|
|
20
|
+
for (const [key, child] of Object.entries(value)) {
|
|
21
|
+
if (FORBIDDEN.test(key)) continue;
|
|
22
|
+
if (typeof child === 'string' && /(?:[A-Za-z0-9+/]{512,}|[0-9a-fA-F]{512,})/.test(child)) continue;
|
|
23
|
+
out[key] = sanitize(child, seen);
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const hasForbidden = (value, seen = new Set()) => {
|
|
29
|
+
if (value === null || typeof value !== 'object') return false;
|
|
30
|
+
if (seen.has(value)) return false;
|
|
31
|
+
seen.add(value);
|
|
32
|
+
if (Array.isArray(value)) return value.some((entry) => hasForbidden(entry, seen));
|
|
33
|
+
return Object.entries(value).some(([key, child]) => FORBIDDEN.test(key) || hasForbidden(child, seen));
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function readDispatchRecord(stateDir, planSha) {
|
|
37
|
+
if (typeof stateDir !== 'string' || stateDir === '' || typeof planSha !== 'string' || !/^[0-9a-f]{64}$/.test(planSha)) return { ok: false, code: 'NOT_DISPATCHED' };
|
|
38
|
+
const path = join(stateDir, 'joint-dev', 'dispatches', planSha + '.json');
|
|
39
|
+
if (!existsSync(path)) return { ok: false, code: 'NOT_DISPATCHED' };
|
|
40
|
+
try {
|
|
41
|
+
const record = JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, ''));
|
|
42
|
+
if (record?.plan_sha256 !== planSha || !Array.isArray(record.tasks)) return { ok: false, code: 'DISPATCH_RECORD_INVALID' };
|
|
43
|
+
return { ok: true, record };
|
|
44
|
+
} catch {
|
|
45
|
+
return { ok: false, code: 'DISPATCH_RECORD_UNREADABLE' };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {{stateDir: string, planSha: string, relayUrl: string, deviceToken: string,
|
|
51
|
+
* fetchImpl?: Function, timeoutMs?: number, taskIds?: Map<string,string>}} input
|
|
52
|
+
*/
|
|
53
|
+
export async function planProgress(input) {
|
|
54
|
+
const record = readDispatchRecord(input?.stateDir, input?.planSha);
|
|
55
|
+
if (record.ok !== true) return { ok: false, code: record.code };
|
|
56
|
+
const fetchImpl = typeof input?.fetchImpl === 'function' ? input.fetchImpl : globalThis.fetch;
|
|
57
|
+
const timeoutMs = Number.isInteger(input?.timeoutMs) ? input.timeoutMs : 15_000;
|
|
58
|
+
const base = typeof input?.relayUrl === 'string' ? input.relayUrl.replace(/\/$/, '') : '';
|
|
59
|
+
if (base === '') return { ok: false, code: 'RELAY_URL_REQUIRED' };
|
|
60
|
+
const token = typeof input?.deviceToken === 'string' ? input.deviceToken : '';
|
|
61
|
+
if (token === '') return { ok: false, code: 'DEVICE_TOKEN_REQUIRED' };
|
|
62
|
+
const steps = [];
|
|
63
|
+
for (const task of record.record.tasks) {
|
|
64
|
+
const taskId = String(task?.task_id ?? '');
|
|
65
|
+
const stepId = String(task?.step_id ?? '');
|
|
66
|
+
if (taskId === '') continue;
|
|
67
|
+
let response = null;
|
|
68
|
+
try {
|
|
69
|
+
const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(timeoutMs) : undefined;
|
|
70
|
+
response = await fetchImpl(base + '/v1/tasks/' + encodeURIComponent(taskId), { headers: { authorization: 'Bearer ' + token, accept: 'application/json' }, signal });
|
|
71
|
+
} catch {
|
|
72
|
+
steps.push({ step_id: stepId, task_id: taskId, state: 'unreadable', code: 'TASK_UNREADABLE' });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (response.ok !== true) {
|
|
76
|
+
steps.push({ step_id: stepId, task_id: taskId, state: 'unavailable', code: response.status === 404 ? 'TASK_NOT_FOUND' : 'TASK_UNREADABLE', http_status: response.status });
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
let body = null;
|
|
80
|
+
try { body = await response.json(); } catch { body = null; }
|
|
81
|
+
if (body === null || typeof body !== 'object') {
|
|
82
|
+
steps.push({ step_id: stepId, task_id: taskId, state: 'unavailable', code: 'TASK_UNREADABLE' });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const progress = body.progress ?? body.task?.progress ?? null;
|
|
86
|
+
const row = {
|
|
87
|
+
step_id: stepId,
|
|
88
|
+
task_id: taskId,
|
|
89
|
+
status: body.task?.status ?? body.status ?? null,
|
|
90
|
+
progress: progress === null ? null : sanitize(progress),
|
|
91
|
+
leases: Array.isArray(body.leases) ? body.leases.map((lease) => ({ machine_id: lease?.machine_id ?? null, state: lease?.state ?? null })) : [],
|
|
92
|
+
};
|
|
93
|
+
if (progress === null) { row.progress_state = 'unavailable'; row.code = 'PROGRESS_UNSUPPORTED'; }
|
|
94
|
+
if (hasForbidden(body)) row.leak_fields_stripped = true;
|
|
95
|
+
steps.push(row);
|
|
96
|
+
}
|
|
97
|
+
const unavailable = steps.filter((step) => step.progress_state === 'unavailable').length;
|
|
98
|
+
return {
|
|
99
|
+
ok: true,
|
|
100
|
+
plan_sha256: record.record.plan_sha256,
|
|
101
|
+
approval_sha256: record.record.approval_sha256 ?? null,
|
|
102
|
+
dispatched_at: record.record.dispatched_at ?? null,
|
|
103
|
+
progress_state: steps.length > 0 && unavailable === steps.length ? 'unavailable' : 'partial',
|
|
104
|
+
code: steps.length > 0 && unavailable === steps.length ? 'PROGRESS_UNSUPPORTED' : null,
|
|
105
|
+
steps,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* returns are checked: a lease on a machine that was not pinned cancels the task and fails.
|
|
13
13
|
*/
|
|
14
14
|
import { createHash } from 'node:crypto';
|
|
15
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
15
17
|
|
|
16
18
|
import { appendAudit } from './audit-file.mjs';
|
|
17
19
|
import { inspectControlMessage } from './redline.mjs';
|
|
@@ -42,8 +44,13 @@ function orderSteps(steps) {
|
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
function writeAudit(file, event) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
+
// No audit file requested (library callers): nothing to write, and that is not a failure.
|
|
48
|
+
if (typeof file !== 'string' || file === '') return { ok: true, skipped: true };
|
|
49
|
+
try {
|
|
50
|
+
return appendAudit(file, event);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
return { ok: false, code: 'AUDIT_WRITE_FAILED', detail: error instanceof Error ? error.message : String(error) };
|
|
53
|
+
}
|
|
47
54
|
}
|
|
48
55
|
|
|
49
56
|
/**
|
|
@@ -74,35 +81,52 @@ export async function dispatchPlanToRelay(input) {
|
|
|
74
81
|
const headers = { authorization: `Bearer ${token}`, 'content-type': 'application/json', accept: 'application/json' };
|
|
75
82
|
const auditFile = typeof input?.auditFile === 'string' ? input.auditFile : '';
|
|
76
83
|
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
84
|
+
// D2 (B5): the version gate is observable and defaults to warn. It becomes a refusal only when the
|
|
85
|
+
// caller explicitly asks for enforce; a mixed-version fleet is reported, not silently assumed clean.
|
|
86
|
+
const versionGate = input?.versionGate === 'off' || input?.versionGate === 'enforce' ? input.versionGate : 'warn';
|
|
87
|
+
const warnings = [];
|
|
88
|
+
const expectedVersion = typeof input?.expectedVersion === 'string' && input.expectedVersion !== '' ? input.expectedVersion : null;
|
|
89
|
+
if (expectedVersion !== null && !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(expectedVersion)) {
|
|
90
|
+
return { ok: false, code: 'VERSION_UNPARSED', detail: { expected: expectedVersion } };
|
|
91
|
+
}
|
|
92
|
+
if (versionGate !== 'off' && expectedVersion !== null) {
|
|
93
|
+
const deviceToken = typeof input?.deviceToken === 'string' && input.deviceToken !== '' ? input.deviceToken : '';
|
|
94
|
+
let fleet = null;
|
|
95
|
+
if (deviceToken !== '') {
|
|
82
96
|
try {
|
|
83
|
-
const response = await fetchImpl(`${rabbitUrl}/v1/devices`, { headers: { authorization: `Bearer ${
|
|
97
|
+
const response = await fetchImpl(`${rabbitUrl}/v1/devices`, { headers: { authorization: `Bearer ${deviceToken}`, accept: 'application/json' }, signal: signalOf() });
|
|
84
98
|
fleet = response.ok === true ? await response.json() : null;
|
|
85
99
|
} catch { fleet = null; }
|
|
86
|
-
|
|
100
|
+
}
|
|
101
|
+
if (fleet === null || !Array.isArray(fleet.devices)) {
|
|
102
|
+
if (versionGate === 'enforce') return { ok: false, code: 'VERSION_UNVERIFIABLE', detail: { reason: 'the relay device list could not be read', expected: expectedVersion } };
|
|
103
|
+
warnings.push({ code: 'VERSION_UNVERIFIABLE', detail: 'the relay device list could not be read' });
|
|
104
|
+
} else {
|
|
87
105
|
const byId = new Map(fleet.devices.map((device) => [device.machine_id, device]));
|
|
88
106
|
const skew = ordered.steps
|
|
89
107
|
.map((step) => ({ machine_id: step.machine_id, version: byId.get(step.machine_id)?.caps?.plugin_version ?? null }))
|
|
90
|
-
.filter((row) => row.version !==
|
|
91
|
-
if (skew.length > 0)
|
|
108
|
+
.filter((row) => row.version !== expectedVersion);
|
|
109
|
+
if (skew.length > 0) {
|
|
110
|
+
if (versionGate === 'enforce') return { ok: false, code: 'VERSION_SKEW', detail: { expected: expectedVersion, machines: skew } };
|
|
111
|
+
warnings.push({ code: 'VERSION_SKEW', detail: { expected: expectedVersion, machines: skew } });
|
|
112
|
+
}
|
|
92
113
|
}
|
|
93
114
|
}
|
|
94
115
|
|
|
95
116
|
const capabilities = await readRelayCapabilities({ rabbitUrl, fetchImpl, timeoutMs });
|
|
96
117
|
if (capabilities.ok !== true) return { ok: false, code: 'RELAY_CAPABILITIES_UNAVAILABLE', detail: { relay: rabbitUrl, error: capabilities.error ?? null } };
|
|
97
|
-
if (capabilities.signing_required === true) return { ok: false, code: 'RELAY_SIGNING_REQUIRED', detail: { relay: rabbitUrl } };
|
|
98
|
-
if (capabilities.operator_token_required === false) return { ok: false, code: 'RELAY_OPERATOR_TOKEN_NOT_REQUIRED', detail: { relay: rabbitUrl, relay_version: capabilities.relay_version ?? null } };
|
|
99
118
|
const negotiation = negotiateFeatures(capabilities);
|
|
100
119
|
if (negotiation.ok !== true) {
|
|
101
120
|
return { ok: false, code: negotiation.code ?? 'RELAY_CAPABILITIES_MISSING', detail: { missing: negotiation.missing ?? null, relay: rabbitUrl } };
|
|
102
121
|
}
|
|
122
|
+
if (capabilities.signing_required !== false) {
|
|
123
|
+
return { ok: false, code: capabilities.signing_required === true ? 'RELAY_SIGNING_REQUIRED' : 'SIGNING_UNVERIFIABLE', detail: { relay: rabbitUrl, signing_required: capabilities.signing_required } };
|
|
124
|
+
}
|
|
125
|
+
if (capabilities.operator_token_required !== true) return { ok: false, code: capabilities.operator_token_required === false ? 'RELAY_OPERATOR_TOKEN_NOT_REQUIRED' : 'OPERATOR_TOKEN_UNVERIFIED', detail: { relay: rabbitUrl, relay_version: capabilities.relay_version ?? null } };
|
|
103
126
|
|
|
104
127
|
const dispatched = new Map(); // step_id -> task_id
|
|
105
128
|
const tasks = [];
|
|
129
|
+
const dispatchDir = typeof input?.dispatchDir === 'string' && input.dispatchDir !== '' ? input.dispatchDir : null;
|
|
106
130
|
for (const step of ordered.steps) {
|
|
107
131
|
const taskId = taskIdOf(plan.plan_sha256, step.step_id);
|
|
108
132
|
const dependsOn = (step.depends_on ?? []).map((dep) => dispatched.get(dep)).filter((id) => typeof id === 'string');
|
|
@@ -113,6 +137,7 @@ export async function dispatchPlanToRelay(input) {
|
|
|
113
137
|
executor_machine_id: step.machine_id,
|
|
114
138
|
command_argv: step.command_argv,
|
|
115
139
|
command_hash: step.command_hash,
|
|
140
|
+
cwd_rel: typeof step.cwd_rel === 'string' && step.cwd_rel !== '' ? step.cwd_rel : '.',
|
|
116
141
|
base_commit: plan.base_commit,
|
|
117
142
|
plan_sha256: plan.plan_sha256,
|
|
118
143
|
approval_sha256: approval.approval_sha256,
|
|
@@ -120,16 +145,19 @@ export async function dispatchPlanToRelay(input) {
|
|
|
120
145
|
...(dependsOn.length > 0 ? { depends_on: dependsOn } : {}),
|
|
121
146
|
...(Array.isArray(step.write_scope) && step.write_scope.length > 0 ? { write: true, write_scope: step.write_scope } : {}),
|
|
122
147
|
...(step.timeout_ms === undefined ? {} : { timeout_ms: step.timeout_ms }),
|
|
123
|
-
...(input?.includeOffline ===
|
|
148
|
+
...(input?.includeOffline === true ? { include_offline: true } : {}),
|
|
124
149
|
};
|
|
125
150
|
const check = inspectControlMessage({ v: 1, type: 'joint.dispatch', body });
|
|
126
151
|
if (check.ok !== true) {
|
|
127
152
|
writeAudit(auditFile, { event: 'joint.dispatch.refused', plan_sha256: plan.plan_sha256, step_id: step.step_id, reason_code: check.code ?? 'CONTROL_REFUSED' });
|
|
128
153
|
return { ok: false, code: check.code ?? 'CONTROL_REFUSED', detail: check.detail ?? null, partial: tasks.length > 0, tasks };
|
|
129
154
|
}
|
|
155
|
+
const controlText = JSON.stringify(body);
|
|
156
|
+
const audited = writeAudit(auditFile, { event: 'joint.dispatch.sending', plan_sha256: plan.plan_sha256, step_id: step.step_id, task_id: taskId, machine_id: step.machine_id, control_bytes: Buffer.byteLength(controlText), control_sha256: sha256(controlText) });
|
|
157
|
+
if (audited.ok !== true) return { ok: false, code: 'AUDIT_UNAVAILABLE', detail: audited, partial: tasks.length > 0, tasks };
|
|
130
158
|
let response;
|
|
131
159
|
try {
|
|
132
|
-
response = await fetchImpl(`${rabbitUrl}/v1/task`, { method: 'POST', headers, body:
|
|
160
|
+
response = await fetchImpl(`${rabbitUrl}/v1/task`, { method: 'POST', headers, body: controlText, signal: signalOf() });
|
|
133
161
|
} catch (error) {
|
|
134
162
|
writeAudit(auditFile, { event: 'joint.dispatch.failed', plan_sha256: plan.plan_sha256, step_id: step.step_id, task_id: taskId, reason_code: 'RELAY_UNREACHABLE' });
|
|
135
163
|
return { ok: false, code: 'RELAY_UNREACHABLE', detail: error instanceof Error ? error.message : String(error), partial: tasks.length > 0, tasks };
|
|
@@ -142,12 +170,7 @@ export async function dispatchPlanToRelay(input) {
|
|
|
142
170
|
const read = await fetchImpl(`${rabbitUrl}/v1/tasks/${encodeURIComponent(taskId)}`, { headers, signal: signalOf() });
|
|
143
171
|
existing = read.ok === true ? await read.json() : null;
|
|
144
172
|
} catch { existing = null; }
|
|
145
|
-
if (existing === null) {
|
|
146
|
-
writeAudit(auditFile, { event: 'joint.dispatch.already', plan_sha256: plan.plan_sha256, step_id: step.step_id, task_id: taskId });
|
|
147
|
-
dispatched.set(step.step_id, taskId);
|
|
148
|
-
tasks.push({ step_id: step.step_id, task_id: taskId, already: true, leases: [] });
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
173
|
+
if (existing === null) return { ok: false, code: 'TASK_EXISTS_UNVERIFIED', detail: { step_id: step.step_id, task_id: taskId }, partial: tasks.length > 0, tasks };
|
|
151
174
|
const existingLeases = Array.isArray(existing?.leases) ? existing.leases : [];
|
|
152
175
|
const mine = existingLeases.filter((lease) => lease?.machine_id === step.machine_id);
|
|
153
176
|
if (existingLeases.length > 0 && mine.length === 0) return { ok: false, code: 'TASK_EXISTS_FOREIGN', detail: { step_id: step.step_id, task_id: taskId }, partial: tasks.length > 0, tasks };
|
|
@@ -196,5 +219,12 @@ export async function dispatchPlanToRelay(input) {
|
|
|
196
219
|
leases: leases.map((lease) => ({ machine_id: lease?.machine_id ?? null, state: lease?.state ?? null })),
|
|
197
220
|
});
|
|
198
221
|
}
|
|
199
|
-
|
|
222
|
+
if (dispatchDir !== null) {
|
|
223
|
+
try {
|
|
224
|
+
mkdirSync(dispatchDir, { recursive: true, mode: 0o700 });
|
|
225
|
+
const file = join(dispatchDir, plan.plan_sha256 + '.json');
|
|
226
|
+
writeFileSync(file, JSON.stringify({ v: 1, plan_sha256: plan.plan_sha256, approval_sha256: approval.approval_sha256, dispatched_at: new Date().toISOString(), tasks: tasks.map((task) => ({ step_id: task.step_id, task_id: task.task_id, machine_id: task.leases?.[0]?.machine_id ?? null, already: task.already === true })) }, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
227
|
+
} catch { /* the progress view is best effort; dispatch itself already happened */ }
|
|
228
|
+
}
|
|
229
|
+
return { ok: true, relay_version: capabilities.relay_version ?? null, features: capabilities.features ?? null, version_gate: versionGate, ...(warnings.length > 0 ? { warnings } : {}), tasks };
|
|
200
230
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/api/w2m/project-stream` (A6): authenticated SSE proxy for the read-only project panel.
|
|
3
|
+
*
|
|
4
|
+
* The browser cannot set an Authorization header on EventSource, and the device token must not be
|
|
5
|
+
* exposed to client code. This route takes the project id and cursor from the local user, attaches
|
|
6
|
+
* the device token server-side and streams the relay's frames through unchanged (including the
|
|
7
|
+
* PROJECT_REPLAY_TRUNCATED notice).
|
|
8
|
+
*/
|
|
9
|
+
export const PROJECT_STREAM_ROUTE_PATH = '/api/w2m/project-stream';
|
|
10
|
+
const NO_STORE = { 'cache-control': 'no-store' };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {object} ctx Cordis context
|
|
14
|
+
* @param {{relayUrl?: () => string|null, deviceToken?: () => Promise<string|null>|string|null}} [deps]
|
|
15
|
+
*/
|
|
16
|
+
export function registerProjectStreamRoute(ctx, deps = {}) {
|
|
17
|
+
const status = { mounted: false, reason: 'the host has no Connection service' };
|
|
18
|
+
if (typeof ctx?.inject !== 'function') return status;
|
|
19
|
+
ctx.inject(['connection'], (webCtx) => {
|
|
20
|
+
const registry = webCtx?.connection?.fetch;
|
|
21
|
+
if (registry === undefined || typeof registry.register !== 'function') {
|
|
22
|
+
status.reason = 'the connection service carries no fetch registry';
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
registry.register({
|
|
26
|
+
path: PROJECT_STREAM_ROUTE_PATH,
|
|
27
|
+
methods: ['GET'],
|
|
28
|
+
fetch: async (request) => {
|
|
29
|
+
const url = new URL(request.url ?? 'http://local.invalid/api/w2m/project-stream');
|
|
30
|
+
const projectId = url.searchParams.get('project_id') ?? '';
|
|
31
|
+
if (!/^[A-Za-z0-9._-]{1,80}$/.test(projectId)) {
|
|
32
|
+
return Response.json({ ok: false, code: 'PROJECT_ID_INVALID' }, { status: 400, headers: NO_STORE });
|
|
33
|
+
}
|
|
34
|
+
const sinceRaw = url.searchParams.get('since_seq') ?? '0';
|
|
35
|
+
if (!/^[0-9]+$/.test(sinceRaw)) return Response.json({ ok: false, code: 'PROJECT_CURSOR_INVALID' }, { status: 400, headers: NO_STORE });
|
|
36
|
+
const relayUrl = typeof deps?.relayUrl === 'function' ? deps.relayUrl() : null;
|
|
37
|
+
if (typeof relayUrl !== 'string' || relayUrl === '') return Response.json({ ok: false, code: 'RELAY_URL_REQUIRED' }, { status: 503, headers: NO_STORE });
|
|
38
|
+
const token = typeof deps?.deviceToken === 'function' ? await deps.deviceToken() : null;
|
|
39
|
+
if (typeof token !== 'string' || token === '') return Response.json({ ok: false, code: 'DEVICE_TOKEN_REQUIRED' }, { status: 503, headers: NO_STORE });
|
|
40
|
+
const target = relayUrl.replace(/\/$/, '') + '/v1/projects/' + encodeURIComponent(projectId) + '/stream?since_seq=' + sinceRaw;
|
|
41
|
+
let upstream = null;
|
|
42
|
+
try {
|
|
43
|
+
upstream = await fetch(target, { headers: { authorization: 'Bearer ' + token, accept: 'text/event-stream' }, signal: request.signal });
|
|
44
|
+
} catch {
|
|
45
|
+
return Response.json({ ok: false, code: 'PROJECT_STREAM_UNAVAILABLE' }, { status: 502, headers: NO_STORE });
|
|
46
|
+
}
|
|
47
|
+
if (upstream.ok !== true || upstream.body === null) return Response.json({ ok: false, code: 'PROJECT_STREAM_REFUSED', http_status: upstream.status }, { status: 502, headers: NO_STORE });
|
|
48
|
+
return new Response(upstream.body, {
|
|
49
|
+
status: 200,
|
|
50
|
+
headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' },
|
|
51
|
+
});
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
status.mounted = true;
|
|
55
|
+
status.reason = null;
|
|
56
|
+
});
|
|
57
|
+
return status;
|
|
58
|
+
}
|
|
@@ -11,7 +11,7 @@ import { loadOperatorKey } from './operator-key.mjs';
|
|
|
11
11
|
|
|
12
12
|
const json = (value) => JSON.stringify(value, null, 2);
|
|
13
13
|
|
|
14
|
-
export function registerJointTools(ctx, { defineTool, stateDir, machineName, output }) {
|
|
14
|
+
export function registerJointTools(ctx, { defineTool, stateDir, machineName, output, progress }) {
|
|
15
15
|
const requireStateDir = () => {
|
|
16
16
|
if (typeof stateDir === 'string' && stateDir !== '') return stateDir;
|
|
17
17
|
return null;
|
|
@@ -69,12 +69,17 @@ export function registerJointTools(ctx, { defineTool, stateDir, machineName, out
|
|
|
69
69
|
'returns argv, output or file bytes, and it cannot approve or dispatch.',
|
|
70
70
|
parameters: {
|
|
71
71
|
plan_sha256: { type: 'string', description: 'the 64-hex plan id' },
|
|
72
|
+
action: { type: 'string', enum: ['status', 'progress'], description: 'status (default) or progress (relay projection; PROGRESS_UNSUPPORTED on an old relay)' },
|
|
72
73
|
},
|
|
73
74
|
output: output ?? { type: 'json' },
|
|
74
75
|
async execute(args) {
|
|
75
76
|
const dir = requireStateDir();
|
|
76
77
|
if (dir === null) return json({ ok: false, code: 'JOINT_STATE_UNAVAILABLE' });
|
|
77
78
|
const planSha = String(args?.plan_sha256 ?? '');
|
|
79
|
+
if ((args?.action ?? 'status') === 'progress') {
|
|
80
|
+
if (typeof progress !== 'function') return json({ ok: false, code: 'PROGRESS_UNAVAILABLE' });
|
|
81
|
+
return json(await progress({ planSha }));
|
|
82
|
+
}
|
|
78
83
|
const plan = loadPlan({ stateDir: dir, planSha });
|
|
79
84
|
if (plan.ok !== true) return json(plan);
|
|
80
85
|
const key = loadOperatorKey();
|
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.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, {
|
|
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) {
|