@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "2.2.0",
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.0",
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: 'account'` (the default) makes the signed-in account authoritative: an explicit
46
- * `fleetKey` is a **fallback** for when the account cannot be read, not a pin that survives an
47
- * account switch. `'config'` restores the old operator-pin behaviour for fleets that deliberately
48
- * outlive the signed-in account.
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
- * The distinction exists because a key in the profile has no provenance: it may have been placed
51
- * there by an earlier setup for the *previous* account, or by an operator who means to pin the fleet.
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 = 'account' } = {}) {
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
+ }