@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.
@@ -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.2.3", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.2.6", "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.2.3",
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.3",
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": [
@@ -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
- stdout_head: execution?.stdout_head ?? '',
2601
- stdout_tail: execution?.stdout_tail ?? '',
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
 
@@ -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
+ }