@twinsearth/w2m-dsh-plugin 0.5.9 → 0.6.2

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.
@@ -23,8 +23,8 @@
23
23
 
24
24
  import { Readable } from 'node:stream';
25
25
  import { access, constants as fsConstants } from 'node:fs/promises';
26
- import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
27
- import { join } from 'node:path';
26
+ import { existsSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
27
+ import { dirname, join } from 'node:path';
28
28
  import { randomUUID } from 'node:crypto';
29
29
 
30
30
  import {
@@ -49,7 +49,7 @@ import {
49
49
  } from './git.mjs';
50
50
  import { createSpool } from './spool.mjs';
51
51
  import { matchAllowedCommand, parseAllowedCommands } from './allowed-commands.mjs';
52
- import { CRYPTO_REASON, DeviceKey, PeerKeyStore } from './crypto.mjs';
52
+ import { CRYPTO_REASON, DEVICE_KEY_FILE, DeviceKey, PeerKeyStore } from './crypto.mjs';
53
53
  import { DEFAULT_P2P_MODE, P2P_DEFAULTS, P2PNode, normalizeP2PMode, normalizeP2PPort } from './p2p-node.mjs';
54
54
  import { punchSession, reverseDialSession } from './p2p.mjs';
55
55
  import { createSigner } from '../signing.mjs';
@@ -141,6 +141,24 @@ export const AGENT_STATE_SCHEMA_VERSION = 1;
141
141
  */
142
142
  export const AGENT_STATE_PUBLISH_INTERVAL_MS = 30_000;
143
143
 
144
+ /**
145
+ * How many times `publishState()` tries the final `renameSync` before giving up, and the base of
146
+ * the synchronous backoff between tries (attempt N waits N * base).
147
+ *
148
+ * The publish renames a temp file *over* `agent-state.json` while another process may be reading
149
+ * it -- the plugin in the DSH host, an editor, a backup agent, or an antivirus scanner. On Windows
150
+ * that contention comes back as `EPERM`/`EACCES` from the rename even though both paths are fine,
151
+ * and the condition clears within milliseconds; without a retry the publish is lost, and (before
152
+ * v0.5.10) its temp file was left behind, which is how one live state directory accumulated 266
153
+ * `agent-state.json.tmp-*` files.
154
+ *
155
+ * Bounded on purpose: 5 attempts, and only a retryable error is retried, so a permanent failure
156
+ * (the destination is a directory, the volume is gone, the paths are on different devices) costs at
157
+ * most 30ms of synchronous backoff on a diagnostics-only path.
158
+ */
159
+ export const STATE_RENAME_ATTEMPTS = 5;
160
+ export const STATE_RENAME_BACKOFF_MS = 3;
161
+
144
162
  /**
145
163
  * How many times a relay identity change may trigger an *immediate* cursor-less
146
164
  * re-attach before the agent falls back to the normal backoff ladder. A healthy
@@ -857,6 +875,56 @@ export class Heartbeat {
857
875
  }
858
876
  }
859
877
 
878
+ /**
879
+ * Block the current thread for a few milliseconds.
880
+ *
881
+ * Only for {@link renameStateFile}'s backoff, and only on the failing path: `publishState()` is
882
+ * synchronous by design (an async publish would need a queue to keep two publishes from
883
+ * interleaving), and `Atomics.wait` on a throwaway shared buffer is the one way to wait without
884
+ * either a busy loop or an async boundary. There is no `await` here to yield at, and each wait is
885
+ * a handful of milliseconds.
886
+ *
887
+ * @param {number} ms
888
+ */
889
+ function sleepSync(ms) {
890
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
891
+ }
892
+
893
+ /** Is this rename failure the transient Windows-contention kind, rather than a real one? */
894
+ function isRetryableRenameError(error) {
895
+ const code = error?.code;
896
+ return code === 'EPERM' || code === 'EACCES' || code === 'EBUSY';
897
+ }
898
+
899
+ /**
900
+ * `renameSync` over the state file, retried briefly while the failure is transient.
901
+ *
902
+ * See {@link STATE_RENAME_ATTEMPTS} for why this exists at all. Only a retryable error is retried
903
+ * and only a bounded number of times, so the caller's "never throws" contract is preserved: the
904
+ * last error is rethrown for a permanent failure, exactly as a bare `renameSync` would have.
905
+ *
906
+ * @param {string} from The temp file this call created.
907
+ * @param {string} to The published path.
908
+ * @returns {number} The 1-based attempt that succeeded (a caller-visible fact for tests).
909
+ */
910
+ function renameStateFile(from, to) {
911
+ /** @type {unknown} */
912
+ let lastError = null;
913
+ for (let attempt = 1; attempt <= STATE_RENAME_ATTEMPTS; attempt += 1) {
914
+ try {
915
+ renameSync(from, to);
916
+ return attempt;
917
+ } catch (error) {
918
+ lastError = error;
919
+ if (attempt === STATE_RENAME_ATTEMPTS || !isRetryableRenameError(error)) throw error;
920
+ sleepSync(STATE_RENAME_BACKOFF_MS * attempt);
921
+ }
922
+ }
923
+ // Unreachable: the loop either returns or throws. Kept so a future edit cannot make this
924
+ // function fall off the end and return `undefined` to a caller that expects a count.
925
+ throw lastError;
926
+ }
927
+
860
928
  // ---------------------------------------------------------------------------
861
929
  // The agent
862
930
  // ---------------------------------------------------------------------------
@@ -920,6 +988,7 @@ export function createAgent(options) {
920
988
  project,
921
989
  stateDir,
922
990
  identity,
991
+ identityPath = null,
923
992
  caps,
924
993
  platform,
925
994
  once = false,
@@ -1025,11 +1094,17 @@ export function createAgent(options) {
1025
1094
  // *dispatcher* publishes in an offer, and "which key will this machine present" must not depend on
1026
1095
  // whether a particular code path happened to have run yet. `loadOrCreate` is the documented rule
1027
1096
  // ("generated the first time a machine pairs"), and a failure is never fatal — see `deviceKeyError`.
1028
- const deviceKeyOutcome = loadDeviceKey(options.deviceKey ?? null, stateDir, identity.machine_id);
1097
+ const deviceKeyOutcome = loadDeviceKey(options.deviceKey ?? null, stateDir, identity.machine_id, identityPath);
1029
1098
  /** @type {object|null} This machine's static keypair, or null when there is none. */
1030
1099
  const deviceKey = deviceKeyOutcome.key;
1031
1100
  /** @type {string|null} Why there is no key, when there is none. Reported, never thrown. */
1032
1101
  const deviceKeyError = deviceKeyOutcome.error;
1102
+ /**
1103
+ * A key adopted from the pre-v0.6.2 location (`--state/device.key`). Reported once at startup,
1104
+ * because a machine whose key moved is a machine whose public key a peer may have learned under the
1105
+ * old file — silence here would make "my peers stopped talking to me" have no visible cause.
1106
+ */
1107
+ const deviceKeyMigratedFrom = deviceKeyOutcome.migratedFrom ?? null;
1033
1108
  /**
1034
1109
  * Peer static keys, learned from offers.
1035
1110
  *
@@ -1291,6 +1366,15 @@ export function createAgent(options) {
1291
1366
  log(result.ok ? 'info' : 'warn', `p2p ${p2pMode}: ${result.ok ? 'node started' : 'node did not start'}`, {
1292
1367
  error: result.error ?? null,
1293
1368
  });
1369
+ // v0.6.2: a key adopted from the pre-fix location. Said out loud because the public key is what
1370
+ // a peer authenticates this machine by, and a silent move would look like a peer going deaf.
1371
+ if (deviceKeyMigratedFrom !== null) {
1372
+ log('warn', 'p2p: adopting a device key from the old --state location', {
1373
+ from: deviceKeyMigratedFrom,
1374
+ public_key: deviceKey === null ? null : deviceKey.publicKeyBase64,
1375
+ note: 'v0.6.2 moved device.key beside device.json; delete the old file once every peer has seen the new announcement',
1376
+ });
1377
+ }
1294
1378
  refreshP2PStatus();
1295
1379
  return result;
1296
1380
  })();
@@ -1929,19 +2013,47 @@ export function createAgent(options) {
1929
2013
  * the agent (or a running task) down -- the file is a diagnostic, not a
1930
2014
  * delivery guarantee -- so failure is logged once and otherwise ignored.
1931
2015
  *
2016
+ * v0.5.10 adds the two halves of "the temp file is ours to clean up": the rename is retried
2017
+ * briefly (see {@link renameStateFile}), and a publish that fails outright unlinks its own temp
2018
+ * file. Before that, every failed rename left a `agent-state.json.tmp-*` behind, and a live state
2019
+ * directory that could not rename for a few hours had accumulated 266 of them / 187 KB with the
2020
+ * agent still warning once and carrying on.
2021
+ *
1932
2022
  * @returns {object|null} The snapshot written, or null when the write failed.
1933
2023
  */
1934
2024
  function publishState() {
1935
2025
  const snapshot = agentStateSnapshot();
2026
+ /** @type {string|null} The temp file this call created; null once it has been renamed away. */
2027
+ let tmp = null;
1936
2028
  try {
1937
2029
  mkdirSync(stateDir, { recursive: true, mode: 0o700 });
1938
- const tmp = `${stateFilePath}.tmp-${process.pid}-${randomUUID().slice(0, 8)}`;
2030
+ tmp = `${stateFilePath}.tmp-${process.pid}-${randomUUID().slice(0, 8)}`;
1939
2031
  writeFileSync(tmp, `${JSON.stringify(snapshot, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
1940
- renameSync(tmp, stateFilePath);
2032
+ const attempts = renameStateFile(tmp, stateFilePath);
2033
+ // Renamed: the temp name is gone, so the catch below must not be handed it.
2034
+ tmp = null;
2035
+ if (attempts > 1) {
2036
+ // Named, not silent: contention on this path was invisible before v0.5.10, and the only
2037
+ // trace of it was a growing pile of temp files. One line per publish that needed a retry
2038
+ // is the evidence an operator needs to recognise "something is holding the state file".
2039
+ log('info', `published ${AGENT_STATE_FILE} after ${attempts} rename attempts`, {
2040
+ path: stateFilePath,
2041
+ });
2042
+ }
1941
2043
  state.statePublishedAt = snapshot.updated_at;
1942
2044
  statePublishWarned = false;
1943
2045
  return snapshot;
1944
2046
  } catch (error) {
2047
+ // A publish that failed is a publish whose temp file must not survive it. The unlink is
2048
+ // itself best-effort and never rethrows: this function's contract is "never throws", and the
2049
+ // file it is trying to remove is by definition one nobody wants.
2050
+ if (tmp !== null) {
2051
+ try {
2052
+ unlinkSync(tmp);
2053
+ } catch {
2054
+ /* already gone, or the directory is unwritable -- nothing left to do here */
2055
+ }
2056
+ }
1945
2057
  if (!statePublishWarned) {
1946
2058
  statePublishWarned = true;
1947
2059
  log('warn', `could not publish ${AGENT_STATE_FILE} (diagnostics only; continuing)`, {
@@ -2692,9 +2804,14 @@ export function createAgent(options) {
2692
2804
  }
2693
2805
 
2694
2806
  // ---- 5. allow-list (default-deny) -----------------------------------
2807
+ //
2808
+ // `commandArgv` is the offer's own command line, which for a `pipeline` the relay sets to
2809
+ // `stages[0].command_argv` -- so this check covers the first stage and nothing else. Every
2810
+ // stage is checked below, before any of them runs; see the note there for why the second check
2811
+ // has to exist even though the plugin's pre-flight also checks every stage.
2695
2812
  const allowed = matchAllowedCommand(commandArgv, allowedCommands);
2696
2813
  if (!allowed.allowed) {
2697
- log('warn', `refused ${offer.task_id}: COMMAND_NOT_ALLOWED`, { argv: commandArgv });
2814
+ log('warn', `refused ${offer.task_id}: ${allowed.reason_code ?? REFUSAL.COMMAND_NOT_ALLOWED}`, { argv: commandArgv });
2698
2815
  await finish({
2699
2816
  offer,
2700
2817
  cwdRel,
@@ -2703,7 +2820,11 @@ export function createAgent(options) {
2703
2820
  anchorsAfter: null,
2704
2821
  execution: null,
2705
2822
  status: 'refused',
2706
- refusalReason: REFUSAL.COMMAND_NOT_ALLOWED,
2823
+ // The matcher's own code, not a second spelling of it: the plugin's dispatch pre-flight
2824
+ // reports the same literal for the same argv, and `COMMAND_REPARSING_ARGUMENT` and the two
2825
+ // executable-identity codes have to travel the same way or the two ends would disagree
2826
+ // about *why* one command was refused.
2827
+ refusalReason: allowed.reason_code ?? REFUSAL.COMMAND_NOT_ALLOWED,
2707
2828
  warnings,
2708
2829
  startedAt,
2709
2830
  endedAt: new Date().toISOString(),
@@ -2744,6 +2865,48 @@ export function createAgent(options) {
2744
2865
  }))
2745
2866
  : [{ index: 0, command_argv: [...offer.command_argv], cwd_rel: offer.cwd_rel ?? '.', continue_on_failure: false }];
2746
2867
 
2868
+ // ---- 6a. allow-list, again, for **every** stage ----------------------
2869
+ //
2870
+ // Step 5 checked the offer's `command_argv`, which the relay sets to `stages[0].command_argv`
2871
+ // (src/relay/state.mjs) -- so on a chain it vouched for the first stage alone. The plugin's
2872
+ // dispatch-time pre-flight does check every stage (src/plugin/tools.mjs), and its comment says
2873
+ // why: "the allow-list is a default-deny control, and a chain is not an exemption from it".
2874
+ // But that pre-flight runs on the *dispatcher*, and a dispatcher that posts straight to
2875
+ // `/v1/task` with the operator token never calls it. Enforcement on the machine that executes
2876
+ // is the only control that survives a hostile dispatcher, so it has to be here as well.
2877
+ //
2878
+ // The whole chain is refused before anything runs, not at the offending stage: running stages
2879
+ // 1..n-1 of a chain that will not finish would leave side effects from work the caller was
2880
+ // never allowed to ask for, and the plugin's pre-flight refuses the dispatch as a whole for the
2881
+ // same reason. The refusal names the stage index, because "this chain is not allowed" is not
2882
+ // actionable without knowing which stage.
2883
+ for (const stage of plannedStages) {
2884
+ const stageAllowed = matchAllowedCommand(stage.command_argv, allowedCommands);
2885
+ if (stageAllowed.allowed) continue;
2886
+ const code = stageAllowed.reason_code ?? REFUSAL.COMMAND_NOT_ALLOWED;
2887
+ log('warn', `refused ${offer.task_id}: ${code} at stage ${stage.index}`, {
2888
+ argv: stage.command_argv,
2889
+ });
2890
+ await finish({
2891
+ offer,
2892
+ cwdRel,
2893
+ commandArgv,
2894
+ anchorsBefore,
2895
+ anchorsAfter: null,
2896
+ execution: null,
2897
+ status: 'refused',
2898
+ refusalReason: code,
2899
+ warnings,
2900
+ startedAt,
2901
+ endedAt: new Date().toISOString(),
2902
+ // `protocol_violations` is the field a reader already finds a refused stage in (see the
2903
+ // cwd refusal below), and the stage index is the fact that makes it actionable. The
2904
+ // top-level `refusal_reason` carries the code the plugin's pre-flight would have reported.
2905
+ protocolViolations: [`stage ${stage.index}: ${code} - ${stageAllowed.detail}`],
2906
+ });
2907
+ return;
2908
+ }
2909
+
2747
2910
  // The task's timeout is a budget for the whole chain, not per stage. Giving each stage the full
2748
2911
  // timeout would let a 16-stage pipeline run 16x longer than the caller asked for, and the relay
2749
2912
  // would have given up long before.
@@ -3920,32 +4083,88 @@ export function createAgent(options) {
3920
4083
  * be read so an operator can fix it. Refusing to start an agent because a *security upgrade* could not
3921
4084
  * complete is exactly the fleet-losing failure `docs/P2P-CRYPTO.md` §4 forbids.
3922
4085
  *
3923
- * The key lives beside the spool and `agent-state.json` (the `--state` directory), which is where
3924
- * `identity.mjs` puts `device.json` when the agent is run with `--state`. A caller that injected a
3925
- * `DeviceKey` gets that one, which is the test seam and the way a caller supplies a key from
3926
- * somewhere else entirely.
4086
+ * WHERE THE KEY LIVES, AND THE BUG THAT CAME FROM GETTING IT WRONG (v0.6.2)
4087
+ *
4088
+ * `DeviceKey.load` documents the rule as "`device.key` **beside `device.json`**", and this function
4089
+ * used to break it: it loaded from the `--state` directory, justified by a comment claiming that
4090
+ * `identity.mjs` puts `device.json` there "when the agent is run with `--state`". That claim is false.
4091
+ * `device.json` is fixed at `<DSH_HOME>/xclient/device.json` by the identity contract and `--state`
4092
+ * does not move it — which is why an agent run as `--state ~/.dsh/xclient/localside-mac` had its
4093
+ * identity one directory *above* its key.
4094
+ *
4095
+ * The measured consequence, on one machine running both halves of W2M: the plugin (which loads the key
4096
+ * correctly, from `dirname(device.json)`) and the agent each generated a *different* pair, and both
4097
+ * announced to the one `/v1/peer/{machine_id}` slot — the relay's record alternated between the two
4098
+ * nodes' ports every few seconds, and the `static_key` a dispatcher read was one node's or the other's.
4099
+ * A dispatcher that learns the wrong key cannot arm a handshake, so every direct channel came back
4100
+ * `P2P_CRYPTO_PLAINTEXT_PEER` — reported as "the peer is an older build" when both ends were the same
4101
+ * build on the same host.
4102
+ *
4103
+ * MIGRATION. `DeviceKey.load` warns that "silently generating a replacement key would make every peer
4104
+ * that knows the old one refuse this machine". So a key already generated in the old location is
4105
+ * adopted rather than abandoned: canonical first, then a copy forward from `--state`, then generate.
3927
4106
  *
3928
- * @param {object|null} injected A `DeviceKey`, or null to load or generate one from the state dir.
3929
- * @param {string} stateDir
4107
+ * @param {object|null} injected A `DeviceKey`, or null to load or generate one.
4108
+ * @param {string} stateDir The `--state` directory; the key's home only when `deviceFile` is absent.
3930
4109
  * @param {string} machineId
3931
- * @returns {{key: object|null, error: string|null}}
4110
+ * @param {string|null} [deviceFile] Absolute path of the `device.json` this agent loaded. The key
4111
+ * belongs beside it; pass the path the caller resolved rather than re-resolving `DSH_HOME` here.
4112
+ * @returns {{key: object|null, error: string|null, migratedFrom: string|null}}
3932
4113
  */
3933
- function loadDeviceKey(injected, stateDir, machineId) {
4114
+ function loadDeviceKey(injected, stateDir, machineId, deviceFile = undefined) {
3934
4115
  if (injected !== null && injected !== undefined) {
3935
4116
  if (typeof injected.sharedSecret !== 'function' || typeof injected.publicKeyBase64 !== 'string') {
3936
- return { key: null, error: 'P2P_CRYPTO_NO_KEY: the injected deviceKey is not a DeviceKey' };
4117
+ return { key: null, error: 'P2P_CRYPTO_NO_KEY: the injected deviceKey is not a DeviceKey', migratedFrom: null };
3937
4118
  }
3938
- return { key: injected, error: null };
4119
+ return { key: injected, error: null, migratedFrom: null };
3939
4120
  }
3940
- try {
3941
- const loaded = DeviceKey.load({ dir: stateDir, machineId });
3942
- return { key: loaded.key, error: null };
3943
- } catch (error) {
4121
+
4122
+ // Beside `device.json` — the documented rule. `identityPath` is the path the caller actually
4123
+ // loaded, never a fresh global resolution: resolving `DSH_HOME` here would make a test (or any
4124
+ // caller with an injected identity) read and *create* a key in the real user's home.
4125
+ let canonicalDir = null;
4126
+ if (typeof deviceFile === 'string' && deviceFile !== '') canonicalDir = dirname(deviceFile);
4127
+
4128
+ const legacyPath = join(stateDir, DEVICE_KEY_FILE);
4129
+
4130
+ /**
4131
+ * Candidates in order. `mayCreate` is the whole subtlety: only a location that is *the* documented
4132
+ * home may mint a key. A legacy directory is consulted to adopt a key that is already there, never
4133
+ * to create one — `DeviceKey.load` warns that generating a replacement "would make every peer that
4134
+ * knows the old one refuse this machine", so an upgrade must not silently change this machine's
4135
+ * public key.
4136
+ *
4137
+ * With no `identityPath` the caller injected its own identity and there is no canonical home, so
4138
+ * `--state` keeps the pre-v0.6.2 behaviour of being able to create. That is what stops a test or an
4139
+ * embedder from reading and writing the real user's `~/.dsh/xclient`.
4140
+ */
4141
+ const candidates =
4142
+ canonicalDir === null
4143
+ ? [{ dir: stateDir, mayCreate: true, from: null }]
4144
+ : [
4145
+ { dir: canonicalDir, mayCreate: true, from: null },
4146
+ ...(existsSync(legacyPath) ? [{ dir: stateDir, mayCreate: false, from: legacyPath }] : []),
4147
+ ];
4148
+
4149
+ let lastError = null;
4150
+ for (const candidate of candidates) {
4151
+ try {
4152
+ const loaded = DeviceKey.load({ dir: candidate.dir, machineId });
4153
+ if (candidate.mayCreate === false && loaded.created) continue;
4154
+ return { key: loaded.key, error: null, migratedFrom: candidate.from };
4155
+ } catch (error) {
4156
+ lastError = error;
4157
+ }
4158
+ }
4159
+
4160
+ if (lastError !== null) {
3944
4161
  return {
3945
4162
  key: null,
3946
- error: `P2P_CRYPTO_KEY_UNREADABLE: ${error instanceof Error ? error.message : String(error)}`,
4163
+ error: `P2P_CRYPTO_KEY_UNREADABLE: ${lastError instanceof Error ? lastError.message : String(lastError)}`,
4164
+ migratedFrom: null,
3947
4165
  };
3948
4166
  }
4167
+ return { key: null, error: null, migratedFrom: null };
3949
4168
  }
3950
4169
 
3951
4170
  /**