@openparachute/vault 0.6.5-rc.10 → 0.6.5-rc.12
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/core/src/core.test.ts +7 -7
- package/core/src/mcp.ts +1 -1
- package/core/src/schema.ts +5 -5
- package/core/src/tag-hierarchy.ts +1 -1
- package/core/src/tag-schemas.ts +2 -2
- package/core/src/types.ts +1 -1
- package/package.json +1 -1
- package/src/admin-spa.ts +2 -1
- package/src/auth.ts +1 -1
- package/src/cli.ts +47 -19
- package/src/export-watch.ts +1 -1
- package/src/routes.ts +3 -3
- package/src/routing.test.ts +2 -2
- package/src/routing.ts +1 -1
- package/src/tag-scope.ts +3 -3
- package/src/token-store.ts +2 -2
- package/src/transcription/build.test.ts +86 -5
- package/src/transcription/build.ts +87 -7
- package/src/transcription/download.test.ts +101 -0
- package/src/transcription/download.ts +71 -0
- package/src/transcription/select.test.ts +41 -1
- package/src/transcription/select.ts +63 -0
- package/src/vault.test.ts +1 -1
package/core/src/core.test.ts
CHANGED
|
@@ -4046,7 +4046,7 @@ describe("query-notes link expansion", async () => {
|
|
|
4046
4046
|
});
|
|
4047
4047
|
|
|
4048
4048
|
// ---------------------------------------------------------------------------
|
|
4049
|
-
// Tag hierarchy via tags.parent_names (post-v14,
|
|
4049
|
+
// Tag hierarchy via tags.parent_names (post-v14, docs/contracts/tag-data-model.md)
|
|
4050
4050
|
// ---------------------------------------------------------------------------
|
|
4051
4051
|
|
|
4052
4052
|
describe("tag hierarchy (tags.parent_names)", async () => {
|
|
@@ -5107,7 +5107,7 @@ describe("schema inheritance via parent_names (vault#270)", async () => {
|
|
|
5107
5107
|
});
|
|
5108
5108
|
});
|
|
5109
5109
|
|
|
5110
|
-
describe("expandTagsWithDescendants (tag-scoped tokens —
|
|
5110
|
+
describe("expandTagsWithDescendants (tag-scoped tokens — docs/contracts/tag-scoped-tokens.md)", async () => {
|
|
5111
5111
|
it("returns the union of root + every descendant per tags.parent_names", async () => {
|
|
5112
5112
|
await store.upsertTagRecord("health/food", { parent_names: ["health"] });
|
|
5113
5113
|
await store.upsertTagRecord("health/food/breakfast", { parent_names: ["health/food"] });
|
|
@@ -5143,10 +5143,10 @@ describe("expandTagsWithDescendants (tag-scoped tokens — patterns/tag-scoped-t
|
|
|
5143
5143
|
});
|
|
5144
5144
|
|
|
5145
5145
|
// ---------------------------------------------------------------------------
|
|
5146
|
-
// Tag record API —
|
|
5146
|
+
// Tag record API — docs/contracts/tag-data-model.md
|
|
5147
5147
|
// ---------------------------------------------------------------------------
|
|
5148
5148
|
|
|
5149
|
-
describe("tag record API (
|
|
5149
|
+
describe("tag record API (docs/contracts/tag-data-model.md)", async () => {
|
|
5150
5150
|
it("upsertTagRecord persists description + fields + relationships + parent_names", async () => {
|
|
5151
5151
|
await store.upsertTagRecord("project", {
|
|
5152
5152
|
description: "long-running deliverable",
|
|
@@ -5430,7 +5430,7 @@ describe("tag record API (patterns/tag-data-model.md)", async () => {
|
|
|
5430
5430
|
});
|
|
5431
5431
|
|
|
5432
5432
|
// ---------------------------------------------------------------------------
|
|
5433
|
-
// Schema migration v13 → v14 —
|
|
5433
|
+
// Schema migration v13 → v14 — docs/contracts/tag-data-model.md
|
|
5434
5434
|
// ---------------------------------------------------------------------------
|
|
5435
5435
|
|
|
5436
5436
|
describe("schema migration v13 → v14", async () => {
|
|
@@ -5809,7 +5809,7 @@ describe("schema migration v15 → v16", async () => {
|
|
|
5809
5809
|
});
|
|
5810
5810
|
|
|
5811
5811
|
// ---------------------------------------------------------------------------
|
|
5812
|
-
// Tag-scope auth post-v14 —
|
|
5812
|
+
// Tag-scope auth post-v14 — docs/contracts/tag-scoped-tokens.md
|
|
5813
5813
|
// ---------------------------------------------------------------------------
|
|
5814
5814
|
|
|
5815
5815
|
describe("tag-scope auth (post-v14 hierarchy)", async () => {
|
|
@@ -5824,7 +5824,7 @@ describe("tag-scope auth (post-v14 hierarchy)", async () => {
|
|
|
5824
5824
|
});
|
|
5825
5825
|
|
|
5826
5826
|
it("orphan sub-tag fallback: token for `health` still sees `#health/food` even with no declared hierarchy", async () => {
|
|
5827
|
-
// Per
|
|
5827
|
+
// Per docs/contracts/tag-scoped-tokens.md §Storage details, the auth check
|
|
5828
5828
|
// also splits on '/' and matches the root verbatim against the raw
|
|
5829
5829
|
// allowlist. This survives the v14 source-of-truth swap because the
|
|
5830
5830
|
// fallback lives in src/tag-scope.ts, not in the resolver.
|
package/core/src/mcp.ts
CHANGED
|
@@ -1408,7 +1408,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1408
1408
|
{
|
|
1409
1409
|
name: "update-tag",
|
|
1410
1410
|
requiredVerb: "write",
|
|
1411
|
-
description: "Create or update a tag's identity row: description, indexed-field schemas, relationship-vocabulary map, and hierarchy parents. If the tag doesn't exist, it's created. Fields are merged (new keys added, existing keys replaced); relationships and parent_names are replaced wholesale when provided. Pass null for fields/relationships/parent_names to clear that column. See parachute-
|
|
1411
|
+
description: "Create or update a tag's identity row: description, indexed-field schemas, relationship-vocabulary map, and hierarchy parents. If the tag doesn't exist, it's created. Fields are merged (new keys added, existing keys replaced); relationships and parent_names are replaced wholesale when provided. Pass null for fields/relationships/parent_names to clear that column. See parachute-vault/docs/contracts/tag-data-model.md.",
|
|
1412
1412
|
inputSchema: {
|
|
1413
1413
|
type: "object",
|
|
1414
1414
|
properties: {
|
package/core/src/schema.ts
CHANGED
|
@@ -41,7 +41,7 @@ CREATE TABLE IF NOT EXISTS notes (
|
|
|
41
41
|
|
|
42
42
|
-- Tags: first-class identity carrying schema, hierarchy, and typed-link
|
|
43
43
|
-- declarations. One row per tag; no notes-as-config sidecars for these
|
|
44
|
-
-- concerns. See
|
|
44
|
+
-- concerns. See docs/contracts/tag-data-model.md.
|
|
45
45
|
--
|
|
46
46
|
-- description — human-readable blurb (markdown).
|
|
47
47
|
-- fields — JSON: indexed metadata field declarations per
|
|
@@ -136,7 +136,7 @@ CREATE TABLE IF NOT EXISTS indexed_fields (
|
|
|
136
136
|
-- scoped_tags is a JSON-encoded array of root tag names that constrain the
|
|
137
137
|
-- token's effective access (intersection with the scopes column). NULL
|
|
138
138
|
-- means unscoped — full vault access per scopes. Introduced in v13 per
|
|
139
|
-
--
|
|
139
|
+
-- docs/contracts/tag-scoped-tokens.md. Hierarchy expansion is applied at auth
|
|
140
140
|
-- time via getTagDescendants; the column stores root names only.
|
|
141
141
|
--
|
|
142
142
|
-- vault_name (v16) binds the token to a single vault. NULL means the
|
|
@@ -443,7 +443,7 @@ export function initSchema(db: Database): void {
|
|
|
443
443
|
// Migrate v13 → v14: tag-data-model reshape. Augment `tags` row with
|
|
444
444
|
// description/fields/relationships/parent_names/timestamps; copy data
|
|
445
445
|
// from the v6-era tag_schemas sidecar and from `_tags/<name>` config
|
|
446
|
-
// notes; drop tag_schemas after copy. See
|
|
446
|
+
// notes; drop tag_schemas after copy. See docs/contracts/tag-data-model.md.
|
|
447
447
|
migrateToV14(db);
|
|
448
448
|
|
|
449
449
|
// Migrate v14 → v15: retire the `_schemas/<name>` and `_schema_defaults`
|
|
@@ -650,7 +650,7 @@ function migrateToV12(db: Database): void {
|
|
|
650
650
|
* (current full-vault behavior); a JSON array of root tag names narrows the
|
|
651
651
|
* token's access to notes carrying one of those tags or a sub-tag thereof
|
|
652
652
|
* (hierarchy expansion via getTagDescendants at auth time). See
|
|
653
|
-
*
|
|
653
|
+
* docs/contracts/tag-scoped-tokens.md.
|
|
654
654
|
*/
|
|
655
655
|
function migrateToV13(db: Database): void {
|
|
656
656
|
if (hasTable(db, "tokens") && !hasColumn(db, "tokens", "scoped_tags")) {
|
|
@@ -659,7 +659,7 @@ function migrateToV13(db: Database): void {
|
|
|
659
659
|
}
|
|
660
660
|
|
|
661
661
|
/**
|
|
662
|
-
* Migrate v13 → v14: tag-data-model reshape (
|
|
662
|
+
* Migrate v13 → v14: tag-data-model reshape (docs/contracts/tag-data-model.md).
|
|
663
663
|
*
|
|
664
664
|
* Augments the `tags` table with five new columns and one timestamp pair,
|
|
665
665
|
* then copies pre-existing data from two notes-as-config sidecars:
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* History: pre-v14 vaults stored hierarchy in notes-as-config at
|
|
10
10
|
* `_tags/<name>`. The v14 migration (see core/src/schema.ts:migrateToV14)
|
|
11
11
|
* lifts those parent declarations onto the tags row and the resolver here
|
|
12
|
-
* was swapped accordingly. See
|
|
12
|
+
* was swapped accordingly. See docs/contracts/tag-data-model.md.
|
|
13
13
|
*
|
|
14
14
|
* Resolution model:
|
|
15
15
|
* - Lazy: built on first access, cached on the store.
|
package/core/src/tag-schemas.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Each tag row carries: human-readable description, indexed metadata field
|
|
5
5
|
* declarations (`fields`), typed-link relationship declarations
|
|
6
6
|
* (`relationships`), and the hierarchy parent list (`parent_names`).
|
|
7
|
-
* See
|
|
7
|
+
* See docs/contracts/tag-data-model.md.
|
|
8
8
|
*
|
|
9
9
|
* This module retains the historical `tag-schemas` filename and exports
|
|
10
10
|
* (`TagSchema`, `listTagSchemas`, `getTagSchema`, `upsertTagSchema`,
|
|
@@ -51,7 +51,7 @@ export interface TagFieldSchema {
|
|
|
51
51
|
* `{ target_tag, cardinality }` declaration — but `relationships` is now an
|
|
52
52
|
* opaque vocabulary map (see `TagRelationshipMap` / `validateRelationships`),
|
|
53
53
|
* so this is one valid value shape among many, not a required one.
|
|
54
|
-
* See
|
|
54
|
+
* See docs/contracts/tag-data-model.md §Relationships.
|
|
55
55
|
*/
|
|
56
56
|
export type TagRelCardinality = "one" | "optional" | "many" | "many-required";
|
|
57
57
|
|
package/core/src/types.ts
CHANGED
|
@@ -406,7 +406,7 @@ export interface Store {
|
|
|
406
406
|
|
|
407
407
|
// Tag records — full v14 identity row (description + fields + typed
|
|
408
408
|
// relationships + parent_names + timestamps). See
|
|
409
|
-
//
|
|
409
|
+
// docs/contracts/tag-data-model.md.
|
|
410
410
|
listTagRecords(): Promise<TagRecord[]>;
|
|
411
411
|
getTagRecord(tag: string): Promise<TagRecord | null>;
|
|
412
412
|
/**
|
package/package.json
CHANGED
package/src/admin-spa.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Per-vault mount (vault#252): the SPA lives under `/vault/<name>/admin/*`
|
|
10
10
|
* rather than the origin-rooted `/admin/*` it shipped with. The hub only
|
|
11
|
-
* proxies `/vault/<name>/*` paths (per
|
|
11
|
+
* proxies `/vault/<name>/*` paths (per the module protocol —
|
|
12
|
+
* https://github.com/ParachuteComputer/parachute-hub/blob/main/docs/contracts/module-protocol.md),
|
|
12
13
|
* so an origin-rooted SPA is unreachable through the hub. Asset URLs are
|
|
13
14
|
* relative (Vite `base: "./"`), so the same bundle works at any mount
|
|
14
15
|
* point — no rebuild per vault.
|
package/src/auth.ts
CHANGED
|
@@ -123,7 +123,7 @@ export interface AuthResult {
|
|
|
123
123
|
* Tag-allowlist (root tag names) for tag-scoped tokens. NULL = unscoped
|
|
124
124
|
* (current full-vault behavior). Hub-issued JWTs and legacy YAML keys
|
|
125
125
|
* always have null — tag scoping is a per-token vault-DB attribute, not
|
|
126
|
-
* an OAuth-claim concern. See
|
|
126
|
+
* an OAuth-claim concern. See docs/contracts/tag-scoped-tokens.md.
|
|
127
127
|
*/
|
|
128
128
|
scoped_tags: string[] | null;
|
|
129
129
|
/**
|
package/src/cli.ts
CHANGED
|
@@ -152,6 +152,7 @@ import {
|
|
|
152
152
|
resolveTranscribeCppPaths,
|
|
153
153
|
resolveTranscriptionProviderName,
|
|
154
154
|
transcribeCppInstalled,
|
|
155
|
+
probeTranscribeCliRunnable,
|
|
155
156
|
readManifest,
|
|
156
157
|
transcriptionHomeDir,
|
|
157
158
|
pythonVenvDir,
|
|
@@ -173,6 +174,7 @@ import {
|
|
|
173
174
|
TRANSCRIBE_CPP_SOURCE_REF,
|
|
174
175
|
type CliBuildResult,
|
|
175
176
|
} from "./transcription/build.ts";
|
|
177
|
+
import { downloadTo } from "./transcription/download.ts";
|
|
176
178
|
import { selectDefaultProvider, NOMINAL_SLACK_GB, type TierPlan } from "./transcription/tiers.ts";
|
|
177
179
|
import {
|
|
178
180
|
PYTHON_PROVIDERS,
|
|
@@ -3572,7 +3574,7 @@ async function cmdTranscription(args: string[]) {
|
|
|
3572
3574
|
return;
|
|
3573
3575
|
}
|
|
3574
3576
|
if (sub === "status" || sub === undefined) {
|
|
3575
|
-
cmdTranscriptionStatus();
|
|
3577
|
+
await cmdTranscriptionStatus();
|
|
3576
3578
|
return;
|
|
3577
3579
|
}
|
|
3578
3580
|
console.error(`Unknown transcription subcommand: ${sub}`);
|
|
@@ -3867,14 +3869,25 @@ async function runTranscribeCppInstall(opts: {
|
|
|
3867
3869
|
|
|
3868
3870
|
// 5) CLI resolution + provider switch — honest, no silent success. `binPath`
|
|
3869
3871
|
// resolves to TRANSCRIBE_CPP_BIN or the built binary; we activate
|
|
3870
|
-
// transcribe-cpp only when one actually
|
|
3871
|
-
//
|
|
3872
|
-
//
|
|
3872
|
+
// transcribe-cpp only when one actually EXECUTES (`--help` exits 0 —
|
|
3873
|
+
// vault#534: an existsSync-only check activated Linux installs whose CLI
|
|
3874
|
+
// exited 1 on every run). Otherwise we keep the current provider and
|
|
3875
|
+
// report the gap rather than taking transcription offline behind a
|
|
3876
|
+
// provider that can't run.
|
|
3873
3877
|
if (existsSync(paths.binPath)) {
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
+
const probe = await probeTranscribeCliRunnable(paths.binPath);
|
|
3879
|
+
if (probe.ok) {
|
|
3880
|
+
setEnvVar("TRANSCRIPTION_PROVIDER", "transcribe-cpp");
|
|
3881
|
+
console.log(`\n✓ Installed and activated → TRANSCRIPTION_PROVIDER=transcribe-cpp set in the vault .env.`);
|
|
3882
|
+
console.log(` CLI: ${paths.binPath} (verified runnable)`);
|
|
3883
|
+
console.log(` Restart vault to activate: parachute restart vault`);
|
|
3884
|
+
} else {
|
|
3885
|
+
console.log(
|
|
3886
|
+
`\n⚠ transcribe-cli exists at ${paths.binPath} but is NOT runnable (${probe.reason}).`,
|
|
3887
|
+
);
|
|
3888
|
+
console.log(" NOT activating transcribe-cpp — your current provider is unchanged.");
|
|
3889
|
+
console.log(noRunnableCliGuidance(paths, buildResult));
|
|
3890
|
+
}
|
|
3878
3891
|
} else {
|
|
3879
3892
|
console.log(`\n✓ Libraries + model installed to ${paths.dir} — transcription is NOT yet activated.`);
|
|
3880
3893
|
console.log(noRunnableCliGuidance(paths, buildResult));
|
|
@@ -3886,13 +3899,6 @@ async function runTranscribeCppInstall(opts: {
|
|
|
3886
3899
|
}
|
|
3887
3900
|
}
|
|
3888
3901
|
|
|
3889
|
-
/** Download a URL to a destination file (streamed via fetch → Bun.write). */
|
|
3890
|
-
async function downloadTo(url: string, dest: string): Promise<void> {
|
|
3891
|
-
const resp = await fetch(url, { redirect: "follow" });
|
|
3892
|
-
if (!resp.ok) throw new Error(`download failed (${resp.status}) for ${url}`);
|
|
3893
|
-
await Bun.write(dest, resp);
|
|
3894
|
-
}
|
|
3895
|
-
|
|
3896
3902
|
/**
|
|
3897
3903
|
* Download the release tarball, extract it, and place its runtime shared
|
|
3898
3904
|
* libraries (libtranscribe + libggml `.dylib`/`.so`) into `paths.libsDir`,
|
|
@@ -3984,6 +3990,8 @@ async function maybeBuildTranscribeCli(
|
|
|
3984
3990
|
await downloadTo(cliSourceUrl(rel), dest);
|
|
3985
3991
|
}
|
|
3986
3992
|
},
|
|
3993
|
+
readFile: (p) => readFileSync(p, "utf8"),
|
|
3994
|
+
writeFile: (p, content) => writeFileSync(p, content),
|
|
3987
3995
|
compile: (cmd) => {
|
|
3988
3996
|
const p = Bun.spawnSync(cmd);
|
|
3989
3997
|
return { exitCode: p.exitCode ?? 1, stderr: new TextDecoder().decode(p.stderr) };
|
|
@@ -4032,6 +4040,14 @@ function noRunnableCliGuidance(
|
|
|
4032
4040
|
" • build transcribe-cli yourself, point TRANSCRIBE_CPP_BIN at it, and re-run; OR",
|
|
4033
4041
|
" • use the remote provider: parachute-vault transcription install --provider scribe-http",
|
|
4034
4042
|
);
|
|
4043
|
+
} else if (build && !build.ok && build.stage === "patch") {
|
|
4044
|
+
lines.push(
|
|
4045
|
+
`\n⚠ Could not apply the vault#534 backend-init source patch — ${build.message}`,
|
|
4046
|
+
" The pinned upstream source no longer matches the patch anchor. This needs a code fix",
|
|
4047
|
+
" (re-anchor or drop the patch), not a toolchain fix. Until then, either:",
|
|
4048
|
+
" • build a transcribe-cli yourself, point TRANSCRIBE_CPP_BIN at it, and re-run; OR",
|
|
4049
|
+
" • use the remote provider: parachute-vault transcription install --provider scribe-http",
|
|
4050
|
+
);
|
|
4035
4051
|
} else if (build && !build.ok) {
|
|
4036
4052
|
lines.push(
|
|
4037
4053
|
`\n⚠ transcribe-cli build failed (${build.stage}). The libraries + model are installed;`,
|
|
@@ -4055,7 +4071,7 @@ function noRunnableCliGuidance(
|
|
|
4055
4071
|
}
|
|
4056
4072
|
|
|
4057
4073
|
/** `parachute-vault transcription status` — provider + per-provider install state. */
|
|
4058
|
-
function cmdTranscriptionStatus(): void {
|
|
4074
|
+
async function cmdTranscriptionStatus(): Promise<void> {
|
|
4059
4075
|
const active = resolveTranscriptionProviderName();
|
|
4060
4076
|
console.log(`Configured provider: ${active}`);
|
|
4061
4077
|
|
|
@@ -4069,12 +4085,24 @@ function cmdTranscriptionStatus(): void {
|
|
|
4069
4085
|
`Tier default for this host (${tier.platform}/${tier.arch}, ${tier.totalRamGb}GB): ${tier.provider}${tier.model ? ` (${tier.model})` : ""}`,
|
|
4070
4086
|
);
|
|
4071
4087
|
|
|
4072
|
-
// transcribe-cpp — prebuilt libs + GGUF model + built CLI.
|
|
4088
|
+
// transcribe-cpp — prebuilt libs + GGUF model + built CLI. "runnable" is an
|
|
4089
|
+
// EXECUTED verdict, not a stat: `--help` must actually exit 0 (vault#534 —
|
|
4090
|
+
// an existsSync-only check said "yes" on Linux while the CLI exited 1 on
|
|
4091
|
+
// every real transcription because its dlopen'd CPU backends never loaded).
|
|
4073
4092
|
const paths = resolveTranscribeCppPaths();
|
|
4074
4093
|
const manifest = readManifest(paths.manifestPath);
|
|
4075
4094
|
const cliPresent = existsSync(paths.binPath);
|
|
4076
|
-
const
|
|
4077
|
-
|
|
4095
|
+
const filesPresent = transcribeCppInstalled(paths); // CLI + model both on disk
|
|
4096
|
+
let installed = false;
|
|
4097
|
+
let notRunnableReason: string | undefined;
|
|
4098
|
+
if (!filesPresent) {
|
|
4099
|
+
notRunnableReason = !cliPresent ? "no transcribe-cli binary" : "no GGUF model on disk";
|
|
4100
|
+
} else {
|
|
4101
|
+
const probe = await probeTranscribeCliRunnable(paths.binPath);
|
|
4102
|
+
installed = probe.ok;
|
|
4103
|
+
notRunnableReason = probe.reason;
|
|
4104
|
+
}
|
|
4105
|
+
console.log(`\ntranscribe-cpp runnable: ${installed ? "yes" : `no (${notRunnableReason})`}`);
|
|
4078
4106
|
if (manifest) {
|
|
4079
4107
|
console.log(` model: ${manifest.model} (${manifest.modelFile})`);
|
|
4080
4108
|
console.log(
|
package/src/export-watch.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* cursor captured at the *start* of the previous cycle. Vault writes are
|
|
11
11
|
* HTTP-mediated and don't surface to filesystem watchers (the bun:sqlite DB
|
|
12
12
|
* is opaque), so polling on `updated_at >= cursor` is the simplest robust
|
|
13
|
-
* detection. See `
|
|
13
|
+
* detection. See `docs/contracts/vault-portable-export.md`.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { ensureGitAvailable } from "./git-preflight.ts";
|
package/src/routes.ts
CHANGED
|
@@ -2089,7 +2089,7 @@ export async function handleTags(
|
|
|
2089
2089
|
|
|
2090
2090
|
// PUT /tags/:name — upsert tag identity row. Body accepts any combination
|
|
2091
2091
|
// of { description, fields, relationships, parent_names }; omitted keys
|
|
2092
|
-
// are preserved, explicit null clears. See
|
|
2092
|
+
// are preserved, explicit null clears. See docs/contracts/tag-data-model.md.
|
|
2093
2093
|
if (req.method === "PUT") {
|
|
2094
2094
|
// Canonical-bare-tag guard (vault#XXX): normalize the upserted tag NAME so
|
|
2095
2095
|
// the existing-field merge read (store.getTagSchema below) and the upsert
|
|
@@ -2114,7 +2114,7 @@ export async function handleTags(
|
|
|
2114
2114
|
* the MCP `update-tag` tool relies on (omitted keys preserved). The
|
|
2115
2115
|
* Schema editor (vault#283) sends the full map + `replace_fields: true`
|
|
2116
2116
|
* so removing a field row actually deletes the field. See
|
|
2117
|
-
*
|
|
2117
|
+
* docs/contracts/tag-data-model.md.
|
|
2118
2118
|
*/
|
|
2119
2119
|
replace_fields?: unknown;
|
|
2120
2120
|
};
|
|
@@ -2211,7 +2211,7 @@ export async function handleTags(
|
|
|
2211
2211
|
// Tag-scoped tokens reference root tags by name; deleting a referenced
|
|
2212
2212
|
// tag would silently orphan the token's allowlist. Fail closed (409)
|
|
2213
2213
|
// and name the offending tokens so the operator can revoke or re-mint
|
|
2214
|
-
// before retrying.
|
|
2214
|
+
// before retrying. docs/contracts/tag-scoped-tokens.md §Dependencies.
|
|
2215
2215
|
const referenced_by = findTokensReferencingTag(store.db, tagName);
|
|
2216
2216
|
if (referenced_by.length > 0) {
|
|
2217
2217
|
return json(
|
package/src/routing.test.ts
CHANGED
|
@@ -1631,7 +1631,7 @@ describe("scope enforcement on /api/*", () => {
|
|
|
1631
1631
|
expect(res.status).toBe(401);
|
|
1632
1632
|
});
|
|
1633
1633
|
|
|
1634
|
-
// ----- tag-scoped tokens (
|
|
1634
|
+
// ----- tag-scoped tokens (docs/contracts/tag-scoped-tokens.md) -----------------
|
|
1635
1635
|
|
|
1636
1636
|
/**
|
|
1637
1637
|
* Mint a tag-scoped token. Mirrors `mintToken` above but threads
|
|
@@ -1745,7 +1745,7 @@ describe("scope enforcement on /api/*", () => {
|
|
|
1745
1745
|
});
|
|
1746
1746
|
|
|
1747
1747
|
// ----- Q6: orphan-sub-tag fail-open ------------------------------------
|
|
1748
|
-
//
|
|
1748
|
+
// docs/contracts/tag-scoped-tokens.md §Storage: when a sub-tag has no declared
|
|
1749
1749
|
// schema, the string-form root authorizes. Token allowlisted for `health`
|
|
1750
1750
|
// must see `#health/food` even when no `_tags/health/food` schema exists.
|
|
1751
1751
|
|
package/src/routing.ts
CHANGED
|
@@ -856,7 +856,7 @@ export async function route(
|
|
|
856
856
|
|
|
857
857
|
const apiPath = apiMatch[1] ?? "";
|
|
858
858
|
|
|
859
|
-
// Tag-scoped tokens (
|
|
859
|
+
// Tag-scoped tokens (docs/contracts/tag-scoped-tokens.md): expand the token's
|
|
860
860
|
// root-tag allowlist into `{root} ∪ descendants(root)` once per request,
|
|
861
861
|
// so handlers can intersect against the note's tag set without re-walking
|
|
862
862
|
// the `_tags/<name>` hierarchy on every check. `tagScope.allowed` is null
|
package/src/tag-scope.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tag-scope enforcement for tag-scoped tokens (
|
|
2
|
+
* Tag-scope enforcement for tag-scoped tokens (docs/contracts/tag-scoped-tokens.md).
|
|
3
3
|
*
|
|
4
4
|
* A token's `scoped_tags` allowlist narrows its effective access to notes
|
|
5
5
|
* carrying one of the allowlisted tags or a sub-tag thereof. The expansion
|
|
6
6
|
* to descendants happens via the per-vault `_tags/<name>` config-note
|
|
7
7
|
* hierarchy (see core/src/tag-hierarchy.ts).
|
|
8
8
|
*
|
|
9
|
-
* Auth check pseudocode (from
|
|
9
|
+
* Auth check pseudocode (from docs/contracts/tag-scoped-tokens.md):
|
|
10
10
|
*
|
|
11
11
|
* if (!hasScope(token, ...)) return forbidden();
|
|
12
12
|
* if (token.scoped_tags === null) return ok(); // unscoped
|
|
@@ -38,7 +38,7 @@ export async function expandTokenTagScope(
|
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* Return true iff the note's tag set intersects the expanded allowlist OR
|
|
41
|
-
* — fail-open per
|
|
41
|
+
* — fail-open per docs/contracts/tag-scoped-tokens.md §Storage — any of the
|
|
42
42
|
* note's tags has a string-form root inside `rawRoots`. The string-form
|
|
43
43
|
* fallback covers the orphan-sub-tag case: a token allowlisted for
|
|
44
44
|
* `health` should still see `#health/food` even when no `_tags/health/food`
|
package/src/token-store.ts
CHANGED
|
@@ -54,7 +54,7 @@ export interface Token {
|
|
|
54
54
|
* access is the intersection of `scopes` and notes carrying one of these
|
|
55
55
|
* tags or a sub-tag thereof (hierarchy expansion via getTagDescendants).
|
|
56
56
|
* NULL = unscoped, full vault access per `scopes`. See
|
|
57
|
-
*
|
|
57
|
+
* docs/contracts/tag-scoped-tokens.md.
|
|
58
58
|
*/
|
|
59
59
|
scoped_tags: string[] | null;
|
|
60
60
|
/**
|
|
@@ -301,7 +301,7 @@ export function markMcpMintLedgerRevoked(
|
|
|
301
301
|
* Returns display ID + label pairs (no token-hash exposure) so error
|
|
302
302
|
* envelopes can name the offending tokens for the operator. The match is
|
|
303
303
|
* exact on the root name — `scoped_tags` only ever stores roots per
|
|
304
|
-
*
|
|
304
|
+
* docs/contracts/tag-scoped-tokens.md.
|
|
305
305
|
*/
|
|
306
306
|
export function findTokensReferencingTag(
|
|
307
307
|
db: Database,
|
|
@@ -6,6 +6,9 @@ import {
|
|
|
6
6
|
detectCompiler,
|
|
7
7
|
compilerInstallHint,
|
|
8
8
|
cliSourceUrl,
|
|
9
|
+
patchMainCppBackendInit,
|
|
10
|
+
BACKEND_INIT_PATCH_ANCHOR,
|
|
11
|
+
BACKEND_INIT_PATCH_MARKER,
|
|
9
12
|
CLI_SOURCE_FILES,
|
|
10
13
|
CXX_CANDIDATES,
|
|
11
14
|
TRANSCRIBE_CPP_SOURCE_REF,
|
|
@@ -17,10 +20,26 @@ import {
|
|
|
17
20
|
* transcribe-cli build tests (scribe-fold Phase 2b). Pure recipe shape +
|
|
18
21
|
* fully-mocked orchestration — NO network, NO real compiler (the real compile
|
|
19
22
|
* is exercised only by the install-time live verify). Covers the toolchain-
|
|
20
|
-
* absent, fetch-failure, compile-failure, and success branches,
|
|
21
|
-
* "never leave a half-built binary" invariant.
|
|
23
|
+
* absent, fetch-failure, patch-failure, compile-failure, and success branches,
|
|
24
|
+
* and the "never leave a half-built binary" invariant.
|
|
22
25
|
*/
|
|
23
26
|
|
|
27
|
+
/** A minimal stand-in for the pinned main.cpp: log-sink install + the batch-
|
|
28
|
+
* mode comment the vault#534 patch anchors on. */
|
|
29
|
+
const MAIN_CPP_FIXTURE = [
|
|
30
|
+
"int main(int argc, char ** argv) {",
|
|
31
|
+
" if (!args.quiet) {",
|
|
32
|
+
" transcribe_log_set(log_cb, nullptr);",
|
|
33
|
+
" }",
|
|
34
|
+
"",
|
|
35
|
+
BACKEND_INIT_PATCH_ANCHOR,
|
|
36
|
+
" // the model ONCE and reuses the context across all files.",
|
|
37
|
+
" if (!args.batch_file.empty()) {",
|
|
38
|
+
" }",
|
|
39
|
+
"}",
|
|
40
|
+
"",
|
|
41
|
+
].join("\n");
|
|
42
|
+
|
|
24
43
|
describe("detectCompiler", () => {
|
|
25
44
|
test("returns the first candidate found (c++ preferred)", () => {
|
|
26
45
|
const which = (c: string) => (c === "c++" ? "/usr/bin/c++" : null);
|
|
@@ -106,15 +125,31 @@ describe("buildCompileCommand — the proven recipe shape", () => {
|
|
|
106
125
|
|
|
107
126
|
/** Build a deps object with call-recording mocks; overrides win. */
|
|
108
127
|
function mkDeps(over: Partial<BuildCliDeps> = {}): BuildCliDeps & {
|
|
109
|
-
calls: {
|
|
128
|
+
calls: {
|
|
129
|
+
fetch: number;
|
|
130
|
+
compile: number;
|
|
131
|
+
removed: string[];
|
|
132
|
+
renamed: Array<[string, string]>;
|
|
133
|
+
written: Array<[string, string]>;
|
|
134
|
+
};
|
|
110
135
|
} {
|
|
111
|
-
const calls = {
|
|
136
|
+
const calls = {
|
|
137
|
+
fetch: 0,
|
|
138
|
+
compile: 0,
|
|
139
|
+
removed: [] as string[],
|
|
140
|
+
renamed: [] as Array<[string, string]>,
|
|
141
|
+
written: [] as Array<[string, string]>,
|
|
142
|
+
};
|
|
112
143
|
const deps: BuildCliDeps = {
|
|
113
144
|
platform: "darwin",
|
|
114
145
|
which: (c) => (c === "c++" ? "/usr/bin/c++" : null),
|
|
115
146
|
fetchSource: async () => {
|
|
116
147
|
calls.fetch++;
|
|
117
148
|
},
|
|
149
|
+
readFile: () => MAIN_CPP_FIXTURE,
|
|
150
|
+
writeFile: (p, content) => {
|
|
151
|
+
calls.written.push([p, content]);
|
|
152
|
+
},
|
|
118
153
|
compile: async (): Promise<CompileResult> => {
|
|
119
154
|
calls.compile++;
|
|
120
155
|
return { exitCode: 0, stderr: "" };
|
|
@@ -134,8 +169,36 @@ function mkDeps(over: Partial<BuildCliDeps> = {}): BuildCliDeps & {
|
|
|
134
169
|
const INPUT = { srcDir: "/t/.src", libsDir: "/t/libs", binPath: "/t/bin/transcribe-cli" };
|
|
135
170
|
const TMP = `${INPUT.binPath}.building`; // the temp output the build compiles to
|
|
136
171
|
|
|
172
|
+
describe("patchMainCppBackendInit — the vault#534 backend-init source patch", () => {
|
|
173
|
+
test("injects transcribe_init_backends_default() before the batch-mode anchor", () => {
|
|
174
|
+
const patched = patchMainCppBackendInit(MAIN_CPP_FIXTURE);
|
|
175
|
+
expect(patched).toContain("transcribe_init_backends_default();");
|
|
176
|
+
expect(patched).toContain(BACKEND_INIT_PATCH_MARKER);
|
|
177
|
+
// The init call lands BEFORE the inference paths (the anchor) and AFTER
|
|
178
|
+
// the log-sink install, so backend plugin-load logs route through the sink.
|
|
179
|
+
const initAt = patched.indexOf("transcribe_init_backends_default();");
|
|
180
|
+
expect(initAt).toBeGreaterThan(patched.indexOf("transcribe_log_set(log_cb, nullptr);"));
|
|
181
|
+
expect(initAt).toBeLessThan(patched.indexOf(BACKEND_INIT_PATCH_ANCHOR));
|
|
182
|
+
// Everything else is preserved verbatim: removing the injected block
|
|
183
|
+
// restores the original source exactly.
|
|
184
|
+
const [before, after] = patched.split(/ \/\/ \[parachute-vault[\s\S]*?transcribe_init_backends_default\(\);\n\n/);
|
|
185
|
+
expect(`${before}${after}`).toBe(MAIN_CPP_FIXTURE);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("idempotent: an already-patched source is returned unchanged", () => {
|
|
189
|
+
const once = patchMainCppBackendInit(MAIN_CPP_FIXTURE);
|
|
190
|
+
expect(patchMainCppBackendInit(once)).toBe(once);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("anchor missing ⇒ throws a loud, actionable error naming the pin + vault#534", () => {
|
|
194
|
+
expect(() => patchMainCppBackendInit("int main() { return 0; }\n")).toThrow(
|
|
195
|
+
/anchor not found[\s\S]*vault#534/,
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
137
200
|
describe("buildTranscribeCli — orchestration branches", () => {
|
|
138
|
-
test("SUCCESS: fetch → compile ok → temp exists ⇒ promote (rename) + { ok:true }", async () => {
|
|
201
|
+
test("SUCCESS: fetch → patch → compile ok → temp exists ⇒ promote (rename) + { ok:true }", async () => {
|
|
139
202
|
const deps = mkDeps();
|
|
140
203
|
const r = await buildTranscribeCli(INPUT, deps);
|
|
141
204
|
expect(r.ok).toBe(true);
|
|
@@ -149,11 +212,29 @@ describe("buildTranscribeCli — orchestration branches", () => {
|
|
|
149
212
|
}
|
|
150
213
|
expect(deps.calls.fetch).toBe(1);
|
|
151
214
|
expect(deps.calls.compile).toBe(1);
|
|
215
|
+
// the vault#534 backend-init patch was written into the fetched main.cpp
|
|
216
|
+
expect(deps.calls.written).toHaveLength(1);
|
|
217
|
+
const [patchedPath, patchedContent] = deps.calls.written[0]!;
|
|
218
|
+
expect(patchedPath).toBe(join(INPUT.srcDir, "examples", "cli", "main.cpp"));
|
|
219
|
+
expect(patchedContent).toContain("transcribe_init_backends_default();");
|
|
152
220
|
// only the TEMP is cleared pre-compile; the fresh temp is renamed into place
|
|
153
221
|
expect(deps.calls.removed).toEqual([TMP]);
|
|
154
222
|
expect(deps.calls.renamed).toEqual([[TMP, INPUT.binPath]]);
|
|
155
223
|
});
|
|
156
224
|
|
|
225
|
+
test("PATCH FAILURE: anchor missing in fetched source ⇒ { ok:false, stage:'patch' }, no write, no compile", async () => {
|
|
226
|
+
const deps = mkDeps({ readFile: () => "int main() { return 0; }\n" });
|
|
227
|
+
const r = await buildTranscribeCli(INPUT, deps);
|
|
228
|
+
expect(r.ok).toBe(false);
|
|
229
|
+
if (!r.ok) {
|
|
230
|
+
expect(r.stage).toBe("patch");
|
|
231
|
+
expect(r.message).toContain("anchor not found");
|
|
232
|
+
expect(r.message).toContain("vault#534");
|
|
233
|
+
}
|
|
234
|
+
expect(deps.calls.written).toEqual([]);
|
|
235
|
+
expect(deps.calls.compile).toBe(0);
|
|
236
|
+
});
|
|
237
|
+
|
|
157
238
|
test("TOOLCHAIN ABSENT: no compiler ⇒ { ok:false, stage:'toolchain' }, no fetch/compile", async () => {
|
|
158
239
|
const deps = mkDeps({ which: () => null });
|
|
159
240
|
const r = await buildTranscribeCli(INPUT, deps);
|
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
* `install.ts` + the provider-seam design doc. This module closes that gap: it
|
|
7
7
|
* fetches the CLI's source (`examples/cli/main.cpp` + `examples/common/wav.cpp`
|
|
8
8
|
* + the public headers) from the transcribe.cpp repo pinned at the v0.1.1 tag
|
|
9
|
-
* commit,
|
|
10
|
-
*
|
|
9
|
+
* commit, applies the **vault#534 backend-init patch** (see
|
|
10
|
+
* `patchMainCppBackendInit` below — temporary until upstream ships the fix),
|
|
11
|
+
* then compiles it with the host C++ compiler against the extracted prebuilt
|
|
12
|
+
* dylibs. The result is a ~127KB driver that links the prebuilt
|
|
11
13
|
* `libtranscribe` at runtime — **no ggml / Metal rebuild**.
|
|
12
14
|
*
|
|
13
15
|
* ## The proven recipe (live-verified 2026-07-03, macOS arm64 M4)
|
|
@@ -34,10 +36,11 @@
|
|
|
34
36
|
* ## Testability
|
|
35
37
|
*
|
|
36
38
|
* `buildTranscribeCli` is a pure orchestrator over injected side effects
|
|
37
|
-
* (`which` / `fetchSource` / `
|
|
38
|
-
* provider's `SpawnRunner` seam. Tests exercise
|
|
39
|
-
* absent, fetch failure,
|
|
40
|
-
*
|
|
39
|
+
* (`which` / `fetchSource` / `readFile` / `writeFile` / `compile` / `exists` /
|
|
40
|
+
* `removeBin`), mirroring the provider's `SpawnRunner` seam. Tests exercise
|
|
41
|
+
* every branch — toolchain absent, fetch failure, patch failure, compile
|
|
42
|
+
* failure, success — with no network and no real compiler. The real compile is
|
|
43
|
+
* exercised only by the install-time live verify.
|
|
41
44
|
*/
|
|
42
45
|
|
|
43
46
|
import { join } from "path";
|
|
@@ -78,6 +81,63 @@ export function cliSourceUrl(repoPath: string): string {
|
|
|
78
81
|
return `${RAW_BASE}/${repoPath}`;
|
|
79
82
|
}
|
|
80
83
|
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Build-time source patch: unconditional backend init (vault#534 blocker 2)
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
/** Marker comment baked into the injected code — makes the patch idempotent
|
|
89
|
+
* and greppable in a fetched source tree. */
|
|
90
|
+
export const BACKEND_INIT_PATCH_MARKER = "[parachute-vault vault#534 backend-init patch]";
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The anchor line in the pinned `examples/cli/main.cpp` the patch injects
|
|
94
|
+
* before: the batch-mode comment that opens the inference half of `main()`,
|
|
95
|
+
* directly AFTER the log-sink install (so backend plugin-load logs route
|
|
96
|
+
* through the CLI's log callback) and after the `--list-devices` early return.
|
|
97
|
+
* Unique at the pinned ref (verified); if the pin ever advances and this
|
|
98
|
+
* anchor drifts, the build fails LOUDLY (stage "patch") so the patch gets
|
|
99
|
+
* re-evaluated against the new source instead of silently shipping a CLI that
|
|
100
|
+
* can't transcribe on Linux.
|
|
101
|
+
*/
|
|
102
|
+
export const BACKEND_INIT_PATCH_ANCHOR =
|
|
103
|
+
" // Batch mode: --batch reads a file list, one wav path per line. Loads";
|
|
104
|
+
|
|
105
|
+
const BACKEND_INIT_PATCH_BLOCK = ` // ${BACKEND_INIT_PATCH_MARKER}
|
|
106
|
+
// Upstream (at the pinned ${TRANSCRIBE_CPP_SOURCE_REF.slice(0, 12)}) only calls
|
|
107
|
+
// transcribe_init_backends_default() in the --list-devices path. On Linux
|
|
108
|
+
// the CPU backends are dlopen plugins (libggml-cpu-*.so), so with no init
|
|
109
|
+
// the ggml device registry is empty and EVERY transcription fails
|
|
110
|
+
// ("whisper: failed to initialize CPU backend", exit 1). macOS is masked:
|
|
111
|
+
// its libtranscribe.dylib direct-links libggml-cpu, which self-registers
|
|
112
|
+
// at library load. Init the default backends unconditionally before any
|
|
113
|
+
// inference (batch or single-file). Verified fix on real Linux containers,
|
|
114
|
+
// both arches — see vault#534. DROP THIS PATCH when upstream ships the fix
|
|
115
|
+
// and the source pin advances past it.
|
|
116
|
+
transcribe_init_backends_default();
|
|
117
|
+
|
|
118
|
+
`;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Apply the vault#534 backend-init patch to the fetched `main.cpp` source.
|
|
122
|
+
* Pure (string → string) so tests pin the behavior without a filesystem.
|
|
123
|
+
* Idempotent: an already-patched source is returned unchanged. Throws with a
|
|
124
|
+
* clear operator-facing message when the anchor is missing — the caller
|
|
125
|
+
* surfaces it as a `stage: "patch"` build failure (fail loudly; never compile
|
|
126
|
+
* an unpatched CLI on the assumption it'll work).
|
|
127
|
+
*/
|
|
128
|
+
export function patchMainCppBackendInit(source: string): string {
|
|
129
|
+
if (source.includes(BACKEND_INIT_PATCH_MARKER)) return source;
|
|
130
|
+
const idx = source.indexOf(BACKEND_INIT_PATCH_ANCHOR);
|
|
131
|
+
if (idx === -1) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`backend-init patch anchor not found in examples/cli/main.cpp at ${TRANSCRIBE_CPP_SOURCE_REF.slice(0, 12)} — ` +
|
|
134
|
+
`the pinned source no longer matches the vault#534 patch. If the pin advanced, check whether upstream ` +
|
|
135
|
+
`now calls transcribe_init_backends_default() unconditionally (then drop the patch); otherwise re-anchor it.`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return source.slice(0, idx) + BACKEND_INIT_PATCH_BLOCK + source.slice(idx);
|
|
139
|
+
}
|
|
140
|
+
|
|
81
141
|
/** C++ compiler binaries we probe for, in preference order. */
|
|
82
142
|
export const CXX_CANDIDATES = ["c++", "clang++", "g++"] as const;
|
|
83
143
|
|
|
@@ -158,6 +218,10 @@ export interface BuildCliDeps {
|
|
|
158
218
|
which: (cmd: string) => string | null | undefined;
|
|
159
219
|
/** Fetch `files` (repo-relative) into `srcDir`. Throws on network failure. */
|
|
160
220
|
fetchSource: (files: readonly string[], srcDir: string) => Promise<void>;
|
|
221
|
+
/** Read a fetched source file as UTF-8 (production: `fs.readFileSync`). */
|
|
222
|
+
readFile: (p: string) => string;
|
|
223
|
+
/** Write a patched source file (production: `fs.writeFileSync`). */
|
|
224
|
+
writeFile: (p: string, content: string) => void;
|
|
161
225
|
/** Run the compiler argv. */
|
|
162
226
|
compile: (cmd: string[]) => CompileResult | Promise<CompileResult>;
|
|
163
227
|
/** Existence probe (production: `fs.existsSync`). */
|
|
@@ -185,7 +249,7 @@ export type CliBuildResult =
|
|
|
185
249
|
| {
|
|
186
250
|
ok: false;
|
|
187
251
|
/** Which stage failed — drives the operator guidance. */
|
|
188
|
-
stage: "toolchain" | "fetch" | "compile";
|
|
252
|
+
stage: "toolchain" | "fetch" | "patch" | "compile";
|
|
189
253
|
message: string;
|
|
190
254
|
compiler?: string;
|
|
191
255
|
/** The compile argv (as a string) when we got far enough to build one. */
|
|
@@ -223,6 +287,22 @@ export async function buildTranscribeCli(
|
|
|
223
287
|
};
|
|
224
288
|
}
|
|
225
289
|
|
|
290
|
+
// Apply the vault#534 backend-init source patch BEFORE compiling — without
|
|
291
|
+
// it the built CLI exits 1 on every transcription on Linux (dlopen'd CPU
|
|
292
|
+
// backends are never registered). A missing anchor is a loud, typed failure,
|
|
293
|
+
// never a silent skip.
|
|
294
|
+
const mainCppPath = join(input.srcDir, "examples", "cli", "main.cpp");
|
|
295
|
+
try {
|
|
296
|
+
deps.writeFile(mainCppPath, patchMainCppBackendInit(deps.readFile(mainCppPath)));
|
|
297
|
+
} catch (err) {
|
|
298
|
+
return {
|
|
299
|
+
ok: false,
|
|
300
|
+
stage: "patch",
|
|
301
|
+
compiler,
|
|
302
|
+
message: err instanceof Error ? err.message : String(err),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
226
306
|
const common = { platform: deps.platform, compiler, srcDir: input.srcDir, libsDir: input.libsDir };
|
|
227
307
|
// The reported command targets the FINAL binPath (what an operator would run
|
|
228
308
|
// by hand to reproduce). The actual compile writes to a temp sibling and is
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, test, expect, afterAll } from "bun:test";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { downloadTo } from "./download.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `downloadTo` tests (vault#534 blocker 1). The manual body→FileSink pump
|
|
9
|
+
* replaced `Bun.write(dest, resp)`, which hangs forever on Linux for large
|
|
10
|
+
* responses. These run the pump against an in-process `Bun.serve` (no
|
|
11
|
+
* subprocess involved — see CLAUDE.md's spawnSync note; plain in-process
|
|
12
|
+
* fetches are fine) and pin: bytes land intact, HTTP errors throw, and a
|
|
13
|
+
* mid-stream failure never leaves a partial file behind.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const dir = mkdtempSync(join(tmpdir(), "vault-download-test-"));
|
|
17
|
+
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
|
18
|
+
|
|
19
|
+
// 4MB of deterministic non-trivial bytes — large enough to stream in many
|
|
20
|
+
// chunks (the hang was specific to the large-response path).
|
|
21
|
+
const PAYLOAD = new Uint8Array(4 * 1024 * 1024);
|
|
22
|
+
for (let i = 0; i < PAYLOAD.length; i++) PAYLOAD[i] = (i * 31 + (i >> 8)) & 0xff;
|
|
23
|
+
|
|
24
|
+
function serve(handler: (req: Request) => Response | Promise<Response>) {
|
|
25
|
+
const server = Bun.serve({ port: 0, fetch: handler });
|
|
26
|
+
return { server, url: `http://127.0.0.1:${server.port}` };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("downloadTo", () => {
|
|
30
|
+
test("streams a multi-MB body to disk byte-for-byte", async () => {
|
|
31
|
+
const { server, url } = serve(() => new Response(PAYLOAD));
|
|
32
|
+
const dest = join(dir, "ok.bin");
|
|
33
|
+
try {
|
|
34
|
+
await downloadTo(`${url}/file`, dest);
|
|
35
|
+
const got = readFileSync(dest);
|
|
36
|
+
expect(got.byteLength).toBe(PAYLOAD.byteLength);
|
|
37
|
+
expect(Buffer.from(got).equals(Buffer.from(PAYLOAD))).toBe(true);
|
|
38
|
+
} finally {
|
|
39
|
+
server.stop(true);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("non-OK status throws with the status + URL, writes nothing", async () => {
|
|
44
|
+
const { server, url } = serve(() => new Response("nope", { status: 404 }));
|
|
45
|
+
const dest = join(dir, "missing.bin");
|
|
46
|
+
try {
|
|
47
|
+
expect(downloadTo(`${url}/gone`, dest)).rejects.toThrow(/download failed \(404\)/);
|
|
48
|
+
expect(existsSync(dest)).toBe(false);
|
|
49
|
+
} finally {
|
|
50
|
+
server.stop(true);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("truncated download throws AND removes the partial file", async () => {
|
|
55
|
+
// Raw TCP fixture: a real content-length response (how GitHub releases +
|
|
56
|
+
// HuggingFace serve these binaries) whose connection dies mid-body.
|
|
57
|
+
// Bun.serve can't fixture this — it re-frames streamed responses as
|
|
58
|
+
// chunked (dropping content-length), and Bun's fetch ends a truncated
|
|
59
|
+
// chunked stream CLEANLY, so truncation is only detectable on the
|
|
60
|
+
// content-length path `downloadTo` verifies.
|
|
61
|
+
const partial = PAYLOAD.slice(0, 64 * 1024);
|
|
62
|
+
const listener = Bun.listen({
|
|
63
|
+
hostname: "127.0.0.1",
|
|
64
|
+
port: 0,
|
|
65
|
+
socket: {
|
|
66
|
+
data(socket) {
|
|
67
|
+
socket.write(
|
|
68
|
+
`HTTP/1.1 200 OK\r\ncontent-type: application/octet-stream\r\ncontent-length: ${PAYLOAD.byteLength}\r\n\r\n`,
|
|
69
|
+
);
|
|
70
|
+
socket.write(partial);
|
|
71
|
+
socket.end(); // die after 64KB of a declared 4MB
|
|
72
|
+
},
|
|
73
|
+
open() {},
|
|
74
|
+
close() {},
|
|
75
|
+
error() {},
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
const dest = join(dir, "partial.bin");
|
|
79
|
+
try {
|
|
80
|
+
let threw = false;
|
|
81
|
+
try {
|
|
82
|
+
await downloadTo(`http://127.0.0.1:${listener.port}/flaky`, dest);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
threw = true;
|
|
85
|
+
// The truncation surfaces differently per platform: on macOS the body
|
|
86
|
+
// stream errors mid-pump (wrapped "failed after N bytes") or the
|
|
87
|
+
// short read trips the content-length check ("truncated"); on Linux
|
|
88
|
+
// Bun's fetch() itself rejects on the early socket close. All are
|
|
89
|
+
// loud failures — the invariants are THROWS + NO PARTIAL FILE.
|
|
90
|
+
expect(String(err)).toMatch(
|
|
91
|
+
/failed after \d+ bytes|truncated|socket connection was closed|connection closed/i,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
expect(threw).toBe(true);
|
|
95
|
+
// The half-written file must not survive to be mistaken for a good artifact.
|
|
96
|
+
expect(existsSync(dest)).toBe(false);
|
|
97
|
+
} finally {
|
|
98
|
+
listener.stop(true);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming file download for `transcription install` (tarball, GGUF models,
|
|
3
|
+
* CLI sources).
|
|
4
|
+
*
|
|
5
|
+
* ## Why a manual pump instead of `Bun.write(dest, resp)` (vault#534 blocker 1)
|
|
6
|
+
*
|
|
7
|
+
* `Bun.write(dest, response)` HANGS FOREVER on Linux when streaming a large
|
|
8
|
+
* Response body to disk — zero bytes land, no error, no timeout. Reproduced
|
|
9
|
+
* deterministically in real Linux containers on BOTH arches (aarch64 native +
|
|
10
|
+
* x86_64 emulated) across bun 1.2.23 / 1.3.13 / 1.3.14, while the identical
|
|
11
|
+
* code works on macOS. In the same environment `curl` pulls the same URL at
|
|
12
|
+
* full speed and `await resp.arrayBuffer()` gets all 26MB in ~2s — the bug is
|
|
13
|
+
* specifically Bun's Response→file streaming fast path. Manually pumping
|
|
14
|
+
* `resp.body` chunks into a `Bun.file(dest).writer()` moves the same 26MB in
|
|
15
|
+
* <1s and works on both platforms, so that's what we do everywhere (no
|
|
16
|
+
* platform gate). See vault#534 for the full container verification.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { rmSync } from "fs";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Download `url` to `dest` (follows redirects). Streams chunk-by-chunk — never
|
|
23
|
+
* buffers the whole body (models run up to ~660MB). On any mid-stream failure
|
|
24
|
+
* the partial file is removed so a retry never trusts a truncated artifact.
|
|
25
|
+
* When the server sent an honest `content-length` (no content-encoding
|
|
26
|
+
* transform), a byte-count mismatch is treated as a failed download too.
|
|
27
|
+
*/
|
|
28
|
+
export async function downloadTo(url: string, dest: string): Promise<void> {
|
|
29
|
+
const resp = await fetch(url, { redirect: "follow" });
|
|
30
|
+
if (!resp.ok) throw new Error(`download failed (${resp.status}) for ${url}`);
|
|
31
|
+
if (!resp.body) throw new Error(`download failed (empty response body) for ${url}`);
|
|
32
|
+
|
|
33
|
+
// content-length is only the on-the-wire byte count when no transfer
|
|
34
|
+
// decompression happened; fetch auto-decodes content-encoding'd bodies, so
|
|
35
|
+
// only enforce the size check for identity responses (the normal case for
|
|
36
|
+
// release tarballs + GGUFs — both already-compressed binary).
|
|
37
|
+
const encoding = resp.headers.get("content-encoding");
|
|
38
|
+
const lengthHeader = resp.headers.get("content-length");
|
|
39
|
+
const expectedBytes =
|
|
40
|
+
(!encoding || encoding === "identity") && lengthHeader && /^\d+$/.test(lengthHeader)
|
|
41
|
+
? Number(lengthHeader)
|
|
42
|
+
: null;
|
|
43
|
+
|
|
44
|
+
const sink = Bun.file(dest).writer();
|
|
45
|
+
let written = 0;
|
|
46
|
+
try {
|
|
47
|
+
for await (const chunk of resp.body) {
|
|
48
|
+
sink.write(chunk);
|
|
49
|
+
written += chunk.byteLength;
|
|
50
|
+
}
|
|
51
|
+
await sink.end();
|
|
52
|
+
} catch (err) {
|
|
53
|
+
// Flush/close what we can, then remove the partial file — a half-written
|
|
54
|
+
// tarball/model must never survive to be mistaken for a good artifact.
|
|
55
|
+
try {
|
|
56
|
+
await sink.end();
|
|
57
|
+
} catch {
|
|
58
|
+
// best-effort close; the rm below is what matters
|
|
59
|
+
}
|
|
60
|
+
rmSync(dest, { force: true });
|
|
61
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
62
|
+
throw new Error(`download of ${url} failed after ${written} bytes: ${msg}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (expectedBytes !== null && written !== expectedBytes) {
|
|
66
|
+
rmSync(dest, { force: true });
|
|
67
|
+
throw new Error(
|
|
68
|
+
`download of ${url} was truncated: got ${written} bytes, expected ${expectedBytes} (content-length)`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { describe, test, expect, afterEach } from "bun:test";
|
|
2
|
-
import { mkdirSync, writeFileSync, rmSync } from "fs";
|
|
2
|
+
import { mkdirSync, writeFileSync, rmSync, chmodSync } from "fs";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { tmpdir } from "os";
|
|
5
5
|
import {
|
|
6
6
|
resolveTranscriptionProviderName,
|
|
7
7
|
resolveTranscribeCppPaths,
|
|
8
8
|
transcribeCppInstalled,
|
|
9
|
+
probeTranscribeCliRunnable,
|
|
9
10
|
readManifest,
|
|
10
11
|
transcriptionHomeDir,
|
|
11
12
|
pythonVenvDir,
|
|
@@ -120,6 +121,45 @@ describe("transcribeCppInstalled", () => {
|
|
|
120
121
|
});
|
|
121
122
|
});
|
|
122
123
|
|
|
124
|
+
describe("probeTranscribeCliRunnable — EXECUTED, not stat'd (vault#534)", () => {
|
|
125
|
+
// Tiny real shell scripts stand in for transcribe-cli: the probe's whole
|
|
126
|
+
// point is that it actually runs the binary, so mocks would test nothing.
|
|
127
|
+
function script(home: string, body: string): string {
|
|
128
|
+
const p = join(home, "fake-cli");
|
|
129
|
+
writeFileSync(p, `#!/bin/sh\n${body}\n`);
|
|
130
|
+
chmodSync(p, 0o755);
|
|
131
|
+
return p;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
test("exit 0 ⇒ ok", async () => {
|
|
135
|
+
const bin = script(mkTmpHome(), "exit 0");
|
|
136
|
+
expect(await probeTranscribeCliRunnable(bin)).toEqual({ ok: true });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("nonzero exit ⇒ not ok, reason carries the exit code + first stderr line", async () => {
|
|
140
|
+
// Mirrors the vault#534 Linux failure shape: binary present + launches,
|
|
141
|
+
// but errors out (there: empty ggml backend registry, exit 1).
|
|
142
|
+
const bin = script(mkTmpHome(), 'echo "whisper: failed to initialize CPU backend" >&2\nexit 1');
|
|
143
|
+
const r = await probeTranscribeCliRunnable(bin);
|
|
144
|
+
expect(r.ok).toBe(false);
|
|
145
|
+
expect(r.reason).toContain("exited 1");
|
|
146
|
+
expect(r.reason).toContain("failed to initialize CPU backend");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("unlaunchable binary (missing) ⇒ not ok with a reason", async () => {
|
|
150
|
+
const r = await probeTranscribeCliRunnable(join(mkTmpHome(), "no-such-cli"));
|
|
151
|
+
expect(r.ok).toBe(false);
|
|
152
|
+
expect(r.reason).toBeTruthy();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("hang ⇒ not ok after the timeout", async () => {
|
|
156
|
+
const bin = script(mkTmpHome(), "sleep 30");
|
|
157
|
+
const r = await probeTranscribeCliRunnable(bin, { timeoutMs: 250 });
|
|
158
|
+
expect(r.ok).toBe(false);
|
|
159
|
+
expect(r.reason).toContain("250ms");
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
123
163
|
describe("readManifest", () => {
|
|
124
164
|
test("returns null for a missing/unreadable manifest", () => {
|
|
125
165
|
expect(readManifest(join(tmpdir(), "does-not-exist-xyz.json"))).toBeNull();
|
|
@@ -182,6 +182,69 @@ export function transcribeCppInstalled(
|
|
|
182
182
|
return existsSync(paths.binPath) && !!paths.modelPath && existsSync(paths.modelPath);
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
/** Outcome of the EXECUTED runnable probe (`probeTranscribeCliRunnable`). */
|
|
186
|
+
export interface RunnableProbeResult {
|
|
187
|
+
ok: boolean;
|
|
188
|
+
/** Why the probe failed, for the `runnable: no (<reason>)` print. */
|
|
189
|
+
reason?: string;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* EXECUTED runnable probe: actually launch `transcribe-cli --help` and check
|
|
194
|
+
* the exit code. Cheap (no model load, no inference — upstream's `--help`
|
|
195
|
+
* prints usage and exits 0) but honest where a stat can't be: it catches a
|
|
196
|
+
* binary whose shared libraries don't resolve (broken/missing `libs/`, a bad
|
|
197
|
+
* rpath), a wrong-arch or corrupt binary, and a missing exec bit — all cases
|
|
198
|
+
* where `existsSync` says "yes" while every real run fails (the vault#534
|
|
199
|
+
* honesty bug: Linux installs reported `runnable: yes` while the CLI exited 1
|
|
200
|
+
* on every transcription).
|
|
201
|
+
*
|
|
202
|
+
* Deliberately NOT used by the per-request capability gate or the provider's
|
|
203
|
+
* `available()` (both are documented spawn-free); this is for the human-paced
|
|
204
|
+
* paths — `transcription status` and the install verb's activation check.
|
|
205
|
+
*/
|
|
206
|
+
export async function probeTranscribeCliRunnable(
|
|
207
|
+
binPath: string,
|
|
208
|
+
opts: { timeoutMs?: number } = {},
|
|
209
|
+
): Promise<RunnableProbeResult> {
|
|
210
|
+
const timeoutMs = opts.timeoutMs ?? 10_000;
|
|
211
|
+
let proc: ReturnType<typeof Bun.spawn>;
|
|
212
|
+
try {
|
|
213
|
+
proc = Bun.spawn([binPath, "--help"], { stdin: "ignore", stdout: "ignore", stderr: "pipe" });
|
|
214
|
+
} catch (err) {
|
|
215
|
+
return {
|
|
216
|
+
ok: false,
|
|
217
|
+
reason: `could not launch ${binPath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
let timedOut = false;
|
|
221
|
+
const timer = setTimeout(() => {
|
|
222
|
+
timedOut = true;
|
|
223
|
+
proc.kill("SIGKILL"); // diagnostic probe — no graceful shutdown to preserve
|
|
224
|
+
}, timeoutMs);
|
|
225
|
+
try {
|
|
226
|
+
const exitCode = await proc.exited;
|
|
227
|
+
if (timedOut) return { ok: false, reason: `--help did not exit within ${timeoutMs}ms` };
|
|
228
|
+
// stderr normally EOFs at exit; the race guards against a wrapper-script
|
|
229
|
+
// binary whose orphaned grandchild still holds the pipe open.
|
|
230
|
+
const stderr = await Promise.race([
|
|
231
|
+
new Response(proc.stderr as ReadableStream).text(),
|
|
232
|
+
new Promise<string>((resolve) => setTimeout(resolve, 2_000, "")),
|
|
233
|
+
]);
|
|
234
|
+
const detail = stderr.trim().split("\n")[0]?.slice(0, 200);
|
|
235
|
+
if (proc.signalCode) {
|
|
236
|
+
// e.g. macOS dyld aborts with SIGABRT when a linked dylib is missing.
|
|
237
|
+
return { ok: false, reason: `--help died on ${proc.signalCode}${detail ? `: ${detail}` : ""}` };
|
|
238
|
+
}
|
|
239
|
+
if (exitCode !== 0) {
|
|
240
|
+
return { ok: false, reason: `--help exited ${exitCode}${detail ? `: ${detail}` : ""}` };
|
|
241
|
+
}
|
|
242
|
+
return { ok: true };
|
|
243
|
+
} finally {
|
|
244
|
+
clearTimeout(timer);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
185
248
|
// ---------------------------------------------------------------------------
|
|
186
249
|
// Python-based local providers (parakeet-mlx / onnx-asr) — scribe-fold 2b
|
|
187
250
|
// ---------------------------------------------------------------------------
|
package/src/vault.test.ts
CHANGED
|
@@ -1093,7 +1093,7 @@ describe("scoped MCP wrapper", async () => {
|
|
|
1093
1093
|
close();
|
|
1094
1094
|
});
|
|
1095
1095
|
|
|
1096
|
-
// -- tag-scoped MCP wrappers (
|
|
1096
|
+
// -- tag-scoped MCP wrappers (docs/contracts/tag-scoped-tokens.md) ------------
|
|
1097
1097
|
//
|
|
1098
1098
|
// These pin the behavior of `applyTagScopeWrappers` in mcp-tools.ts: each
|
|
1099
1099
|
// wrapped tool's execute() honors the auth's scoped_tags allowlist. The
|