@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.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +1795 -1779
- package/bin/w2m-joint.mjs +69 -7
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/2.0.5/RELEASE-205.json +21 -0
- package/docs/acceptance/2.0.5/RELEASE-2050.json +22 -0
- package/docs/acceptance/2.0.6/RELEASE-2060.json +22 -0
- package/docs/adr/0001-collaboration-crdt.md +59 -0
- package/lib/client.js +215 -19
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/src/agent/agent.mjs +97 -36
- package/src/agent/artifact-delivery.mjs +89 -0
- package/src/agent/caps.mjs +16 -2
- package/src/agent/sinks/git-sink.mjs +78 -0
- package/src/agent/sinks/ssh-sink.mjs +83 -0
- package/src/agent/sse-cursor.mjs +16 -0
- package/src/agent/sse-watchdog.mjs +71 -0
- package/src/plugin/dataplane-sink-route.mjs +118 -0
- package/src/plugin/fleet-chat-route.mjs +11 -0
- package/src/plugin/joint-dev/dispatch.mjs +5 -1
- package/src/plugin/joint-dev/merge-approval.mjs +115 -0
- package/src/plugin/joint-dev/progress.mjs +107 -0
- package/src/plugin/joint-dev/relay-dispatch.mjs +52 -22
- package/src/plugin/project-stream-route.mjs +58 -0
- package/src/plugin/tools-joint.mjs +6 -1
- package/src/plugin/tools.mjs +25 -5
- package/src/relay/fleet-messages.mjs +48 -24
- package/src/relay/project-room.mjs +47 -2
- package/src/relay/server.mjs +11 -5
- package/src/relay/state.mjs +63 -3
- package/src/util/chat-crypto.mjs +70 -1
- package/docs/COLLAB/COLLABORATION-MODEL.md +0 -240
- package/docs/COLLAB/EXECUTION-AUTH-MODEL.md +0 -361
- package/docs/COLLAB/M1-ACCEPTANCE-RUNBOOK.md +0 -70
- package/docs/COLLAB/M1-MAINTENANCE.md +0 -313
- package/docs/COLLAB/M2-TASK-GRANT.md +0 -231
- package/docs/COLLAB/M3-GIT-NATIVE-TASKS.md +0 -291
- package/docs/COLLAB/M4-PROJECT-ROOM.md +0 -260
- package/docs/COLLAB/M5-MULTI-CLIENT-PRIMITIVES.md +0 -263
- package/docs/COLLAB/M6-CROSS-NETWORK-RELIABILITY.md +0 -231
- package/docs/COLLAB/M7-METRICS-ACCEPTANCE.md +0 -263
- package/docs/COLLAB/TASK-MODEL.md +0 -281
- package/docs/acceptance/1.9.10/RELEASE-1910.json +0 -21
- package/docs/acceptance/1.9.11/RELEASE-1911.json +0 -20
- package/docs/acceptance/1.9.12/RELEASE-1912.json +0 -20
- package/docs/acceptance/1.9.13/RELEASE-1913.json +0 -20
- package/docs/acceptance/1.9.14/RELEASE-1914.json +0 -20
- package/docs/acceptance/1.9.15/RELEASE-1915.json +0 -22
- package/docs/acceptance/1.9.16/RELEASE-1916.json +0 -21
- package/docs/acceptance/1.9.17/RELEASE-1917.json +0 -20
- package/docs/acceptance/1.9.19/RELEASE-1919.json +0 -21
- package/docs/acceptance/1.9.20/RELEASE-1920.json +0 -22
- package/docs/acceptance/1.9.4/RELEASE-194.json +0 -23
- package/docs/acceptance/1.9.5/RELEASE-195.json +0 -23
- package/docs/acceptance/1.9.6/RELEASE-196.json +0 -22
- package/docs/acceptance/1.9.7/RELEASE-197.json +0 -21
- package/docs/acceptance/1.9.8/RELEASE-198.json +0 -21
- package/docs/acceptance/1.9.9/RELEASE-199.json +0 -21
- package/lib/client.js.bak2 +0 -640
- package/src/plugin/dataflow.mjs +0 -72
- package/src/plugin/fleet-target.mjs.bak-arch2 +0 -812
- package/src/plugin/fleet-target.mjs.bak-archive +0 -812
- package/src/plugin/tofuture/report.mjs +0 -44
- package/src/plugin/tools.mjs.bak-c7 +0 -7029
- package/src/relay/tofuture-whitelist.mjs +0 -46
- package/src/util/data-plane.mjs +0 -180
- package/src/util/sealed-object.mjs +0 -141
- 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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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 ${
|
|
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
|
-
|
|
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 !==
|
|
91
|
-
if (skew.length > 0)
|
|
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 ===
|
|
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:
|
|
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
|
-
|
|
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();
|