@indigoai-us/hq-cli 5.47.15 → 5.47.17
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/.github/workflows/publish.yml +2 -2
- package/dist/commands/feedback.d.ts +2 -0
- package/dist/commands/feedback.js +24 -2
- package/dist/commands/files.d.ts +19 -0
- package/dist/commands/files.js +37 -3
- package/dist/commands/people.d.ts +22 -0
- package/dist/commands/people.js +187 -0
- package/dist/commands/secrets.js +25 -2
- package/dist/index.js +6 -2
- package/dist/run/hq-plugin.js +9 -2
- package/dist/sentry-dsn.generated.d.ts +1 -1
- package/dist/sentry-dsn.generated.js +1 -1
- package/dist/utils/feedback-diagnostics.d.ts +7 -0
- package/dist/utils/feedback-diagnostics.js +4 -2
- package/dist/utils/feedback-screenshots.d.ts +23 -0
- package/dist/utils/feedback-screenshots.js +98 -0
- package/dist/utils/feedback-versions.d.ts +34 -0
- package/dist/utils/feedback-versions.js +50 -0
- package/dist/utils/people.d.ts +99 -0
- package/dist/utils/people.js +168 -0
- package/package.json +1 -1
- package/src/commands/feedback.test.ts +44 -0
- package/src/commands/feedback.ts +46 -13
- package/src/commands/files-delete.test.ts +132 -0
- package/src/commands/files.ts +42 -1
- package/src/commands/people.test.ts +426 -0
- package/src/commands/people.ts +240 -0
- package/src/commands/secrets.test.ts +80 -0
- package/src/commands/secrets.ts +35 -0
- package/src/index.ts +4 -0
- package/src/run/hq-plugin.test.ts +39 -0
- package/src/run/hq-plugin.ts +7 -0
- package/src/utils/feedback-diagnostics.test.ts +11 -0
- package/src/utils/feedback-diagnostics.ts +8 -0
- package/src/utils/feedback-screenshots.test.ts +134 -0
- package/src/utils/feedback-screenshots.ts +124 -0
- package/src/utils/feedback-versions.test.ts +98 -0
- package/src/utils/feedback-versions.ts +68 -0
- package/src/utils/people.ts +222 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq people` — read a company's people (membership) records from the local HQ
|
|
3
|
+
* tree and search/resolve over them.
|
|
4
|
+
*
|
|
5
|
+
* hq people list [--company <slug>] [--json]
|
|
6
|
+
* hq people search <keyword> [--company <slug>] [--json]
|
|
7
|
+
* hq people resolve <name> [--company <slug>] [--json]
|
|
8
|
+
*
|
|
9
|
+
* Source of truth is `companies/<company>/people/<person>/meta.yaml`. Every
|
|
10
|
+
* subcommand operates on exactly ONE company (the active company, or the one
|
|
11
|
+
* named by `--company`); nothing reads across company boundaries.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from "fs";
|
|
15
|
+
import { Command, Option } from "commander";
|
|
16
|
+
import chalk from "chalk";
|
|
17
|
+
import * as yaml from "js-yaml";
|
|
18
|
+
import { findHqRoot } from "../utils/manifest.js";
|
|
19
|
+
import { manifestPath, type ManifestDoc } from "./cloud-provision.js";
|
|
20
|
+
import {
|
|
21
|
+
assertSafeCompanySlug,
|
|
22
|
+
listCompanyPeople,
|
|
23
|
+
searchPeople,
|
|
24
|
+
resolveNameToEmail,
|
|
25
|
+
companyPeopleDir,
|
|
26
|
+
type PersonRecord,
|
|
27
|
+
} from "../utils/people.js";
|
|
28
|
+
|
|
29
|
+
interface PeopleScopeOpts {
|
|
30
|
+
company?: string;
|
|
31
|
+
hqRoot?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Companies that still exist (anything not explicitly `status: archived`). */
|
|
35
|
+
function activeCompanySlugs(manifest: ManifestDoc): string[] {
|
|
36
|
+
const companies = manifest.companies ?? {};
|
|
37
|
+
return Object.entries(companies)
|
|
38
|
+
.filter(([, entry]) => (entry?.status ?? "active") !== "archived")
|
|
39
|
+
.map(([slug]) => slug)
|
|
40
|
+
.sort();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the single company to operate on. Explicit `--company` always wins
|
|
45
|
+
* (after a path-safety check). Otherwise the active company is inferred from
|
|
46
|
+
* `companies/manifest.yaml`: if exactly one company exists it's used; if several
|
|
47
|
+
* do, the caller must disambiguate with `--company`.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveCompanySlug(
|
|
50
|
+
hqRoot: string,
|
|
51
|
+
explicit: string | undefined,
|
|
52
|
+
): string {
|
|
53
|
+
if (explicit) {
|
|
54
|
+
assertSafeCompanySlug(explicit);
|
|
55
|
+
return explicit;
|
|
56
|
+
}
|
|
57
|
+
const mPath = manifestPath(hqRoot);
|
|
58
|
+
if (!fs.existsSync(mPath)) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
"Could not determine the active company — companies/manifest.yaml not found. " +
|
|
61
|
+
"Re-run with --company <slug>.",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
let manifest: ManifestDoc;
|
|
65
|
+
try {
|
|
66
|
+
manifest = yaml.load(fs.readFileSync(mPath, "utf-8")) as ManifestDoc;
|
|
67
|
+
} catch (err) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`companies/manifest.yaml is malformed: ${err instanceof Error ? err.message : String(err)}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
const slugs = activeCompanySlugs(manifest ?? {});
|
|
73
|
+
if (slugs.length === 1) return slugs[0];
|
|
74
|
+
if (slugs.length === 0) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
"No companies found in companies/manifest.yaml — re-run with --company <slug>.",
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
throw new Error(
|
|
80
|
+
"Multiple companies found — re-run with --company <slug> to pick one:\n" +
|
|
81
|
+
slugs.map((s) => ` --company ${s}`).join("\n"),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Resolve the HQ root: explicit override (tests) → cwd walk-up. */
|
|
86
|
+
function resolveHqRoot(opts: PeopleScopeOpts): string {
|
|
87
|
+
return opts.hqRoot ?? findHqRoot();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function printPeopleTable(people: PersonRecord[]): void {
|
|
91
|
+
const nameW = Math.max(4, ...people.map((p) => p.name.length));
|
|
92
|
+
const emailW = Math.max(5, ...people.map((p) => (p.email ?? "—").length));
|
|
93
|
+
const roleW = Math.max(4, ...people.map((p) => (p.role ?? "—").length));
|
|
94
|
+
console.log(
|
|
95
|
+
chalk.bold(
|
|
96
|
+
[
|
|
97
|
+
"NAME".padEnd(nameW),
|
|
98
|
+
"EMAIL".padEnd(emailW),
|
|
99
|
+
"ROLE".padEnd(roleW),
|
|
100
|
+
"TYPE",
|
|
101
|
+
].join(" "),
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
for (const p of people) {
|
|
105
|
+
console.log(
|
|
106
|
+
[
|
|
107
|
+
p.name.padEnd(nameW),
|
|
108
|
+
(p.email ?? "—").padEnd(emailW),
|
|
109
|
+
(p.role ?? "—").padEnd(roleW),
|
|
110
|
+
p.type ?? "—",
|
|
111
|
+
].join(" "),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function fail(message: string): never {
|
|
117
|
+
console.error(chalk.red(message));
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function registerPeopleCommand(program: Command): void {
|
|
122
|
+
const people = program
|
|
123
|
+
.command("people")
|
|
124
|
+
.description(
|
|
125
|
+
"List, search, and resolve a company's people (from companies/<co>/people)",
|
|
126
|
+
)
|
|
127
|
+
.option(
|
|
128
|
+
"--company <slug>",
|
|
129
|
+
"Company slug to scope to (defaults to the active company)",
|
|
130
|
+
)
|
|
131
|
+
// Hidden escape hatch for tests / non-standard layouts — point the reader at
|
|
132
|
+
// an explicit HQ tree root instead of walking up from cwd.
|
|
133
|
+
.addOption(
|
|
134
|
+
new Option(
|
|
135
|
+
"--hq-root <path>",
|
|
136
|
+
"Override the HQ tree root (advanced)",
|
|
137
|
+
).hideHelp(),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
people
|
|
141
|
+
.command("list")
|
|
142
|
+
.description("List all people recorded for the company")
|
|
143
|
+
.option("--json", "Output JSON instead of a table")
|
|
144
|
+
.action((opts: { json?: boolean }) => {
|
|
145
|
+
try {
|
|
146
|
+
const scope = people.opts() as PeopleScopeOpts;
|
|
147
|
+
const hqRoot = resolveHqRoot(scope);
|
|
148
|
+
const slug = resolveCompanySlug(hqRoot, scope.company);
|
|
149
|
+
const records = listCompanyPeople(hqRoot, slug);
|
|
150
|
+
|
|
151
|
+
if (opts.json) {
|
|
152
|
+
console.log(JSON.stringify(records, null, 2));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (records.length === 0) {
|
|
156
|
+
console.log(
|
|
157
|
+
chalk.gray(
|
|
158
|
+
`No people recorded for '${slug}' (looked in ${companyPeopleDir(hqRoot, slug)}).`,
|
|
159
|
+
),
|
|
160
|
+
);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
printPeopleTable(records);
|
|
164
|
+
} catch (err) {
|
|
165
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
people
|
|
170
|
+
.command("search <keyword>")
|
|
171
|
+
.description("Keyword search over people names and emails")
|
|
172
|
+
.option("--json", "Output JSON instead of a table")
|
|
173
|
+
.action((keyword: string, opts: { json?: boolean }) => {
|
|
174
|
+
try {
|
|
175
|
+
const scope = people.opts() as PeopleScopeOpts;
|
|
176
|
+
const hqRoot = resolveHqRoot(scope);
|
|
177
|
+
const slug = resolveCompanySlug(hqRoot, scope.company);
|
|
178
|
+
const matches = searchPeople(listCompanyPeople(hqRoot, slug), keyword);
|
|
179
|
+
|
|
180
|
+
if (opts.json) {
|
|
181
|
+
console.log(JSON.stringify(matches, null, 2));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (matches.length === 0) {
|
|
185
|
+
console.log(chalk.gray(`No people in '${slug}' match "${keyword}".`));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
printPeopleTable(matches);
|
|
189
|
+
} catch (err) {
|
|
190
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
people
|
|
195
|
+
.command("resolve <name>")
|
|
196
|
+
.description("Resolve a person name to their email address")
|
|
197
|
+
.option("--json", "Output JSON instead of plain text")
|
|
198
|
+
.action((name: string, opts: { json?: boolean }) => {
|
|
199
|
+
try {
|
|
200
|
+
const scope = people.opts() as PeopleScopeOpts;
|
|
201
|
+
const hqRoot = resolveHqRoot(scope);
|
|
202
|
+
const slug = resolveCompanySlug(hqRoot, scope.company);
|
|
203
|
+
const result = resolveNameToEmail(listCompanyPeople(hqRoot, slug), name);
|
|
204
|
+
|
|
205
|
+
if (opts.json) {
|
|
206
|
+
console.log(JSON.stringify(result, null, 2));
|
|
207
|
+
if (result.status === "found") return;
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
switch (result.status) {
|
|
212
|
+
case "found":
|
|
213
|
+
// Bare email on stdout so callers can capture it directly.
|
|
214
|
+
console.log(result.email);
|
|
215
|
+
return;
|
|
216
|
+
case "no_email":
|
|
217
|
+
fail(
|
|
218
|
+
`Found '${result.person.name}' in '${slug}' but no email is recorded for them.`,
|
|
219
|
+
);
|
|
220
|
+
break;
|
|
221
|
+
case "ambiguous":
|
|
222
|
+
console.error(
|
|
223
|
+
chalk.yellow(
|
|
224
|
+
`"${name}" matches ${result.matches.length} people in '${slug}' — be more specific:`,
|
|
225
|
+
),
|
|
226
|
+
);
|
|
227
|
+
for (const m of result.matches) {
|
|
228
|
+
console.error(` ${m.name}${m.email ? ` <${m.email}>` : ""}`);
|
|
229
|
+
}
|
|
230
|
+
process.exit(1);
|
|
231
|
+
break;
|
|
232
|
+
case "not_found":
|
|
233
|
+
fail(`No person matching "${name}" found in '${slug}'.`);
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
} catch (err) {
|
|
237
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|
|
@@ -160,6 +160,86 @@ describe("secrets exists (HQ-4H HEAD probe)", () => {
|
|
|
160
160
|
});
|
|
161
161
|
});
|
|
162
162
|
|
|
163
|
+
// US-003 (secrets-server-proxy): the CLI surfaces the SERVER's refusal of a
|
|
164
|
+
// high-security ("nuclear") secret on the local-injection path as a clear,
|
|
165
|
+
// actionable error pointing at the proxy — and never prints the value. The
|
|
166
|
+
// server-side deny is the real control (it returns 403 + highSecurity:true and
|
|
167
|
+
// NO plaintext); these tests assert the CLI's surfacing behavior.
|
|
168
|
+
describe("US-003 — CLI refuses high-security secrets on local injection", () => {
|
|
169
|
+
// The server's 403 refusal shape for a high-security secret.
|
|
170
|
+
function highSecurityDenied(): Response {
|
|
171
|
+
return new Response(
|
|
172
|
+
JSON.stringify({
|
|
173
|
+
error:
|
|
174
|
+
"Secret 'ANTHROPIC_API_KEY' is high-security and cannot be retrieved via local injection. Use the server-side proxy.",
|
|
175
|
+
highSecurity: true,
|
|
176
|
+
}),
|
|
177
|
+
{ status: 403, headers: { "Content-Type": "application/json" } },
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let exitSpy: MockInstance<typeof process.exit>;
|
|
182
|
+
beforeEach(() => {
|
|
183
|
+
exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
|
|
184
|
+
throw new Error("__exit__");
|
|
185
|
+
}) as never);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("E2E: `secrets get --reveal` is denied — clear proxy-pointing error, no value printed", async () => {
|
|
189
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(highSecurityDenied());
|
|
190
|
+
|
|
191
|
+
const program = buildProgram();
|
|
192
|
+
try {
|
|
193
|
+
await program.parseAsync([
|
|
194
|
+
"node",
|
|
195
|
+
"hq",
|
|
196
|
+
"secrets",
|
|
197
|
+
"get",
|
|
198
|
+
"ANTHROPIC_API_KEY",
|
|
199
|
+
"--reveal",
|
|
200
|
+
]);
|
|
201
|
+
} catch {
|
|
202
|
+
// exit sentinel
|
|
203
|
+
}
|
|
204
|
+
const exitCode = exitSpy.mock.calls[0]?.[0] as number | undefined;
|
|
205
|
+
|
|
206
|
+
expect(exitCode).toBe(1);
|
|
207
|
+
// A clear, actionable error mentioning high-security + the proxy.
|
|
208
|
+
const errText = errSpy.mock.calls.flat().join(" ");
|
|
209
|
+
expect(errText).toMatch(/high-security/i);
|
|
210
|
+
expect(errText).toMatch(/proxy/i);
|
|
211
|
+
// The value is NEVER printed — no "Value:" line carrying plaintext.
|
|
212
|
+
const logText = logSpy.mock.calls.flat().join(" ");
|
|
213
|
+
expect(logText).not.toMatch(/sk-ant/i);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("E2E: `secrets exec --only <name> -- env` is denied — proxy-pointing error, command not run", async () => {
|
|
217
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(highSecurityDenied());
|
|
218
|
+
|
|
219
|
+
const program = buildProgram();
|
|
220
|
+
try {
|
|
221
|
+
await program.parseAsync([
|
|
222
|
+
"node",
|
|
223
|
+
"hq",
|
|
224
|
+
"secrets",
|
|
225
|
+
"exec",
|
|
226
|
+
"--only",
|
|
227
|
+
"ANTHROPIC_API_KEY",
|
|
228
|
+
"--",
|
|
229
|
+
"env",
|
|
230
|
+
]);
|
|
231
|
+
} catch {
|
|
232
|
+
// exit sentinel
|
|
233
|
+
}
|
|
234
|
+
const exitCode = exitSpy.mock.calls[0]?.[0] as number | undefined;
|
|
235
|
+
|
|
236
|
+
expect(exitCode).toBe(1);
|
|
237
|
+
const errText = errSpy.mock.calls.flat().join(" ");
|
|
238
|
+
expect(errText).toMatch(/high-security/i);
|
|
239
|
+
expect(errText).toMatch(/proxy/i);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
163
243
|
describe("secrets generate-link", () => {
|
|
164
244
|
it("mints one-time submission links for personal secrets", async () => {
|
|
165
245
|
const program = buildProgram();
|
package/src/commands/secrets.ts
CHANGED
|
@@ -392,6 +392,13 @@ export async function loadRevealedSecrets(
|
|
|
392
392
|
if (!res.ok) {
|
|
393
393
|
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
|
394
394
|
const message = extractApiMessage(body, res.statusText);
|
|
395
|
+
// High-security ("nuclear") refusal surfaced at the batch level (rather
|
|
396
|
+
// than per-name): point the caller at the proxy and never leak plaintext.
|
|
397
|
+
if (body.code === "high_security_denied" || body.highSecurity === true) {
|
|
398
|
+
throw new Error(
|
|
399
|
+
"A requested secret is high-security and cannot be injected locally — it can only be used through the HQ secret proxy, which keeps the plaintext server-side.",
|
|
400
|
+
);
|
|
401
|
+
}
|
|
395
402
|
if (
|
|
396
403
|
res.status >= 400 &&
|
|
397
404
|
res.status < 500 &&
|
|
@@ -428,6 +435,17 @@ export async function loadRevealedSecrets(
|
|
|
428
435
|
for (const key of chunk) {
|
|
429
436
|
if (resolved.has(key)) continue;
|
|
430
437
|
const err = errorsByName.get(key);
|
|
438
|
+
// High-security ("nuclear") secret: the server refuses to vend it on the
|
|
439
|
+
// local-injection (batch-load) path — per-name code `high_security_denied`,
|
|
440
|
+
// no plaintext returned. Every caller of loadRevealedSecrets injects or
|
|
441
|
+
// prints the plaintext locally (`secrets get --reveal`, `secrets exec`,
|
|
442
|
+
// `secrets env`), so a high-security secret can NEVER be used here. Surface
|
|
443
|
+
// a clear, actionable error pointing at the proxy instead of a raw failure.
|
|
444
|
+
if (err?.code === "high_security_denied") {
|
|
445
|
+
throw new Error(
|
|
446
|
+
`Secret '${key}' is high-security and cannot be injected locally — it can only be used through the HQ secret proxy, which keeps the plaintext server-side.`,
|
|
447
|
+
);
|
|
448
|
+
}
|
|
431
449
|
const reason =
|
|
432
450
|
err?.code === "not_found"
|
|
433
451
|
? "Secret not found"
|
|
@@ -538,6 +556,23 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
538
556
|
|
|
539
557
|
if (!res.ok) {
|
|
540
558
|
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
|
559
|
+
// High-security ("nuclear") secret: the server refuses to reveal it on
|
|
560
|
+
// the local-injection path (403, no plaintext). Surface a clear,
|
|
561
|
+
// actionable error pointing the user at the proxy rather than a raw
|
|
562
|
+
// 4xx — the value can ONLY be used through the server-side proxy.
|
|
563
|
+
if (res.status === 403 && body.highSecurity === true) {
|
|
564
|
+
console.error(
|
|
565
|
+
chalk.red(
|
|
566
|
+
`Secret '${name}' is high-security and cannot be revealed locally.`,
|
|
567
|
+
),
|
|
568
|
+
);
|
|
569
|
+
console.error(
|
|
570
|
+
chalk.dim(
|
|
571
|
+
" It can only be used through the HQ secret proxy, which keeps the plaintext server-side.",
|
|
572
|
+
),
|
|
573
|
+
);
|
|
574
|
+
process.exit(1);
|
|
575
|
+
}
|
|
541
576
|
console.error(
|
|
542
577
|
chalk.red(`Failed to get secret: ${extractApiMessage(body, res.statusText)}`),
|
|
543
578
|
);
|
package/src/index.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { registerGroupGrantsCommand } from "./commands/group-grants.js";
|
|
|
36
36
|
import { registerFilesCommand } from "./commands/files.js";
|
|
37
37
|
import { registerFilesBrowseCommands } from "./commands/files-browse.js";
|
|
38
38
|
import { registerMembersCommand } from "./commands/members.js";
|
|
39
|
+
import { registerPeopleCommand } from "./commands/people.js";
|
|
39
40
|
import { registerDmCommand } from "./commands/dm.js";
|
|
40
41
|
import { registerFeedbackCommand } from "./commands/feedback.js";
|
|
41
42
|
import { registerMeetingsCommand } from "./commands/meetings.js";
|
|
@@ -166,6 +167,9 @@ registerFilesBrowseCommands(filesCmd);
|
|
|
166
167
|
|
|
167
168
|
// Membership management (subcommand group — hq members invite|list|revoke)
|
|
168
169
|
registerMembersCommand(program);
|
|
170
|
+
// People directory (subcommand group — hq people list|search|resolve), reading
|
|
171
|
+
// the local companies/<co>/people store scoped to one company.
|
|
172
|
+
registerPeopleCommand(program);
|
|
169
173
|
registerDmCommand(program);
|
|
170
174
|
|
|
171
175
|
// Onboarding (top-level — Cognito + vault-service provisioning)
|
|
@@ -153,4 +153,43 @@ describe('hq-plugin', () => {
|
|
|
153
153
|
const fooErrors = (graph as any).configSchema['FOO'].errors as Array<{ message: string }>;
|
|
154
154
|
expect(fooErrors.some((e) => e.message.includes('No read permission for secret "FOO"'))).toBe(true);
|
|
155
155
|
});
|
|
156
|
+
|
|
157
|
+
// US-003 (secrets-server-proxy): the server refuses to batch-load a
|
|
158
|
+
// high-security ("nuclear") secret (code: high_security_denied, no plaintext).
|
|
159
|
+
// `hq run` injects plaintext into the child env, so it must surface this as a
|
|
160
|
+
// clear, actionable error pointing at the proxy — and never resolve a value.
|
|
161
|
+
it('high-security-denied: nuclear secret is surfaced as a proxy-pointing ResolutionError, no value', async () => {
|
|
162
|
+
const schemaPath = path.join(tmpDir, '.env.schema');
|
|
163
|
+
fs.writeFileSync(schemaPath, `# @hqCompany("test")\n\nANTHROPIC_API_KEY=hq()\n`);
|
|
164
|
+
|
|
165
|
+
const uid = `test-uid-${Math.random().toString(36).slice(2)}`;
|
|
166
|
+
const mocks = makeMocks({
|
|
167
|
+
resolveCompanyUid: async () => uid,
|
|
168
|
+
fetchBatch: async () => ({
|
|
169
|
+
secrets: [],
|
|
170
|
+
errors: [
|
|
171
|
+
{
|
|
172
|
+
name: 'ANTHROPIC_API_KEY',
|
|
173
|
+
code: 'high_security_denied',
|
|
174
|
+
message: 'high-security; use the proxy',
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
}),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
let state!: PluginState;
|
|
181
|
+
const graph = await internal.loadEnvGraph({
|
|
182
|
+
entryFilePaths: [schemaPath],
|
|
183
|
+
afterInit: async (g) => {
|
|
184
|
+
state = installHqPlugin(g, mocks);
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
await prewarmHqSecrets(graph, mocks, state);
|
|
188
|
+
await graph.resolveEnvValues();
|
|
189
|
+
|
|
190
|
+
const errs = (graph as any).configSchema['ANTHROPIC_API_KEY'].errors as Array<{ message: string }>;
|
|
191
|
+
expect(errs.some((e) => /high-security/i.test(e.message) && /proxy/i.test(e.message))).toBe(true);
|
|
192
|
+
// No plaintext value was resolved for the nuclear secret.
|
|
193
|
+
expect((graph.getResolvedEnvObject() as Record<string, unknown>).ANTHROPIC_API_KEY).toBeUndefined();
|
|
194
|
+
});
|
|
156
195
|
});
|
package/src/run/hq-plugin.ts
CHANGED
|
@@ -85,6 +85,13 @@ export function installHqPlugin(graph: any /* EnvGraph */, opts: InstallHqPlugin
|
|
|
85
85
|
if (err.code === 'not_found') {
|
|
86
86
|
throw new ResolutionError(`Secret "${secretName}" does not exist in company`);
|
|
87
87
|
}
|
|
88
|
+
// High-security ("nuclear") secret: the server refuses to vend it on
|
|
89
|
+
// the local-injection path. It can ONLY be used through the
|
|
90
|
+
// server-side proxy, so `hq run` (which injects plaintext into the
|
|
91
|
+
// child env) can never load it. Surface a clear, actionable error.
|
|
92
|
+
if (err.code === 'high_security_denied') {
|
|
93
|
+
throw new ResolutionError(`Secret "${secretName}" is high-security and cannot be injected locally — it can only be used via the HQ secret proxy (POST /secrets/{companyUid}/proxy/{path}), which keeps the plaintext server-side. Remove it from this schema's locally-injected vars.`);
|
|
94
|
+
}
|
|
88
95
|
throw new ResolutionError(`Failed to load secret "${secretName}": ${err.message ?? err.code}`);
|
|
89
96
|
}
|
|
90
97
|
// Sentinel-check style throughout: `readCache` returns `string | null`
|
|
@@ -91,6 +91,17 @@ describe("collectDiagnostics", () => {
|
|
|
91
91
|
if (saved !== undefined) process.env.npm_package_version = saved;
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
+
it("attaches a versions block carrying the cli version (core/sync best-effort)", () => {
|
|
95
|
+
const blob = collectDiagnostics();
|
|
96
|
+
expect(blob.versions.cli).toBe(CLI_VERSION);
|
|
97
|
+
// core + sync are environment-dependent; they must be present as
|
|
98
|
+
// string | null, never undefined, so triage always gets the shape.
|
|
99
|
+
expect(blob.versions).toHaveProperty("core");
|
|
100
|
+
expect(blob.versions).toHaveProperty("sync");
|
|
101
|
+
expect(["string", "object"]).toContain(typeof blob.versions.core); // string | null
|
|
102
|
+
expect(["string", "object"]).toContain(typeof blob.versions.sync); // string | null
|
|
103
|
+
});
|
|
104
|
+
|
|
94
105
|
it("cliVersion is unaffected by npm_package_version env var", () => {
|
|
95
106
|
process.env.npm_package_version = "99.99.99";
|
|
96
107
|
const blob = collectDiagnostics();
|
|
@@ -2,6 +2,7 @@ import * as os from "os";
|
|
|
2
2
|
import { execFileSync } from "child_process";
|
|
3
3
|
import { getRecentBreadcrumbs } from "./breadcrumb-buffer.js";
|
|
4
4
|
import { CLI_VERSION } from "../cli-version.js";
|
|
5
|
+
import { collectVersions, type VersionInfo } from "./feedback-versions.js";
|
|
5
6
|
|
|
6
7
|
export interface GitContext {
|
|
7
8
|
branch: string | null;
|
|
@@ -12,6 +13,12 @@ export interface GitContext {
|
|
|
12
13
|
|
|
13
14
|
export interface DiagnosticsBlob {
|
|
14
15
|
cliVersion: string;
|
|
16
|
+
/**
|
|
17
|
+
* The hq-cli, hq-core, and hq-sync versions from the submitter's
|
|
18
|
+
* environment. `cliVersion` above is retained for back-compat; new
|
|
19
|
+
* consumers should read `versions` (which carries core + sync too).
|
|
20
|
+
*/
|
|
21
|
+
versions: VersionInfo;
|
|
15
22
|
nodeVersion: string;
|
|
16
23
|
os: { platform: string; release: string; arch: string };
|
|
17
24
|
command: string[];
|
|
@@ -101,6 +108,7 @@ function collectGitContext(): GitContext {
|
|
|
101
108
|
export function collectDiagnostics(): DiagnosticsBlob {
|
|
102
109
|
return {
|
|
103
110
|
cliVersion: CLI_VERSION,
|
|
111
|
+
versions: collectVersions(),
|
|
104
112
|
nodeVersion: process.version,
|
|
105
113
|
os: {
|
|
106
114
|
platform: os.platform(),
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
|
|
6
|
+
vi.mock("./vault-api.js", () => ({
|
|
7
|
+
vaultApiFetch: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
import { vaultApiFetch } from "./vault-api.js";
|
|
11
|
+
import {
|
|
12
|
+
MAX_SCREENSHOTS,
|
|
13
|
+
contentTypeForPath,
|
|
14
|
+
loadScreenshots,
|
|
15
|
+
uploadScreenshots,
|
|
16
|
+
} from "./feedback-screenshots.js";
|
|
17
|
+
|
|
18
|
+
let tmp: string;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "shots-"));
|
|
22
|
+
vi.mocked(vaultApiFetch).mockReset();
|
|
23
|
+
});
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function writePng(name: string, bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47])): string {
|
|
29
|
+
const p = path.join(tmp, name);
|
|
30
|
+
fs.writeFileSync(p, bytes);
|
|
31
|
+
return p;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("contentTypeForPath", () => {
|
|
35
|
+
it("maps known image extensions (case-insensitive)", () => {
|
|
36
|
+
expect(contentTypeForPath("a.png")).toBe("image/png");
|
|
37
|
+
expect(contentTypeForPath("a.JPG")).toBe("image/jpeg");
|
|
38
|
+
expect(contentTypeForPath("a.jpeg")).toBe("image/jpeg");
|
|
39
|
+
expect(contentTypeForPath("a.webp")).toBe("image/webp");
|
|
40
|
+
expect(contentTypeForPath("a.gif")).toBe("image/gif");
|
|
41
|
+
});
|
|
42
|
+
it("rejects unsupported extensions", () => {
|
|
43
|
+
expect(() => contentTypeForPath("a.pdf")).toThrow(/unsupported screenshot type/);
|
|
44
|
+
expect(() => contentTypeForPath("a")).toThrow(/unsupported screenshot type/);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("loadScreenshots", () => {
|
|
49
|
+
it("reads valid images with their content types", () => {
|
|
50
|
+
const a = writePng("a.png");
|
|
51
|
+
const loaded = loadScreenshots([a]);
|
|
52
|
+
expect(loaded).toHaveLength(1);
|
|
53
|
+
expect(loaded[0].contentType).toBe("image/png");
|
|
54
|
+
expect(loaded[0].bytes.byteLength).toBeGreaterThan(0);
|
|
55
|
+
});
|
|
56
|
+
it("rejects more than MAX_SCREENSHOTS", () => {
|
|
57
|
+
const paths = Array.from({ length: MAX_SCREENSHOTS + 1 }, (_, i) => writePng(`s${i}.png`));
|
|
58
|
+
expect(() => loadScreenshots(paths)).toThrow(new RegExp(`at most ${MAX_SCREENSHOTS}`));
|
|
59
|
+
});
|
|
60
|
+
it("rejects a missing file", () => {
|
|
61
|
+
expect(() => loadScreenshots([path.join(tmp, "nope.png")])).toThrow(/cannot read screenshot/);
|
|
62
|
+
});
|
|
63
|
+
it("rejects an empty file", () => {
|
|
64
|
+
expect(() => loadScreenshots([writePng("empty.png", Buffer.alloc(0))])).toThrow(/empty/);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("uploadScreenshots", () => {
|
|
69
|
+
it("returns [] without calling the API when no paths given", async () => {
|
|
70
|
+
const keys = await uploadScreenshots({ paths: [], token: "t" });
|
|
71
|
+
expect(keys).toEqual([]);
|
|
72
|
+
expect(vaultApiFetch).not.toHaveBeenCalled();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("presigns, PUTs each image to S3, and returns the keys", async () => {
|
|
76
|
+
const a = writePng("a.png");
|
|
77
|
+
const b = writePng("b.jpg");
|
|
78
|
+
vi.mocked(vaultApiFetch).mockResolvedValue({
|
|
79
|
+
ok: true,
|
|
80
|
+
json: async () => ({
|
|
81
|
+
screenshots: [
|
|
82
|
+
{ key: "feedback-screenshots/prs_a/sub/0.png", url: "https://s3/put0", contentType: "image/png" },
|
|
83
|
+
{ key: "feedback-screenshots/prs_a/sub/1.jpg", url: "https://s3/put1", contentType: "image/jpeg" },
|
|
84
|
+
],
|
|
85
|
+
}),
|
|
86
|
+
} as unknown as Response);
|
|
87
|
+
const putCalls: Array<{ url: string; method?: string; contentType?: unknown }> = [];
|
|
88
|
+
const fetchImpl = vi.fn(async (url: string, init: { method?: string; headers?: Record<string, string> }) => {
|
|
89
|
+
putCalls.push({ url, method: init.method, contentType: init.headers?.["Content-Type"] });
|
|
90
|
+
return { ok: true, status: 200 } as Response;
|
|
91
|
+
}) as unknown as typeof fetch;
|
|
92
|
+
|
|
93
|
+
const keys = await uploadScreenshots({ paths: [a, b], token: "tok", fetchImpl });
|
|
94
|
+
|
|
95
|
+
expect(keys).toEqual([
|
|
96
|
+
"feedback-screenshots/prs_a/sub/0.png",
|
|
97
|
+
"feedback-screenshots/prs_a/sub/1.jpg",
|
|
98
|
+
]);
|
|
99
|
+
// Presign request asked for the right content types.
|
|
100
|
+
const presignBody = vi.mocked(vaultApiFetch).mock.calls[0][0].body;
|
|
101
|
+
expect(presignBody).toEqual({ contentTypes: ["image/png", "image/jpeg"] });
|
|
102
|
+
// One PUT per image, to the presigned URL, with the right Content-Type.
|
|
103
|
+
expect(putCalls).toEqual([
|
|
104
|
+
{ url: "https://s3/put0", method: "PUT", contentType: "image/png" },
|
|
105
|
+
{ url: "https://s3/put1", method: "PUT", contentType: "image/jpeg" },
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("throws when the presign call fails", async () => {
|
|
110
|
+
const a = writePng("a.png");
|
|
111
|
+
vi.mocked(vaultApiFetch).mockResolvedValue({
|
|
112
|
+
ok: false,
|
|
113
|
+
statusText: "Bad Request",
|
|
114
|
+
json: async () => ({ error: "at most 5 screenshots are allowed" }),
|
|
115
|
+
} as unknown as Response);
|
|
116
|
+
await expect(uploadScreenshots({ paths: [a], token: "t" })).rejects.toThrow(
|
|
117
|
+
/Failed to presign screenshots: at most 5/,
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("throws when an S3 upload fails", async () => {
|
|
122
|
+
const a = writePng("a.png");
|
|
123
|
+
vi.mocked(vaultApiFetch).mockResolvedValue({
|
|
124
|
+
ok: true,
|
|
125
|
+
json: async () => ({
|
|
126
|
+
screenshots: [{ key: "feedback-screenshots/prs_a/sub/0.png", url: "https://s3/put0", contentType: "image/png" }],
|
|
127
|
+
}),
|
|
128
|
+
} as unknown as Response);
|
|
129
|
+
const fetchImpl = vi.fn(async () => ({ ok: false, status: 403 }) as Response) as unknown as typeof fetch;
|
|
130
|
+
await expect(uploadScreenshots({ paths: [a], token: "t", fetchImpl })).rejects.toThrow(
|
|
131
|
+
/Failed to upload screenshot .*: HTTP 403/,
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
});
|