@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.
- package/CHANGELOG.md +379 -3
- package/PROTOCOL-v0.4.0.md +11 -3
- package/PROTOCOL.md +69 -15
- package/README.md +197 -27
- package/SECURITY.md +69 -19
- package/TROUBLESHOOTING.md +129 -53
- package/bin/w2m-localside.mjs +5 -0
- package/bin/w2m-rabbit.mjs +32 -5
- package/deploy/networking/LESSONS.md +18 -1
- package/deploy/networking/README.md +32 -7
- package/deploy/windows/README.md +20 -1
- package/deploy/windows/install-service.ps1 +98 -19
- package/deploy/windows/verify-supervisor.mjs +794 -0
- package/deploy/windows/w2m-agent-supervisor.mjs +867 -40
- package/docs/AUDIT.md +10 -1
- package/docs/DEPLOY.md +16 -3
- package/docs/P2P-CRYPTO.md +105 -4
- package/docs/PIPELINE-DESIGN.md +35 -10
- package/docs/TROUBLESHOOTING.md +22 -3
- package/package.json +5 -5
- package/src/agent/agent.mjs +242 -23
- package/src/agent/allowed-commands.mjs +480 -18
- package/src/agent/crypto.mjs +252 -40
- package/src/agent/p2p-node.mjs +136 -7
- package/src/agent/p2p.mjs +342 -37
- package/src/agent/resolve.mjs +68 -3
- package/src/plugin/fleet-target.mjs +160 -15
- package/src/plugin/tools.mjs +27 -29
- package/src/relay/persistence.mjs +72 -2
- package/src/relay/server.mjs +84 -11
- package/src/relay/state.mjs +9 -2
package/src/agent/agent.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
3924
|
-
*
|
|
3925
|
-
* `DeviceKey`
|
|
3926
|
-
*
|
|
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
|
|
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
|
-
* @
|
|
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
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
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: ${
|
|
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
|
/**
|