@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
@@ -0,0 +1,118 @@
1
+ /**
2
+ * `/api/w2m/dataplane-sink` (A3): the settings-page half of the S4 data-plane authorization.
3
+ *
4
+ * GET -> { ok, configured, kind, root|host/user/dir, saved_at }
5
+ * POST -> { ok, configured: true, kind, ... } and writes <stateDir>/dataplane/sink.json (0600)
6
+ *
7
+ * Authorization is a LOCAL USER action through this host route. Model/tool parameters can never
8
+ * authorize a sink: the tool schemas do not expose this path, and this route does not accept an
9
+ * operator/device token as the authority (the host's same-origin fence is the boundary).
10
+ */
11
+ import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs';
12
+ import { dirname, isAbsolute, join, relative } from 'node:path';
13
+ import { randomBytes } from 'node:crypto';
14
+
15
+ export const DATAPLANE_SINK_ROUTE_PATH = '/api/w2m/dataplane-sink';
16
+ const NO_STORE = { 'cache-control': 'no-store' };
17
+ const KINDS = new Set(['dir', 'lan-git', 'ssh']);
18
+ const CREDENTIAL_KEYS = /^(?:password|passphrase|key|private_key|token|secret|credential|identity)$/i;
19
+
20
+ function configPath(stateDir) {
21
+ return join(stateDir, 'dataplane', 'sink.json');
22
+ }
23
+
24
+ function readConfig(stateDir) {
25
+ const file = configPath(stateDir);
26
+ if (!existsSync(file)) return null;
27
+ try { return JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); } catch { return null; }
28
+ }
29
+
30
+ function insideStateDir(stateDir, target) {
31
+ // Compare canonical paths: Windows temp dirs come back as RUNNER~1 here and runneradmin in the
32
+ // realpath, and macOS /var is a symlink to /private/var, so a raw prefix test lets an inside path
33
+ // through on CI even though it is inside the state dir.
34
+ let base = stateDir;
35
+ try { base = realpathSync.native ? realpathSync.native(stateDir) : realpathSync(stateDir); } catch { /* keep raw */ }
36
+ const rel = relative(base, target);
37
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
38
+ }
39
+
40
+ /** Strip anything that looks like a secret; the file only holds references, never credentials. */
41
+ function publicView(config) {
42
+ if (config === null) return { configured: false, kind: null };
43
+ if (config.kind === 'ssh') return { configured: true, kind: config.kind, host: config.host ?? null, user: config.user ?? null, dir: config.dir ?? null, port: config.port ?? null, saved_at: config.saved_at ?? null };
44
+ return { configured: true, kind: config.kind, root: config.root ?? null, remote: config.remote ?? null, branch: config.branch ?? null, saved_at: config.saved_at ?? null };
45
+ }
46
+
47
+ /**
48
+ * @param {object} ctx Cordis context
49
+ * @param {{stateDir?: () => string|null}} [deps]
50
+ */
51
+ export function registerDataplaneSinkRoute(ctx, deps = {}) {
52
+ const status = { mounted: false, reason: 'the host has no Connection service' };
53
+ if (typeof ctx?.inject !== 'function') return status;
54
+ ctx.inject(['connection'], (webCtx) => {
55
+ const registry = webCtx?.connection?.fetch;
56
+ if (registry === undefined || typeof registry.register !== 'function') {
57
+ status.reason = 'the connection service carries no fetch registry';
58
+ return;
59
+ }
60
+ const stateDirOf = () => (typeof deps?.stateDir === 'function' ? deps.stateDir() : null);
61
+ registry.register({
62
+ path: DATAPLANE_SINK_ROUTE_PATH,
63
+ methods: ['GET', 'POST'],
64
+ requestBody: 'buffered',
65
+ fetch: async (request) => {
66
+ const stateDir = stateDirOf();
67
+ if (typeof stateDir !== 'string' || stateDir === '') {
68
+ return Response.json({ ok: false, code: 'DATAPLANE_STATE_UNAVAILABLE' }, { status: 503, headers: NO_STORE });
69
+ }
70
+ if (request.method === 'GET') {
71
+ return Response.json({ ok: true, ...publicView(readConfig(stateDir)) }, { headers: NO_STORE });
72
+ }
73
+ let body = null;
74
+ try { body = await request.json(); } catch { /* refused below */ }
75
+ if (body === null || typeof body !== 'object') return Response.json({ ok: false, code: 'SINK_BODY_REQUIRED' }, { status: 400, headers: NO_STORE });
76
+ for (const key of Object.keys(body)) {
77
+ if (CREDENTIAL_KEYS.test(key)) return Response.json({ ok: false, code: 'SINK_CREDENTIALS_NOT_ACCEPTED', detail: key }, { status: 400, headers: NO_STORE });
78
+ }
79
+ const kind = typeof body.kind === 'string' ? body.kind : '';
80
+ if (!KINDS.has(kind)) return Response.json({ ok: false, code: 'SINK_KIND_INVALID' }, { status: 400, headers: NO_STORE });
81
+ const config = { v: 1, kind, saved_at: new Date().toISOString(), approved_by: 'local-user' };
82
+ if (kind === 'ssh') {
83
+ if (typeof body.host !== 'string' || !/^[A-Za-z0-9.-]{1,253}$/.test(body.host)) return Response.json({ ok: false, code: 'SINK_SSH_HOST_INVALID' }, { status: 400, headers: NO_STORE });
84
+ if (typeof body.user !== 'string' || !/^[A-Za-z0-9._-]{1,64}$/.test(body.user)) return Response.json({ ok: false, code: 'SINK_SSH_USER_INVALID' }, { status: 400, headers: NO_STORE });
85
+ if (typeof body.dir !== 'string' || !isAbsolute(body.dir)) return Response.json({ ok: false, code: 'SINK_SSH_DIR_INVALID' }, { status: 400, headers: NO_STORE });
86
+ if (body.port !== undefined && !(Number.isInteger(body.port) && body.port >= 1 && body.port <= 65535)) return Response.json({ ok: false, code: 'SINK_SSH_PORT_INVALID' }, { status: 400, headers: NO_STORE });
87
+ config.host = body.host;
88
+ config.user = body.user;
89
+ config.dir = body.dir;
90
+ if (body.port !== undefined) config.port = body.port;
91
+ } else {
92
+ if (typeof body.root !== 'string' || !isAbsolute(body.root)) return Response.json({ ok: false, code: 'SINK_ROOT_INVALID' }, { status: 400, headers: NO_STORE });
93
+ const resolved = realpathSync.native ? (() => { try { return realpathSync.native(body.root); } catch { return null; } })() : null;
94
+ if (resolved === null) return Response.json({ ok: false, code: 'SINK_ROOT_NOT_FOUND' }, { status: 400, headers: NO_STORE });
95
+ const stat = statSync(resolved);
96
+ if (!stat.isDirectory()) return Response.json({ ok: false, code: 'SINK_ROOT_NOT_DIRECTORY' }, { status: 400, headers: NO_STORE });
97
+ if (insideStateDir(stateDir, resolved)) return Response.json({ ok: false, code: 'SINK_ROOT_INSIDE_STATE' }, { status: 400, headers: NO_STORE });
98
+ if (kind === 'lan-git' && !existsSync(join(resolved, '.git'))) return Response.json({ ok: false, code: 'SINK_GIT_NOT_A_REPO' }, { status: 400, headers: NO_STORE });
99
+ config.root = resolved;
100
+ if (kind === 'lan-git') {
101
+ if (typeof body.remote === 'string' && body.remote !== '') config.remote = body.remote;
102
+ if (typeof body.branch === 'string' && body.branch !== '') config.branch = body.branch;
103
+ }
104
+ }
105
+ const file = configPath(stateDir);
106
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
107
+ const tmp = file + '.tmp-' + process.pid + '-' + randomBytes(4).toString('hex');
108
+ writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
109
+ renameSync(tmp, file);
110
+ try { chmodSync(file, 0o600); } catch { /* Windows ACL */ }
111
+ return Response.json({ ok: true, ...publicView(config) }, { headers: NO_STORE });
112
+ },
113
+ });
114
+ status.mounted = true;
115
+ status.reason = null;
116
+ });
117
+ return status;
118
+ }
@@ -23,6 +23,13 @@ import {
23
23
  } from './session-watch.mjs';
24
24
  import { createSessionSync } from './session-sync.mjs';
25
25
 
26
+ /** Session prompts are mirrored only when the operator opts in; the default is off. */
27
+ export const DEFAULT_MIRROR_MODE = 'off';
28
+ export function mirrorModeOf(cfg) {
29
+ const mode = cfg?.fleetChatMirror;
30
+ return mode === 'plaintext' || mode === 'encrypted' ? mode : DEFAULT_MIRROR_MODE;
31
+ }
32
+
26
33
 
27
34
  /**
28
35
  * The newest transcript for a cwd, or null. Reads directory listings and stat only -- this module never opens a
@@ -158,12 +165,16 @@ export function registerChatRoutes(ctx, deps) {
158
165
  * are extracted, and only the new ones are published. A failure here must not disturb the panel or the route.
159
166
  */
160
167
  const MIRROR_MAX_BYTES = 3500;
168
+
161
169
  const truncateUtf8 = (value, max) => {
162
170
  const bytes = Buffer.from(value, 'utf8');
163
171
  if (bytes.length <= max) return value;
164
172
  return bytes.subarray(0, max).toString('utf8').replace(/\uFFFD+$/, '') + ' …(已截断)';
165
173
  };
166
174
  const mirrorOnce = async () => {
175
+ const mode = mirrorModeOf(deps.cfg());
176
+ if (mode === 'off') return;
177
+ if (mode === 'encrypted') return; // encrypted mirroring lands with the AEAD v2 slice
167
178
  const identity = await identityOf();
168
179
  if (identity.conversationKey === null) return;
169
180
  const file = newestTranscript(projectDirOf());
@@ -7,6 +7,8 @@
7
7
  */
8
8
  import { createHash } from 'node:crypto';
9
9
 
10
+ import { computeCommandHash, normalizeCwdRel, SHELL_ID_DIRECT } from '../../relay/state.mjs';
11
+
10
12
  import { appendAudit } from './audit-file.mjs';
11
13
  import { createAudit, inspectControlMessage } from './redline.mjs';
12
14
 
@@ -80,7 +82,8 @@ export function buildPlan(input) {
80
82
  step_id: step.step_id,
81
83
  machine_id: step.machine_id,
82
84
  command_argv: step.command_argv.slice(0, 32),
83
- command_hash: sha256(JSON.stringify(step.command_argv)),
85
+ cwd_rel: normalizeCwdRel(step.cwd_rel),
86
+ command_hash: computeCommandHash(step.command_argv, SHELL_ID_DIRECT, normalizeCwdRel(step.cwd_rel)),
84
87
  write_scope: Array.isArray(step.write_scope) ? step.write_scope.slice(0, 8) : [],
85
88
  depends_on: Array.isArray(step.depends_on) ? step.depends_on.slice(0, 8) : [],
86
89
  timeout_ms: Number.isInteger(step.timeout_ms) ? step.timeout_ms : null,
@@ -101,6 +104,7 @@ export function buildControlEnvelopes(plan) {
101
104
  base_commit: plan.base_commit,
102
105
  command_argv: step.command_argv,
103
106
  command_hash: step.command_hash,
107
+ cwd_rel: step.cwd_rel ?? '.',
104
108
  write_scope: step.write_scope,
105
109
  depends_on: step.depends_on,
106
110
  ...(step.timeout_ms === null ? {} : { timeout_ms: step.timeout_ms }),
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Merge approval (A4/D4): the second human signature in the joint-dev SOP.
3
+ *
4
+ * A plan approval authorises *sending work*; a merge approval authorises *moving a branch*. It binds
5
+ * the plan, repository, base commit, target ref, the old value the CAS will replace, and the exact
6
+ * integration head that was reviewed. Signed with the same operator Ed25519 key, TTL-capped, and
7
+ * consumed once (O_EXCL) so a replay cannot move a second branch.
8
+ */
9
+ import { createHash, randomBytes } from 'node:crypto';
10
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+
13
+ import { ensureOperatorKey, keyIdOf, signApprovalPayload, verifyApprovalPayload } from '../operator-key.mjs';
14
+
15
+ const HEX64 = /^[0-9a-f]{64}$/;
16
+ const SHA = /^[0-9a-f]{7,40}$/;
17
+ const sha256 = (text) => createHash('sha256').update(text, 'utf8').digest('hex');
18
+
19
+ export function bodyOf(input) {
20
+ const approvedAtIn = typeof input.approvedAt === 'string' ? input.approvedAt : (typeof input.approved_at === 'string' ? input.approved_at : null);
21
+ const expiresAtIn = typeof input.expiresAt === 'string' ? input.expiresAt : (typeof input.expires_at === 'string' ? input.expires_at : null);
22
+ const now = Number.isInteger(input.now) ? input.now : (approvedAtIn !== null ? Date.parse(approvedAtIn) : Date.now());
23
+ const approvedAt = approvedAtIn !== null ? approvedAtIn : new Date(now).toISOString();
24
+ const expiresAt = expiresAtIn !== null ? expiresAtIn : new Date(now + (Number.isInteger(input.ttlMs) ? input.ttlMs : 600_000)).toISOString();
25
+ return {
26
+ v: 1,
27
+ type: 'joint.merge',
28
+ plan_sha256: typeof input.planSha === 'string' && input.planSha !== '' ? input.planSha : (typeof input.plan_sha256 === 'string' && input.plan_sha256 !== '' ? input.plan_sha256 : null),
29
+ repo: String(input.repo ?? ''),
30
+ base_commit: String(input.baseCommit ?? input.base_commit ?? ''),
31
+ target_ref: String(input.targetRef ?? input.target_ref ?? ''),
32
+ expected_old: String(input.expectedOld ?? input.expected_old ?? ''),
33
+ integration_head: String(input.integrationHead ?? input.integration_head ?? ''),
34
+ approver: String(input.approver ?? ''),
35
+ authority: Number.isInteger(input.authority) ? input.authority : 0,
36
+ approved_at: approvedAt,
37
+ expires_at: expiresAt,
38
+ };
39
+ }
40
+
41
+ /**
42
+ * @param {{stateDir: string, planSha?: string|null, repo: string, baseCommit: string, targetRef: string,
43
+ * expectedOld: string, integrationHead: string, approver: string, authority: number,
44
+ * ttlMs?: number, now?: number, key?: {privateKey: string, publicKeyPem: string, key_id: string}}} input
45
+ */
46
+ export function createMergeApproval(input) {
47
+ if (!SHA.test(String(input.baseCommit ?? ''))) return { ok: false, code: 'MERGE_APPROVAL_BASE_INVALID' };
48
+ if (!SHA.test(String(input.expectedOld ?? ''))) return { ok: false, code: 'MERGE_APPROVAL_OLD_INVALID' };
49
+ if (!SHA.test(String(input.integrationHead ?? ''))) return { ok: false, code: 'MERGE_APPROVAL_HEAD_INVALID' };
50
+ if (!Number.isInteger(input.authority) || input.authority < 70 || input.authority > 100) return { ok: false, code: 'MERGE_APPROVAL_AUTHORITY_INVALID' };
51
+ const key = input.key ?? ensureOperatorKey();
52
+ if (key === null || key === undefined || key.ok !== true || key.privateKey === undefined) return { ok: false, code: key?.code ?? 'OPERATOR_KEY_MISSING' };
53
+ const body = bodyOf(input);
54
+ const approvalSha = sha256(JSON.stringify(body));
55
+ const record = {
56
+ ...body,
57
+ approval_sha256: approvalSha,
58
+ operator_key: { key_id: key.key_id ?? keyIdOf(key.publicKeyPem), public_key_pem: key.publicKeyPem },
59
+ sig: signApprovalPayload(body, key.privateKey),
60
+ };
61
+ const dir = join(input.stateDir, 'joint-dev', 'merge-approvals');
62
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
63
+ const path = join(dir, approvalSha + '.json');
64
+ const tmp = path + '.tmp-' + process.pid + '-' + randomBytes(4).toString('hex');
65
+ writeFileSync(tmp, JSON.stringify(record, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
66
+ renameSync(tmp, path);
67
+ try { chmodSync(path, 0o600); } catch { /* Windows ACL */ }
68
+ return { ok: true, approval: record, approval_sha256: approvalSha, path };
69
+ }
70
+
71
+ /**
72
+ * Verify signature, TTL and the exact binding the caller is about to apply.
73
+ *
74
+ * @param {{stateDir: string, approvalSha: string, trustedOperatorKeyIds?: string[], now?: number,
75
+ * repo: string, baseCommit: string, targetRef: string, expectedOld: string, integrationHead: string,
76
+ * planSha?: string|null}} input
77
+ */
78
+ export function loadMergeApproval(input) {
79
+ if (typeof input?.approvalSha !== 'string' || !HEX64.test(input.approvalSha)) return { ok: false, code: 'MERGE_APPROVAL_ID_INVALID' };
80
+ const path = join(input.stateDir, 'joint-dev', 'merge-approvals', input.approvalSha + '.json');
81
+ if (!existsSync(path)) return { ok: false, code: 'MERGE_APPROVAL_NOT_FOUND' };
82
+ let record = null;
83
+ try { record = JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, '')); } catch { return { ok: false, code: 'MERGE_APPROVAL_UNREADABLE' }; }
84
+ if (record?.type !== 'joint.merge') return { ok: false, code: 'MERGE_APPROVAL_TYPE_INVALID' };
85
+ if (record.approval_sha256 !== sha256(JSON.stringify(bodyOf(record)))) return { ok: false, code: 'MERGE_APPROVAL_TAMPERED' };
86
+ const pem = record?.operator_key?.public_key_pem;
87
+ if (typeof pem !== 'string' || record?.operator_key?.key_id !== keyIdOf(pem)) return { ok: false, code: 'MERGE_APPROVAL_KEY_MISMATCH' };
88
+ const trusted = Array.isArray(input.trustedOperatorKeyIds) ? input.trustedOperatorKeyIds : null;
89
+ if (trusted !== null && !trusted.includes(record.operator_key.key_id)) return { ok: false, code: 'MERGE_APPROVAL_KEY_UNTRUSTED' };
90
+ if (verifyApprovalPayload(record, record.sig, pem) !== true) return { ok: false, code: 'MERGE_APPROVAL_SIGNATURE_INVALID' };
91
+ const now = Number.isInteger(input.now) ? input.now : Date.now();
92
+ if (Date.parse(record.expires_at) <= now) return { ok: false, code: 'MERGE_APPROVAL_EXPIRED' };
93
+ for (const [field, expected] of [['repo', input.repo], ['base_commit', input.baseCommit], ['target_ref', input.targetRef], ['expected_old', input.expectedOld], ['integration_head', input.integrationHead]]) {
94
+ if (record[field] !== expected) return { ok: false, code: 'MERGE_APPROVAL_MISMATCH', detail: { field, expected, actual: record[field] } };
95
+ }
96
+ if (input.planSha !== undefined && input.planSha !== null && record.plan_sha256 !== input.planSha) {
97
+ return { ok: false, code: 'MERGE_APPROVAL_MISMATCH', detail: { field: 'plan_sha256', expected: input.planSha, actual: record.plan_sha256 } };
98
+ }
99
+ return { ok: true, approval: record, strength: 'signature' };
100
+ }
101
+
102
+ /** One-shot consumption: the marker is created with O_EXCL, so a second apply cannot reuse the signature. */
103
+ export function consumeMergeApproval({ stateDir, approvalSha }) {
104
+ if (typeof approvalSha !== 'string' || !HEX64.test(approvalSha)) return { ok: false, code: 'MERGE_APPROVAL_ID_INVALID' };
105
+ const dir = join(stateDir, 'joint-dev', 'merge-consumed');
106
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
107
+ const marker = join(dir, approvalSha);
108
+ try {
109
+ writeFileSync(marker, new Date().toISOString() + '\n', { encoding: 'utf8', mode: 0o600, flag: 'wx' });
110
+ return { ok: true };
111
+ } catch (error) {
112
+ if (error?.code === 'EEXIST') return { ok: false, code: 'MERGE_APPROVAL_ALREADY_USED' };
113
+ return { ok: false, code: 'MERGE_APPROVAL_CONSUME_FAILED', detail: error instanceof Error ? error.message : String(error) };
114
+ }
115
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * A5 progress consumer: read the relay's per-task progress for a dispatched joint plan.
3
+ *
4
+ * The relay stores no argv/content on the projection either, and this module enforces the same on
5
+ * the way out: the projection is rebuilt from a small allow-list, and any forbidden key found in the
6
+ * relay's answer is stripped and reported (never echoed). A relay that predates the `progress` key
7
+ * (1.9.16) yields PROGRESS_UNSUPPORTED instead of an empty success.
8
+ */
9
+ import { existsSync, readFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+
12
+ const FORBIDDEN = /^(?:argv|command|command_argv|command_hash|text|content|stdout|stderr|stdout_head|stderr_head|head|tail|patch|diff|env|token|secret|password|base64|blob|blobs|bytes|buffer|files?)$/i;
13
+
14
+ function sanitize(value, seen = new Set()) {
15
+ if (value === null || typeof value !== 'object') return value;
16
+ if (seen.has(value)) return null;
17
+ seen.add(value);
18
+ if (Array.isArray(value)) return value.map((entry) => sanitize(entry, seen));
19
+ const out = {};
20
+ for (const [key, child] of Object.entries(value)) {
21
+ if (FORBIDDEN.test(key)) continue;
22
+ if (typeof child === 'string' && /(?:[A-Za-z0-9+/]{512,}|[0-9a-fA-F]{512,})/.test(child)) continue;
23
+ out[key] = sanitize(child, seen);
24
+ }
25
+ return out;
26
+ }
27
+
28
+ const hasForbidden = (value, seen = new Set()) => {
29
+ if (value === null || typeof value !== 'object') return false;
30
+ if (seen.has(value)) return false;
31
+ seen.add(value);
32
+ if (Array.isArray(value)) return value.some((entry) => hasForbidden(entry, seen));
33
+ return Object.entries(value).some(([key, child]) => FORBIDDEN.test(key) || hasForbidden(child, seen));
34
+ };
35
+
36
+ function readDispatchRecord(stateDir, planSha) {
37
+ if (typeof stateDir !== 'string' || stateDir === '' || typeof planSha !== 'string' || !/^[0-9a-f]{64}$/.test(planSha)) return { ok: false, code: 'NOT_DISPATCHED' };
38
+ const path = join(stateDir, 'joint-dev', 'dispatches', planSha + '.json');
39
+ if (!existsSync(path)) return { ok: false, code: 'NOT_DISPATCHED' };
40
+ try {
41
+ const record = JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, ''));
42
+ if (record?.plan_sha256 !== planSha || !Array.isArray(record.tasks)) return { ok: false, code: 'DISPATCH_RECORD_INVALID' };
43
+ return { ok: true, record };
44
+ } catch {
45
+ return { ok: false, code: 'DISPATCH_RECORD_UNREADABLE' };
46
+ }
47
+ }
48
+
49
+ /**
50
+ * @param {{stateDir: string, planSha: string, relayUrl: string, deviceToken: string,
51
+ * fetchImpl?: Function, timeoutMs?: number, taskIds?: Map<string,string>}} input
52
+ */
53
+ export async function planProgress(input) {
54
+ const record = readDispatchRecord(input?.stateDir, input?.planSha);
55
+ if (record.ok !== true) return { ok: false, code: record.code };
56
+ const fetchImpl = typeof input?.fetchImpl === 'function' ? input.fetchImpl : globalThis.fetch;
57
+ const timeoutMs = Number.isInteger(input?.timeoutMs) ? input.timeoutMs : 15_000;
58
+ const base = typeof input?.relayUrl === 'string' ? input.relayUrl.replace(/\/$/, '') : '';
59
+ if (base === '') return { ok: false, code: 'RELAY_URL_REQUIRED' };
60
+ const token = typeof input?.deviceToken === 'string' ? input.deviceToken : '';
61
+ if (token === '') return { ok: false, code: 'DEVICE_TOKEN_REQUIRED' };
62
+ const steps = [];
63
+ for (const task of record.record.tasks) {
64
+ const taskId = String(task?.task_id ?? '');
65
+ const stepId = String(task?.step_id ?? '');
66
+ if (taskId === '') continue;
67
+ let response = null;
68
+ try {
69
+ const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(timeoutMs) : undefined;
70
+ response = await fetchImpl(base + '/v1/tasks/' + encodeURIComponent(taskId), { headers: { authorization: 'Bearer ' + token, accept: 'application/json' }, signal });
71
+ } catch {
72
+ steps.push({ step_id: stepId, task_id: taskId, state: 'unreadable', code: 'TASK_UNREADABLE' });
73
+ continue;
74
+ }
75
+ if (response.ok !== true) {
76
+ steps.push({ step_id: stepId, task_id: taskId, state: 'unavailable', code: response.status === 404 ? 'TASK_NOT_FOUND' : 'TASK_UNREADABLE', http_status: response.status });
77
+ continue;
78
+ }
79
+ let body = null;
80
+ try { body = await response.json(); } catch { body = null; }
81
+ if (body === null || typeof body !== 'object') {
82
+ steps.push({ step_id: stepId, task_id: taskId, state: 'unavailable', code: 'TASK_UNREADABLE' });
83
+ continue;
84
+ }
85
+ const progress = body.progress ?? body.task?.progress ?? null;
86
+ const row = {
87
+ step_id: stepId,
88
+ task_id: taskId,
89
+ status: body.task?.status ?? body.status ?? null,
90
+ progress: progress === null ? null : sanitize(progress),
91
+ leases: Array.isArray(body.leases) ? body.leases.map((lease) => ({ machine_id: lease?.machine_id ?? null, state: lease?.state ?? null })) : [],
92
+ };
93
+ if (progress === null) { row.progress_state = 'unavailable'; row.code = 'PROGRESS_UNSUPPORTED'; }
94
+ if (hasForbidden(body)) row.leak_fields_stripped = true;
95
+ steps.push(row);
96
+ }
97
+ const unavailable = steps.filter((step) => step.progress_state === 'unavailable').length;
98
+ return {
99
+ ok: true,
100
+ plan_sha256: record.record.plan_sha256,
101
+ approval_sha256: record.record.approval_sha256 ?? null,
102
+ dispatched_at: record.record.dispatched_at ?? null,
103
+ progress_state: steps.length > 0 && unavailable === steps.length ? 'unavailable' : 'partial',
104
+ code: steps.length > 0 && unavailable === steps.length ? 'PROGRESS_UNSUPPORTED' : null,
105
+ steps,
106
+ };
107
+ }
@@ -12,6 +12,8 @@
12
12
  * returns are checked: a lease on a machine that was not pinned cancels the task and fails.
13
13
  */
14
14
  import { createHash } from 'node:crypto';
15
+ import { mkdirSync, writeFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
15
17
 
16
18
  import { appendAudit } from './audit-file.mjs';
17
19
  import { inspectControlMessage } from './redline.mjs';
@@ -42,8 +44,13 @@ function orderSteps(steps) {
42
44
  }
43
45
 
44
46
  function writeAudit(file, event) {
45
- if (typeof file !== 'string' || file === '') return;
46
- try { appendAudit(file, event); } catch { /* an audit write must not change the dispatch outcome */ }
47
+ // No audit file requested (library callers): nothing to write, and that is not a failure.
48
+ if (typeof file !== 'string' || file === '') return { ok: true, skipped: true };
49
+ try {
50
+ return appendAudit(file, event);
51
+ } catch (error) {
52
+ return { ok: false, code: 'AUDIT_WRITE_FAILED', detail: error instanceof Error ? error.message : String(error) };
53
+ }
47
54
  }
48
55
 
49
56
  /**
@@ -74,35 +81,52 @@ export async function dispatchPlanToRelay(input) {
74
81
  const headers = { authorization: `Bearer ${token}`, 'content-type': 'application/json', accept: 'application/json' };
75
82
  const auditFile = typeof input?.auditFile === 'string' ? input.auditFile : '';
76
83
 
77
- // R3: when a device token is supplied, every target machine must advertise the same plugin version.
78
- if (typeof input?.deviceToken === 'string' && input.deviceToken !== '') {
79
- const expected = typeof input?.expectedVersion === 'string' ? input.expectedVersion : null;
80
- if (expected !== null) {
81
- let fleet;
84
+ // D2 (B5): the version gate is observable and defaults to warn. It becomes a refusal only when the
85
+ // caller explicitly asks for enforce; a mixed-version fleet is reported, not silently assumed clean.
86
+ const versionGate = input?.versionGate === 'off' || input?.versionGate === 'enforce' ? input.versionGate : 'warn';
87
+ const warnings = [];
88
+ const expectedVersion = typeof input?.expectedVersion === 'string' && input.expectedVersion !== '' ? input.expectedVersion : null;
89
+ if (expectedVersion !== null && !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(expectedVersion)) {
90
+ return { ok: false, code: 'VERSION_UNPARSED', detail: { expected: expectedVersion } };
91
+ }
92
+ if (versionGate !== 'off' && expectedVersion !== null) {
93
+ const deviceToken = typeof input?.deviceToken === 'string' && input.deviceToken !== '' ? input.deviceToken : '';
94
+ let fleet = null;
95
+ if (deviceToken !== '') {
82
96
  try {
83
- const response = await fetchImpl(`${rabbitUrl}/v1/devices`, { headers: { authorization: `Bearer ${input.deviceToken}`, accept: 'application/json' }, signal: signalOf() });
97
+ const response = await fetchImpl(`${rabbitUrl}/v1/devices`, { headers: { authorization: `Bearer ${deviceToken}`, accept: 'application/json' }, signal: signalOf() });
84
98
  fleet = response.ok === true ? await response.json() : null;
85
99
  } catch { fleet = null; }
86
- if (fleet === null || !Array.isArray(fleet.devices)) return { ok: false, code: 'VERSION_UNVERIFIABLE', detail: { reason: 'the relay device list could not be read' } };
100
+ }
101
+ if (fleet === null || !Array.isArray(fleet.devices)) {
102
+ if (versionGate === 'enforce') return { ok: false, code: 'VERSION_UNVERIFIABLE', detail: { reason: 'the relay device list could not be read', expected: expectedVersion } };
103
+ warnings.push({ code: 'VERSION_UNVERIFIABLE', detail: 'the relay device list could not be read' });
104
+ } else {
87
105
  const byId = new Map(fleet.devices.map((device) => [device.machine_id, device]));
88
106
  const skew = ordered.steps
89
107
  .map((step) => ({ machine_id: step.machine_id, version: byId.get(step.machine_id)?.caps?.plugin_version ?? null }))
90
- .filter((row) => row.version !== expected);
91
- if (skew.length > 0) return { ok: false, code: 'VERSION_SKEW', detail: { expected, machines: skew } };
108
+ .filter((row) => row.version !== expectedVersion);
109
+ if (skew.length > 0) {
110
+ if (versionGate === 'enforce') return { ok: false, code: 'VERSION_SKEW', detail: { expected: expectedVersion, machines: skew } };
111
+ warnings.push({ code: 'VERSION_SKEW', detail: { expected: expectedVersion, machines: skew } });
112
+ }
92
113
  }
93
114
  }
94
115
 
95
116
  const capabilities = await readRelayCapabilities({ rabbitUrl, fetchImpl, timeoutMs });
96
117
  if (capabilities.ok !== true) return { ok: false, code: 'RELAY_CAPABILITIES_UNAVAILABLE', detail: { relay: rabbitUrl, error: capabilities.error ?? null } };
97
- if (capabilities.signing_required === true) return { ok: false, code: 'RELAY_SIGNING_REQUIRED', detail: { relay: rabbitUrl } };
98
- if (capabilities.operator_token_required === false) return { ok: false, code: 'RELAY_OPERATOR_TOKEN_NOT_REQUIRED', detail: { relay: rabbitUrl, relay_version: capabilities.relay_version ?? null } };
99
118
  const negotiation = negotiateFeatures(capabilities);
100
119
  if (negotiation.ok !== true) {
101
120
  return { ok: false, code: negotiation.code ?? 'RELAY_CAPABILITIES_MISSING', detail: { missing: negotiation.missing ?? null, relay: rabbitUrl } };
102
121
  }
122
+ if (capabilities.signing_required !== false) {
123
+ return { ok: false, code: capabilities.signing_required === true ? 'RELAY_SIGNING_REQUIRED' : 'SIGNING_UNVERIFIABLE', detail: { relay: rabbitUrl, signing_required: capabilities.signing_required } };
124
+ }
125
+ if (capabilities.operator_token_required !== true) return { ok: false, code: capabilities.operator_token_required === false ? 'RELAY_OPERATOR_TOKEN_NOT_REQUIRED' : 'OPERATOR_TOKEN_UNVERIFIED', detail: { relay: rabbitUrl, relay_version: capabilities.relay_version ?? null } };
103
126
 
104
127
  const dispatched = new Map(); // step_id -> task_id
105
128
  const tasks = [];
129
+ const dispatchDir = typeof input?.dispatchDir === 'string' && input.dispatchDir !== '' ? input.dispatchDir : null;
106
130
  for (const step of ordered.steps) {
107
131
  const taskId = taskIdOf(plan.plan_sha256, step.step_id);
108
132
  const dependsOn = (step.depends_on ?? []).map((dep) => dispatched.get(dep)).filter((id) => typeof id === 'string');
@@ -113,6 +137,7 @@ export async function dispatchPlanToRelay(input) {
113
137
  executor_machine_id: step.machine_id,
114
138
  command_argv: step.command_argv,
115
139
  command_hash: step.command_hash,
140
+ cwd_rel: typeof step.cwd_rel === 'string' && step.cwd_rel !== '' ? step.cwd_rel : '.',
116
141
  base_commit: plan.base_commit,
117
142
  plan_sha256: plan.plan_sha256,
118
143
  approval_sha256: approval.approval_sha256,
@@ -120,16 +145,19 @@ export async function dispatchPlanToRelay(input) {
120
145
  ...(dependsOn.length > 0 ? { depends_on: dependsOn } : {}),
121
146
  ...(Array.isArray(step.write_scope) && step.write_scope.length > 0 ? { write: true, write_scope: step.write_scope } : {}),
122
147
  ...(step.timeout_ms === undefined ? {} : { timeout_ms: step.timeout_ms }),
123
- ...(input?.includeOffline === false ? {} : { include_offline: true }),
148
+ ...(input?.includeOffline === true ? { include_offline: true } : {}),
124
149
  };
125
150
  const check = inspectControlMessage({ v: 1, type: 'joint.dispatch', body });
126
151
  if (check.ok !== true) {
127
152
  writeAudit(auditFile, { event: 'joint.dispatch.refused', plan_sha256: plan.plan_sha256, step_id: step.step_id, reason_code: check.code ?? 'CONTROL_REFUSED' });
128
153
  return { ok: false, code: check.code ?? 'CONTROL_REFUSED', detail: check.detail ?? null, partial: tasks.length > 0, tasks };
129
154
  }
155
+ const controlText = JSON.stringify(body);
156
+ const audited = writeAudit(auditFile, { event: 'joint.dispatch.sending', plan_sha256: plan.plan_sha256, step_id: step.step_id, task_id: taskId, machine_id: step.machine_id, control_bytes: Buffer.byteLength(controlText), control_sha256: sha256(controlText) });
157
+ if (audited.ok !== true) return { ok: false, code: 'AUDIT_UNAVAILABLE', detail: audited, partial: tasks.length > 0, tasks };
130
158
  let response;
131
159
  try {
132
- response = await fetchImpl(`${rabbitUrl}/v1/task`, { method: 'POST', headers, body: JSON.stringify(body), signal: signalOf() });
160
+ response = await fetchImpl(`${rabbitUrl}/v1/task`, { method: 'POST', headers, body: controlText, signal: signalOf() });
133
161
  } catch (error) {
134
162
  writeAudit(auditFile, { event: 'joint.dispatch.failed', plan_sha256: plan.plan_sha256, step_id: step.step_id, task_id: taskId, reason_code: 'RELAY_UNREACHABLE' });
135
163
  return { ok: false, code: 'RELAY_UNREACHABLE', detail: error instanceof Error ? error.message : String(error), partial: tasks.length > 0, tasks };
@@ -142,12 +170,7 @@ export async function dispatchPlanToRelay(input) {
142
170
  const read = await fetchImpl(`${rabbitUrl}/v1/tasks/${encodeURIComponent(taskId)}`, { headers, signal: signalOf() });
143
171
  existing = read.ok === true ? await read.json() : null;
144
172
  } catch { existing = null; }
145
- if (existing === null) {
146
- writeAudit(auditFile, { event: 'joint.dispatch.already', plan_sha256: plan.plan_sha256, step_id: step.step_id, task_id: taskId });
147
- dispatched.set(step.step_id, taskId);
148
- tasks.push({ step_id: step.step_id, task_id: taskId, already: true, leases: [] });
149
- continue;
150
- }
173
+ if (existing === null) return { ok: false, code: 'TASK_EXISTS_UNVERIFIED', detail: { step_id: step.step_id, task_id: taskId }, partial: tasks.length > 0, tasks };
151
174
  const existingLeases = Array.isArray(existing?.leases) ? existing.leases : [];
152
175
  const mine = existingLeases.filter((lease) => lease?.machine_id === step.machine_id);
153
176
  if (existingLeases.length > 0 && mine.length === 0) return { ok: false, code: 'TASK_EXISTS_FOREIGN', detail: { step_id: step.step_id, task_id: taskId }, partial: tasks.length > 0, tasks };
@@ -196,5 +219,12 @@ export async function dispatchPlanToRelay(input) {
196
219
  leases: leases.map((lease) => ({ machine_id: lease?.machine_id ?? null, state: lease?.state ?? null })),
197
220
  });
198
221
  }
199
- return { ok: true, relay_version: capabilities.relay_version ?? null, features: capabilities.features ?? null, tasks };
222
+ if (dispatchDir !== null) {
223
+ try {
224
+ mkdirSync(dispatchDir, { recursive: true, mode: 0o700 });
225
+ const file = join(dispatchDir, plan.plan_sha256 + '.json');
226
+ writeFileSync(file, JSON.stringify({ v: 1, plan_sha256: plan.plan_sha256, approval_sha256: approval.approval_sha256, dispatched_at: new Date().toISOString(), tasks: tasks.map((task) => ({ step_id: task.step_id, task_id: task.task_id, machine_id: task.leases?.[0]?.machine_id ?? null, already: task.already === true })) }, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
227
+ } catch { /* the progress view is best effort; dispatch itself already happened */ }
228
+ }
229
+ return { ok: true, relay_version: capabilities.relay_version ?? null, features: capabilities.features ?? null, version_gate: versionGate, ...(warnings.length > 0 ? { warnings } : {}), tasks };
200
230
  }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * `/api/w2m/project-stream` (A6): authenticated SSE proxy for the read-only project panel.
3
+ *
4
+ * The browser cannot set an Authorization header on EventSource, and the device token must not be
5
+ * exposed to client code. This route takes the project id and cursor from the local user, attaches
6
+ * the device token server-side and streams the relay's frames through unchanged (including the
7
+ * PROJECT_REPLAY_TRUNCATED notice).
8
+ */
9
+ export const PROJECT_STREAM_ROUTE_PATH = '/api/w2m/project-stream';
10
+ const NO_STORE = { 'cache-control': 'no-store' };
11
+
12
+ /**
13
+ * @param {object} ctx Cordis context
14
+ * @param {{relayUrl?: () => string|null, deviceToken?: () => Promise<string|null>|string|null}} [deps]
15
+ */
16
+ export function registerProjectStreamRoute(ctx, deps = {}) {
17
+ const status = { mounted: false, reason: 'the host has no Connection service' };
18
+ if (typeof ctx?.inject !== 'function') return status;
19
+ ctx.inject(['connection'], (webCtx) => {
20
+ const registry = webCtx?.connection?.fetch;
21
+ if (registry === undefined || typeof registry.register !== 'function') {
22
+ status.reason = 'the connection service carries no fetch registry';
23
+ return;
24
+ }
25
+ registry.register({
26
+ path: PROJECT_STREAM_ROUTE_PATH,
27
+ methods: ['GET'],
28
+ fetch: async (request) => {
29
+ const url = new URL(request.url ?? 'http://local.invalid/api/w2m/project-stream');
30
+ const projectId = url.searchParams.get('project_id') ?? '';
31
+ if (!/^[A-Za-z0-9._-]{1,80}$/.test(projectId)) {
32
+ return Response.json({ ok: false, code: 'PROJECT_ID_INVALID' }, { status: 400, headers: NO_STORE });
33
+ }
34
+ const sinceRaw = url.searchParams.get('since_seq') ?? '0';
35
+ if (!/^[0-9]+$/.test(sinceRaw)) return Response.json({ ok: false, code: 'PROJECT_CURSOR_INVALID' }, { status: 400, headers: NO_STORE });
36
+ const relayUrl = typeof deps?.relayUrl === 'function' ? deps.relayUrl() : null;
37
+ if (typeof relayUrl !== 'string' || relayUrl === '') return Response.json({ ok: false, code: 'RELAY_URL_REQUIRED' }, { status: 503, headers: NO_STORE });
38
+ const token = typeof deps?.deviceToken === 'function' ? await deps.deviceToken() : null;
39
+ if (typeof token !== 'string' || token === '') return Response.json({ ok: false, code: 'DEVICE_TOKEN_REQUIRED' }, { status: 503, headers: NO_STORE });
40
+ const target = relayUrl.replace(/\/$/, '') + '/v1/projects/' + encodeURIComponent(projectId) + '/stream?since_seq=' + sinceRaw;
41
+ let upstream = null;
42
+ try {
43
+ upstream = await fetch(target, { headers: { authorization: 'Bearer ' + token, accept: 'text/event-stream' }, signal: request.signal });
44
+ } catch {
45
+ return Response.json({ ok: false, code: 'PROJECT_STREAM_UNAVAILABLE' }, { status: 502, headers: NO_STORE });
46
+ }
47
+ if (upstream.ok !== true || upstream.body === null) return Response.json({ ok: false, code: 'PROJECT_STREAM_REFUSED', http_status: upstream.status }, { status: 502, headers: NO_STORE });
48
+ return new Response(upstream.body, {
49
+ status: 200,
50
+ headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' },
51
+ });
52
+ },
53
+ });
54
+ status.mounted = true;
55
+ status.reason = null;
56
+ });
57
+ return status;
58
+ }
@@ -11,7 +11,7 @@ import { loadOperatorKey } from './operator-key.mjs';
11
11
 
12
12
  const json = (value) => JSON.stringify(value, null, 2);
13
13
 
14
- export function registerJointTools(ctx, { defineTool, stateDir, machineName, output }) {
14
+ export function registerJointTools(ctx, { defineTool, stateDir, machineName, output, progress }) {
15
15
  const requireStateDir = () => {
16
16
  if (typeof stateDir === 'string' && stateDir !== '') return stateDir;
17
17
  return null;
@@ -69,12 +69,17 @@ export function registerJointTools(ctx, { defineTool, stateDir, machineName, out
69
69
  'returns argv, output or file bytes, and it cannot approve or dispatch.',
70
70
  parameters: {
71
71
  plan_sha256: { type: 'string', description: 'the 64-hex plan id' },
72
+ action: { type: 'string', enum: ['status', 'progress'], description: 'status (default) or progress (relay projection; PROGRESS_UNSUPPORTED on an old relay)' },
72
73
  },
73
74
  output: output ?? { type: 'json' },
74
75
  async execute(args) {
75
76
  const dir = requireStateDir();
76
77
  if (dir === null) return json({ ok: false, code: 'JOINT_STATE_UNAVAILABLE' });
77
78
  const planSha = String(args?.plan_sha256 ?? '');
79
+ if ((args?.action ?? 'status') === 'progress') {
80
+ if (typeof progress !== 'function') return json({ ok: false, code: 'PROGRESS_UNAVAILABLE' });
81
+ return json(await progress({ planSha }));
82
+ }
78
83
  const plan = loadPlan({ stateDir: dir, planSha });
79
84
  if (plan.ok !== true) return json(plan);
80
85
  const key = loadOperatorKey();