@twinsearth/w2m-dsh-plugin 1.3.1 → 1.3.11
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 +588 -0
- package/bin/w2m-maintenance.mjs +112 -0
- package/codex/mcp-servers.toml +1 -1
- package/deploy/maintenance/bootstrap.ps1 +30 -0
- package/deploy/maintenance/bootstrap.sh +59 -0
- package/deploy/server/w2m-maintenance.path +10 -0
- package/deploy/server/w2m-maintenance.service +20 -0
- package/deploy/windows/w2m-maintenance.xml +31 -0
- package/docs/COLLAB/COLLABORATION-MODEL.md +240 -0
- package/docs/COLLAB/EXECUTION-AUTH-MODEL.md +250 -0
- package/docs/COLLAB/M1-ACCEPTANCE-RUNBOOK.md +70 -0
- package/docs/COLLAB/M1-MAINTENANCE.md +313 -0
- package/docs/COLLAB/M2-TASK-GRANT.md +231 -0
- package/docs/COLLAB/M3-GIT-NATIVE-TASKS.md +291 -0
- package/docs/COLLAB/M4-PROJECT-ROOM.md +260 -0
- package/docs/COLLAB/M5-MULTI-CLIENT-PRIMITIVES.md +263 -0
- package/docs/COLLAB/M6-CROSS-NETWORK-RELIABILITY.md +231 -0
- package/docs/COLLAB/M7-METRICS-ACCEPTANCE.md +263 -0
- package/docs/COLLAB/TASK-MODEL.md +281 -0
- package/docs/MULTI-MACHINE-COLLABORATION-PLAN.md +186 -0
- package/docs/RELEASE-STABLE.md +193 -0
- package/docs/acceptance/1.3.10-beta.10/M1-LIVE-DIAG.json +27 -0
- package/docs/acceptance/1.3.10-beta.10/M1-LIVE-POSTUPDATE-DIAG.json +18 -0
- package/docs/acceptance/1.3.10-beta.10/M1-LIVE-RESTART.json +24 -0
- package/docs/acceptance/1.3.10-beta.10/M1-LIVE-UPDATE.json +24 -0
- package/docs/acceptance/1.3.11/M1-LIVE-DIAG.json +26 -0
- package/package.json +22 -4
- package/plugin.json +1 -1
- package/src/acceptance/evidence.mjs +79 -0
- package/src/agent/agent.mjs +64 -2
- package/src/agent/grant-gate.mjs +82 -0
- package/src/agent/maintenance-ferry.mjs +111 -0
- package/src/agent/outbox.mjs +138 -0
- package/src/agent/patch-artifact.mjs +53 -0
- package/src/agent/worktree.mjs +109 -0
- package/src/maintenance/actions.mjs +203 -0
- package/src/maintenance/grant.mjs +139 -0
- package/src/maintenance/operator-key.mjs +151 -0
- package/src/maintenance/protocol.mjs +204 -0
- package/src/maintenance/runner.mjs +189 -0
- package/src/plugin/tools.mjs +1 -1
- package/src/relay/blob-sessions.mjs +148 -0
- package/src/relay/merge-queue.mjs +137 -0
- package/src/relay/project-locks.mjs +179 -0
- package/src/relay/project-notifications.mjs +182 -0
- package/src/relay/project-reviews.mjs +161 -0
- package/src/relay/project-room.mjs +163 -0
- package/src/relay/reliability-metrics.mjs +180 -0
- package/src/relay/server.mjs +164 -1
- package/src/relay/state.mjs +128 -1
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator signing keys: generation, pinning and rotation for the maintenance channel (M1, v1.3.2-beta).
|
|
3
|
+
*
|
|
4
|
+
* The operator key is the only thing that can authorise a maintenance action, so it must not be the same credential as
|
|
5
|
+
* the operator token used to dispatch tasks: a leaked dispatch token should not become a leaked authorisation.
|
|
6
|
+
*
|
|
7
|
+
* Target machines pin the operator public key. First contact is trust-on-first-use (the relay hands out the registered
|
|
8
|
+
* key), and every later change must be signed by the currently pinned key — the rotation chain. That turns a key swap
|
|
9
|
+
* from a silent event into a refusal unless the previous key authorised it.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash, generateKeyPairSync, sign as signBytes, verify as verifyBytes, createPublicKey, createPrivateKey } from 'node:crypto';
|
|
12
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
|
|
13
|
+
import { dirname } from 'node:path';
|
|
14
|
+
|
|
15
|
+
export const OPERATOR_ROTATION_PROTOCOL = 'w2m-operator-rotation/v1';
|
|
16
|
+
|
|
17
|
+
export class OperatorKeyError extends Error {
|
|
18
|
+
constructor(code, message, detail = null) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'OperatorKeyError';
|
|
21
|
+
this.code = code;
|
|
22
|
+
this.detail = detail;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function keyIdOf(publicKeyLike) {
|
|
27
|
+
const key = publicKeyLike?.type === 'public' ? publicKeyLike : createPublicKey(publicKeyLike);
|
|
28
|
+
return createHash('sha256').update(key.export({ type: 'spki', format: 'der' })).digest('hex').slice(0, 16);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Create a fresh Ed25519 operator key pair. The caller is responsible for storing the private half safely. */
|
|
32
|
+
export function generateOperatorKeyPair() {
|
|
33
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
34
|
+
return {
|
|
35
|
+
keyId: keyIdOf(publicKey),
|
|
36
|
+
publicKeyPem: publicKey.export({ type: 'spki', format: 'pem' }).toString(),
|
|
37
|
+
privateKeyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function publicKeyPemOf(privateKeyPem) {
|
|
42
|
+
return createPublicKey(createPrivateKey(privateKeyPem)).export({ type: 'spki', format: 'pem' }).toString();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What the control side sends to the relay so paired machines can pin the key. It is not a secret. */
|
|
46
|
+
export function registrationPayload(keyPair, nowIso = new Date().toISOString()) {
|
|
47
|
+
return {
|
|
48
|
+
protocol: 'w2m-operator-key/v1',
|
|
49
|
+
key_id: keyPair.keyId,
|
|
50
|
+
public_key_pem: keyPair.publicKeyPem,
|
|
51
|
+
created_at: nowIso,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The exact bytes signed when rotating to a new key. */
|
|
56
|
+
export function rotationPayload({ oldKeyId, newKeyId, newPublicKeyPem }) {
|
|
57
|
+
return JSON.stringify({ protocol: OPERATOR_ROTATION_PROTOCOL, old_key_id: oldKeyId, new_key_id: newKeyId, new_public_key_pem: newPublicKeyPem });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A target machine's pin store. One file, atomically replaced; the whole lifecycle (pin, rotate, revoke) is expressed
|
|
62
|
+
* here so the runner never touches the file format directly.
|
|
63
|
+
*/
|
|
64
|
+
export function createPinStore(filePath, { now = () => Date.now() } = {}) {
|
|
65
|
+
const empty = () => ({ protocol: 'w2m-operator-pins/v1', pinned: null, revoked: [], history: [] });
|
|
66
|
+
const load = () => {
|
|
67
|
+
if (!existsSync(filePath)) return empty();
|
|
68
|
+
try {
|
|
69
|
+
const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
70
|
+
return {
|
|
71
|
+
protocol: 'w2m-operator-pins/v1',
|
|
72
|
+
pinned: parsed.pinned ?? null,
|
|
73
|
+
revoked: Array.isArray(parsed.revoked) ? parsed.revoked : [],
|
|
74
|
+
history: Array.isArray(parsed.history) ? parsed.history : [],
|
|
75
|
+
};
|
|
76
|
+
} catch {
|
|
77
|
+
// A corrupt pin file must not be silently replaced: refusing is the only safe reading of "I cannot tell which key
|
|
78
|
+
// is trusted". The runner reports this as a failure rather than continuing with an empty pin.
|
|
79
|
+
throw new OperatorKeyError('PINS_CORRUPT', 'operator pin store is unreadable; refusing to trust any key');
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
const save = (state) => {
|
|
83
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
84
|
+
const tmp = filePath + '.tmp-' + process.pid;
|
|
85
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', 'utf8');
|
|
86
|
+
renameSync(tmp, filePath);
|
|
87
|
+
};
|
|
88
|
+
const isRevoked = (keyId) => {
|
|
89
|
+
const state = load();
|
|
90
|
+
return state.revoked.some((entry) => entry.key_id === keyId);
|
|
91
|
+
};
|
|
92
|
+
return {
|
|
93
|
+
filePath,
|
|
94
|
+
load,
|
|
95
|
+
isRevoked,
|
|
96
|
+
/**
|
|
97
|
+
* Pin a key after checking a registration payload. First pin is TOFU; a different pinned key requires rotation.
|
|
98
|
+
*/
|
|
99
|
+
pinFromRegistration(payload) {
|
|
100
|
+
if (payload === null || typeof payload !== 'object' || typeof payload.public_key_pem !== 'string') {
|
|
101
|
+
throw new OperatorKeyError('BAD_REGISTRATION', 'operator registration payload is malformed');
|
|
102
|
+
}
|
|
103
|
+
const keyId = keyIdOf(payload.public_key_pem);
|
|
104
|
+
if (typeof payload.key_id === 'string' && payload.key_id !== keyId) {
|
|
105
|
+
throw new OperatorKeyError('KEY_ID_MISMATCH', 'registration key_id does not match its public key', { claimed: payload.key_id, computed: keyId });
|
|
106
|
+
}
|
|
107
|
+
if (this.isRevoked(keyId)) throw new OperatorKeyError('KEY_REVOKED', 'operator key is revoked', { key_id: keyId });
|
|
108
|
+
const state = load();
|
|
109
|
+
if (state.pinned === null) {
|
|
110
|
+
state.pinned = { key_id: keyId, public_key_pem: payload.public_key_pem, pinned_at: new Date(now()).toISOString(), rotation_of: null };
|
|
111
|
+
state.history.push({ event: 'pinned', key_id: keyId, at: state.pinned.pinned_at });
|
|
112
|
+
save(state);
|
|
113
|
+
return { ok: true, pinned: state.pinned, firstContact: true };
|
|
114
|
+
}
|
|
115
|
+
if (state.pinned.key_id === keyId) return { ok: true, pinned: state.pinned, firstContact: false };
|
|
116
|
+
throw new OperatorKeyError('ROTATION_REQUIRED', 'a different operator key is already pinned; sign a rotation with the pinned key', { pinned: state.pinned.key_id, offered: keyId });
|
|
117
|
+
},
|
|
118
|
+
/** Apply a rotation that was signed by the currently pinned key. */
|
|
119
|
+
rotate({ newPublicKeyPem, signatureBase64 }) {
|
|
120
|
+
const state = load();
|
|
121
|
+
if (state.pinned === null) throw new OperatorKeyError('NO_PINNED_KEY', 'nothing is pinned yet; rotation is not possible');
|
|
122
|
+
const newKeyId = keyIdOf(newPublicKeyPem);
|
|
123
|
+
if (this.isRevoked(newKeyId)) throw new OperatorKeyError('KEY_REVOKED', 'new operator key is revoked', { key_id: newKeyId });
|
|
124
|
+
const oldKey = createPublicKey(state.pinned.public_key_pem);
|
|
125
|
+
const payload = rotationPayload({ oldKeyId: state.pinned.key_id, newKeyId, newPublicKeyPem });
|
|
126
|
+
const ok = verifyBytes(null, Buffer.from(payload, 'utf8'), oldKey, Buffer.from(String(signatureBase64 ?? ''), 'base64'));
|
|
127
|
+
if (ok !== true) throw new OperatorKeyError('ROTATION_BAD_SIGNATURE', 'rotation was not signed by the currently pinned key');
|
|
128
|
+
state.history.push({ event: 'rotated', from: state.pinned.key_id, to: newKeyId, at: new Date(now()).toISOString() });
|
|
129
|
+
state.pinned = { key_id: newKeyId, public_key_pem: newPublicKeyPem, pinned_at: new Date(now()).toISOString(), rotation_of: state.pinned.key_id };
|
|
130
|
+
save(state);
|
|
131
|
+
return { ok: true, pinned: state.pinned };
|
|
132
|
+
},
|
|
133
|
+
revoke(keyId, reason) {
|
|
134
|
+
const state = load();
|
|
135
|
+
if (!state.revoked.some((entry) => entry.key_id === keyId)) {
|
|
136
|
+
state.revoked.push({ key_id: keyId, reason: String(reason ?? 'unspecified'), at: new Date(now()).toISOString() });
|
|
137
|
+
save(state);
|
|
138
|
+
}
|
|
139
|
+
return { ok: true, key_id: keyId };
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Sign a rotation payload with the old private key (control-side helper). */
|
|
145
|
+
export function signRotation({ oldPrivateKeyPem, oldKeyId, newPublicKeyPem }) {
|
|
146
|
+
const oldKey = createPrivateKey(oldPrivateKeyPem);
|
|
147
|
+
const newKeyId = keyIdOf(newPublicKeyPem);
|
|
148
|
+
const payload = rotationPayload({ oldKeyId, newKeyId, newPublicKeyPem });
|
|
149
|
+
const signature = signBytes(null, Buffer.from(payload, 'utf8'), oldKey);
|
|
150
|
+
return { newKeyId, signatureBase64: signature.toString('base64') };
|
|
151
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The maintenance-request wire protocol (v1.3.2-beta, M1).
|
|
3
|
+
*
|
|
4
|
+
* A request is a signed JSON document that authorises exactly one of three fixed actions (update/restart/diag) on one
|
|
5
|
+
* machine. This module owns the shape, the canonical bytes that are signed, and every refusal the verifier can produce.
|
|
6
|
+
* It deliberately does not execute anything: the root runner decides, this file only says yes or no.
|
|
7
|
+
*
|
|
8
|
+
* Design notes that matter:
|
|
9
|
+
* - The signature covers the action and its parameters, the machine id and the time window. A relay (or any transport)
|
|
10
|
+
* can therefore reroute a request but cannot reshape it.
|
|
11
|
+
* - Timestamps are compared against an injected clock, because tests must be able to sit on both sides of a TTL edge.
|
|
12
|
+
* - Every refusal is a named code; "it failed" alone would tell an operator nothing about what to fix.
|
|
13
|
+
*/
|
|
14
|
+
import { createHash, randomBytes, sign as signBytes, verify as verifyBytes, createPrivateKey, createPublicKey } from 'node:crypto';
|
|
15
|
+
|
|
16
|
+
export const MAINTENANCE_PROTOCOL = 'w2m-maintenance/v1';
|
|
17
|
+
|
|
18
|
+
/** The complete action vocabulary. Anything else is refused before signature checking can be wasted on it. */
|
|
19
|
+
export const MAINTENANCE_ACTIONS = Object.freeze(['update', 'restart', 'diag']);
|
|
20
|
+
|
|
21
|
+
/** Request files are small by construction: a large request is either a bug or an attempt to exhaust the queue. */
|
|
22
|
+
export const MAX_REQUEST_BYTES = 64 * 1024;
|
|
23
|
+
|
|
24
|
+
/** The operator may not authorise an action that stays valid for longer than this, whatever the request says. */
|
|
25
|
+
export const MAX_TTL_MS = 10 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
/** Clock disagreement is tolerated, but only in the direction that cannot extend a window. */
|
|
28
|
+
export const CLOCK_SKEW_MS = 60 * 1000;
|
|
29
|
+
|
|
30
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
31
|
+
const KEY_ID = /^[0-9a-f]{8,32}$/;
|
|
32
|
+
const REQUEST_ID = /^[0-9A-Za-z-]{8,64}$/;
|
|
33
|
+
const NONCE = /^[0-9A-Za-z_-]{8,64}$/;
|
|
34
|
+
|
|
35
|
+
export class MaintenanceProtocolError extends Error {
|
|
36
|
+
constructor(code, message, detail = null) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = 'MaintenanceProtocolError';
|
|
39
|
+
this.code = code;
|
|
40
|
+
this.detail = detail;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Deep-sort object keys so two processes sign the same bytes. Arrays keep order: argv and files are ordered data. */
|
|
45
|
+
export function canonicalize(value) {
|
|
46
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
47
|
+
if (Array.isArray(value)) return '[' + value.map((entry) => canonicalize(entry)).join(',') + ']';
|
|
48
|
+
const keys = Object.keys(value).sort();
|
|
49
|
+
return '{' + keys.map((key) => JSON.stringify(key) + ':' + canonicalize(value[key])).join(',') + '}';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The exact bytes the operator signs. `signature` itself is excluded by construction. */
|
|
53
|
+
export function signingPayload(request) {
|
|
54
|
+
const { signature: _signature, ...rest } = request;
|
|
55
|
+
return canonicalize(rest);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function keyIdOf(publicKeyLike) {
|
|
59
|
+
const key = publicKeyLike?.type === 'public' ? publicKeyLike : createPublicKey(publicKeyLike);
|
|
60
|
+
const der = key.export({ type: 'spki', format: 'der' });
|
|
61
|
+
return createHash('sha256').update(der).digest('hex').slice(0, 16);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function generateRequestId(nowMs = Date.now()) {
|
|
65
|
+
return 'mr-' + nowMs.toString(36) + '-' + randomBytes(6).toString('hex');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function generateNonce() {
|
|
69
|
+
return randomBytes(16).toString('hex');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Build the unsigned request body. Validation of parameters happens in `validateParams`, not here: a builder that
|
|
74
|
+
* silently repaired bad input would make the verifier's refusal list incomplete.
|
|
75
|
+
*/
|
|
76
|
+
export function buildRequest({ action, params = {}, machineId, issuedBy = 'operator', ttlMs = 60_000, nowMs = Date.now(), requestId, nonce }) {
|
|
77
|
+
if (!Number.isInteger(nowMs) || nowMs < 0) throw new MaintenanceProtocolError('MAINTENANCE_BAD_SHAPE', 'nowMs must be a non-negative integer');
|
|
78
|
+
return {
|
|
79
|
+
protocol: MAINTENANCE_PROTOCOL,
|
|
80
|
+
request_id: requestId ?? generateRequestId(nowMs),
|
|
81
|
+
action,
|
|
82
|
+
params,
|
|
83
|
+
machine_id: machineId,
|
|
84
|
+
nonce: nonce ?? generateNonce(),
|
|
85
|
+
issued_at: new Date(nowMs).toISOString(),
|
|
86
|
+
expires_at: new Date(nowMs + ttlMs).toISOString(),
|
|
87
|
+
issued_by: issuedBy,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Attach an Ed25519 signature. The operator key never leaves the control side. */
|
|
92
|
+
export function signRequest(request, privateKeyPem) {
|
|
93
|
+
const privateKey = createPrivateKey(privateKeyPem);
|
|
94
|
+
const publicKey = createPublicKey(privateKey);
|
|
95
|
+
const signature = signBytes(null, Buffer.from(signingPayload(request), 'utf8'), privateKey);
|
|
96
|
+
return {
|
|
97
|
+
...request,
|
|
98
|
+
signature: { alg: 'ed25519', key_id: keyIdOf(publicKey), value_base64: signature.toString('base64') },
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function requestBytes(request) {
|
|
103
|
+
return Buffer.byteLength(JSON.stringify(request), 'utf8');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function validateParams(action, params) {
|
|
107
|
+
if (params === null || typeof params !== 'object' || Array.isArray(params)) {
|
|
108
|
+
return { ok: false, code: 'MAINTENANCE_BAD_PARAMS', detail: { action, reason: 'params must be a JSON object' } };
|
|
109
|
+
}
|
|
110
|
+
if (action === 'update') {
|
|
111
|
+
const target = params.target;
|
|
112
|
+
if (target !== 'plugin' && target !== 'relay') return { ok: false, code: 'MAINTENANCE_BAD_PARAMS', detail: { reason: 'target must be plugin or relay' } };
|
|
113
|
+
if (typeof params.version !== 'string' || params.version.trim() === '') return { ok: false, code: 'MAINTENANCE_BAD_PARAMS', detail: { reason: 'version is required' } };
|
|
114
|
+
if (typeof params.artifact_sha256 !== 'string' || !SHA256_HEX.test(params.artifact_sha256)) return { ok: false, code: 'MAINTENANCE_BAD_PARAMS', detail: { reason: 'artifact_sha256 must be 64 lowercase hex' } };
|
|
115
|
+
const urls = params.artifact_urls;
|
|
116
|
+
if (!Array.isArray(urls) || urls.length === 0 || urls.length > 3) return { ok: false, code: 'MAINTENANCE_BAD_PARAMS', detail: { reason: 'artifact_urls must be 1..3 entries' } };
|
|
117
|
+
for (const url of urls) {
|
|
118
|
+
if (typeof url !== 'string' || !url.startsWith('https://')) return { ok: false, code: 'MAINTENANCE_BAD_PARAMS', detail: { reason: 'artifact_urls entries must be https URLs' } };
|
|
119
|
+
}
|
|
120
|
+
if (params.restart_after_update !== undefined && typeof params.restart_after_update !== 'boolean') return { ok: false, code: 'MAINTENANCE_BAD_PARAMS', detail: { reason: 'restart_after_update must be boolean' } };
|
|
121
|
+
return { ok: true };
|
|
122
|
+
}
|
|
123
|
+
if (action === 'restart') {
|
|
124
|
+
const targets = params.targets;
|
|
125
|
+
if (targets !== 'rabbit' && targets !== 'agent' && targets !== 'all') return { ok: false, code: 'MAINTENANCE_BAD_PARAMS', detail: { reason: 'targets must be rabbit, agent or all' } };
|
|
126
|
+
if (params.wait_healthy !== undefined && typeof params.wait_healthy !== 'boolean') return { ok: false, code: 'MAINTENANCE_BAD_PARAMS', detail: { reason: 'wait_healthy must be boolean' } };
|
|
127
|
+
return { ok: true };
|
|
128
|
+
}
|
|
129
|
+
// diag: exactly the fixed, read-only package; an unknown key would be an attempt to widen it.
|
|
130
|
+
const allowedKeys = new Set(['sections']);
|
|
131
|
+
for (const key of Object.keys(params)) if (!allowedKeys.has(key)) return { ok: false, code: 'MAINTENANCE_BAD_PARAMS', detail: { reason: 'diag accepts no parameters' } };
|
|
132
|
+
if (params.sections !== undefined && !Array.isArray(params.sections)) return { ok: false, code: 'MAINTENANCE_BAD_PARAMS', detail: { reason: 'sections must be an array when present' } };
|
|
133
|
+
return { ok: true };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Verify a request. Every check is ordered cheapest-and-most-fundamental first, and every failure stops before any
|
|
138
|
+
* execution can be attempted.
|
|
139
|
+
*
|
|
140
|
+
* @param {object} request parsed JSON (untrusted)
|
|
141
|
+
* @param {{ publicKeyPem: string, expectedKeyId?: string, nowMs?: number, expectedMachineId: string,
|
|
142
|
+
* usedRequestIds?: Set<string>, usedNonces?: Set<string>, isRevoked?: (keyId: string) => boolean }} context
|
|
143
|
+
* @returns {{ok: true, request: object} | {ok: false, code: string, detail: object}}
|
|
144
|
+
*/
|
|
145
|
+
export function verifyRequest(request, context) {
|
|
146
|
+
const nowMs = Number.isInteger(context?.nowMs) ? context.nowMs : Date.now();
|
|
147
|
+
const fail = (code, message, detail = {}) => ({ ok: false, code, detail: { message, ...detail } });
|
|
148
|
+
|
|
149
|
+
if (request === null || typeof request !== 'object' || Array.isArray(request)) return fail('MAINTENANCE_BAD_SHAPE', 'request must be a JSON object');
|
|
150
|
+
if (requestBytes(request) > MAX_REQUEST_BYTES) return fail('MAINTENANCE_TOO_LARGE', `request exceeds ${MAX_REQUEST_BYTES} bytes`);
|
|
151
|
+
if (request.protocol !== MAINTENANCE_PROTOCOL) return fail('MAINTENANCE_UNSUPPORTED_VERSION', 'unknown maintenance protocol', { got: String(request.protocol) });
|
|
152
|
+
if (typeof request.request_id !== 'string' || !REQUEST_ID.test(request.request_id)) return fail('MAINTENANCE_BAD_SHAPE', 'request_id is malformed');
|
|
153
|
+
if (typeof request.nonce !== 'string' || !NONCE.test(request.nonce)) return fail('MAINTENANCE_BAD_SHAPE', 'nonce is malformed');
|
|
154
|
+
if (typeof request.machine_id !== 'string' || request.machine_id === '') return fail('MAINTENANCE_BAD_SHAPE', 'machine_id is required');
|
|
155
|
+
if (!MAINTENANCE_ACTIONS.includes(request.action)) return fail('MAINTENANCE_BAD_ACTION', 'action is not one of update/restart/diag', { got: String(request.action) });
|
|
156
|
+
const params = validateParams(request.action, request.params);
|
|
157
|
+
if (params.ok !== true) return fail(params.code, 'action parameters are invalid', params.detail);
|
|
158
|
+
|
|
159
|
+
const signature = request.signature;
|
|
160
|
+
if (signature === null || typeof signature !== 'object' || signature.alg !== 'ed25519' || typeof signature.value_base64 !== 'string' || typeof signature.key_id !== 'string') {
|
|
161
|
+
return fail('MAINTENANCE_MISSING_SIGNATURE', 'request carries no usable ed25519 signature');
|
|
162
|
+
}
|
|
163
|
+
if (!KEY_ID.test(signature.key_id)) return fail('MAINTENANCE_BAD_SHAPE', 'key_id is malformed');
|
|
164
|
+
if (context?.expectedKeyId !== undefined && signature.key_id !== context.expectedKeyId) {
|
|
165
|
+
return fail('MAINTENANCE_UNKNOWN_KEY', 'request was signed by a key this machine has not pinned', { key_id: signature.key_id, pinned: context.expectedKeyId });
|
|
166
|
+
}
|
|
167
|
+
if (typeof context?.isRevoked === 'function' && context.isRevoked(signature.key_id) === true) {
|
|
168
|
+
return fail('MAINTENANCE_REVOKED', 'signing key has been revoked', { key_id: signature.key_id });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let publicKey;
|
|
172
|
+
try {
|
|
173
|
+
publicKey = createPublicKey(context?.publicKeyPem ?? '');
|
|
174
|
+
} catch {
|
|
175
|
+
return fail('MAINTENANCE_UNKNOWN_KEY', 'no usable operator public key is configured');
|
|
176
|
+
}
|
|
177
|
+
let signatureOk = false;
|
|
178
|
+
try {
|
|
179
|
+
signatureOk = verifyBytes(null, Buffer.from(signingPayload(request), 'utf8'), publicKey, Buffer.from(signature.value_base64, 'base64'));
|
|
180
|
+
} catch {
|
|
181
|
+
signatureOk = false;
|
|
182
|
+
}
|
|
183
|
+
if (signatureOk !== true) return fail('MAINTENANCE_BAD_SIGNATURE', 'signature does not cover this request');
|
|
184
|
+
|
|
185
|
+
if (typeof request.machine_id !== 'string' || typeof context?.expectedMachineId !== 'string') return fail('MAINTENANCE_BAD_SHAPE', 'machine binding is missing');
|
|
186
|
+
if (request.machine_id !== context.expectedMachineId) return fail('MAINTENANCE_WRONG_MACHINE', 'request targets another machine', { request: request.machine_id, local: context.expectedMachineId });
|
|
187
|
+
|
|
188
|
+
const issuedAt = Date.parse(String(request.issued_at ?? ''));
|
|
189
|
+
const expiresAt = Date.parse(String(request.expires_at ?? ''));
|
|
190
|
+
if (!Number.isFinite(issuedAt) || !Number.isFinite(expiresAt)) return fail('MAINTENANCE_BAD_SHAPE', 'issued_at/expires_at must be RFC3339 timestamps');
|
|
191
|
+
if (expiresAt <= issuedAt) return fail('MAINTENANCE_BAD_SHAPE', 'expires_at must be after issued_at');
|
|
192
|
+
if (expiresAt - issuedAt > MAX_TTL_MS) return fail('MAINTENANCE_TTL_TOO_LONG', 'request lives longer than the maximum TTL', { ttl_ms: expiresAt - issuedAt, max_ttl_ms: MAX_TTL_MS });
|
|
193
|
+
if (issuedAt > nowMs + CLOCK_SKEW_MS) return fail('MAINTENANCE_FUTURE', 'request is not valid yet', { issued_at: request.issued_at });
|
|
194
|
+
if (nowMs > expiresAt) return fail('MAINTENANCE_EXPIRED', 'request has expired', { expires_at: request.expires_at });
|
|
195
|
+
|
|
196
|
+
if (context?.usedRequestIds instanceof Set && context.usedRequestIds.has(request.request_id)) {
|
|
197
|
+
return fail('MAINTENANCE_REPLAY', 'request_id was already executed', { request_id: request.request_id });
|
|
198
|
+
}
|
|
199
|
+
if (context?.usedNonces instanceof Set && context.usedNonces.has(request.nonce)) {
|
|
200
|
+
return fail('MAINTENANCE_REPLAY', 'nonce was already used', { nonce: request.nonce });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { ok: true, request };
|
|
204
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The maintenance runner (v1.3.2-beta, M1).
|
|
3
|
+
*
|
|
4
|
+
* This is the only component that may execute a maintenance action. It is installed to run as root/SYSTEM by a path unit
|
|
5
|
+
* (Linux) or a scheduled task (Windows); it reads request files from a queue directory that the unprivileged agent can
|
|
6
|
+
* write to, verifies the operator signature, and writes a result file the agent can read back.
|
|
7
|
+
*
|
|
8
|
+
* Safety properties this file is responsible for:
|
|
9
|
+
* - nothing is executed before the signature, machine binding and time window are verified;
|
|
10
|
+
* - a request id is executed at most once (claimed via rename, and a persisted replay set);
|
|
11
|
+
* - a result file exists for every request the runner looked at, including refusals, so the agent never waits forever;
|
|
12
|
+
* - a corrupt request file fails that request, not the runner.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { MaintenanceProtocolError, MAX_REQUEST_BYTES, verifyRequest } from './protocol.mjs';
|
|
17
|
+
import { OperatorKeyError } from './operator-key.mjs';
|
|
18
|
+
|
|
19
|
+
const STATE_USED_LIMIT = 500;
|
|
20
|
+
|
|
21
|
+
function loadState(stateFile) {
|
|
22
|
+
if (!existsSync(stateFile)) return { protocol: 'w2m-maintenance-state/v1', used_request_ids: [], used_nonces: [] };
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(readFileSync(stateFile, 'utf8'));
|
|
25
|
+
return {
|
|
26
|
+
protocol: 'w2m-maintenance-state/v1',
|
|
27
|
+
used_request_ids: Array.isArray(parsed.used_request_ids) ? parsed.used_request_ids : [],
|
|
28
|
+
used_nonces: Array.isArray(parsed.used_nonces) ? parsed.used_nonces : [],
|
|
29
|
+
};
|
|
30
|
+
} catch {
|
|
31
|
+
throw new MaintenanceProtocolError('STATE_CORRUPT', 'maintenance state file is unreadable; refusing to execute anything');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function saveState(stateFile, state) {
|
|
36
|
+
mkdirSync(join(stateFile, '..'), { recursive: true });
|
|
37
|
+
const tmp = stateFile + '.tmp-' + process.pid;
|
|
38
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', 'utf8');
|
|
39
|
+
renameSync(tmp, stateFile);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function writeResult(doneDir, requestId, result) {
|
|
43
|
+
mkdirSync(doneDir, { recursive: true });
|
|
44
|
+
const target = join(doneDir, encodeURIComponent(requestId) + '.json');
|
|
45
|
+
const tmp = target + '.tmp-' + process.pid;
|
|
46
|
+
writeFileSync(tmp, JSON.stringify(result, null, 2) + '\n', 'utf8');
|
|
47
|
+
renameSync(tmp, target);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Process every request currently in the queue once. The runner is a one-shot: the path unit starts it again when new
|
|
52
|
+
* files arrive, so there is no long-lived loop to keep correct.
|
|
53
|
+
*
|
|
54
|
+
* @param {object} options
|
|
55
|
+
* @param {string} options.queueDir directory the agent writes requests to
|
|
56
|
+
* @param {string} options.doneDir directory results are written to
|
|
57
|
+
* @param {string} options.stateFile replay state (request ids + nonces already executed)
|
|
58
|
+
* @param {string} options.claimedDir directory claimed requests are moved to while executing
|
|
59
|
+
* @param {string} options.machineId this machine's id
|
|
60
|
+
* @param {() => {pinned: {key_id: string, public_key_pem: string} | null, isRevoked: (keyId: string) => boolean}} options.pinStore
|
|
61
|
+
* @param {(action: string, params: object, request: object) => Promise<{status: string, code?: string, detail?: object}>} options.executeAction
|
|
62
|
+
* @param {() => number} options.now
|
|
63
|
+
* @param {(level: string, message: string, detail?: object) => void} [options.log]
|
|
64
|
+
*/
|
|
65
|
+
export async function runOnce(options) {
|
|
66
|
+
const {
|
|
67
|
+
queueDir,
|
|
68
|
+
doneDir,
|
|
69
|
+
stateFile,
|
|
70
|
+
claimedDir,
|
|
71
|
+
machineId,
|
|
72
|
+
pinStore,
|
|
73
|
+
executeAction,
|
|
74
|
+
now = () => Date.now(),
|
|
75
|
+
log = () => {},
|
|
76
|
+
} = options;
|
|
77
|
+
mkdirSync(claimedDir, { recursive: true });
|
|
78
|
+
const state = loadState(stateFile);
|
|
79
|
+
const usedRequestIds = new Set(state.used_request_ids);
|
|
80
|
+
const usedNonces = new Set(state.used_nonces);
|
|
81
|
+
const processed = [];
|
|
82
|
+
|
|
83
|
+
let entries;
|
|
84
|
+
try {
|
|
85
|
+
entries = readdirSync(queueDir).filter((name) => name.endsWith('.json')).sort();
|
|
86
|
+
} catch {
|
|
87
|
+
return { ok: true, processed };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const name of entries) {
|
|
91
|
+
const source = join(queueDir, name);
|
|
92
|
+
let request;
|
|
93
|
+
let raw;
|
|
94
|
+
try {
|
|
95
|
+
raw = readFileSync(source);
|
|
96
|
+
} catch {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const claimPath = join(claimedDir, name);
|
|
100
|
+
const finish = (result) => {
|
|
101
|
+
writeResult(doneDir, result.request_id ?? name.replace(/\.json$/, ''), result);
|
|
102
|
+
try { rmSync(claimPath, { force: true }); } catch { /* claimed file is best effort */ }
|
|
103
|
+
try { rmSync(source, { force: true }); } catch { /* the request is already claimed */ }
|
|
104
|
+
processed.push(result);
|
|
105
|
+
};
|
|
106
|
+
if (raw.byteLength > MAX_REQUEST_BYTES) {
|
|
107
|
+
finish({ protocol: 'w2m-maintenance-result/v1', request_id: name, status: 'failed', code: 'MAINTENANCE_TOO_LARGE', machine_id: machineId, ended_at: new Date(now()).toISOString() });
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
request = JSON.parse(raw.toString('utf8'));
|
|
112
|
+
} catch {
|
|
113
|
+
finish({ protocol: 'w2m-maintenance-result/v1', request_id: name, status: 'failed', code: 'MAINTENANCE_BAD_SHAPE', detail: { message: 'request is not valid JSON' }, machine_id: machineId, ended_at: new Date(now()).toISOString() });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Claim before any verification side effects: two runners must not both execute the same file.
|
|
118
|
+
try {
|
|
119
|
+
renameSync(source, claimPath);
|
|
120
|
+
} catch {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const alreadyDone = typeof request?.request_id === 'string' && existsSync(join(doneDir, encodeURIComponent(request.request_id) + '.json'));
|
|
125
|
+
if (alreadyDone) {
|
|
126
|
+
try { rmSync(claimPath, { force: true }); } catch { /* ignore */ }
|
|
127
|
+
log('info', 'maintenance request already executed; returning the existing result', { request_id: request.request_id });
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let pins;
|
|
132
|
+
try {
|
|
133
|
+
pins = pinStore.load();
|
|
134
|
+
} catch (error) {
|
|
135
|
+
finish({ protocol: 'w2m-maintenance-result/v1', request_id: String(request?.request_id ?? name), status: 'failed', code: error instanceof OperatorKeyError ? error.code : 'PINS_CORRUPT', detail: { message: error?.message ?? 'pin store unreadable' }, machine_id: machineId, ended_at: new Date(now()).toISOString() });
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (pins.pinned === null) {
|
|
139
|
+
finish({ protocol: 'w2m-maintenance-result/v1', request_id: String(request?.request_id ?? name), status: 'failed', code: 'MAINTENANCE_NO_PINNED_KEY', detail: { message: 'no operator key is pinned yet; refusing every maintenance request' }, machine_id: machineId, ended_at: new Date(now()).toISOString() });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const verified = verifyRequest(request, {
|
|
144
|
+
publicKeyPem: pins.pinned.public_key_pem,
|
|
145
|
+
expectedKeyId: pins.pinned.key_id,
|
|
146
|
+
expectedMachineId: machineId,
|
|
147
|
+
nowMs: now(),
|
|
148
|
+
usedRequestIds,
|
|
149
|
+
usedNonces,
|
|
150
|
+
isRevoked: pins.isRevoked,
|
|
151
|
+
});
|
|
152
|
+
if (verified.ok !== true) {
|
|
153
|
+
finish({ protocol: 'w2m-maintenance-result/v1', request_id: String(request?.request_id ?? name), action: request?.action ?? null, status: 'failed', code: verified.code, detail: verified.detail, machine_id: machineId, ended_at: new Date(now()).toISOString() });
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const startedAt = new Date(now()).toISOString();
|
|
158
|
+
let outcome;
|
|
159
|
+
try {
|
|
160
|
+
outcome = await executeAction(request.action, request.params, request);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
outcome = { status: 'failed', code: 'MAINTENANCE_ACTION_FAILED', detail: { message: error?.message ?? String(error) } };
|
|
163
|
+
}
|
|
164
|
+
const result = {
|
|
165
|
+
protocol: 'w2m-maintenance-result/v1',
|
|
166
|
+
request_id: request.request_id,
|
|
167
|
+
action: request.action,
|
|
168
|
+
status: outcome?.status === 'ok' ? 'ok' : 'failed',
|
|
169
|
+
code: outcome?.status === 'ok' ? null : (outcome?.code ?? 'MAINTENANCE_ACTION_FAILED'),
|
|
170
|
+
detail: outcome?.detail ?? null,
|
|
171
|
+
machine_id: machineId,
|
|
172
|
+
started_at: startedAt,
|
|
173
|
+
ended_at: new Date(now()).toISOString(),
|
|
174
|
+
};
|
|
175
|
+
finish(result);
|
|
176
|
+
state.used_request_ids.push(request.request_id);
|
|
177
|
+
state.used_nonces.push(request.nonce);
|
|
178
|
+
while (state.used_request_ids.length > STATE_USED_LIMIT) state.used_request_ids.shift();
|
|
179
|
+
while (state.used_nonces.length > STATE_USED_LIMIT) state.used_nonces.shift();
|
|
180
|
+
usedRequestIds.add(request.request_id);
|
|
181
|
+
usedNonces.add(request.nonce);
|
|
182
|
+
try { saveState(stateFile, state); } catch (error) {
|
|
183
|
+
log('warn', 'maintenance state could not be persisted; a replay would not be detected', { error: error?.message });
|
|
184
|
+
}
|
|
185
|
+
log('info', 'maintenance request executed', { request_id: request.request_id, action: request.action, status: result.status });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { ok: true, processed };
|
|
189
|
+
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -274,7 +274,7 @@ const MAX_STAGES = 16;
|
|
|
274
274
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
275
275
|
* overrides it at runtime for tests and packagers.
|
|
276
276
|
*/
|
|
277
|
-
const PLUGIN_VERSION = '1.3.
|
|
277
|
+
const PLUGIN_VERSION = '1.3.11';
|
|
278
278
|
|
|
279
279
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
280
280
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|