@qvac/harness 0.2.8 → 0.2.9
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/dist/harness.bundle +65 -12
- package/dist/prebuilds-manifest.json +8 -0
- package/dist-lib/index.d.ts +2 -1
- package/dist-lib/index.js +2 -1
- package/dist-lib/lib/capabilities/completion.d.ts +35 -2
- package/dist-lib/lib/capabilities/completion.js +84 -18
- package/dist-lib/lib/connect.js +1 -0
- package/dist-lib/lib/contract-types.d.ts +1 -0
- package/dist-lib/lib/serve.js +1 -0
- package/dist-lib/lib/wire.js +28 -0
- package/dist-lib/schema/harness.js +5 -2
- package/dist-lib/spec/hrpc/messages.js +22 -7
- package/dist-lib/spec/hyperschema/index.js +22 -7
- package/dist-lib/spec/hyperschema/schema.json +14 -1
- package/dist-lib/spec/hyperschema/types.d.ts +3 -2
- package/package.json +6 -6
- package/.claude/skills/asana/SKILL.md +0 -86
- package/.claude/skills/gh-stack/SKILL.md +0 -181
- package/.claude/skills/gh-stack/references/commands.md +0 -177
- package/.claude/skills/gh-stack/references/stack-design.md +0 -97
- package/.claude/skills/gh-stack/references/troubleshooting.md +0 -159
- package/.claude/skills/js-house-style/SKILL.md +0 -671
- package/.claude/skills/kvcache/SKILL.md +0 -171
- package/.claude/skills/pr-create/SKILL.md +0 -180
- package/.claude/skills/pr-review/SKILL.md +0 -414
- package/CLAUDE.md +0 -39
- package/docs/decisions/2026-05-29-kvcache-tool-turns.md +0 -75
- package/docs/decisions/2026-06-02-kvcache-tool-turns-reenabled.md +0 -127
- package/docs/decisions/2026-06-23-android-cpu-llm-defaults.md +0 -126
- package/docs/decisions/2026-07-29-assistant-owned-storage-layout.md +0 -88
- package/docs/decisions/2026-08-04-sidecar-orphan-reaping.md +0 -79
- package/docs/decisions/2026-08-11-pyodide-worker-thread.md +0 -68
- package/docs/decisions/2026-08-17-harness-hook-pipeline.md +0 -84
- package/docs/decisions/2026-08-17-harness-outbound-egress-guard.md +0 -176
- package/docs/decisions/2026-08-20-chat-prompt-prefix-is-stable.md +0 -95
- package/docs/decisions/2026-08-20-kvcache-static-placement.md +0 -109
- package/docs/decisions/2026-08-21-vendored-python-wheels.md +0 -85
- package/docs/decisions/2026-08-26-exec-is-not-gated-by-skills.md +0 -64
- package/docs/decisions/2026-08-27-writable-skills-overlay.md +0 -56
- package/docs/decisions/2026-09-01-skill-operation-manifests.md +0 -53
- package/docs/decisions/2026-09-01-usage-breakdown-is-measured.md +0 -78
- package/docs/decisions/2026-09-03-usage-breakdown-reported-by-the-harness.md +0 -136
- package/docs/decisions/2026-09-10-skill-wording-is-measured-offline.md +0 -63
- package/docs/decisions/2026-09-16-a-lookup-reports-the-place-it-resolved.md +0 -70
- package/docs/decisions/INDEX.md +0 -40
- package/docs/decisions/TEMPLATE.md +0 -32
- package/docs/kvcache-reference.md +0 -655
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
id: ADR-2026-06-23-android-cpu-llm-defaults
|
|
3
|
-
status: active
|
|
4
|
-
introduced_by: PR-691
|
|
5
|
-
supersedes: []
|
|
6
|
-
---
|
|
7
|
-
|
|
8
|
-
# Android LLM defaults to CPU except Tensor-era Pixels
|
|
9
|
-
|
|
10
|
-
## Decision
|
|
11
|
-
|
|
12
|
-
Android LLM inference defaults to CPU with a `q4_0` KV cache, except on
|
|
13
|
-
Tensor-era Google Pixel devices before Pixel 10 where LLM inference defaults
|
|
14
|
-
back to GPU. Pixel 10 and newer remain on CPU.
|
|
15
|
-
|
|
16
|
-
## Context
|
|
17
|
-
|
|
18
|
-
Android GPU inference runs on Vulkan, which is unreliable across the fleet:
|
|
19
|
-
Adreno on Snapdragon devices, including many Samsung variants, aborts under the
|
|
20
|
-
LLM workload, and older devices ship buggy drivers. CPU is the only
|
|
21
|
-
consistently stable target.
|
|
22
|
-
|
|
23
|
-
The earlier Samsung and Pixel 10 CPU special-casing plus stripping the KV-cache
|
|
24
|
-
quantization fields at the host boundary existed only to dodge the Vulkan abort.
|
|
25
|
-
With all inference on CPU that abort path is gone, so a quantized `q4_0` cache
|
|
26
|
-
is safe and lowers memory use.
|
|
27
|
-
|
|
28
|
-
Pixel 9 profiling later showed that long chat/tool-shaped prompts can collapse
|
|
29
|
-
decode throughput on the SDK CPU path while the GPU path keeps decode TPS
|
|
30
|
-
healthy. Pixel 10 had prior GPU stability issues, so the exception is limited
|
|
31
|
-
to Tensor-era Pixel devices before Pixel 10, including Pixel Tablet and the
|
|
32
|
-
first-generation Pixel Fold. Original Pixel phones are not included in this
|
|
33
|
-
exception.
|
|
34
|
-
|
|
35
|
-
Core v2 device profiling (2026-07) added two KV-cache facts for the GPU
|
|
36
|
-
exception: a quantized V cache aborts the Adreno backend outright, and a
|
|
37
|
-
quantized K cache forces per-token dequantization onto a slow flash-attention
|
|
38
|
-
path on Mali (Pixel 8 decode dropped from ~10 tps to ~3 tps). The GPU branch
|
|
39
|
-
therefore sends no KV-cache override at all; the SDK default f16/f16 is both
|
|
40
|
-
the stable and the fast profile, matching shipped 0.12. The harness
|
|
41
|
-
additionally clamps cache-type-v to f16 on Android GPU loads as a backstop.
|
|
42
|
-
|
|
43
|
-
## Consequences
|
|
44
|
-
|
|
45
|
-
- Embeddings follow the same split, and only on Android: Android devices and
|
|
46
|
-
every simulator pin `embedDevice: "cpu"`, while iOS devices keep GPU
|
|
47
|
-
embeddings. That matches the retired Core v1 policy and reflects the failure modes:
|
|
48
|
-
Adreno/Vulkan aborts outright, whereas a Metal device-lost fault self-heals
|
|
49
|
-
through the harness escalation in `lib/models/sdk-faults.ts`
|
|
50
|
-
(reload once, fall back to CPU on a repeat).
|
|
51
|
-
- Do not reintroduce host-boundary KV-cache field stripping; it only made sense
|
|
52
|
-
for the GPU/Vulkan path.
|
|
53
|
-
- This trades GPU acceleration on most Android devices for reliability and
|
|
54
|
-
lower memory.
|
|
55
|
-
- Tensor-era Pixel devices before Pixel 10 keep GPU acceleration for LLM
|
|
56
|
-
completions.
|
|
57
|
-
- Pixel 10 and newer stay on the Android CPU/q4_0 policy.
|
|
58
|
-
- Revisit if the SDK gains a capability probe that can distinguish Vulkan
|
|
59
|
-
drivers/devices that are safe under load, allowing a selective GPU re-enable.
|
|
60
|
-
|
|
61
|
-
## Amendment 2026-08-03: the abort attribution is unverified
|
|
62
|
-
|
|
63
|
-
The Adreno aborts above are attributed to Vulkan, but the app never supplied
|
|
64
|
-
`openclCacheDir` on any load. `@qvac/llm-llamacpp` documents Adreno 700+ as an
|
|
65
|
-
**OpenCL** target, and the OpenCL backend `ggml_abort()`s during init when it
|
|
66
|
-
cannot write its kernel cache, the same symptom this ADR blames on Vulkan. The
|
|
67
|
-
backend that actually ran was never recorded, so which of the two caused the
|
|
68
|
-
aborts is still open.
|
|
69
|
-
|
|
70
|
-
Three things changed to make it answerable rather than re-litigated:
|
|
71
|
-
|
|
72
|
-
- `configureSdkStorage` now exports a writable kernel-cache directory, and the
|
|
73
|
-
harness passes `openclCacheDir` on Android LLM, embedding and transcription
|
|
74
|
-
loads (`lib/models/opencl-cache.ts`).
|
|
75
|
-
- The KV clamp covers **K as well as V** on Android GPU loads
|
|
76
|
-
(`adrenoSafeKvCache`). Adreno OpenCL throws on any K type other than
|
|
77
|
-
`f16`/`f32`/`bf16`, and this ADR's own Mali finding points the same way; only V
|
|
78
|
-
was clamped before, so an agent row pinning `kvCacheK` reached the load
|
|
79
|
-
unclamped.
|
|
80
|
-
- `EXPO_PUBLIC_QVAC_ANDROID_LLM_DEVICE` and
|
|
81
|
-
`EXPO_PUBLIC_QVAC_ANDROID_EMBED_DEVICE` (`"cpu" | "gpu"`) override the Android
|
|
82
|
-
branches per build, so a device can be retested without a code change. The
|
|
83
|
-
simulator branch ignores them.
|
|
84
|
-
|
|
85
|
-
The defaults in this ADR are unchanged and remain in force. Before flipping any
|
|
86
|
-
of them, record on real hardware the `backendDevice` value from
|
|
87
|
-
`hlog('round stats', …)` (the SDK reports `cpu | vulkan | metal | opencl`) plus
|
|
88
|
-
cold and warm `hlog('llm loaded', { loadMs })`. `opencl` means the kernel cache
|
|
89
|
-
is load-bearing and this ADR's attribution needs correcting; `vulkan` means the
|
|
90
|
-
cache dir is inert on this fleet and the CPU pin stands on its original
|
|
91
|
-
reasoning.
|
|
92
|
-
|
|
93
|
-
## Amendment 2026-09-04: transcription follows the split too
|
|
94
|
-
|
|
95
|
-
The Consequences above extended the split to embeddings and stopped there, so
|
|
96
|
-
the transcription pool loaded parakeet with a hardcoded `useGPU: true` on every
|
|
97
|
-
device — including the Pixel 10 whose GPU this ADR pins to CPU for the LLM. On
|
|
98
|
-
that hardware the model loads, runs for a time proportional to the clip, and
|
|
99
|
-
returns an empty transcript, so the app reports "contained no detectable
|
|
100
|
-
speech" about a file full of speech. Nothing throws, so the
|
|
101
|
-
`voice gpu load refused, retrying on cpu` fallback added for QVAC-24132 never
|
|
102
|
-
fires: it covers a load that fails, not one that succeeds and produces nothing.
|
|
103
|
-
|
|
104
|
-
Transcription now takes the same GPU verdict as the LLM, carried on the host
|
|
105
|
-
boundary as `transcriptionDevice` (`"cpu" | "gpu"`) alongside `llmDevice` and
|
|
106
|
-
`embedDevice`, with `EXPO_PUBLIC_QVAC_ANDROID_TRANSCRIPTION_DEVICE` and
|
|
107
|
-
`EXPO_PUBLIC_QVAC_IOS_TRANSCRIPTION_DEVICE` to retest a device per build. An
|
|
108
|
-
absent value still means GPU, so desktop is unaffected. Parakeet allocates its
|
|
109
|
-
weights in one `ggml_backend_alloc_ctx_tensors` call with no partial-offload
|
|
110
|
-
knob, so a GPU this ADR does not trust with the LLM cannot be trusted with it
|
|
111
|
-
either.
|
|
112
|
-
|
|
113
|
-
Still open: the voice pools have no equivalent of the embedding fault
|
|
114
|
-
escalation in `lib/models/sdk-faults.ts`, so an empty
|
|
115
|
-
transcript from a GPU-loaded model is reported as fact rather than retried on
|
|
116
|
-
CPU. Until that exists, a mis-set device is silent rather than self-healing.
|
|
117
|
-
|
|
118
|
-
## When Agents Should Read This
|
|
119
|
-
|
|
120
|
-
Read when changing Android device selection or KV-cache configuration for any
|
|
121
|
-
inference category — LLM, embeddings or transcription — in
|
|
122
|
-
`qvac-app → mobile/lib/android-llm-device.ts`, `qvac-app → mobile/lib/core-v2-harness-config.ts` or
|
|
123
|
-
the pool constructors in `index.ts`, or re-evaluating the
|
|
124
|
-
Android Vulkan/CPU and KV-cache Known Risks. Read the 2026-08-03 amendment
|
|
125
|
-
before treating the Vulkan attribution as settled, and the 2026-09-04 amendment
|
|
126
|
-
before adding a category that loads a model on the GPU.
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
id: ADR-2026-07-29-assistant-owned-storage-layout
|
|
3
|
-
status: active
|
|
4
|
-
introduced_by: PR-000
|
|
5
|
-
supersedes: []
|
|
6
|
-
---
|
|
7
|
-
|
|
8
|
-
# Assistant-owned storage separates shared models from private KV
|
|
9
|
-
|
|
10
|
-
## Decision
|
|
11
|
-
|
|
12
|
-
The assistant owns the Desktop and Mobile storage layout. Hosts pass a release
|
|
13
|
-
environment plus optional root/model overrides; they do not construct assistant
|
|
14
|
-
or SDK subpaths.
|
|
15
|
-
|
|
16
|
-
Models stay at the SDK's canonical shared location on every platform:
|
|
17
|
-
|
|
18
|
-
```text
|
|
19
|
-
~/.qvac/models/ SDK-wide model and asset cache
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
(Snap uses `$SNAP_USER_COMMON` as the home base, i.e.
|
|
23
|
-
`$SNAP_USER_COMMON/.qvac/models`.) This deliberately matches the `@qvac/sdk`
|
|
24
|
-
default (`getDefaultCacheDir → $HOME/.qvac/models`) so a standalone SDK, a CLI, or
|
|
25
|
-
another QVAC product on the same machine reuses one downloaded model cache rather
|
|
26
|
-
than each re-downloading multi-GB assets. Because of that sharing requirement
|
|
27
|
-
models are **not** moved under `XDG_DATA_HOME` — the host may still relocate them
|
|
28
|
-
explicitly via `QVAC_MODEL_CACHE_PATH` / the `models` override.
|
|
29
|
-
|
|
30
|
-
The assistant **image cache** resolves via `bare-storage.persistent()` —
|
|
31
|
-
`qvac/image-cache` under the OS data tier on every platform (`$XDG_DATA_HOME` on
|
|
32
|
-
Linux, `~/Library/Application Support` on macOS, `%APPDATA%` on Windows; Flatpak
|
|
33
|
-
`~/.var/app/<id>/data`). It stays in the _data_ tier (not `ephemeral()`/cache)
|
|
34
|
-
because the assistant sweeps it itself (`imageCacheMaxBytes`/`imageCacheMaxAgeMs`
|
|
35
|
-
→ `sweepImageCache`), so it does not rely on OS cache eviction.
|
|
36
|
-
|
|
37
|
-
Private state is rooted at the platform persistent-data directory returned by
|
|
38
|
-
`bare-storage` (or `SNAP_USER_COMMON` for Snap):
|
|
39
|
-
|
|
40
|
-
```text
|
|
41
|
-
qvac/{prod|staging|dev}/
|
|
42
|
-
├── assistant/ private application state
|
|
43
|
-
└── sdk-home/ private SDK runtime home, registry, and KV cache
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
The assistant writes an SDK JSON config with `cacheDirectory` pointing at the
|
|
47
|
-
canonical models directory and uses the SDK's supported home override to assign
|
|
48
|
-
each app/channel a private `sdk-home`. The SDK continues to derive its own
|
|
49
|
-
KV-cache subpath; Workbench does not duplicate that SDK implementation detail.
|
|
50
|
-
|
|
51
|
-
On Mobile, Delete All Data asks the live assistant harness to call the SDK's
|
|
52
|
-
`deleteCache({ all: true })` operation before stopping Core, then removes only
|
|
53
|
-
the `assistant` directory. A failed or unsupported KV-cache operation aborts the
|
|
54
|
-
reset so Workbench does not report permanent deletion while conversation-derived
|
|
55
|
-
cache remains. The host never deletes `sdk-home` directly, preserving SDK
|
|
56
|
-
registry metadata alongside the shared model assets.
|
|
57
|
-
|
|
58
|
-
The channel that home override rides on is per-runtime, not per-process. Off
|
|
59
|
-
bare-kit it is `env[SNAP_USER_COMMON]`, which is also the key `resolveStorage`
|
|
60
|
-
reads as the platform root; on bare-kit it is `Bare.argv[0]`, which `@qvac/sdk`
|
|
61
|
-
prefers over `HOME_DIR` and resolves lazily. Bare-kit worklets share one OS
|
|
62
|
-
`environ`, so a host that runs two of them — mobile runs the assistant and the
|
|
63
|
-
harness side by side — would have each `configureSdkStorage` redefine the root
|
|
64
|
-
the other's `resolveStorage` is about to read. Argv is copied into each
|
|
65
|
-
runtime, so the write cannot leave the worklet that made it.
|
|
66
|
-
|
|
67
|
-
Desktop `--storage`, `--tmp`, and `QVAC_STORAGE_PATH` replace only the private
|
|
68
|
-
root. `QVAC_MODEL_CACHE_PATH` is a separate override for hermetic tests and
|
|
69
|
-
other explicit host requirements. Mobile applies the same relative layout, but
|
|
70
|
-
OS app-ID sandboxes naturally prevent sharing across Development, Staging, and
|
|
71
|
-
Production installations.
|
|
72
|
-
|
|
73
|
-
## Consequences
|
|
74
|
-
|
|
75
|
-
- Desktop apps and release channels reuse downloaded model assets without
|
|
76
|
-
sharing conversations, meshes, runtime locks, or KV caches.
|
|
77
|
-
- Mobile app reset clears every SDK KV cache through the harness and deletes
|
|
78
|
-
private assistant state while leaving shared models and SDK registry metadata
|
|
79
|
-
intact.
|
|
80
|
-
- Existing `~/.qvac/models` downloads continue to be used on every platform —
|
|
81
|
-
models are unchanged and stay aligned with the SDK default. The assistant
|
|
82
|
-
**image cache** moved from `~/.qvac-assistant/image-cache` to
|
|
83
|
-
`bare-storage.persistent()` (`qvac/image-cache`, data tier) — a greenfield
|
|
84
|
-
change with no migration of prior cache data. Legacy global KV and app-data
|
|
85
|
-
directories are not migrated or deleted.
|
|
86
|
-
- SDK cache layout changes remain owned by the SDK: Core supplies the supported
|
|
87
|
-
model-cache and home overrides and invokes SDK cache deletion without
|
|
88
|
-
reproducing the KV subpath.
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
id: ADR-2026-08-04-sidecar-orphan-reaping
|
|
3
|
-
status: active
|
|
4
|
-
introduced_by: PR-1382
|
|
5
|
-
supersedes: []
|
|
6
|
-
---
|
|
7
|
-
|
|
8
|
-
# The host reaps the sidecar's process group; the harness cannot reap itself
|
|
9
|
-
|
|
10
|
-
## Decision
|
|
11
|
-
|
|
12
|
-
When the assistant sidecar exits, the desktop host kills the sidecar's whole process group.
|
|
13
|
-
The sidecar is spawned detached (`qvac-app → desktop/electron/assistant.ts`), so it leads its own group
|
|
14
|
-
and every harness it spawns stays in that group; killing the group on the exit edge reaps the
|
|
15
|
-
strays and can reach nothing else.
|
|
16
|
-
|
|
17
|
-
This is POSIX-only, and so is the bug. On Windows the kernel already ties the tree together:
|
|
18
|
-
libuv assigns every non-detached child to a Job Object created with `KILL_ON_JOB_CLOSE`
|
|
19
|
-
("so windows will kill it when the parent process dies" — libuv's own comment), and
|
|
20
|
-
`bare-subprocess` is a thin `uv_spawn` binding that sets `UV_PROCESS_DETACHED` only when asked.
|
|
21
|
-
The sidecar and the harness are both spawned non-detached there, so killing the sidecar kills
|
|
22
|
-
the harness at kernel level, mid-inference included. Verified empirically on a real Windows
|
|
23
|
-
machine: sidecar killed mid-generation, both bare processes died, nothing lingered.
|
|
24
|
-
|
|
25
|
-
The harness does not try to detect its own orphaning.
|
|
26
|
-
|
|
27
|
-
## Context
|
|
28
|
-
|
|
29
|
-
The harness holds gigabytes of loaded model weights. Stow's stop signal is pipe EOF, and a
|
|
30
|
-
sidecar killed outright (SIGKILL, OOM) never runs the ordered teardown that bounds its child —
|
|
31
|
-
so on POSIX the harness reparents to init and holds that memory until the machine reboots.
|
|
32
|
-
|
|
33
|
-
A manual `kill -9` is only the cleanest way to reproduce it. The same state follows from an
|
|
34
|
-
`abort()` or segfault in one of the sidecar's native addons (SQLite, the vector extension,
|
|
35
|
-
ffmpeg image normalization), from the kernel OOM killer picking the sidecar instead of the
|
|
36
|
-
harness, or from a developer killing a hung app.
|
|
37
|
-
|
|
38
|
-
## Alternatives Considered
|
|
39
|
-
|
|
40
|
-
An in-process watchdog in the harness (poll `os.ppid()`, exit on reparent) was implemented and
|
|
41
|
-
reverted. The shipped bundle did contain the call, unref'd intervals do fire under bare, and
|
|
42
|
-
`os.ppid()` is a live `getppid()` that reflects reparenting — yet a harness orphaned for minutes
|
|
43
|
-
never exited. A harness parked in a native SDK call is not running JS at all, so no _JS-level_
|
|
44
|
-
mechanism can survive it, whatever signal it watches.
|
|
45
|
-
|
|
46
|
-
A native parent-death watcher would survive it, since it does not need the JS thread:
|
|
47
|
-
`WaitForSingleObject` on the parent handle, `PR_SET_PDEATHSIG` on Linux, kqueue `NOTE_EXIT` on
|
|
48
|
-
macOS. All require native work in bare, so the host-side reap was preferred.
|
|
49
|
-
|
|
50
|
-
## Consequences
|
|
51
|
-
|
|
52
|
-
- The host is the only place that can bound the harness on abrupt sidecar death. The reap must
|
|
53
|
-
stay wired for every sidecar spawned, whether or not a renderer ever adopted it.
|
|
54
|
-
- The `detached: process.platform !== "win32"` split is load-bearing in both directions. POSIX
|
|
55
|
-
needs the detach or the group kill has nothing to aim at; Windows needs the non-detach or
|
|
56
|
-
libuv skips the job assignment and the kernel tie is lost. Setting `detached: true` on Windows
|
|
57
|
-
would create there the very bug this reap fixes on POSIX.
|
|
58
|
-
- Keep the harness in the sidecar's group — never spawn it detached, on any platform. On POSIX
|
|
59
|
-
a detached harness leaves the sidecar's group and silently escapes this reap (no error, no
|
|
60
|
-
failing test unless the real-process test in
|
|
61
|
-
`qvac-app → desktop/test/electron/assistant-sidecar-orphan.test.ts` is kept); on Windows it leaves the
|
|
62
|
-
sidecar's job and escapes the kernel cascade the same way.
|
|
63
|
-
- Prefer the group over a pid on POSIX. The harness's own children (exec and shell tools) are in
|
|
64
|
-
the group too; killing the harness pid alone leaves them orphaned.
|
|
65
|
-
- The win32 branch of `forceKillAssistantSidecarTree` (`taskkill /T` on the sidecar) matters for
|
|
66
|
-
the clean-stop escalation, where the sidecar is alive but will not unwind; on the exit edge it
|
|
67
|
-
is a post-mortem no-op — the job object has already done the work.
|
|
68
|
-
- Not solved on POSIX: the host dying abruptly while the sidecar is itself blocked in a native
|
|
69
|
-
call. Then the sidecar cannot act on its own pipe EOF and nothing alive can reap either
|
|
70
|
-
process. A sweep for stray harnesses at startup is the remedy if that shows up in practice.
|
|
71
|
-
(Windows is covered even here: the host's death closes its job handle and the cascade takes
|
|
72
|
-
the whole tree.)
|
|
73
|
-
- `killStowChildren()` (`bare-supervisor/stow`) still covers a clean host exit; this covers the
|
|
74
|
-
abrupt sidecar death it cannot see.
|
|
75
|
-
|
|
76
|
-
## When Agents Should Read This
|
|
77
|
-
|
|
78
|
-
Read this when changing the paths, workflows, runtime boundaries, or platform behavior listed
|
|
79
|
-
for this ADR in `docs/decisions/INDEX.md`.
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
id: ADR-2026-08-11-pyodide-worker-thread
|
|
3
|
-
status: active
|
|
4
|
-
introduced_by: PR-1524
|
|
5
|
-
supersedes: []
|
|
6
|
-
---
|
|
7
|
-
|
|
8
|
-
# Pyodide runs in a worker thread with a shared interrupt buffer
|
|
9
|
-
|
|
10
|
-
## Decision
|
|
11
|
-
|
|
12
|
-
The harness executes python (Pyodide, CPython on WebAssembly) in a dedicated
|
|
13
|
-
worker thread — `Bare.Thread` + `bare-channel` on Bare, `worker_threads` on
|
|
14
|
-
Node — never on the harness thread. Cancellation goes through a 1-byte
|
|
15
|
-
`SharedArrayBuffer` registered with `pyodide.setInterruptBuffer()`: the host
|
|
16
|
-
writes SIGINT, CPython raises `KeyboardInterrupt` at the next bytecode
|
|
17
|
-
boundary, and the interpreter survives for the next run. A run that ignores
|
|
18
|
-
the interrupt past a grace window gets the worker killed and respawned.
|
|
19
|
-
|
|
20
|
-
## Context
|
|
21
|
-
|
|
22
|
-
`runPython` is synchronous inside Pyodide, so on the harness thread a busy
|
|
23
|
-
script froze every tool and chat turn, and timeout/abort could only settle
|
|
24
|
-
before or after a run. An interrupt can only be delivered from another thread
|
|
25
|
-
(the blocked thread cannot run JS), so shared memory plus a second thread is
|
|
26
|
-
the minimal correct shape. Two Bare constraints shaped the implementation:
|
|
27
|
-
|
|
28
|
-
- `Bare.Thread.terminate()` is honored at a safepoint only. It cannot stop a
|
|
29
|
-
busy-spinning thread, and `join()` on one blocks forever — so on Bare a
|
|
30
|
-
wedged worker is abandoned (never joined), while Node's `terminate()`
|
|
31
|
-
genuinely kills. Joining a healthy worker is only safe after its channel end
|
|
32
|
-
has closed (`drainAndReap`), because the channel teardown itself needs the
|
|
33
|
-
host's event loop.
|
|
34
|
-
- A fresh thread's module loader reads plain files only. Spawning a thread
|
|
35
|
-
whose entry lives inside a stowed `.bundle` aborts the whole process, but a
|
|
36
|
-
`.bundle` path _as the entry itself_ boots fine. Every stowed harness —
|
|
37
|
-
desktop (dev and packaged) and mobile — must therefore stage the worker as
|
|
38
|
-
its own bundle and name it via `QVAC_PYODIDE_WORKER_ENTRY`; until a host
|
|
39
|
-
sets it, a bundled harness falls back to the pre-worker in-process path
|
|
40
|
-
rather than crash, so the worker only runs under the harness test suites.
|
|
41
|
-
|
|
42
|
-
## Alternatives Considered
|
|
43
|
-
|
|
44
|
-
- **In-process with a watchdog thread writing the interrupt buffer**: keeps
|
|
45
|
-
the harness thread blocked during runs and cannot serve a user abort (the
|
|
46
|
-
event loop that would receive it is the blocked one).
|
|
47
|
-
- **Subprocess CPython**: no wasm sandbox, not portable to mobile (iOS forbids
|
|
48
|
-
spawning processes).
|
|
49
|
-
|
|
50
|
-
## Consequences
|
|
51
|
-
|
|
52
|
-
- The worker thread is an availability boundary, not a security boundary:
|
|
53
|
-
python can still reach the worker realm's globals via `from js import …`, so
|
|
54
|
-
a python grant keeps shell-level trust (see `scope.ts`).
|
|
55
|
-
- Anything that changes worker/entry module paths must keep the
|
|
56
|
-
bundled-harness fallback and the `QVAC_PYODIDE_WORKER_ENTRY` override
|
|
57
|
-
working, or every bundled host crashes on the first python run.
|
|
58
|
-
- The in-process path survives for injected test loaders only; fakes cannot
|
|
59
|
-
cross a thread boundary. Worker transport tests load a fake interpreter
|
|
60
|
-
inside the thread via `QVAC_PYODIDE_TEST_LOADER`.
|
|
61
|
-
- Revisit if Bare threads gain forced termination, or when the packaging
|
|
62
|
-
pipeline ships the staged worker bundle (the fallback then becomes dead
|
|
63
|
-
code).
|
|
64
|
-
|
|
65
|
-
## When Agents Should Read This
|
|
66
|
-
|
|
67
|
-
Read this when changing the paths, workflows, runtime boundaries, or platform
|
|
68
|
-
behavior listed for this ADR in `docs/decisions/INDEX.md`.
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
id: ADR-2026-08-17-harness-hook-pipeline
|
|
3
|
-
status: active
|
|
4
|
-
introduced_by: PR-000
|
|
5
|
-
supersedes: []
|
|
6
|
-
---
|
|
7
|
-
|
|
8
|
-
# Harness hooks fold a whole round before any call runs, and fail closed
|
|
9
|
-
|
|
10
|
-
## Decision
|
|
11
|
-
|
|
12
|
-
`assistant-harness` exposes four hook phases — `runStart`, `preToolCall`, `postToolCall`,
|
|
13
|
-
`runEnd` — registered in a single process-wide array (`harnessHooks`) and driven by
|
|
14
|
-
`HookPipeline` from inside `completion()`. Hooks receive a deep JSON clone of their payload and
|
|
15
|
-
influence the run only through what they return: new `args` for a pre-hook, a new `result` for a
|
|
16
|
-
post-hook, or a `HookRejection` (`{ error, guidance: true }`).
|
|
17
|
-
|
|
18
|
-
Two ordering rules are durable:
|
|
19
|
-
|
|
20
|
-
- **Pre-hooks fold the whole round before any call in that round executes.** The assistant turn
|
|
21
|
-
written to history replays the folded calls, so history can never disagree with what ran.
|
|
22
|
-
- **Post-hooks fire only after a real `tool.execute()`.** Refused, denied, invalid, guarded,
|
|
23
|
-
capped, and unknown calls never reach a post-hook.
|
|
24
|
-
|
|
25
|
-
A pre-hook may also _repair_ a call: the `args` it returns replace the model's own, and the
|
|
26
|
-
payload carries the tool's declared `parameters` so the repair is schema-driven rather than
|
|
27
|
-
per-tool. `ArgRepairHook` is registered first for that reason — it decodes a structured parameter
|
|
28
|
-
a small model handed over as text (fenced, newline-broken, or missing its outer closer) before
|
|
29
|
-
approval, the activity log, and `execute()` read the call, so all three see the same canonical
|
|
30
|
-
args and the tool itself keeps a strict reader.
|
|
31
|
-
|
|
32
|
-
Failure is closed, not fatal. A throwing tool hook becomes model-facing guidance and the loop
|
|
33
|
-
continues; a throwing `runStart`/`runEnd` hook is logged and ignored, because run authorization
|
|
34
|
-
lives in the policy layer, not here.
|
|
35
|
-
|
|
36
|
-
## Context
|
|
37
|
-
|
|
38
|
-
Tool calls already pass through validation, the trusted-source guard, effect classification, and
|
|
39
|
-
the approval memo. Extending any of those for cross-cutting concerns (redaction, arg rewriting,
|
|
40
|
-
telemetry, per-tool filtering) would have meant editing the gate chain for every new concern.
|
|
41
|
-
|
|
42
|
-
The round-level fold exists because the assistant turn is written to history once, before the
|
|
43
|
-
calls run. Folding per call would let a pre-hook mutate arg N after the turn already recorded the
|
|
44
|
-
model's original wrapper — history and execution would diverge, and the model would be trained by
|
|
45
|
-
its own record to re-emit the un-mutated form.
|
|
46
|
-
|
|
47
|
-
Deep cloning exists because payloads are the run's live objects: model-emitted args, history-bound
|
|
48
|
-
results, the message list. A hook that mutates one in place would leak into the run without
|
|
49
|
-
returning anything, which makes the "influence only through return values" contract unenforceable.
|
|
50
|
-
Payloads are JSON-domain, so a `JSON.parse(JSON.stringify(...))` round-trip severs every reference;
|
|
51
|
-
non-JSON values (`undefined`, functions, `BigInt`, cycles) pass through as-is rather than
|
|
52
|
-
withholding output that already ran.
|
|
53
|
-
|
|
54
|
-
## Alternatives Considered
|
|
55
|
-
|
|
56
|
-
- **Per-call fold (pre(N) after post(N-1)).** Rejected: it desynchronizes the assistant turn from
|
|
57
|
-
what executes. Accepted cost of the round fold is that pre-hooks fire for calls a later gate
|
|
58
|
-
(skill lock, search cap, abort) never runs.
|
|
59
|
-
- **Hooks that throw abort the run.** Rejected: a buggy third-party hook would become a run-level
|
|
60
|
-
denial of service. Failing closed on the _call_ keeps the run recoverable.
|
|
61
|
-
- **In-place mutation of the live payload.** Rejected: no way to distinguish a deliberate change
|
|
62
|
-
from an accidental one, and no way to reject a hook's edit.
|
|
63
|
-
- **Structured-clone / a clone library.** Rejected: payloads are already JSON-domain; a dependency
|
|
64
|
-
buys nothing a `JSON` round-trip does not.
|
|
65
|
-
|
|
66
|
-
## Consequences
|
|
67
|
-
|
|
68
|
-
- A new hook phase must state whether it fires before or after execution, and post-phase hooks
|
|
69
|
-
must never fire for a call that did not execute.
|
|
70
|
-
- Rejection wording must never match the SDK wedge patterns in `models/sdk-faults.ts` — those mark
|
|
71
|
-
a dead native context for eviction, not guidance for the model. A post-hook rejection says the
|
|
72
|
-
tool already executed, so the model does not retry a side effect.
|
|
73
|
-
- With no hook registered a run pays nothing, not even a payload clone. Keep that: `harnessHooks`
|
|
74
|
-
is empty by default.
|
|
75
|
-
- Registration is a process-wide array with no ordering, priority, or per-agent scoping. That is
|
|
76
|
-
deliberate and should be revisited only when a real consumer needs it.
|
|
77
|
-
- Hooks cannot bypass approval, the trusted-source guard, or the denial memo — those gates run on
|
|
78
|
-
the folded args, after the hook.
|
|
79
|
-
|
|
80
|
-
## When Agents Should Read This
|
|
81
|
-
|
|
82
|
-
Read this when changing the paths listed for this ADR in `docs/decisions/INDEX.md` — the tool loop
|
|
83
|
-
in `capabilities/completion.ts`, anything under `lib/hooks/`, or any new gate that must order
|
|
84
|
-
itself against the fold.
|
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
id: ADR-2026-08-17-harness-outbound-egress-guard
|
|
3
|
-
status: active
|
|
4
|
-
introduced_by: PR-QVAC-23689
|
|
5
|
-
supersedes: []
|
|
6
|
-
---
|
|
7
|
-
|
|
8
|
-
# Harness tool egress and spawn environments are allowlists, not filters
|
|
9
|
-
|
|
10
|
-
## Decision
|
|
11
|
-
|
|
12
|
-
Every outbound URL a harness tool reaches — `http_request` and `mcp_call`, on the
|
|
13
|
-
first request and on each redirect hop — passes `resolveOutboundUrl`
|
|
14
|
-
(`lib/tools/url-guard.ts`) before a socket opens. The guard
|
|
15
|
-
rejects loopback, RFC1918, CGNAT, link-local, cloud-metadata hosts and their IPv6
|
|
16
|
-
equivalents through `lib/tools/private-ip.ts`, and rejects a
|
|
17
|
-
hostname whose resolved addresses land in those ranges. `private-ip.ts`
|
|
18
|
-
default-denies any address it cannot positively classify as global unicast.
|
|
19
|
-
|
|
20
|
-
The check splits along the hook boundary from
|
|
21
|
-
ADR-2026-08-17-harness-hook-pipeline. The model-supplied URL gets a
|
|
22
|
-
synchronous literal-host check from `checkOutboundHost`
|
|
23
|
-
(`lib/tools/url-guard.ts`), run by `EgressGuardHook`
|
|
24
|
-
(`lib/hooks/built-in/egress.ts`), a `preToolCall` hook
|
|
25
|
-
shipped by default in `harnessHooks`
|
|
26
|
-
(`lib/hooks/registry.ts`) so every host gets it without
|
|
27
|
-
registering anything; a rejected call never executes and the rejection
|
|
28
|
-
reaches the model as guidance. The hook does not resolve DNS — it only
|
|
29
|
-
catches a literal blocked host (an IP or bracketed IPv6 literal in the URL
|
|
30
|
-
itself). Resolving the hostname and pinning the connection to the addresses
|
|
31
|
-
the guard approved happens in the tool, which needs those addresses anyway to
|
|
32
|
-
connect: each tool runs `resolveOutboundUrl` itself with a `resolveHost`
|
|
33
|
-
defaulting to `lib/dns-lookup.ts`, so a tool constructed
|
|
34
|
-
directly (bypassing the pipeline) is still guarded. `resolveOutboundUrl`
|
|
35
|
-
returns both the verdict and the resolved address set, which the tool then
|
|
36
|
-
pins the connection to. `http_request` re-runs the
|
|
37
|
-
check on every redirect hop, which only exists mid-execution after the hook
|
|
38
|
-
has fired; `mcp_call` runs it before any credential resolves, so a blocked
|
|
39
|
-
host never gets a token minted for it.
|
|
40
|
-
|
|
41
|
-
On Node, `lib/no-redirect-fetch-node.ts` pins the connection
|
|
42
|
-
to the addresses the guard already approved: it passes them to `http`/`https`
|
|
43
|
-
request's `lookup` callback, which connects only to those addresses and
|
|
44
|
-
rejects private results. On Bare, `lib/no-redirect-fetch-bare.ts`
|
|
45
|
-
does not pin — the fetch agent carries a validating `lookup` hook
|
|
46
|
-
(`lib/validating-lookup.ts`) that re-resolves through the
|
|
47
|
-
same validation, plus a `createConnection` wrapper that re-checks literal-IP
|
|
48
|
-
hosts, so the address it connects to is validated but not necessarily the one
|
|
49
|
-
the guard first approved. That gap is the residual rebinding window on Bare;
|
|
50
|
-
a rebind into a private range is still caught, just by a second resolve
|
|
51
|
-
instead of by reusing the first one.
|
|
52
|
-
|
|
53
|
-
A spawned child receives an environment built by adding to an allowlist, never by
|
|
54
|
-
subtracting from `process.env`. `EXEC_ENV_ALLOWLIST` in
|
|
55
|
-
`lib/helpers/spawn-path.ts` is `PATH`, `HOME`, the temp-dir
|
|
56
|
-
trio, locale, `TZ`, `USER`, `LOGNAME`, the proxy and CA-bundle variables a
|
|
57
|
-
granted binary needs to reach the network at all behind a corporate proxy, plus
|
|
58
|
-
a per-platform set for the Obsidian launchers. Nothing else — no `SSH_*`, no
|
|
59
|
-
`AWS_*`, no host tokens — crosses into a child.
|
|
60
|
-
|
|
61
|
-
The same fail-closed shape covers the two remaining tool inputs that name a host
|
|
62
|
-
resource. `exec`'s shell `workdir` resolves through `resolveKey` against the
|
|
63
|
-
granted `roots`, and with no roots granted it is refused outright rather than
|
|
64
|
-
passed to `spawn`. Skill loading is gated by `SkillCatalogGuardHook`
|
|
65
|
-
(`lib/hooks/built-in/skill-catalog.ts`), shipped by default
|
|
66
|
-
in `harnessHooks` alongside `EgressGuardHook`; a host that loads a catalog
|
|
67
|
-
passes it to the constructor so `name` resolves against the advertised
|
|
68
|
-
catalog, keeping the loadable set equal to the set the model was shown. The
|
|
69
|
-
`skill` tool itself reads through a `Localdrive` rooted at `skillsDir`, which
|
|
70
|
-
refuses traversal and absolute names regardless of the hook.
|
|
71
|
-
|
|
72
|
-
A grant names what a tool may reach; a deny root names what no grant can reach, and
|
|
73
|
-
deny wins. `resolveKey` (`lib/tools/fs/scope.ts`) resolves every
|
|
74
|
-
path against the granted `roots` and then against `deniedRoots`, which the host
|
|
75
|
-
passes and the model cannot influence. Both sides are canonicalized through
|
|
76
|
-
`realpath` before comparison — once per resolution, not once per path segment — so
|
|
77
|
-
a symlink, an uncanonical root, or a `..` walk cannot step around the deny root.
|
|
78
|
-
The walking tools (`file_glob`, `grep`) drop denied paths as they enumerate,
|
|
79
|
-
because a listing must not name what a read may not open. The desktop sidecar and
|
|
80
|
-
the mobile worklet both derive the deny root from the storage layout the host
|
|
81
|
-
already passes (ADR-2026-07-29-assistant-owned-storage-layout), so the app's own
|
|
82
|
-
`credentials.vault`, `vault.key` and plaintext config database sit outside every
|
|
83
|
-
grant without a new host argument.
|
|
84
|
-
|
|
85
|
-
## Context
|
|
86
|
-
|
|
87
|
-
`qvac-app → docs/tool-review-checklist.md` carried SSRF as an unchecked placeholder — "port
|
|
88
|
-
v1 `private-ip.ts`" — while `http_request` and `mcp_call` shipped in desktop and
|
|
89
|
-
mobile builds. Both are model-driven: the URL, the redirect target, the `workdir`
|
|
90
|
-
and the skill name all come from generated arguments, so any host reachable from
|
|
91
|
-
the device was reachable by a prompt. Cloud metadata endpoints and a developer's
|
|
92
|
-
own loopback services are the valuable targets, and both are one plain hostname
|
|
93
|
-
away.
|
|
94
|
-
|
|
95
|
-
An allowlist rather than a denylist for the spawn environment follows from the
|
|
96
|
-
same reasoning applied to a different axis. A denylist has to enumerate every
|
|
97
|
-
secret-bearing variable a host might set, and loses the moment a new tool
|
|
98
|
-
convention appears. The allowlist only has to enumerate what the pinned binaries
|
|
99
|
-
actually need to run, which is short and changes rarely.
|
|
100
|
-
|
|
101
|
-
## Alternatives Considered
|
|
102
|
-
|
|
103
|
-
**Check only the literal URL, and skip DNS resolution.** Cheap, and it stops
|
|
104
|
-
`http://127.0.0.1/`. It does not stop a hostname with an `A` record pointing at
|
|
105
|
-
`127.0.0.1`, which is the standard way this is exploited. Resolving is the
|
|
106
|
-
point of the guard as a whole — the hook's literal check is a fast early
|
|
107
|
-
reject, but the tool must still resolve the hostname and check the resolved
|
|
108
|
-
addresses before it connects, since it is the tool that ends up holding those
|
|
109
|
-
addresses.
|
|
110
|
-
|
|
111
|
-
**Make `resolveHost` a required option.** Rejected: every existing construction
|
|
112
|
-
site would have to pass it, and the one that forgets ships a tool with no guard.
|
|
113
|
-
Defaulting it means the unsafe configuration cannot be expressed by omission.
|
|
114
|
-
|
|
115
|
-
**Put the initial-URL check only in the hook pipeline, not in each tool.**
|
|
116
|
-
Tempting once the pipeline landed: the guard is exactly the cross-cutting
|
|
117
|
-
per-tool gate the pipeline exists for, a rejection there reaches the model as
|
|
118
|
-
guidance without the call executing, and one hook covers every URL-bearing tool
|
|
119
|
-
instead of each tool re-implementing the same preamble. `EgressGuardHook` ships
|
|
120
|
-
by default in `harnessHooks` (`lib/hooks/registry.ts`), so
|
|
121
|
-
every host gets the model-facing guidance without registering anything — but
|
|
122
|
-
the tools still keep their own check, because a tool can be constructed
|
|
123
|
-
directly and called outside the hook pipeline, and that construction site must
|
|
124
|
-
stay guarded on its own. A host that needs to drop the hook builds its own
|
|
125
|
-
hook list instead of using `harnessHooks`; the tool-level check still holds
|
|
126
|
-
either way.
|
|
127
|
-
|
|
128
|
-
**Rely on the redirect-following code to re-enter the guard implicitly.** Rejected
|
|
129
|
-
— redirect handling lives in shared helpers that do not own the resolver, so the
|
|
130
|
-
hop check is written explicitly at each hop instead. A guard that only runs on the
|
|
131
|
-
first URL is bypassed by any open redirect.
|
|
132
|
-
|
|
133
|
-
**Filter `process.env` by pattern (drop anything matching `*TOKEN*`, `*KEY*`).**
|
|
134
|
-
Rejected: it reads as security while depending on naming discipline in software we
|
|
135
|
-
do not control.
|
|
136
|
-
|
|
137
|
-
**Rely on the fs grants alone, and trust hosts not to grant a directory that
|
|
138
|
-
contains the vault.** Rejected: the natural grant on desktop and mobile _is_ the
|
|
139
|
-
user's home or the app sandbox, and both contain the storage root. A boundary that
|
|
140
|
-
holds only while every host picks a narrow grant is not a boundary.
|
|
141
|
-
|
|
142
|
-
## Consequences
|
|
143
|
-
|
|
144
|
-
- A new URL-bearing tool must run `resolveOutboundUrl` itself, on its own target
|
|
145
|
-
and on any URL it derives mid-execution (redirects, discovery), and should be
|
|
146
|
-
added to `EgressGuardHook`'s tool match so the refusal reaches the model
|
|
147
|
-
as guidance. A tool that opens its own socket is outside the boundary and the
|
|
148
|
-
review checklist should catch it.
|
|
149
|
-
- A new host embedding the harness gets `EgressGuardHook` and
|
|
150
|
-
`SkillCatalogGuardHook` by default through `harnessHooks`; registration is
|
|
151
|
-
optional, not required. A host that needs to drop one builds its own hook
|
|
152
|
-
list instead of using `harnessHooks`. Dropping the egress hook costs
|
|
153
|
-
the guidance, not the gate; dropping the catalog hook leaves skill loading
|
|
154
|
-
bounded only by the `skill` tool's `Localdrive` root, so the loadable set is
|
|
155
|
-
the whole skills directory rather than the advertised catalog.
|
|
156
|
-
- New entries in `EXEC_ENV_ALLOWLIST` need a stated reason — the file's comments
|
|
157
|
-
say what each existing entry is load-bearing for, and `PATH` in particular is
|
|
158
|
-
what makes the exec binary allowlist resolvable.
|
|
159
|
-
- The Node fetch path pins each request to the address set returned by the
|
|
160
|
-
egress check. A direct use of the low-level Node fetch adapter without a
|
|
161
|
-
supplied address set performs its own validating lookup before connecting.
|
|
162
|
-
- A new fs-shaped tool takes `deniedRoots` and resolves through `resolveKey`; one
|
|
163
|
-
that walks the tree must also drop denied entries while enumerating, not only
|
|
164
|
-
refuse the eventual read. A new host embedding the harness must pass the deny
|
|
165
|
-
root — omitting it hands the vault to whatever else it grants.
|
|
166
|
-
- `workdir` on a device with no granted directories is an error, not a silent
|
|
167
|
-
fallback to the process cwd. That is intended, and the error text says to use
|
|
168
|
-
absolute paths instead.
|
|
169
|
-
- Revisit if the harness ever needs to reach a private address deliberately — a
|
|
170
|
-
self-hosted MCP server on a LAN, say. That needs an explicit per-device grant,
|
|
171
|
-
not a hole in `private-ip.ts`.
|
|
172
|
-
|
|
173
|
-
## When Agents Should Read This
|
|
174
|
-
|
|
175
|
-
Read this when changing the paths, workflows, runtime boundaries, or platform
|
|
176
|
-
behavior listed for this ADR in `docs/decisions/INDEX.md`.
|