@twinsearth/w2m-dsh-plugin 2.2.0 → 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 +11 -0
- package/README.md +1 -1
- package/codex/mcp-servers.toml +1 -1
- 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 +9 -12
- 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 +220 -28
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "2.2.
|
|
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.2.
|
|
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": [
|
|
@@ -42,23 +42,20 @@ export function shouldRejoin({ token, fleetId, fleetKey, expectedFleetId = null
|
|
|
42
42
|
/**
|
|
43
43
|
* Choose the key a start actually binds with.
|
|
44
44
|
*
|
|
45
|
-
* `fleetKeyPolicy: '
|
|
46
|
-
*
|
|
47
|
-
* account
|
|
48
|
-
*
|
|
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
49
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* The policy is the machine-readable statement of which one it is; without it, auto-switching would
|
|
53
|
-
* silently override a deliberate pin.
|
|
54
|
-
*
|
|
55
|
-
* @param {{configured?: string|null, account?: string|null, policy?: string}} input
|
|
56
|
-
* @returns {{fleetKey: string|null, source: 'account'|'config'|null}}
|
|
50
|
+
* @param {{configured?: string|null, account?: string|null, home?: string|null, policy?: string}} input
|
|
51
|
+
* @returns {{fleetKey: string|null, source: 'home'|'account'|'config'|null}}
|
|
57
52
|
*/
|
|
58
|
-
export function selectFleetKey({ configured = null, account = null, policy = '
|
|
53
|
+
export function selectFleetKey({ configured = null, account = null, home = null, policy = 'home' } = {}) {
|
|
59
54
|
const hasConfig = typeof configured === 'string' && configured.trim() !== '';
|
|
60
55
|
const hasAccount = typeof account === 'string' && account.trim() !== '';
|
|
56
|
+
const hasHome = typeof home === 'string' && home.trim() !== '';
|
|
61
57
|
if (policy === 'config' && hasConfig) return { fleetKey: configured, source: 'config' };
|
|
58
|
+
if (policy === 'home' && hasHome) return { fleetKey: home, source: 'home' };
|
|
62
59
|
if (hasAccount) return { fleetKey: account, source: 'account' };
|
|
63
60
|
if (hasConfig) return { fleetKey: configured, source: 'config' };
|
|
64
61
|
return { fleetKey: null, source: null };
|
|
@@ -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
|
+
}
|