@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.
Files changed (72) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +17 -0
  4. package/README.md +187 -462
  5. package/bin/w2m-joint.mjs +69 -7
  6. package/codex/mcp-servers.toml +1 -1
  7. package/docs/acceptance/2.0.4/RELEASE-2040.json +22 -0
  8. package/docs/acceptance/2.0.5/RELEASE-205.json +21 -0
  9. package/docs/acceptance/2.0.5/RELEASE-2050.json +22 -0
  10. package/docs/adr/0001-collaboration-crdt.md +59 -0
  11. package/lib/client.js +2754 -2599
  12. package/package.json +2 -2
  13. package/plugin.json +1 -1
  14. package/src/agent/agent.mjs +97 -36
  15. package/src/agent/artifact-delivery.mjs +89 -0
  16. package/src/agent/caps.mjs +16 -2
  17. package/src/agent/sinks/git-sink.mjs +78 -0
  18. package/src/agent/sinks/ssh-sink.mjs +83 -0
  19. package/src/agent/sse-cursor.mjs +16 -0
  20. package/src/agent/sse-watchdog.mjs +71 -0
  21. package/src/plugin/dataplane-sink-route.mjs +113 -0
  22. package/src/plugin/fleet-chat-route.mjs +11 -0
  23. package/src/plugin/gpw.mjs +99 -15
  24. package/src/plugin/joint-dev/dispatch.mjs +5 -1
  25. package/src/plugin/joint-dev/merge-approval.mjs +115 -0
  26. package/src/plugin/joint-dev/progress.mjs +107 -0
  27. package/src/plugin/joint-dev/relay-dispatch.mjs +52 -22
  28. package/src/plugin/project-stream-route.mjs +58 -0
  29. package/src/plugin/tools-joint.mjs +6 -1
  30. package/src/plugin/tools.mjs +25 -5
  31. package/src/relay/fleet-messages.mjs +48 -24
  32. package/src/relay/project-room.mjs +47 -2
  33. package/src/relay/server.mjs +11 -5
  34. package/src/relay/state.mjs +63 -3
  35. package/src/util/chat-crypto.mjs +70 -1
  36. package/docs/COLLAB/COLLABORATION-MODEL.md +0 -240
  37. package/docs/COLLAB/EXECUTION-AUTH-MODEL.md +0 -361
  38. package/docs/COLLAB/M1-ACCEPTANCE-RUNBOOK.md +0 -70
  39. package/docs/COLLAB/M1-MAINTENANCE.md +0 -313
  40. package/docs/COLLAB/M2-TASK-GRANT.md +0 -231
  41. package/docs/COLLAB/M3-GIT-NATIVE-TASKS.md +0 -291
  42. package/docs/COLLAB/M4-PROJECT-ROOM.md +0 -260
  43. package/docs/COLLAB/M5-MULTI-CLIENT-PRIMITIVES.md +0 -263
  44. package/docs/COLLAB/M6-CROSS-NETWORK-RELIABILITY.md +0 -231
  45. package/docs/COLLAB/M7-METRICS-ACCEPTANCE.md +0 -263
  46. package/docs/COLLAB/TASK-MODEL.md +0 -281
  47. package/docs/acceptance/1.9.10/RELEASE-1910.json +0 -21
  48. package/docs/acceptance/1.9.11/RELEASE-1911.json +0 -20
  49. package/docs/acceptance/1.9.12/RELEASE-1912.json +0 -20
  50. package/docs/acceptance/1.9.13/RELEASE-1913.json +0 -20
  51. package/docs/acceptance/1.9.14/RELEASE-1914.json +0 -20
  52. package/docs/acceptance/1.9.15/RELEASE-1915.json +0 -22
  53. package/docs/acceptance/1.9.16/RELEASE-1916.json +0 -21
  54. package/docs/acceptance/1.9.17/RELEASE-1917.json +0 -20
  55. package/docs/acceptance/1.9.19/RELEASE-1919.json +0 -21
  56. package/docs/acceptance/1.9.20/RELEASE-1920.json +0 -22
  57. package/docs/acceptance/1.9.4/RELEASE-194.json +0 -23
  58. package/docs/acceptance/1.9.5/RELEASE-195.json +0 -23
  59. package/docs/acceptance/1.9.6/RELEASE-196.json +0 -22
  60. package/docs/acceptance/1.9.7/RELEASE-197.json +0 -21
  61. package/docs/acceptance/1.9.8/RELEASE-198.json +0 -21
  62. package/docs/acceptance/1.9.9/RELEASE-199.json +0 -21
  63. package/lib/client.js.bak2 +0 -640
  64. package/src/plugin/dataflow.mjs +0 -72
  65. package/src/plugin/fleet-target.mjs.bak-arch2 +0 -812
  66. package/src/plugin/fleet-target.mjs.bak-archive +0 -812
  67. package/src/plugin/tofuture/report.mjs +0 -44
  68. package/src/plugin/tools.mjs.bak-c7 +0 -7029
  69. package/src/relay/tofuture-whitelist.mjs +0 -46
  70. package/src/util/data-plane.mjs +0 -180
  71. package/src/util/sealed-object.mjs +0 -141
  72. 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
- if (typeof file !== 'string' || file === '') return;
46
- try { appendAudit(file, event); } catch { /* an audit write must not change the dispatch outcome */ }
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
- // R3: when a device token is supplied, every target machine must advertise the same plugin version.
78
- if (typeof input?.deviceToken === 'string' && input.deviceToken !== '') {
79
- const expected = typeof input?.expectedVersion === 'string' ? input.expectedVersion : null;
80
- if (expected !== null) {
81
- let fleet;
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 ${input.deviceToken}`, accept: 'application/json' }, signal: signalOf() });
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
- if (fleet === null || !Array.isArray(fleet.devices)) return { ok: false, code: 'VERSION_UNVERIFIABLE', detail: { reason: 'the relay device list could not be read' } };
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 !== expected);
91
- if (skew.length > 0) return { ok: false, code: 'VERSION_SKEW', detail: { expected, machines: skew } };
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 === false ? {} : { include_offline: true }),
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: JSON.stringify(body), signal: signalOf() });
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
- return { ok: true, relay_version: capabilities.relay_version ?? null, features: capabilities.features ?? null, tasks };
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();
@@ -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.3';
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) {