@twinsearth/w2m-dsh-plugin 1.9.17 → 1.9.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +6 -0
- package/README.md +3 -3
- package/bin/w2m-joint.mjs +1 -1
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/1.9.19/RELEASE-1919.json +21 -0
- package/docs/acceptance-ops/2026-10-28/AG-SMOKE.json +24 -0
- package/lib/client.js +185 -105
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/src/agent/agent.mjs +6 -1
- package/src/agent/sinks/dir-sink.mjs +59 -13
- package/src/plugin/fleet-chat-route.mjs +7 -1
- package/src/plugin/joint-dev/audit-file.mjs +70 -37
- package/src/plugin/joint-dev/merge-engine.mjs +102 -0
- package/src/plugin/joint-dev/relay-dispatch.mjs +103 -21
- package/src/plugin/tools-joint.mjs +97 -0
- package/src/plugin/tools.mjs +5 -2
- package/src/relay/fleet-messages.mjs +22 -0
- package/src/relay/report.mjs +35 -0
- package/src/relay/server.mjs +2043 -2038
- package/src/relay/state.mjs +11 -0
- package/src/util/artifact-ref.mjs +88 -0
- package/src/util/redline-core.mjs +2 -1
|
@@ -1,33 +1,79 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 目录 sink
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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,
|
|
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
|
|
20
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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.
|
|
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 =
|
|
54
|
+
const bad = contentKeysDeep(event);
|
|
24
55
|
if (bad.length > 0) return { ok: false, code: 'AUDIT_CONTENT_FORBIDDEN', detail: { keys: bad } };
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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:
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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:
|
|
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:
|
|
166
|
+
task_id: returnedTaskId,
|
|
85
167
|
leases: leases.map((lease) => ({ machine_id: lease?.machine_id ?? null, state: lease?.state ?? null })),
|
|
86
168
|
});
|
|
87
169
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S2-d: the joint-dev tool surface.
|
|
3
|
+
*
|
|
4
|
+
* Only the two inert halves live here: a plan can be created/read/listed, and a status can be read.
|
|
5
|
+
* Approval and dispatch stay in `w2m-joint` (operator CLI) and the settings page — a model can never
|
|
6
|
+
* sign an approval or send a command, which is the decision-2 boundary.
|
|
7
|
+
*/
|
|
8
|
+
import { buildPlan } from './joint-dev/dispatch.mjs';
|
|
9
|
+
import { assertDispatchAllowed, listPlans, loadApproval, loadPlan, savePlan } from './joint-dev/plan-store.mjs';
|
|
10
|
+
import { loadOperatorKey } from './operator-key.mjs';
|
|
11
|
+
|
|
12
|
+
const json = (value) => JSON.stringify(value, null, 2);
|
|
13
|
+
|
|
14
|
+
export function registerJointTools(ctx, { defineTool, stateDir, machineName, output }) {
|
|
15
|
+
const requireStateDir = () => {
|
|
16
|
+
if (typeof stateDir === 'string' && stateDir !== '') return stateDir;
|
|
17
|
+
return null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
ctx.tools.register(defineTool({
|
|
21
|
+
name: 'w2m_joint_plan',
|
|
22
|
+
description:
|
|
23
|
+
'Joint-dev plan store (metadata only). action=create builds a canonical plan from steps_json ' +
|
|
24
|
+
'(an array of {step_id, machine_id, command_argv, depends_on?, write_scope?}), action=list lists ' +
|
|
25
|
+
'saved plans, action=get reads one. Nothing is approved or dispatched by this tool.',
|
|
26
|
+
parameters: {
|
|
27
|
+
action: { type: 'string', enum: ['create', 'list', 'get'], description: 'create | list | get (default list)' },
|
|
28
|
+
steps_json: { type: 'string', description: 'action=create: JSON array of steps' },
|
|
29
|
+
base_commit: { type: 'string', description: 'action=create: base commit the plan is anchored to' },
|
|
30
|
+
plan_sha256: { type: 'string', description: 'action=get: the 64-hex plan id' },
|
|
31
|
+
},
|
|
32
|
+
output: output ?? { type: 'json' },
|
|
33
|
+
async execute(args) {
|
|
34
|
+
const dir = requireStateDir();
|
|
35
|
+
if (dir === null) return json({ ok: false, code: 'JOINT_STATE_UNAVAILABLE', detail: 'no stateDir configured for this plugin' });
|
|
36
|
+
const action = typeof args?.action === 'string' ? args.action : 'list';
|
|
37
|
+
if (action === 'list') return json({ ok: true, ...listPlans({ stateDir: dir }) });
|
|
38
|
+
if (action === 'get') {
|
|
39
|
+
const loaded = loadPlan({ stateDir: dir, planSha: String(args?.plan_sha256 ?? '') });
|
|
40
|
+
return json(loaded);
|
|
41
|
+
}
|
|
42
|
+
if (action === 'create') {
|
|
43
|
+
let steps;
|
|
44
|
+
try { steps = JSON.parse(String(args?.steps_json ?? '')); } catch { return json({ ok: false, code: 'PLAN_STEPS_INVALID' }); }
|
|
45
|
+
const built = buildPlan({ base_commit: typeof args?.base_commit === 'string' ? args.base_commit : undefined, steps });
|
|
46
|
+
if (built.ok !== true) return json(built);
|
|
47
|
+
const saved = savePlan({ stateDir: dir, plan: built.plan });
|
|
48
|
+
if (saved.ok !== true) return json(saved);
|
|
49
|
+
return json({
|
|
50
|
+
ok: true,
|
|
51
|
+
plan_sha256: built.plan.plan_sha256,
|
|
52
|
+
steps: built.plan.steps.length,
|
|
53
|
+
machines: [...new Set(built.plan.steps.map((step) => step.machine_id))],
|
|
54
|
+
path: saved.path,
|
|
55
|
+
reused: saved.reused,
|
|
56
|
+
initiator: machineName ?? null,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return json({ ok: false, code: 'PLAN_ACTION_INVALID', detail: { action } });
|
|
60
|
+
},
|
|
61
|
+
presentCall: () => ({ card: 'generic', title: 'joint plan', kind: 'read', rawInput: {} }),
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
ctx.tools.register(defineTool({
|
|
65
|
+
name: 'w2m_joint_status',
|
|
66
|
+
description:
|
|
67
|
+
'Read one joint-dev plan and its approval state (metadata only): approval strength, expiry, ' +
|
|
68
|
+
'whether a signed approval allows dispatch, and the per-step machine/command hash. It never ' +
|
|
69
|
+
'returns argv, output or file bytes, and it cannot approve or dispatch.',
|
|
70
|
+
parameters: {
|
|
71
|
+
plan_sha256: { type: 'string', description: 'the 64-hex plan id' },
|
|
72
|
+
},
|
|
73
|
+
output: output ?? { type: 'json' },
|
|
74
|
+
async execute(args) {
|
|
75
|
+
const dir = requireStateDir();
|
|
76
|
+
if (dir === null) return json({ ok: false, code: 'JOINT_STATE_UNAVAILABLE' });
|
|
77
|
+
const planSha = String(args?.plan_sha256 ?? '');
|
|
78
|
+
const plan = loadPlan({ stateDir: dir, planSha });
|
|
79
|
+
if (plan.ok !== true) return json(plan);
|
|
80
|
+
const key = loadOperatorKey();
|
|
81
|
+
const trusted = key.ok === true ? [key.key_id] : [];
|
|
82
|
+
const gate = assertDispatchAllowed({ stateDir: dir, planSha, requireSignature: true, trustedOperatorKeyIds: trusted });
|
|
83
|
+
const approval = loadApproval({ stateDir: dir, planSha, trustedOperatorKeyIds: trusted });
|
|
84
|
+
return json({
|
|
85
|
+
ok: true,
|
|
86
|
+
plan_sha256: planSha,
|
|
87
|
+
steps: plan.plan.steps.map((step) => ({ step_id: step.step_id, machine_id: step.machine_id, command_hash: step.command_hash })),
|
|
88
|
+
approval: approval.ok === true
|
|
89
|
+
? { strength: approval.strength, approver: approval.approval.approver, expires_at: approval.approval.expires_at, key_id: approval.approval.operator_key?.key_id ?? null }
|
|
90
|
+
: { state: approval.code },
|
|
91
|
+
dispatch_allowed: gate.ok === true,
|
|
92
|
+
blocked_by: gate.ok === true ? null : gate.code,
|
|
93
|
+
});
|
|
94
|
+
},
|
|
95
|
+
presentCall: () => ({ card: 'generic', title: 'joint status', kind: 'read', rawInput: {} }),
|
|
96
|
+
}));
|
|
97
|
+
}
|