@twinsearth/w2m-dsh-plugin 1.2.3 → 1.2.6
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 +184 -0
- package/README.md +462 -462
- package/codex/mcp-servers.toml +1 -1
- package/package.json +14 -4
- package/plugin.json +1 -1
- package/src/agent/agent.mjs +162 -4
- package/src/agent/crypto.mjs +31 -0
- package/src/plugin/ask-store.mjs +167 -0
- package/src/plugin/schema-spec.mjs +33 -0
- package/src/plugin/task-graph.mjs +147 -0
- package/src/plugin/tools.mjs +7748 -7385
- package/src/plugin/turn.mjs +41 -0
- package/src/relay/fleet-members.mjs +78 -0
- package/src/relay/fleet-messages.mjs +241 -173
- package/src/relay/report.mjs +5 -0
- package/src/relay/server.mjs +1596 -1498
- package/src/relay/state.mjs +175 -4
- package/src/relay/task-deps.mjs +92 -0
- package/src/util/chat-crypto.mjs +98 -0
- package/src/util/dataflow.mjs +72 -0
- package/src/util/envelope-signature.mjs +69 -0
- package/src/util/policy-matrix.mjs +65 -0
- package/src/util/redact.mjs +70 -0
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.2.
|
|
3
|
+
"version": "1.2.6",
|
|
4
4
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -64,14 +64,14 @@
|
|
|
64
64
|
"pack": "node scripts/pack.mjs",
|
|
65
65
|
"reachability": "node scripts/reachability-check.mjs",
|
|
66
66
|
"reachability:classify": "node scripts/reachability-classify.mjs",
|
|
67
|
-
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs",
|
|
67
|
+
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs",
|
|
68
68
|
"test:acceptance-doc": "node --test --test-force-exit test/acceptance-doc.test.mjs",
|
|
69
69
|
"test:account-identity": "node --test --test-force-exit test/account-identity.test.mjs",
|
|
70
70
|
"test:account-join": "node --test --test-force-exit test/account-join.test.mjs",
|
|
71
71
|
"test:admission": "node --test --test-force-exit test/admission.test.mjs",
|
|
72
72
|
"test:admission-routes": "node --test --test-force-exit test/admission-routes.test.mjs",
|
|
73
73
|
"test:agent": "node --test --test-force-exit test/agent.test.mjs test/memory.test.mjs",
|
|
74
|
-
"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 test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs",
|
|
74
|
+
"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 test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs",
|
|
75
75
|
"test:approval-gate": "node --test --test-force-exit test/approval-gate.test.mjs",
|
|
76
76
|
"test:approval-index": "node --test --test-force-exit test/approval-index.test.mjs",
|
|
77
77
|
"test:authority": "node --test --test-force-exit test/authority.test.mjs",
|
|
@@ -128,7 +128,17 @@
|
|
|
128
128
|
"test:fleet-memory": "node --test --test-force-exit test/fleet-memory.test.mjs",
|
|
129
129
|
"test:memory-view": "node --test --test-force-exit test/memory-view.test.mjs",
|
|
130
130
|
"test:schema-spec": "node --test --test-force-exit test/schema-spec.test.mjs",
|
|
131
|
-
"test:fleet-weight-parity": "node --test --test-force-exit test/fleet-weight-parity.test.mjs"
|
|
131
|
+
"test:fleet-weight-parity": "node --test --test-force-exit test/fleet-weight-parity.test.mjs",
|
|
132
|
+
"test:task-deps": "node --test --test-force-exit test/task-deps.test.mjs",
|
|
133
|
+
"test:dataflow-wiring": "node --test --test-force-exit test/dataflow-wiring.test.mjs",
|
|
134
|
+
"test:task-graph": "node --test --test-force-exit test/task-graph.test.mjs",
|
|
135
|
+
"test:ask-store": "node --test --test-force-exit test/ask-store.test.mjs",
|
|
136
|
+
"test:turn": "node --test --test-force-exit test/turn.test.mjs",
|
|
137
|
+
"test:fleet-members": "node --test --test-force-exit test/fleet-members.test.mjs",
|
|
138
|
+
"test:redact": "node --test --test-force-exit test/redact.test.mjs",
|
|
139
|
+
"test:policy-matrix": "node --test --test-force-exit test/policy-matrix.test.mjs",
|
|
140
|
+
"test:envelope-signature": "node --test --test-force-exit test/envelope-signature.test.mjs",
|
|
141
|
+
"test:chat-crypto": "node --test --test-force-exit test/chat-crypto.test.mjs"
|
|
132
142
|
},
|
|
133
143
|
"devDependencies": {
|
|
134
144
|
"@eslint/js": "^10.0.1",
|
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.2.
|
|
4
|
+
"version": "1.2.6",
|
|
5
5
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
package/src/agent/agent.mjs
CHANGED
|
@@ -25,7 +25,7 @@ import { Readable } from 'node:stream';
|
|
|
25
25
|
import { access, constants as fsConstants } from 'node:fs/promises';
|
|
26
26
|
import { existsSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
27
27
|
import { dirname, join } from 'node:path';
|
|
28
|
-
import { randomUUID } from 'node:crypto';
|
|
28
|
+
import { randomUUID , sign as signBytes } from 'node:crypto';
|
|
29
29
|
|
|
30
30
|
import {
|
|
31
31
|
EMPTY_SHA256,
|
|
@@ -50,10 +50,13 @@ import {
|
|
|
50
50
|
import { createSpool } from './spool.mjs';
|
|
51
51
|
import { MEMORY_FILE, appendMemory, memoryRecord } from './memory.mjs';
|
|
52
52
|
import { matchAllowedCommand, parseAllowedCommands } from './allowed-commands.mjs';
|
|
53
|
-
import { CRYPTO_REASON, DEVICE_KEY_FILE, DeviceKey, PeerKeyStore } from './crypto.mjs';
|
|
53
|
+
import { CRYPTO_REASON, DEVICE_KEY_FILE, DeviceKey, PeerKeyStore, deriveSigningKey } from './crypto.mjs';
|
|
54
54
|
import { DEFAULT_P2P_MODE, P2P_DEFAULTS, P2PNode, normalizeP2PMode, normalizeP2PPort } from './p2p-node.mjs';
|
|
55
55
|
import { punchSession, reverseDialSession } from './p2p.mjs';
|
|
56
56
|
import { createSigner } from '../signing.mjs';
|
|
57
|
+
import { planStageOutput } from '../util/dataflow.mjs';
|
|
58
|
+
import { redactEnvelope } from '../util/redact.mjs';
|
|
59
|
+
import { canonicalSignedPayload } from '../util/envelope-signature.mjs';
|
|
57
60
|
import { joinUrl, resolveBaseUrl } from './url.mjs';
|
|
58
61
|
|
|
59
62
|
/** Wire protocol version (§1). */
|
|
@@ -996,6 +999,8 @@ export function createAgent(options) {
|
|
|
996
999
|
fetchImpl = globalThis.fetch,
|
|
997
1000
|
heartbeatIntervalMs = HEARTBEAT_INTERVAL_MS,
|
|
998
1001
|
maxOutputBytes,
|
|
1002
|
+
// v1.2.6: filter secrets out of the readable envelope fields. Off is allowed and marked `unredacted: true`.
|
|
1003
|
+
redactOutput = true,
|
|
999
1004
|
objectMode = 'temp',
|
|
1000
1005
|
tmpDir,
|
|
1001
1006
|
onceIdleMs = 0,
|
|
@@ -2208,6 +2213,42 @@ export function createAgent(options) {
|
|
|
2208
2213
|
return result;
|
|
2209
2214
|
}
|
|
2210
2215
|
|
|
2216
|
+
/**
|
|
2217
|
+
* PUT one stage's stdout to the relay's content-addressed blob store (v1.2.4).
|
|
2218
|
+
*
|
|
2219
|
+
* The relay's blob route is device-gated and content-addressed: the name IS the hash, so a body that does not hash to it
|
|
2220
|
+
* would be stored under the wrong name. The caller hashes first and passes that name, which also means the assistant's
|
|
2221
|
+
* envelope can refer to the blob before the upload finishes without inventing an identifier.
|
|
2222
|
+
*
|
|
2223
|
+
* Returns the refusal instead of throwing: a pipeline stage fails as a stage, with BLOB_UPLOAD_FAILED, because that is
|
|
2224
|
+
* the fact a reader needs. `{ status }` is the HTTP status when there was a response, 0 when the call itself failed.
|
|
2225
|
+
*
|
|
2226
|
+
* @param {string} sha256
|
|
2227
|
+
* @param {string} body
|
|
2228
|
+
* @returns {Promise<{ok: boolean, status: number, sha256?: string, code?: string}>}
|
|
2229
|
+
*/
|
|
2230
|
+
async function putBlobBytes(sha256, body) {
|
|
2231
|
+
const path = `/v1/fleet/blobs/${sha256}`;
|
|
2232
|
+
// The stage's stdout arrives as a Buffer. Work on BYTES: the name must be the hash of exactly what is sent, and the
|
|
2233
|
+
// signature must cover those same bytes -- decoding and re-encoding would replace invalid UTF-8 and change the hash.
|
|
2234
|
+
const bytes = Buffer.isBuffer(body) ? body : Buffer.from(String(body ?? ''), 'utf8');
|
|
2235
|
+
const headers = { 'content-type': 'application/octet-stream', accept: 'application/json' };
|
|
2236
|
+
if (token()) headers.authorization = `Bearer ${token()}`;
|
|
2237
|
+
Object.assign(headers, signingHeaders('PUT', path, bytes.toString('utf8')));
|
|
2238
|
+
try {
|
|
2239
|
+
const response = await fetchImpl(url(path), { method: 'PUT', headers, body: bytes });
|
|
2240
|
+
if (response.ok) return { ok: true, status: response.status, sha256 };
|
|
2241
|
+
let code = `HTTP_${response.status}`;
|
|
2242
|
+
try {
|
|
2243
|
+
const parsed = JSON.parse(await response.text());
|
|
2244
|
+
if (typeof parsed?.error?.code === 'string') code = parsed.error.code;
|
|
2245
|
+
} catch { /* the code is then the HTTP status, which is still a name */ }
|
|
2246
|
+
return { ok: false, status: response.status, code };
|
|
2247
|
+
} catch (error) {
|
|
2248
|
+
return { ok: false, status: 0, code: error instanceof Error ? 'BLOB_UPLOAD_FAILED' : 'BLOB_UPLOAD_FAILED' };
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2211
2252
|
/** @param {string} path @param {object} body @param {number} [retries] */
|
|
2212
2253
|
async function postJsonWithRetry(path, body, retries = resultRetries) {
|
|
2213
2254
|
let lastError = null;
|
|
@@ -2295,6 +2336,15 @@ export function createAgent(options) {
|
|
|
2295
2336
|
platform,
|
|
2296
2337
|
caps,
|
|
2297
2338
|
};
|
|
2339
|
+
/**
|
|
2340
|
+
* v1.2.6: the public half of this machine's envelope-signing key. It is not a secret, and pairing is the one moment the
|
|
2341
|
+
* relay has a reason to believe it belongs to this machine -- so a later result can be checked against the key that was
|
|
2342
|
+
* registered here rather than against whichever key the result happens to carry.
|
|
2343
|
+
*/
|
|
2344
|
+
{
|
|
2345
|
+
const signing = deriveSigningKey(deviceKey);
|
|
2346
|
+
if (signing !== null) body.public_key = signing.publicKeyBase64;
|
|
2347
|
+
}
|
|
2298
2348
|
/**
|
|
2299
2349
|
* Send only the discriminator that was actually given.
|
|
2300
2350
|
*
|
|
@@ -2520,6 +2570,7 @@ export function createAgent(options) {
|
|
|
2520
2570
|
anchorsBefore,
|
|
2521
2571
|
anchorsAfter,
|
|
2522
2572
|
execution,
|
|
2573
|
+
blobSha256 = null,
|
|
2523
2574
|
status,
|
|
2524
2575
|
refusalReason,
|
|
2525
2576
|
warnings,
|
|
@@ -2591,14 +2642,17 @@ export function createAgent(options) {
|
|
|
2591
2642
|
refusal_reason: refusalReason ?? null,
|
|
2592
2643
|
stdout_sha256: execution ? execution.stdout_sha256 : EMPTY_SHA256,
|
|
2593
2644
|
stdout_bytes: execution ? execution.stdout_bytes : 0,
|
|
2645
|
+
...(blobSha256 === null ? {} : { blob_sha256: blobSha256, output_mode: 'blob' }),
|
|
2594
2646
|
stderr_sha256: execution ? execution.stderr_sha256 : EMPTY_SHA256,
|
|
2595
2647
|
stderr_bytes: execution ? execution.stderr_bytes : 0,
|
|
2596
2648
|
warnings,
|
|
2597
2649
|
// Optional fields (§5.2): present on both machines for the same task, so
|
|
2598
2650
|
// they stay comparable; head/tail are informational only.
|
|
2599
2651
|
stdout_normalized_sha256: sha256Hex(normalized),
|
|
2600
|
-
|
|
2601
|
-
|
|
2652
|
+
// v1.2.4: when the stage's output became a blob, the envelope names it and does NOT also inline the first/last 4 KiB --
|
|
2653
|
+
// carrying both would make two readers disagree about which one is authoritative.
|
|
2654
|
+
stdout_head: blobSha256 === null ? (execution?.stdout_head ?? '') : '',
|
|
2655
|
+
stdout_tail: blobSha256 === null ? (execution?.stdout_tail ?? '') : '',
|
|
2602
2656
|
stderr_head: execution?.stderr_head ?? '',
|
|
2603
2657
|
stderr_tail: execution?.stderr_tail ?? '',
|
|
2604
2658
|
untracked: anchorsAfter?.untracked ?? anchorsBefore?.untracked ?? [],
|
|
@@ -2606,6 +2660,18 @@ export function createAgent(options) {
|
|
|
2606
2660
|
toolchain: toolchain ?? null,
|
|
2607
2661
|
};
|
|
2608
2662
|
if (execution?.signal) envelope.signal = execution.signal;
|
|
2663
|
+
|
|
2664
|
+
/**
|
|
2665
|
+
* v1.2.6: secrets a command printed are replaced before the envelope leaves this machine. The hashes above stay the
|
|
2666
|
+
* evidence of the raw bytes; only the readable heads/tails are filtered. `redactOutput: false` disables it and marks the
|
|
2667
|
+
* envelope `unredacted: true`, because a silent no-op would be worse than no redaction.
|
|
2668
|
+
*/
|
|
2669
|
+
if (redactOutput !== false) {
|
|
2670
|
+
const filtered = redactEnvelope(envelope, { enabled: true });
|
|
2671
|
+
Object.assign(envelope, filtered.envelope);
|
|
2672
|
+
} else {
|
|
2673
|
+
envelope.unredacted = true;
|
|
2674
|
+
}
|
|
2609
2675
|
if (offer.write === true) envelope.write = true;
|
|
2610
2676
|
|
|
2611
2677
|
// v0.3.3 `pipeline` (§5.2 optional). Present only for a chain, so a single-command envelope is
|
|
@@ -2671,6 +2737,32 @@ export function createAgent(options) {
|
|
|
2671
2737
|
? { written: true, file: MEMORY_FILE, rotated: memoryWrite.rotated === true }
|
|
2672
2738
|
: { written: false, reason_code: memoryWrite.error };
|
|
2673
2739
|
|
|
2740
|
+
/**
|
|
2741
|
+
* v1.2.6: sign the four facts that identify THE COMMAND. The signature covers the tuple only, because everything else in
|
|
2742
|
+
* the envelope is a report about the run rather than a claim about which command ran. `unsigned` is a fact a reader can
|
|
2743
|
+
* see (an older build, or no device key); it is not silently claimed as signed.
|
|
2744
|
+
*/
|
|
2745
|
+
const signingKey = deviceKey === null ? null : deriveSigningKey(deviceKey);
|
|
2746
|
+
if (signingKey !== null) {
|
|
2747
|
+
const payload = canonicalSignedPayload({
|
|
2748
|
+
task_id: envelope.task_id,
|
|
2749
|
+
argv_hash: envelope.command_hash,
|
|
2750
|
+
machine_id: envelope.machine_id,
|
|
2751
|
+
ts: endedAt,
|
|
2752
|
+
});
|
|
2753
|
+
try {
|
|
2754
|
+
envelope.signature = {
|
|
2755
|
+
alg: 'ed25519',
|
|
2756
|
+
value: signBytes(null, Buffer.from(payload, 'utf8'), signingKey.privateKey).toString('base64'),
|
|
2757
|
+
public_key: signingKey.publicKeyBase64,
|
|
2758
|
+
ts: endedAt,
|
|
2759
|
+
};
|
|
2760
|
+
} catch {
|
|
2761
|
+
// The failure is reported by the envelope arriving unsigned; a key that cannot sign must not fail the run.
|
|
2762
|
+
// A key that cannot sign must not fail the run: the envelope simply arrives unsigned, which readers report.
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2674
2766
|
return buildEnvelope(envelope);
|
|
2675
2767
|
}
|
|
2676
2768
|
|
|
@@ -2969,6 +3061,8 @@ export function createAgent(options) {
|
|
|
2969
3061
|
const chainStartedAt = Date.now();
|
|
2970
3062
|
|
|
2971
3063
|
const stageReports = [];
|
|
3064
|
+
/** The last stage's blob reference, so the envelope can name it without re-deriving anything. */
|
|
3065
|
+
let lastBlobSha256 = null;
|
|
2972
3066
|
/** The last stage's execution, used for the envelope's top-level exit code and output hashes. */
|
|
2973
3067
|
let execution = null;
|
|
2974
3068
|
let stoppedAt = null;
|
|
@@ -3038,6 +3132,60 @@ export function createAgent(options) {
|
|
|
3038
3132
|
for (const warning of ran.warnings) if (!warnings.includes(warning)) warnings.push(warning);
|
|
3039
3133
|
|
|
3040
3134
|
const stageStatus = classifyExit(ran);
|
|
3135
|
+
/**
|
|
3136
|
+
* v1.2.4: a stage whose stdout is large leaves it as a blob, and the stage record carries the reference instead of
|
|
3137
|
+
* the text. The upload is authoritative: a blob that did not land FAILS the stage (BLOB_UPLOAD_FAILED) rather than
|
|
3138
|
+
* leaving a reference to nothing, because a reduce over a partial fleet that calls itself complete is worse than a
|
|
3139
|
+
* failed stage.
|
|
3140
|
+
*/
|
|
3141
|
+
const stagePlan = planStageOutput({
|
|
3142
|
+
machineId: identity.machine_id,
|
|
3143
|
+
stageIndex: stage.index,
|
|
3144
|
+
stdoutBytes: ran.stdout_bytes,
|
|
3145
|
+
});
|
|
3146
|
+
if (stagePlan.ok !== true) {
|
|
3147
|
+
stageReports.push({
|
|
3148
|
+
index: stage.index,
|
|
3149
|
+
command_argv: stage.command_argv,
|
|
3150
|
+
cwd_rel: stageCwdOutcome.cwd_rel ?? stage.cwd_rel,
|
|
3151
|
+
continue_on_failure: stage.continue_on_failure,
|
|
3152
|
+
status: 'failed',
|
|
3153
|
+
refusal_reason: stagePlan.code,
|
|
3154
|
+
exit_code: ran.exit_code,
|
|
3155
|
+
duration_ms: ran.duration_ms,
|
|
3156
|
+
stdout_sha256: ran.stdout_sha256,
|
|
3157
|
+
stderr_sha256: ran.stderr_sha256,
|
|
3158
|
+
});
|
|
3159
|
+
stoppedAt = stage.index;
|
|
3160
|
+
break;
|
|
3161
|
+
}
|
|
3162
|
+
let blobSha256 = null;
|
|
3163
|
+
if (stagePlan.mode === 'blob') {
|
|
3164
|
+
// The agent caps what it keeps in memory; if that cap was hit the blob holds the same bytes the hash covers, and
|
|
3165
|
+
// the warning says so instead of letting a reader assume the stage printed exactly this much.
|
|
3166
|
+
if (ran.truncated_stdout === true && !warnings.includes('PIPELINE_STDOUT_TRUNCATED')) warnings.push('PIPELINE_STDOUT_TRUNCATED');
|
|
3167
|
+
const uploaded = await putBlobBytes(ran.stdout_sha256, ran.stdout);
|
|
3168
|
+
if (uploaded.ok !== true) {
|
|
3169
|
+
log("warn", offer.task_id + " stage " + stage.index + ": blob upload failed", { code: uploaded.code });
|
|
3170
|
+
stageReports.push({
|
|
3171
|
+
index: stage.index,
|
|
3172
|
+
command_argv: stage.command_argv,
|
|
3173
|
+
cwd_rel: stageCwdOutcome.cwd_rel ?? stage.cwd_rel,
|
|
3174
|
+
continue_on_failure: stage.continue_on_failure,
|
|
3175
|
+
status: 'failed',
|
|
3176
|
+
refusal_reason: 'BLOB_UPLOAD_FAILED',
|
|
3177
|
+
detail: { code: uploaded.code, http_status: uploaded.status },
|
|
3178
|
+
exit_code: ran.exit_code,
|
|
3179
|
+
duration_ms: ran.duration_ms,
|
|
3180
|
+
stdout_sha256: ran.stdout_sha256,
|
|
3181
|
+
stderr_sha256: ran.stderr_sha256,
|
|
3182
|
+
});
|
|
3183
|
+
stoppedAt = stage.index;
|
|
3184
|
+
break;
|
|
3185
|
+
}
|
|
3186
|
+
blobSha256 = uploaded.sha256;
|
|
3187
|
+
lastBlobSha256 = blobSha256;
|
|
3188
|
+
}
|
|
3041
3189
|
stageReports.push({
|
|
3042
3190
|
index: stage.index,
|
|
3043
3191
|
command_argv: stage.command_argv,
|
|
@@ -3050,6 +3198,12 @@ export function createAgent(options) {
|
|
|
3050
3198
|
duration_ms: ran.duration_ms,
|
|
3051
3199
|
stdout_sha256: ran.stdout_sha256,
|
|
3052
3200
|
stderr_sha256: ran.stderr_sha256,
|
|
3201
|
+
...(blobSha256 === null
|
|
3202
|
+
? {}
|
|
3203
|
+
: {
|
|
3204
|
+
blob_sha256: blobSha256,
|
|
3205
|
+
output_mode: 'blob',
|
|
3206
|
+
}),
|
|
3053
3207
|
});
|
|
3054
3208
|
|
|
3055
3209
|
if (ran.timed_out) log('warn', `${offer.task_id} stage ${stage.index} timed out`);
|
|
@@ -3081,6 +3235,7 @@ export function createAgent(options) {
|
|
|
3081
3235
|
anchorsBefore,
|
|
3082
3236
|
anchorsAfter,
|
|
3083
3237
|
execution,
|
|
3238
|
+
blobSha256: lastBlobSha256,
|
|
3084
3239
|
status,
|
|
3085
3240
|
refusalReason: null,
|
|
3086
3241
|
warnings,
|
|
@@ -3350,6 +3505,9 @@ export function createAgent(options) {
|
|
|
3350
3505
|
// envelope above and released with it, so a long-lived agent keeps one record per *live* task.
|
|
3351
3506
|
releaseDialRecord(envelope.task_id);
|
|
3352
3507
|
scheduleChannelRelease(envelope.task_id);
|
|
3508
|
+
|
|
3509
|
+
|
|
3510
|
+
|
|
3353
3511
|
return envelope;
|
|
3354
3512
|
}
|
|
3355
3513
|
|
package/src/agent/crypto.mjs
CHANGED
|
@@ -336,6 +336,37 @@ export function rawPrivateOf(privateKey) {
|
|
|
336
336
|
* stored: the public half is derived, which is what makes a hand-edited file unable to publish a
|
|
337
337
|
* public key that does not match its private key.
|
|
338
338
|
*/
|
|
339
|
+
/**
|
|
340
|
+
* v1.2.6: the Ed25519 key this machine signs envelopes with.
|
|
341
|
+
*
|
|
342
|
+
* ## Why derived rather than a second file
|
|
343
|
+
*
|
|
344
|
+
* `DeviceKey` is X25519: it does key agreement for the p2p handshake and cannot sign (the runtime refuses it outright). A second
|
|
345
|
+
* on-disk key would be a second thing to back up, rotate and lose. The device's 32-byte private scalar is a perfectly good
|
|
346
|
+
* Ed25519 seed, so the signing key is derived from it: one secret to protect, and a machine that re-pairs cannot end up with a
|
|
347
|
+
* signature key nobody can verify.
|
|
348
|
+
*
|
|
349
|
+
* The public half travels INSIDE the signature so a reader can verify the tuple without knowing this machine beforehand. Binding
|
|
350
|
+
* that key to the relay's device record is the remaining step for non-repudiation across machines -- recorded in the roadmap.
|
|
351
|
+
*
|
|
352
|
+
* @param {DeviceKey} deviceKey
|
|
353
|
+
* @returns {{privateKey: import('node:crypto').KeyObject, publicKeyBase64: string}|null}
|
|
354
|
+
*/
|
|
355
|
+
export function deriveSigningKey(deviceKey) {
|
|
356
|
+
if (!(deviceKey instanceof DeviceKey)) return null;
|
|
357
|
+
try {
|
|
358
|
+
const seed = rawPrivateOf(deviceKey.privateKey);
|
|
359
|
+
if (seed.length !== 32) return null;
|
|
360
|
+
const pkcs8 = Buffer.concat([Buffer.from('302e020100300506032b657004220420', 'hex'), seed]);
|
|
361
|
+
const privateKey = createPrivateKey({ key: pkcs8, format: 'der', type: 'pkcs8' });
|
|
362
|
+
const publicKey = createPublicKey(privateKey);
|
|
363
|
+
const raw = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32);
|
|
364
|
+
return { privateKey, publicKeyBase64: raw.toString('base64') };
|
|
365
|
+
} catch {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
339
370
|
export class DeviceKey {
|
|
340
371
|
/**
|
|
341
372
|
* @param {object} options
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open questions and their answers, on disk.
|
|
3
|
+
*
|
|
4
|
+
* ## Why a file, when the answer also arrives by chat
|
|
5
|
+
*
|
|
6
|
+
* An `await` that is answered while the asking process is being restarted has to survive the restart: the question is still
|
|
7
|
+
* the machine's question, and the answer is still an answer. Keeping the state in memory made the outcome depend on uptime.
|
|
8
|
+
*
|
|
9
|
+
* ## The two states that are easy to get wrong
|
|
10
|
+
*
|
|
11
|
+
* - **timeout is not final.** A late answer is recorded as `answered_after_timeout` rather than overwriting the timeout or
|
|
12
|
+
* being dropped: the operator asked, something answered, and when it answered is a fact worth keeping.
|
|
13
|
+
* - **a settled question is never reopened.** `settle` on a decided ask returns the existing record with `settled: false`, so
|
|
14
|
+
* two replies cannot race into one slot.
|
|
15
|
+
*
|
|
16
|
+
* Records are appended as JSON lines; `compact` rewrites atomically through a temporary file, which is the only writer of the
|
|
17
|
+
* whole file.
|
|
18
|
+
*/
|
|
19
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { dirname, join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
export const ASK_STATE = Object.freeze(['open', 'answered', 'refused', 'timeout', 'answered_after_timeout']);
|
|
23
|
+
|
|
24
|
+
/** The states that are still waiting for something. */
|
|
25
|
+
export const OPEN_STATES = Object.freeze(['open']);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {{now?: () => number, maxRecords?: number}} [options]
|
|
29
|
+
*/
|
|
30
|
+
export function createAskStore(options = {}) {
|
|
31
|
+
const now = typeof options.now === 'function' ? options.now : () => Date.now();
|
|
32
|
+
const maxRecords = Number.isInteger(options.maxRecords) && options.maxRecords > 0 ? options.maxRecords : 2_000;
|
|
33
|
+
/** ask_id -> record */
|
|
34
|
+
const asks = new Map();
|
|
35
|
+
let seq = 0;
|
|
36
|
+
const file = typeof options.stateDir === 'string' && options.stateDir !== ''
|
|
37
|
+
? join(options.stateDir, 'ask-store.jsonl')
|
|
38
|
+
: null;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Load whatever a previous process wrote. A malformed line is skipped rather than fatal: an unfinished write is the
|
|
42
|
+
* expected shape of a crash, and losing the whole store over the last line would be worse than losing the line.
|
|
43
|
+
*/
|
|
44
|
+
if (file !== null) {
|
|
45
|
+
try {
|
|
46
|
+
const text = readFileSync(file, 'utf8');
|
|
47
|
+
for (const line of text.split('\n')) {
|
|
48
|
+
if (line.trim() === '') continue;
|
|
49
|
+
try { const record = JSON.parse(line); if (typeof record?.ask_id === 'string') asks.set(record.ask_id, record); } catch { /* one bad line */ }
|
|
50
|
+
}
|
|
51
|
+
} catch { /* no store yet */ }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Rewrite the file atomically: a reader never sees half a record. */
|
|
55
|
+
function persist() {
|
|
56
|
+
if (file === null) return;
|
|
57
|
+
try {
|
|
58
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
59
|
+
const body = exportAll().map((record) => JSON.stringify(record)).join('\n') + '\n';
|
|
60
|
+
const temporary = file + '.tmp';
|
|
61
|
+
writeFileSync(temporary, body, 'utf8');
|
|
62
|
+
renameSync(temporary, file);
|
|
63
|
+
} catch { /* the in-memory store is still correct; persistence is best effort and says so by not throwing */ }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function openAsk(input = {}) {
|
|
67
|
+
const sessionId = typeof input.sessionId === 'string' && input.sessionId !== '' ? input.sessionId : null;
|
|
68
|
+
const messageId = typeof input.messageId === 'string' && input.messageId !== '' ? input.messageId : null;
|
|
69
|
+
if (sessionId === null || messageId === null) return { ok: false, code: 'ASK_BAD_INPUT' };
|
|
70
|
+
const askId = 'ask_' + (++seq) + '_' + now();
|
|
71
|
+
const record = {
|
|
72
|
+
ask_id: askId,
|
|
73
|
+
session_id: sessionId,
|
|
74
|
+
message_id: messageId,
|
|
75
|
+
thread_id: typeof input.threadId === 'string' ? input.threadId : null,
|
|
76
|
+
to: typeof input.to === 'string' ? input.to : null,
|
|
77
|
+
opened_at_ms: now(),
|
|
78
|
+
deadline_ms: now() + (Number.isFinite(input.timeoutMs) ? Math.max(1, input.timeoutMs) : 60_000),
|
|
79
|
+
state: 'open',
|
|
80
|
+
reply_message_id: null,
|
|
81
|
+
settled_at_ms: null,
|
|
82
|
+
};
|
|
83
|
+
asks.set(askId, record);
|
|
84
|
+
trim();
|
|
85
|
+
persist();
|
|
86
|
+
return { ok: true, ask: { ...record } };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function settle(askId, state, reply = {}) {
|
|
90
|
+
const record = asks.get(askId) ?? null;
|
|
91
|
+
if (record === null) return { ok: false, code: 'ASK_UNKNOWN', detail: { askId } };
|
|
92
|
+
if (!ASK_STATE.includes(state) || state === 'open') return { ok: false, code: 'ASK_BAD_STATE', detail: { state } };
|
|
93
|
+
if (record.state !== 'open' && record.state !== 'timeout') {
|
|
94
|
+
return { ok: true, ask: { ...record }, settled: false };
|
|
95
|
+
}
|
|
96
|
+
// A reply after the deadline is recorded as such: "answered late" and "answered" are different facts.
|
|
97
|
+
const late = record.state === 'timeout';
|
|
98
|
+
record.state = late ? 'answered_after_timeout' : state;
|
|
99
|
+
record.reply_message_id = typeof reply.messageId === 'string' ? reply.messageId : null;
|
|
100
|
+
record.settled_at_ms = now();
|
|
101
|
+
persist();
|
|
102
|
+
return { ok: true, ask: { ...record }, settled: true };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function expire(askId) {
|
|
106
|
+
const record = asks.get(askId) ?? null;
|
|
107
|
+
if (record === null) return { ok: false, code: 'ASK_UNKNOWN', detail: { askId } };
|
|
108
|
+
if (record.state !== 'open') return { ok: true, ask: { ...record }, expired: false };
|
|
109
|
+
record.state = 'timeout';
|
|
110
|
+
record.settled_at_ms = now();
|
|
111
|
+
persist();
|
|
112
|
+
return { ok: true, ask: { ...record }, expired: true };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function expireDue() {
|
|
116
|
+
const due = [];
|
|
117
|
+
for (const record of asks.values()) {
|
|
118
|
+
if (record.state === 'open' && record.deadline_ms <= now()) {
|
|
119
|
+
record.state = 'timeout';
|
|
120
|
+
record.settled_at_ms = now();
|
|
121
|
+
due.push(record.ask_id);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (due.length > 0) persist();
|
|
125
|
+
return { ok: true, expired: due };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** @param {{state?: string, sessionId?: string}} [filter] */
|
|
129
|
+
function list(filter = {}) {
|
|
130
|
+
const wanted = typeof filter.state === 'string' ? filter.state : null;
|
|
131
|
+
const session = typeof filter.sessionId === 'string' ? filter.sessionId : null;
|
|
132
|
+
return {
|
|
133
|
+
ok: true,
|
|
134
|
+
asks: [...asks.values()]
|
|
135
|
+
.filter((record) => (wanted === null || record.state === wanted) && (session === null || record.session_id === session))
|
|
136
|
+
.sort((a, b) => a.opened_at_ms - b.opened_at_ms)
|
|
137
|
+
.map((record) => ({ ...record })),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function restore(records) {
|
|
142
|
+
const restored = [];
|
|
143
|
+
for (const record of Array.isArray(records) ? records : []) {
|
|
144
|
+
const askId = typeof record?.ask_id === 'string' ? record.ask_id : null;
|
|
145
|
+
if (askId === null || !ASK_STATE.includes(record.state)) continue;
|
|
146
|
+
asks.set(askId, { ...record });
|
|
147
|
+
restored.push(askId);
|
|
148
|
+
}
|
|
149
|
+
persist();
|
|
150
|
+
return { ok: true, restored: restored.length };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The records that survive a restart, oldest first. */
|
|
154
|
+
function exportAll() {
|
|
155
|
+
return [...asks.values()].sort((a, b) => a.opened_at_ms - b.opened_at_ms).map((record) => ({ ...record }));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function trim() {
|
|
159
|
+
while (asks.size > maxRecords) {
|
|
160
|
+
const oldest = [...asks.values()].sort((a, b) => a.opened_at_ms - b.opened_at_ms)[0];
|
|
161
|
+
if (oldest === undefined) return;
|
|
162
|
+
asks.delete(oldest.ask_id);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return { openAsk, settle, expire, expireDue, list, restore, exportAll, _asks: asks };
|
|
167
|
+
}
|
|
@@ -47,3 +47,36 @@ export function schemaProblems(schema, name = 'tool') {
|
|
|
47
47
|
.map(({ path }) => ({ path: path + '.additionalProperties', why: 'an object parameter must state additionalProperties: true or false' }))
|
|
48
48
|
.map((problem) => ({ ...problem, path: name + '.' + problem.path }));
|
|
49
49
|
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
/** Keys a parameter declaration may carry. Anything else is a typo or a parameter nested by accident. */
|
|
53
|
+
const PARAM_KEYS = new Set(['type', 'items', 'description', 'default', 'required', 'enum', 'properties', 'additionalProperties', 'oneOf', 'anyOf']);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Parameters nested inside another parameter's declaration.
|
|
57
|
+
*
|
|
58
|
+
* This is the shape that shipped in the 1.2.4 build: a parameter block pasted inside its predecessor. Every local schema check
|
|
59
|
+
* was green, and the real host refused the whole entry ("parameters.poll_ms.reducer_machine_id is not supported by the value
|
|
60
|
+
* schema DSL") -- because to JavaScript it is a perfectly good object.
|
|
61
|
+
*
|
|
62
|
+
* @param {Record<string, object>} parameters
|
|
63
|
+
* @param {string} [name]
|
|
64
|
+
* @returns {Array<{path: string, why: string}>}
|
|
65
|
+
*/
|
|
66
|
+
export function strayParameterProblems(parameters, name = 'tool') {
|
|
67
|
+
const problems = [];
|
|
68
|
+
for (const [key, decl] of Object.entries(parameters ?? {})) {
|
|
69
|
+
if (decl === null || typeof decl !== 'object') continue;
|
|
70
|
+
for (const nested of Object.keys(decl)) {
|
|
71
|
+
if (PARAM_KEYS.has(nested)) continue;
|
|
72
|
+
const looksLikeParameter = decl[nested] !== null && typeof decl[nested] === 'object' && typeof decl[nested].type === 'string';
|
|
73
|
+
if (looksLikeParameter) {
|
|
74
|
+
problems.push({
|
|
75
|
+
path: name + '.parameters.' + key + '.' + nested,
|
|
76
|
+
why: 'a parameter declaration is nested inside another parameter; move it to the parameters object',
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return problems;
|
|
82
|
+
}
|