@twinsearth/w2m-dsh-plugin 1.3.1 → 1.4.0

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 (57) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +709 -0
  4. package/bin/w2m-grant.mjs +98 -0
  5. package/bin/w2m-maintenance.mjs +112 -0
  6. package/codex/mcp-servers.toml +1 -1
  7. package/deploy/maintenance/bootstrap.ps1 +30 -0
  8. package/deploy/maintenance/bootstrap.sh +61 -0
  9. package/deploy/server/w2m-maintenance.path +10 -0
  10. package/deploy/server/w2m-maintenance.service +20 -0
  11. package/deploy/windows/w2m-maintenance.xml +31 -0
  12. package/docs/COLLAB/COLLABORATION-MODEL.md +240 -0
  13. package/docs/COLLAB/EXECUTION-AUTH-MODEL.md +361 -0
  14. package/docs/COLLAB/M1-ACCEPTANCE-RUNBOOK.md +70 -0
  15. package/docs/COLLAB/M1-MAINTENANCE.md +313 -0
  16. package/docs/COLLAB/M2-TASK-GRANT.md +231 -0
  17. package/docs/COLLAB/M3-GIT-NATIVE-TASKS.md +291 -0
  18. package/docs/COLLAB/M4-PROJECT-ROOM.md +260 -0
  19. package/docs/COLLAB/M5-MULTI-CLIENT-PRIMITIVES.md +263 -0
  20. package/docs/COLLAB/M6-CROSS-NETWORK-RELIABILITY.md +231 -0
  21. package/docs/COLLAB/M7-METRICS-ACCEPTANCE.md +263 -0
  22. package/docs/COLLAB/TASK-MODEL.md +281 -0
  23. package/docs/MULTI-MACHINE-COLLABORATION-PLAN.md +186 -0
  24. package/docs/RELEASE-STABLE.md +193 -0
  25. package/docs/acceptance/1.3.10-beta.10/M1-LIVE-DIAG.json +27 -0
  26. package/docs/acceptance/1.3.10-beta.10/M1-LIVE-POSTUPDATE-DIAG.json +18 -0
  27. package/docs/acceptance/1.3.10-beta.10/M1-LIVE-RESTART.json +24 -0
  28. package/docs/acceptance/1.3.10-beta.10/M1-LIVE-UPDATE.json +24 -0
  29. package/docs/acceptance/1.3.11/M1-LIVE-DIAG.json +26 -0
  30. package/docs/acceptance/1.3.12-beta.1/M1-LIVE-DIAG.json +26 -0
  31. package/docs/acceptance/1.4.0/M1-LIVE-MAINTENANCE.json +20 -0
  32. package/docs/acceptance/1.4.0/REGRESSION-1.4.0.json +81 -0
  33. package/package.json +26 -5
  34. package/plugin.json +1 -1
  35. package/src/acceptance/evidence.mjs +79 -0
  36. package/src/agent/agent.mjs +141 -3
  37. package/src/agent/grant-gate.mjs +89 -0
  38. package/src/agent/maintenance-ferry.mjs +111 -0
  39. package/src/agent/outbox.mjs +138 -0
  40. package/src/agent/patch-artifact.mjs +53 -0
  41. package/src/agent/worktree.mjs +118 -0
  42. package/src/maintenance/actions.mjs +203 -0
  43. package/src/maintenance/grant.mjs +139 -0
  44. package/src/maintenance/operator-key.mjs +151 -0
  45. package/src/maintenance/protocol.mjs +204 -0
  46. package/src/maintenance/runner.mjs +189 -0
  47. package/src/plugin/tools.mjs +1 -1
  48. package/src/relay/blob-sessions.mjs +148 -0
  49. package/src/relay/merge-queue.mjs +137 -0
  50. package/src/relay/project-locks.mjs +179 -0
  51. package/src/relay/project-notifications.mjs +184 -0
  52. package/src/relay/project-refs.mjs +110 -0
  53. package/src/relay/project-reviews.mjs +161 -0
  54. package/src/relay/project-room.mjs +163 -0
  55. package/src/relay/reliability-metrics.mjs +180 -0
  56. package/src/relay/server.mjs +382 -1
  57. package/src/relay/state.mjs +308 -1
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Result outbox (M6, v1.3.10-beta.21 slice).
3
+ *
4
+ * A machine can finish a task while the network is down. The result must not live only in memory: it goes to disk first, is
5
+ * re-delivered on reconnect, and is removed only after the relay acknowledged it. The relay deduplicates by
6
+ * (task_id, attempt), so a re-delivery is safe even when the acknowledgement itself was lost.
7
+ *
8
+ * Files are written atomically (temp + rename), so a crash leaves either the old state or the complete new one. Retries use
9
+ * exponential backoff persisted with the entry, which is what makes "close the laptop for an hour" a resume rather than a
10
+ * restart.
11
+ *
12
+ * Limits: at most 200 entries / 16 MiB. Past that the OLDEST entries are dropped and the caller sees outbox_full:true, because
13
+ * a full disk is a worse failure than a lost notification about old work.
14
+ */
15
+ import { createHash } from 'node:crypto';
16
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+
19
+ export const OUTBOX_LIMITS = Object.freeze({ maxItems: 200, maxBytes: 16 * 1024 * 1024, maxAttempts: 10, baseDelayMs: 1000, maxDelayMs: 60_000 });
20
+
21
+ const resultIdOf = (taskId, attempt) => taskId + ':' + String(attempt);
22
+
23
+ export function createResultOutbox({ dir, now = () => Date.now(), limits = OUTBOX_LIMITS }) {
24
+ if (typeof dir !== 'string' || dir === '') throw new Error('outbox dir is required');
25
+ const deadDir = join(dir, 'dead');
26
+ mkdirSync(dir, { recursive: true });
27
+ mkdirSync(deadDir, { recursive: true });
28
+
29
+ const fileOf = (resultId) => join(dir, resultId.replace(/[^A-Za-z0-9._-]/g, '_') + '.json');
30
+
31
+ const serialize = (item) => {
32
+ const envelopeJson = JSON.stringify(item.envelope ?? null);
33
+ return { ...item, payload_sha256: createHash('sha256').update(envelopeJson).digest('hex') };
34
+ };
35
+
36
+ const writeAtomic = (item) => {
37
+ const target = fileOf(item.result_id);
38
+ const temp = target + '.tmp';
39
+ writeFileSync(temp, JSON.stringify(item) + '\n', 'utf8');
40
+ renameSync(temp, target);
41
+ return target;
42
+ };
43
+
44
+ /** Read every entry on disk; a corrupt file is quarantined instead of breaking the agent on start. */
45
+ const load = () => {
46
+ const items = [];
47
+ for (const name of readdirSync(dir)) {
48
+ if (!name.endsWith('.json')) continue;
49
+ const path = join(dir, name);
50
+ try {
51
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
52
+ if (typeof parsed?.result_id !== 'string' || typeof parsed?.task_id !== 'string') throw new Error('not an outbox entry');
53
+ items.push(parsed);
54
+ } catch {
55
+ try { renameSync(path, join(deadDir, name + '.corrupt')); } catch { /* best effort */ }
56
+ }
57
+ }
58
+ items.sort((a, b) => (a.created_at_ms ?? 0) - (b.created_at_ms ?? 0) || a.result_id.localeCompare(b.result_id));
59
+ return items;
60
+ };
61
+
62
+ const sizeOf = (items) => items.reduce((sum, item) => sum + statSize(item), 0);
63
+ const statSize = (item) => Buffer.byteLength(JSON.stringify(item), 'utf8');
64
+
65
+ /** Drop the oldest entries until the new one fits. Returns how many went. */
66
+ const makeRoom = (items, incoming) => {
67
+ let dropped = 0;
68
+ while (items.length > 0 && (items.length >= limits.maxItems || sizeOf(items) + statSize(incoming) > limits.maxBytes)) {
69
+ const oldest = items.shift();
70
+ try { rmSync(fileOf(oldest.result_id), { force: true }); } catch { /* already gone */ }
71
+ dropped += 1;
72
+ }
73
+ return dropped;
74
+ };
75
+
76
+ return {
77
+ /** Persist a result; the same (task_id, attempt) is one entry, so a retried handler does not duplicate it. */
78
+ enqueue({ taskId, attempt, dedupeKey = '', envelope }) {
79
+ if (typeof taskId !== 'string' || taskId === '') throw new Error('task_id is required');
80
+ if (!Number.isInteger(attempt) || attempt < 1) throw new Error('attempt must be a positive integer');
81
+ const resultId = resultIdOf(taskId, attempt);
82
+ const existing = fileOf(resultId);
83
+ if (existsSync(existing)) {
84
+ const item = JSON.parse(readFileSync(existing, 'utf8'));
85
+ return { ok: true, duplicate: true, item };
86
+ }
87
+ const at = now();
88
+ const item = serialize({
89
+ result_id: resultId,
90
+ task_id: taskId,
91
+ attempt,
92
+ dedupe_key: typeof dedupeKey === 'string' ? dedupeKey : '',
93
+ envelope: envelope ?? null,
94
+ attempts: 0,
95
+ next_retry_ms: at,
96
+ created_at_ms: at,
97
+ created_at: new Date(at).toISOString(),
98
+ });
99
+ if (statSize(item) > limits.maxBytes) return { ok: false, code: 'OUTBOX_FULL', detail: 'the result alone exceeds the outbox byte limit', bytes: statSize(item) };
100
+ const items = load();
101
+ const dropped = makeRoom(items, item);
102
+ writeAtomic(item);
103
+ return { ok: true, item, outbox_full: dropped > 0, dropped };
104
+ },
105
+ /** Entries whose backoff expired; a caller drains these and calls ack or fail for each. */
106
+ pending() {
107
+ const at = now();
108
+ return load().filter((item) => (item.next_retry_ms ?? 0) <= at);
109
+ },
110
+ ack({ resultId }) {
111
+ const path = fileOf(resultId);
112
+ if (!existsSync(path)) return { ok: false, code: 'OUTBOX_NOT_FOUND', result_id: resultId };
113
+ rmSync(path, { force: true });
114
+ return { ok: true };
115
+ },
116
+ /** Count a failed delivery. Backoff doubles per attempt; past maxAttempts the entry moves to dead/ and is reported exhausted. */
117
+ fail({ resultId, error = null }) {
118
+ const path = fileOf(resultId);
119
+ if (!existsSync(path)) return { ok: false, code: 'OUTBOX_NOT_FOUND', result_id: resultId };
120
+ const item = JSON.parse(readFileSync(path, 'utf8'));
121
+ item.attempts = (item.attempts ?? 0) + 1;
122
+ item.last_error = error === null ? null : String(error).slice(0, 500);
123
+ if (item.attempts >= limits.maxAttempts) {
124
+ try { renameSync(path, join(deadDir, 'exhausted-' + item.result_id.replace(/[^A-Za-z0-9._-]/g, '_') + '.json')); } catch { rmSync(path, { force: true }); }
125
+ return { ok: true, exhausted: true, attempts: item.attempts };
126
+ }
127
+ const delay = Math.min(limits.baseDelayMs * 2 ** (item.attempts - 1), limits.maxDelayMs);
128
+ item.next_retry_ms = now() + delay;
129
+ writeAtomic(item);
130
+ return { ok: true, exhausted: false, attempts: item.attempts, retry_in_ms: delay };
131
+ },
132
+ stats() {
133
+ const items = load();
134
+ return { depth: items.length, bytes: sizeOf(items), limits: { maxItems: limits.maxItems, maxBytes: limits.maxBytes } };
135
+ },
136
+ list() { return load(); },
137
+ };
138
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Patch artifacts (M3, v1.3.10-beta.6 slice).
3
+ *
4
+ * A task's code change must be transportable and self-describing: the receiver needs the bytes, the hash that names them,
5
+ * and the base commit they apply to. This module turns a patch into that contract. It never applies a patch and never decides
6
+ * whether the patch is acceptable — the merge queue owns that.
7
+ */
8
+ import { createHash } from 'node:crypto';
9
+
10
+ export const PATCH_FORMAT = 'git-format-patch';
11
+
12
+ /**
13
+ * @param {{patch: string, baseCommit: string, headCommit?: string|null, filesChanged?: string[],
14
+ * tests?: object|null, outputMode?: 'inline'|'blob'}} input
15
+ */
16
+ export function describePatchArtifact(input) {
17
+ const patch = typeof input?.patch === 'string' ? input.patch : null;
18
+ if (patch === null) return { ok: false, code: 'PATCH_REQUIRED', message: 'patch text is required' };
19
+ if (typeof input.baseCommit !== 'string' || !/^[0-9a-f]{7,64}$/.test(input.baseCommit)) {
20
+ return { ok: false, code: 'BASE_COMMIT_INVALID', message: 'base_commit must be a hex commit id' };
21
+ }
22
+ const bytes = Buffer.byteLength(patch, 'utf8');
23
+ const empty = patch.trim() === '';
24
+ if (empty && input.allowEmpty !== true) {
25
+ return { ok: true, empty: true, outcome: 'no_change', format: PATCH_FORMAT, baseCommit: input.baseCommit, bytes: 0, sha256: null };
26
+ }
27
+ const sha256 = createHash('sha256').update(patch, 'utf8').digest('hex');
28
+ return {
29
+ ok: true,
30
+ empty: false,
31
+ outcome: 'changed',
32
+ format: PATCH_FORMAT,
33
+ sha256,
34
+ bytes,
35
+ outputMode: input.outputMode === 'blob' ? 'blob' : 'inline',
36
+ baseCommit: input.baseCommit,
37
+ headCommit: typeof input.headCommit === 'string' && input.headCommit !== '' ? input.headCommit : null,
38
+ filesChanged: Array.isArray(input.filesChanged) ? [...input.filesChanged].sort() : [],
39
+ tests: input.tests ?? null,
40
+ };
41
+ }
42
+
43
+ /** The fields a task result carries for a worktree task (see docs/COLLAB/TASK-MODEL.md §7). */
44
+ export function workspaceEvidence({ workspace, patchArtifact }) {
45
+ if (patchArtifact?.ok !== true) return { ok: false, code: 'PATCH_ARTIFACT_REQUIRED' };
46
+ return {
47
+ ok: true,
48
+ workspace: workspace ?? null,
49
+ artifacts: patchArtifact.empty
50
+ ? []
51
+ : [{ kind: 'patch', sha256: patchArtifact.sha256, bytes: patchArtifact.bytes, output_mode: patchArtifact.outputMode }],
52
+ };
53
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Isolated git worktrees for tasks (M3, v1.3.10-beta.6 slice).
3
+ *
4
+ * The old task model ran every command in the machine's own checkout. That cannot support two tasks that change code: they
5
+ * would see each other's partial edits, and "which base was this change made against" would have no answer. A worktree per
6
+ * task fixes both: the starting point is a commit the caller named, and everything the task changes is confined to a
7
+ * directory the task owns.
8
+ *
9
+ * All git calls use argv arrays; nothing here goes through a shell, so a branch name or path cannot become a command.
10
+ */
11
+ import { execFile } from 'node:child_process';
12
+ import { existsSync, mkdirSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+
15
+ export function defaultGit() {
16
+ return (args, cwd) => new Promise((resolve, reject) => {
17
+ execFile('git', args, { cwd, timeout: 120_000, maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => {
18
+ if (error) reject(Object.assign(new Error(String(stderr || error.message).trim()), { stdout, stderr }));
19
+ else resolve({ stdout: String(stdout), stderr: String(stderr) });
20
+ });
21
+ });
22
+ }
23
+
24
+ /**
25
+ * Create a detached worktree at an exact commit and make it safe to run an agent in.
26
+ *
27
+ * @returns {Promise<{ok: true, worktreeDir: string, baseCommit: string} | {ok: false, code: string, message: string}>}
28
+ */
29
+ export async function createWorktree({ repoDir, baseCommit, worktreeDir, git = defaultGit() }) {
30
+ if (!existsSync(repoDir)) return { ok: false, code: 'REPO_NOT_FOUND', message: 'repository directory does not exist' };
31
+ if (typeof baseCommit !== 'string' || !/^[0-9a-f]{7,64}$/.test(baseCommit)) return { ok: false, code: 'BASE_COMMIT_INVALID', message: 'base_commit must be a hex commit id' };
32
+ if (typeof worktreeDir !== 'string' || worktreeDir === '') return { ok: false, code: 'WORKTREE_PATH_REQUIRED', message: 'worktreeDir is required' };
33
+ try {
34
+ await git(['rev-parse', '--verify', baseCommit + '^{commit}'], repoDir);
35
+ } catch {
36
+ return { ok: false, code: 'BASE_COMMIT_UNKNOWN', message: 'base_commit does not resolve in this repository' };
37
+ }
38
+ try {
39
+ mkdirSync(join(worktreeDir, '..'), { recursive: true });
40
+ await git(['worktree', 'add', '--detach', worktreeDir, baseCommit], repoDir);
41
+ } catch (error) {
42
+ return { ok: false, code: 'WORKTREE_CREATE_FAILED', message: error?.message ?? 'git worktree add failed' };
43
+ }
44
+ // The worktree's configuration is task-local: hooks cannot run a task's code behind its back, and commits must be
45
+ // reproducible rather than attributed to whoever happens to be logged in.
46
+ const isolates = [
47
+ ['config', 'core.hooksPath', join(worktreeDir, '.no-hooks')],
48
+ ['config', 'user.name', 'w2m-agent'],
49
+ ['config', 'user.email', 'agent@w2m.invalid'],
50
+ ['config', 'commit.gpgsign', 'false'],
51
+ ];
52
+ for (const argv of isolates) {
53
+ try { await git(argv, worktreeDir); } catch { /* configuration is defense in depth; git add already succeeded */ }
54
+ }
55
+ return { ok: true, worktreeDir, baseCommit };
56
+ }
57
+
58
+ /** A porcelain snapshot, used before and after a task as evidence of what changed. */
59
+ export async function snapshotStatus({ worktreeDir, git = defaultGit() }) {
60
+ try {
61
+ const { stdout } = await git(['status', '--porcelain'], worktreeDir);
62
+ return { ok: true, dirty: stdout.trim() !== '', status: stdout };
63
+ } catch (error) {
64
+ return { ok: false, code: 'STATUS_FAILED', message: error?.message ?? 'git status failed' };
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Commit whatever the task changed, if anything.
70
+ *
71
+ * @returns {Promise<{ok: true, changed: boolean, headCommit: string|null, message: string|null} | {ok: false, code: string, message: string}>}
72
+ */
73
+ export async function commitTaskChanges({ worktreeDir, message = 'task output', git = defaultGit() }) {
74
+ const before = await snapshotStatus({ worktreeDir, git });
75
+ if (before.ok !== true) return { ok: false, code: 'STATUS_FAILED', message: before.message };
76
+ if (before.dirty === false) {
77
+ const head = await git(['rev-parse', 'HEAD'], worktreeDir).then((r) => r.stdout.trim()).catch(() => null);
78
+ return { ok: true, changed: false, headCommit: head, message: null };
79
+ }
80
+ try {
81
+ await git(['add', '--all'], worktreeDir);
82
+ await git(['commit', '-m', message], worktreeDir);
83
+ const head = (await git(['rev-parse', 'HEAD'], worktreeDir)).stdout.trim();
84
+ return { ok: true, changed: true, headCommit: head, message };
85
+ } catch (error) {
86
+ return { ok: false, code: 'COMMIT_FAILED', message: error?.message ?? 'git commit failed' };
87
+ }
88
+ }
89
+
90
+ /** Produce the transportable form of the change: a patch relative to the task's base commit. */
91
+ export async function producePatch({ worktreeDir, baseCommit, git = defaultGit() }) {
92
+ try {
93
+ const { stdout } = await git(['format-patch', '--stdout', '--binary', baseCommit], worktreeDir);
94
+ return { ok: true, patch: stdout, bytes: Buffer.byteLength(stdout, 'utf8'), empty: stdout.trim() === '' };
95
+ } catch (error) {
96
+ return { ok: false, code: 'PATCH_FAILED', message: error?.message ?? 'git format-patch failed' };
97
+ }
98
+ }
99
+
100
+ /** Remove a worktree. Failure is reported, never hidden: a leaked worktree blocks the next task on that path. */
101
+ export async function listChangedFiles({ worktreeDir, baseCommit, git = defaultGit() }) {
102
+ try {
103
+ const result = await git(['diff', '--name-only', baseCommit + '..HEAD'], worktreeDir);
104
+ return { ok: true, files: String(result.stdout).split(String.fromCharCode(10)).map((line) => line.trim()).filter((line) => line !== '').sort() };
105
+ } catch (error) {
106
+ return { ok: false, code: 'DIFF_FAILED', message: error?.message ?? 'git diff failed' };
107
+ }
108
+ }
109
+
110
+ export async function removeWorktree({ repoDir, worktreeDir, force = true, git = defaultGit() }) {
111
+ try {
112
+ const args = ['worktree', 'remove', ...(force ? ['--force'] : []), worktreeDir];
113
+ await git(args, repoDir);
114
+ return { ok: true };
115
+ } catch (error) {
116
+ return { ok: false, code: 'WORKTREE_REMOVE_FAILED', message: error?.message ?? 'git worktree remove failed' };
117
+ }
118
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * The three fixed maintenance actions (v1.3.2-beta, M1).
3
+ *
4
+ * Everything here is executed by the root runner, so the action set is closed by construction: update/restart/diag are
5
+ * functions with fixed argv, and no value from a request is ever interpolated into a command line. Parameters name
6
+ * versions, hashes, URLs and unit selections; they do not become shell text.
7
+ */
8
+ import { execFile as execFileCb } from 'node:child_process';
9
+ import { createHash } from 'node:crypto';
10
+ import { existsSync, mkdirSync, readFileSync, rmSync, renameSync, writeFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { tmpdir } from 'node:os';
13
+ import { dirname, join as joinPath } from 'node:path';
14
+
15
+ export const UNITS = Object.freeze({
16
+ rabbit: 'w2m-rabbit.service',
17
+ agent: 'w2m-localside.service',
18
+ stun: 'w2m-stun.service',
19
+ });
20
+
21
+ const MAX_DOWNLOAD_BYTES = 200 * 1024 * 1024;
22
+ const MAX_SECTION_BYTES = 32 * 1024;
23
+ const MAX_TOTAL_DIAG_BYTES = 256 * 1024;
24
+
25
+ export function defaultDeps(overrides = {}) {
26
+ return {
27
+ platform: process.platform,
28
+ // The staging tree must be on the SAME filesystem as the app directory: swap is a rename, and /tmp is often tmpfs,
29
+ // so renaming across the boundary fails with EXDEV after the download and extraction already succeeded.
30
+ appDir: process.platform === 'win32' ? 'C:\\w2m\\app' : '/opt/w2m/app',
31
+ stagingRoot: process.platform === 'win32' ? 'C:\\w2m\\staging' : joinPath(dirname('/opt/w2m/app'), '.maintenance-staging'),
32
+ backupDir: process.platform === 'win32' ? 'C:\\w2m\\app.old' : '/opt/w2m/app.old',
33
+ healthzUrl: 'http://127.0.0.1:8787/healthz',
34
+ sleep: (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }),
35
+ now: () => Date.now(),
36
+ fetch: (...args) => globalThis.fetch(...args),
37
+ execFile: (command, argv, options = {}) => new Promise((resolve, reject) => {
38
+ execFileCb(command, argv, { timeout: 60_000, maxBuffer: 4 * 1024 * 1024, ...options }, (error, stdout, stderr) => {
39
+ if (error) reject(Object.assign(error, { stdout, stderr }));
40
+ else resolve({ stdout, stderr });
41
+ });
42
+ }),
43
+ ...overrides,
44
+ };
45
+ }
46
+
47
+ async function run(deps, command, argv) {
48
+ try {
49
+ const result = await deps.execFile(command, argv);
50
+ return { ok: true, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
51
+ } catch (error) {
52
+ return { ok: false, stdout: error?.stdout ?? '', stderr: error?.stderr ?? String(error?.message ?? error) };
53
+ }
54
+ }
55
+
56
+ function clip(text) {
57
+ const value = String(text ?? '');
58
+ if (Buffer.byteLength(value, 'utf8') <= MAX_SECTION_BYTES) return value;
59
+ let cut = value;
60
+ while (Buffer.byteLength(cut, 'utf8') > MAX_SECTION_BYTES) cut = cut.slice(0, -1024);
61
+ return cut + '\n[truncated]';
62
+ }
63
+
64
+ async function diag(deps, _params) {
65
+ const sections = {};
66
+ const commands = {
67
+ version: ['sh', ['-c', `cat ${deps.appDir}/package.json`]],
68
+ disk: ['df', ['-h', '/opt', '/var/lib/w2m']],
69
+ ports: ['ss', ['-ltn']],
70
+ };
71
+ for (const key of Object.keys(commands)) {
72
+ const result = await run(deps, commands[key][0], commands[key][1]);
73
+ sections[key] = clip(result.ok ? result.stdout : `ERROR: ${result.stderr}`);
74
+ }
75
+ for (const unit of Object.values(UNITS)) {
76
+ const active = await run(deps, 'systemctl', ['is-active', unit]);
77
+ sections[`service:${unit}`] = clip(active.ok ? active.stdout : `inactive (${active.stderr.trim()})`);
78
+ const logs = await run(deps, 'journalctl', ['-u', unit, '-n', '100', '--no-pager']);
79
+ sections[`journal:${unit}`] = clip(logs.ok ? logs.stdout : `ERROR: ${logs.stderr}`);
80
+ }
81
+ let total = 0;
82
+ for (const key of Object.keys(sections)) total += Buffer.byteLength(sections[key], 'utf8');
83
+ return { status: 'ok', detail: { sections, bytes: total, truncated: total > MAX_TOTAL_DIAG_BYTES } };
84
+ }
85
+
86
+ async function waitFor(deps, probe, timeoutMs, intervalMs = 2000) {
87
+ const deadline = deps.now() + timeoutMs;
88
+ while (deps.now() < deadline) {
89
+ if (await probe()) return true;
90
+ await deps.sleep(intervalMs);
91
+ }
92
+ return false;
93
+ }
94
+
95
+ async function healthy(deps) {
96
+ try {
97
+ const response = await deps.fetch(deps.healthzUrl);
98
+ return response.status === 200;
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ async function restart(deps, params) {
105
+ const targets = params.targets === 'all' ? ['rabbit', 'stun', 'agent'] : [params.targets];
106
+ const states = {};
107
+ for (const target of targets) {
108
+ const unit = UNITS[target];
109
+ const result = await run(deps, 'systemctl', ['restart', unit]);
110
+ if (!result.ok) return { status: 'failed', code: 'RESTART_FAILED', detail: { unit, stderr: result.stderr } };
111
+ const active = await run(deps, 'systemctl', ['is-active', unit]);
112
+ states[target] = active.ok ? active.stdout.trim() : 'inactive';
113
+ }
114
+ if (params.wait_healthy === false) return { status: 'ok', detail: { states } };
115
+ if (targets.includes('rabbit')) {
116
+ const ok = await waitFor(deps, () => healthy(deps), 60_000);
117
+ if (!ok) return { status: 'failed', code: 'HEALTH_TIMEOUT', detail: { states, probe: deps.healthzUrl } };
118
+ }
119
+ return { status: 'ok', detail: { states, waited: true } };
120
+ }
121
+
122
+ async function sha256Of(buffer) {
123
+ return createHash('sha256').update(buffer).digest('hex');
124
+ }
125
+
126
+ async function downloadTo(deps, urls, sha256) {
127
+ const errors = [];
128
+ for (const url of urls) {
129
+ try {
130
+ const response = await deps.fetch(url);
131
+ if (response.status !== 200) { errors.push(`${url}: HTTP ${response.status}`); continue; }
132
+ const buffer = Buffer.from(await response.arrayBuffer());
133
+ if (buffer.byteLength > MAX_DOWNLOAD_BYTES) { errors.push(`${url}: too large (${buffer.byteLength})`); continue; }
134
+ const digest = await sha256Of(buffer);
135
+ if (digest !== sha256) { errors.push(`${url}: sha256 ${digest} != expected`); continue; }
136
+ const file = join(tmpdir(), 'w2m-maintenance-' + Date.now() + '.tgz');
137
+ writeFileSync(file, buffer);
138
+ return { ok: true, file, bytes: buffer.byteLength };
139
+ } catch (error) {
140
+ errors.push(`${url}: ${error?.message ?? String(error)}`);
141
+ }
142
+ }
143
+ return { ok: false, errors };
144
+ }
145
+
146
+ async function update(deps, params, request) {
147
+ const downloaded = await downloadTo(deps, params.artifact_urls, params.artifact_sha256);
148
+ if (!downloaded.ok) return { status: 'failed', code: 'UPDATE_DOWNLOAD_FAILED', detail: { errors: downloaded.errors } };
149
+
150
+ const staging = join(deps.stagingRoot, 'w2m-maintenance-' + request.request_id);
151
+ try { rmSync(staging, { recursive: true, force: true }); } catch { /* staging may not exist */ }
152
+ mkdirSync(staging, { recursive: true });
153
+ const extracted = await run(deps, 'tar', ['-xzf', downloaded.file, '-C', staging]);
154
+ if (!extracted.ok) return { status: 'failed', code: 'UPDATE_EXTRACT_FAILED', detail: { stderr: extracted.stderr } };
155
+
156
+ const stagedPkg = join(staging, 'package', 'package.json');
157
+ if (!existsSync(stagedPkg)) return { status: 'failed', code: 'UPDATE_BAD_ARTIFACT', detail: { message: 'package/package.json missing' } };
158
+ const stagedVersion = JSON.parse(readFileSync(stagedPkg, 'utf8')).version;
159
+ if (stagedVersion !== params.version) return { status: 'failed', code: 'UPDATE_VERSION_MISMATCH', detail: { expected: params.version, artifact: stagedVersion } };
160
+
161
+ let rolledBack = false;
162
+ const hadPrevious = existsSync(deps.appDir);
163
+ if (hadPrevious) {
164
+ try { rmSync(deps.backupDir, { recursive: true, force: true }); } catch { /* old backup is replaceable */ }
165
+ try { renameSync(deps.appDir, deps.backupDir); } catch (error) { return { status: 'failed', code: 'UPDATE_BACKUP_FAILED', detail: { message: error?.message } }; }
166
+ }
167
+ try {
168
+ renameSync(join(staging, 'package'), deps.appDir);
169
+ } catch (error) {
170
+ if (hadPrevious) { try { renameSync(deps.backupDir, deps.appDir); } catch { /* rollback attempted */ } }
171
+ return { status: 'failed', code: 'UPDATE_SWAP_FAILED', detail: { message: error?.message } };
172
+ }
173
+
174
+ const restarted = params.restart_after_update !== false;
175
+ if (restarted) {
176
+ const units = params.target === 'relay' ? ['rabbit', 'stun', 'agent'] : ['agent'];
177
+ for (const target of units) {
178
+ const result = await run(deps, 'systemctl', ['restart', UNITS[target]]);
179
+ if (!result.ok) { rolledBack = true; break; }
180
+ }
181
+ if (!rolledBack) {
182
+ const healthOk = units.includes('rabbit') ? await waitFor(deps, () => healthy(deps), 60_000) : true;
183
+ if (!healthOk) rolledBack = true;
184
+ }
185
+ }
186
+ if (rolledBack) {
187
+ try {
188
+ rmSync(deps.appDir, { recursive: true, force: true });
189
+ renameSync(deps.backupDir, deps.appDir);
190
+ await run(deps, 'systemctl', ['restart', UNITS.agent]);
191
+ } catch { /* rollback best effort, reported below */ }
192
+ return { status: 'failed', code: 'UPDATE_ROLLED_BACK', detail: { version: params.version, rolled_back: true } };
193
+ }
194
+ return { status: 'ok', detail: { version: stagedVersion, sha256: params.artifact_sha256, bytes: downloaded.bytes, restarted, backup: hadPrevious ? deps.backupDir : null } };
195
+ }
196
+
197
+ /** Execute one verified request. Returns a result the runner writes to the done directory. */
198
+ export async function executeAction(action, params, request, deps = defaultDeps()) {
199
+ if (action === 'diag') return diag(deps, params);
200
+ if (action === 'restart') return restart(deps, params);
201
+ if (action === 'update') return update(deps, params, request);
202
+ return { status: 'failed', code: 'MAINTENANCE_BAD_ACTION', detail: { action } };
203
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Task-level execution grants (v1.3.10-beta.3 / M2 design slice).
3
+ *
4
+ * A grant is the operator's explicit, single-use authorisation for one argv on named machines. It is checked by the agent
5
+ * before the allow-list: an allow-list entry is a standing decision made by the machine owner, while a grant is a decision
6
+ * made now, for this, with an expiry — and the ledger can tell them apart afterwards.
7
+ *
8
+ * This module is the protocol only. Signing lives on the control side; verification runs on the machine that would execute
9
+ * the command. Nothing here executes anything.
10
+ */
11
+ import { createHash, createPrivateKey, createPublicKey, sign as signBytes, verify as verifyBytes } from 'node:crypto';
12
+ import { canonicalize } from './protocol.mjs';
13
+ import { keyIdOf } from './operator-key.mjs';
14
+
15
+ export const GRANT_PROTOCOL = 'w2m-grant/v1';
16
+ export const MAX_GRANT_BYTES = 8 * 1024;
17
+ export const MAX_GRANT_TTL_MS = 10 * 60 * 1000;
18
+ export const CLOCK_SKEW_MS = 60 * 1000;
19
+
20
+ const OBJ = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
21
+ const STR = (value) => typeof value === 'string' && value !== '';
22
+
23
+ export function argvHash(argv) {
24
+ return createHash('sha256').update(JSON.stringify(argv), 'utf8').digest('hex');
25
+ }
26
+
27
+ /**
28
+ * Build the unsigned grant body. The caller supplies the expanded argv (the operator must see what is being authorised).
29
+ */
30
+ export function buildGrant({ taskId = null, machineIds, argv, mode, cwdRel, write, writeScope, maxUses = 1, ttlMs = 5 * 60 * 1000, nowMs = Date.now(), issuedBy = 'operator', grantId, nonce }) {
31
+ const grant = {
32
+ protocol: GRANT_PROTOCOL,
33
+ grant_id: grantId ?? 'gr-' + nowMs.toString(36) + '-' + createHash('sha256').update(String(Math.random())).digest('hex').slice(0, 10),
34
+ task_id: taskId,
35
+ machine_ids: [...machineIds],
36
+ argv_hash: argvHash(argv),
37
+ argv_head: argv.slice(0, 8).map((part) => String(part).slice(0, 160)),
38
+ max_uses: maxUses,
39
+ issued_at: new Date(nowMs).toISOString(),
40
+ expires_at: new Date(nowMs + ttlMs).toISOString(),
41
+ nonce: nonce ?? createHash('sha256').update(String(Math.random()) + String(nowMs)).digest('hex').slice(0, 32),
42
+ issued_by: issuedBy,
43
+ };
44
+ if (mode !== undefined) grant.mode = mode;
45
+ if (cwdRel !== undefined) grant.cwd_rel = cwdRel;
46
+ if (write !== undefined) grant.write = write;
47
+ if (writeScope !== undefined) grant.write_scope = writeScope;
48
+ return grant;
49
+ }
50
+
51
+ export function signingPayload(grant) {
52
+ const { signature: _signature, ...rest } = grant;
53
+ return canonicalize(rest);
54
+ }
55
+
56
+ /** Sign on the control side. The private key never leaves the operator's machine. */
57
+ export function signGrant(grant, privateKeyPem) {
58
+ const privateKey = createPrivateKey(privateKeyPem);
59
+ const signature = signBytes(null, Buffer.from(signingPayload(grant), 'utf8'), privateKey);
60
+ const publicKey = createPublicKey(privateKey);
61
+ return { ...grant, signature: { alg: 'ed25519', key_id: keyIdOf(publicKey), value_base64: signature.toString('base64') } };
62
+ }
63
+
64
+ function checkConstraints(grant, context) {
65
+ const policy = context?.policy ?? {};
66
+ if (grant.mode !== undefined && typeof policy.mode === 'string' && grant.mode !== policy.mode) return { ok: false, code: 'GRANT_CONSTRAINT_VIOLATION', detail: { field: 'mode', grant: grant.mode, task: policy.mode } };
67
+ if (grant.cwd_rel !== undefined && typeof policy.cwd_rel === 'string' && grant.cwd_rel !== policy.cwd_rel) return { ok: false, code: 'GRANT_CONSTRAINT_VIOLATION', detail: { field: 'cwd_rel', grant: grant.cwd_rel, task: policy.cwd_rel } };
68
+ if (grant.write === true && policy.write !== true) return { ok: false, code: 'GRANT_CONSTRAINT_VIOLATION', detail: { field: 'write', reason: 'a grant cannot widen a read-only task' } };
69
+ if (Array.isArray(grant.write_scope) && Array.isArray(policy.write_scope)) {
70
+ for (const scope of grant.write_scope) if (!policy.write_scope.includes(scope)) return { ok: false, code: 'GRANT_CONSTRAINT_VIOLATION', detail: { field: 'write_scope', scope } };
71
+ }
72
+ return { ok: true };
73
+ }
74
+
75
+ /**
76
+ * Verify a grant against the concrete action about to run.
77
+ *
78
+ * @param {object} grant parsed JSON (untrusted)
79
+ * @param {{publicKeyPem: string, expectedKeyId?: string, nowMs?: number, machineId: string, taskId?: string|null,
80
+ * argv: string[], usedGrantIds?: Set<string>, usedNonces?: Set<string>, usedCount?: number,
81
+ * isRevoked?: (keyId: string) => boolean, policy?: object}} context
82
+ * @returns {{ok: true, grant: object} | {ok: false, code: string, detail: object}}
83
+ */
84
+ export function verifyGrant(grant, context) {
85
+ const nowMs = Number.isInteger(context?.nowMs) ? context.nowMs : Date.now();
86
+ const fail = (code, message, detail = {}) => ({ ok: false, code, detail: { message, ...detail } });
87
+
88
+ if (!OBJ(grant)) return fail('GRANT_BAD_SHAPE', 'grant must be a JSON object');
89
+ if (Buffer.byteLength(JSON.stringify(grant), 'utf8') > MAX_GRANT_BYTES) return fail('GRANT_TOO_LARGE', `grant exceeds ${MAX_GRANT_BYTES} bytes`);
90
+ if (grant.protocol !== GRANT_PROTOCOL) return fail('GRANT_UNSUPPORTED_VERSION', 'unknown grant protocol', { got: String(grant.protocol) });
91
+ if (!STR(grant.grant_id) || !STR(grant.nonce)) return fail('GRANT_BAD_SHAPE', 'grant_id and nonce are required');
92
+ if (!Array.isArray(grant.machine_ids) || grant.machine_ids.length === 0) return fail('GRANT_BAD_SHAPE', 'machine_ids must be a non-empty array');
93
+ if (!Number.isInteger(grant.max_uses) || grant.max_uses < 1 || grant.max_uses > 100) return fail('GRANT_BAD_SHAPE', 'max_uses must be an integer in 1..100');
94
+ if (!/^[0-9a-f]{64}$/.test(String(grant.argv_hash ?? ''))) return fail('GRANT_BAD_SHAPE', 'argv_hash must be 64 lowercase hex');
95
+
96
+ const signature = grant.signature;
97
+ if (!OBJ(signature) || signature.alg !== 'ed25519' || !STR(signature.value_base64) || !STR(signature.key_id)) {
98
+ return fail('GRANT_MISSING_SIGNATURE', 'grant carries no usable ed25519 signature');
99
+ }
100
+ if (context?.expectedKeyId !== undefined && signature.key_id !== context.expectedKeyId) {
101
+ return fail('GRANT_UNKNOWN_KEY', 'grant was signed by a key this machine has not pinned', { key_id: signature.key_id, pinned: context.expectedKeyId });
102
+ }
103
+ if (typeof context?.isRevoked === 'function' && context.isRevoked(signature.key_id) === true) {
104
+ return fail('GRANT_REVOKED', 'signing key has been revoked', { key_id: signature.key_id });
105
+ }
106
+
107
+ let publicKey;
108
+ try { publicKey = createPublicKey(context?.publicKeyPem ?? ''); } catch { return fail('GRANT_UNKNOWN_KEY', 'no usable operator public key is configured'); }
109
+ let ok = false;
110
+ try { ok = verifyBytes(null, Buffer.from(signingPayload(grant), 'utf8'), publicKey, Buffer.from(signature.value_base64, 'base64')); } catch { ok = false; }
111
+ if (ok !== true) return fail('GRANT_BAD_SIGNATURE', 'signature does not cover this grant');
112
+
113
+ if (!STR(context?.machineId)) return fail('GRANT_BAD_SHAPE', 'machine binding is missing');
114
+ if (!grant.machine_ids.includes(context.machineId)) return fail('GRANT_WRONG_MACHINE', 'grant does not name this machine', { machine_ids: grant.machine_ids, local: context.machineId });
115
+
116
+ const issuedAt = Date.parse(String(grant.issued_at ?? ''));
117
+ const expiresAt = Date.parse(String(grant.expires_at ?? ''));
118
+ if (!Number.isFinite(issuedAt) || !Number.isFinite(expiresAt) || expiresAt <= issuedAt) return fail('GRANT_BAD_SHAPE', 'issued_at/expires_at are not a usable window');
119
+ if (expiresAt - issuedAt > MAX_GRANT_TTL_MS) return fail('GRANT_TTL_TOO_LONG', 'grant lives longer than the maximum TTL', { ttl_ms: expiresAt - issuedAt, max_ttl_ms: MAX_GRANT_TTL_MS });
120
+ if (issuedAt > nowMs + CLOCK_SKEW_MS) return fail('GRANT_FUTURE', 'grant is not valid yet', { issued_at: grant.issued_at });
121
+ if (nowMs > expiresAt) return fail('GRANT_EXPIRED', 'grant has expired', { expires_at: grant.expires_at });
122
+
123
+ if (grant.task_id !== null && grant.task_id !== undefined) {
124
+ if (typeof context?.taskId !== 'string' || context.taskId !== grant.task_id) return fail('GRANT_WRONG_TASK', 'grant is bound to a different task', { grant_task: grant.task_id, task: context?.taskId ?? null });
125
+ }
126
+ if (context?.usedGrantIds instanceof Set && context.usedGrantIds.has(grant.grant_id)) return fail('GRANT_REPLAY', 'grant_id was already used', { grant_id: grant.grant_id });
127
+ if (context?.usedNonces instanceof Set && context.usedNonces.has(grant.nonce)) return fail('GRANT_REPLAY', 'nonce was already used', { nonce: grant.nonce });
128
+ const used = Number.isInteger(context?.usedCount) ? context.usedCount : 0;
129
+ if (used >= grant.max_uses) return fail('GRANT_USES_EXHAUSTED', 'grant has no uses left', { max_uses: grant.max_uses, used });
130
+
131
+ if (!Array.isArray(context?.argv)) return fail('GRANT_BAD_SHAPE', 'the concrete argv is required for the hash comparison');
132
+ const actualHash = argvHash(context.argv);
133
+ if (actualHash !== grant.argv_hash) return fail('GRANT_ARGV_MISMATCH', 'argv_hash does not match the command about to run', { expected: grant.argv_hash, actual: actualHash });
134
+
135
+ const constraints = checkConstraints(grant, context);
136
+ if (constraints.ok !== true) return { ok: false, code: constraints.code, detail: constraints.detail };
137
+
138
+ return { ok: true, grant };
139
+ }