@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.
@@ -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 { createHash, randomBytes } from 'node:crypto';
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
- if (path === '/v1/fleet/blob-sessions' || path.startsWith('/v1/fleet/blob-sessions/')
702
- || path === '/v1/fleet/blob-latest' || path.startsWith('/v1/fleet/blobs')) {
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,