mixdog 0.9.147 → 0.9.149
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/package.json +23 -3
- package/scripts/build-voice-runtime-unix.sh +101 -0
- package/scripts/embedding-quality-probe.mjs +96 -0
- package/scripts/generate-voice-runtime-manifest.mjs +300 -0
- package/scripts/generate-voice-runtime-manifest.test.mjs +135 -0
- package/scripts/native-binary-arch.mjs +30 -13
- package/scripts/native-binary-arch.test.mjs +40 -4
- package/scripts/plan-voice-runtime-release.mjs +73 -0
- package/scripts/prepare-native-assets.mjs +50 -9
- package/scripts/prune-embedding-runtime.mjs +3 -3
- package/scripts/sync-voice-runtime-manifest.mjs +46 -0
- package/scripts/verify-release-assets.mjs +106 -6
- package/scripts/verify-voice-runtime-release.mjs +89 -0
- package/scripts/voice-runtime-config.json +52 -0
- package/src/app.mjs +42 -5
- package/src/headless-exec.mjs +58 -32
- package/src/headless-exec.test.mjs +74 -13
- package/src/rules/lead/01-general.md +2 -0
- package/src/rules/shared/10-tool-workflow.md +7 -4
- package/src/rules/shared/30-exploration.md +5 -1
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +28 -9
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +6 -1
- package/src/runtime/agent/orchestrator/config.mjs +53 -18
- package/src/runtime/agent/orchestrator/mcp/cancellation.test.mjs +139 -0
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +34 -5
- package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -10
- package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +5 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.mjs +98 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.test.mjs +90 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +67 -16
- package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +4 -82
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +0 -5
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +62 -121
- package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.mjs +48 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.test.mjs +215 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +44 -48
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +54 -31
- package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +56 -2
- package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +34 -2
- package/src/runtime/agent/orchestrator/providers/cursor-wire.mjs +61 -1
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +6 -1
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +61 -15
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +120 -1
- package/src/runtime/agent/orchestrator/providers/lib/env-utils.mjs +9 -0
- package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +0 -3
- package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +187 -84
- package/src/runtime/agent/orchestrator/providers/openai-codex-identity.test.mjs +32 -9
- package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +78 -33
- package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +21 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +36 -36
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +16 -6
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +50 -213
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +135 -126
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +20 -11
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +112 -36
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +220 -50
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +112 -9
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +11 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +130 -60
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +28 -10
- package/src/runtime/agent/orchestrator/providers/registry.mjs +122 -95
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +69 -3
- package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +4 -22
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +7 -14
- package/src/runtime/agent/orchestrator/session/compact-policy.test.mjs +128 -0
- package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +27 -48
- package/src/runtime/agent/orchestrator/session/loop/steering.mjs +19 -14
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -43
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +43 -5
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -2
- package/src/runtime/agent/orchestrator/session/manager/pending-messages-persistence.test.mjs +325 -0
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +599 -195
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +22 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +9 -2
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +26 -16
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +23 -11
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +118 -3
- package/src/runtime/agent/orchestrator/session/store-summary-reader.test.mjs +332 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +38 -1
- package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +27 -6
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -20
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +291 -128
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +46 -9
- package/src/runtime/agent/orchestrator/tools/builtin/edit-encoding-eol.test.mjs +224 -0
- package/src/runtime/agent/orchestrator/tools/builtin/edit-sequential-occupation.test.mjs +53 -3
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +255 -25
- package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +87 -0
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +132 -9
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +10 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-pattern-fanout.mjs +29 -3
- package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool-integrity.test.mjs +14 -0
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +180 -31
- package/src/runtime/agent/orchestrator/tools/builtin/local-search-telemetry.mjs +4 -1
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +32 -5
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-health.test.mjs +5 -2
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +10 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +38 -10
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +12 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +31 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +11 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-windows.mjs +22 -4
- package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +6 -8
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +25 -24
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +152 -133
- package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +114 -42
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +129 -15
- package/src/runtime/agent/orchestrator/tools/code-graph/loose-file-target.test.mjs +72 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +7 -8
- package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +4 -4
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +201 -19
- package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +26 -3
- package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +10 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/symbol-search-scope.test.mjs +55 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +115 -25
- package/src/runtime/agent/orchestrator/tools/code-graph/text-span-mask.test.mjs +268 -0
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +3 -3
- package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +296 -46
- package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.test.mjs +104 -0
- package/src/runtime/agent/orchestrator/tools/lib/shell-descendants.mjs +136 -0
- package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +127 -38
- package/src/runtime/agent/orchestrator/tools/patch/engine-contract-gate.test.mjs +661 -0
- package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +350 -9
- package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +418 -44
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +27 -3
- package/src/runtime/agent/orchestrator/tools/patch/patch-codec-eol.test.mjs +552 -0
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +65 -15
- package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +284 -109
- package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +51 -21
- package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +44 -27
- package/src/runtime/agent/orchestrator/tools/shell-policy.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
- package/src/runtime/attachments/store.mjs +11 -3
- package/src/runtime/channels/data/voice-runtime-manifest.json +46 -70
- package/src/runtime/channels/lib/event-queue.mjs +51 -41
- package/src/runtime/channels/lib/scheduler.mjs +23 -28
- package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -17
- package/src/runtime/channels/lib/voice-runtime-fetcher.test.mjs +17 -0
- package/src/runtime/channels/lib/webhook.mjs +39 -20
- package/src/runtime/channels/lib/whisper-server.mjs +46 -1
- package/src/runtime/channels/lib/worker-main.mjs +3 -1
- package/src/runtime/media/adapters/gemini-video.mjs +20 -4
- package/src/runtime/media/jobs.mjs +13 -0
- package/src/runtime/media/renditions.mjs +7 -1
- package/src/runtime/media/store.mjs +42 -22
- package/src/runtime/memory/index.mjs +4 -9
- package/src/runtime/memory/lib/agent-ipc.mjs +1 -6
- package/src/runtime/memory/lib/archive-security.test.mjs +21 -1
- package/src/runtime/memory/lib/core-memory-store.mjs +28 -50
- package/src/runtime/memory/lib/embedding-model-config.mjs +37 -1
- package/src/runtime/memory/lib/embedding-model-config.test.mjs +33 -0
- package/src/runtime/memory/lib/embedding-provider.mjs +34 -12
- package/src/runtime/memory/lib/embedding-worker.mjs +37 -5
- package/src/runtime/memory/lib/ko-morph.mjs +46 -15
- package/src/runtime/memory/lib/memory-action-handlers.mjs +18 -1
- package/src/runtime/memory/lib/memory-cycle1.mjs +1 -18
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +1 -18
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +98 -45
- package/src/runtime/memory/lib/memory-cycle2-shared.mjs +71 -0
- package/src/runtime/memory/lib/memory-cycle2-shared.test.mjs +78 -0
- package/src/runtime/memory/lib/memory-cycle2.mjs +37 -3
- package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
- package/src/runtime/memory/lib/memory-embedding-migration.test.mjs +50 -0
- package/src/runtime/memory/lib/memory-recall-store.mjs +9 -24
- package/src/runtime/memory/lib/memory.mjs +50 -12
- package/src/runtime/memory/lib/pg/process.mjs +1 -10
- package/src/runtime/memory/lib/pg/supervisor.mjs +1 -9
- package/src/runtime/memory/lib/query-handlers.mjs +2 -4
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +2 -2
- package/src/runtime/shared/background-tasks.mjs +15 -0
- package/src/runtime/shared/background-tasks.test.mjs +78 -0
- package/src/runtime/shared/child-guardian.mjs +2 -10
- package/src/runtime/shared/pid-liveness.mjs +27 -0
- package/src/runtime/shared/pristine-execution-contract.json +0 -1
- package/src/runtime/shared/runtime-root.mjs +1 -11
- package/src/runtime/shared/service-discovery.mjs +1 -17
- package/src/runtime/shared/singleton-owner.mjs +1 -11
- package/src/runtime/shared/staged-update.mjs +2 -12
- package/src/runtime/shared/tool-status.mjs +5 -1
- package/src/runtime/shared/tool-surface.mjs +15 -7
- package/src/runtime/shared/turn-snapshot-store.mjs +168 -0
- package/src/runtime/shared/turn-snapshot.mjs +137 -13
- package/src/runtime/shared/turn-worktree-snapshot.mjs +110 -18
- package/src/runtime/shared/webhook-session-run.mjs +16 -2
- package/src/runtime/web-search/lib/http-fetch.mjs +34 -4
- package/src/runtime/web-search/lib/state.mjs +15 -15
- package/src/runtime/web-search/lib/web-tools.mjs +46 -5
- package/src/session-runtime/context-status.mjs +4 -12
- package/src/session-runtime/runtime-core.mjs +20 -1
- package/src/session-runtime/session-lifecycle.mjs +9 -0
- package/src/standalone/agent-dispatch-broker.mjs +33 -2
- package/src/standalone/agent-tool/job-task-reconcile.mjs +78 -0
- package/src/standalone/agent-tool/notify.mjs +3 -1
- package/src/standalone/agent-tool/spawn-flow.mjs +52 -70
- package/src/standalone/agent-tool/tag-registry.mjs +116 -33
- package/src/standalone/agent-tool/tool-def.mjs +2 -2
- package/src/standalone/agent-tool/worker-index.mjs +8 -11
- package/src/standalone/agent-tool/worker-rows.mjs +4 -1
- package/src/standalone/agent-tool.mjs +20 -55
- package/src/standalone/agent-watchdog-registry.mjs +28 -4
- package/src/standalone/channel-client.mjs +47 -15
- package/src/standalone/channel-transport.mjs +44 -25
- package/src/standalone/daemon.mjs +31 -2
- package/src/standalone/hook-bus/config.mjs +25 -9
- package/src/standalone/hook-bus/handlers.mjs +16 -52
- package/src/standalone/hook-bus.mjs +29 -23
- package/src/standalone/memory-runtime-proxy.mjs +1 -16
- package/src/standalone/session-client.mjs +86 -23
- package/src/standalone/session-protocol.mjs +0 -2
- package/src/standalone/session-runtime-host-health.test.mjs +5 -4
- package/src/standalone/session-runtime-host.mjs +101 -6
- package/src/standalone/session-runtime-record.mjs +1 -5
- package/src/standalone/session-runtime-worker.mjs +52 -57
- package/src/standalone/session-service.mjs +4 -1
- package/src/standalone/session-transport.mjs +56 -25
- package/src/tui/App.jsx +40 -49
- package/src/tui/app/app-view.jsx +14 -21
- package/src/tui/app/core-memory-picker.mjs +29 -16
- package/src/tui/app/create-app-pickers.mjs +15 -36
- package/src/tui/app/extension-pickers.mjs +60 -49
- package/src/tui/app/maintenance-pickers.mjs +100 -57
- package/src/tui/app/message-selector.mjs +6 -5
- package/src/tui/app/model-picker.mjs +30 -13
- package/src/tui/app/onboarding-steps.mjs +21 -12
- package/src/tui/app/panel-epoch.mjs +56 -0
- package/src/tui/app/panel-epoch.test.mjs +1009 -0
- package/src/tui/app/panel-surface.mjs +98 -0
- package/src/tui/app/project-picker.mjs +30 -29
- package/src/tui/app/prompt-submit.mjs +192 -97
- package/src/tui/app/prompt-submit.test.mjs +206 -0
- package/src/tui/app/provider-setup-picker.mjs +74 -43
- package/src/tui/app/resume-picker.mjs +11 -8
- package/src/tui/app/route-pickers.mjs +45 -28
- package/src/tui/app/settings-picker.mjs +69 -18
- package/src/tui/app/shell-layout.mjs +1 -3
- package/src/tui/app/slash-commands.mjs +0 -2
- package/src/tui/app/slash-dispatch.mjs +4 -2
- package/src/tui/app/text-entry-policy.mjs +25 -0
- package/src/tui/app/theme-effort-pickers.mjs +11 -16
- package/src/tui/app/usage-context-panels.mjs +21 -25
- package/src/tui/app/use-global-key-input.mjs +3 -2
- package/src/tui/app/use-prompt-draft-flow.mjs +6 -18
- package/src/tui/app/use-prompt-handlers.mjs +3 -2
- package/src/tui/app/use-welcome-prompt-hint.mjs +17 -3
- package/src/tui/components/Picker.jsx +1 -1
- package/src/tui/components/TextEntryPanel.jsx +7 -2
- package/src/tui/dist/index.mjs +652 -561
- package/src/tui/lib/voice-setup.mjs +1 -2
- package/src/tui/session/agent-job-feed.mjs +73 -11
- package/src/tui/session/session-api-ext.mjs +5 -6
- package/src/tui/session/session-api.mjs +1 -0
- package/src/tui/session/tool-approval.mjs +15 -5
- package/src/tui/session/tui-steering-persist.mjs +33 -19
- package/src/tui/session/tui-steering-persist.test.mjs +104 -0
- package/src/tui/session-local.mjs +0 -22
- package/scripts/.tmp-coverage-report.mjs +0 -96
- package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.mjs +0 -57
- package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.test.mjs +0 -57
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
// mixdog-patch Rust engine via the persistent stdio server. There is NO JS
|
|
7
7
|
// apply fallback: unsupported / unsafe input returns a clean Error string.
|
|
8
8
|
|
|
9
|
-
import { existsSync } from 'node:fs';
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
10
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
10
12
|
import { spawn } from 'node:child_process';
|
|
11
13
|
import { createInterface } from 'node:readline';
|
|
12
14
|
import { fileURLToPath } from 'node:url';
|
|
@@ -75,6 +77,261 @@ function nativePatchPersistent() {
|
|
|
75
77
|
return /^(1|true|yes|server|persistent)$/i.test(nativePatchMode());
|
|
76
78
|
}
|
|
77
79
|
|
|
80
|
+
// Engine-contract gate. The Rust engine embeds this exact marker string; an
|
|
81
|
+
// artifact WITHOUT it predates the byte-fidelity contract this build requires
|
|
82
|
+
// (line-span rewrites, per-line terminators, BOM as a file prefix), and using
|
|
83
|
+
// it silently corrupts bytes — a stale installed binary in the field hits
|
|
84
|
+
// exactly that. The check is a literal byte search over the artifact, keyed by
|
|
85
|
+
// the digest of those bytes: deterministic, no spawn, no behaviour heuristics.
|
|
86
|
+
// The engine contract is proven by the RUNNING SESSION, never by a separate
|
|
87
|
+
// probe of a path: the process that will execute the work ANSWERS `CONTRACT`
|
|
88
|
+
// over the real protocol — bytes that merely appear (a startup printer) are
|
|
89
|
+
// not an answer, and the artifact judged is always the one about to run. A wrapper that prints the marker cannot serve the
|
|
90
|
+
// protocol, a swapped artifact cannot change a process that is already
|
|
91
|
+
// running, and one session means one verification for any number of callers.
|
|
92
|
+
// The marker search below is only a negative pre-filter (skip spawning an
|
|
93
|
+
// artifact that cannot possibly answer); it is never the proof.
|
|
94
|
+
export const NATIVE_PATCH_ENGINE_CONTRACT = 'mixdog-patch-engine-contract:3';
|
|
95
|
+
export const NATIVE_PATCH_CONTRACT_FAILED = 'ENATIVEPATCHCONTRACT';
|
|
96
|
+
const ENGINE_CONTRACT_TIMEOUT_MS = 5_000;
|
|
97
|
+
// Hard read bound for the handshake: a flood is cut off as it arrives instead
|
|
98
|
+
// of being buffered until the timeout.
|
|
99
|
+
const ENGINE_CONTRACT_MAX_BYTES = 4_096;
|
|
100
|
+
|
|
101
|
+
// The handshake exchange itself, over any object that speaks the session
|
|
102
|
+
// surface (the live server; a stub in tests). It is a TWO-STEP proof, because
|
|
103
|
+
// arrival order proves nothing: a binary's startup output can be scheduled late
|
|
104
|
+
// and land inside the post-request window, and a gate that trusts the window
|
|
105
|
+
// accepts a printer that never read a byte.
|
|
106
|
+
//
|
|
107
|
+
// 1. CHALLENGE — a request whose answer can only be produced by READING it: a
|
|
108
|
+
// dry-run EDIT of a probe path carrying a fresh 128-bit nonce. The engine
|
|
109
|
+
// echoes that path back in its `stat` error, so the reply is derived from
|
|
110
|
+
// this request. Bytes printed unasked cannot contain a nonce invented
|
|
111
|
+
// microseconds earlier, whenever they happen to arrive.
|
|
112
|
+
// 2. CONTRACT — answered by EXACTLY one `OK\t<marker>` frame.
|
|
113
|
+
//
|
|
114
|
+
// The challenge writes nothing anywhere: dry_run=1, and the probe path sits
|
|
115
|
+
// inside a directory that does not exist, so even a hostile peer that "obeys"
|
|
116
|
+
// it cannot create a file. Queued output, trailing bytes, an oversized flood or
|
|
117
|
+
// a late frame all invalidate the session — leftover output must never become
|
|
118
|
+
// the next command's response.
|
|
119
|
+
export async function verifyContractOverSession(session, {
|
|
120
|
+
timeoutMs = ENGINE_CONTRACT_TIMEOUT_MS,
|
|
121
|
+
maxBytes = ENGINE_CONTRACT_MAX_BYTES,
|
|
122
|
+
} = {}) {
|
|
123
|
+
_contractVerificationCount += 1;
|
|
124
|
+
try { session.assertAlive?.('contract'); } catch { return false; }
|
|
125
|
+
const expected = `OK\t${NATIVE_PATCH_ENGINE_CONTRACT}`;
|
|
126
|
+
let timer = null;
|
|
127
|
+
let onData = null;
|
|
128
|
+
let seen = 0;
|
|
129
|
+
let overflow = false;
|
|
130
|
+
let signalOverflow = null;
|
|
131
|
+
const overflowed = new Promise((resolve) => { signalOverflow = resolve; });
|
|
132
|
+
session.ref?.();
|
|
133
|
+
try {
|
|
134
|
+
onData = (chunk) => {
|
|
135
|
+
seen += chunk.length;
|
|
136
|
+
if (seen > maxBytes && !overflow) {
|
|
137
|
+
overflow = true;
|
|
138
|
+
signalOverflow(null);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
session.child.stdout.on('data', onData);
|
|
142
|
+
const timeout = new Promise((resolve) => {
|
|
143
|
+
timer = setTimeout(() => resolve(null), timeoutMs);
|
|
144
|
+
timer.unref?.();
|
|
145
|
+
});
|
|
146
|
+
// One request/response exchange. Output that is already queued was produced
|
|
147
|
+
// without being asked for, so it can never be an answer — refuse rather
|
|
148
|
+
// than consume it, and refuse again if anything trails the reply.
|
|
149
|
+
const ask = async (payload) => {
|
|
150
|
+
if (Array.isArray(session.lines) && session.lines.length > 0) return null;
|
|
151
|
+
const linePromise = session.nextLine();
|
|
152
|
+
linePromise.catch(() => {});
|
|
153
|
+
session.child.stdin.write(payload);
|
|
154
|
+
const line = await Promise.race([linePromise, overflowed, timeout]);
|
|
155
|
+
if (overflow || typeof line !== 'string') return null;
|
|
156
|
+
if (Array.isArray(session.lines) && session.lines.length > 0) return null;
|
|
157
|
+
return line;
|
|
158
|
+
};
|
|
159
|
+
const nonce = randomBytes(16).toString('hex');
|
|
160
|
+
const probePath = pathJoin(tmpdir(), `mixdog-engine-challenge-${nonce}`, 'probe');
|
|
161
|
+
const probeBuf = Buffer.from(probePath, 'utf8');
|
|
162
|
+
const challenge = await ask(Buffer.concat([
|
|
163
|
+
Buffer.from(`EDIT ${probeBuf.length} 1 0 0 1\n`, 'utf8'),
|
|
164
|
+
probeBuf,
|
|
165
|
+
Buffer.from('x', 'utf8'),
|
|
166
|
+
]));
|
|
167
|
+
// Evidence, not timing: the answer carries the nonce this request invented.
|
|
168
|
+
if (typeof challenge !== 'string' || !challenge.includes(nonce)) return false;
|
|
169
|
+
const seenBeforeContract = seen;
|
|
170
|
+
const line = await ask('CONTRACT\n');
|
|
171
|
+
if (line !== expected) return false;
|
|
172
|
+
// Exactly one frame answered CONTRACT — no trailing bytes, no second line.
|
|
173
|
+
const contractBytes = seen - seenBeforeContract;
|
|
174
|
+
if (contractBytes < expected.length || contractBytes > expected.length + 2) return false;
|
|
175
|
+
return true;
|
|
176
|
+
} catch {
|
|
177
|
+
return false;
|
|
178
|
+
} finally {
|
|
179
|
+
if (timer) clearTimeout(timer);
|
|
180
|
+
if (onData) { try { session.child.stdout.off('data', onData); } catch { /* detached */ } }
|
|
181
|
+
session.unref?.();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Content-keyed NEGATIVE PRE-FILTER only: "these bytes do not carry the marker"
|
|
185
|
+
// is a property of the bytes themselves, so remembering it is always true of
|
|
186
|
+
// them and skips a pointless spawn. A session VERDICT is never keyed by
|
|
187
|
+
// content — see verifiedSession().
|
|
188
|
+
const _markerlessDigests = new Set();
|
|
189
|
+
let _contractVerificationCount = 0;
|
|
190
|
+
// What bounds a non-conforming artifact is this backoff, not a verdict cache:
|
|
191
|
+
// after three consecutive failed handshakes, stop spawning for a while.
|
|
192
|
+
const CONTRACT_FAILURE_LIMIT = 3;
|
|
193
|
+
const CONTRACT_BACKOFF_MS = 30_000;
|
|
194
|
+
let _contractFailures = 0;
|
|
195
|
+
let _contractBackoffUntil = 0;
|
|
196
|
+
let _beforeSpawnHook = null;
|
|
197
|
+
|
|
198
|
+
export function _engineContractVerificationCountForTest() {
|
|
199
|
+
return _contractVerificationCount;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Clears the CACHES — never the failure ledger. The three-failure bound is
|
|
203
|
+
// absolute: only a proven session or the passage of real time reopens it, so no
|
|
204
|
+
// exported hatch can turn a flapping artifact back into an unbounded respawn
|
|
205
|
+
// loop. Tests that need a pristine ledger import a fresh module instance.
|
|
206
|
+
export function _resetEngineContractCachesForTest() {
|
|
207
|
+
_markerlessDigests.clear();
|
|
208
|
+
_beforeSpawnHook = null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Read-only: proves the bound is a finite cost, not a permanent lockout. */
|
|
212
|
+
export function _contractBackoffRemainingMsForTest() {
|
|
213
|
+
return Math.max(0, _contractBackoffUntil - Date.now());
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Test seam: runs between reading the artifact and spawning the session. */
|
|
217
|
+
export function _setBeforeEngineSpawnHookForTest(hook) {
|
|
218
|
+
_beforeSpawnHook = typeof hook === 'function' ? hook : null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function contractBackoffActive() {
|
|
222
|
+
if (_contractBackoffUntil === 0) return false;
|
|
223
|
+
if (Date.now() < _contractBackoffUntil) return true;
|
|
224
|
+
_contractBackoffUntil = 0;
|
|
225
|
+
_contractFailures = 0;
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function noteContractFailure() {
|
|
230
|
+
_contractFailures += 1;
|
|
231
|
+
if (_contractFailures >= CONTRACT_FAILURE_LIMIT) {
|
|
232
|
+
_contractBackoffUntil = Date.now() + CONTRACT_BACKOFF_MS;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function clearContractFailures() {
|
|
237
|
+
_contractFailures = 0;
|
|
238
|
+
_contractBackoffUntil = 0;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function readArtifactOrNull(binPath) {
|
|
242
|
+
try {
|
|
243
|
+
return readFileSync(binPath);
|
|
244
|
+
} catch {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function digestOf(bytes) {
|
|
250
|
+
return bytes ? createHash('sha256').update(bytes).digest('hex') : null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function contractFailure(action) {
|
|
254
|
+
const err = new Error(
|
|
255
|
+
`native patch engine did not state contract ${NATIVE_PATCH_ENGINE_CONTRACT} (${action})`,
|
|
256
|
+
);
|
|
257
|
+
err.code = NATIVE_PATCH_CONTRACT_FAILED;
|
|
258
|
+
return err;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function currentSession(kind) {
|
|
262
|
+
return kind === 'edit' ? _nativeEditServer : _nativePatchServer;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// The ONE binary resolution, for every kind. The gate hashes what this returns
|
|
266
|
+
// and the session spawns exactly the same path, so "verified artifact" and
|
|
267
|
+
// "executed artifact" cannot be two different files. The edit override is
|
|
268
|
+
// resolved HERE and nowhere else: a second resolution inside the edit getter
|
|
269
|
+
// let a markerless override run while the genuine engine took the exam.
|
|
270
|
+
function nativeBinPathFor(kind) {
|
|
271
|
+
if (kind === 'edit' && process.env.MIXDOG_EDIT_NATIVE_BIN) {
|
|
272
|
+
return process.env.MIXDOG_EDIT_NATIVE_BIN;
|
|
273
|
+
}
|
|
274
|
+
return nativePatchBinPath();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function spawnSession(kind, binPath) {
|
|
278
|
+
return kind === 'edit' ? getNativeEditServer(binPath) : getNativePatchServer(binPath);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// A LIVE session that already proved itself IS the proof — no disk read and no
|
|
282
|
+
// metadata are consulted while it lives. Only a cold start looks at the file.
|
|
283
|
+
function liveVerifiedSession(kind) {
|
|
284
|
+
const server = currentSession(kind);
|
|
285
|
+
if (!server || server.exited || server.contractFailed) return null;
|
|
286
|
+
return server.contractVerified === true ? server : null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Shared by the apply and edit sessions: reuse a proven session, else read the
|
|
290
|
+
// artifact ONCE, key the verdict on those exact bytes, and make the spawned
|
|
291
|
+
// session prove itself. Returns a verified server or `null`.
|
|
292
|
+
async function verifiedSession(kind) {
|
|
293
|
+
const live = liveVerifiedSession(kind);
|
|
294
|
+
if (live) return live;
|
|
295
|
+
if (contractBackoffActive()) return null;
|
|
296
|
+
const binPath = nativeBinPathFor(kind);
|
|
297
|
+
const bytes = readArtifactOrNull(binPath);
|
|
298
|
+
if (!bytes) return null; // missing / unreadable artifact — fail closed
|
|
299
|
+
// Pre-filter on the BYTES ACTUALLY READ from the artifact that will be
|
|
300
|
+
// spawned: a stat-keyed memo let a genuine engine that replaced a bad one at
|
|
301
|
+
// the same path/size/mtime inherit a stale `false` and never be re-verified.
|
|
302
|
+
const digest = digestOf(bytes);
|
|
303
|
+
if (_markerlessDigests.has(digest)) return null; // known markerless — no spawn
|
|
304
|
+
if (!bytes.includes(NATIVE_PATCH_ENGINE_CONTRACT)) {
|
|
305
|
+
_markerlessDigests.add(digest);
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
let server = null;
|
|
309
|
+
let ok = false;
|
|
310
|
+
try {
|
|
311
|
+
if (_beforeSpawnHook) await _beforeSpawnHook();
|
|
312
|
+
server = spawnSession(kind, binPath);
|
|
313
|
+
ok = await server.contract();
|
|
314
|
+
} catch {
|
|
315
|
+
ok = false;
|
|
316
|
+
}
|
|
317
|
+
// A session verdict is NEVER memoized by content. Re-hashing the path after
|
|
318
|
+
// the fact cannot prove which bytes the process loaded — an A→B→A swap-back
|
|
319
|
+
// hashes A while B ran, and A then carried B's failure forever. The bounded
|
|
320
|
+
// backoff, not a cache, is what stops a bad artifact from being respawned in
|
|
321
|
+
// a loop; every fresh session simply proves itself again.
|
|
322
|
+
if (ok) clearContractFailures();
|
|
323
|
+
else noteContractFailure();
|
|
324
|
+
return ok ? server : null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export async function nativePatchSessionSatisfiesContract() {
|
|
328
|
+
return (await verifiedSession('patch')) !== null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export async function nativeEditSessionSatisfiesContract() {
|
|
332
|
+
return (await verifiedSession('edit')) !== null;
|
|
333
|
+
}
|
|
334
|
+
|
|
78
335
|
export function nativePatchBinPath(options = {}) {
|
|
79
336
|
if (process.env.MIXDOG_PATCH_NATIVE_BIN) return process.env.MIXDOG_PATCH_NATIVE_BIN;
|
|
80
337
|
// Local cargo build first, then a fetched/cached prebuilt; absence is
|
|
@@ -136,13 +393,47 @@ function decodeNativeFailures(hexPayload) {
|
|
|
136
393
|
// retry is safe.
|
|
137
394
|
export const NATIVE_PATCH_TRANSPORT_DEAD = 'ENATIVEPATCHDEAD';
|
|
138
395
|
|
|
396
|
+
// The child's pipes ARE the engine's protocol: a caller holding `stdin` can
|
|
397
|
+
// speak APPLY/EDIT around every gate — after a bare PING, a direct
|
|
398
|
+
// `child.stdin.write('EDIT …')` wrote a file on an unproven session. So the
|
|
399
|
+
// exported view of the child is lifecycle-only (kill, pid, events); there is
|
|
400
|
+
// nothing on it to write into.
|
|
401
|
+
const CHILD_TRANSPORT_KEYS = new Set(['stdin', 'stdout', 'stderr', 'stdio', 'channel']);
|
|
402
|
+
|
|
403
|
+
function lifecycleOnlyChild(child) {
|
|
404
|
+
return new Proxy(child, {
|
|
405
|
+
get(target, prop) {
|
|
406
|
+
if (CHILD_TRANSPORT_KEYS.has(prop)) return undefined;
|
|
407
|
+
const value = Reflect.get(target, prop, target);
|
|
408
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
409
|
+
},
|
|
410
|
+
set() { return false; },
|
|
411
|
+
defineProperty() { return false; },
|
|
412
|
+
deleteProperty() { return false; },
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
139
416
|
class NativePatchServer {
|
|
417
|
+
// Private state. The transport is unreachable from outside, and the
|
|
418
|
+
// verification flag is not assignable: a public `_contractVerified = true`
|
|
419
|
+
// used to unlock a raw .edit() with no handshake at all.
|
|
420
|
+
#child;
|
|
421
|
+
|
|
422
|
+
#rl;
|
|
423
|
+
|
|
424
|
+
#lifecycleChild;
|
|
425
|
+
|
|
426
|
+
#contractVerified = false;
|
|
427
|
+
|
|
428
|
+
#contractPromise = null;
|
|
429
|
+
|
|
140
430
|
constructor(binPath) {
|
|
141
431
|
this.binPath = binPath;
|
|
142
432
|
// windowsHide: mixdog-patch.exe is a console binary; without this each spawn
|
|
143
433
|
// flashes an empty console window on Windows. Especially visible now that the
|
|
144
434
|
// idle watchdog exits the server and it respawns on the next request.
|
|
145
|
-
this
|
|
435
|
+
this.#child = spawn(binPath, ['--server'], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
|
|
436
|
+
this.#lifecycleChild = lifecycleOnlyChild(this.#child);
|
|
146
437
|
// No child guardian here. The server is self-terminating on BOTH orphan
|
|
147
438
|
// paths: stdin EOF ends its request loop the moment this host's pipe handle
|
|
148
439
|
// closes, and MIXDOG_PATCH_SERVER_IDLE_MS bounds the surviving-handle case
|
|
@@ -155,28 +446,57 @@ class NativePatchServer {
|
|
|
155
446
|
this.waiters = [];
|
|
156
447
|
this.exited = false;
|
|
157
448
|
this.transportError = null;
|
|
158
|
-
this
|
|
159
|
-
this
|
|
160
|
-
this
|
|
161
|
-
this
|
|
449
|
+
this.#child.stderr.setEncoding('utf8');
|
|
450
|
+
this.#child.stderr.on('data', (chunk) => { this.stderr += chunk; });
|
|
451
|
+
this.#rl = createInterface({ input: this.#child.stdout });
|
|
452
|
+
this.#rl.on('line', (line) => {
|
|
162
453
|
const waiter = this.waiters.shift();
|
|
163
|
-
if (waiter) waiter.resolve(line);
|
|
164
|
-
|
|
454
|
+
if (waiter) { waiter.resolve(line); return; }
|
|
455
|
+
// Unsolicited output. Every command registers its waiter BEFORE writing,
|
|
456
|
+
// so a line with no waiter is off-protocol chatter — queueing it would
|
|
457
|
+
// hand it to the NEXT command as that command's response. Abandon the
|
|
458
|
+
// session instead of letting stray bytes leak forward.
|
|
459
|
+
this.lines.push(line);
|
|
460
|
+
this.markProtocolViolation(line);
|
|
165
461
|
});
|
|
166
462
|
// A child that died mid-request turns every pending `stdin.write()` into an
|
|
167
463
|
// UNHANDLED 'error' event (EPIPE) that takes the whole host process down —
|
|
168
464
|
// a release validate job was lost exactly that way. Absorb it here: mark
|
|
169
465
|
// the transport dead and reject the waiters so callers see an ordinary
|
|
170
466
|
// rejection (and respawn) instead of a crash.
|
|
171
|
-
this
|
|
172
|
-
this
|
|
467
|
+
this.#child.stdin.on('error', (err) => { this.failTransport(err); });
|
|
468
|
+
this.#child.on('exit', (code, signal) => {
|
|
173
469
|
this.exited = true;
|
|
174
470
|
const err = new Error(`native patch server exited code=${code} signal=${signal} stderr=${this.stderr}`);
|
|
175
471
|
for (const waiter of this.waiters.splice(0)) waiter.reject(err);
|
|
176
|
-
try { this
|
|
472
|
+
try { this.#rl.close(); } catch {}
|
|
177
473
|
});
|
|
178
474
|
}
|
|
179
475
|
|
|
476
|
+
/** Lifecycle only: kill/pid/events. The pipes stay inside this class. */
|
|
477
|
+
get child() {
|
|
478
|
+
return this.#lifecycleChild;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Read-only; true only after a completed handshake on THIS process. */
|
|
482
|
+
get contractVerified() {
|
|
483
|
+
return this.#contractVerified === true;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// The handshake is the one thing that needs the raw pipes. This view is built
|
|
487
|
+
// inside the class body, handed straight to the verifier, and never returned
|
|
488
|
+
// to a caller.
|
|
489
|
+
#handshakeView() {
|
|
490
|
+
return {
|
|
491
|
+
child: this.#child,
|
|
492
|
+
lines: this.lines,
|
|
493
|
+
assertAlive: (action) => this.assertAlive(action),
|
|
494
|
+
nextLine: () => this.nextLine(),
|
|
495
|
+
ref: () => this.ref(),
|
|
496
|
+
unref: () => this.unref(),
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
180
500
|
// Idempotent death record: the stdin error and the child exit usually both
|
|
181
501
|
// fire for the same death, and only the first one owns the reason.
|
|
182
502
|
failTransport(cause) {
|
|
@@ -187,12 +507,54 @@ class NativePatchServer {
|
|
|
187
507
|
this.transportError = err;
|
|
188
508
|
}
|
|
189
509
|
for (const waiter of this.waiters.splice(0)) waiter.reject(this.transportError);
|
|
190
|
-
try { this
|
|
510
|
+
try { this.#rl.close(); } catch {}
|
|
191
511
|
}
|
|
192
512
|
|
|
193
513
|
// Pre-flight for every request: refuse BEFORE a single byte is written, so
|
|
194
514
|
// the caller may respawn and retry without any risk of double-applying.
|
|
515
|
+
// Contract handshake on THIS session, memoized per instance: N concurrent
|
|
516
|
+
// callers await one exchange, and a respawned session proves itself again.
|
|
517
|
+
contract() {
|
|
518
|
+
if (!this.#contractPromise) {
|
|
519
|
+
this.#contractPromise = verifyContractOverSession(this.#handshakeView()).then(
|
|
520
|
+
(ok) => {
|
|
521
|
+
this.#contractVerified = ok === true;
|
|
522
|
+
if (!ok) this.markContractFailed();
|
|
523
|
+
return ok;
|
|
524
|
+
},
|
|
525
|
+
() => { this.#contractVerified = false; this.markContractFailed(); return false; },
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
return this.#contractPromise;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// A session that cannot state the contract — or that emitted anything
|
|
532
|
+
// off-protocol — is abandoned outright: it never receives work, and the
|
|
533
|
+
// module slot is cleared so nothing reuses it.
|
|
534
|
+
markContractFailed() {
|
|
535
|
+
this.contractFailed = true;
|
|
536
|
+
this.#contractVerified = false;
|
|
537
|
+
if (_nativePatchServer === this) _nativePatchServer = null;
|
|
538
|
+
if (_nativeEditServer === this) _nativeEditServer = null;
|
|
539
|
+
try { this.#child.kill('SIGKILL'); } catch { /* already gone */ }
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
markProtocolViolation(detail) {
|
|
543
|
+
this.protocolViolation = String(detail ?? '').slice(0, 200);
|
|
544
|
+
this.markContractFailed();
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Every byte-writing command goes through here first. `_contractVerified` is
|
|
548
|
+
// set ONLY by a completed handshake on this very process, so no exported
|
|
549
|
+
// surface — including the raw session getNativePatchServer() hands out — can
|
|
550
|
+
// reach apply/edit on an unproven engine. (PING writes nothing and stays
|
|
551
|
+
// open, so prewarm can still warm a process before it is judged.)
|
|
552
|
+
assertContractVerified(action) {
|
|
553
|
+
if (this.#contractVerified !== true) throw contractFailure(action);
|
|
554
|
+
}
|
|
555
|
+
|
|
195
556
|
assertAlive(action) {
|
|
557
|
+
if (this.contractFailed) throw contractFailure(action);
|
|
196
558
|
if (!this.exited && !this.transportError) return;
|
|
197
559
|
const reason = this.transportError?.message || this.stderr || 'exited';
|
|
198
560
|
const err = new Error(`native patch server is not running (${action}): ${reason}`);
|
|
@@ -205,7 +567,7 @@ class NativePatchServer {
|
|
|
205
567
|
err.name = 'AbortError';
|
|
206
568
|
if (_nativePatchServer === this) _nativePatchServer = null;
|
|
207
569
|
for (const waiter of this.waiters.splice(0)) waiter.reject(err);
|
|
208
|
-
try { this
|
|
570
|
+
try { this.#child.kill('SIGTERM'); } catch {}
|
|
209
571
|
return err;
|
|
210
572
|
}
|
|
211
573
|
|
|
@@ -216,24 +578,24 @@ class NativePatchServer {
|
|
|
216
578
|
}
|
|
217
579
|
|
|
218
580
|
ref() {
|
|
219
|
-
try { this
|
|
220
|
-
try { this
|
|
221
|
-
try { this
|
|
222
|
-
try { this
|
|
581
|
+
try { this.#child.ref(); } catch {}
|
|
582
|
+
try { this.#child.stdin.ref?.(); } catch {}
|
|
583
|
+
try { this.#child.stdout.ref?.(); } catch {}
|
|
584
|
+
try { this.#child.stderr.ref?.(); } catch {}
|
|
223
585
|
}
|
|
224
586
|
|
|
225
587
|
unref() {
|
|
226
|
-
try { this
|
|
227
|
-
try { this
|
|
228
|
-
try { this
|
|
229
|
-
try { this
|
|
588
|
+
try { this.#child.unref(); } catch {}
|
|
589
|
+
try { this.#child.stdin.unref?.(); } catch {}
|
|
590
|
+
try { this.#child.stdout.unref?.(); } catch {}
|
|
591
|
+
try { this.#child.stderr.unref?.(); } catch {}
|
|
230
592
|
}
|
|
231
593
|
|
|
232
594
|
async ping() {
|
|
233
595
|
this.ref();
|
|
234
596
|
this.assertAlive('ping');
|
|
235
597
|
const linePromise = this.nextLine();
|
|
236
|
-
this
|
|
598
|
+
this.#child.stdin.write('PING\n');
|
|
237
599
|
const line = await linePromise;
|
|
238
600
|
if (line !== 'OK\tPONG') {
|
|
239
601
|
throw new Error(`native patch server ping failed: ${line || 'no native response'}`);
|
|
@@ -242,6 +604,7 @@ class NativePatchServer {
|
|
|
242
604
|
|
|
243
605
|
async apply(basePath, patchText, { fuzz = 2, rejectPartial = true, dryRun = false, signal = null } = {}) {
|
|
244
606
|
this.ref();
|
|
607
|
+
this.assertContractVerified('apply');
|
|
245
608
|
this.assertAlive('apply');
|
|
246
609
|
if (signal?.aborted) {
|
|
247
610
|
const err = new Error(signal.reason?.message || signal.reason || 'native patch aborted');
|
|
@@ -268,9 +631,9 @@ class NativePatchServer {
|
|
|
268
631
|
const fuzzTok = Number.isFinite(fuzz) && fuzz >= 0 ? Math.floor(fuzz) : 2;
|
|
269
632
|
const rpTok = rejectPartial ? 1 : 0;
|
|
270
633
|
const dryTok = dryRun ? 1 : 0;
|
|
271
|
-
this
|
|
272
|
-
this
|
|
273
|
-
this
|
|
634
|
+
this.#child.stdin.write(`APPLY ${baseBuf.length} ${patchBuf.length} 1 ${dryTok} ${fuzzTok} ${rpTok}\n`);
|
|
635
|
+
this.#child.stdin.write(baseBuf);
|
|
636
|
+
this.#child.stdin.write(patchBuf);
|
|
274
637
|
let line;
|
|
275
638
|
try {
|
|
276
639
|
line = abortPromise ? await Promise.race([linePromise, abortPromise]) : await linePromise;
|
|
@@ -339,6 +702,7 @@ class NativePatchServer {
|
|
|
339
702
|
// matched tier.
|
|
340
703
|
async edit(fullPath, oldBuf, newBuf, { replaceAll = false, dryRun = false, signal = null } = {}) {
|
|
341
704
|
this.ref();
|
|
705
|
+
this.assertContractVerified('edit');
|
|
342
706
|
this.assertAlive('edit');
|
|
343
707
|
if (signal?.aborted) {
|
|
344
708
|
const err = new Error(signal.reason?.message || signal.reason || 'native edit aborted');
|
|
@@ -354,12 +718,12 @@ class NativePatchServer {
|
|
|
354
718
|
abortListener = () => { reject(this.abort(signal)); };
|
|
355
719
|
signal.addEventListener('abort', abortListener, { once: true });
|
|
356
720
|
}) : null;
|
|
357
|
-
this
|
|
721
|
+
this.#child.stdin.write(
|
|
358
722
|
`EDIT ${pathBuf.length} ${oldBuf.length} ${newBuf.length} ${replaceAll ? 1 : 0} ${dryRun ? 1 : 0}\n`,
|
|
359
723
|
);
|
|
360
|
-
this
|
|
361
|
-
this
|
|
362
|
-
this
|
|
724
|
+
this.#child.stdin.write(pathBuf);
|
|
725
|
+
this.#child.stdin.write(oldBuf);
|
|
726
|
+
this.#child.stdin.write(newBuf);
|
|
363
727
|
let line;
|
|
364
728
|
try {
|
|
365
729
|
line = abortPromise ? await Promise.race([linePromise, abortPromise]) : await linePromise;
|
|
@@ -389,20 +753,19 @@ class NativePatchServer {
|
|
|
389
753
|
if (this.exited) return;
|
|
390
754
|
const waitForExit = options?.waitForExit !== false;
|
|
391
755
|
if (!waitForExit) {
|
|
392
|
-
try { this
|
|
756
|
+
try { this.#child.stdin.end('QUIT\n'); } catch {}
|
|
393
757
|
this.unref();
|
|
394
758
|
return;
|
|
395
759
|
}
|
|
396
760
|
this.ref();
|
|
397
|
-
try { this
|
|
398
|
-
await new Promise((resolve) => this
|
|
399
|
-
try { this
|
|
761
|
+
try { this.#child.stdin.end('QUIT\n'); } catch {}
|
|
762
|
+
await new Promise((resolve) => this.#child.once('exit', resolve));
|
|
763
|
+
try { this.#rl.close(); } catch {}
|
|
400
764
|
}
|
|
401
765
|
}
|
|
402
766
|
|
|
403
|
-
export function getNativePatchServer() {
|
|
767
|
+
export function getNativePatchServer(binPath = nativeBinPathFor('patch')) {
|
|
404
768
|
markNativePatchRuntimeTouched();
|
|
405
|
-
const binPath = nativePatchBinPath();
|
|
406
769
|
if (!existsSync(binPath)) {
|
|
407
770
|
throw new Error(`native patch binary not found: ${binPath}`);
|
|
408
771
|
}
|
|
@@ -412,11 +775,11 @@ export function getNativePatchServer() {
|
|
|
412
775
|
return _nativePatchServer;
|
|
413
776
|
}
|
|
414
777
|
|
|
415
|
-
|
|
778
|
+
// The path comes from nativeBinPathFor('edit') — the same call the gate hashed.
|
|
779
|
+
// This getter resolves nothing itself, so there is no second path a session
|
|
780
|
+
// could be spawned from.
|
|
781
|
+
function getNativeEditServer(binPath = nativeBinPathFor('edit')) {
|
|
416
782
|
markNativePatchRuntimeTouched();
|
|
417
|
-
// Honor MIXDOG_EDIT_NATIVE_BIN (the same override the edit gating checks) so
|
|
418
|
-
// the gated binary and the spawned server binary cannot diverge.
|
|
419
|
-
const binPath = process.env.MIXDOG_EDIT_NATIVE_BIN || nativePatchBinPath();
|
|
420
783
|
if (!existsSync(binPath)) {
|
|
421
784
|
throw new Error(`native patch binary not found: ${binPath}`);
|
|
422
785
|
}
|
|
@@ -431,12 +794,19 @@ function getNativeEditServer() {
|
|
|
431
794
|
// patch requests never interleave their stdin framing on one stdout stream.
|
|
432
795
|
export async function runServerEdit({ fullPath, oldBuf, newBuf, replaceAll = false, dryRun = false, signal = null }) {
|
|
433
796
|
try {
|
|
434
|
-
|
|
797
|
+
// Defence in depth: no work is written to a session that has not proven
|
|
798
|
+
// its contract, even if a caller skipped the gate.
|
|
799
|
+
const verified = await verifiedSession('edit');
|
|
800
|
+
if (!verified) throw contractFailure('edit');
|
|
801
|
+
return await verified.edit(fullPath, oldBuf, newBuf, { replaceAll, dryRun, signal });
|
|
435
802
|
} catch (err) {
|
|
436
803
|
if (err?.code !== NATIVE_PATCH_TRANSPORT_DEAD) throw err;
|
|
437
804
|
// The dead instance was refused before any byte went out; the getter
|
|
438
|
-
// respawns because the previous one is marked exited
|
|
439
|
-
|
|
805
|
+
// respawns because the previous one is marked exited — and the respawn
|
|
806
|
+
// must prove itself again.
|
|
807
|
+
const respawned = await verifiedSession('edit');
|
|
808
|
+
if (!respawned) throw contractFailure('edit');
|
|
809
|
+
return respawned.edit(fullPath, oldBuf, newBuf, { replaceAll, dryRun, signal });
|
|
440
810
|
} finally {
|
|
441
811
|
if (!nativePatchPersistent() && _nativeEditServer) {
|
|
442
812
|
if (process.versions?.bun) {
|
|
@@ -454,10 +824,14 @@ export async function runServerEdit({ fullPath, oldBuf, newBuf, replaceAll = fal
|
|
|
454
824
|
// between requests must cost a respawn, not the request.
|
|
455
825
|
export async function runServerApply(basePath, patchText, options = {}) {
|
|
456
826
|
try {
|
|
457
|
-
|
|
827
|
+
const verified = await verifiedSession('patch');
|
|
828
|
+
if (!verified) throw contractFailure('apply');
|
|
829
|
+
return await verified.apply(basePath, patchText, options);
|
|
458
830
|
} catch (err) {
|
|
459
831
|
if (err?.code !== NATIVE_PATCH_TRANSPORT_DEAD) throw err;
|
|
460
|
-
|
|
832
|
+
const respawned = await verifiedSession('patch');
|
|
833
|
+
if (!respawned) throw contractFailure('apply');
|
|
834
|
+
return respawned.apply(basePath, patchText, options);
|
|
461
835
|
}
|
|
462
836
|
}
|
|
463
837
|
|
|
@@ -36,6 +36,8 @@ import { assertPathReachable } from '../builtin/fs-reachability.mjs';
|
|
|
36
36
|
import { findBySuffixStrip, findFileByBasename } from '../builtin/path-diagnostics.mjs';
|
|
37
37
|
import { resolveReadPathRedirect } from '../builtin/snapshot-store.mjs';
|
|
38
38
|
import { dispatchNativePatch, dispatchJsPatchEntries } from './dispatch.mjs';
|
|
39
|
+
import { patchTargetUsesUtf16 } from './matcher.mjs';
|
|
40
|
+
import { nativePatchSessionSatisfiesContract } from './native-server.mjs';
|
|
39
41
|
import {
|
|
40
42
|
planV4ARenameSections,
|
|
41
43
|
applyV4ARenameSections,
|
|
@@ -184,15 +186,37 @@ async function applyParsedWave({ parsed: wparsed, entries: wentries, headerRewri
|
|
|
184
186
|
// Create entries use the JS atomic writer even inside the base path. Its
|
|
185
187
|
// expected-absent snapshot makes Add File create-only under external races;
|
|
186
188
|
// the native patch engine intentionally supports overwrite-style additions.
|
|
189
|
+
// Route by the codec actually DETECTED, not by BOM presence: a BOM-less
|
|
190
|
+
// UTF-16 file reaching the UTF-8 native engine came back as mixed
|
|
191
|
+
// UTF-16/UTF-8 bytes. Any UTF-16 target (BOM or not) takes the JS writer,
|
|
192
|
+
// which decodes and re-encodes with that exact codec; undecidable files stay
|
|
193
|
+
// native, where the encoding gate refuses them before any write. Deletes
|
|
194
|
+
// touch no bytes and stay native.
|
|
195
|
+
//
|
|
196
|
+
// The engine-contract gate is the same protection for a STALE artifact: a
|
|
197
|
+
// binary that predates this build's byte-fidelity contract is not used at
|
|
198
|
+
// all — everything takes the JS writer instead.
|
|
199
|
+
const engineContractOk = await nativePatchSessionSatisfiesContract();
|
|
200
|
+
// Codec routing only concerns entries that REWRITE bytes; a delete does not.
|
|
201
|
+
// An unverified engine, however, must receive no work at all — deletes
|
|
202
|
+
// included — so the whole wave takes the JS writer.
|
|
203
|
+
const codecNeedsJs = (fullPath, kind) => kind !== 'create' && kind !== 'delete'
|
|
204
|
+
&& patchTargetUsesUtf16(fullPath);
|
|
205
|
+
const needsJsCodec = (fullPath, kind) => !engineContractOk || codecNeedsJs(fullPath, kind);
|
|
187
206
|
const nativeEntries = wentries.filter(
|
|
188
|
-
(entry) => entry.kind !== 'create'
|
|
207
|
+
(entry) => entry.kind !== 'create'
|
|
208
|
+
&& !isResolvedPathOutsideBase(entry.fullPath, basePath)
|
|
209
|
+
&& !needsJsCodec(entry.fullPath, entry.kind),
|
|
189
210
|
);
|
|
190
211
|
const jsEntries = wentries.filter(
|
|
191
|
-
(entry) => entry.kind === 'create'
|
|
212
|
+
(entry) => entry.kind === 'create'
|
|
213
|
+
|| isResolvedPathOutsideBase(entry.fullPath, basePath)
|
|
214
|
+
|| needsJsCodec(entry.fullPath, entry.kind),
|
|
192
215
|
);
|
|
193
216
|
const parsedInside = (wparsed || []).filter(
|
|
194
217
|
(entry) => classifyEntry(entry) !== 'create'
|
|
195
|
-
&& !isResolvedPathOutsideBase(parsedEntryResolvedPath(entry, basePath), basePath)
|
|
218
|
+
&& !isResolvedPathOutsideBase(parsedEntryResolvedPath(entry, basePath), basePath)
|
|
219
|
+
&& !needsJsCodec(parsedEntryResolvedPath(entry, basePath), classifyEntry(entry)),
|
|
196
220
|
);
|
|
197
221
|
const executor = jsEntries.length > 0
|
|
198
222
|
? (nativeEntries.length > 0 ? 'native+js-patch' : 'js-patch')
|