@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +100 -0
- package/README.md +151 -899
- package/codex/mcp-servers.toml +1 -1
- package/package.json +6 -4
- package/plugin.json +1 -1
- package/src/agent/agent.mjs +17 -0
- package/src/plugin/account-join.mjs +86 -0
- package/src/plugin/tools.mjs +114 -4
- package/src/relay/admission.mjs +13 -2
- package/src/relay/server.mjs +1496 -1496
package/codex/mcp-servers.toml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "1.1.
|
|
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.
|
|
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": [
|
package/src/agent/agent.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -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.
|
|
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
|
}
|
package/src/relay/admission.mjs
CHANGED
|
@@ -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
|
}
|