@twinsearth/w2m-dsh-plugin 1.9.17 → 1.9.20

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.
@@ -23,7 +23,7 @@
23
23
 
24
24
  import { Readable } from 'node:stream';
25
25
  import { access, constants as fsConstants } from 'node:fs/promises';
26
- import { existsSync, mkdirSync, mkdtempSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
26
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
27
27
  import { dirname, join } from 'node:path';
28
28
  import { commitTaskChanges, createWorktree, listChangedFiles, producePatch, removeWorktree } from './worktree.mjs';
29
29
  import { describePatchArtifact } from './patch-artifact.mjs';
@@ -31,6 +31,7 @@ import { ferryMaintenance, maintenancePaths } from './maintenance-ferry.mjs';
31
31
  import { authorizeWithGrant } from './grant-gate.mjs';
32
32
  import { tmpdir } from 'node:os';
33
33
  import { randomUUID , sign as signBytes } from 'node:crypto';
34
+ import { fileURLToPath } from 'node:url';
34
35
 
35
36
  import {
36
37
  EMPTY_SHA256,
@@ -2122,6 +2123,10 @@ export function createAgent(options) {
2122
2123
  python: caps.python ?? null,
2123
2124
  npm: caps.npm ?? null,
2124
2125
  git,
2126
+ plugin_version: (() => {
2127
+ try { return JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json'), 'utf8')).version ?? null; } catch { return null; }
2128
+ })(),
2129
+ joint_dev: 'v2',
2125
2130
  };
2126
2131
  return toolchainCache;
2127
2132
  }
@@ -1,33 +1,79 @@
1
1
  /**
2
- * 目录 sink(本地/局域网共享盘):密封对象落盘、按内容哈希取回。
3
- * 数据面适配器的最小实现;git/ssh 适配器同契约,后续补。
4
- * 对象已经是 sealObject() 的密文,sink 不接触明文与密钥。
2
+ * 目录 sink(本地/局域网共享盘):ArtifactRef v1 的对象落盘与取回。
3
+ *
4
+ * 对象已经是调用方封装好的密文字节,sink 不接触明文与密钥。写入是原子的
5
+ * (tmp → fsync → rename,0600),取回时校验对象 id 形状与期望的 cipher sha256。
5
6
  */
6
7
  import { createHash } from 'node:crypto';
7
- import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
8
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, unlinkSync, writeSync } from 'node:fs';
8
9
  import { join } from 'node:path';
9
10
 
11
+ const OBJECT_ID = /^[0-9a-f]{32,64}$/;
12
+ const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
13
+
10
14
  export function createDirSink({ root }) {
11
15
  if (typeof root !== 'string' || root === '') throw new Error('SINK_ROOT_REQUIRED');
12
16
  mkdirSync(root, { recursive: true });
17
+
18
+ const pathOf = (objectId) => join(root, objectId + '.json');
19
+
20
+ /** Write the sealed object bytes atomically under `<objectId>.json`. */
21
+ async function writeObject({ objectId, body, expectSha256 = null }) {
22
+ if (typeof objectId !== 'string' || !OBJECT_ID.test(objectId)) return { ok: false, code: 'OBJECT_ID_INVALID' };
23
+ const bytes = Buffer.isBuffer(body) ? body : Buffer.from(typeof body === 'string' ? body : JSON.stringify(body), 'utf8');
24
+ const actual = sha256(bytes);
25
+ if (typeof expectSha256 === 'string' && expectSha256 !== '' && expectSha256 !== actual) {
26
+ return { ok: false, code: 'OBJECT_HASH_MISMATCH', detail: { expected: expectSha256, actual } };
27
+ }
28
+ const path = pathOf(objectId);
29
+ const tmp = path + '.tmp-' + process.pid + '-' + Date.now().toString(36);
30
+ const fd = openSync(tmp, 'w', 0o600);
31
+ try {
32
+ writeSync(fd, bytes);
33
+ fsyncSync(fd);
34
+ } finally {
35
+ closeSync(fd);
36
+ }
37
+ renameSync(tmp, path);
38
+ return { ok: true, objectId, path, rel_path: objectId + '.json', sha256: actual, bytes: bytes.length };
39
+ }
40
+
41
+ /** Read the object bytes back and verify the expected cipher sha256. */
42
+ async function readObject({ objectId, expectSha256 = null }) {
43
+ if (typeof objectId !== 'string' || !OBJECT_ID.test(objectId)) return { ok: false, code: 'OBJECT_ID_INVALID' };
44
+ const path = pathOf(objectId);
45
+ if (!existsSync(path)) return { ok: false, code: 'OBJECT_NOT_FOUND' };
46
+ let bytes;
47
+ try { bytes = readFileSync(path); } catch { return { ok: false, code: 'OBJECT_UNREADABLE' }; }
48
+ const actual = sha256(bytes);
49
+ if (typeof expectSha256 === 'string' && expectSha256 !== '' && expectSha256 !== actual) {
50
+ return { ok: false, code: 'OBJECT_HASH_MISMATCH', detail: { expected: expectSha256, actual } };
51
+ }
52
+ return { ok: true, objectId, path, sha256: actual, bytes };
53
+ }
54
+
13
55
  return {
14
56
  kind: 'dir',
57
+ writeObject,
58
+ readObject,
59
+ // Backwards-compatible adapters for the pre-v2 callers: the object id is derived from the body.
15
60
  async pushObject({ sealed }) {
16
61
  if (sealed === null || typeof sealed !== 'object') return { ok: false, code: 'OBJECT_INVALID' };
17
62
  const body = JSON.stringify(sealed);
18
- const id = createHash('sha256').update(body, 'utf8').digest('hex');
19
- const path = join(root, id + '.json');
20
- writeFileSync(path, body, { encoding: 'utf8', mode: 0o600 });
21
- return { ok: true, objectId: id, path, bytes: Buffer.byteLength(body, 'utf8') };
63
+ const id = createHash('sha256').update(body, 'utf8').digest('hex').slice(0, 32);
64
+ const written = await writeObject({ objectId: id, body });
65
+ return written.ok === true ? { ok: true, objectId: id, path: written.path, bytes: written.bytes } : written;
22
66
  },
23
67
  async fetchObject({ objectId }) {
24
- if (typeof objectId !== 'string' || !/^[0-9a-f]{64}$/.test(objectId)) return { ok: false, code: 'OBJECT_ID_INVALID' };
25
- const path = join(root, objectId + '.json');
26
- if (!existsSync(path)) return { ok: false, code: 'OBJECT_NOT_FOUND' };
27
- return { ok: true, sealed: JSON.parse(readFileSync(path, 'utf8')), path };
68
+ const read = await readObject({ objectId });
69
+ if (read.ok !== true) return read;
70
+ try { return { ok: true, sealed: JSON.parse(read.bytes.toString('utf8')), path: read.path }; } catch { return { ok: false, code: 'OBJECT_UNREADABLE' }; }
28
71
  },
29
72
  async listObjects() {
30
- return readdirSync(root).filter((name) => name.endsWith('.json')).map((name) => name.replace(/\.json$/, ''));
73
+ return readdirSync(root).filter((name) => name.endsWith('.json') && OBJECT_ID.test(name.slice(0, -5))).map((name) => name.slice(0, -5));
74
+ },
75
+ removeObject(objectId) {
76
+ try { unlinkSync(pathOf(objectId)); return { ok: true }; } catch { return { ok: false, code: 'OBJECT_NOT_FOUND' }; }
31
77
  },
32
78
  };
33
79
  }
@@ -156,6 +156,12 @@ export function registerChatRoutes(ctx, deps) {
156
156
  * The session files are never written and nothing is injected into DSH: the transcript is read, the user's messages
157
157
  * are extracted, and only the new ones are published. A failure here must not disturb the panel or the route.
158
158
  */
159
+ const MIRROR_MAX_BYTES = 3500;
160
+ const truncateUtf8 = (value, max) => {
161
+ const bytes = Buffer.from(value, 'utf8');
162
+ if (bytes.length <= max) return value;
163
+ return bytes.subarray(0, max).toString('utf8').replace(/\uFFFD+$/, '') + ' …(已截断)';
164
+ };
159
165
  const mirrorOnce = async () => {
160
166
  const identity = await identityOf();
161
167
  if (identity.conversationKey === null) return;
@@ -165,7 +171,7 @@ export function registerChatRoutes(ctx, deps) {
165
171
  try { messages = extractUserMessages(decodeTranscript(readFileSync(file))); } catch { return; }
166
172
  for (const payload of sync.pending(messages)) {
167
173
  await postToRelay(identity, {
168
- text: payload.text,
174
+ text: truncateUtf8(payload.text, MIRROR_MAX_BYTES),
169
175
  machine_id: identity.machineId,
170
176
  machine_name: identity.machineName,
171
177
  origin: 'session',
@@ -2,11 +2,42 @@
2
2
  * 红线审计落盘:JSONL + fsync + 哈希链 + 校验。
3
3
  * 只接受元数据字段(内容字段直接拒绝),链断裂/篡改可被定位。
4
4
  */
5
- import { closeSync, fsyncSync, openSync, readFileSync, writeSync } from 'node:fs';
5
+ import { closeSync, existsSync, fsyncSync, openSync, readFileSync, statSync, unlinkSync, writeSync } from 'node:fs';
6
6
  import { createHash } from 'node:crypto';
7
7
 
8
8
  export const AUDIT_FORBIDDEN = new Set(['content', 'data', 'file', 'files', 'patch', 'stdout', 'stderr', 'diff', 'blob', 'bytes', 'text', 'base64']);
9
9
 
10
+ /** Recursively collect forbidden content keys: a nested payload must not slip past the top level. */
11
+ function contentKeysDeep(value, path = '', hits = []) {
12
+ if (value === null || typeof value !== 'object') return hits;
13
+ if (Array.isArray(value)) { value.forEach((entry, index) => contentKeysDeep(entry, path + '[' + index + ']', hits)); return hits; }
14
+ for (const [key, child] of Object.entries(value)) {
15
+ const keyPath = path === '' ? key : path + '.' + key;
16
+ if (AUDIT_FORBIDDEN.has(key.toLowerCase())) hits.push(keyPath);
17
+ else contentKeysDeep(child, keyPath, hits);
18
+ }
19
+ return hits;
20
+ }
21
+
22
+ const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
23
+
24
+ function withLock(file, fn) {
25
+ const lock = file + '.lock';
26
+ for (let attempt = 0; attempt < 50; attempt += 1) {
27
+ try {
28
+ const fd = openSync(lock, 'wx', 0o600);
29
+ try { writeSync(fd, String(process.pid)); } finally { closeSync(fd); }
30
+ try { return fn(); } finally { try { unlinkSync(lock); } catch { /* already gone */ } }
31
+ } catch (error) {
32
+ if (error?.code !== 'EEXIST') throw error;
33
+ try { if (Date.now() - statSync(lock).mtimeMs > 10_000) { unlinkSync(lock); continue; } } catch { /* raced */ }
34
+ sleep(20);
35
+ }
36
+ }
37
+ return { ok: false, code: 'AUDIT_BUSY' };
38
+ }
39
+
40
+
10
41
  function stable(value) {
11
42
  if (value === null || typeof value !== 'object') return JSON.stringify(value);
12
43
  if (Array.isArray(value)) return '[' + value.map(stable).join(',') + ']';
@@ -20,44 +51,46 @@ export function appendAudit(file, event) {
20
51
  if (event === null || typeof event !== 'object' || Array.isArray(event)) {
21
52
  return { ok: false, code: 'AUDIT_EVENT_INVALID' };
22
53
  }
23
- const bad = Object.keys(event).filter((key) => AUDIT_FORBIDDEN.has(key.toLowerCase()));
54
+ const bad = contentKeysDeep(event);
24
55
  if (bad.length > 0) return { ok: false, code: 'AUDIT_CONTENT_FORBIDDEN', detail: { keys: bad } };
25
-
26
- let previous = null;
27
- let seq = 1;
28
- try {
29
- const lines = readFileSync(file, 'utf8').split('\n').filter((line) => line.trim() !== '');
30
- if (lines.length > 0) {
31
- const last = JSON.parse(lines[lines.length - 1]);
32
- previous = last.record_sha256 ?? null;
33
- seq = Number(last.seq ?? lines.length) + 1;
56
+ return withLock(file, () => {
57
+ let previous = null;
58
+ let seq = 1;
59
+ if (existsSync(file)) {
60
+ const lines = readFileSync(file, 'utf8').split('\n').filter((line) => line.trim() !== '');
61
+ if (lines.length > 0) {
62
+ // A corrupt tail is refused, never treated as "a new file": resetting the chain hides tampering.
63
+ let last;
64
+ try { last = JSON.parse(lines[lines.length - 1]); } catch { return { ok: false, code: 'AUDIT_TAIL_CORRUPT', detail: { line: lines.length } }; }
65
+ previous = last.record_sha256 ?? null;
66
+ seq = Number(last.seq ?? lines.length) + 1;
67
+ }
34
68
  }
35
- } catch { /* 新文件 */ }
36
-
37
- const body = {
38
- v: 1,
39
- seq,
40
- at: event.at ?? new Date().toISOString(),
41
- event: typeof event.event === 'string' ? event.event : 'joint.event',
42
- plan_sha256: event.plan_sha256 ?? null,
43
- step_id: event.step_id ?? null,
44
- task_id: event.task_id ?? null,
45
- machine_id: event.machine_id ?? null,
46
- outcome: event.outcome ?? null,
47
- reason_code: event.reason_code ?? null,
48
- control_bytes: Number.isInteger(event.control_bytes) ? event.control_bytes : null,
49
- control_sha256: typeof event.control_sha256 === 'string' ? event.control_sha256 : null,
50
- prev_sha256: previous,
51
- };
52
- const record = { ...body, record_sha256: sha256(stable(body)) };
53
- const fd = openSync(file, 'a');
54
- try {
55
- writeSync(fd, JSON.stringify(record) + '\n');
56
- fsyncSync(fd);
57
- } finally {
58
- closeSync(fd);
59
- }
60
- return { ok: true, record };
69
+ const body = {
70
+ v: 1,
71
+ seq,
72
+ at: event.at ?? new Date().toISOString(),
73
+ event: typeof event.event === 'string' ? event.event : 'joint.event',
74
+ plan_sha256: event.plan_sha256 ?? null,
75
+ step_id: event.step_id ?? null,
76
+ task_id: event.task_id ?? null,
77
+ machine_id: event.machine_id ?? null,
78
+ outcome: event.outcome ?? null,
79
+ reason_code: event.reason_code ?? null,
80
+ control_bytes: Number.isInteger(event.control_bytes) ? event.control_bytes : null,
81
+ control_sha256: typeof event.control_sha256 === 'string' ? event.control_sha256 : null,
82
+ prev_sha256: previous,
83
+ };
84
+ const record = { ...body, record_sha256: sha256(stable(body)) };
85
+ const fd = openSync(file, 'a');
86
+ try {
87
+ writeSync(fd, JSON.stringify(record) + '\n');
88
+ fsyncSync(fd);
89
+ } finally {
90
+ closeSync(fd);
91
+ }
92
+ return { ok: true, record };
93
+ });
61
94
  }
62
95
 
63
96
  /** 校验整条链;返回第一处问题的 seq。 */
@@ -0,0 +1,102 @@
1
+ /**
2
+ * S5 MergeEngine: apply machine patches in order inside an integration worktree.
3
+ *
4
+ * Local git only. The engine never touches the target ref: `advanceTarget` is a separate,
5
+ * compare-and-swap step the operator runs only after the user approves the merged head. A conflict
6
+ * is a first-class refusal (`MERGE_CONFLICT`) — patches are not auto-resolved and force is never
7
+ * passed to git.
8
+ */
9
+ import { execFileSync } from 'node:child_process';
10
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+
14
+ const defaultRunGit = (args, cwd) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
15
+
16
+ function assertBaseCommit(repoDir, baseCommit, runGit) {
17
+ try { runGit(['cat-file', '-e', `${baseCommit}^{commit}`], repoDir); return true; } catch { return false; }
18
+ }
19
+
20
+ /** @returns {{ok: true, worktree: string, integration_head: string, applied: object[]} | {ok: false, code: string, detail?: object}} */
21
+ export function mergePatches({ repoDir, baseCommit, patches, runGit = defaultRunGit }) {
22
+ if (typeof repoDir !== 'string' || repoDir === '') return { ok: false, code: 'MERGE_REPO_REQUIRED' };
23
+ if (typeof baseCommit !== 'string' || !/^[0-9a-f]{7,40}$/.test(baseCommit)) return { ok: false, code: 'MERGE_BASE_INVALID' };
24
+ if (!Array.isArray(patches) || patches.length === 0) return { ok: false, code: 'MERGE_PATCHES_REQUIRED' };
25
+ if (assertBaseCommit(repoDir, baseCommit, runGit) !== true) return { ok: false, code: 'MERGE_BASE_MISMATCH', detail: { base_commit: baseCommit } };
26
+
27
+ const worktree = mkdtempSync(join(tmpdir(), 'w2m-merge-'));
28
+ const cleanup = () => {
29
+ try { runGit(['worktree', 'remove', '--force', worktree], repoDir); } catch { /* already gone */ }
30
+ try { rmSync(worktree, { recursive: true, force: true }); } catch { /* best effort */ }
31
+ };
32
+ try {
33
+ runGit(['worktree', 'add', '--detach', worktree, baseCommit], repoDir);
34
+ } catch (error) {
35
+ cleanup();
36
+ return { ok: false, code: 'MERGE_WORKTREE_FAILED', detail: { message: error instanceof Error ? error.message : String(error) } };
37
+ }
38
+
39
+ const applied = [];
40
+ try {
41
+ for (const patch of patches) {
42
+ if (typeof patch?.step_id !== 'string' || typeof patch?.patch !== 'string' || patch.patch === '') {
43
+ cleanup();
44
+ return { ok: false, code: 'MERGE_PATCH_INVALID', detail: { step_id: patch?.step_id ?? null } };
45
+ }
46
+ const file = join(worktree, '.w2m-patch-' + patch.step_id.replace(/[^A-Za-z0-9_-]/g, '_') + '.diff');
47
+ writeFileSync(file, patch.patch, 'utf8');
48
+ try {
49
+ runGit(['apply', '--3way', '--check', file], worktree);
50
+ } catch {
51
+ const files = (() => {
52
+ try { return runGit(['apply', '--check', '--numstat', file], worktree).split('\n').map((line) => line.split('\t').pop()).filter(Boolean); } catch { return []; }
53
+ })();
54
+ cleanup();
55
+ return { ok: false, code: 'MERGE_CONFLICT', detail: { step_id: patch.step_id, machine_id: patch.machine_id ?? null, files } };
56
+ }
57
+ try {
58
+ runGit(['apply', '--3way', file], worktree);
59
+ } catch {
60
+ const applyFailed = { step_id: patch.step_id, machine_id: patch.machine_id ?? null };
61
+ cleanup();
62
+ return { ok: false, code: 'MERGE_CONFLICT', detail: applyFailed };
63
+ }
64
+ runGit(['add', '-A'], worktree);
65
+ runGit(['commit', '-m', `w2m: ${patch.step_id} (${patch.machine_id ?? 'unknown'})`, '--no-verify'], worktree);
66
+ applied.push({ step_id: patch.step_id, machine_id: patch.machine_id ?? null });
67
+ }
68
+ const integrationHead = runGit(['rev-parse', 'HEAD'], worktree);
69
+ return { ok: true, worktree, integration_head: integrationHead, applied };
70
+ } catch (error) {
71
+ cleanup();
72
+ return { ok: false, code: 'MERGE_APPLY_FAILED', detail: { message: error instanceof Error ? error.message : String(error) } };
73
+ }
74
+ }
75
+
76
+ /** Compare-and-swap the target ref; force is impossible here because update-ref takes the old value. */
77
+ export function advanceTarget({ repoDir, targetRef, expectedOld, newCommit, rollbackRef, runGit = defaultRunGit }) {
78
+ if (typeof targetRef !== 'string' || !/^refs\/heads\/[\w./-]+$/.test(targetRef)) return { ok: false, code: 'MERGE_TARGET_INVALID' };
79
+ if (typeof newCommit !== 'string' || !/^[0-9a-f]{7,40}$/.test(newCommit)) return { ok: false, code: 'MERGE_COMMIT_INVALID' };
80
+ if (typeof expectedOld !== 'string' || !/^[0-9a-f]{7,40}$/.test(expectedOld)) return { ok: false, code: 'MERGE_OLD_REQUIRED' };
81
+ const current = (() => { try { return runGit(['rev-parse', targetRef], repoDir); } catch { return null; } })();
82
+ if (current !== expectedOld) return { ok: false, code: 'MERGE_BASE_MISMATCH', detail: { expected: expectedOld, current } };
83
+ if (typeof rollbackRef === 'string' && rollbackRef !== '') {
84
+ if (!/^refs\/w2m\/rollback\/[\w./-]+$/.test(rollbackRef)) return { ok: false, code: 'MERGE_ROLLBACK_REF_INVALID' };
85
+ runGit(['update-ref', rollbackRef, expectedOld], repoDir);
86
+ }
87
+ try {
88
+ runGit(['update-ref', targetRef, newCommit, expectedOld], repoDir);
89
+ } catch (error) {
90
+ return { ok: false, code: 'MERGE_CAS_FAILED', detail: { message: error instanceof Error ? error.message : String(error) } };
91
+ }
92
+ return { ok: true, target_ref: targetRef, head: newCommit, rollback_ref: typeof rollbackRef === 'string' && rollbackRef !== '' ? rollbackRef : null };
93
+ }
94
+
95
+ /** Remove an integration worktree (after a merge, a refusal or an abort). */
96
+ export function abortMerge({ repoDir, worktree, runGit = defaultRunGit }) {
97
+ if (typeof worktree === 'string' && worktree !== '') {
98
+ try { runGit(['worktree', 'remove', '--force', worktree], repoDir); } catch { /* already gone */ }
99
+ try { rmSync(worktree, { recursive: true, force: true }); } catch { /* best effort */ }
100
+ }
101
+ return { ok: true };
102
+ }
@@ -1,20 +1,54 @@
1
1
  /**
2
2
  * Joint-dev relay dispatch (v2 S2).
3
3
  *
4
- * Turns an approved plan into relay tasks: one step = one broadcast task pinned to the step's
5
- * machine via `executor_machine_id`. Capabilities are negotiated first (an older relay that cannot
6
- * honour the pin is refused, never assumed), and the leases the relay returns are checked: a lease
7
- * on any machine other than the pinned one cancels the task and fails the dispatch.
8
- *
9
- * Only the control plane is written here: plan digest, step id, command hash and the argv the
10
- * executor needs. Nothing is uploaded; artifacts are the separate sink path (S4).
4
+ * Turns an approved plan into relay tasks:
5
+ * · steps are dispatched in dependency order; a step's `depends_on` is mapped to the task ids of
6
+ * the steps it waits for, so the relay's own dependency gate keeps them from running together;
7
+ * · task ids are deterministic (`jd-<sha256(plan|step)>`), so retrying a dispatch does not create
8
+ * a second task — a relay `TASK_EXISTS` answer is reported as "already dispatched";
9
+ * · every control envelope is checked by `inspectControlMessage` (16 KiB, field whitelist, no
10
+ * data-plane bytes) and the attempt/outcome is written to the local audit file;
11
+ * · capabilities are negotiated first (an older relay is refused), and the leases the relay
12
+ * returns are checked: a lease on a machine that was not pinned cancels the task and fails.
11
13
  */
14
+ import { createHash } from 'node:crypto';
15
+
16
+ import { appendAudit } from './audit-file.mjs';
17
+ import { inspectControlMessage } from './redline.mjs';
12
18
  import { readRelayCapabilities, negotiateFeatures } from '../relay-capabilities.mjs';
13
19
 
20
+ const sha256 = (text) => createHash('sha256').update(text, 'utf8').digest('hex');
14
21
  const trimBase = (value) => (typeof value === 'string' ? value.replace(/\/+$/, '') : '');
22
+ const taskIdOf = (planSha, stepId) => 'jd-' + sha256(planSha + '|' + stepId).slice(0, 26);
23
+
24
+ /** Kahn order over the plan's dependency graph; unknown dependencies are a refusal, not a guess. */
25
+ function orderSteps(steps) {
26
+ const byId = new Map(steps.map((step) => [step.step_id, step]));
27
+ for (const step of steps) {
28
+ for (const dep of step.depends_on ?? []) {
29
+ if (!byId.has(dep)) return { ok: false, code: 'PLAN_DEPENDENCY_UNKNOWN', detail: { step_id: step.step_id, depends_on: dep } };
30
+ }
31
+ }
32
+ const placed = new Set();
33
+ const ordered = [];
34
+ let remaining = steps.slice();
35
+ while (remaining.length > 0) {
36
+ const ready = remaining.filter((step) => (step.depends_on ?? []).every((dep) => placed.has(dep)));
37
+ if (ready.length === 0) return { ok: false, code: 'PLAN_DEPENDENCY_CYCLE' };
38
+ for (const step of ready) { ordered.push(step); placed.add(step.step_id); }
39
+ remaining = remaining.filter((step) => !placed.has(step.step_id));
40
+ }
41
+ return { ok: true, steps: ordered };
42
+ }
43
+
44
+ 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
+ }
15
48
 
16
49
  /**
17
- * @param {{rabbitUrl: string, token: string, plan: object, approval: object, fetchImpl?: Function, timeoutMs?: number, includeOffline?: boolean}} input
50
+ * @param {{rabbitUrl: string, token: string, plan: object, approval: object, fetchImpl?: Function,
51
+ * timeoutMs?: number, includeOffline?: boolean, auditFile?: string}} input
18
52
  */
19
53
  export async function dispatchPlanToRelay(input) {
20
54
  const rabbitUrl = trimBase(input?.rabbitUrl);
@@ -24,12 +58,39 @@ export async function dispatchPlanToRelay(input) {
24
58
  if (rabbitUrl === '') return { ok: false, code: 'RELAY_URL_REQUIRED' };
25
59
  if (token === '') return { ok: false, code: 'OPERATOR_TOKEN_MISSING' };
26
60
  if (plan === null || typeof plan.plan_sha256 !== 'string') return { ok: false, code: 'PLAN_INVALID' };
61
+ if (!Array.isArray(plan.steps) || plan.steps.length === 0) return { ok: false, code: 'PLAN_INVALID' };
27
62
  if (approval === null || typeof approval.approval_sha256 !== 'string') return { ok: false, code: 'APPROVAL_REQUIRED' };
63
+ for (const step of plan.steps) {
64
+ if (typeof step?.step_id !== 'string' || step.step_id === '' || typeof step?.machine_id !== 'string' || step.machine_id === '' || !Array.isArray(step?.command_argv) || step.command_argv.length === 0) {
65
+ return { ok: false, code: 'PLAN_STEP_INVALID', detail: { step_id: step?.step_id ?? null } };
66
+ }
67
+ }
68
+ const ordered = orderSteps(plan.steps);
69
+ if (ordered.ok !== true) return { ok: false, code: ordered.code, detail: ordered.detail ?? null };
28
70
 
29
71
  const fetchImpl = input?.fetchImpl ?? globalThis.fetch;
30
72
  const timeoutMs = Number.isInteger(input?.timeoutMs) ? input.timeoutMs : 60_000;
31
73
  const signalOf = () => (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(timeoutMs) : undefined);
32
74
  const headers = { authorization: `Bearer ${token}`, 'content-type': 'application/json', accept: 'application/json' };
75
+ const auditFile = typeof input?.auditFile === 'string' ? input.auditFile : '';
76
+
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;
82
+ try {
83
+ const response = await fetchImpl(`${rabbitUrl}/v1/devices`, { headers: { authorization: `Bearer ${input.deviceToken}`, accept: 'application/json' }, signal: signalOf() });
84
+ fleet = response.ok === true ? await response.json() : null;
85
+ } 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' } };
87
+ const byId = new Map(fleet.devices.map((device) => [device.machine_id, device]));
88
+ const skew = ordered.steps
89
+ .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 } };
92
+ }
93
+ }
33
94
 
34
95
  const capabilities = await readRelayCapabilities({ rabbitUrl, fetchImpl, timeoutMs });
35
96
  const negotiation = negotiateFeatures(capabilities);
@@ -37,9 +98,13 @@ export async function dispatchPlanToRelay(input) {
37
98
  return { ok: false, code: negotiation.code ?? 'RELAY_CAPABILITIES_MISSING', detail: { missing: negotiation.missing ?? null, relay: rabbitUrl } };
38
99
  }
39
100
 
101
+ const dispatched = new Map(); // step_id -> task_id
40
102
  const tasks = [];
41
- for (const step of plan.steps) {
103
+ for (const step of ordered.steps) {
104
+ const taskId = taskIdOf(plan.plan_sha256, step.step_id);
105
+ const dependsOn = (step.depends_on ?? []).map((dep) => dispatched.get(dep)).filter((id) => typeof id === 'string');
42
106
  const body = {
107
+ task_id: taskId,
43
108
  mode: 'broadcast',
44
109
  executor_machine_id: step.machine_id,
45
110
  command_argv: step.command_argv,
@@ -48,40 +113,57 @@ export async function dispatchPlanToRelay(input) {
48
113
  plan_sha256: plan.plan_sha256,
49
114
  approval_sha256: approval.approval_sha256,
50
115
  step_id: step.step_id,
116
+ ...(dependsOn.length > 0 ? { depends_on: dependsOn } : {}),
117
+ ...(Array.isArray(step.write_scope) && step.write_scope.length > 0 ? { write: true, write_scope: step.write_scope } : {}),
51
118
  ...(step.timeout_ms === undefined ? {} : { timeout_ms: step.timeout_ms }),
52
119
  ...(input?.includeOffline === false ? {} : { include_offline: true }),
53
120
  };
121
+ const check = inspectControlMessage({ v: 1, type: 'joint.dispatch', body });
122
+ if (check.ok !== true) {
123
+ writeAudit(auditFile, { event: 'joint.dispatch.refused', plan_sha256: plan.plan_sha256, step_id: step.step_id, reason_code: check.code ?? 'CONTROL_REFUSED' });
124
+ return { ok: false, code: check.code ?? 'CONTROL_REFUSED', detail: check.detail ?? null, partial: tasks.length > 0, tasks };
125
+ }
54
126
  let response;
55
127
  try {
56
128
  response = await fetchImpl(`${rabbitUrl}/v1/task`, { method: 'POST', headers, body: JSON.stringify(body), signal: signalOf() });
57
129
  } catch (error) {
58
- return { ok: false, code: 'RELAY_UNREACHABLE', detail: error instanceof Error ? error.message : String(error) };
130
+ writeAudit(auditFile, { event: 'joint.dispatch.failed', plan_sha256: plan.plan_sha256, step_id: step.step_id, task_id: taskId, reason_code: 'RELAY_UNREACHABLE' });
131
+ return { ok: false, code: 'RELAY_UNREACHABLE', detail: error instanceof Error ? error.message : String(error), partial: tasks.length > 0, tasks };
59
132
  }
60
133
  const doc = await response.json().catch(() => null);
134
+ if (response.status === 409 && doc?.error?.code === 'TASK_EXISTS') {
135
+ writeAudit(auditFile, { event: 'joint.dispatch.already', plan_sha256: plan.plan_sha256, step_id: step.step_id, task_id: taskId });
136
+ dispatched.set(step.step_id, taskId);
137
+ tasks.push({ step_id: step.step_id, task_id: taskId, already: true, leases: [] });
138
+ continue;
139
+ }
61
140
  if (response.ok !== true) {
62
- return { ok: false, code: doc?.error?.code ?? 'RELAY_REFUSED', status: response.status, detail: doc?.error ?? doc };
141
+ writeAudit(auditFile, { event: 'joint.dispatch.refused', plan_sha256: plan.plan_sha256, step_id: step.step_id, task_id: taskId, reason_code: doc?.error?.code ?? 'RELAY_REFUSED' });
142
+ return { ok: false, code: doc?.error?.code ?? 'RELAY_REFUSED', status: response.status, detail: doc?.error ?? doc, partial: tasks.length > 0, tasks };
63
143
  }
64
- const taskId = doc?.task_id ?? doc?.task?.task_id ?? null;
144
+ const returnedTaskId = doc?.task_id ?? doc?.task?.task_id ?? taskId;
65
145
  const leases = Array.isArray(doc?.leases) ? doc.leases : [];
66
146
  const foreign = leases.filter((lease) => lease?.state !== 'observing' && lease?.machine_id !== step.machine_id);
67
147
  if (foreign.length > 0) {
68
- if (taskId !== null) {
69
- try {
70
- await fetchImpl(`${rabbitUrl}/v1/tasks/${encodeURIComponent(taskId)}/cancel`, {
71
- method: 'POST', headers, body: JSON.stringify({ reason: 'SELECTION_NOT_HONOURED' }), signal: signalOf(),
72
- });
73
- } catch { /* the refusal below is still the answer */ }
74
- }
148
+ try {
149
+ await fetchImpl(`${rabbitUrl}/v1/tasks/${encodeURIComponent(returnedTaskId)}/cancel`, {
150
+ method: 'POST', headers, body: JSON.stringify({ reason: 'SELECTION_NOT_HONOURED' }), signal: signalOf(),
151
+ });
152
+ } catch { /* the refusal below is still the answer */ }
153
+ writeAudit(auditFile, { event: 'joint.dispatch.not_honoured', plan_sha256: plan.plan_sha256, step_id: step.step_id, task_id: returnedTaskId });
75
154
  return {
76
155
  ok: false,
77
156
  code: 'SELECTION_NOT_HONOURED',
78
- detail: { step_id: step.step_id, named: step.machine_id, leased: foreign.map((lease) => lease.machine_id), task_id: taskId },
157
+ detail: { step_id: step.step_id, named: step.machine_id, leased: foreign.map((lease) => lease.machine_id), task_id: returnedTaskId },
158
+ partial: tasks.length > 0,
79
159
  tasks,
80
160
  };
81
161
  }
162
+ writeAudit(auditFile, { event: 'joint.dispatch.sent', plan_sha256: plan.plan_sha256, step_id: step.step_id, task_id: returnedTaskId, machine_id: step.machine_id });
163
+ dispatched.set(step.step_id, returnedTaskId);
82
164
  tasks.push({
83
165
  step_id: step.step_id,
84
- task_id: taskId,
166
+ task_id: returnedTaskId,
85
167
  leases: leases.map((lease) => ({ machine_id: lease?.machine_id ?? null, state: lease?.state ?? null })),
86
168
  });
87
169
  }