@twinsearth/w2m-dsh-plugin 2.1.11 → 2.2.1
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 +18 -0
- package/README.md +1 -1
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/2.2.0/ACCOUNT-SWITCH-POLICY-2200.json +21 -0
- package/docs/acceptance/2.2.1/RELEASE-2210.json +31 -0
- package/lib/client.js +442 -6
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/src/plugin/fleet-binding.mjs +22 -0
- package/src/plugin/fleet-config-route.mjs +130 -0
- package/src/plugin/fleet-config.mjs +183 -0
- package/src/plugin/fleet-home.mjs +263 -0
- package/src/plugin/tools.mjs +263 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "2.1
|
|
3
|
+
"version": "2.2.1",
|
|
4
4
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"pack": "node scripts/pack.mjs",
|
|
72
72
|
"reachability": "node scripts/reachability-check.mjs",
|
|
73
73
|
"reachability:classify": "node scripts/reachability-classify.mjs",
|
|
74
|
-
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs test/gpw-colors.test.mjs test/gpw-recent-fold.test.mjs test/gpw-auto-approve.test.mjs test/joint-dev-dispatch.test.mjs test/joint-dev-loop.test.mjs test/joint-dev-audit.test.mjs test/joint-dev-redline.test.mjs test/relay-redline-guard.test.mjs test/relay-p0-projection.test.mjs test/guarded-dispatch.test.mjs test/p0-single-matcher.test.mjs test/path-policy.test.mjs test/path-policy-fs.test.mjs test/gpw-inheritance-table.test.mjs test/gpw-policy-adapter.test.mjs test/target-policy.test.mjs test/relay-redline-audit.test.mjs test/relay-repair-ledger.test.mjs test/relay-purge-blobs.test.mjs test/relay-scrub.test.mjs test/joint-dev-plan-store.test.mjs test/relay-basepath-blobs.test.mjs test/operator-token.test.mjs test/relay-health-capabilities.test.mjs test/joint-relay-dispatch.test.mjs test/operator-token-route.test.mjs test/relay-scrub-task-projection.test.mjs test/operator-key-approval.test.mjs test/relay-offer-journal.test.mjs test/joint-r5-wiring.test.mjs test/p1-hardening.test.mjs test/joint-merge-engine.test.mjs test/artifact-ref-dir-sink.test.mjs test/chat-sticker-guard.test.mjs test/relay-task-progress.test.mjs test/gpw-modes.test.mjs test/joint-cli.test.mjs test/joint-command-hash.test.mjs test/joint-dispatch-audit.test.mjs test/tools-cancel-auth.test.mjs test/fleet-mirror.test.mjs test/sink-git-ssh.test.mjs test/agent-sse-watchdog.test.mjs test/version-gate.test.mjs test/joint-merge-cli.test.mjs test/joint-progress.test.mjs test/project-room-truncation.test.mjs test/chat-crypto-aad.test.mjs test/agent-artifact-delivery.test.mjs test/dataplane-sink-route.test.mjs test/artifact-sink-e2e.test.mjs test/project-room-persistence.test.mjs test/project-room-disk-persistence.test.mjs test/project-stream-route.test.mjs test/chat-v2-gate.test.mjs test/xiangqi.test.mjs test/xfer-foundation.test.mjs test/xfer-ferry.test.mjs test/xfer-publish.test.mjs test/xfer-want-block.test.mjs test/p2p-cipher-diag.test.mjs test/chacha-fallback.test.mjs test/fleet-binding.test.mjs test/xfer-channel.test.mjs test/p2p-race.test.mjs test/path-scheduler.test.mjs test/transport.test.mjs test/iroh-sidecar.test.mjs test/discovery.test.mjs test/p2p-rtt.test.mjs test/artifact-ref-v3.test.mjs test/xfer-recovery.test.mjs test/xfer-router.test.mjs test/remote-s2-capture.test.mjs test/view-capture.test.mjs test/view-frame-channel.test.mjs test/remote-view-route.test.mjs test/remote-media.test.mjs test/view-session-key.test.mjs test/remote-requests.test.mjs test/relay-target.test.mjs",
|
|
74
|
+
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs test/gpw-colors.test.mjs test/gpw-recent-fold.test.mjs test/gpw-auto-approve.test.mjs test/joint-dev-dispatch.test.mjs test/joint-dev-loop.test.mjs test/joint-dev-audit.test.mjs test/joint-dev-redline.test.mjs test/relay-redline-guard.test.mjs test/relay-p0-projection.test.mjs test/guarded-dispatch.test.mjs test/p0-single-matcher.test.mjs test/path-policy.test.mjs test/path-policy-fs.test.mjs test/gpw-inheritance-table.test.mjs test/gpw-policy-adapter.test.mjs test/target-policy.test.mjs test/relay-redline-audit.test.mjs test/relay-repair-ledger.test.mjs test/relay-purge-blobs.test.mjs test/relay-scrub.test.mjs test/joint-dev-plan-store.test.mjs test/relay-basepath-blobs.test.mjs test/operator-token.test.mjs test/relay-health-capabilities.test.mjs test/joint-relay-dispatch.test.mjs test/operator-token-route.test.mjs test/relay-scrub-task-projection.test.mjs test/operator-key-approval.test.mjs test/relay-offer-journal.test.mjs test/joint-r5-wiring.test.mjs test/p1-hardening.test.mjs test/joint-merge-engine.test.mjs test/artifact-ref-dir-sink.test.mjs test/chat-sticker-guard.test.mjs test/relay-task-progress.test.mjs test/gpw-modes.test.mjs test/joint-cli.test.mjs test/joint-command-hash.test.mjs test/joint-dispatch-audit.test.mjs test/tools-cancel-auth.test.mjs test/fleet-mirror.test.mjs test/sink-git-ssh.test.mjs test/agent-sse-watchdog.test.mjs test/version-gate.test.mjs test/joint-merge-cli.test.mjs test/joint-progress.test.mjs test/project-room-truncation.test.mjs test/chat-crypto-aad.test.mjs test/agent-artifact-delivery.test.mjs test/dataplane-sink-route.test.mjs test/artifact-sink-e2e.test.mjs test/project-room-persistence.test.mjs test/project-room-disk-persistence.test.mjs test/project-stream-route.test.mjs test/chat-v2-gate.test.mjs test/xiangqi.test.mjs test/xfer-foundation.test.mjs test/xfer-ferry.test.mjs test/xfer-publish.test.mjs test/xfer-want-block.test.mjs test/p2p-cipher-diag.test.mjs test/chacha-fallback.test.mjs test/fleet-binding.test.mjs test/fleet-home.test.mjs test/fleet-config.test.mjs test/fleet-config-route.test.mjs test/xfer-channel.test.mjs test/p2p-race.test.mjs test/path-scheduler.test.mjs test/transport.test.mjs test/iroh-sidecar.test.mjs test/discovery.test.mjs test/p2p-rtt.test.mjs test/artifact-ref-v3.test.mjs test/xfer-recovery.test.mjs test/xfer-router.test.mjs test/remote-s2-capture.test.mjs test/view-capture.test.mjs test/view-frame-channel.test.mjs test/remote-view-route.test.mjs test/remote-media.test.mjs test/view-session-key.test.mjs test/remote-requests.test.mjs test/relay-target.test.mjs",
|
|
75
75
|
"test:acceptance-doc": "node --test --test-force-exit test/acceptance-doc.test.mjs",
|
|
76
76
|
"test:account-identity": "node --test --test-force-exit test/account-identity.test.mjs",
|
|
77
77
|
"test:account-join": "node --test --test-force-exit test/account-join.test.mjs",
|
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "twinsearth-w2m-dsh",
|
|
4
|
-
"version": "2.1
|
|
4
|
+
"version": "2.2.1",
|
|
5
5
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
@@ -39,6 +39,28 @@ export function shouldRejoin({ token, fleetId, fleetKey, expectedFleetId = null
|
|
|
39
39
|
return typeof expectedFleetId === 'string' && expectedFleetId !== '' && fleetId !== expectedFleetId;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Choose the key a start actually binds with.
|
|
44
|
+
*
|
|
45
|
+
* `fleetKeyPolicy: 'home'` (the default in 2.2.1) is the "the leaver returns, nobody else moves"
|
|
46
|
+
* rule: a machine that has been in a fleet for this account uses that fleet's key again, even when
|
|
47
|
+
* the freshly derived account key differs. `'account'` is the v2.2.0 opt-in that always follows the
|
|
48
|
+
* signed-in account, and `'config'` is the explicit operator pin.
|
|
49
|
+
*
|
|
50
|
+
* @param {{configured?: string|null, account?: string|null, home?: string|null, policy?: string}} input
|
|
51
|
+
* @returns {{fleetKey: string|null, source: 'home'|'account'|'config'|null}}
|
|
52
|
+
*/
|
|
53
|
+
export function selectFleetKey({ configured = null, account = null, home = null, policy = 'home' } = {}) {
|
|
54
|
+
const hasConfig = typeof configured === 'string' && configured.trim() !== '';
|
|
55
|
+
const hasAccount = typeof account === 'string' && account.trim() !== '';
|
|
56
|
+
const hasHome = typeof home === 'string' && home.trim() !== '';
|
|
57
|
+
if (policy === 'config' && hasConfig) return { fleetKey: configured, source: 'config' };
|
|
58
|
+
if (policy === 'home' && hasHome) return { fleetKey: home, source: 'home' };
|
|
59
|
+
if (hasAccount) return { fleetKey: account, source: 'account' };
|
|
60
|
+
if (hasConfig) return { fleetKey: configured, source: 'config' };
|
|
61
|
+
return { fleetKey: null, source: null };
|
|
62
|
+
}
|
|
63
|
+
|
|
42
64
|
/**
|
|
43
65
|
* Read this machine's relay record and classify its fleet binding.
|
|
44
66
|
*
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/api/w2m/fleet-config` (v2.2.1): the settings-page half of the fleet pin.
|
|
3
|
+
*
|
|
4
|
+
* GET /api/w2m/fleet-config
|
|
5
|
+
* → {ok, configured, policy, fleet_id, fingerprint, path}
|
|
6
|
+
* POST /api/w2m/fleet-config {action:'preview', fleet_key, expected_fleet_id?}
|
|
7
|
+
* → {ok, fleet_id, fleet_key_fingerprint, matches_expected} (writes nothing)
|
|
8
|
+
* POST /api/w2m/fleet-config {action:'save', fleet_key, expected_fleet_id?}
|
|
9
|
+
* → {ok, configured, policy, fleet_id, fingerprint, path, backup, restart_required}
|
|
10
|
+
*
|
|
11
|
+
* The raw key is never returned. `preview` is the confirmation step: the page shows the derived
|
|
12
|
+
* fleet id, the operator compares it with the expected id, and only then does `save` replace the
|
|
13
|
+
* file (with a timestamped backup and rollback).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { fingerprintOf } from './operator-token.mjs';
|
|
17
|
+
import { readFleetConfig, writeFleetConfig } from './fleet-config.mjs';
|
|
18
|
+
|
|
19
|
+
export const FLEET_CONFIG_ROUTE_PATH = '/api/w2m/fleet-config';
|
|
20
|
+
const NO_STORE = { 'cache-control': 'no-store' };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} ctx Cordis context.
|
|
24
|
+
* @param {{file?: string|(() => string|null)}} [deps]
|
|
25
|
+
* @returns {{mounted: boolean, reason: string|null}}
|
|
26
|
+
*/
|
|
27
|
+
export function registerFleetConfigRoute(ctx, deps = {}) {
|
|
28
|
+
const status = { mounted: false, reason: 'the host has no Connection service' };
|
|
29
|
+
if (typeof ctx?.inject !== 'function') return status;
|
|
30
|
+
ctx.inject(['connection'], (webCtx) => {
|
|
31
|
+
const registry = webCtx?.connection?.fetch;
|
|
32
|
+
if (registry === undefined || typeof registry.register !== 'function') {
|
|
33
|
+
status.reason = 'the connection service carries no fetch registry';
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const fileOf = () => (typeof deps.file === 'function' ? deps.file() : deps.file ?? null);
|
|
37
|
+
|
|
38
|
+
registry.register({
|
|
39
|
+
path: FLEET_CONFIG_ROUTE_PATH,
|
|
40
|
+
methods: ['GET', 'POST'],
|
|
41
|
+
requestBody: 'buffered',
|
|
42
|
+
fetch: async (request) => {
|
|
43
|
+
const file = fileOf();
|
|
44
|
+
if (typeof file !== 'string' || file === '') {
|
|
45
|
+
return Response.json({ ok: false, code: 'FLEET_CONFIG_NO_STATE', error: 'no state directory is known' }, { status: 503, headers: NO_STORE });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (request.method === 'GET') {
|
|
49
|
+
const read = readFleetConfig(file);
|
|
50
|
+
if (read.ok !== true) {
|
|
51
|
+
return Response.json({ ok: false, code: read.code, error: read.error, path: read.path }, { status: 500, headers: NO_STORE });
|
|
52
|
+
}
|
|
53
|
+
const config = read.config;
|
|
54
|
+
return Response.json({
|
|
55
|
+
ok: true,
|
|
56
|
+
configured: config !== null,
|
|
57
|
+
policy: config?.policy ?? null,
|
|
58
|
+
fleet_id: config?.fleetId ?? null,
|
|
59
|
+
fingerprint: config === null ? null : fingerprintOf(config.fleetKey),
|
|
60
|
+
updated_at: config?.updatedAt ?? null,
|
|
61
|
+
path: file,
|
|
62
|
+
source: config === null ? 'none' : 'file',
|
|
63
|
+
}, { headers: NO_STORE });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let body = null;
|
|
67
|
+
try { body = await request.json(); } catch { /* refused below */ }
|
|
68
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
|
|
69
|
+
return Response.json({ ok: false, code: 'FLEET_CONFIG_BAD_BODY', error: 'send a JSON object' }, { status: 400, headers: NO_STORE });
|
|
70
|
+
}
|
|
71
|
+
const action = body.action;
|
|
72
|
+
if (action !== 'preview' && action !== 'save') {
|
|
73
|
+
return Response.json({ ok: false, code: 'FLEET_CONFIG_BAD_ACTION', error: 'action must be preview or save' }, { status: 400, headers: NO_STORE });
|
|
74
|
+
}
|
|
75
|
+
const fleetKey = typeof body.fleet_key === 'string' ? body.fleet_key.trim() : '';
|
|
76
|
+
if (fleetKey === '') {
|
|
77
|
+
return Response.json({ ok: false, code: 'FLEET_CONFIG_KEY_REQUIRED', error: 'fleet_key is required' }, { status: 400, headers: NO_STORE });
|
|
78
|
+
}
|
|
79
|
+
const { planJoin } = await import('../chatops/fleet-join.mjs');
|
|
80
|
+
const plan = planJoin({ fleetKeyEncoded: fleetKey });
|
|
81
|
+
if (plan.ok !== true) {
|
|
82
|
+
return Response.json({ ok: false, code: plan.reason, error: plan.text }, { status: 400, headers: NO_STORE });
|
|
83
|
+
}
|
|
84
|
+
const expected = typeof body.expected_fleet_id === 'string' ? body.expected_fleet_id.trim().toLowerCase() : '';
|
|
85
|
+
const matchesExpected = expected === '' ? null : expected === plan.fleetId;
|
|
86
|
+
if (action === 'preview') {
|
|
87
|
+
return Response.json({
|
|
88
|
+
ok: true,
|
|
89
|
+
action: 'preview',
|
|
90
|
+
fleet_id: plan.fleetId,
|
|
91
|
+
fleet_key_fingerprint: fingerprintOf(fleetKey),
|
|
92
|
+
matches_expected: matchesExpected,
|
|
93
|
+
}, { headers: NO_STORE });
|
|
94
|
+
}
|
|
95
|
+
if (matchesExpected === false) {
|
|
96
|
+
return Response.json({
|
|
97
|
+
ok: false,
|
|
98
|
+
code: 'FLEET_CONFIG_FLEET_ID_MISMATCH',
|
|
99
|
+
error: 'the key derives a different fleet_id than the expected one',
|
|
100
|
+
fleet_id: plan.fleetId,
|
|
101
|
+
expected_fleet_id: expected,
|
|
102
|
+
}, { status: 409, headers: NO_STORE });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const written = writeFleetConfig(file, {
|
|
106
|
+
fleetKey,
|
|
107
|
+
fleetId: plan.fleetId,
|
|
108
|
+
policy: 'config',
|
|
109
|
+
});
|
|
110
|
+
if (written.ok !== true) {
|
|
111
|
+
return Response.json({ ok: false, code: written.code, error: written.error, path: written.path }, { status: 500, headers: NO_STORE });
|
|
112
|
+
}
|
|
113
|
+
return Response.json({
|
|
114
|
+
ok: true,
|
|
115
|
+
action: 'save',
|
|
116
|
+
configured: true,
|
|
117
|
+
policy: 'config',
|
|
118
|
+
fleet_id: plan.fleetId,
|
|
119
|
+
fingerprint: fingerprintOf(fleetKey),
|
|
120
|
+
path: written.path,
|
|
121
|
+
backup: written.backup,
|
|
122
|
+
restart_required: true,
|
|
123
|
+
}, { headers: NO_STORE });
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
status.mounted = true;
|
|
127
|
+
status.reason = null;
|
|
128
|
+
});
|
|
129
|
+
return status;
|
|
130
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The operator-facing fleet pin, stored beside `device.json`.
|
|
3
|
+
*
|
|
4
|
+
* `cordis.patch.yml` is a profile layer, and asking an operator to hand-edit it is how a working
|
|
5
|
+
* DSH gets broken. This file is the supported alternative: the settings page writes it atomically,
|
|
6
|
+
* the plugin reads it at startup, and the profile YAML is never touched.
|
|
7
|
+
*
|
|
8
|
+
* The file contains an encoded fleet key, which is a credential: mode 0600 where the platform
|
|
9
|
+
* honours it, and the route only ever returns a fingerprint. Every write makes a timestamped
|
|
10
|
+
* backup first; a failed validation or rename restores that backup instead of leaving a half-written
|
|
11
|
+
* pin that changes the next boot.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
chmodSync,
|
|
16
|
+
copyFileSync,
|
|
17
|
+
existsSync,
|
|
18
|
+
mkdirSync,
|
|
19
|
+
readFileSync,
|
|
20
|
+
renameSync,
|
|
21
|
+
rmSync,
|
|
22
|
+
writeFileSync,
|
|
23
|
+
} from 'node:fs';
|
|
24
|
+
import { dirname, join } from 'node:path';
|
|
25
|
+
import { deviceFilePath } from '../agent/identity.mjs';
|
|
26
|
+
|
|
27
|
+
export const FLEET_CONFIG_FILE = 'fleet-config.json';
|
|
28
|
+
export const FLEET_CONFIG_VERSION = 1;
|
|
29
|
+
export const FLEET_CONFIG_POLICIES = Object.freeze(['config', 'home', 'account']);
|
|
30
|
+
|
|
31
|
+
const MAX_KEY_LENGTH = 4096;
|
|
32
|
+
const FLEET_ID_RE = /^[0-9a-f]{64}$/;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the pin file the same way the plugin resolves `device.json`.
|
|
36
|
+
*
|
|
37
|
+
* @param {string|null|undefined} stateDir An explicit state directory, or null/undefined.
|
|
38
|
+
* @returns {string|null}
|
|
39
|
+
*/
|
|
40
|
+
export function fleetConfigPathFor(stateDir) {
|
|
41
|
+
if (typeof stateDir === 'string' && stateDir.trim() !== '') {
|
|
42
|
+
return join(stateDir.trim(), FLEET_CONFIG_FILE);
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
return join(dirname(deviceFilePath({})), FLEET_CONFIG_FILE);
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @param {string|null} deviceFile */
|
|
52
|
+
export function fleetConfigPathBeside(deviceFile) {
|
|
53
|
+
return typeof deviceFile === 'string' && deviceFile !== '' ? join(dirname(deviceFile), FLEET_CONFIG_FILE) : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Read the pin. A missing file is "not configured", not an error.
|
|
58
|
+
*
|
|
59
|
+
* @param {string|null} file
|
|
60
|
+
* @returns {{ok: true, config: null, path: string|null}
|
|
61
|
+
* | {ok: true, config: {fleetKey: string, policy: string, fleetId: string, updatedAt: string}, path: string}
|
|
62
|
+
* | {ok: false, code: string, error: string, path: string|null}}
|
|
63
|
+
*/
|
|
64
|
+
export function readFleetConfig(file) {
|
|
65
|
+
if (file === null || file === undefined || file === '') return { ok: true, config: null, path: null };
|
|
66
|
+
if (!existsSync(file)) return { ok: true, config: null, path: file };
|
|
67
|
+
|
|
68
|
+
let text;
|
|
69
|
+
try {
|
|
70
|
+
text = readFileSync(file, 'utf8');
|
|
71
|
+
} catch (error) {
|
|
72
|
+
return { ok: false, code: 'FLEET_CONFIG_UNREADABLE', error: error instanceof Error ? error.message : String(error), path: file };
|
|
73
|
+
}
|
|
74
|
+
let parsed;
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(text);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
return { ok: false, code: 'FLEET_CONFIG_CORRUPT', error: `not valid JSON: ${error instanceof Error ? error.message : String(error)}`, path: file };
|
|
79
|
+
}
|
|
80
|
+
if (parsed === null || typeof parsed !== 'object' || parsed.version !== FLEET_CONFIG_VERSION) {
|
|
81
|
+
return { ok: false, code: 'FLEET_CONFIG_CORRUPT', error: `expected version ${FLEET_CONFIG_VERSION}`, path: file };
|
|
82
|
+
}
|
|
83
|
+
const fleetKey = typeof parsed.fleet_key === 'string' ? parsed.fleet_key.trim() : '';
|
|
84
|
+
const policy = typeof parsed.fleet_key_policy === 'string' ? parsed.fleet_key_policy.trim() : '';
|
|
85
|
+
const fleetId = typeof parsed.fleet_id === 'string' ? parsed.fleet_id.trim().toLowerCase() : '';
|
|
86
|
+
if (fleetKey === '' || fleetKey.length > MAX_KEY_LENGTH) {
|
|
87
|
+
return { ok: false, code: 'FLEET_CONFIG_CORRUPT', error: 'fleet_key is missing or too long', path: file };
|
|
88
|
+
}
|
|
89
|
+
if (!FLEET_CONFIG_POLICIES.includes(policy)) {
|
|
90
|
+
return { ok: false, code: 'FLEET_CONFIG_CORRUPT', error: `fleet_key_policy must be one of ${FLEET_CONFIG_POLICIES.join(', ')}`, path: file };
|
|
91
|
+
}
|
|
92
|
+
if (!FLEET_ID_RE.test(fleetId)) {
|
|
93
|
+
return { ok: false, code: 'FLEET_CONFIG_CORRUPT', error: 'fleet_id is not a 64-hex SHA-256', path: file };
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
ok: true,
|
|
97
|
+
config: {
|
|
98
|
+
fleetKey,
|
|
99
|
+
policy,
|
|
100
|
+
fleetId,
|
|
101
|
+
updatedAt: typeof parsed.updated_at === 'string' ? parsed.updated_at : '',
|
|
102
|
+
},
|
|
103
|
+
path: file,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Write a validated pin with a backup and rollback.
|
|
109
|
+
*
|
|
110
|
+
* Validation is repeated on the temporary file before it replaces the live one, so a write that
|
|
111
|
+
* somehow serialised wrong cannot become the next boot's configuration.
|
|
112
|
+
*
|
|
113
|
+
* @param {string|null} file
|
|
114
|
+
* @param {{fleetKey: string, fleetId: string, policy?: string, now?: number}} input
|
|
115
|
+
* @returns {{ok: true, path: string, backup: string|null}
|
|
116
|
+
* | {ok: false, code: string, error: string, path: string|null}}
|
|
117
|
+
*/
|
|
118
|
+
export function writeFleetConfig(file, input) {
|
|
119
|
+
if (file === null || file === undefined || file === '') {
|
|
120
|
+
return { ok: false, code: 'FLEET_CONFIG_NO_PATH', error: 'no state directory is known', path: null };
|
|
121
|
+
}
|
|
122
|
+
const fleetKey = typeof input?.fleetKey === 'string' ? input.fleetKey.trim() : '';
|
|
123
|
+
const fleetId = typeof input?.fleetId === 'string' ? input.fleetId.trim().toLowerCase() : '';
|
|
124
|
+
const policy = typeof input?.policy === 'string' ? input.policy : 'config';
|
|
125
|
+
if (fleetKey === '' || fleetKey.length > MAX_KEY_LENGTH) {
|
|
126
|
+
return { ok: false, code: 'FLEET_CONFIG_BAD_KEY', error: 'fleet key is empty or too long', path: file };
|
|
127
|
+
}
|
|
128
|
+
if (!FLEET_ID_RE.test(fleetId)) {
|
|
129
|
+
return { ok: false, code: 'FLEET_CONFIG_BAD_FLEET_ID', error: 'fleet id is not a 64-hex SHA-256', path: file };
|
|
130
|
+
}
|
|
131
|
+
if (!FLEET_CONFIG_POLICIES.includes(policy)) {
|
|
132
|
+
return { ok: false, code: 'FLEET_CONFIG_BAD_POLICY', error: 'policy must be config, home or account', path: file };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const previous = readFleetConfig(file);
|
|
136
|
+
if (previous.ok !== true) return previous;
|
|
137
|
+
|
|
138
|
+
const now = Number.isInteger(input?.now) ? input.now : Date.now();
|
|
139
|
+
const backup = previous.config === null
|
|
140
|
+
? null
|
|
141
|
+
: `${file}.bak-${new Date(now).toISOString().replace(/[:.]/g, '-')}`;
|
|
142
|
+
try {
|
|
143
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
144
|
+
if (backup !== null) {
|
|
145
|
+
copyFileSync(file, backup);
|
|
146
|
+
try { chmodSync(backup, 0o600); } catch { /* Windows ACL carries it */ }
|
|
147
|
+
}
|
|
148
|
+
const tmp = `${file}.tmp-${process.pid}-${now}`;
|
|
149
|
+
writeFileSync(tmp, `${JSON.stringify({
|
|
150
|
+
version: FLEET_CONFIG_VERSION,
|
|
151
|
+
fleet_key: fleetKey,
|
|
152
|
+
fleet_key_policy: policy,
|
|
153
|
+
fleet_id: fleetId,
|
|
154
|
+
updated_at: new Date(now).toISOString(),
|
|
155
|
+
}, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
156
|
+
try { chmodSync(tmp, 0o600); } catch { /* Windows ACL carries it */ }
|
|
157
|
+
const check = readFleetConfig(tmp);
|
|
158
|
+
if (check.ok !== true || check.config === null || check.config.fleetKey !== fleetKey || check.config.fleetId !== fleetId) {
|
|
159
|
+
rmSync(tmp, { force: true });
|
|
160
|
+
throw new Error(check.ok === true ? 'the written pin did not validate' : check.error);
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
renameSync(tmp, file);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
rmSync(tmp, { force: true });
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
return { ok: true, path: file, backup };
|
|
169
|
+
} catch (error) {
|
|
170
|
+
// Roll back to the previous file. Without a backup, remove a partial new file rather than leave
|
|
171
|
+
// a half-written pin for the next boot.
|
|
172
|
+
try {
|
|
173
|
+
if (backup !== null && existsSync(backup)) copyFileSync(backup, file);
|
|
174
|
+
else rmSync(file, { force: true });
|
|
175
|
+
} catch { /* best effort: the original error is more useful */ }
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
code: 'FLEET_CONFIG_WRITE_FAILED',
|
|
179
|
+
error: error instanceof Error ? error.message : String(error),
|
|
180
|
+
path: file,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-account fleet homes: the local answer to "which fleet was this machine in last time it was
|
|
3
|
+
* signed in to this account?"
|
|
4
|
+
*
|
|
5
|
+
* # Why a file, and why this file
|
|
6
|
+
*
|
|
7
|
+
* The account-derived key is deterministic, but it is not a complete history. A fleet can have
|
|
8
|
+
* been created with an explicit key, or before account-derived keys existed, and the derived key
|
|
9
|
+
* then points at a different (new) fleet. Replacing the old key on every account switch is what
|
|
10
|
+
* made one machine's sign-in move the whole fleet -- v2.2.0's `fleetKeyPolicy: account`.
|
|
11
|
+
*
|
|
12
|
+
* The home map is deliberately **per account**: switching from A to B does not erase A's home, so
|
|
13
|
+
* switching back to A returns to A's fleet instead of minting another one. It is written beside
|
|
14
|
+
* `device.json` (same directory, same permissions concerns) and contains encoded fleet keys, which
|
|
15
|
+
* are credentials: the file is 0600 where the platform honours it.
|
|
16
|
+
*
|
|
17
|
+
* # Failure policy
|
|
18
|
+
*
|
|
19
|
+
* `readFleetHome` never silently treats a corrupt file as "no homes". A machine in `home` policy
|
|
20
|
+
* that cannot read its history must stay unbound and report the fault rather than derive a new key
|
|
21
|
+
* and create a new fleet -- that automatic migration is the behaviour this module exists to stop.
|
|
22
|
+
* The caller decides whether to block (home policy) or continue without the history (account
|
|
23
|
+
* policy).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
27
|
+
import { dirname, join } from 'node:path';
|
|
28
|
+
|
|
29
|
+
/** The file beside `device.json`; the basename is part of the operator-visible contract. */
|
|
30
|
+
export const FLEET_HOME_FILE = 'fleet-home.json';
|
|
31
|
+
|
|
32
|
+
/** Bump only with a reader that still understands the previous shape. */
|
|
33
|
+
export const FLEET_HOME_VERSION = 1;
|
|
34
|
+
|
|
35
|
+
const ACCOUNT_CODE_RE = /^[0-9a-f]{8}$/;
|
|
36
|
+
const MAX_KEY_LENGTH = 4096;
|
|
37
|
+
const MAX_ACCOUNTS = 64;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {string|null} deviceFile Absolute path of `device.json`, or null when unknown.
|
|
41
|
+
* @returns {string|null} The path of the home file, or null when there is no state directory.
|
|
42
|
+
*/
|
|
43
|
+
export function fleetHomePath(deviceFile) {
|
|
44
|
+
return typeof deviceFile === 'string' && deviceFile !== '' ? join(dirname(deviceFile), FLEET_HOME_FILE) : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Read the map. Missing file means "no history yet", which is a success, not an error.
|
|
49
|
+
*
|
|
50
|
+
* @param {string|null} file
|
|
51
|
+
* @returns {{ok: true, homes: Record<string, {fleet_key: string, updated_at: string}>, lastAccount: string|null, path: string|null}
|
|
52
|
+
* | {ok: false, code: string, error: string, path: string|null}}
|
|
53
|
+
*/
|
|
54
|
+
export function readFleetHome(file) {
|
|
55
|
+
if (file === null || file === undefined || file === '') return { ok: true, homes: {}, lastAccount: null, path: null };
|
|
56
|
+
if (!existsSync(file)) return { ok: true, homes: {}, lastAccount: null, path: file };
|
|
57
|
+
|
|
58
|
+
let text;
|
|
59
|
+
try {
|
|
60
|
+
text = readFileSync(file, 'utf8');
|
|
61
|
+
} catch (error) {
|
|
62
|
+
return {
|
|
63
|
+
ok: false,
|
|
64
|
+
code: 'FLEET_HOME_UNREADABLE',
|
|
65
|
+
error: error instanceof Error ? error.message : String(error),
|
|
66
|
+
path: file,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let parsed;
|
|
71
|
+
try {
|
|
72
|
+
parsed = JSON.parse(text);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
code: 'FLEET_HOME_CORRUPT',
|
|
77
|
+
error: `not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
78
|
+
path: file,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (parsed === null || typeof parsed !== 'object'
|
|
82
|
+
|| parsed.version !== FLEET_HOME_VERSION
|
|
83
|
+
|| parsed.homes === null || typeof parsed.homes !== 'object' || Array.isArray(parsed.homes)) {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
code: 'FLEET_HOME_CORRUPT',
|
|
87
|
+
error: `expected {version: ${FLEET_HOME_VERSION}, homes: {…}}`,
|
|
88
|
+
path: file,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const homes = {};
|
|
93
|
+
for (const [accountCode, entry] of Object.entries(parsed.homes)) {
|
|
94
|
+
if (!ACCOUNT_CODE_RE.test(accountCode)) {
|
|
95
|
+
return { ok: false, code: 'FLEET_HOME_CORRUPT', error: `bad account code ${accountCode}`, path: file };
|
|
96
|
+
}
|
|
97
|
+
if (entry === null || typeof entry !== 'object'
|
|
98
|
+
|| typeof entry.fleet_key !== 'string' || entry.fleet_key === ''
|
|
99
|
+
|| entry.fleet_key.length > MAX_KEY_LENGTH) {
|
|
100
|
+
return { ok: false, code: 'FLEET_HOME_CORRUPT', error: `bad home for ${accountCode}`, path: file };
|
|
101
|
+
}
|
|
102
|
+
homes[accountCode] = {
|
|
103
|
+
fleet_key: entry.fleet_key,
|
|
104
|
+
updated_at: typeof entry.updated_at === 'string' ? entry.updated_at : '',
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (Object.keys(homes).length > MAX_ACCOUNTS) {
|
|
108
|
+
return { ok: false, code: 'FLEET_HOME_CORRUPT', error: `more than ${MAX_ACCOUNTS} account homes`, path: file };
|
|
109
|
+
}
|
|
110
|
+
const lastAccount = typeof parsed.last_account === 'string' && ACCOUNT_CODE_RE.test(parsed.last_account)
|
|
111
|
+
? parsed.last_account
|
|
112
|
+
: null;
|
|
113
|
+
return { ok: true, homes, lastAccount, path: file };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {Record<string, {fleet_key: string}>} homes
|
|
118
|
+
* @param {string|null} accountCode
|
|
119
|
+
* @returns {{fleet_key: string, updated_at: string}|null}
|
|
120
|
+
*/
|
|
121
|
+
export function getFleetHome(homes, accountCode) {
|
|
122
|
+
if (typeof accountCode !== 'string' || !ACCOUNT_CODE_RE.test(accountCode)) return null;
|
|
123
|
+
const entry = homes?.[accountCode];
|
|
124
|
+
return entry !== undefined && typeof entry.fleet_key === 'string' && entry.fleet_key !== '' ? entry : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Should a machine with an existing token and no recorded home stay exactly where it is?
|
|
129
|
+
*
|
|
130
|
+
* This is the migration guard for 2.2.0 -> 2.2.1. Such a machine may be sitting in an old fleet
|
|
131
|
+
* whose key no longer exists in its profile; deriving the account key at that moment is precisely
|
|
132
|
+
* how an upgrade would move it (and, if the account is shared, tempt everyone else to follow).
|
|
133
|
+
* Staying put and reporting "no home key" is recoverable; silently re-pairing is not.
|
|
134
|
+
*
|
|
135
|
+
* A configured key is excluded: it is the operator's explicit seed and is used instead of the
|
|
136
|
+
* account key. A fresh, unpaired machine is excluded too: it has no fleet to preserve.
|
|
137
|
+
*
|
|
138
|
+
* @param {{policy?: string, hasHome?: boolean, hasConfiguredKey?: boolean, hasToken?: boolean,
|
|
139
|
+
* accountChanged?: boolean}} input
|
|
140
|
+
* @returns {boolean}
|
|
141
|
+
*/
|
|
142
|
+
export function shouldPreserveExistingFleet({
|
|
143
|
+
policy = 'home',
|
|
144
|
+
hasHome = false,
|
|
145
|
+
hasConfiguredKey = false,
|
|
146
|
+
hasToken = false,
|
|
147
|
+
accountChanged = false,
|
|
148
|
+
} = {}) {
|
|
149
|
+
return policy === 'home'
|
|
150
|
+
&& hasHome !== true
|
|
151
|
+
&& hasConfiguredKey !== true
|
|
152
|
+
&& hasToken === true
|
|
153
|
+
&& accountChanged !== true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Write the complete state atomically. Internal: callers have already validated what they add.
|
|
158
|
+
*
|
|
159
|
+
* @param {string} file
|
|
160
|
+
* @param {{homes: Record<string, {fleet_key: string, updated_at: string}>, lastAccount: string|null}} state
|
|
161
|
+
* @returns {{ok: true, path: string}|{ok: false, code: string, error: string, path: string|null}}
|
|
162
|
+
*/
|
|
163
|
+
function writeFleetState(file, state) {
|
|
164
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
165
|
+
try {
|
|
166
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
167
|
+
writeFileSync(tmp, `${JSON.stringify({
|
|
168
|
+
version: FLEET_HOME_VERSION,
|
|
169
|
+
last_account: state.lastAccount,
|
|
170
|
+
homes: state.homes,
|
|
171
|
+
}, null, 2)}\n`, {
|
|
172
|
+
encoding: 'utf8',
|
|
173
|
+
mode: 0o600,
|
|
174
|
+
});
|
|
175
|
+
try { chmodSync(tmp, 0o600); } catch { /* Windows: the file ACL carries the protection */ }
|
|
176
|
+
try {
|
|
177
|
+
renameSync(tmp, file);
|
|
178
|
+
} catch {
|
|
179
|
+
// Windows can refuse to replace a file that another handle has open. The old file is gone
|
|
180
|
+
// only after the copy exists, so this fallback cannot turn a failed write into no file.
|
|
181
|
+
rmSync(file, { force: true });
|
|
182
|
+
renameSync(tmp, file);
|
|
183
|
+
}
|
|
184
|
+
return { ok: true, path: file };
|
|
185
|
+
} catch (error) {
|
|
186
|
+
try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
|
|
187
|
+
return {
|
|
188
|
+
ok: false,
|
|
189
|
+
code: 'FLEET_HOME_WRITE_FAILED',
|
|
190
|
+
error: error instanceof Error ? error.message : String(error),
|
|
191
|
+
path: file,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Remember one account's fleet key. Other accounts are preserved verbatim, and the last account
|
|
198
|
+
* that successfully bound is recorded so a restart can tell "same account, no home yet" apart from
|
|
199
|
+
* "the account actually changed".
|
|
200
|
+
*
|
|
201
|
+
* The write is temp-file + rename in the same directory, so a crash leaves either the old complete
|
|
202
|
+
* map or the new complete map. A corrupt existing map is **not** overwritten: silently replacing a
|
|
203
|
+
* history we cannot parse is how the recovery data would be lost exactly when it is needed.
|
|
204
|
+
*
|
|
205
|
+
* @param {string|null} file
|
|
206
|
+
* @param {{accountCode: string, fleetKey: string, lastAccount?: string|null, now?: number}} input
|
|
207
|
+
* @returns {{ok: true, path: string}|{ok: false, code: string, error: string, path: string|null}}
|
|
208
|
+
*/
|
|
209
|
+
export function rememberFleetHome(file, input) {
|
|
210
|
+
if (file === null || file === undefined || file === '') {
|
|
211
|
+
return { ok: false, code: 'FLEET_HOME_NO_PATH', error: 'no state directory is known', path: null };
|
|
212
|
+
}
|
|
213
|
+
const accountCode = typeof input?.accountCode === 'string' ? input.accountCode : '';
|
|
214
|
+
if (!ACCOUNT_CODE_RE.test(accountCode)) {
|
|
215
|
+
return { ok: false, code: 'FLEET_HOME_BAD_ACCOUNT', error: 'account code is not 8 hex characters', path: file };
|
|
216
|
+
}
|
|
217
|
+
const fleetKey = typeof input?.fleetKey === 'string' ? input.fleetKey : '';
|
|
218
|
+
if (fleetKey === '' || fleetKey.length > MAX_KEY_LENGTH) {
|
|
219
|
+
return { ok: false, code: 'FLEET_HOME_BAD_KEY', error: 'fleet key is empty or too long', path: file };
|
|
220
|
+
}
|
|
221
|
+
const previous = readFleetHome(file);
|
|
222
|
+
if (previous.ok !== true) return previous;
|
|
223
|
+
const homes = {
|
|
224
|
+
...previous.homes,
|
|
225
|
+
[accountCode]: {
|
|
226
|
+
fleet_key: fleetKey,
|
|
227
|
+
updated_at: new Date(Number.isInteger(input?.now) ? input.now : Date.now()).toISOString(),
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
if (Object.keys(homes).length > MAX_ACCOUNTS) {
|
|
231
|
+
return { ok: false, code: 'FLEET_HOME_TOO_MANY', error: `more than ${MAX_ACCOUNTS} account homes`, path: file };
|
|
232
|
+
}
|
|
233
|
+
const lastAccount = typeof input?.lastAccount === 'string' && ACCOUNT_CODE_RE.test(input.lastAccount)
|
|
234
|
+
? input.lastAccount
|
|
235
|
+
: accountCode;
|
|
236
|
+
if (previous.homes[accountCode]?.fleet_key === fleetKey && previous.lastAccount === lastAccount) {
|
|
237
|
+
return { ok: true, path: file };
|
|
238
|
+
}
|
|
239
|
+
return writeFleetState(file, { homes, lastAccount });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Record which account this machine last bound under, without changing any fleet key.
|
|
244
|
+
*
|
|
245
|
+
* Used by the migration guard: a paired machine that cannot derive a key still has an account
|
|
246
|
+
* identity, and persisting it is what lets the *next* real account switch be recognised.
|
|
247
|
+
*
|
|
248
|
+
* @param {string|null} file
|
|
249
|
+
* @param {string} accountCode
|
|
250
|
+
* @returns {{ok: true, path: string}|{ok: false, code: string, error: string, path: string|null}}
|
|
251
|
+
*/
|
|
252
|
+
export function rememberFleetAccount(file, accountCode) {
|
|
253
|
+
if (file === null || file === undefined || file === '') {
|
|
254
|
+
return { ok: false, code: 'FLEET_HOME_NO_PATH', error: 'no state directory is known', path: null };
|
|
255
|
+
}
|
|
256
|
+
if (!ACCOUNT_CODE_RE.test(typeof accountCode === 'string' ? accountCode : '')) {
|
|
257
|
+
return { ok: false, code: 'FLEET_HOME_BAD_ACCOUNT', error: 'account code is not 8 hex characters', path: file };
|
|
258
|
+
}
|
|
259
|
+
const previous = readFleetHome(file);
|
|
260
|
+
if (previous.ok !== true) return previous;
|
|
261
|
+
if (previous.lastAccount === accountCode) return { ok: true, path: file };
|
|
262
|
+
return writeFleetState(file, { homes: previous.homes, lastAccount: accountCode });
|
|
263
|
+
}
|