@twinsearth/w2m-dsh-plugin 1.1.14 → 1.1.15

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.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "The w2m fleet plugins: one account, many machines, any of Windows, macOS or Linux.",
8
- "version": "1.1.14"
8
+ "version": "1.1.15"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "w2m-fleet",
13
13
  "source": "./",
14
14
  "description": "Read fleet state and dispatch commands across the machines registered to one account. Read-only MCP surface: the five exposed tools cannot change anything.",
15
- "version": "1.1.14",
15
+ "version": "1.1.15",
16
16
  "author": {
17
17
  "name": "TwinsEarth"
18
18
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "w2m-fleet",
3
3
  "description": "Drive the same project on online Windows, macOS and Linux machines from one account: read fleet state, dispatch commands and read results. The MCP surface is deliberately read-only.",
4
- "version": "1.1.14",
4
+ "version": "1.1.15",
5
5
  "author": {
6
6
  "name": "TwinsEarth"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -2,6 +2,57 @@
2
2
 
3
3
  All notable changes to this project are documented here.
4
4
 
5
+ ## [1.1.15] — 2026-10-17
6
+
7
+ **A machine that cannot join the old way now proves its account, asks to join, and waits for an answer -- and the
8
+ executed command finally learns which machine and which slice it is.**
9
+
10
+ ### The fallback path is connected end to end
11
+
12
+ v1.1.14 shipped the server half. This release connects the client:
13
+
14
+ * \`pairNow\` classifies its failure and, for **only** the three degradable reasons (invalid pairing code, unreachable
15
+ relay, timeout), calls the new chain: challenge -> sign with the account key -> submit proof -> poll until a terminal
16
+ state. Anything else still needs a human and still does not change mechanisms;
17
+ * **a rejection is final.** The chain stops on \`rejected\`; re-asking would turn a decision into a notification queue;
18
+ * **an unfinished wait is reported, never hung on**: \`STILL_PENDING\` after the poll budget, \`expired\` when the relay
19
+ says so;
20
+ * the seed for the account key is the **fleet key, never the account code** -- the account code is written to the log,
21
+ and deriving a signing key from a value anyone can read would hand out the account's identity;
22
+ * the machine id comes from the agent object in scope, **not** from device.json: this machine never paired, which is the
23
+ entire situation.
24
+
25
+ ### Polling without a device token
26
+
27
+ The asking machine has no device token -- that is why it is asking. The request response therefore carries a
28
+ \`poll_token\`, and \`GET /v1/fleet/admission/{id}?poll_token=\` is reachable **ahead of** the device-token gate. A wrong
29
+ token returns 404 rather than revealing whether the request exists.
30
+
31
+ ### The command learns where it is (P1)
32
+
33
+ \`runArgv\` accepts an environment, and every executed command receives \`W2M_INDEX\`, \`W2M_INDEX_TOTAL\`,
34
+ \`W2M_STAGE_INDEX\`, \`W2M_TASK_ID\`, \`W2M_MODE\`, \`W2M_MACHINE_ID\`, \`W2M_MACHINE_NAME\`. Values come from the OFFER,
35
+ never from argv, so one machine cannot reshape a fleet-wide split. The variables are merged over \`process.env\` rather
36
+ than replacing it -- PATH must survive, or no interpreter is found. \`W2M_STAGE_INDEX\` is deliberately separate from
37
+ \`W2M_INDEX\`: for a split over four machines each runs one stage (0) but they are machines 0..3.
38
+
39
+ This makes the operator's acceptance shape possible: \`process.exit(Number(process.env.W2M_INDEX)+1)\` yields 1/2/3/4
40
+ across four machines. The single-machine half is tested here; the four-machine run needs the fleet and is **not yet
41
+ verified**.
42
+
43
+ ### NOT shipped, stated plainly
44
+
45
+ * **The approval UI** (10s popup with countdown plus a standing dialog) is not written. The server enforces the rules it
46
+ must: no automatic approval with zero members online, and a rejection beats the ten seconds;
47
+ * **no end-to-end run**: new install -> popup -> admitted has not been executed, so \`agent.runtime.machineId\` being
48
+ non-empty on a real machine is **UNVERIFIED** -- if it is empty the fallback logs and does not fire;
49
+ * the fleet chat / broadcast channel is designed (FLEET-CHAT-DESIGN.md) and not implemented.
50
+
51
+ ### Verified
52
+
53
+ Full gate GREEN (final pass, 15 of 15). Suites touched: tools 115, account-join 6, account-identity 6, join-challenge 8,
54
+ join-degradation 3, admission 16, admission-routes 3, relay 163, broadcast 19.
55
+
5
56
  ## [1.1.14] — 2026-10-17
6
57
 
7
58
  **A new machine joins by itself, and a machine that cannot join the old way can now prove its account and ask.**
@@ -12,6 +12,6 @@
12
12
 
13
13
  [mcp_servers.w2m-fleet]
14
14
  command = "npx"
15
- args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.1.14", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.1.15", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "1.1.14",
3
+ "version": "1.1.15",
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
  "main": "lib/index.js",
@@ -129,13 +129,13 @@
129
129
  "pack": "node scripts/pack.mjs",
130
130
  "reachability": "node scripts/reachability-check.mjs",
131
131
  "reachability:classify": "node scripts/reachability-classify.mjs",
132
- "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",
132
+ "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",
133
133
  "test:acceptance-doc": "node --test --test-force-exit test/acceptance-doc.test.mjs",
134
134
  "test:account-identity": "node --test --test-force-exit test/account-identity.test.mjs",
135
135
  "test:admission": "node --test --test-force-exit test/admission.test.mjs",
136
136
  "test:admission-routes": "node --test --test-force-exit test/admission-routes.test.mjs",
137
137
  "test:agent": "node --test --test-force-exit test/agent.test.mjs test/memory.test.mjs",
138
- "test:all": "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/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.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-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.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",
138
+ "test:all": "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/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.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-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.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",
139
139
  "test:approval-gate": "node --test --test-force-exit test/approval-gate.test.mjs",
140
140
  "test:approval-index": "node --test --test-force-exit test/approval-index.test.mjs",
141
141
  "test:authority": "node --test --test-force-exit test/authority.test.mjs",
@@ -173,7 +173,9 @@
173
173
  "test:test-report": "node --test --test-force-exit test/test-report.test.mjs",
174
174
  "test:tools": "node --test --test-force-exit test/tools.test.mjs",
175
175
  "verify": "node scripts/check-entrypoints.mjs && node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/tools.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/p2p-node.test.mjs test/p2p-agent.test.mjs test/client-bundle.test.mjs test/loader-entry.test.mjs",
176
- "verify:join": "node scripts/verify-join.mjs"
176
+ "verify:join": "node scripts/verify-join.mjs",
177
+ "test:exec-env": "node --test --test-force-exit test/exec-env-injection.test.mjs",
178
+ "test:account-join": "node --test --test-force-exit test/account-join.test.mjs"
177
179
  },
178
180
  "keywords": [
179
181
  "dsh-bundle",
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": "1.1.14",
4
+ "version": "1.1.15",
5
5
  "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.",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -3011,11 +3011,28 @@ export function createAgent(options) {
3011
3011
  break;
3012
3012
  }
3013
3013
 
3014
+ /**
3015
+ * v1.1.15 (P1): the executed command learns which machine and which slice it is.
3016
+ *
3017
+ * Values come from the OFFER, never from argv, so a fleet-wide split cannot be reshaped by one machine.
3018
+ * W2M_STAGE_INDEX is separate from W2M_INDEX: for a split over four machines, each runs one stage (0) but they
3019
+ * are machines 0..3, and conflating the two would make "print my index" print 0 everywhere.
3020
+ */
3021
+ const taskEnv = {
3022
+ W2M_INDEX: String(Number.isInteger(offer.index) ? offer.index : 0),
3023
+ W2M_INDEX_TOTAL: String(Number.isInteger(offer.index_total) ? offer.index_total : 1),
3024
+ W2M_STAGE_INDEX: String(Number.isInteger(stage.index) ? stage.index : 0),
3025
+ W2M_TASK_ID: String(offer.task_id ?? ''),
3026
+ W2M_MODE: String(offer.mode ?? ''),
3027
+ W2M_MACHINE_ID: String(offer.machine_id ?? ''),
3028
+ W2M_MACHINE_NAME: String(offer.machine_name ?? ''),
3029
+ };
3014
3030
  const ran = await runArgv(stage.command_argv, {
3015
3031
  cwd: stageCwdOutcome.cwd,
3016
3032
  timeoutMs: remainingMs,
3017
3033
  maxOutputBytes,
3018
3034
  signal: controller.signal,
3035
+ env: taskEnv,
3019
3036
  });
3020
3037
  execution = ran;
3021
3038
  for (const warning of ran.warnings) if (!warnings.includes(warning)) warnings.push(warning);
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The fallback joining path, as the client actually walks it.
3
+ *
4
+ * ## Why this is a separate, injectable function
5
+ *
6
+ * The old path fails for three named reasons only (see join-degradation.mjs), and then this runs: ask for a challenge,
7
+ * sign it with the account key, submit the proof, wait for a human (or the ten-second popup) to decide. Every one of
8
+ * those steps can be slow or refuse, and the whole thing must be testable without a relay, a fleet, or a person.
9
+ *
10
+ * ## The two rules this encodes, both learned the hard way
11
+ *
12
+ * - **A rejection is final.** Re-asking after a "no" turns a decision into a notification queue and harasses the members
13
+ * who said no. Only a caller that explicitly resets may try again.
14
+ * - **Give up loudly.** If the request is never decided, the caller must be told which state it ended in (expired,
15
+ * pending past the deadline) rather than being left with a promise that never settles.
16
+ */
17
+ import { signChallenge } from '../util/account-identity.mjs';
18
+
19
+ /** Terminal states from the relay. \`pending\` is NOT terminal: it means "still waiting". */
20
+ const TERMINAL = new Set(['approved', 'rejected', 'expired']);
21
+
22
+ /**
23
+ * @param {{request: Function, identity: {fingerprint: string, privateKey: unknown},
24
+ * machine: {machine_id: string, machine_name?: string},
25
+ * basePath?: string, pollIntervalMs?: number, maxPolls?: number, sleep?: Function,
26
+ * now?: () => number}} deps
27
+ * @returns {Promise<{ok: boolean, state: string, device_token?: string, request_id?: string, code?: string}>}
28
+ */
29
+ export async function joinByAccount({
30
+ request,
31
+ identity,
32
+ machine,
33
+ basePath = '',
34
+ pollIntervalMs = 1000,
35
+ maxPolls = 30,
36
+ sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }),
37
+ }) {
38
+ if (typeof request !== 'function') throw new TypeError('request must be a function');
39
+ if (!identity || typeof identity.fingerprint !== 'string') throw new TypeError('identity with a fingerprint is required');
40
+ if (!machine || typeof machine.machine_id !== 'string' || machine.machine_id === '') throw new TypeError('machine.machine_id is required');
41
+
42
+ const challenge = await request({
43
+ method: 'GET',
44
+ pathname: basePath + '/v1/fleet/admission/challenge',
45
+ query: { fingerprint: identity.fingerprint, machine_id: machine.machine_id },
46
+ });
47
+ if (challenge?.ok !== true) {
48
+ return { ok: false, state: 'error', code: challenge?.error?.code ?? 'CHALLENGE_REFUSED' };
49
+ }
50
+ const signature = signChallenge(identity, [challenge.json.challenge_id, challenge.json.nonce, identity.fingerprint, machine.machine_id].join('|'));
51
+
52
+ const submitted = await request({
53
+ method: 'POST',
54
+ pathname: basePath + '/v1/fleet/admission/request',
55
+ body: { machine_id: machine.machine_id, machine_name: machine.machine_name ?? null, challenge_id: challenge.json.challenge_id, signature },
56
+ });
57
+ if (submitted?.ok !== true) {
58
+ return { ok: false, state: 'error', code: submitted?.error?.code ?? 'REQUEST_REFUSED' };
59
+ }
60
+ let state = submitted.json.state ?? 'pending';
61
+ const requestId = submitted.json.request_id ?? null;
62
+ const pollToken = submitted.json.poll_token ?? null;
63
+
64
+ for (let attempt = 0; attempt < maxPolls && !TERMINAL.has(state); attempt += 1) {
65
+ if (attempt > 0) await sleep(pollIntervalMs);
66
+ const polled = await request({
67
+ method: 'GET',
68
+ pathname: basePath + '/v1/fleet/admission/' + encodeURIComponent(requestId ?? ''),
69
+ query: { poll_token: pollToken ?? '' },
70
+ });
71
+ if (polled?.ok !== true) {
72
+ return { ok: false, state: 'error', code: polled?.error?.code ?? 'POLL_REFUSED' };
73
+ }
74
+ state = polled.json.state ?? state;
75
+ if (TERMINAL.has(state)) {
76
+ return { ok: state === 'approved', state, request_id: requestId, device_token: polled.json.device_token };
77
+ }
78
+ }
79
+ // Not terminal within the budget: report the state we ended in rather than hanging.
80
+ return { ok: false, state, request_id: requestId, code: TERMINAL.has(state) ? undefined : 'STILL_PENDING' };
81
+ }
82
+
83
+ /** True only for a state a caller may treat as an answer. Exported so callers do not re-derive it. */
84
+ export function isTerminalAdmissionState(state) {
85
+ return TERMINAL.has(state);
86
+ }
@@ -108,6 +108,8 @@ import { acceptWithAuthority } from '../agent/field-authority.mjs';
108
108
  // exhaustively, without a profile or a relay.
109
109
  import { mergeMemory } from './fleet-merge.mjs';
110
110
  import { shouldDegrade } from './join-degradation.mjs';
111
+ import { deriveAccountKeypair } from '../util/account-identity.mjs';
112
+ import { joinByAccount } from './account-join.mjs';
111
113
 
112
114
  // The base-address rules live in one place (§2), shared with the agent side. Importing the - rather
113
115
  // than re-deriving them her - is what stops the plugin and the agent from disagreeing about what
@@ -212,7 +214,7 @@ const MAX_STAGES = 16;
212
214
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
213
215
  * overrides it at runtime for tests and packagers.
214
216
  */
215
- const PLUGIN_VERSION = '1.1.14';
217
+ const PLUGIN_VERSION = '1.1.15';
216
218
 
217
219
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
218
220
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -6716,6 +6718,44 @@ async function startLocalStack(ctx, cfg) {
6716
6718
  */
6717
6719
  const reason = error?.reason_code ?? error?.code ?? null;
6718
6720
  const degrade = shouldDegrade(reason);
6721
+ if (degrade === true && key !== null) {
6722
+ /**
6723
+ * v1.1.15: the fallback path, finally called.
6724
+ *
6725
+ * Two invariants are load-bearing here:
6726
+ *
6727
+ * 1. the seed is the FLEET KEY, never the account code -- the account code is written to the log two
6728
+ * hundred lines above, and deriving a signing key from a value anyone can read would hand out the
6729
+ * account's identity;
6730
+ * 2. the machine id comes from the agent object that is already in this scope, not from device.json --
6731
+ * this machine never paired, so it has no device.json, which is the entire situation.
6732
+ *
6733
+ * A failure here does not change the outcome of the old attempt: the caller still sees
6734
+ * `{paired: false, degrade: true}` and can retry later. It must not throw out of this catch block.
6735
+ */
6736
+ try {
6737
+ const localMachineId = typeof agent?.runtime?.machineId === 'string' ? agent.runtime.machineId
6738
+ : (typeof agent?.machineId === 'string' ? agent.machineId : '');
6739
+ if (localMachineId !== '') {
6740
+ const joined = await joinByAccount({
6741
+ request,
6742
+ identity: deriveAccountKeypair(key),
6743
+ machine: { machine_id: localMachineId, machine_name: cfg.machineName ?? null },
6744
+ maxPolls: 10,
6745
+ });
6746
+ if (joined.ok === true && typeof joined.device_token === 'string') {
6747
+ saveDeviceToken(joined.device_token, identityOptions);
6748
+ ctx.logger?.info?.('w2m: joined by account admission (' + joined.state + ')');
6749
+ return { paired: true, by: 'account-admission' };
6750
+ }
6751
+ ctx.logger?.warn?.('w2m: account admission did not admit this machine (' + joined.state + (joined.code ? '/' + joined.code : '') + ')');
6752
+ } else {
6753
+ ctx.logger?.warn?.('w2m: no local machine id, so the account-admission fallback cannot ask to join');
6754
+ }
6755
+ } catch (fallbackError) {
6756
+ ctx.logger?.warn?.('w2m: account-admission fallback failed: ' + (fallbackError instanceof Error ? fallbackError.message : String(fallbackError)));
6757
+ }
6758
+ }
6719
6759
  ctx.logger?.warn?.(`w2m: pairing by ${by} failed${reason === null ? '' : ' (' + String(reason) + ')'}: ${error instanceof Error ? error.message : String(error)}`);
6720
6760
  return { paired: false, by, reason_code: reason, degrade };
6721
6761
  }
@@ -1,3 +1,4 @@
1
+ import { randomBytes } from 'node:crypto';
1
2
  /**
2
3
  * Fleet admission: who may join a fleet, who may be removed, and by how many votes.
3
4
  *
@@ -111,8 +112,9 @@ export function createAdmission(options = {}) {
111
112
  request_id, machine_id, machine_name, account_fingerprint, fleet_id,
112
113
  state: ADMISSION.PENDING, requested_at_ms: now(), decided_at_ms: null,
113
114
  decided_by: null, auto: false,
115
+ poll_token: randomBytes(16).toString('hex'),
114
116
  });
115
- return { ok: true, request_id, state: ADMISSION.PENDING };
117
+ return { ok: true, request_id, state: ADMISSION.PENDING, poll_token: requests.get(request_id).poll_token };
116
118
  }
117
119
 
118
120
  function getRequest(request_id) {
@@ -231,9 +233,18 @@ export function createAdmission(options = {}) {
231
233
  return r === undefined ? null : { removal_id: r.removal_id, target_machine_id: r.target_machine_id, state: r.state, need: r.need, snapshot: [...r.snapshot], yes: [...r.votes.values()].filter((v) => v === 'yes').length, no: [...r.votes.values()].filter((v) => v === 'no').length, votes: r.votes.size };
232
234
  }
233
235
 
236
+ /** Poll without a device token: the asking machine has none. The token is the credential, and it is not guessable from the id. */
237
+ function poll(request_id, poll_token) {
238
+ const r = requests.get(request_id);
239
+ if (r === undefined || typeof poll_token !== 'string' || poll_token === '' || r.poll_token !== poll_token) {
240
+ return { ok: false, code: 'NOT_FOUND' };
241
+ }
242
+ return { ok: true, request_id, state: r.state };
243
+ }
244
+
234
245
  function pendingFor() {
235
246
  return [...requests.values()].filter((r) => r.state === ADMISSION.PENDING).map((r) => ({ ...r }));
236
247
  }
237
248
 
238
- return { requestAdmission, decide, tick, getRequest, pendingFor, remove, vote, getRemoval, isMember, memberOf, setOnline, seedFounder, _members: members };
249
+ return { requestAdmission, decide, tick, getRequest, pendingFor, poll, remove, vote, getRemoval, isMember, memberOf, setOnline, seedFounder, _members: members };
239
250
  }