@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.
Files changed (70) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +1795 -1779
  4. package/bin/w2m-joint.mjs +69 -7
  5. package/codex/mcp-servers.toml +1 -1
  6. package/docs/acceptance/2.0.5/RELEASE-205.json +21 -0
  7. package/docs/acceptance/2.0.5/RELEASE-2050.json +22 -0
  8. package/docs/acceptance/2.0.6/RELEASE-2060.json +22 -0
  9. package/docs/adr/0001-collaboration-crdt.md +59 -0
  10. package/lib/client.js +215 -19
  11. package/package.json +2 -2
  12. package/plugin.json +1 -1
  13. package/src/agent/agent.mjs +97 -36
  14. package/src/agent/artifact-delivery.mjs +89 -0
  15. package/src/agent/caps.mjs +16 -2
  16. package/src/agent/sinks/git-sink.mjs +78 -0
  17. package/src/agent/sinks/ssh-sink.mjs +83 -0
  18. package/src/agent/sse-cursor.mjs +16 -0
  19. package/src/agent/sse-watchdog.mjs +71 -0
  20. package/src/plugin/dataplane-sink-route.mjs +118 -0
  21. package/src/plugin/fleet-chat-route.mjs +11 -0
  22. package/src/plugin/joint-dev/dispatch.mjs +5 -1
  23. package/src/plugin/joint-dev/merge-approval.mjs +115 -0
  24. package/src/plugin/joint-dev/progress.mjs +107 -0
  25. package/src/plugin/joint-dev/relay-dispatch.mjs +52 -22
  26. package/src/plugin/project-stream-route.mjs +58 -0
  27. package/src/plugin/tools-joint.mjs +6 -1
  28. package/src/plugin/tools.mjs +25 -5
  29. package/src/relay/fleet-messages.mjs +48 -24
  30. package/src/relay/project-room.mjs +47 -2
  31. package/src/relay/server.mjs +11 -5
  32. package/src/relay/state.mjs +63 -3
  33. package/src/util/chat-crypto.mjs +70 -1
  34. package/docs/COLLAB/COLLABORATION-MODEL.md +0 -240
  35. package/docs/COLLAB/EXECUTION-AUTH-MODEL.md +0 -361
  36. package/docs/COLLAB/M1-ACCEPTANCE-RUNBOOK.md +0 -70
  37. package/docs/COLLAB/M1-MAINTENANCE.md +0 -313
  38. package/docs/COLLAB/M2-TASK-GRANT.md +0 -231
  39. package/docs/COLLAB/M3-GIT-NATIVE-TASKS.md +0 -291
  40. package/docs/COLLAB/M4-PROJECT-ROOM.md +0 -260
  41. package/docs/COLLAB/M5-MULTI-CLIENT-PRIMITIVES.md +0 -263
  42. package/docs/COLLAB/M6-CROSS-NETWORK-RELIABILITY.md +0 -231
  43. package/docs/COLLAB/M7-METRICS-ACCEPTANCE.md +0 -263
  44. package/docs/COLLAB/TASK-MODEL.md +0 -281
  45. package/docs/acceptance/1.9.10/RELEASE-1910.json +0 -21
  46. package/docs/acceptance/1.9.11/RELEASE-1911.json +0 -20
  47. package/docs/acceptance/1.9.12/RELEASE-1912.json +0 -20
  48. package/docs/acceptance/1.9.13/RELEASE-1913.json +0 -20
  49. package/docs/acceptance/1.9.14/RELEASE-1914.json +0 -20
  50. package/docs/acceptance/1.9.15/RELEASE-1915.json +0 -22
  51. package/docs/acceptance/1.9.16/RELEASE-1916.json +0 -21
  52. package/docs/acceptance/1.9.17/RELEASE-1917.json +0 -20
  53. package/docs/acceptance/1.9.19/RELEASE-1919.json +0 -21
  54. package/docs/acceptance/1.9.20/RELEASE-1920.json +0 -22
  55. package/docs/acceptance/1.9.4/RELEASE-194.json +0 -23
  56. package/docs/acceptance/1.9.5/RELEASE-195.json +0 -23
  57. package/docs/acceptance/1.9.6/RELEASE-196.json +0 -22
  58. package/docs/acceptance/1.9.7/RELEASE-197.json +0 -21
  59. package/docs/acceptance/1.9.8/RELEASE-198.json +0 -21
  60. package/docs/acceptance/1.9.9/RELEASE-199.json +0 -21
  61. package/lib/client.js.bak2 +0 -640
  62. package/src/plugin/dataflow.mjs +0 -72
  63. package/src/plugin/fleet-target.mjs.bak-arch2 +0 -812
  64. package/src/plugin/fleet-target.mjs.bak-archive +0 -812
  65. package/src/plugin/tofuture/report.mjs +0 -44
  66. package/src/plugin/tools.mjs.bak-c7 +0 -7029
  67. package/src/relay/tofuture-whitelist.mjs +0 -46
  68. package/src/util/data-plane.mjs +0 -180
  69. package/src/util/sealed-object.mjs +0 -141
  70. package/src/util/tofuture-hmac.mjs +0 -30
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "twinsearth-w2m-dsh",
4
- "version": "2.0.4",
4
+ "version": "2.0.6",
5
5
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -33,6 +33,11 @@ import { tmpdir } from 'node:os';
33
33
  import { randomUUID , sign as signBytes } from 'node:crypto';
34
34
  import { fileURLToPath } from 'node:url';
35
35
 
36
+ import { deliverArtifact } from './artifact-delivery.mjs';
37
+
38
+ import { createSseWatchdog } from './sse-watchdog.mjs';
39
+ import { shouldAdvanceCursor } from './sse-cursor.mjs';
40
+
36
41
  import {
37
42
  EMPTY_SHA256,
38
43
  classifyExit,
@@ -1921,6 +1926,8 @@ export function createAgent(options) {
1921
1926
  const COMPLETED_CACHE_LIMIT = 64;
1922
1927
  let pumping = false;
1923
1928
  let streamAbort = null;
1929
+ /** Set when the current attach attempt parsed the relay's `ready` frame. */
1930
+ let attemptAttached = false;
1924
1931
  let runAbort = null;
1925
1932
  /** @type {Heartbeat|null} */
1926
1933
  let heartbeat = null;
@@ -2247,26 +2254,19 @@ export function createAgent(options) {
2247
2254
  * @param {string} body
2248
2255
  * @returns {Promise<{ok: boolean, status: number, sha256?: string, code?: string}>}
2249
2256
  */
2250
- async function putBlobBytes(sha256, body) {
2251
- const path = `/v1/fleet/blobs/${sha256}`;
2252
- // The stage's stdout arrives as a Buffer. Work on BYTES: the name must be the hash of exactly what is sent, and the
2253
- // signature must cover those same bytes -- decoding and re-encoding would replace invalid UTF-8 and change the hash.
2254
- const bytes = Buffer.isBuffer(body) ? body : Buffer.from(String(body ?? ''), 'utf8');
2255
- const headers = { 'content-type': 'application/octet-stream', accept: 'application/json' };
2256
- if (token()) headers.authorization = `Bearer ${token()}`;
2257
- Object.assign(headers, signingHeaders('PUT', path, bytes.toString('utf8')));
2258
- try {
2259
- const response = await fetchImpl(url(path), { method: 'PUT', headers, body: bytes });
2260
- if (response.ok) return { ok: true, status: response.status, sha256 };
2261
- let code = `HTTP_${response.status}`;
2262
- try {
2263
- const parsed = JSON.parse(await response.text());
2264
- if (typeof parsed?.error?.code === 'string') code = parsed.error.code;
2265
- } catch { /* the code is then the HTTP status, which is still a name */ }
2266
- return { ok: false, status: response.status, code };
2267
- } catch (error) {
2268
- return { ok: false, status: 0, code: error instanceof Error ? 'BLOB_UPLOAD_FAILED' : 'BLOB_UPLOAD_FAILED' };
2269
- }
2257
+ async function putBlobBytes(sha256, body, kind = 'stdout', taskId = null) {
2258
+ // A3/S4: artifacts leave through the user's sink (dir/git/ssh), never through the relay.
2259
+ // Without a sink the bytes are kept locally and the caller reports ARTIFACT_NOT_DELIVERED;
2260
+ // the relay is not contacted at all.
2261
+ const delivered = await deliverArtifact({
2262
+ stateDir,
2263
+ kind,
2264
+ bytes: body,
2265
+ taskId: typeof taskId === 'string' && taskId !== '' ? taskId : 'unknown-task',
2266
+ machineId: identity.machine_id,
2267
+ });
2268
+ if (delivered.ok === true) return { ok: true, status: 200, sha256: delivered.sha256, ref: delivered.ref };
2269
+ return { ok: false, status: 0, code: delivered.code ?? 'ARTIFACT_NOT_DELIVERED', reason: delivered.reason ?? null, local: delivered.local ?? null };
2270
2270
  }
2271
2271
 
2272
2272
  /** @param {string} path @param {object} body @param {number} [retries] */
@@ -3359,8 +3359,12 @@ export function createAgent(options) {
3359
3359
  // The agent caps what it keeps in memory; if that cap was hit the blob holds the same bytes the hash covers, and
3360
3360
  // the warning says so instead of letting a reader assume the stage printed exactly this much.
3361
3361
  if (ran.truncated_stdout === true && !warnings.includes('PIPELINE_STDOUT_TRUNCATED')) warnings.push('PIPELINE_STDOUT_TRUNCATED');
3362
- const uploaded = await putBlobBytes(ran.stdout_sha256, ran.stdout);
3363
- if (uploaded.ok !== true) {
3362
+ const uploaded = await putBlobBytes(ran.stdout_sha256, ran.stdout, 'stdout', offer.task_id);
3363
+ if (uploaded.ok !== true && uploaded.code === 'ARTIFACT_NOT_DELIVERED') {
3364
+ // No sink configured: the stage itself succeeded and its bytes are kept locally.
3365
+ warnings.push('ARTIFACT_NOT_DELIVERED');
3366
+ blobSha256 = null;
3367
+ } else if (uploaded.ok !== true) {
3364
3368
  log("warn", offer.task_id + " stage " + stage.index + ": blob upload failed", { code: uploaded.code });
3365
3369
  stageReports.push({
3366
3370
  index: stage.index,
@@ -3377,9 +3381,10 @@ export function createAgent(options) {
3377
3381
  });
3378
3382
  stoppedAt = stage.index;
3379
3383
  break;
3384
+ } else {
3385
+ blobSha256 = uploaded.sha256;
3386
+ lastBlobSha256 = blobSha256;
3380
3387
  }
3381
- blobSha256 = uploaded.sha256;
3382
- lastBlobSha256 = blobSha256;
3383
3388
  }
3384
3389
  stageReports.push({
3385
3390
  index: stage.index,
@@ -3422,12 +3427,12 @@ export function createAgent(options) {
3422
3427
  const plainPlan = planStageOutput({ machineId: identity.machine_id, stageIndex: 0, stdoutBytes: execution.stdout_bytes });
3423
3428
  if (plainPlan.ok === true && plainPlan.mode === 'blob') {
3424
3429
  if (execution.truncated_stdout === true && !warnings.includes('STDOUT_TRUNCATED')) warnings.push('STDOUT_TRUNCATED');
3425
- const uploaded = await putBlobBytes(execution.stdout_sha256, execution.stdout);
3430
+ const uploaded = await putBlobBytes(execution.stdout_sha256, execution.stdout, 'stdout', offer.task_id);
3426
3431
  if (uploaded.ok === true) {
3427
3432
  lastBlobSha256 = uploaded.sha256;
3428
3433
  } else {
3429
3434
  log('warn', offer.task_id + ': plain stdout blob upload failed', { code: uploaded.code });
3430
- warnings.push('BLOB_UPLOAD_FAILED');
3435
+ warnings.push(uploaded.code === 'ARTIFACT_NOT_DELIVERED' ? 'ARTIFACT_NOT_DELIVERED' : 'BLOB_UPLOAD_FAILED');
3431
3436
  }
3432
3437
  }
3433
3438
  }
@@ -3451,9 +3456,9 @@ export function createAgent(options) {
3451
3456
  let patchSha = null;
3452
3457
  if (patch.ok === true && typeof patch.patch === 'string' && patch.patch.trim() !== '') {
3453
3458
  const sha = sha256Hex(patch.patch);
3454
- const uploaded = await putBlobBytes(sha, patch.patch);
3459
+ const uploaded = await putBlobBytes(sha, patch.patch, 'patch', offer.task_id);
3455
3460
  if (uploaded.ok === true) patchSha = sha;
3456
- else warnings.push('PATCH_UPLOAD_FAILED');
3461
+ else warnings.push(uploaded.code === 'ARTIFACT_NOT_DELIVERED' ? 'ARTIFACT_NOT_DELIVERED' : 'PATCH_UPLOAD_FAILED');
3457
3462
  }
3458
3463
  const removed = await removeWorktree({ repoDir: project, worktreeDir: workspaceState.worktreeDir });
3459
3464
  if (removed.ok !== true) warnings.push('WORKTREE_CLEANUP_FAILED');
@@ -3833,7 +3838,24 @@ export function createAgent(options) {
3833
3838
  // break the "proxy strips the prefix" deployment (see signingHeaders).
3834
3839
  Object.assign(headers, signingHeaders('GET', `/v1/stream?${query.toString()}`));
3835
3840
 
3836
- const response = await fetchImpl(streamUrl, { headers, signal: streamAbort.signal });
3841
+ // Header deadline: a proxy that accepts the socket but never returns headers must not hang the
3842
+ // agent forever. Node >= 20.19 has AbortSignal.any, and every other HTTP call in the tree already
3843
+ // carries a timeout.
3844
+ const headerTimeoutMs = Number(process.env.W2M_SSE_FIRST_FRAME_MS) > 0 ? Number(process.env.W2M_SSE_FIRST_FRAME_MS) : 5000;
3845
+ const headerSignal = typeof AbortSignal.any === 'function'
3846
+ ? AbortSignal.any([streamAbort.signal, AbortSignal.timeout(headerTimeoutMs)])
3847
+ : streamAbort.signal;
3848
+ let response;
3849
+ try {
3850
+ response = await fetchImpl(streamUrl, { headers, signal: headerSignal });
3851
+ } catch (error) {
3852
+ if (streamAbort.signal.aborted) throw error;
3853
+ const timeout = new Error('SSE_FIRST_FRAME_TIMEOUT');
3854
+ timeout.code = 'SSE_FIRST_FRAME_TIMEOUT';
3855
+ state.lastRelayError = { at: new Date().toISOString(), http_status: null, code: timeout.code, path: '/v1/stream' };
3856
+ publishState();
3857
+ throw timeout;
3858
+ }
3837
3859
  if (!response.ok) {
3838
3860
  const body = await response.text().catch(() => '');
3839
3861
  let code = null;
@@ -3855,19 +3877,51 @@ export function createAgent(options) {
3855
3877
  throw error; }
3856
3878
  if (!response.body) throw new Error('stream response had no body');
3857
3879
 
3880
+ // P0-f: two deadlines on the stream this attempt. A half-open connection must not look
3881
+ // like a healthy one forever; the relay keepalive is 15 s, so 45 s is three missed beats.
3882
+ const envMs = (name) => (Number(process.env[name]) > 0 ? Number(process.env[name]) : undefined);
3883
+ let timeoutCode = null;
3884
+ let source = null;
3885
+ const watchdog = createSseWatchdog({
3886
+ firstFrameMs: envMs('W2M_SSE_FIRST_FRAME_MS'),
3887
+ idleMs: envMs('W2M_SSE_IDLE_MS'),
3888
+ onTimeout: (code) => {
3889
+ timeoutCode = code;
3890
+ state.lastRelayError = { at: new Date().toISOString(), http_status: null, code, path: '/v1/stream' };
3891
+ publishState();
3892
+ const error = new Error(code);
3893
+ error.code = code;
3894
+ try { source?.destroy(error); } catch { /* already gone */ }
3895
+ },
3896
+ });
3897
+
3858
3898
  // Both assignments mark the same stream attempt as no longer live: one before the read loop, one
3859
3899
  // after it ends. Only one stream runs at a time, so there is nothing to interleave with.
3860
3900
  // eslint-disable-next-line require-atomic-updates -- one stream attempt at a time
3861
3901
  state.connected = false;
3862
3902
  const decoder = new TextDecoder('utf-8');
3863
3903
  const parser = new SseParser();
3864
- const source = Readable.fromWeb(response.body);
3904
+ source = Readable.fromWeb(response.body);
3865
3905
 
3866
- for await (const chunk of source) {
3867
- if (state.stopped) break;
3868
- for (const frame of parser.push(decoder.decode(chunk, { stream: true }))) {
3869
- handleFrame(frame);
3906
+ try {
3907
+ for await (const chunk of source) {
3908
+ if (state.stopped) break;
3909
+ watchdog.noteBytes();
3910
+ for (const frame of parser.push(decoder.decode(chunk, { stream: true }))) {
3911
+ watchdog.noteFrame();
3912
+ handleFrame(frame);
3913
+ }
3914
+ }
3915
+ } catch (error) {
3916
+ if (timeoutCode !== null) {
3917
+ const timeout = new Error(timeoutCode);
3918
+ timeout.code = timeoutCode;
3919
+ log('warn', 'event stream watchdog fired; reconnecting', { code: timeoutCode });
3920
+ throw timeout;
3870
3921
  }
3922
+ throw error;
3923
+ } finally {
3924
+ watchdog.dispose();
3871
3925
  }
3872
3926
  state.connected = false;
3873
3927
  publishState();
@@ -3934,7 +3988,9 @@ export function createAgent(options) {
3934
3988
  */
3935
3989
  function dispatchFrame(payload, source) {
3936
3990
  const type = payload?.type ?? source.event;
3937
- if (source.transport === 'relay') {
3991
+ if (source.transport === 'relay' && shouldAdvanceCursor({ type, event: source.event })) {
3992
+ // P0-f: ready/notice carry sequence numbers that never entered the replay ring; adopting one
3993
+ // would claim events that were never received and a reconnect would skip the real gap.
3938
3994
  if (Number.isInteger(payload?.seq)) state.seq = Math.max(state.seq, payload.seq);
3939
3995
  else if (source.id && /^\d+$/.test(source.id)) state.seq = Math.max(state.seq, Number(source.id));
3940
3996
  }
@@ -3950,6 +4006,7 @@ export function createAgent(options) {
3950
4006
  }
3951
4007
  // §3.1: nothing counts as connected before `ready` arrives.
3952
4008
  state.connected = true;
4009
+ attemptAttached = true;
3953
4010
  const relayId =
3954
4011
  typeof payload.relay_id === 'string' && payload.relay_id !== '' ? payload.relay_id : null;
3955
4012
  const previousRelayId = state.relayId;
@@ -4296,6 +4353,7 @@ export function createAgent(options) {
4296
4353
  const response = await postJson('/v1/heartbeat', {
4297
4354
  diagnostic: true,
4298
4355
  machine_id: identity.machine_id,
4356
+ ...(caps === null || caps === undefined ? {} : { caps }),
4299
4357
  ...heartbeatDiagnostics({
4300
4358
  rttMs: state.rttMs,
4301
4359
  reconnectAttempts: state.reconnectAttempts,
@@ -4416,6 +4474,7 @@ export function createAgent(options) {
4416
4474
  streamAbort = new AbortController();
4417
4475
  state.streamRestartRequested = false;
4418
4476
  const openedAt = Date.now();
4477
+ attemptAttached = false;
4419
4478
  /** @type {Error|null} */
4420
4479
  let failure = null;
4421
4480
  try {
@@ -4449,7 +4508,9 @@ export function createAgent(options) {
4449
4508
  });
4450
4509
  }
4451
4510
 
4452
- const stable = livedMs >= STABLE_STREAM_MS;
4511
+ // A stream that never delivered `ready` is not stable however long the socket lived: the
4512
+ // watchdog deadlines are seconds, so the old rule reset the backoff to 500 ms forever.
4513
+ const stable = attemptAttached && livedMs >= STABLE_STREAM_MS;
4453
4514
  if (stable) {
4454
4515
  reconnectAttempt = 0;
4455
4516
  restartReconnects = 0;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * S4 data-plane delivery (A3): artifacts leave the agent through a USER sink, never through relay.
3
+ *
4
+ * Order of events:
5
+ * 1. no sink configured -> keep the bytes locally (0600) and report
6
+ * ARTIFACT_NOT_DELIVERED / DATA_PLANE_SINK_REQUIRED; the relay is not contacted;
7
+ * 2. sink configured -> write the sealed bytes to the sink (dir/git/ssh), then build an ArtifactRef
8
+ * (metadata only, <= 4 KiB) that a reader can resolve against the same sink.
9
+ */
10
+ import { createHash, randomBytes } from 'node:crypto';
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+
14
+ import { createArtifactRef } from '../util/artifact-ref.mjs';
15
+ import { createDirSink } from './sinks/dir-sink.mjs';
16
+ import { createGitSink } from './sinks/git-sink.mjs';
17
+ import { createSshSink } from './sinks/ssh-sink.mjs';
18
+
19
+ const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
20
+ const SAFE_TASK = /^[A-Za-z0-9._-]{1,80}$/;
21
+
22
+ /** Read `<stateDir>/dataplane/sink.json` and build the configured sink, or null (fail closed). */
23
+ export function loadArtifactSink(stateDir) {
24
+ if (typeof stateDir !== 'string' || stateDir === '') return null;
25
+ const file = join(stateDir, 'dataplane', 'sink.json');
26
+ if (!existsSync(file)) return null;
27
+ try {
28
+ const config = JSON.parse(readFileSync(file, 'utf8'));
29
+ if (config?.kind === 'dir' && typeof config.root === 'string') return createDirSink({ root: config.root });
30
+ if (config?.kind === 'lan-git' && typeof config.root === 'string') return createGitSink({ root: config.root, remote: config.remote ?? null, branch: config.branch ?? null });
31
+ if (config?.kind === 'ssh' && typeof config.host === 'string' && typeof config.user === 'string' && typeof config.dir === 'string') {
32
+ return createSshSink({ host: config.host, user: config.user, dir: config.dir, port: Number.isInteger(config.port) ? config.port : null, keyPath: typeof config.keyPath === 'string' ? config.keyPath : null });
33
+ }
34
+ return null;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Deliver one artifact.
42
+ *
43
+ * @param {{stateDir: string, kind?: 'stdout'|'stderr'|'patch'|'commit'|'reduce-input', bytes: Buffer|string,
44
+ * taskId: string, machineId: string, stepId?: string|null, sink?: object|null}} input
45
+ * @returns {Promise<{ok: true, ref: object, sha256: string, bytes: number}
46
+ * | {ok: false, code: 'ARTIFACT_NOT_DELIVERED', reason: string, local: object}>}
47
+ */
48
+ export async function deliverArtifact(input) {
49
+ const bytes = Buffer.isBuffer(input?.bytes) ? input.bytes : Buffer.from(String(input?.bytes ?? ''), 'utf8');
50
+ const digest = sha256(bytes);
51
+ const taskId = typeof input?.taskId === 'string' && input.taskId !== '' ? input.taskId : 'unknown-task';
52
+ const machineId = typeof input?.machineId === 'string' && input.machineId !== '' ? input.machineId : 'unknown-machine';
53
+ const taskDir = SAFE_TASK.test(taskId) ? taskId : 'unknown-task';
54
+ const kind = typeof input?.kind === 'string' ? input.kind : 'stdout';
55
+ const stateDir = typeof input?.stateDir === 'string' ? input.stateDir : '';
56
+ const localPath = join(stateDir, 'artifacts', taskDir, kind + '-' + digest + '.bin');
57
+ const keepLocal = () => {
58
+ try {
59
+ mkdirSync(join(stateDir, 'artifacts', taskDir), { recursive: true, mode: 0o700 });
60
+ writeFileSync(localPath, bytes, { encoding: 'utf8', mode: 0o600 });
61
+ return { sha256: digest, bytes: bytes.length, path: localPath };
62
+ } catch {
63
+ return { sha256: digest, bytes: bytes.length, path: null };
64
+ }
65
+ };
66
+ const sink = input?.sink === undefined ? loadArtifactSink(stateDir) : input.sink;
67
+ if (sink === null || sink === undefined) {
68
+ return { ok: false, code: 'ARTIFACT_NOT_DELIVERED', reason: 'DATA_PLANE_SINK_REQUIRED', local: keepLocal() };
69
+ }
70
+ const objectId = randomBytes(16).toString('hex');
71
+ const written = await sink.writeObject({ objectId, body: bytes, expectSha256: digest });
72
+ if (written.ok !== true) {
73
+ return { ok: false, code: 'ARTIFACT_NOT_DELIVERED', reason: written.code ?? 'SINK_WRITE_FAILED', local: keepLocal() };
74
+ }
75
+ const ref = createArtifactRef({
76
+ objectId,
77
+ taskId: taskId,
78
+ stepId: typeof input?.stepId === 'string' ? input.stepId : null,
79
+ machineId,
80
+ kind,
81
+ sinkKind: sink.kind === 'lan-git' ? 'lan-git' : sink.kind,
82
+ relPath: written.rel_path ?? objectId + '.json',
83
+ cipherSha256: digest,
84
+ cipherBytes: bytes.length,
85
+ plainBytes: bytes.length,
86
+ });
87
+ if (ref.ok !== true) return { ok: false, code: 'ARTIFACT_NOT_DELIVERED', reason: ref.code ?? 'REF_INVALID', local: keepLocal() };
88
+ return { ok: true, ref: ref.ref, sha256: digest, bytes: bytes.length };
89
+ }
@@ -20,7 +20,7 @@
20
20
  * leaks into an envelope.
21
21
  */
22
22
 
23
- import { chmodSync, mkdtempSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
23
+ import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
24
24
  import { tmpdir, release as osRelease } from 'node:os';
25
25
  import { basename, extname, join } from 'node:path';
26
26
  import { randomBytes } from 'node:crypto';
@@ -40,7 +40,20 @@ export {
40
40
  } from './resolve.mjs';
41
41
 
42
42
  /** Keys of the contract's `caps` object — nothing else may be added. */
43
- export const CAPS_KEYS = ['case_sensitive_fs', 'symlinks', 'exec_bit', 'python', 'npm', 'node'];
43
+ export const CAPS_KEYS = ['case_sensitive_fs', 'symlinks', 'exec_bit', 'python', 'npm', 'node', 'plugin_version'];
44
+
45
+ /** The plugin's own version, read once from the package manifest (the version gate compares it). */
46
+ let pluginVersionCache = null;
47
+ export function pluginVersion() {
48
+ if (pluginVersionCache !== null) return pluginVersionCache;
49
+ try {
50
+ const manifest = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8').replace(/^\uFEFF/, ''));
51
+ pluginVersionCache = typeof manifest.version === 'string' && manifest.version !== '' ? manifest.version : null;
52
+ } catch {
53
+ pluginVersionCache = null;
54
+ }
55
+ return pluginVersionCache;
56
+ }
44
57
 
45
58
  /**
46
59
  * Run `<exe> <args>` and return the first regex capture of stdout+stderr.
@@ -403,6 +416,7 @@ export async function probeCapsDetailed(options = {}) {
403
416
  python: python.version,
404
417
  npm: npm.version,
405
418
  node: node.version,
419
+ plugin_version: pluginVersion(),
406
420
  };
407
421
 
408
422
  return {
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Git sink (S4/D3): artifacts land in a user-run git worktree and are committed there.
3
+ *
4
+ * The sink never carries plaintext: callers hand it the sealed object bytes. Writes are
5
+ * object-id-addressed (`<object_id>.json`), hash-verified on the way in and out, and committed so
6
+ * the same directory can be synced by the operator's own git remote (no relay data plane).
7
+ */
8
+ import { createHash } from 'node:crypto';
9
+ import { execFileSync } from 'node:child_process';
10
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, unlinkSync, writeSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+
13
+ const OBJECT_ID = /^[0-9a-f]{32,64}$/;
14
+ const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
15
+
16
+ /**
17
+ * @param {{root: string, run?: Function, commitPrefix?: string, remote?: string|null, branch?: string|null}} input
18
+ */
19
+ export function createGitSink({ root, run = null, commitPrefix = 'w2m artifact', remote = null, branch = null }) {
20
+ if (typeof root !== 'string' || root === '') throw new Error('SINK_ROOT_REQUIRED');
21
+ if (!existsSync(join(root, '.git'))) throw new Error('GIT_SINK_NOT_A_REPO');
22
+ mkdirSync(root, { recursive: true });
23
+ const git = (args) => (run === null ? execFileSync('git', args, { cwd: root, stdio: 'pipe' }) : run(args));
24
+ const pathOf = (objectId) => join(root, objectId + '.json');
25
+
26
+ async function writeObject({ objectId, body, expectSha256 = null }) {
27
+ if (typeof objectId !== 'string' || !OBJECT_ID.test(objectId)) return { ok: false, code: 'OBJECT_ID_INVALID' };
28
+ const bytes = Buffer.isBuffer(body) ? body : Buffer.from(typeof body === 'string' ? body : JSON.stringify(body), 'utf8');
29
+ const actual = sha256(bytes);
30
+ if (typeof expectSha256 === 'string' && expectSha256 !== '' && expectSha256 !== actual) {
31
+ return { ok: false, code: 'OBJECT_HASH_MISMATCH', detail: { expected: expectSha256, actual } };
32
+ }
33
+ const path = pathOf(objectId);
34
+ const tmp = path + '.tmp-' + process.pid + '-' + Date.now().toString(36);
35
+ const fd = openSync(tmp, 'w', 0o600);
36
+ try { writeSync(fd, bytes); fsyncSync(fd); } finally { closeSync(fd); }
37
+ renameSync(tmp, path);
38
+ try {
39
+ git(['add', '--', objectId + '.json']);
40
+ git(['commit', '-m', commitPrefix + ' ' + objectId, '--', objectId + '.json']);
41
+ if (remote !== null && branch !== null) git(['push', remote, 'HEAD:' + branch]);
42
+ } catch (error) {
43
+ return { ok: false, code: 'GIT_SINK_COMMIT_FAILED', detail: error instanceof Error ? error.message : String(error) };
44
+ }
45
+ return { ok: true, objectId, path, rel_path: objectId + '.json', sha256: actual, bytes: bytes.length };
46
+ }
47
+
48
+ async function readObject({ objectId, expectSha256 = null }) {
49
+ if (typeof objectId !== 'string' || !OBJECT_ID.test(objectId)) return { ok: false, code: 'OBJECT_ID_INVALID' };
50
+ const path = pathOf(objectId);
51
+ if (!existsSync(path)) return { ok: false, code: 'OBJECT_NOT_FOUND' };
52
+ let bytes;
53
+ try { bytes = readFileSync(path); } catch { return { ok: false, code: 'OBJECT_UNREADABLE' }; }
54
+ const actual = sha256(bytes);
55
+ if (typeof expectSha256 === 'string' && expectSha256 !== '' && expectSha256 !== actual) {
56
+ return { ok: false, code: 'OBJECT_HASH_MISMATCH', detail: { expected: expectSha256, actual } };
57
+ }
58
+ return { ok: true, objectId, path, sha256: actual, bytes };
59
+ }
60
+
61
+ return {
62
+ kind: 'lan-git',
63
+ writeObject,
64
+ readObject,
65
+ async listObjects() {
66
+ return readdirSync(root).filter((name) => name.endsWith('.json') && OBJECT_ID.test(name.slice(0, -5))).map((name) => name.slice(0, -5));
67
+ },
68
+ async removeObject(objectId) {
69
+ if (typeof objectId !== 'string' || !OBJECT_ID.test(objectId)) return { ok: false, code: 'OBJECT_ID_INVALID' };
70
+ try {
71
+ unlinkSync(pathOf(objectId));
72
+ git(['rm', '--cached', '--ignore-unmatch', '--', objectId + '.json']);
73
+ git(['commit', '-m', commitPrefix + ' remove ' + objectId, '--', objectId + '.json']);
74
+ return { ok: true };
75
+ } catch { return { ok: false, code: 'OBJECT_NOT_FOUND' }; }
76
+ },
77
+ };
78
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * SSH sink (S4/D3): artifacts are written to a directory on a user-owned host over SSH.
3
+ *
4
+ * No relay data plane: the operator supplies the host and directory; every path component is
5
+ * validated before it is embedded in the remote command, and the local side verifies the object
6
+ * hash again after a read. The transport is delegated to the system `ssh` binary (optionally
7
+ * injected for tests), so key handling stays with the user's existing SSH configuration.
8
+ */
9
+ import { createHash } from 'node:crypto';
10
+ import { spawnSync } from 'node:child_process';
11
+
12
+ const OBJECT_ID = /^[0-9a-f]{32,64}$/;
13
+ const HOST = /^[A-Za-z0-9._:[\]-]+$/;
14
+ const USER = /^[A-Za-z0-9._-]+$/;
15
+ const DIR = /^\/[A-Za-z0-9._/-]*$/;
16
+ const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
17
+
18
+ function defaultRun(args, input) {
19
+ const result = spawnSync('ssh', args, { input, maxBuffer: 64 * 1024 * 1024, windowsHide: true });
20
+ if (result.error) return { ok: false, code: 'SSH_SPAWN_FAILED', detail: result.error.message };
21
+ if (result.status !== 0) return { ok: false, code: 'SSH_COMMAND_FAILED', detail: String(result.stderr ?? '').slice(0, 400) };
22
+ return { ok: true, stdout: Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout ?? '') };
23
+ }
24
+
25
+ /**
26
+ * @param {{host: string, user: string, dir: string, port?: number, keyPath?: string|null, run?: Function}} input
27
+ */
28
+ export function createSshSink({ host, user, dir, port = null, keyPath = null, run = null }) {
29
+ if (typeof host !== 'string' || !HOST.test(host)) throw new Error('SSH_SINK_HOST_INVALID');
30
+ if (typeof user !== 'string' || !USER.test(user)) throw new Error('SSH_SINK_USER_INVALID');
31
+ if (typeof dir !== 'string' || !DIR.test(dir) || dir.includes('..')) throw new Error('SSH_SINK_DIR_INVALID');
32
+ if (port !== null && (!Number.isInteger(port) || port < 1 || port > 65535)) throw new Error('SSH_SINK_PORT_INVALID');
33
+ if (keyPath !== null && (typeof keyPath !== 'string' || keyPath === '')) throw new Error('SSH_SINK_KEY_INVALID');
34
+ const exec = run ?? defaultRun;
35
+ const target = user + '@' + host;
36
+ const baseArgs = ['-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new'];
37
+ if (port !== null) baseArgs.push('-p', String(port));
38
+ if (keyPath !== null) baseArgs.push('-i', keyPath);
39
+ const pathOf = (objectId) => dir.replace(/\/$/, '') + '/' + objectId + '.json';
40
+ const ssh = (remoteCommand, input = undefined) => exec([...baseArgs, '--', target, remoteCommand], input);
41
+
42
+ async function writeObject({ objectId, body, expectSha256 = null }) {
43
+ if (typeof objectId !== 'string' || !OBJECT_ID.test(objectId)) return { ok: false, code: 'OBJECT_ID_INVALID' };
44
+ const bytes = Buffer.isBuffer(body) ? body : Buffer.from(typeof body === 'string' ? body : JSON.stringify(body), 'utf8');
45
+ const actual = sha256(bytes);
46
+ if (typeof expectSha256 === 'string' && expectSha256 !== '' && expectSha256 !== actual) {
47
+ return { ok: false, code: 'OBJECT_HASH_MISMATCH', detail: { expected: expectSha256, actual } };
48
+ }
49
+ const final = pathOf(objectId);
50
+ const tmp = final + '.tmp-' + process.pid + '-' + Date.now().toString(36);
51
+ const written = ssh('mkdir -p ' + dir + ' && cat > ' + tmp + ' && mv ' + tmp + ' ' + final, bytes);
52
+ if (written.ok !== true) return written;
53
+ return { ok: true, objectId, rel_path: objectId + '.json', remote_path: final, sha256: actual, bytes: bytes.length };
54
+ }
55
+
56
+ async function readObject({ objectId, expectSha256 = null }) {
57
+ if (typeof objectId !== 'string' || !OBJECT_ID.test(objectId)) return { ok: false, code: 'OBJECT_ID_INVALID' };
58
+ const read = ssh('cat ' + pathOf(objectId));
59
+ if (read.ok !== true) return read;
60
+ const bytes = read.stdout;
61
+ const actual = sha256(bytes);
62
+ if (typeof expectSha256 === 'string' && expectSha256 !== '' && expectSha256 !== actual) {
63
+ return { ok: false, code: 'OBJECT_HASH_MISMATCH', detail: { expected: expectSha256, actual } };
64
+ }
65
+ return { ok: true, objectId, sha256: actual, bytes };
66
+ }
67
+
68
+ return {
69
+ kind: 'ssh',
70
+ writeObject,
71
+ readObject,
72
+ async listObjects() {
73
+ const listed = ssh('ls -1 ' + dir);
74
+ if (listed.ok !== true) return [];
75
+ return listed.stdout.toString('utf8').split('\n').map((name) => name.trim()).filter((name) => name.endsWith('.json') && OBJECT_ID.test(name.slice(0, -5))).map((name) => name.slice(0, -5));
76
+ },
77
+ async removeObject(objectId) {
78
+ if (typeof objectId !== 'string' || !OBJECT_ID.test(objectId)) return { ok: false, code: 'OBJECT_ID_INVALID' };
79
+ const removed = ssh('rm -f ' + pathOf(objectId));
80
+ return removed.ok === true ? { ok: true } : removed;
81
+ },
82
+ };
83
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * SSE cursor rule (P0-f): only replayable frames may move the resume cursor.
3
+ *
4
+ * The relay's `ready` and `notice` frames are assigned a sequence number with `reserveSeq()` but are
5
+ * not written into the replay ring, so a client that adopts those numbers claims to have seen events
6
+ * it never received - and a reconnect then silently skips the gap. The cursor therefore advances
7
+ * only for frames the ring actually replays (`task.*`, `peer.*`, `result.ack`).
8
+ */
9
+ export const NON_REPLAYABLE_EVENTS = Object.freeze(['ready', 'notice']);
10
+
11
+ /** @param {{type?: string|null, event?: string|null}} input */
12
+ export function shouldAdvanceCursor(input) {
13
+ const type = String(input?.type ?? input?.event ?? '');
14
+ if (type === '') return false;
15
+ return !NON_REPLAYABLE_EVENTS.includes(type);
16
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * SSE watchdog (P0-f): the stream must not hang forever on a half-open connection.
3
+ *
4
+ * Two deadlines are enforced:
5
+ * · first frame — nothing parsed within 5 s means the stream is not really attached;
6
+ * · idle — 45 s without any byte, which is longer than the relay's 15 s keepalive
7
+ * interval by design (three missed keepalives before we give up).
8
+ *
9
+ * `noteBytes` sustains the idle deadline (keepalive comments prove the socket is alive) but it does
10
+ * NOT satisfy the first-frame deadline: a stream that connects and sends keepalives without a
11
+ * parseable `ready` is exactly the buffered/half-dead case this watchdog exists to catch.
12
+ */
13
+ export const SSE_FIRST_FRAME_MS = 5000;
14
+ export const SSE_IDLE_MS = 45000;
15
+ export const SSE_FIRST_FRAME_TIMEOUT = 'SSE_FIRST_FRAME_TIMEOUT';
16
+ export const SSE_IDLE_TIMEOUT = 'SSE_IDLE_TIMEOUT';
17
+
18
+ export function createSseWatchdog(input = {}) {
19
+ const firstFrameMs = Number.isInteger(input.firstFrameMs) && input.firstFrameMs > 0 ? input.firstFrameMs : SSE_FIRST_FRAME_MS;
20
+ const idleMs = Number.isInteger(input.idleMs) && input.idleMs > 0 ? input.idleMs : SSE_IDLE_MS;
21
+ const onTimeout = typeof input.onTimeout === 'function' ? input.onTimeout : () => {};
22
+ let disposed = false;
23
+ let firstTimer = null;
24
+ let idleTimer = null;
25
+
26
+ const armIdle = () => {
27
+ if (disposed) return;
28
+ if (idleTimer !== null) clearTimeout(idleTimer);
29
+ idleTimer = setTimeout(() => {
30
+ if (disposed) return;
31
+ disposed = true;
32
+ clearTimers();
33
+ onTimeout(SSE_IDLE_TIMEOUT);
34
+ }, idleMs);
35
+ if (typeof idleTimer.unref === 'function') idleTimer.unref();
36
+ };
37
+ const clearTimers = () => {
38
+ if (firstTimer !== null) { clearTimeout(firstTimer); firstTimer = null; }
39
+ if (idleTimer !== null) { clearTimeout(idleTimer); idleTimer = null; }
40
+ };
41
+
42
+ firstTimer = setTimeout(() => {
43
+ if (disposed) return;
44
+ disposed = true;
45
+ clearTimers();
46
+ onTimeout(SSE_FIRST_FRAME_TIMEOUT);
47
+ }, firstFrameMs);
48
+ if (typeof firstTimer.unref === 'function') firstTimer.unref();
49
+ armIdle();
50
+
51
+ return {
52
+ /** Any byte (headers, keepalive comment, frame) proves the socket is alive: it clears the
53
+ * first-frame deadline and re-arms the idle deadline. */
54
+ noteBytes() {
55
+ if (disposed) return;
56
+ armIdle();
57
+ },
58
+ /** A parsed frame clears the first-frame deadline. */
59
+ noteFrame() {
60
+ if (disposed) return;
61
+ if (firstTimer !== null) { clearTimeout(firstTimer); firstTimer = null; }
62
+ armIdle();
63
+ },
64
+ get disposed() { return disposed; },
65
+ dispose() {
66
+ if (disposed) return;
67
+ disposed = true;
68
+ clearTimers();
69
+ },
70
+ };
71
+ }