@openparachute/vault 0.7.5 → 0.7.6-rc.2

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.
@@ -13,8 +13,8 @@
13
13
  * The active provider is chosen by the `TRANSCRIPTION_PROVIDER` env var
14
14
  * (persisted in `~/.parachute/vault/.env`), resolved here so the worker boot
15
15
  * (`server.ts`) and the capability flag (`capability.ts`) agree on one source
16
- * of truth. Unset ⇒ `scribe-http`, so no config change means no behavior
17
- * change.
16
+ * of truth. Unset ⇒ `whisper-cpp` UNLESS a scribe is actually reachable, in
17
+ * which case `scribe-http` (see `resolveTranscriptionProviderName`).
18
18
  *
19
19
  * The `transcribe-cli` binary, GGUF model, and runtime shared libraries live
20
20
  * under `$PARACHUTE_HOME/transcription/` (parallel to the vault's other
@@ -30,6 +30,7 @@ import { join } from "path";
30
30
  import { homedir } from "os";
31
31
  import { existsSync, readFileSync } from "fs";
32
32
  import { DEFAULT_MODEL_ID } from "./models.ts";
33
+ import { resolveScribeUrl } from "../scribe-discovery.ts";
33
34
 
34
35
  export const TRANSCRIPTION_PROVIDERS = [
35
36
  "whisper-cpp",
@@ -54,34 +55,65 @@ export type TranscriptionProviderName = (typeof TRANSCRIPTION_PROVIDERS)[number]
54
55
  export const DEFAULT_PARAKEET_MLX_MODEL = "mlx-community/parakeet-tdt-0.6b-v3";
55
56
  export const DEFAULT_ONNX_ASR_MODEL = "nemo-parakeet-tdt-0.6b-v3";
56
57
 
58
+ /** Injection seam: "is a scribe actually reachable from here?" */
59
+ export interface ProviderResolveDeps {
60
+ scribeConfiguredImpl?: () => boolean;
61
+ }
62
+
57
63
  /**
58
- * Resolve the configured provider name. `TRANSCRIPTION_PROVIDER` selects it;
59
- * unset (or blank) ⇒ `scribe-http`. An unrecognized value warns once and falls
60
- * back rather than failing boot a typo shouldn't take transcription offline
61
- * hard.
64
+ * The default provider when nothing is configured.
65
+ *
66
+ * This used to be an unconditional `scribe-http`, which was the wrong default
67
+ * the moment scribe stopped shipping: a fresh box resolved to a provider with
68
+ * no backend, so it accepted audio and transcribed nothing (vault#643 made that
69
+ * visible; it did not make it correct). `whisper-cpp` is the right default —
70
+ * it's local, and `transcription install` now installs a binary + model and
71
+ * verifies a real transcription before claiming success (vault#636), which is
72
+ * the precondition this flip was waiting on.
62
73
  *
63
- * NOTE on the default: `scribe-http` remains the fallback for now because
64
- * flipping it is a separate, riskier change see the `whisper-cpp` entry in
65
- * `models.ts`. On a box with nothing configured, `scribe-http` with no
66
- * SCRIBE_URL means transcription doesn't run at all, which vault#643 now
67
- * reports honestly instead of silently skipping. Making `whisper-cpp` the
68
- * default is the follow-up, once `transcription install` can guarantee a
69
- * runnable binary + model.
74
+ * But flipping unconditionally would break the boxes that DO have a working
75
+ * scribe: their config is the absence of config, so "unset" can't be read as
76
+ * "wants local". So the default asks whether a scribe is actually reachable —
77
+ * the same `SCRIBE_URL`-then-`services.json` resolution the provider itself
78
+ * uses and defers to it when one is. A working box keeps working; a box with
79
+ * nothing gets the provider it can actually install.
80
+ *
81
+ * Deliberately NOT a migration that writes `TRANSCRIPTION_PROVIDER` to `.env`:
82
+ * pinning a value at upgrade time would freeze whichever answer was true that
83
+ * day, and an operator who later removes scribe would stay pinned to a dead
84
+ * provider — the exact failure being fixed here.
85
+ */
86
+ function defaultProviderName(
87
+ env: NodeJS.ProcessEnv,
88
+ deps: ProviderResolveDeps,
89
+ ): TranscriptionProviderName {
90
+ const scribeConfigured =
91
+ deps.scribeConfiguredImpl ?? (() => resolveScribeUrl(env, undefined, { warn: undefined }) !== undefined);
92
+ return scribeConfigured() ? "scribe-http" : "whisper-cpp";
93
+ }
94
+
95
+ /**
96
+ * Resolve the configured provider name. `TRANSCRIPTION_PROVIDER` selects it;
97
+ * unset (or blank) ⇒ {@link defaultProviderName}. An unrecognized value warns
98
+ * once and falls back rather than failing boot — a typo shouldn't take
99
+ * transcription offline hard.
70
100
  */
71
101
  export function resolveTranscriptionProviderName(
72
102
  env: NodeJS.ProcessEnv = process.env,
73
103
  logger: { warn?: (...args: unknown[]) => void } = console,
104
+ deps: ProviderResolveDeps = {},
74
105
  ): TranscriptionProviderName {
75
106
  const raw = env.TRANSCRIPTION_PROVIDER?.trim();
76
- if (!raw) return "scribe-http";
107
+ if (!raw) return defaultProviderName(env, deps);
77
108
  if ((TRANSCRIPTION_PROVIDERS as readonly string[]).includes(raw)) {
78
109
  return raw as TranscriptionProviderName;
79
110
  }
111
+ const fallback = defaultProviderName(env, deps);
80
112
  logger.warn?.(
81
- `[transcribe] unknown TRANSCRIPTION_PROVIDER="${raw}" — falling back to scribe-http. ` +
113
+ `[transcribe] unknown TRANSCRIPTION_PROVIDER="${raw}" — falling back to ${fallback}. ` +
82
114
  `Valid values: ${TRANSCRIPTION_PROVIDERS.join(", ")}.`,
83
115
  );
84
- return "scribe-http";
116
+ return fallback;
85
117
  }
86
118
 
87
119
  /** The ecosystem root (shared with `config.ts`'s `configDirPath`), per-call so
@@ -44,12 +44,13 @@ beforeEach(() => {
44
44
  process.env.PATH = "";
45
45
  delete process.env.WHISPER_CPP_BIN_DIR;
46
46
  delete process.env.TRANSCRIPTION_PROVIDER;
47
+ delete process.env.SCRIBE_URL;
47
48
  delete process.env.TRANSCRIPTION_MODEL;
48
49
  });
49
50
 
50
51
  afterEach(() => {
51
52
  rmSync(home, { recursive: true, force: true });
52
- for (const k of ["PARACHUTE_HOME", "PATH", "WHISPER_CPP_BIN_DIR", "TRANSCRIPTION_PROVIDER", "TRANSCRIPTION_MODEL"]) {
53
+ for (const k of ["PARACHUTE_HOME", "PATH", "WHISPER_CPP_BIN_DIR", "TRANSCRIPTION_PROVIDER", "TRANSCRIPTION_MODEL", "SCRIBE_URL"]) {
53
54
  if (ORIG[k] === undefined) delete process.env[k];
54
55
  else process.env[k] = ORIG[k];
55
56
  }
@@ -68,11 +69,33 @@ function installFfmpeg() {
68
69
  present.add(join(home, "ff", "ffmpeg"));
69
70
  }
70
71
 
71
- describe("snapshot — the default (stale) provider", () => {
72
- test("scribe-http with no backend reports NOT ready and says why", () => {
73
- // The fresh-install state: nothing configured, so the provider resolves to
74
- // scribe-http and there is no scribe. This is the case that used to be
75
- // invisible.
72
+ describe("snapshot — the default provider on a fresh box", () => {
73
+ test("nothing configured whisper-cpp, and it reports what's missing", () => {
74
+ // The fresh-install state. This used to resolve to `scribe-http` with no
75
+ // scribe anywhere a provider that could never run, which is how audio was
76
+ // accepted and silently never transcribed. The default is now the local
77
+ // provider, so "not ready" is a list of things `transcription install`
78
+ // fixes rather than a dead end.
79
+ const s = buildTranscriptionSnapshot(deps(false));
80
+ expect(s.provider).toBe("whisper-cpp");
81
+ expect(s.ready).toBe(false);
82
+ expect(s.reason).toMatch(/parakeet-cli|model file|ffmpeg/);
83
+ expect(s.fix_command).toBe("parachute-vault transcription install");
84
+ });
85
+
86
+ test("a box with a reachable scribe still resolves to scribe-http", () => {
87
+ // The flip's safety property, asserted at the snapshot level: an operator
88
+ // running scribe today configured it by NOT setting TRANSCRIPTION_PROVIDER,
89
+ // so the flip must not move them off it.
90
+ process.env.SCRIBE_URL = "http://127.0.0.1:1943";
91
+ const s = buildTranscriptionSnapshot(deps(true));
92
+ expect(s.provider).toBe("scribe-http");
93
+ // ...and with a worker live, it reports as working rather than as broken.
94
+ expect(s.ready).toBe(true);
95
+ });
96
+
97
+ test("an explicitly-pinned scribe-http with nothing behind it still says why", () => {
98
+ process.env.TRANSCRIPTION_PROVIDER = "scribe-http";
76
99
  const s = buildTranscriptionSnapshot(deps(false));
77
100
  expect(s.provider).toBe("scribe-http");
78
101
  expect(s.ready).toBe(false);
@@ -0,0 +1,221 @@
1
+ /**
2
+ * `transcription status` — that it reports the ACTIVE provider honestly.
3
+ *
4
+ * Found live: a box with a working whisper-cpp install (binary, model, and
5
+ * ffmpeg all present, transcribing fine) was told
6
+ *
7
+ * ⚠ provider is whisper-cpp but no runnable whisper-cpp install was found —
8
+ * transcription is offline until one is available
9
+ *
10
+ * The command predates whisper-cpp (vault#635) and never grew a branch for it,
11
+ * so the `activeRunnable` disjunction simply omitted it and could only ever
12
+ * evaluate false. A status command that reports working software as broken is
13
+ * worse than no status command — it sends someone to re-install a 400 MB model
14
+ * to fix nothing.
15
+ *
16
+ * The fix shares `buildTranscriptionSnapshot` with the admin SPA's page, so
17
+ * these tests pin the property that actually prevents recurrence: the CLI and
18
+ * the UI answer "is transcription working" from ONE implementation.
19
+ */
20
+
21
+ import { describe, expect, test, afterEach } from "bun:test";
22
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, chmodSync } from "fs";
23
+ import { tmpdir } from "os";
24
+ import { join, resolve } from "path";
25
+ import { buildTranscriptionSnapshot } from "./transcription-routes.ts";
26
+ import { TRANSCRIPTION_PROVIDERS } from "./transcription/select.ts";
27
+
28
+ describe("every provider the CLI can resolve is one status knows about", () => {
29
+ test("the provider list has no member without a readiness story", () => {
30
+ // The regression in one assertion: whisper-cpp was resolvable as the active
31
+ // provider while nothing computed its readiness. Any future provider added
32
+ // to TRANSCRIPTION_PROVIDERS has to be handled too.
33
+ const handled = new Set([
34
+ "scribe-http",
35
+ "whisper-cpp",
36
+ "transcribe-cpp",
37
+ "parakeet-mlx",
38
+ "onnx-asr",
39
+ ]);
40
+ for (const p of TRANSCRIPTION_PROVIDERS) {
41
+ expect(handled.has(p)).toBe(true);
42
+ }
43
+ });
44
+ });
45
+
46
+ describe("the snapshot is the single source of truth", () => {
47
+ test("readiness comes back as a decidable boolean with a reason when false", () => {
48
+ const snap = buildTranscriptionSnapshot({
49
+ active: false,
50
+ resolveBinaryImpl: () => undefined,
51
+ resolveFfmpegImpl: () => undefined,
52
+ existsImpl: () => false,
53
+ });
54
+ expect(typeof snap.ready).toBe("boolean");
55
+ expect(snap.ready).toBe(false);
56
+ // A false readiness must always carry something actionable, or the CLI has
57
+ // nothing honest to print.
58
+ expect(snap.reason).toBeTruthy();
59
+ expect(snap.fix_command).toBeTruthy();
60
+ });
61
+
62
+ test("a fully-present whisper-cpp install reports ready, with paths to print", () => {
63
+ const prev = process.env.TRANSCRIPTION_PROVIDER;
64
+ process.env.TRANSCRIPTION_PROVIDER = "whisper-cpp";
65
+ try {
66
+ const snap = buildTranscriptionSnapshot({
67
+ active: true,
68
+ resolveBinaryImpl: () => "/opt/homebrew/bin/parakeet-cli",
69
+ resolveFfmpegImpl: () => "/opt/homebrew/bin/ffmpeg",
70
+ existsImpl: () => true,
71
+ });
72
+ expect(snap.provider).toBe("whisper-cpp");
73
+ expect(snap.ready).toBe(true);
74
+ expect(snap.reason).toBeNull();
75
+ // These are exactly what the CLI prints — a ready snapshot must carry
76
+ // them or the output degrades to "yes" with no evidence.
77
+ expect(snap.binary.path).toBe("/opt/homebrew/bin/parakeet-cli");
78
+ expect(snap.ffmpeg.path).toBe("/opt/homebrew/bin/ffmpeg");
79
+ expect(snap.model?.installed).toBe(true);
80
+ } finally {
81
+ if (prev === undefined) delete process.env.TRANSCRIPTION_PROVIDER;
82
+ else process.env.TRANSCRIPTION_PROVIDER = prev;
83
+ }
84
+ });
85
+
86
+ test("a missing binary still reports WHERE it looked — the launchd trap", () => {
87
+ // What makes "NOT FOUND" actionable on macOS, where the binary is often
88
+ // installed but invisible to a launchd-supervised vault.
89
+ const snap = buildTranscriptionSnapshot({
90
+ active: false,
91
+ resolveBinaryImpl: () => undefined,
92
+ resolveFfmpegImpl: () => "/usr/bin/ffmpeg",
93
+ existsImpl: () => true,
94
+ });
95
+ expect(snap.binary.path).toBeNull();
96
+ expect(snap.binary.searched.length).toBeGreaterThan(0);
97
+ });
98
+ });
99
+
100
+ describe("what `transcription status` must not claim", () => {
101
+ test("readiness never depends on the in-process worker registry", () => {
102
+ // A one-shot CLI process has no transcription worker — `active` is
103
+ // structurally false there. Keying the headline off it would print
104
+ // "ready, but the worker isn't running yet" on every healthy box, which is
105
+ // the same class of lie the command was just fixed for.
106
+ const installed = {
107
+ resolveBinaryImpl: () => "/opt/homebrew/bin/parakeet-cli",
108
+ resolveFfmpegImpl: () => "/opt/homebrew/bin/ffmpeg",
109
+ existsImpl: () => true,
110
+ };
111
+ const prev = process.env.TRANSCRIPTION_PROVIDER;
112
+ process.env.TRANSCRIPTION_PROVIDER = "whisper-cpp";
113
+ try {
114
+ // `ready` is identical whether or not a worker happens to be live; only
115
+ // `active` differs. The CLI reports the former.
116
+ const withWorker = buildTranscriptionSnapshot({ ...installed, active: true });
117
+ const without = buildTranscriptionSnapshot({ ...installed, active: false });
118
+ expect(withWorker.ready).toBe(true);
119
+ expect(without.ready).toBe(true);
120
+ expect(without.active).toBe(false);
121
+ } finally {
122
+ if (prev === undefined) delete process.env.TRANSCRIPTION_PROVIDER;
123
+ else process.env.TRANSCRIPTION_PROVIDER = prev;
124
+ }
125
+ });
126
+ });
127
+
128
+ /**
129
+ * The command must report the CONFIG FILE, not its own process environment.
130
+ *
131
+ * Found live (UniOps, 2026-08-02): a box whose `.env` said `whisper-cpp` was
132
+ * told `scribe-http` — the retired service — while `status` was the very tool
133
+ * being used to diagnose why transcription was dead. Same class as the bug at
134
+ * the top of this file: the daemon loads `~/.parachute/vault/.env` at boot
135
+ * (`server.ts` → `loadEnvFile()`), a one-shot CLI process never did, and every
136
+ * resolver underneath reads `process.env`.
137
+ *
138
+ * These spawn the real CLI because that is where the defect lived — the
139
+ * resolvers were always correct when handed the right env; nothing but a
140
+ * process boundary reproduces it. No in-test `Bun.serve` is involved, so
141
+ * `Bun.spawnSync` is fine here (CLAUDE.md, "Subprocess tests + Bun.serve").
142
+ *
143
+ * Each case sets a value that DIFFERS from the fallback. A test using a value
144
+ * the fallback happens to produce would pass without the fix — which is
145
+ * precisely how this shipped: on an unconfigured box the default agrees with
146
+ * the file, so the command looked right until someone changed something.
147
+ */
148
+ describe("`transcription status` reads ~/.parachute/vault/.env", () => {
149
+ const CLI = resolve(import.meta.dir, "cli.ts");
150
+ const homes: string[] = [];
151
+
152
+ afterEach(() => {
153
+ for (const h of homes.splice(0)) rmSync(h, { recursive: true, force: true });
154
+ });
155
+
156
+ /** A temp PARACHUTE_HOME whose `vault/.env` holds exactly these lines. */
157
+ function homeWithEnv(lines: string[]): string {
158
+ const home = mkdtempSync(join(tmpdir(), "pv-transcription-env-"));
159
+ homes.push(home);
160
+ mkdirSync(join(home, "vault"), { recursive: true });
161
+ writeFileSync(join(home, "vault", ".env"), `${lines.join("\n")}\n`);
162
+ return home;
163
+ }
164
+
165
+ /** Run `transcription status` with the file's values ONLY on disk. */
166
+ function status(home: string): string {
167
+ // Strip the inherited values so the child cannot pass by reading the
168
+ // parent's environment — the file is the only source in play.
169
+ const env: Record<string, string | undefined> = { ...process.env, PARACHUTE_HOME: home };
170
+ for (const k of [
171
+ "TRANSCRIPTION_PROVIDER",
172
+ "TRANSCRIPTION_MODEL",
173
+ "WHISPER_CPP_BIN_DIR",
174
+ "SCRIBE_URL",
175
+ "PARACHUTE_HUB_ORIGIN",
176
+ ]) {
177
+ delete env[k];
178
+ }
179
+ const proc = Bun.spawnSync({
180
+ cmd: ["bun", CLI, "transcription", "status"],
181
+ stdout: "pipe",
182
+ stderr: "pipe",
183
+ env,
184
+ });
185
+ return new TextDecoder().decode(proc.stdout);
186
+ }
187
+
188
+ test("the provider comes from the file, not the fallback default", () => {
189
+ // `scribe-http` is never the fallback on a box with no scribe in
190
+ // services.json — the default there is whisper-cpp. So this asserts the
191
+ // file was read rather than that two paths coincided.
192
+ const out = status(homeWithEnv(["TRANSCRIPTION_PROVIDER=scribe-http", "SCRIBE_URL=http://127.0.0.1:1943"]));
193
+ expect(out).toContain("scribe-http");
194
+ expect(out).not.toContain("(whisper-cpp)");
195
+ });
196
+
197
+ test("the model comes from the file", () => {
198
+ const out = status(homeWithEnv(["TRANSCRIPTION_PROVIDER=whisper-cpp", "TRANSCRIPTION_MODEL=whisper-tiny.en"]));
199
+ expect(out).toContain("Whisper Tiny (English)");
200
+ expect(out).not.toContain("Parakeet TDT 0.6b v3");
201
+ });
202
+
203
+ test("a binary-dir override in the file is honored — the false 'not found'", () => {
204
+ // The sharpest symptom: an installed, working binary reported missing
205
+ // because the `.env` override naming its directory never reached the
206
+ // resolver. This is the `runnable: no` line UniOps flagged as suspect.
207
+ const home = homeWithEnv([]);
208
+ const bin = join(home, "fakebin");
209
+ mkdirSync(bin, { recursive: true });
210
+ const exe = join(bin, "parakeet-cli");
211
+ writeFileSync(exe, "#!/bin/sh\nexit 0\n");
212
+ chmodSync(exe, 0o755);
213
+ writeFileSync(
214
+ join(home, "vault", ".env"),
215
+ `TRANSCRIPTION_PROVIDER=whisper-cpp\nWHISPER_CPP_BIN_DIR=${bin}\n`,
216
+ );
217
+ const out = status(home);
218
+ expect(out).toContain(exe);
219
+ expect(out).not.toContain("parakeet-cli not found");
220
+ });
221
+ });
package/src/vault.test.ts CHANGED
@@ -2901,6 +2901,63 @@ describe("HTTP /notes", async () => {
2901
2901
  expect((note!.metadata as any)?.transcribe_stub).toBeUndefined();
2902
2902
  });
2903
2903
 
2904
+ // The explicit opt-out. `transcribe: false` is a caller saying no, and it
2905
+ // used to be indistinguishable from saying nothing — `body.transcribe` was
2906
+ // read once, as `=== true`. So a user who turned the capture's transcribe
2907
+ // toggle OFF had their decision answered by the auto-transcribe guess,
2908
+ // which exists for callers who expressed no preference.
2909
+ //
2910
+ // Contrast with the test directly above: same request, same suite, same
2911
+ // absent provider — absent gets `failed`, `false` gets nothing at all.
2912
+ test("transcribe: false is honoured — no transcription, and NO failure marker", async () => {
2913
+ await store.createNote("note body", { id: "v2c" });
2914
+ const res = await handleNotes(
2915
+ mkReq("POST", "/notes/v2c/attachments", {
2916
+ path: "memos/memo-optout.webm",
2917
+ mimeType: "audio/webm",
2918
+ transcribe: false,
2919
+ }),
2920
+ store,
2921
+ "/v2c/attachments",
2922
+ );
2923
+ expect(res.status).toBe(201);
2924
+ const att = await res.json() as any;
2925
+ // Not enqueued...
2926
+ expect(att.metadata?.transcribe_status).toBeUndefined();
2927
+ // ...and NOT recorded as a misconfiguration either. Nothing failed; the
2928
+ // caller asked for nothing to happen and nothing happened.
2929
+ expect(att.metadata?.transcribe_error).toBeUndefined();
2930
+ expect(att.metadata?.transcribe_origin).toBeUndefined();
2931
+ // The note is untouched — no stub to fill, because no transcript is coming.
2932
+ const note = await store.getNote("v2c");
2933
+ expect((note!.metadata as any)?.transcribe_stub).toBeUndefined();
2934
+ });
2935
+
2936
+ test("false and absent are no longer the same request", async () => {
2937
+ // The regression this pair exists to prevent, stated as one assertion:
2938
+ // if these two ever produce equal metadata again, the opt-out has been
2939
+ // collapsed back into "no opinion".
2940
+ await store.createNote("body", { id: "v2d" });
2941
+ await store.createNote("body", { id: "v2e" });
2942
+ const mk = async (id: string, extra: Record<string, unknown>) => {
2943
+ const r = await handleNotes(
2944
+ mkReq("POST", `/notes/${id}/attachments`, {
2945
+ path: `memos/${id}.webm`,
2946
+ mimeType: "audio/webm",
2947
+ ...extra,
2948
+ }),
2949
+ store,
2950
+ `/${id}/attachments`,
2951
+ );
2952
+ return ((await r.json()) as any).metadata ?? {};
2953
+ };
2954
+ const absent = await mk("v2d", {});
2955
+ const optedOut = await mk("v2e", { transcribe: false });
2956
+ expect(absent.transcribe_status).toBe("failed");
2957
+ expect(optedOut.transcribe_status).toBeUndefined();
2958
+ expect(absent).not.toEqual(optedOut);
2959
+ });
2960
+
2904
2961
  test("NON-audio with no flag still leaves metadata completely empty", async () => {
2905
2962
  await store.createNote("note body", { id: "v2b" });
2906
2963
  const res = await handleNotes(
@@ -7541,6 +7598,23 @@ describe("manage-token MCP tool (vault#403, MGT — hub-JWT attenuation proxy)",
7541
7598
  closeAllStores();
7542
7599
  });
7543
7600
 
7601
+ test("long_lived=true permits a 30-day TTL", async () => {
7602
+ installHubStub();
7603
+ const { vaultName, auth } = await setupAdminSession("mint-long-lived");
7604
+ const { closeAllStores } = await import("./vault-store.ts");
7605
+ const { parsed } = await callTool(vaultName, auth, "manage-token", {
7606
+ action: "mint",
7607
+ scope: "vault:read",
7608
+ ttl_seconds: 2592000,
7609
+ long_lived: true,
7610
+ });
7611
+ expect(parsed.action).toBe("mint");
7612
+ expect(parsed.error).toBeUndefined();
7613
+ const mint = hubCalls.find((c) => c.url.endsWith("/api/auth/mint-token"));
7614
+ expect(mint!.body.expires_in).toBe(2592000);
7615
+ closeAllStores();
7616
+ });
7617
+
7544
7618
  test("tag-scoped caller's mint includes permissions.scoped_tags", async () => {
7545
7619
  installHubStub();
7546
7620
  const { vaultName, auth } = await setupAdminSession("mint-scoped", ["task", "project"]);
@@ -7568,6 +7642,52 @@ describe("manage-token MCP tool (vault#403, MGT — hub-JWT attenuation proxy)",
7568
7642
  const { closeAllStores } = await import("./vault-store.ts");
7569
7643
  const { parsed } = await callTool(vaultName, auth, "manage-token", { action: "mint", scope: "vault:read", ttl_seconds: 3601 });
7570
7644
  expect(parsed.error).toBe("invalid_request");
7645
+ expect(parsed.message).toBe("manage-token mint: ttl_seconds must be in (0, 3600]; got 3601.");
7646
+ expect(hubCalls.length).toBe(0);
7647
+ closeAllStores();
7648
+ });
7649
+
7650
+ test("long_lived=true rejects TTL above the 90-day cap locally", async () => {
7651
+ installHubStub();
7652
+ const { vaultName, auth } = await setupAdminSession("mint-long-over");
7653
+ const { closeAllStores } = await import("./vault-store.ts");
7654
+ const { parsed } = await callTool(vaultName, auth, "manage-token", {
7655
+ action: "mint",
7656
+ scope: "vault:read",
7657
+ ttl_seconds: 7776001,
7658
+ long_lived: true,
7659
+ });
7660
+ expect(parsed.error).toBe("invalid_request");
7661
+ expect(parsed.message).toContain("7776000");
7662
+ expect(hubCalls.length).toBe(0);
7663
+ closeAllStores();
7664
+ });
7665
+
7666
+ test("long_lived=false keeps the 1-hour cap", async () => {
7667
+ installHubStub();
7668
+ const { vaultName, auth } = await setupAdminSession("mint-long-false");
7669
+ const { closeAllStores } = await import("./vault-store.ts");
7670
+ const { parsed } = await callTool(vaultName, auth, "manage-token", {
7671
+ action: "mint",
7672
+ scope: "vault:read",
7673
+ ttl_seconds: 7200,
7674
+ long_lived: false,
7675
+ });
7676
+ expect(parsed.error).toBe("invalid_request");
7677
+ expect(hubCalls.length).toBe(0);
7678
+ closeAllStores();
7679
+ });
7680
+
7681
+ test("omitted long_lived keeps the 1-hour cap", async () => {
7682
+ installHubStub();
7683
+ const { vaultName, auth } = await setupAdminSession("mint-long-omitted");
7684
+ const { closeAllStores } = await import("./vault-store.ts");
7685
+ const { parsed } = await callTool(vaultName, auth, "manage-token", {
7686
+ action: "mint",
7687
+ scope: "vault:read",
7688
+ ttl_seconds: 7200,
7689
+ });
7690
+ expect(parsed.error).toBe("invalid_request");
7571
7691
  expect(hubCalls.length).toBe(0);
7572
7692
  closeAllStores();
7573
7693
  });