@twinsearth/w2m-dsh-plugin 1.1.14 → 1.1.16

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.
@@ -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.16", "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.16",
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.16",
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
+ }
@@ -1,4 +1,4 @@
1
- /**
1
+ /**
2
2
  * DSH: Windows2MacO - the DSH Cordis plugin.
3
3
  *
4
4
  * # What this exposes
@@ -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.16';
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;
@@ -6136,6 +6138,39 @@ export async function apply(ctx, config = {}) {
6136
6138
  }
6137
6139
  const actor = { id: typeof args?.actor_id === 'string' && args.actor_id.trim() !== '' ? args.actor_id.trim() : cfg.machineName, level: args.actor_level };
6138
6140
  const outcome = action === 'approve' ? 'approved' : 'rejected';
6141
+ /**
6142
+ * v1.1.16: a fleet-admission request is settled at the RELAY, because that is where the request lives and where
6143
+ * the quorum rules are enforced. The local index would accept the write and change nothing that matters.
6144
+ *
6145
+ * The actor id is the person answering; the relay records it on the decision. A refusal is reported as such
6146
+ * (`ALREADY_DECIDED` included) rather than thrown: the caller is looking at a list that may be a second old.
6147
+ */
6148
+ if (/^adm-/.test(approvalId)) {
6149
+ const decideDevice = await readDevice(cfg);
6150
+ const decideRelay = cfg.rabbitUrl ?? null;
6151
+ const settled = decideRelay === null || decideDevice.device_token === null
6152
+ ? { ok: false, json: { code: 'W2M_NO_RELAY' } }
6153
+ : await request({
6154
+ rabbitUrl: decideRelay,
6155
+ token: requireToken(decideDevice),
6156
+ method: 'POST',
6157
+ pathname: '/v1/fleet/admission/' + encodeURIComponent(approvalId) + '/decide',
6158
+ body: { decision: action === 'approve' ? 'approve' : 'reject', decided_by: actor.id, auto: false },
6159
+ });
6160
+ return JSON.stringify({
6161
+ ok: settled?.ok === true,
6162
+ action,
6163
+ approval_id: approvalId,
6164
+ kind: 'fleet_admission',
6165
+ settled: settled?.ok === true ? outcome : null,
6166
+ reason_code: settled?.ok === true ? null : (settled?.json?.error?.code ?? settled?.json?.code ?? settled?.error?.code ?? null),
6167
+ detail: settled?.ok === true ? null : (settled?.json?.error?.message ?? null),
6168
+ acted_by: actor,
6169
+ note: settled?.ok === true
6170
+ ? 'the machine is admitted; it will appear in w2m_devices once its agent reconnects'
6171
+ : 'the decision was refused; the request is unchanged (already decided requests are normal when the ten-second window has passed)',
6172
+ });
6173
+ }
6139
6174
  const result = queue.decide(approvalId, actor, outcome);
6140
6175
  return JSON.stringify({
6141
6176
  ok: result.ok === true,
@@ -6159,6 +6194,43 @@ export async function apply(ctx, config = {}) {
6159
6194
  };
6160
6195
  // An actor with no level still gets the list: knowing what is waiting does not require the authority to
6161
6196
  // answer it, and a queue only its supervisors can see is a queue that stays full.
6197
+ /**
6198
+ * v1.1.16: a machine waiting to join the fleet is a request waiting for a human, which is what this tool is.
6199
+ *
6200
+ * It is NOT a permission escalation, so it must not be shoved through `approvalRequestFor` -- that shape requires
6201
+ * a needs-approval verdict and a capability, and faking one would record a lie in the audit trail. The list is
6202
+ * shared; the store underneath is not.
6203
+ *
6204
+ * The countdown uses the RELAY's clock: `requested_at_ms` was stamped there, and three machines in this fleet
6205
+ * disagree on the time by tens of seconds. A local-clock countdown would fire the automatic approval early on a
6206
+ * fast machine and late on a slow one. The relay still owns the decision (it refuses to auto-approve with no
6207
+ * member online); this is display only.
6208
+ */
6209
+ let admissionRows = [];
6210
+ try {
6211
+ const admissionDevice = await readDevice(cfg);
6212
+ const admissionRelay = cfg.rabbitUrl ?? null;
6213
+ // A relay URL and a device token are what the rest of this file passes on every authenticated call; omitting
6214
+ // them made this request throw into the catch below, which is why the merged rows were silently missing.
6215
+ const admissionPending = admissionRelay === null || admissionDevice.device_token === null
6216
+ ? { ok: false }
6217
+ : await request({ rabbitUrl: admissionRelay, token: requireToken(admissionDevice), method: 'GET', pathname: '/v1/fleet/admission/pending' });
6218
+ if (admissionPending?.ok === true && Array.isArray(admissionPending.json?.pending)) {
6219
+ const relayNow = Date.parse(String(admissionPending.json.rabbit_time ?? '')) || Date.now();
6220
+ admissionRows = admissionPending.json.pending.map((row) => ({
6221
+ approval_id: row.request_id,
6222
+ kind: 'fleet_admission',
6223
+ machine_id: row.machine_id ?? null,
6224
+ machine_name: row.machine_name ?? null,
6225
+ requested_at_ms: row.requested_at_ms ?? null,
6226
+ countdown_ms: Number.isFinite(row.requested_at_ms) ? Math.max(0, 10_000 - (relayNow - row.requested_at_ms)) : null,
6227
+ may_act: true,
6228
+ note: 'approving admits this machine to the fleet; the ten-second window is enforced by the relay and needs a member online',
6229
+ }));
6230
+ }
6231
+ } catch {
6232
+ // The approval list must still work when the relay is unreachable: the permission queue is local.
6233
+ }
6162
6234
  const rows = queue.listFor(
6163
6235
  actor.level === null ? { id: actor.id, level: 0 } : actor,
6164
6236
  { includeSettled: args?.include_settled === true },
@@ -6170,8 +6242,8 @@ export async function apply(ctx, config = {}) {
6170
6242
  as: actor,
6171
6243
  level_known: actor.level !== null,
6172
6244
  summary,
6173
- count: rows.length,
6174
- rows,
6245
+ count: rows.length + admissionRows.length,
6246
+ rows: rows.concat(admissionRows),
6175
6247
  note: actor.level === null
6176
6248
  ? 'no actor_level was supplied, so may_act is reported as if the level were 0: the queue is visible, but whether you may answer any of it is not known'
6177
6249
  : 'may_act reflects the actor_level supplied',
@@ -6716,6 +6788,44 @@ async function startLocalStack(ctx, cfg) {
6716
6788
  */
6717
6789
  const reason = error?.reason_code ?? error?.code ?? null;
6718
6790
  const degrade = shouldDegrade(reason);
6791
+ if (degrade === true && key !== null) {
6792
+ /**
6793
+ * v1.1.15: the fallback path, finally called.
6794
+ *
6795
+ * Two invariants are load-bearing here:
6796
+ *
6797
+ * 1. the seed is the FLEET KEY, never the account code -- the account code is written to the log two
6798
+ * hundred lines above, and deriving a signing key from a value anyone can read would hand out the
6799
+ * account's identity;
6800
+ * 2. the machine id comes from the agent object that is already in this scope, not from device.json --
6801
+ * this machine never paired, so it has no device.json, which is the entire situation.
6802
+ *
6803
+ * A failure here does not change the outcome of the old attempt: the caller still sees
6804
+ * `{paired: false, degrade: true}` and can retry later. It must not throw out of this catch block.
6805
+ */
6806
+ try {
6807
+ const localMachineId = typeof agent?.runtime?.machineId === 'string' ? agent.runtime.machineId
6808
+ : (typeof agent?.machineId === 'string' ? agent.machineId : '');
6809
+ if (localMachineId !== '') {
6810
+ const joined = await joinByAccount({
6811
+ request,
6812
+ identity: deriveAccountKeypair(key),
6813
+ machine: { machine_id: localMachineId, machine_name: cfg.machineName ?? null },
6814
+ maxPolls: 10,
6815
+ });
6816
+ if (joined.ok === true && typeof joined.device_token === 'string') {
6817
+ saveDeviceToken(joined.device_token, identityOptions);
6818
+ ctx.logger?.info?.('w2m: joined by account admission (' + joined.state + ')');
6819
+ return { paired: true, by: 'account-admission' };
6820
+ }
6821
+ ctx.logger?.warn?.('w2m: account admission did not admit this machine (' + joined.state + (joined.code ? '/' + joined.code : '') + ')');
6822
+ } else {
6823
+ ctx.logger?.warn?.('w2m: no local machine id, so the account-admission fallback cannot ask to join');
6824
+ }
6825
+ } catch (fallbackError) {
6826
+ ctx.logger?.warn?.('w2m: account-admission fallback failed: ' + (fallbackError instanceof Error ? fallbackError.message : String(fallbackError)));
6827
+ }
6828
+ }
6719
6829
  ctx.logger?.warn?.(`w2m: pairing by ${by} failed${reason === null ? '' : ' (' + String(reason) + ')'}: ${error instanceof Error ? error.message : String(error)}`);
6720
6830
  return { paired: false, by, reason_code: reason, degrade };
6721
6831
  }
@@ -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
  }