@twinsearth/w2m-dsh-plugin 1.9.11 → 1.9.12
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 +6 -0
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/1.9.12/RELEASE-1912.json +20 -0
- package/package.json +265 -263
- package/plugin.json +1 -1
- package/src/plugin/joint-dev/plan-store.mjs +92 -21
- package/src/plugin/tools.mjs +52 -3
- package/src/relay/p0-projection.mjs +26 -4
- package/src/relay/purge-blobs.mjs +137 -0
- package/src/relay/repair-ledger.mjs +165 -0
- package/src/relay/scrub.mjs +7 -17
- package/src/relay/server.mjs +19 -148
package/src/relay/server.mjs
CHANGED
|
@@ -11,10 +11,21 @@ import { guardBlobRoute, guardResultEnvelope, guardTaskBody } from './redline-gu
|
|
|
11
11
|
|
|
12
12
|
import http from 'node:http';
|
|
13
13
|
import https from 'node:https';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
|
|
16
|
+
/** The relay's own version, from the package it was installed from (W2M_RELAY_VERSION overrides). */
|
|
17
|
+
function readRelayVersion() {
|
|
18
|
+
try {
|
|
19
|
+
const pkg = createRequire(import.meta.url)('../../package.json');
|
|
20
|
+
if (typeof pkg?.version === 'string' && pkg.version !== '') return pkg.version;
|
|
21
|
+
} catch { /* packaged without package.json */ }
|
|
22
|
+
return process.env.W2M_RELAY_VERSION ?? 'unknown';
|
|
23
|
+
}
|
|
24
|
+
export const RELAY_VERSION = readRelayVersion();
|
|
14
25
|
import fs from 'node:fs';
|
|
15
26
|
import os from 'node:os';
|
|
16
27
|
import path from 'node:path';
|
|
17
|
-
import {
|
|
28
|
+
import { randomBytes } from 'node:crypto';
|
|
18
29
|
|
|
19
30
|
import {
|
|
20
31
|
MAX_FRAME_BYTES,
|
|
@@ -698,8 +709,12 @@ export class RelayServer {
|
|
|
698
709
|
const path = normalizePath(stripped);
|
|
699
710
|
// B3/V12:basePath 剥离与 URL 规范化之后立即拒绝数据面 blob 路由,
|
|
700
711
|
// 覆盖 `..`、`%2e%2e`、basePath 前缀等变形;在读取 body 之前返回。
|
|
701
|
-
|
|
702
|
-
|
|
712
|
+
// Percent-encoded dot segments are not resolved by WHATWG URL, so the gate resolves them itself:
|
|
713
|
+
const gatePath = (() => {
|
|
714
|
+
try { return normalizePath(new URL(decodeURIComponent(path), 'http://gate.invalid').pathname); } catch { return path; }
|
|
715
|
+
})();
|
|
716
|
+
if (gatePath === '/v1/fleet/blob-sessions' || gatePath.startsWith('/v1/fleet/blob-sessions/')
|
|
717
|
+
|| gatePath === '/v1/fleet/blob-latest' || gatePath.startsWith('/v1/fleet/blobs')) {
|
|
703
718
|
const refusal = guardBlobRoute(method);
|
|
704
719
|
return sendJson(res, refusal.status, refusal.body);
|
|
705
720
|
}
|
|
@@ -1233,151 +1248,6 @@ export class RelayServer {
|
|
|
1233
1248
|
const { _instanceKey, ...clean } = result;
|
|
1234
1249
|
return sendJson(res, 200, clean);
|
|
1235
1250
|
}
|
|
1236
|
-
/**
|
|
1237
|
-
* v1.3.10-beta.20: chunked blob sessions (M6). A >64 KiB artifact is uploaded in idempotent chunks and only becomes a
|
|
1238
|
-
* blob when the assembled bytes hash to the declared sha256; the session answers "which chunks are missing" so an
|
|
1239
|
-
* interrupted upload resumes instead of restarting.
|
|
1240
|
-
*/
|
|
1241
|
-
const blobSessionCreate = method === 'POST' && path === '/v1/fleet/blob-sessions';
|
|
1242
|
-
const blobSessionChunk = /^\/v1\/fleet\/blob-sessions\/([^/]+)\/chunks\/([0-9]+)$/.exec(path);
|
|
1243
|
-
const blobSessionCommit = /^\/v1\/fleet\/blob-sessions\/([^/]+)\/commit$/.exec(path);
|
|
1244
|
-
const blobSessionStatus = /^\/v1\/fleet\/blob-sessions\/([^/]+)$/.exec(path);
|
|
1245
|
-
if (blobSessionCreate || blobSessionChunk !== null || blobSessionCommit !== null || blobSessionStatus !== null) {
|
|
1246
|
-
const wrap = (fn) => { try { return fn(); } catch (error) { if (typeof error?.code === "string") throw new ProtocolError(error.code, error.message, error.detail ?? null); throw error; } };
|
|
1247
|
-
if (blobSessionCreate) {
|
|
1248
|
-
const body = takeJson();
|
|
1249
|
-
const created = wrap(() => this.state.blobSessions.create({ machineId: device.machine_id, sha256: body?.sha256, size: body?.size, chunkBytes: body?.chunk_bytes }));
|
|
1250
|
-
return sendJson(res, 201, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...created });
|
|
1251
|
-
}
|
|
1252
|
-
if (method === 'PUT' && blobSessionChunk !== null) {
|
|
1253
|
-
const data = rawBody ?? Buffer.alloc(0);
|
|
1254
|
-
const stored = wrap(() => this.state.blobSessions.putChunk({ sessionId: decodeURIComponent(blobSessionChunk[1]), index: Number(blobSessionChunk[2]), data }));
|
|
1255
|
-
return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...stored });
|
|
1256
|
-
}
|
|
1257
|
-
if (method === 'POST' && blobSessionCommit !== null) {
|
|
1258
|
-
const committed = wrap(() => this.state.blobSessions.commit({ sessionId: decodeURIComponent(blobSessionCommit[1]) }));
|
|
1259
|
-
return sendJson(res, committed.ok === true ? 201 : 409, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...committed });
|
|
1260
|
-
}
|
|
1261
|
-
if (method === 'GET' && blobSessionStatus !== null) {
|
|
1262
|
-
const status = wrap(() => this.state.blobSessions.status({ sessionId: decodeURIComponent(blobSessionStatus[1]) }));
|
|
1263
|
-
return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...status });
|
|
1264
|
-
}
|
|
1265
|
-
throw new ProtocolError('BAD_REQUEST', 'a blob session is created with POST, chunks with PUT, committed with POST', {});
|
|
1266
|
-
}
|
|
1267
|
-
|
|
1268
|
-
/**
|
|
1269
|
-
* v1.0.7: the content-addressed blob channel (#1).
|
|
1270
|
-
*
|
|
1271
|
-
* ## Why the relay may carry bytes at all
|
|
1272
|
-
*
|
|
1273
|
-
* It persists `stdout_sha256` and never the text, and `src/agent/storage.mjs` records why: each machine's
|
|
1274
|
-
* `memory.jsonl` is the only durable copy of what a command printed. **That invariant is about the relay not
|
|
1275
|
-
* interpreting content** -- it never decides what bytes mean and never asserts they are authentic.
|
|
1276
|
-
*
|
|
1277
|
-
* A blob keyed by its own hash preserves exactly that. The relay still interprets nothing; **the hash is what
|
|
1278
|
-
* says the bytes are the bytes**, exactly as `stdout_sha256` does for a result. What is new is only that a hash
|
|
1279
|
-
* the relay already holds can have its bytes fetched from the same place.
|
|
1280
|
-
*
|
|
1281
|
-
* ## Why the hash is verified before storing
|
|
1282
|
-
*
|
|
1283
|
-
* A content-addressed store that accepts bytes under a key they do not hash to is not content-addressed -- the
|
|
1284
|
-
* key stops being a claim about the content, and every later reader's verification becomes decorative. So a
|
|
1285
|
-
* mismatch is **refused (422)**, never stored and never corrected: correcting it would silently re-key a blob
|
|
1286
|
-
* the caller believes it stored under a different name.
|
|
1287
|
-
*
|
|
1288
|
-
* ## Fleet scoping
|
|
1289
|
-
*
|
|
1290
|
-
* `putFleetBlob`/`getFleetBlob` reuse `scopeFleetId`, the same rule task dispatch obeys. A blob written by one
|
|
1291
|
-
* fleet is **not readable by another**, and the read refusal is a 404 rather than a 403: a distinguishable
|
|
1292
|
-
* "not yours" tells a prober that the hash exists here.
|
|
1293
|
-
*/
|
|
1294
|
-
/**
|
|
1295
|
-
* v1.0.9: **which blob this fleet published last** -- the discovery half of the channel.
|
|
1296
|
-
*
|
|
1297
|
-
* Checked **before** the `<sha256>` matcher deliberately. The matcher only accepts 64 hex characters, so
|
|
1298
|
-
* `latest` cannot collide with it today; **the ordering is asserted anyway**, because "these two patterns cannot
|
|
1299
|
-
* conflict" is exactly the reasoning that produces a shadowed route the day one of them is relaxed.
|
|
1300
|
-
*
|
|
1301
|
-
* A fleet that has never published gets a 404 rather than an empty object: "there is no current document" and
|
|
1302
|
-
* "the current document is nothing" are different statements, and only one of them is true here.
|
|
1303
|
-
*/
|
|
1304
|
-
if (method === 'GET' && path === '/v1/fleet/blobs/latest') {
|
|
1305
|
-
const latest = this.state.latestFleetBlob(device);
|
|
1306
|
-
if (latest === null) {
|
|
1307
|
-
throw new ProtocolError('NOT_FOUND', 'this fleet has published no blob', {});
|
|
1308
|
-
}
|
|
1309
|
-
return sendJson(res, 200, {
|
|
1310
|
-
protocol_version: PROTOCOL_VERSION,
|
|
1311
|
-
hash: latest.hash,
|
|
1312
|
-
bytes: latest.bytes,
|
|
1313
|
-
published_at: new Date(latest.published_at_ms).toISOString(),
|
|
1314
|
-
published_by: latest.published_by ?? null,
|
|
1315
|
-
// Stated in the payload, not only in the docs: a pointer can outlive its blob under oldest-first eviction.
|
|
1316
|
-
hint: 'a hint, not a guarantee -- a pull may answer 404 if the blob has since been evicted',
|
|
1317
|
-
});
|
|
1318
|
-
}
|
|
1319
|
-
|
|
1320
|
-
const blobMatch = /^\/v1\/fleet\/blobs\/([0-9a-f]{64})$/.exec(path);
|
|
1321
|
-
if (blobMatch !== null) {
|
|
1322
|
-
const hash = blobMatch[1];
|
|
1323
|
-
if (method === 'PUT') {
|
|
1324
|
-
const received = rawBody ?? Buffer.alloc(0);
|
|
1325
|
-
const actual = createHash('sha256').update(received).digest('hex');
|
|
1326
|
-
if (actual !== hash) {
|
|
1327
|
-
throw new ProtocolError('BLOB_HASH_MISMATCH', 'the body does not hash to the key it was sent under', {
|
|
1328
|
-
expected: hash,
|
|
1329
|
-
actual,
|
|
1330
|
-
bytes: received.length,
|
|
1331
|
-
});
|
|
1332
|
-
}
|
|
1333
|
-
const stored = this.state.putFleetBlob(device, received);
|
|
1334
|
-
if (stored.ok !== true) {
|
|
1335
|
-
throw new ProtocolError(stored.reason ?? 'BLOB_WRITE_FAILED', stored.detail ?? 'the blob was refused', {
|
|
1336
|
-
hash,
|
|
1337
|
-
});
|
|
1338
|
-
}
|
|
1339
|
-
return sendJson(res, 201, {
|
|
1340
|
-
protocol_version: PROTOCOL_VERSION,
|
|
1341
|
-
ok: true,
|
|
1342
|
-
hash: stored.hash,
|
|
1343
|
-
bytes: stored.bytes,
|
|
1344
|
-
evicted: stored.evicted,
|
|
1345
|
-
});
|
|
1346
|
-
}
|
|
1347
|
-
if (method === 'GET') {
|
|
1348
|
-
const bytes = this.state.getFleetBlob(device, hash);
|
|
1349
|
-
if (bytes === null) {
|
|
1350
|
-
// Same code and shape for "no such blob" and "not your fleet's" -- deliberately indistinguishable.
|
|
1351
|
-
throw new ProtocolError('NOT_FOUND', 'no such blob is readable by this device', { hash });
|
|
1352
|
-
}
|
|
1353
|
-
const rangeParam = url.searchParams.get('range');
|
|
1354
|
-
if (rangeParam !== null) {
|
|
1355
|
-
const match = /^([0-9]+)-([0-9]+)$/.exec(rangeParam);
|
|
1356
|
-
if (match === null) throw new ProtocolError('BAD_REQUEST', 'range must be start-end', { range: rangeParam });
|
|
1357
|
-
const start = Number(match[1]);
|
|
1358
|
-
const end = Math.min(Number(match[2]), bytes.length - 1);
|
|
1359
|
-
if (!(start >= 0 && start <= end && start < bytes.length)) throw new ProtocolError('BAD_REQUEST', 'range is outside the blob', { range: rangeParam, bytes: bytes.length });
|
|
1360
|
-
const slice = bytes.subarray(start, end + 1);
|
|
1361
|
-
res.writeHead(206, {
|
|
1362
|
-
'content-type': 'application/octet-stream',
|
|
1363
|
-
'content-length': slice.length,
|
|
1364
|
-
'content-range': 'bytes ' + start + '-' + end + '/' + bytes.length,
|
|
1365
|
-
'cache-control': 'no-store',
|
|
1366
|
-
});
|
|
1367
|
-
res.end(slice);
|
|
1368
|
-
return true;
|
|
1369
|
-
}
|
|
1370
|
-
res.writeHead(200, {
|
|
1371
|
-
'content-type': 'application/octet-stream',
|
|
1372
|
-
'content-length': bytes.length,
|
|
1373
|
-
// Same reason as `/metrics`: a memory document must not be cached by an intermediary.
|
|
1374
|
-
'cache-control': 'no-store',
|
|
1375
|
-
});
|
|
1376
|
-
res.end(bytes);
|
|
1377
|
-
return true;
|
|
1378
|
-
}
|
|
1379
|
-
throw new ProtocolError('BAD_REQUEST', 'a blob is addressed with PUT or GET', { method });
|
|
1380
|
-
}
|
|
1381
1251
|
|
|
1382
1252
|
if (method === 'GET' && path === '/v1/devices') {
|
|
1383
1253
|
/**
|
|
@@ -1721,6 +1591,7 @@ export class RelayServer {
|
|
|
1721
1591
|
rabbit_time: this.state.nowIso(),
|
|
1722
1592
|
uptime_ms: this.state.nowMs() - this._startedAtMs,
|
|
1723
1593
|
relay_id: this.relayId,
|
|
1594
|
+
version: RELAY_VERSION,
|
|
1724
1595
|
started_at: rfc3339(this._startedAtMs),
|
|
1725
1596
|
effective_scheme: this.effectiveScheme(req),
|
|
1726
1597
|
base_path: this.basePath,
|