@indigoai-us/hq-cli 5.75.0 → 5.76.0

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.
Files changed (39) hide show
  1. package/dist/commands/mcp-registration.d.ts +4 -5
  2. package/dist/commands/mcp-registration.js +5 -4
  3. package/dist/commands/outposts.d.ts +20 -4
  4. package/dist/commands/outposts.js +77 -8
  5. package/dist/commands/pack-install.d.ts +14 -17
  6. package/dist/commands/pack-install.js +53 -29
  7. package/dist/commands/pkg-install.js +3 -1
  8. package/dist/commands/run.d.ts +2 -0
  9. package/dist/commands/run.js +9 -3
  10. package/dist/commands/secrets.js +189 -87
  11. package/dist/run/hq-plugin.js +94 -31
  12. package/dist/utils/sandbox-runner-client.d.ts +1 -0
  13. package/dist/utils/sandbox-runner-client.js +1 -0
  14. package/dist/utils/secrets-cache.d.ts +4 -5
  15. package/dist/utils/secrets-cache.js +5 -8
  16. package/package.json +3 -2
  17. package/pnpm-workspace.yaml +2 -0
  18. package/src/commands/mcp-registration.ts +9 -9
  19. package/src/commands/outposts.test.ts +118 -24
  20. package/src/commands/outposts.ts +197 -43
  21. package/src/commands/pack-install-secret-authorization.test.ts +115 -0
  22. package/src/commands/pack-install.test.ts +5 -1
  23. package/src/commands/pack-install.ts +67 -29
  24. package/src/commands/pkg-install.ts +3 -1
  25. package/src/commands/run.test.ts +45 -0
  26. package/src/commands/run.ts +20 -4
  27. package/src/commands/secrets.test.ts +366 -25
  28. package/src/commands/secrets.ts +222 -96
  29. package/src/run/hq-plugin.test.ts +186 -10
  30. package/src/run/hq-plugin.ts +102 -32
  31. package/src/utils/__fixtures__/scan-packages.generated-block.sh +23 -0
  32. package/src/utils/pack-contributions.test.ts +90 -31
  33. package/src/utils/sandbox-runner-client.test.ts +28 -0
  34. package/src/utils/sandbox-runner-client.ts +2 -0
  35. package/src/utils/secrets-cache.ts +5 -8
  36. package/test/commands/signals.test.ts +2 -2
  37. package/test/commands/sources.test.ts +2 -2
  38. package/test/helpers/vault-service-mock.ts +76 -17
  39. package/test/sources-signals/smoke.test.ts +2 -2
@@ -2,15 +2,27 @@ import { ResolutionError } from 'varlock/plugin-lib';
2
2
  import type { Resolver } from 'varlock/plugin-lib';
3
3
  import {
4
4
  DEFAULT_SECRETS_CACHE_TTL_MS,
5
- readCache,
6
5
  writeCache,
6
+ removeCacheEntry,
7
7
  } from '../utils/secrets-cache.js';
8
8
  import type { SecretLoadResponse, SecretUsage } from '../commands/secrets.js';
9
9
 
10
- function normalizeCacheTtlMs(cacheTtlMs?: number): number {
11
- return typeof cacheTtlMs === 'number'
12
- ? cacheTtlMs
13
- : DEFAULT_SECRETS_CACHE_TTL_MS;
10
+ function normalizeCacheTtlMs(secret: SecretLoadResponse['secrets'][number]): number {
11
+ if (
12
+ secret.tier === 'sensitive' ||
13
+ secret.tier === 'nuclear' ||
14
+ secret.scriptLock?.mode === 'enforced'
15
+ ) {
16
+ return 0;
17
+ }
18
+ if (secret.cacheTtlMs === undefined) {
19
+ return DEFAULT_SECRETS_CACHE_TTL_MS;
20
+ }
21
+ return typeof secret.cacheTtlMs === 'number' &&
22
+ Number.isFinite(secret.cacheTtlMs) &&
23
+ secret.cacheTtlMs > 0
24
+ ? secret.cacheTtlMs
25
+ : 0;
14
26
  }
15
27
 
16
28
  export interface InstallHqPluginOpts {
@@ -69,9 +81,9 @@ export function installHqPlugin(graph: any /* EnvGraph */, opts: InstallHqPlugin
69
81
  impliesSensitive: true,
70
82
  argsSchema: { type: 'array' as const, arrayMaxLength: 1 },
71
83
  resolve: async function (this: HqResolver) {
72
- // Cache-only read. `pluginState` is captured by this inner-class closure;
73
- // `prewarmHqSecrets(graph, opts, state)` populates `state.uid` and
74
- // `state.errorsByName` before `graph.resolveEnvValues()` calls us.
84
+ // `pluginState` is captured by this inner-class closure;
85
+ // `prewarmHqSecrets(graph, opts, state)` server-authorizes and populates
86
+ // the in-memory values before `graph.resolveEnvValues()` calls us.
75
87
  const explicit = this.arrArgs?.[0]?.staticValue;
76
88
  const secretName = (typeof explicit === 'string' && explicit) ? explicit : this._ownerKey;
77
89
  if (!secretName) {
@@ -94,11 +106,6 @@ export function installHqPlugin(graph: any /* EnvGraph */, opts: InstallHqPlugin
94
106
  }
95
107
  throw new ResolutionError(`Failed to load secret "${secretName}": ${err.message ?? err.code}`);
96
108
  }
97
- // Sentinel-check style throughout: `readCache` returns `string | null`
98
- // (verified at `hq/packages/hq-cli/src/utils/secrets-cache.ts:45`); `pluginState.uid`
99
- // is `string | null` per `PluginState`. Use `== null` (covers null AND undefined defensively)
100
- // for both — do not mix in truthy checks like `if (!x)`, which would silently swallow a
101
- // legitimate empty-string value if the contract ever loosened.
102
109
  if (pluginState.uid == null) {
103
110
  throw new ResolutionError('Internal error: prewarmHqSecrets was not called before resolveEnvValues');
104
111
  }
@@ -106,11 +113,9 @@ export function installHqPlugin(graph: any /* EnvGraph */, opts: InstallHqPlugin
106
113
  if (inMemory != null) {
107
114
  return inMemory;
108
115
  }
109
- const cached = readCache(pluginState.uid, secretName); // string | null
110
- if (cached == null) {
111
- throw new ResolutionError(`Internal error: pre-warm did not populate cache for "${secretName}"`);
112
- }
113
- return cached;
116
+ throw new ResolutionError(
117
+ `Secret "${secretName}" was not returned by vault after server authorization`,
118
+ );
114
119
  },
115
120
  };
116
121
 
@@ -186,22 +191,87 @@ export async function prewarmHqSecrets(
186
191
  `hq run supports at most 100 hq() resolvers per schema; got ${uniqueNames.length}`,
187
192
  );
188
193
  }
189
- const result = await opts.fetchBatch(uid, uniqueNames, opts.usage);
194
+ state.loadedSecretsByName.clear();
195
+ state.errorsByName = new Map();
196
+
197
+ let result: SecretLoadResponse;
198
+ try {
199
+ result = await opts.fetchBatch(uid, uniqueNames, opts.usage);
200
+ if (!Array.isArray(result.secrets) || !Array.isArray(result.errors)) {
201
+ throw new Error('Invalid secret load response from vault');
202
+ }
190
203
 
191
- for (const s of result.secrets) {
192
- if (s.value == null) {
193
- continue;
204
+ const errorsByName = new Map<string, { code: string; message?: string }>();
205
+ const returnedNames = new Set<string>();
206
+ const requestedNames = new Set(uniqueNames);
207
+ const seenNames = new Set<string>();
208
+ for (const rawSecret of result.secrets) {
209
+ if (!rawSecret || typeof rawSecret !== 'object') {
210
+ throw new Error('Invalid secret load response from vault');
211
+ }
212
+ const s = rawSecret as SecretLoadResponse['secrets'][number];
213
+ if (
214
+ typeof s.name !== 'string' ||
215
+ !requestedNames.has(s.name) ||
216
+ seenNames.has(s.name) ||
217
+ (s.value != null && typeof s.value !== 'string')
218
+ ) {
219
+ throw new Error('Invalid secret load response from vault');
220
+ }
221
+ seenNames.add(s.name);
222
+ if (s.value == null) {
223
+ errorsByName.set(s.name, {
224
+ code: 'not_returned',
225
+ message: 'not returned by vault after server authorization',
226
+ });
227
+ removeCacheEntry(uid, s.name);
228
+ continue;
229
+ }
230
+ returnedNames.add(s.name);
231
+ state.loadedSecretsByName.set(s.name, s.value);
232
+ const cacheTtlMs = normalizeCacheTtlMs(s);
233
+ if (cacheTtlMs > 0) {
234
+ writeCache(uid, s.name, s.value, cacheTtlMs);
235
+ } else {
236
+ removeCacheEntry(uid, s.name);
237
+ }
238
+ }
239
+ for (const rawError of result.errors) {
240
+ if (!rawError || typeof rawError !== 'object') {
241
+ throw new Error('Invalid secret load response from vault');
242
+ }
243
+ const e = rawError as { name?: unknown; code?: unknown; message?: unknown };
244
+ if (
245
+ typeof e.name !== 'string' ||
246
+ !requestedNames.has(e.name) ||
247
+ seenNames.has(e.name) ||
248
+ typeof e.code !== 'string' ||
249
+ (e.message !== undefined && typeof e.message !== 'string')
250
+ ) {
251
+ throw new Error('Invalid secret load response from vault');
252
+ }
253
+ seenNames.add(e.name);
254
+ errorsByName.set(e.name, { code: e.code, message: e.message });
255
+ state.loadedSecretsByName.delete(e.name);
256
+ removeCacheEntry(uid, e.name);
257
+ }
258
+ for (const name of uniqueNames) {
259
+ if (!returnedNames.has(name) && !errorsByName.has(name)) {
260
+ errorsByName.set(name, {
261
+ code: 'not_returned',
262
+ message: 'not returned by vault after server authorization',
263
+ });
264
+ removeCacheEntry(uid, name);
265
+ }
194
266
  }
195
- state.loadedSecretsByName.set(s.name, s.value);
196
- const cacheTtlMs = normalizeCacheTtlMs(s.cacheTtlMs);
197
- if (cacheTtlMs > 0) {
198
- writeCache(uid, s.name, s.value, cacheTtlMs);
267
+ state.errorsByName = errorsByName;
268
+ state.uid = uid;
269
+ } catch (err) {
270
+ state.loadedSecretsByName.clear();
271
+ state.errorsByName = new Map();
272
+ for (const name of uniqueNames) {
273
+ removeCacheEntry(uid, name);
199
274
  }
275
+ throw err;
200
276
  }
201
- const errorsByName = new Map<string, { code: string; message?: string }>();
202
- for (const e of result.errors) {
203
- errorsByName.set(e.name, { code: e.code, message: e.message });
204
- }
205
- state.errorsByName = errorsByName;
206
- state.uid = uid;
207
277
  }
@@ -0,0 +1,23 @@
1
+ # >>> BEGIN GENERATED contribution table (US-003) — do not edit by hand
2
+ # Generated from hq-cli/src/utils/contribution-table.ts by
3
+ # hq-cli/scripts/generate-scan-packages-table.mjs. Regenerate with:
4
+ # node scripts/generate-scan-packages-table.mjs --write core/scripts/scan-packages.sh
5
+ # Each row is "payload|host|wire" (payload uses {item} for the name).
6
+ # bash 3.2-compatible (macOS default bash has no associative arrays):
7
+ # a plain indexed key list + a generated case-lookup function.
8
+ CONTRIB_KEYS=(workers knowledge skills commands hooks policies scripts mcp)
9
+ # contrib_row <key> -> echoes "payload|host|wire", empty if unknown.
10
+ contrib_row() {
11
+ case "$1" in
12
+ workers) printf '%s' 'workers/{item}|core/workers/public|symlink' ;;
13
+ knowledge) printf '%s' 'knowledge/{item}|core/knowledge/public|symlink' ;;
14
+ skills) printf '%s' 'skills/{item}|.claude/skills|symlink' ;;
15
+ commands) printf '%s' 'commands/{item}.md|.claude/commands|symlink' ;;
16
+ hooks) printf '%s' 'hooks/{item}.sh|.claude/hooks|symlink' ;;
17
+ policies) printf '%s' 'policies/{item}.md|core/policies|symlink' ;;
18
+ scripts) printf '%s' 'scripts/{item}|core/scripts|symlink' ;;
19
+ mcp) printf '%s' 'mcp/{item}.json|merge:claude+codex|merge' ;;
20
+ *) return 0 ;;
21
+ esac
22
+ }
23
+ # <<< END GENERATED contribution table
@@ -12,6 +12,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
12
12
  import * as fs from 'fs';
13
13
  import * as os from 'os';
14
14
  import * as path from 'path';
15
+ import { fileURLToPath } from 'node:url';
15
16
  import {
16
17
  contributionLinks,
17
18
  linkStatus,
@@ -102,12 +103,28 @@ describe('contributionLinks: mapping', () => {
102
103
  // the WRONG payload suffix, or the WRONG wire mode.
103
104
  // ---------------------------------------------------------------------------
104
105
  describe('US-003 parity: contribution table is the single source', () => {
105
- // Resolve the HQ root so we can read the GENERATED scan-packages.sh block.
106
- const scanCandidates = [
107
- path.resolve(process.cwd(), '../../../core/scripts/scan-packages.sh'),
108
- path.resolve(process.cwd(), '../../../../core/scripts/scan-packages.sh'),
109
- ];
110
- const scanPath = scanCandidates.find((p) => fs.existsSync(p));
106
+ // The bash block that `core/scripts/scan-packages.sh` reads is GENERATED from
107
+ // the contribution table (scripts/generate-scan-packages-table.mjs) and lives
108
+ // in hq-core — a separately-versioned repo this package never contains. We
109
+ // vendor that generated block as a committed golden fixture and compare the
110
+ // table against it, so this guard runs DETERMINISTICALLY in CI and from any
111
+ // checkout. Regenerate after any table change: `pnpm gen:scan-golden`
112
+ // (hq-core's own scan-packages.sh must be regenerated from the same table —
113
+ // that cross-repo leg is enforced in the monorepo that holds both).
114
+ //
115
+ // Anchored to THIS module via import.meta.url — never process.cwd().
116
+ // REGRESSION (do NOT reintroduce): the old resolver did
117
+ // path.resolve(process.cwd(), '../../../core/scripts/scan-packages.sh')
118
+ // which, from a checkout sitting 3 levels below an installed HQ (e.g.
119
+ // HQ/workspace/worktrees/<name>), bound to that unrelated, differently-
120
+ // versioned hq-core script and false-failed; from anywhere else it resolved
121
+ // outside the repo and silently skipped, so the guard NEVER ran — not even in
122
+ // CI. See the module-anchor regression test below.
123
+ const goldenBlockPath = path.resolve(
124
+ path.dirname(fileURLToPath(import.meta.url)),
125
+ '__fixtures__',
126
+ 'scan-packages.generated-block.sh',
127
+ );
111
128
 
112
129
  // Parse the generated `contrib_row` case arms back into {key:{payload,host,wire}}.
113
130
  function parseBashTable(bash: string): Record<
@@ -173,31 +190,73 @@ describe('US-003 parity: contribution table is the single source', () => {
173
190
  expect(suffix(CONTRIBUTION_TABLE.scripts.payload)).toBe('');
174
191
  });
175
192
 
176
- (scanPath ? it : it.skip)(
177
- 'scan-packages.sh generated block matches the table on key-set + payload + host + wire',
178
- () => {
179
- const bash = fs.readFileSync(scanPath as string, 'utf-8');
180
- const bashTable = parseBashTable(bash);
181
-
182
- // FULL key-set equivalence (fails if a surface is MISSING a key or has an
183
- // EXTRA one not just a substring presence check).
184
- expect(Object.keys(bashTable).sort()).toEqual([...CONTRIBUTION_KEYS].sort());
185
-
186
- // Per-key payload + host + wire-mode equivalence.
187
- for (const k of CONTRIBUTION_KEYS) {
188
- expect(bashTable[k], `scan-packages.sh missing row for "${k}"`).toBeDefined();
189
- expect(bashTable[k].payload).toBe(CONTRIBUTION_TABLE[k].payload);
190
- expect(bashTable[k].host).toBe(CONTRIBUTION_TABLE[k].host);
191
- expect(bashTable[k].wire).toBe(CONTRIBUTION_TABLE[k].wire);
192
- }
193
-
194
- // The CONTRIB_KEYS array in the script also lists every key.
195
- const keysLine = bash.match(/CONTRIB_KEYS=\(([^)]*)\)/);
196
- expect(keysLine).not.toBeNull();
197
- const bashKeys = (keysLine as RegExpMatchArray)[1].trim().split(/\s+/).sort();
198
- expect(bashKeys).toEqual([...CONTRIBUTION_KEYS].sort());
199
- },
200
- );
193
+ it('generated block (vendored golden) matches the table on key-set + payload + host + wire', () => {
194
+ const bash = fs.readFileSync(goldenBlockPath, 'utf-8');
195
+ const bashTable = parseBashTable(bash);
196
+
197
+ // FULL key-set equivalence (fails if the golden is MISSING a key or has an
198
+ // EXTRA one — not just a substring presence check). This doubles as the
199
+ // drift guard: if the table changed but the golden wasn't regenerated, one
200
+ // of these assertions fails with the exact key/field that diverged.
201
+ expect(Object.keys(bashTable).sort()).toEqual([...CONTRIBUTION_KEYS].sort());
202
+
203
+ // Per-key payload + host + wire-mode equivalence.
204
+ for (const k of CONTRIBUTION_KEYS) {
205
+ expect(
206
+ bashTable[k],
207
+ `golden block missing row for "${k}" — run \`pnpm gen:scan-golden\``,
208
+ ).toBeDefined();
209
+ expect(bashTable[k].payload).toBe(CONTRIBUTION_TABLE[k].payload);
210
+ expect(bashTable[k].host).toBe(CONTRIBUTION_TABLE[k].host);
211
+ expect(bashTable[k].wire).toBe(CONTRIBUTION_TABLE[k].wire);
212
+ }
213
+
214
+ // The CONTRIB_KEYS array in the block also lists every key.
215
+ const keysLine = bash.match(/CONTRIB_KEYS=\(([^)]*)\)/);
216
+ expect(keysLine).not.toBeNull();
217
+ const bashKeys = (keysLine as RegExpMatchArray)[1].trim().split(/\s+/).sort();
218
+ expect(bashKeys).toEqual([...CONTRIBUTION_KEYS].sort());
219
+ });
220
+
221
+ it('resolves the golden from the module, never from process.cwd() (regression: a co-located HQ must not bind)', () => {
222
+ // Rebuild the exact historical trap: an installed-HQ-shaped dir carrying a
223
+ // DIFFERENT scan-packages.sh, with a checkout nested 3 levels below it
224
+ // (the HQ/workspace/worktrees/<name> layout that first surfaced this bug).
225
+ const fakeHq = mkTmp('fake-hq-');
226
+ fs.mkdirSync(path.join(fakeHq, 'core', 'scripts'), { recursive: true });
227
+ fs.writeFileSync(
228
+ path.join(fakeHq, 'core', 'scripts', 'scan-packages.sh'),
229
+ '# unrelated, differently-versioned hq-core script\n',
230
+ );
231
+ const nested = path.join(fakeHq, 'workspace', 'worktrees', 'hq-cli-x');
232
+ fs.mkdirSync(nested, { recursive: true });
233
+
234
+ const orig = process.cwd();
235
+ try {
236
+ process.chdir(nested);
237
+ // Module-anchored resolution is unaffected by cwd and points at the repo
238
+ // golden — which really exists and is what the parity test reads.
239
+ expect(fs.existsSync(goldenBlockPath)).toBe(true);
240
+ expect(
241
+ goldenBlockPath.endsWith(
242
+ path.join('src', 'utils', '__fixtures__', 'scan-packages.generated-block.sh'),
243
+ ),
244
+ ).toBe(true);
245
+ // The OLD cwd-relative resolver WOULD have latched onto the fake file.
246
+ // Prove the trap is real, and that we no longer resolve to it.
247
+ const oldCwdRelative = path.resolve(
248
+ process.cwd(),
249
+ '../../../core/scripts/scan-packages.sh',
250
+ );
251
+ expect(oldCwdRelative).toBe(
252
+ path.join(fakeHq, 'core', 'scripts', 'scan-packages.sh'),
253
+ );
254
+ expect(oldCwdRelative).not.toBe(goldenBlockPath);
255
+ } finally {
256
+ process.chdir(orig);
257
+ fs.rmSync(fakeHq, { recursive: true, force: true });
258
+ }
259
+ });
201
260
 
202
261
  it('validateManifest payload reader (payloadFor) agrees with the table for every key', () => {
203
262
  for (const k of CONTRIBUTION_KEYS) {
@@ -225,12 +225,39 @@ describe("SandboxRunnerClient", () => {
225
225
  ).resolves.toMatchObject({
226
226
  jobId: "job_1",
227
227
  status: "failed",
228
+ error: undefined,
228
229
  output: "boom\n",
229
230
  exitCode: 2,
230
231
  success: false,
231
232
  });
232
233
  });
233
234
 
235
+ // The wire shape the server actually sends when it could not RUN the command:
236
+ // a reason, and no exit code, because nothing ever exited.
237
+ it("surfaces the reason on a job the sandbox could not execute", async () => {
238
+ const fetchImpl = vi.fn<typeof fetch>(async () =>
239
+ jsonRes({
240
+ jobId: "job_1",
241
+ status: "failed",
242
+ error: "Sandbox max-exec exceeded (28000ms)",
243
+ }),
244
+ );
245
+ const client = new SandboxRunnerClient({
246
+ baseUrl: "https://runner.example",
247
+ fetchImpl,
248
+ });
249
+
250
+ await expect(
251
+ client.pollJob("jwt-token", "job_1", { intervalMs: 0 }),
252
+ ).resolves.toMatchObject({
253
+ jobId: "job_1",
254
+ status: "failed",
255
+ error: "Sandbox max-exec exceeded (28000ms)",
256
+ exitCode: undefined,
257
+ output: undefined,
258
+ });
259
+ });
260
+
234
261
  it("uses the requested job id when the live status response omits it", async () => {
235
262
  const fetchImpl = vi.fn<typeof fetch>(async () =>
236
263
  jsonRes({ status: "succeeded", output: "ok\n" }),
@@ -244,6 +271,7 @@ describe("SandboxRunnerClient", () => {
244
271
  jobId: "job_live",
245
272
  status: "succeeded",
246
273
  output: "ok\n",
274
+ error: undefined,
247
275
  exitCode: undefined,
248
276
  success: undefined,
249
277
  });
@@ -15,6 +15,7 @@ export interface SandboxRunnerJob {
15
15
  jobId: string;
16
16
  status: SandboxRunnerState;
17
17
  output?: string;
18
+ error?: string;
18
19
  exitCode?: number;
19
20
  success?: boolean;
20
21
  }
@@ -88,6 +89,7 @@ function normalizeJob(
88
89
  : jobIdFallback ?? requireString(body, "jobId"),
89
90
  status,
90
91
  output: typeof body.output === "string" ? body.output : undefined,
92
+ error: typeof body.error === "string" ? body.error : undefined,
91
93
  exitCode: typeof body.exitCode === "number" ? body.exitCode : undefined,
92
94
  success: typeof body.success === "boolean" ? body.success : undefined,
93
95
  };
@@ -144,11 +144,10 @@ export function writeCache(
144
144
 
145
145
  /**
146
146
  * List the scope UIDs (`cmp_*` / `prs_*` subdirectories) that currently have a
147
- * secrets-cache directory on disk. Used by offline callers (e.g. install-time MCP
148
- * registration) that have no `--company` flag and no network token and so cannot
149
- * resolve a single active company UID up front: they instead probe every cached
150
- * scope for a given secret name. Returns `[]` when the cache root is absent or
151
- * unreadable (the desired graceful-deferral behavior — no scopes, no hits).
147
+ * secrets-cache directory on disk. Install-time MCP registration may use an
148
+ * exactly-one result as a scope hint before reauthorizing every value online; it
149
+ * never reads cached plaintext through this helper. Returns `[]` when the cache
150
+ * root is absent or unreadable.
152
151
  */
153
152
  export function listSecretCacheScopes(): string[] {
154
153
  try {
@@ -156,9 +155,7 @@ export function listSecretCacheScopes(): string[] {
156
155
  .readdirSync(CACHE_DIR, { withFileTypes: true })
157
156
  .filter((e) => e.isDirectory())
158
157
  .map((e) => e.name)
159
- // Only real entity scopes (cmp_*/prs_*); validateInputs in readCache also
160
- // rejects anything with `/` or `..`, so this is belt-and-suspenders.
161
- .filter((name) => !name.startsWith("."));
158
+ .filter((name) => /^(?:cmp|prs)_[A-Za-z0-9_-]+$/.test(name));
162
159
  } catch {
163
160
  return [];
164
161
  }
@@ -55,7 +55,7 @@ let restoreFetch: (() => void) | undefined;
55
55
  let tmpHqRoot: string;
56
56
  let savedEnv: { HQ_ACCESS_TOKEN: string | undefined };
57
57
 
58
- beforeEach(() => {
58
+ beforeEach(async () => {
59
59
  savedEnv = { HQ_ACCESS_TOKEN: process.env.HQ_ACCESS_TOKEN };
60
60
  process.env.HQ_ACCESS_TOKEN = "test-access-token";
61
61
 
@@ -73,7 +73,7 @@ beforeEach(() => {
73
73
 
74
74
  // `files` serves the fixture over the presigned-URL transport (the path
75
75
  // company reads take).
76
- restoreFetch = mockVaultService({
76
+ restoreFetch = await mockVaultService({
77
77
  entities: [{ uid: "cmp_indigo_001", slug: "indigo", bucketName: "hq-indigo-bucket" }],
78
78
  files: [
79
79
  {
@@ -62,7 +62,7 @@ let restoreFetch: (() => void) | undefined;
62
62
  let tmpHqRoot: string;
63
63
  let savedEnv: { HQ_ACCESS_TOKEN: string | undefined };
64
64
 
65
- beforeEach(() => {
65
+ beforeEach(async () => {
66
66
  // Sidestep Cognito interactive flow.
67
67
  savedEnv = { HQ_ACCESS_TOKEN: process.env.HQ_ACCESS_TOKEN };
68
68
  process.env.HQ_ACCESS_TOKEN = "test-access-token";
@@ -83,7 +83,7 @@ beforeEach(() => {
83
83
 
84
84
  // Default vault-service mock with one entity 'indigo'. `files` serves the
85
85
  // fixture over the presigned-URL transport (the path company reads take).
86
- restoreFetch = mockVaultService({
86
+ restoreFetch = await mockVaultService({
87
87
  entities: [{ uid: "cmp_indigo_001", slug: "indigo", bucketName: "hq-indigo-bucket" }],
88
88
  files: [
89
89
  {
@@ -1,17 +1,32 @@
1
1
  /**
2
- * vault-service-mock.ts — fetch stub for vault-service HTTP endpoints.
2
+ * vault-service-mock.ts — vault-service test double.
3
3
  *
4
- * Intercepts globalThis.fetch and handles the subset of vault-service routes
5
- * needed by entity-resolver and STS tests. Returns the original fetch restore
6
- * function so callers can clean up in afterEach/finally.
4
+ * Two collaborating pieces:
5
+ * 1. A `globalThis.fetch` stub for the vault-service JSON API (membership,
6
+ * entity lookup, STS vend, files list/presign).
7
+ * 2. A real ephemeral localhost HTTP server that serves the presigned-object
8
+ * GETs. As of hq-cloud >=6.14.x, `PresignObjectIO` fetches presigned URLs
9
+ * through Node's core HTTP client (`nodePresignedGet`), NOT
10
+ * `globalThis.fetch`, to bypass the runtime-bundled undici parser — so a
11
+ * fetch stub alone never intercepts them and the request escapes to real
12
+ * DNS (`ENOTFOUND`). A real (loopback) server is transport-agnostic: it
13
+ * works for both the fetch and node-http code paths, and future ones.
7
14
  *
8
- * Handled endpoints (URL path suffixes):
15
+ * `mockVaultService` is async (it awaits the server's `listen`) and returns a
16
+ * restore function for `afterEach` that both un-installs the fetch stub and
17
+ * closes the server.
18
+ *
19
+ * Handled JSON endpoints (URL path suffixes):
9
20
  * GET .../membership/me returns { memberships: [...] }
10
21
  * GET .../entity/by-slug/{type}/{slug} returns { entity: {...} }
11
22
  * GET .../entity/{uid} returns { entity: {...} } (direct UID lookup)
12
- * POST .../sts/vend returns { credentials, expiresAt } (canonical company STS endpoint)
23
+ * GET .../v1/files/list returns { objects, cursor, truncated }
24
+ * POST .../v1/files/presign returns { results, expiresAt } (URLs point at the local server)
25
+ * POST .../sts/vend returns { credentials, expiresAt }
13
26
  */
14
27
 
28
+ import http from "node:http";
29
+ import type { AddressInfo } from "node:net";
15
30
  import type { Membership, EntityInfo } from "@indigoai-us/hq-cloud";
16
31
 
17
32
  export interface MockEntity {
@@ -56,8 +71,6 @@ export interface MockVaultOptions {
56
71
  files?: MockVaultFile[];
57
72
  }
58
73
 
59
- /** Host for mock presigned URLs minted by POST /v1/files/presign. */
60
- const PRESIGN_URL_HOST = "https://presigned.test";
61
74
  const DEFAULT_FILE_DATE = new Date("2026-01-01T00:00:00Z");
62
75
 
63
76
  const DEFAULT_STS: MockStsCredentials = {
@@ -67,14 +80,56 @@ const DEFAULT_STS: MockStsCredentials = {
67
80
  };
68
81
 
69
82
  /**
70
- * Installs a fetch stub for vault-service endpoints.
83
+ * Start a loopback HTTP server that serves presigned-object GETs from the
84
+ * in-memory `files`. Each minted presign URL is `${origin}/obj?key=<key>`;
85
+ * the handler resolves the file (200 + body) or 404s (→ NoSuchKey in the
86
+ * reader). Returns the server + its `http://127.0.0.1:<port>` origin.
87
+ */
88
+ async function startPresignedFileServer(
89
+ files: MockVaultFile[],
90
+ ): Promise<{ server: http.Server; origin: string }> {
91
+ const server = http.createServer((req, res) => {
92
+ const requestUrl = new URL(req.url ?? "/", "http://127.0.0.1");
93
+ const key = requestUrl.searchParams.get("key") ?? "";
94
+ const file = files.find((f) => f.key === key);
95
+ if (!file) {
96
+ res.statusCode = 404;
97
+ res.setHeader("content-length", "0");
98
+ res.end();
99
+ return;
100
+ }
101
+ const body = Buffer.from(file.content, "utf-8");
102
+ res.writeHead(200, {
103
+ "content-type": "text/markdown",
104
+ "content-length": String(body.byteLength),
105
+ "last-modified": (file.lastModified ?? DEFAULT_FILE_DATE).toUTCString(),
106
+ etag: '"mock-etag"',
107
+ });
108
+ res.end(body);
109
+ });
110
+ await new Promise<void>((resolve) => {
111
+ server.listen(0, "127.0.0.1", resolve);
112
+ });
113
+ // Don't keep the event loop (or vitest) alive on this listener.
114
+ server.unref();
115
+ const { port } = server.address() as AddressInfo;
116
+ return { server, origin: `http://127.0.0.1:${port}` };
117
+ }
118
+
119
+ /**
120
+ * Installs the vault-service test double (fetch stub + presigned-file server).
71
121
  *
72
- * @returns A restore function — call it in afterEach to un-install the stub.
122
+ * @returns A restore function — call it in afterEach to un-install the stub and
123
+ * close the server.
73
124
  */
74
- export function mockVaultService(opts: MockVaultOptions): () => void {
125
+ export async function mockVaultService(
126
+ opts: MockVaultOptions,
127
+ ): Promise<() => void> {
75
128
  const { entities, stsCredentials = DEFAULT_STS, files = [] } = opts;
76
129
  const originalFetch = globalThis.fetch as typeof fetch | undefined;
77
130
 
131
+ const { server, origin: presignOrigin } = await startPresignedFileServer(files);
132
+
78
133
  globalThis.fetch = async (
79
134
  input: RequestInfo | URL,
80
135
  init?: RequestInit,
@@ -89,9 +144,10 @@ export function mockVaultService(opts: MockVaultOptions): () => void {
89
144
  (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
90
145
 
91
146
  // ── Presigned-URL transport (HQ-59 company reads) ──────────────────────
92
- // A GET against a URL minted by POST /v1/files/presign below. Serve the
93
- // file body (or 404) so PresignObjectIO.getObject resolves like real S3.
94
- if (url.startsWith(`${PRESIGN_URL_HOST}/`)) {
147
+ // Production fetches presigned URLs over node http (served by the loopback
148
+ // server above), but keep a fetch branch too so any fetch-based transport
149
+ // resolves identically without escaping to the network.
150
+ if (url.startsWith(`${presignOrigin}/`)) {
95
151
  const key = new URL(url).searchParams.get("key") ?? "";
96
152
  const file = files.find((f) => f.key === key);
97
153
  if (!file) return new Response("", { status: 404 });
@@ -122,8 +178,9 @@ export function mockVaultService(opts: MockVaultOptions): () => void {
122
178
  }
123
179
 
124
180
  // POST /v1/files/presign (VaultClient.presign) — mint a per-key URL that
125
- // the GET handler above resolves. Unknown keys still get a URL → that GET
126
- // 404s, which the reader normalizes to NoSuchKey (the not-found path).
181
+ // the loopback server (and the GET branch above) resolves. Unknown keys
182
+ // still get a URL → that GET 404s, which the reader normalizes to
183
+ // NoSuchKey (the not-found path).
127
184
  if (method === "POST" && /\/v1\/files\/presign$/.test(url)) {
128
185
  const body = init?.body ? JSON.parse(init.body.toString()) : {};
129
186
  const keys = (body.keys ?? []) as Array<{ key: string; op?: string }>;
@@ -134,7 +191,7 @@ export function mockVaultService(opts: MockVaultOptions): () => void {
134
191
  const results = keys.map((k) => ({
135
192
  key: k.key,
136
193
  op: k.op ?? "get",
137
- url: `${PRESIGN_URL_HOST}/obj?key=${encodeURIComponent(k.key)}`,
194
+ url: `${presignOrigin}/obj?key=${encodeURIComponent(k.key)}`,
138
195
  }));
139
196
  return json({
140
197
  results,
@@ -225,6 +282,8 @@ export function mockVaultService(opts: MockVaultOptions): () => void {
225
282
  } else {
226
283
  delete (globalThis as Record<string, unknown>).fetch;
227
284
  }
285
+ server.closeAllConnections?.();
286
+ server.close();
228
287
  };
229
288
  }
230
289
 
@@ -176,7 +176,7 @@ describe("mockVaultService — membership endpoint", () => {
176
176
  });
177
177
 
178
178
  it("GET /membership/me returns memberships for all configured entities", async () => {
179
- restore = mockVaultService({
179
+ restore = await mockVaultService({
180
180
  entities: [
181
181
  { uid: "cmp_indigo_001", slug: "indigo", bucketName: "hq-indigo-bucket" },
182
182
  { uid: "prs_personal_001", slug: "personal", bucketName: "hq-personal-bucket" },
@@ -194,7 +194,7 @@ describe("mockVaultService — membership endpoint", () => {
194
194
  });
195
195
 
196
196
  it("POST /sts/vend returns credentials", async () => {
197
- restore = mockVaultService({
197
+ restore = await mockVaultService({
198
198
  entities: [{ uid: "cmp_indigo_001", slug: "indigo", bucketName: "hq-indigo-bucket" }],
199
199
  });
200
200