@indigoai-us/hq-cli 5.75.0 → 5.77.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/agents.js +8 -3
- package/dist/commands/files.d.ts +61 -0
- package/dist/commands/files.js +274 -0
- package/dist/commands/mcp-registration.d.ts +4 -5
- package/dist/commands/mcp-registration.js +5 -4
- package/dist/commands/outposts.d.ts +20 -4
- package/dist/commands/outposts.js +79 -10
- 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/billing-gate.d.ts +15 -0
- package/dist/utils/billing-gate.js +35 -0
- 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/agents.test.ts +41 -0
- package/src/commands/agents.ts +7 -3
- package/src/commands/files-recovery.test.ts +361 -0
- package/src/commands/files.ts +410 -0
- package/src/commands/mcp-registration.ts +9 -9
- package/src/commands/outposts.test.ts +155 -24
- package/src/commands/outposts.ts +199 -45
- 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/billing-gate.ts +46 -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
|
|
@@ -180,3 +180,49 @@ export async function surfaceBillingRequired(
|
|
|
180
180
|
console.log(chalk.dim("Once a card is added, re-run the same command."));
|
|
181
181
|
return url;
|
|
182
182
|
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Status-aware surface for a 402 billing block. hq-pro's envelope carries two
|
|
186
|
+
* distinct remediations that must never be conflated (mirroring the server's
|
|
187
|
+
* own P1-C classification):
|
|
188
|
+
* - `payment_failed` — a card EXISTS and the charge was DECLINED. The
|
|
189
|
+
* server's `message` already carries the friendly decline copy ("Your
|
|
190
|
+
* card was declined…", "insufficient funds", …). Telling this user
|
|
191
|
+
* "No card on file" sends them down the wrong remediation path entirely
|
|
192
|
+
* (observed live 2026-07-20: a declined $80 Outpost proration surfaced
|
|
193
|
+
* as "no card", triggering a hunt for a missing card that existed).
|
|
194
|
+
* The capture link still surfaces — as the way to UPDATE the card.
|
|
195
|
+
* - anything else (`billing_required`) — genuinely no usable card on
|
|
196
|
+
* file; the existing add-a-card copy is correct.
|
|
197
|
+
*/
|
|
198
|
+
export async function surfaceBillingBlocked(
|
|
199
|
+
token: string,
|
|
200
|
+
billing: BillingErrorPayload,
|
|
201
|
+
serverMessage?: string,
|
|
202
|
+
): Promise<string | null> {
|
|
203
|
+
if (billing.status !== "payment_failed") {
|
|
204
|
+
return surfaceBillingRequired(token, billing);
|
|
205
|
+
}
|
|
206
|
+
console.error(
|
|
207
|
+
chalk.yellow(
|
|
208
|
+
serverMessage?.trim() ||
|
|
209
|
+
"Your payment failed. Try a different card or contact your bank.",
|
|
210
|
+
),
|
|
211
|
+
);
|
|
212
|
+
if (!billing.setup) return null;
|
|
213
|
+
try {
|
|
214
|
+
const url = await mintPaymentLink(token, billing.setup);
|
|
215
|
+
console.log(
|
|
216
|
+
"Update or replace the card here (safe to share with whoever owns billing):\n " +
|
|
217
|
+
chalk.cyan(url),
|
|
218
|
+
);
|
|
219
|
+
console.log(
|
|
220
|
+
chalk.dim("Once the payment method is sorted, re-run the same command."),
|
|
221
|
+
);
|
|
222
|
+
return url;
|
|
223
|
+
} catch {
|
|
224
|
+
// Link minting is best-effort on the decline path — the decline reason
|
|
225
|
+
// above is the essential part; the console billing page also works.
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -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
|
{
|