@twinsearth/w2m-dsh-plugin 2.0.3 → 2.0.5
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 +17 -0
- package/README.md +187 -462
- package/bin/w2m-joint.mjs +69 -7
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/2.0.4/RELEASE-2040.json +22 -0
- package/docs/acceptance/2.0.5/RELEASE-205.json +21 -0
- package/docs/acceptance/2.0.5/RELEASE-2050.json +22 -0
- package/docs/adr/0001-collaboration-crdt.md +59 -0
- package/lib/client.js +2754 -2599
- 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 +113 -0
- package/src/plugin/fleet-chat-route.mjs +11 -0
- package/src/plugin/gpw.mjs +99 -15
- 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,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE watchdog (P0-f): the stream must not hang forever on a half-open connection.
|
|
3
|
+
*
|
|
4
|
+
* Two deadlines are enforced:
|
|
5
|
+
* · first frame — nothing parsed within 5 s means the stream is not really attached;
|
|
6
|
+
* · idle — 45 s without any byte, which is longer than the relay's 15 s keepalive
|
|
7
|
+
* interval by design (three missed keepalives before we give up).
|
|
8
|
+
*
|
|
9
|
+
* `noteBytes` sustains the idle deadline (keepalive comments prove the socket is alive) but it does
|
|
10
|
+
* NOT satisfy the first-frame deadline: a stream that connects and sends keepalives without a
|
|
11
|
+
* parseable `ready` is exactly the buffered/half-dead case this watchdog exists to catch.
|
|
12
|
+
*/
|
|
13
|
+
export const SSE_FIRST_FRAME_MS = 5000;
|
|
14
|
+
export const SSE_IDLE_MS = 45000;
|
|
15
|
+
export const SSE_FIRST_FRAME_TIMEOUT = 'SSE_FIRST_FRAME_TIMEOUT';
|
|
16
|
+
export const SSE_IDLE_TIMEOUT = 'SSE_IDLE_TIMEOUT';
|
|
17
|
+
|
|
18
|
+
export function createSseWatchdog(input = {}) {
|
|
19
|
+
const firstFrameMs = Number.isInteger(input.firstFrameMs) && input.firstFrameMs > 0 ? input.firstFrameMs : SSE_FIRST_FRAME_MS;
|
|
20
|
+
const idleMs = Number.isInteger(input.idleMs) && input.idleMs > 0 ? input.idleMs : SSE_IDLE_MS;
|
|
21
|
+
const onTimeout = typeof input.onTimeout === 'function' ? input.onTimeout : () => {};
|
|
22
|
+
let disposed = false;
|
|
23
|
+
let firstTimer = null;
|
|
24
|
+
let idleTimer = null;
|
|
25
|
+
|
|
26
|
+
const armIdle = () => {
|
|
27
|
+
if (disposed) return;
|
|
28
|
+
if (idleTimer !== null) clearTimeout(idleTimer);
|
|
29
|
+
idleTimer = setTimeout(() => {
|
|
30
|
+
if (disposed) return;
|
|
31
|
+
disposed = true;
|
|
32
|
+
clearTimers();
|
|
33
|
+
onTimeout(SSE_IDLE_TIMEOUT);
|
|
34
|
+
}, idleMs);
|
|
35
|
+
if (typeof idleTimer.unref === 'function') idleTimer.unref();
|
|
36
|
+
};
|
|
37
|
+
const clearTimers = () => {
|
|
38
|
+
if (firstTimer !== null) { clearTimeout(firstTimer); firstTimer = null; }
|
|
39
|
+
if (idleTimer !== null) { clearTimeout(idleTimer); idleTimer = null; }
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
firstTimer = setTimeout(() => {
|
|
43
|
+
if (disposed) return;
|
|
44
|
+
disposed = true;
|
|
45
|
+
clearTimers();
|
|
46
|
+
onTimeout(SSE_FIRST_FRAME_TIMEOUT);
|
|
47
|
+
}, firstFrameMs);
|
|
48
|
+
if (typeof firstTimer.unref === 'function') firstTimer.unref();
|
|
49
|
+
armIdle();
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
/** Any byte (headers, keepalive comment, frame) proves the socket is alive: it clears the
|
|
53
|
+
* first-frame deadline and re-arms the idle deadline. */
|
|
54
|
+
noteBytes() {
|
|
55
|
+
if (disposed) return;
|
|
56
|
+
armIdle();
|
|
57
|
+
},
|
|
58
|
+
/** A parsed frame clears the first-frame deadline. */
|
|
59
|
+
noteFrame() {
|
|
60
|
+
if (disposed) return;
|
|
61
|
+
if (firstTimer !== null) { clearTimeout(firstTimer); firstTimer = null; }
|
|
62
|
+
armIdle();
|
|
63
|
+
},
|
|
64
|
+
get disposed() { return disposed; },
|
|
65
|
+
dispose() {
|
|
66
|
+
if (disposed) return;
|
|
67
|
+
disposed = true;
|
|
68
|
+
clearTimers();
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
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
|
+
const rel = relative(stateDir, target);
|
|
32
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Strip anything that looks like a secret; the file only holds references, never credentials. */
|
|
36
|
+
function publicView(config) {
|
|
37
|
+
if (config === null) return { configured: false, kind: null };
|
|
38
|
+
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 };
|
|
39
|
+
return { configured: true, kind: config.kind, root: config.root ?? null, remote: config.remote ?? null, branch: config.branch ?? null, saved_at: config.saved_at ?? null };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {object} ctx Cordis context
|
|
44
|
+
* @param {{stateDir?: () => string|null}} [deps]
|
|
45
|
+
*/
|
|
46
|
+
export function registerDataplaneSinkRoute(ctx, deps = {}) {
|
|
47
|
+
const status = { mounted: false, reason: 'the host has no Connection service' };
|
|
48
|
+
if (typeof ctx?.inject !== 'function') return status;
|
|
49
|
+
ctx.inject(['connection'], (webCtx) => {
|
|
50
|
+
const registry = webCtx?.connection?.fetch;
|
|
51
|
+
if (registry === undefined || typeof registry.register !== 'function') {
|
|
52
|
+
status.reason = 'the connection service carries no fetch registry';
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const stateDirOf = () => (typeof deps?.stateDir === 'function' ? deps.stateDir() : null);
|
|
56
|
+
registry.register({
|
|
57
|
+
path: DATAPLANE_SINK_ROUTE_PATH,
|
|
58
|
+
methods: ['GET', 'POST'],
|
|
59
|
+
requestBody: 'buffered',
|
|
60
|
+
fetch: async (request) => {
|
|
61
|
+
const stateDir = stateDirOf();
|
|
62
|
+
if (typeof stateDir !== 'string' || stateDir === '') {
|
|
63
|
+
return Response.json({ ok: false, code: 'DATAPLANE_STATE_UNAVAILABLE' }, { status: 503, headers: NO_STORE });
|
|
64
|
+
}
|
|
65
|
+
if (request.method === 'GET') {
|
|
66
|
+
return Response.json({ ok: true, ...publicView(readConfig(stateDir)) }, { headers: NO_STORE });
|
|
67
|
+
}
|
|
68
|
+
let body = null;
|
|
69
|
+
try { body = await request.json(); } catch { /* refused below */ }
|
|
70
|
+
if (body === null || typeof body !== 'object') return Response.json({ ok: false, code: 'SINK_BODY_REQUIRED' }, { status: 400, headers: NO_STORE });
|
|
71
|
+
for (const key of Object.keys(body)) {
|
|
72
|
+
if (CREDENTIAL_KEYS.test(key)) return Response.json({ ok: false, code: 'SINK_CREDENTIALS_NOT_ACCEPTED', detail: key }, { status: 400, headers: NO_STORE });
|
|
73
|
+
}
|
|
74
|
+
const kind = typeof body.kind === 'string' ? body.kind : '';
|
|
75
|
+
if (!KINDS.has(kind)) return Response.json({ ok: false, code: 'SINK_KIND_INVALID' }, { status: 400, headers: NO_STORE });
|
|
76
|
+
const config = { v: 1, kind, saved_at: new Date().toISOString(), approved_by: 'local-user' };
|
|
77
|
+
if (kind === 'ssh') {
|
|
78
|
+
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 });
|
|
79
|
+
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 });
|
|
80
|
+
if (typeof body.dir !== 'string' || !isAbsolute(body.dir)) return Response.json({ ok: false, code: 'SINK_SSH_DIR_INVALID' }, { status: 400, headers: NO_STORE });
|
|
81
|
+
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 });
|
|
82
|
+
config.host = body.host;
|
|
83
|
+
config.user = body.user;
|
|
84
|
+
config.dir = body.dir;
|
|
85
|
+
if (body.port !== undefined) config.port = body.port;
|
|
86
|
+
} else {
|
|
87
|
+
if (typeof body.root !== 'string' || !isAbsolute(body.root)) return Response.json({ ok: false, code: 'SINK_ROOT_INVALID' }, { status: 400, headers: NO_STORE });
|
|
88
|
+
const resolved = realpathSync.native ? (() => { try { return realpathSync.native(body.root); } catch { return null; } })() : null;
|
|
89
|
+
if (resolved === null) return Response.json({ ok: false, code: 'SINK_ROOT_NOT_FOUND' }, { status: 400, headers: NO_STORE });
|
|
90
|
+
const stat = statSync(resolved);
|
|
91
|
+
if (!stat.isDirectory()) return Response.json({ ok: false, code: 'SINK_ROOT_NOT_DIRECTORY' }, { status: 400, headers: NO_STORE });
|
|
92
|
+
if (insideStateDir(stateDir, resolved)) return Response.json({ ok: false, code: 'SINK_ROOT_INSIDE_STATE' }, { status: 400, headers: NO_STORE });
|
|
93
|
+
if (kind === 'lan-git' && !existsSync(join(resolved, '.git'))) return Response.json({ ok: false, code: 'SINK_GIT_NOT_A_REPO' }, { status: 400, headers: NO_STORE });
|
|
94
|
+
config.root = resolved;
|
|
95
|
+
if (kind === 'lan-git') {
|
|
96
|
+
if (typeof body.remote === 'string' && body.remote !== '') config.remote = body.remote;
|
|
97
|
+
if (typeof body.branch === 'string' && body.branch !== '') config.branch = body.branch;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const file = configPath(stateDir);
|
|
101
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
102
|
+
const tmp = file + '.tmp-' + process.pid + '-' + randomBytes(4).toString('hex');
|
|
103
|
+
writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
104
|
+
renameSync(tmp, file);
|
|
105
|
+
try { chmodSync(file, 0o600); } catch { /* Windows ACL */ }
|
|
106
|
+
return Response.json({ ok: true, ...publicView(config) }, { headers: NO_STORE });
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
status.mounted = true;
|
|
110
|
+
status.reason = null;
|
|
111
|
+
});
|
|
112
|
+
return status;
|
|
113
|
+
}
|
|
@@ -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());
|
package/src/plugin/gpw.mjs
CHANGED
|
@@ -95,7 +95,21 @@ export const GPW_PICK_ROUTE = '/api/w2m/gpw/pick';
|
|
|
95
95
|
export const GPW_RESET_ROUTE = '/api/w2m/gpw/reset';
|
|
96
96
|
|
|
97
97
|
const DEFAULT_BUDGET = { maxPerWindow: 10, windowMs: 60_000, minGapMs: 3_000 };
|
|
98
|
-
const DEFAULT_PENDING_TIMEOUT_MS =
|
|
98
|
+
const DEFAULT_PENDING_TIMEOUT_MS = 10_000;
|
|
99
|
+
|
|
100
|
+
/** Two policy strategies; novice is the product default. */
|
|
101
|
+
export const GPW_MODES = Object.freeze(['novice', 'advanced']);
|
|
102
|
+
export const DEFAULT_GPW_MODE = 'novice';
|
|
103
|
+
/** At most one path-naming auto-pass per 3 s in novice mode. */
|
|
104
|
+
export const NOVICE_APPROVE_GAP_MS = 3000;
|
|
105
|
+
/** Always refused in novice mode, even with an empty operator blacklist. */
|
|
106
|
+
export const DEFAULT_PROTECTED_FOLDERS = Object.freeze([
|
|
107
|
+
'C:\\Windows',
|
|
108
|
+
'C:\\Program Files',
|
|
109
|
+
'D:\\Program Files',
|
|
110
|
+
'C:\\Program Files (x86)',
|
|
111
|
+
'D:\\Program Files (x86)',
|
|
112
|
+
]);
|
|
99
113
|
const RECENT_LIMIT = 20;
|
|
100
114
|
const NO_STORE = { 'cache-control': 'no-store' };
|
|
101
115
|
/** v1.7.9: lists are MAC-signed (v2) so an external edit is detectable instead of silently trusted. */
|
|
@@ -164,29 +178,30 @@ export function readFolders(file, key = null, options = {}) {
|
|
|
164
178
|
raw = readFileSync(file, 'utf8');
|
|
165
179
|
} catch (error) {
|
|
166
180
|
if (error?.code === 'ENOENT' && key !== null && options.allowMissing !== true) {
|
|
167
|
-
return { whitelist: [], blacklist: [], tampered: true, reason: 'folder lists file is missing' };
|
|
181
|
+
return { whitelist: [], blacklist: [], mode: 'novice', tampered: true, reason: 'folder lists file is missing' };
|
|
168
182
|
}
|
|
169
183
|
if (error?.code !== 'ENOENT') {
|
|
170
184
|
return { whitelist: [], blacklist: [], tampered: true, reason: `folder lists unreadable: ${error?.message ?? error}` };
|
|
171
185
|
}
|
|
172
|
-
return { whitelist: [], blacklist: [], tampered: false, reason: null, legacy: false };
|
|
186
|
+
return { whitelist: [], blacklist: [], mode: 'novice', tampered: false, reason: null, legacy: false };
|
|
173
187
|
}
|
|
174
188
|
let parsed = null;
|
|
175
189
|
try {
|
|
176
190
|
parsed = JSON.parse(raw);
|
|
177
191
|
} catch {
|
|
178
|
-
return { whitelist: [], blacklist: [], tampered: true, reason: 'folder lists file is not valid JSON' };
|
|
192
|
+
return { whitelist: [], blacklist: [], mode: 'novice', tampered: true, reason: 'folder lists file is not valid JSON' };
|
|
179
193
|
}
|
|
180
194
|
const whitelist = Array.isArray(parsed?.whitelist) ? parsed.whitelist.filter((v) => typeof v === 'string' && v !== '') : [];
|
|
181
195
|
const blacklist = Array.isArray(parsed?.blacklist) ? parsed.blacklist.filter((v) => typeof v === 'string' && v !== '') : [];
|
|
182
196
|
if (parsed?.version !== FOLDERS_VERSION || typeof parsed?.mac !== 'string') {
|
|
183
|
-
return { whitelist, blacklist, tampered: false, reason: null, legacy: true };
|
|
197
|
+
return { whitelist, blacklist, mode: 'novice', tampered: false, reason: null, legacy: true };
|
|
184
198
|
}
|
|
185
|
-
|
|
199
|
+
const storedMode = parsed?.mode === 'advanced' ? 'advanced' : 'novice';
|
|
200
|
+
if (key === null) return { whitelist, blacklist, mode: storedMode, tampered: false, reason: null, legacy: false };
|
|
186
201
|
if (macOfFolders(whitelist, blacklist, key) !== parsed.mac) {
|
|
187
|
-
return { whitelist, blacklist, tampered: true, reason: 'folder lists were modified outside GPW (MAC mismatch)' };
|
|
202
|
+
return { whitelist, blacklist, mode: storedMode, tampered: true, reason: 'folder lists were modified outside GPW (MAC mismatch)' };
|
|
188
203
|
}
|
|
189
|
-
return { whitelist, blacklist, tampered: false, reason: null, legacy: false };
|
|
204
|
+
return { whitelist, blacklist, mode: storedMode, tampered: false, reason: null, legacy: false };
|
|
190
205
|
}
|
|
191
206
|
|
|
192
207
|
/**
|
|
@@ -200,7 +215,8 @@ export function writeFolders(file, folders, key = null) {
|
|
|
200
215
|
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
201
216
|
const whitelist = [...new Set((folders?.whitelist ?? []).filter((v) => typeof v === 'string' && v.trim() !== ''))];
|
|
202
217
|
const blacklist = [...new Set((folders?.blacklist ?? []).filter((v) => typeof v === 'string' && v.trim() !== ''))];
|
|
203
|
-
const
|
|
218
|
+
const mode = folders?.mode === 'advanced' ? 'advanced' : 'novice';
|
|
219
|
+
const next = { version: FOLDERS_VERSION, whitelist, blacklist, mode };
|
|
204
220
|
if (key !== null) {
|
|
205
221
|
next.algo = FOLDERS_MAC_ALGO;
|
|
206
222
|
next.mac = macOfFolders(whitelist, blacklist, key);
|
|
@@ -213,12 +229,12 @@ export function writeFolders(file, folders, key = null) {
|
|
|
213
229
|
try { chmodSync(tmp, 0o600); } catch { /* best effort */ }
|
|
214
230
|
renameSync(tmp, file);
|
|
215
231
|
try { chmodSync(file, 0o444); } catch { /* read-only is best effort on Windows, MAC still verifies */ }
|
|
216
|
-
return { whitelist, blacklist };
|
|
232
|
+
return { whitelist, blacklist, mode };
|
|
217
233
|
}
|
|
218
234
|
|
|
219
235
|
/** Operator reset: drop every entry and re-sign. Used only after a tamper report. */
|
|
220
236
|
export function resetFolders(file, key) {
|
|
221
|
-
return writeFolders(file, { whitelist: [], blacklist: [] }, key);
|
|
237
|
+
return writeFolders(file, { whitelist: [], blacklist: [], mode: 'novice' }, key);
|
|
222
238
|
}
|
|
223
239
|
|
|
224
240
|
/**
|
|
@@ -363,6 +379,7 @@ export function apply(ctx, config = {}) {
|
|
|
363
379
|
const minGapMs = positiveInt(config.minGapMs, DEFAULT_BUDGET.minGapMs);
|
|
364
380
|
const pendingTimeoutMs = positiveInt(config.pendingTimeoutMs, DEFAULT_PENDING_TIMEOUT_MS);
|
|
365
381
|
const requireFullAccess = config.requireFullAccess !== false;
|
|
382
|
+
const configuredMode = config.gpwMode === 'advanced' ? 'advanced' : DEFAULT_GPW_MODE;
|
|
366
383
|
const foldersFile = foldersFileOf(config);
|
|
367
384
|
const keyInfo = ensureFoldersKey(foldersFile, config);
|
|
368
385
|
const foldersKey = keyInfo.key;
|
|
@@ -384,6 +401,8 @@ export function apply(ctx, config = {}) {
|
|
|
384
401
|
noteTamper(initialFolders.reason);
|
|
385
402
|
} else {
|
|
386
403
|
// Always leave a signed v2 file behind (first run creates the empty one; later runs re-sign it).
|
|
404
|
+
// A configured mode is the bootstrap choice when the file carries none yet (tests and first run).
|
|
405
|
+
if (config.gpwMode !== undefined) initialFolders.mode = configuredMode;
|
|
387
406
|
try {
|
|
388
407
|
writeFolders(foldersFile, initialFolders, foldersKey);
|
|
389
408
|
foldersSeen = true;
|
|
@@ -398,6 +417,7 @@ export function apply(ctx, config = {}) {
|
|
|
398
417
|
let allowedTotal = 0;
|
|
399
418
|
let pendingTotal = 0;
|
|
400
419
|
let deniedTotal = 0;
|
|
420
|
+
let lastNovicePassAt = 0;
|
|
401
421
|
/** id -> { resolve, createdAt, tool, reason, paths } */
|
|
402
422
|
const pending = new Map();
|
|
403
423
|
/** Newest-first record of calls the gate saw, so the panel can offer "add this folder" in one click. */
|
|
@@ -422,9 +442,13 @@ export function apply(ctx, config = {}) {
|
|
|
422
442
|
foldersSeen = true;
|
|
423
443
|
}
|
|
424
444
|
const merge = (a, b) => [...new Set([...(Array.isArray(a) ? a : []), ...b])];
|
|
445
|
+
const mode = config.gpwMode !== undefined ? configuredMode : (stored.mode ?? configuredMode);
|
|
446
|
+
const storedBlacklist = merge(config.blacklist, stored.blacklist);
|
|
425
447
|
return {
|
|
448
|
+
mode,
|
|
426
449
|
whitelist: merge(config.whitelist, stored.whitelist),
|
|
427
|
-
blacklist:
|
|
450
|
+
blacklist: storedBlacklist,
|
|
451
|
+
effectiveBlacklist: mode === 'novice' ? [...new Set([...DEFAULT_PROTECTED_FOLDERS, ...storedBlacklist])] : storedBlacklist,
|
|
428
452
|
tampered: tamperedReason !== null,
|
|
429
453
|
reason: tamperedReason,
|
|
430
454
|
};
|
|
@@ -529,8 +553,8 @@ export function apply(ctx, config = {}) {
|
|
|
529
553
|
ctx.logger?.warn?.(`dsh-gpw: refused ${String(call.name ?? 'tool')} -- lists locked (${lists.reason})`);
|
|
530
554
|
return `GPW: folder lists failed integrity check (${lists.reason}); reset them in the GPW panel`;
|
|
531
555
|
}
|
|
532
|
-
if (lists.
|
|
533
|
-
const result = classifyToolCall({ ...call, blacklist: lists.
|
|
556
|
+
if (lists.effectiveBlacklist.length === 0) return undefined;
|
|
557
|
+
const result = classifyToolCall({ ...call, blacklist: lists.effectiveBlacklist });
|
|
534
558
|
if (result.decision !== 'deny') return undefined;
|
|
535
559
|
deniedTotal += 1;
|
|
536
560
|
noteRecent({ tool: String(call.name ?? 'tool'), decision: 'deny', reason: result.reason, paths: result.paths });
|
|
@@ -575,6 +599,34 @@ export function apply(ctx, config = {}) {
|
|
|
575
599
|
ctx.logger?.warn?.(`dsh-gpw: refused ${String(call.name ?? 'tool')} -- lists locked (${lists.reason})`);
|
|
576
600
|
return { kind: 'deny', reason: `GPW: folder lists failed integrity check (${lists.reason}); reset them in the GPW panel` };
|
|
577
601
|
}
|
|
602
|
+
if (lists.mode === 'novice') {
|
|
603
|
+
// 小白模式: only the blacklist restricts anything; every other call passes.
|
|
604
|
+
// At most one path-naming call per 3 s is auto-passed, so a burst cannot do unbounded damage.
|
|
605
|
+
let noviceCwd = null;
|
|
606
|
+
try {
|
|
607
|
+
noviceCwd = await ctx.workingDirectory?.ensure?.(exec.agent, exec.signal);
|
|
608
|
+
} catch {
|
|
609
|
+
noviceCwd = null;
|
|
610
|
+
}
|
|
611
|
+
const noviceCall = toolCallOf(exec);
|
|
612
|
+
const noviceClassification = classifyToolCall({ ...noviceCall, blacklist: lists.effectiveBlacklist, workspace: noviceCwd ?? null, base: noviceCwd ?? undefined });
|
|
613
|
+
if (noviceClassification.decision === 'deny') {
|
|
614
|
+
deniedTotal += 1;
|
|
615
|
+
colorCounts.red += 1;
|
|
616
|
+
noteRecent({ tool: String(noviceCall.name ?? 'tool'), decision: 'deny', color: 'red', reason: noviceClassification.reason, paths: noviceClassification.paths });
|
|
617
|
+
ctx.logger?.warn?.('dsh-gpw: novice -- refused ' + String(noviceCall.name ?? 'tool') + ' -- ' + noviceClassification.reason);
|
|
618
|
+
return { kind: 'deny', reason: 'GPW: ' + noviceClassification.reason + '; the call was not executed' };
|
|
619
|
+
}
|
|
620
|
+
if (noviceClassification.paths.length > 0) {
|
|
621
|
+
const waitMs = NOVICE_APPROVE_GAP_MS - (Date.now() - lastNovicePassAt);
|
|
622
|
+
if (waitMs > 0) await new Promise((resolveSlot) => { setTimeout(resolveSlot, Math.min(waitMs, NOVICE_APPROVE_GAP_MS)); });
|
|
623
|
+
lastNovicePassAt = Date.now();
|
|
624
|
+
}
|
|
625
|
+
colorCounts.green += 1;
|
|
626
|
+
const noviceReason = '小白模式:黑名单外全部放行(每 3 秒最多一次路径操作)';
|
|
627
|
+
noteRecent({ tool: String(noviceCall.name ?? 'tool'), decision: 'allow', color: 'green', reason: noviceReason, paths: noviceClassification.paths });
|
|
628
|
+
return { kind: 'allow', reason: noviceReason };
|
|
629
|
+
}
|
|
578
630
|
let cwd = null;
|
|
579
631
|
try {
|
|
580
632
|
cwd = await ctx.workingDirectory?.ensure?.(exec.agent, exec.signal);
|
|
@@ -651,6 +703,11 @@ export function apply(ctx, config = {}) {
|
|
|
651
703
|
enabled: true,
|
|
652
704
|
require_full_access: requireFullAccess,
|
|
653
705
|
folders: { whitelist: lists.whitelist, blacklist: lists.blacklist },
|
|
706
|
+
mode: lists.mode,
|
|
707
|
+
modes: [...GPW_MODES],
|
|
708
|
+
protected_folders: lists.mode === 'novice' ? [...DEFAULT_PROTECTED_FOLDERS, ...lists.blacklist] : [...lists.blacklist],
|
|
709
|
+
pending_timeout_ms: pendingTimeoutMs,
|
|
710
|
+
novice_approve_gap_ms: NOVICE_APPROVE_GAP_MS,
|
|
654
711
|
tampered: lists.tampered,
|
|
655
712
|
tampered_reason: lists.reason,
|
|
656
713
|
counters: { allowedTotal, pendingTotal, deniedTotal, budgetWindow: approvals.length, maxPerWindow, colors: { ...colorCounts }, grants: sessionGrants.size },
|
|
@@ -690,6 +747,22 @@ export function apply(ctx, config = {}) {
|
|
|
690
747
|
} catch {
|
|
691
748
|
/* refused below */
|
|
692
749
|
}
|
|
750
|
+
if (body?.action === 'set-mode') {
|
|
751
|
+
const current = folderLists();
|
|
752
|
+
if (current.tampered) {
|
|
753
|
+
return json({ ok: false, error: 'folder lists are locked after an integrity failure (' + current.reason + '); reset them first' }, 409);
|
|
754
|
+
}
|
|
755
|
+
const mode = body?.mode;
|
|
756
|
+
if (mode !== 'novice' && mode !== 'advanced') return json({ ok: false, error: 'mode must be novice or advanced' }, 400);
|
|
757
|
+
const stored = readFolders(foldersFile, foldersKey, { allowMissing: true });
|
|
758
|
+
try {
|
|
759
|
+
writeFolders(foldersFile, { ...stored, mode }, foldersKey);
|
|
760
|
+
} catch (error) {
|
|
761
|
+
return json({ ok: false, error: 'could not write the policy mode: ' + (error?.message ?? error) }, 500);
|
|
762
|
+
}
|
|
763
|
+
ctx.logger?.info?.('dsh-gpw: policy mode -> ' + mode);
|
|
764
|
+
return json({ ok: true, mode, folders: folderLists() });
|
|
765
|
+
}
|
|
693
766
|
const list = body?.list;
|
|
694
767
|
if (list !== 'whitelist' && list !== 'blacklist') return json({ ok: false, error: 'list must be whitelist or blacklist' }, 400);
|
|
695
768
|
if (body?.action !== 'add' && body?.action !== 'remove') return json({ ok: false, error: 'action must be add or remove' }, 400);
|
|
@@ -698,6 +771,17 @@ export function apply(ctx, config = {}) {
|
|
|
698
771
|
return json({ ok: false, error: `folder lists are locked after an integrity failure (${current.reason}); reset them first` }, 409);
|
|
699
772
|
}
|
|
700
773
|
const stored = readFolders(foldersFile, foldersKey, { allowMissing: true });
|
|
774
|
+
if (body?.action === 'set-mode') {
|
|
775
|
+
const mode = body?.mode;
|
|
776
|
+
if (mode !== 'novice' && mode !== 'advanced') return json({ ok: false, error: 'mode must be novice or advanced' }, 400);
|
|
777
|
+
try {
|
|
778
|
+
writeFolders(foldersFile, { ...stored, mode }, foldersKey);
|
|
779
|
+
} catch (error) {
|
|
780
|
+
return json({ ok: false, error: 'could not write the policy mode: ' + (error?.message ?? error) }, 500);
|
|
781
|
+
}
|
|
782
|
+
ctx.logger?.info?.('dsh-gpw: policy mode -> ' + mode);
|
|
783
|
+
return json({ ok: true, mode, folders: folderLists() });
|
|
784
|
+
}
|
|
701
785
|
if (body.action === 'remove') {
|
|
702
786
|
if (typeof body?.path !== 'string' || body.path.trim() === '') return json({ ok: false, error: 'path must be a non-empty string' }, 400);
|
|
703
787
|
const next = { ...stored, [list]: stored[list].filter((entry) => entry !== body.path) };
|
|
@@ -734,7 +818,7 @@ export function apply(ctx, config = {}) {
|
|
|
734
818
|
}
|
|
735
819
|
if (body?.confirm !== 'reset') return json({ ok: false, error: "confirm must be the string 'reset'" }, 400);
|
|
736
820
|
try {
|
|
737
|
-
resetFolders(foldersFile, foldersKey);
|
|
821
|
+
resetFolders(foldersFile, foldersKey, folderLists().mode);
|
|
738
822
|
} catch (error) {
|
|
739
823
|
return json({ ok: false, error: `could not reset folder lists: ${error?.message ?? error}` }, 500);
|
|
740
824
|
}
|
|
@@ -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
|
+
}
|