@indigoai-us/hq-cli 5.74.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.
- package/dist/commands/mcp-registration.d.ts +4 -5
- package/dist/commands/mcp-registration.js +5 -4
- package/dist/commands/outposts.d.ts +23 -5
- package/dist/commands/outposts.js +207 -14
- package/dist/commands/pack-install.d.ts +14 -17
- package/dist/commands/pack-install.js +53 -29
- package/dist/commands/pkg-install.js +3 -1
- package/dist/commands/run.d.ts +2 -0
- package/dist/commands/run.js +9 -3
- package/dist/commands/secrets.js +189 -87
- package/dist/run/hq-plugin.js +94 -31
- package/dist/utils/sandbox-runner-client.d.ts +1 -0
- package/dist/utils/sandbox-runner-client.js +1 -0
- package/dist/utils/secrets-cache.d.ts +4 -5
- package/dist/utils/secrets-cache.js +5 -8
- package/package.json +3 -2
- package/pnpm-workspace.yaml +2 -0
- package/src/commands/mcp-registration.ts +9 -9
- package/src/commands/outposts.test.ts +252 -24
- package/src/commands/outposts.ts +405 -50
- package/src/commands/pack-install-secret-authorization.test.ts +115 -0
- package/src/commands/pack-install.test.ts +5 -1
- package/src/commands/pack-install.ts +67 -29
- package/src/commands/pkg-install.ts +3 -1
- package/src/commands/run.test.ts +45 -0
- package/src/commands/run.ts +20 -4
- package/src/commands/secrets.test.ts +366 -25
- package/src/commands/secrets.ts +222 -96
- package/src/run/hq-plugin.test.ts +186 -10
- package/src/run/hq-plugin.ts +102 -32
- package/src/utils/__fixtures__/scan-packages.generated-block.sh +23 -0
- package/src/utils/pack-contributions.test.ts +90 -31
- package/src/utils/sandbox-runner-client.test.ts +28 -0
- package/src/utils/sandbox-runner-client.ts +2 -0
- package/src/utils/secrets-cache.ts +5 -8
- package/test/commands/signals.test.ts +2 -2
- package/test/commands/sources.test.ts +2 -2
- package/test/helpers/vault-service-mock.ts +76 -17
- package/test/sources-signals/smoke.test.ts +2 -2
package/src/run/hq-plugin.ts
CHANGED
|
@@ -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(
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
//
|
|
73
|
-
// `prewarmHqSecrets(graph, opts, state)`
|
|
74
|
-
//
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
(
|
|
177
|
-
|
|
178
|
-
()
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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.
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
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
|
-
|
|
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 —
|
|
2
|
+
* vault-service-mock.ts — vault-service test double.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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(
|
|
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
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
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
|
|
126
|
-
// 404s, which the reader normalizes to
|
|
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: `${
|
|
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
|
|