@indigoai-us/hq-cli 5.345.151 → 5.345.152
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/CHANGELOG.md +41 -0
- package/dist/command-catalog.generated.d.ts +3 -3
- package/dist/command-catalog.generated.js +3 -3
- package/dist/commands/files.js +23 -7
- package/dist/commands/rescue.d.ts +7 -0
- package/dist/commands/rescue.js +22 -13
- package/dist/commands/secrets.d.ts +13 -0
- package/dist/commands/secrets.js +51 -8
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +186 -186
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/utils/hook-trust.d.ts +27 -0
- package/dist/utils/hook-trust.js +150 -8
- package/dist/utils/shipped-codex-hooks.d.ts +17 -0
- package/dist/utils/shipped-codex-hooks.js +86 -0
- package/package.json +8 -8
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,47 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.345.152] — 2026-10-20
|
|
6
|
+
- The bundled sync engine now uses hq-cloud 6.18.91. When a company Outpost
|
|
7
|
+
fails to start and the server has already cleaned it up,
|
|
8
|
+
`hq outposts provision --company <slug>` (and `create --company --name`)
|
|
9
|
+
stops right away and says nothing was left running, you were not charged,
|
|
10
|
+
and it is safe to run the same command again. `hq outposts status --company`
|
|
11
|
+
shows the same line, and `--json` adds `code: "failed_cleaned_up"` and
|
|
12
|
+
`retrySafe: true`. Other blocked reasons keep their existing text.
|
|
13
|
+
- CI: the "changelog entry" check sets up Node before it runs, so it no longer
|
|
14
|
+
fails with "node: command not found" on the self-hosted ARM runners. No change
|
|
15
|
+
to the `hq` command itself.
|
|
16
|
+
- `hq files share --with out_…` and `hq files unshare --with out_…` now accept
|
|
17
|
+
a company Outpost id, with read or write. `hq files acl` shows the grantee as
|
|
18
|
+
`Outpost (out_…)`; `--json` output is unchanged.
|
|
19
|
+
- `hq secrets share --with out_…` now refuses write or admin for a company
|
|
20
|
+
Outpost with "Company Outposts can only be given read access to a secret."
|
|
21
|
+
before anything is sent. Leaving out `--permission` for an Outpost gives it
|
|
22
|
+
read. A server refusal for the same reason shows the same message.
|
|
23
|
+
- `hq rescue` (and so `/update-hq`) now re-trusts HQ's own Codex hooks after a
|
|
24
|
+
successful rescue. Before this, an update that changed a hook command left
|
|
25
|
+
Codex holding the old trusted hash, so Codex skipped that hook until you ran
|
|
26
|
+
`hq reindex`. Rescue trusts a hook only when its entry in the HQ root's
|
|
27
|
+
`.codex/hooks.json` or `.codex/config.toml` matches the copy shipped in the
|
|
28
|
+
installed core release, which it reads from GitHub at the synced commit.
|
|
29
|
+
It reads that copy only from `indigoai-us/hq-core` or
|
|
30
|
+
`indigoai-us/hq-core-staging`; if `core/core.yaml` names any other repo
|
|
31
|
+
(for example after `hq rescue --source <fork>`), rescue trusts nothing.
|
|
32
|
+
Hooks you added to those files by hand, or shipped entries you edited, are
|
|
33
|
+
never trusted. User and managed hooks are left alone, and hooks that are
|
|
34
|
+
already trusted are not rewritten. Rescue does not re-enable a shipped hook
|
|
35
|
+
you disabled; it only refreshes that hook's trust (`hq reindex` still
|
|
36
|
+
re-enables shipped hooks). Nothing happens when Codex is not installed, and
|
|
37
|
+
`--check` never touches hook trust. Rescue prints a warning that points at
|
|
38
|
+
`hq reindex` and keeps its exit status when the shipped files cannot be
|
|
39
|
+
read, when the release does not ship a file an HQ hook comes from, when a
|
|
40
|
+
shipped file does not parse, when the repo is not an HQ core repo, or when
|
|
41
|
+
the trust step fails.
|
|
42
|
+
- `hq secrets exec` run without a session id (no `HQ_SESSION_ID`, `CLAUDE_CODE_SESSION_ID`, `CLAUDE_SESSION_ID`, `CODEX_SESSION_ID`, `CODEX_THREAD_ID` or `GROK_SESSION_ID`), such as a launchd or cron job, no longer borrows the company lock of whichever interactive session last wrote the sessions `.current` pointer. Before this, a job could load the same-named secret from that session's company. Such a job must now pass `--company <slug>` or `--personal`. Otherwise it prints how to fix the call and exits 78 without reading the vault. An `HQ_API_KEY` (`hqk_`) caller is unaffected because its key is bound to one company. A caller with its own session id keeps the existing lock rules: `--company` can only pick a company inside that session's lock.
|
|
43
|
+
- Defines the Windows acceptance bar for the native hook engine and checks it before a release. Hook checks whose stories are not merged yet are reported as pending and do not block other releases. A failed check still blocks the release.
|
|
44
|
+
- A pass requires runner evidence for that commit. Counts written into corpus files, a source-text match, or a signed-off latency number with no report stay pending and do not block a release.
|
|
45
|
+
|
|
5
46
|
## [5.345.151] — 2026-10-20
|
|
6
47
|
- `hq lanes link _deliver` no longer hands a conductor message to a subagent. Subagent hook events share the root session id, so a subagent tool call used to claim the message and the root session never saw it. Child payloads, including Grok child sessions (`subagent_type`) and Codex child threads, now get nothing and the message stays queued until the root session's next tool call or stop. SubagentStop no longer delivers. A parent session id alone does not mark a child, so resumed, forked, or launcher-spawned Grok roots still receive their messages.
|
|
7
48
|
- If you leave out `--company`, `hq meetings invite <url>` now uses your only active company. If you belong to several companies, it lists them and asks you to pick one. The command used to send the invite as Personal instead. Personal can never record, so you saw the HQ Workforce upgrade prompt even when your company already had meeting recording.
|
|
@@ -2058,7 +2058,7 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
2058
2058
|
readonly hidden: false;
|
|
2059
2059
|
}, {
|
|
2060
2060
|
readonly flags: "--permission <level>";
|
|
2061
|
-
readonly description: "Permission level: read | write | admin";
|
|
2061
|
+
readonly description: "Permission level: read | write | admin (required, except for a company Outpost, which can only be given read and defaults to it)";
|
|
2062
2062
|
readonly hidden: false;
|
|
2063
2063
|
}];
|
|
2064
2064
|
readonly subcommands: readonly [];
|
|
@@ -2813,7 +2813,7 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
2813
2813
|
}];
|
|
2814
2814
|
readonly options: readonly [{
|
|
2815
2815
|
readonly flags: "--with <principal>";
|
|
2816
|
-
readonly description: "Email address, group id, or '@all' to share with every active company member";
|
|
2816
|
+
readonly description: "Email address, group id, company Outpost id (out_...), or '@all' to share with every active company member";
|
|
2817
2817
|
readonly hidden: false;
|
|
2818
2818
|
}, {
|
|
2819
2819
|
readonly flags: "--permission <level>";
|
|
@@ -2859,7 +2859,7 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
2859
2859
|
}];
|
|
2860
2860
|
readonly options: readonly [{
|
|
2861
2861
|
readonly flags: "--with <principal>";
|
|
2862
|
-
readonly description: "Person uid, agent uid, email address, group id, or '@all' to remove the grant";
|
|
2862
|
+
readonly description: "Person uid, agent uid, company Outpost id (out_...), email address, group id, or '@all' to remove the grant";
|
|
2863
2863
|
readonly hidden: false;
|
|
2864
2864
|
}];
|
|
2865
2865
|
readonly subcommands: readonly [];
|
|
@@ -2469,7 +2469,7 @@ export const COMMAND_CATALOG = [
|
|
|
2469
2469
|
},
|
|
2470
2470
|
{
|
|
2471
2471
|
"flags": "--permission <level>",
|
|
2472
|
-
"description": "Permission level: read | write | admin",
|
|
2472
|
+
"description": "Permission level: read | write | admin (required, except for a company Outpost, which can only be given read and defaults to it)",
|
|
2473
2473
|
"hidden": false
|
|
2474
2474
|
}
|
|
2475
2475
|
],
|
|
@@ -3380,7 +3380,7 @@ export const COMMAND_CATALOG = [
|
|
|
3380
3380
|
"options": [
|
|
3381
3381
|
{
|
|
3382
3382
|
"flags": "--with <principal>",
|
|
3383
|
-
"description": "Email address, group id, or '@all' to share with every active company member",
|
|
3383
|
+
"description": "Email address, group id, company Outpost id (out_...), or '@all' to share with every active company member",
|
|
3384
3384
|
"hidden": false
|
|
3385
3385
|
},
|
|
3386
3386
|
{
|
|
@@ -3437,7 +3437,7 @@ export const COMMAND_CATALOG = [
|
|
|
3437
3437
|
"options": [
|
|
3438
3438
|
{
|
|
3439
3439
|
"flags": "--with <principal>",
|
|
3440
|
-
"description": "Person uid, agent uid, email address, group id, or '@all' to remove the grant",
|
|
3440
|
+
"description": "Person uid, agent uid, company Outpost id (out_...), email address, group id, or '@all' to remove the grant",
|
|
3441
3441
|
"hidden": false
|
|
3442
3442
|
}
|
|
3443
3443
|
],
|
package/dist/commands/files.js
CHANGED
|
@@ -4,7 +4,7 @@ import * as readline from "node:readline";
|
|
|
4
4
|
import { resolveVaultCredential } from "../utils/resolve-vault-credential.js";
|
|
5
5
|
import { gateApiKeyCapabilities } from "../utils/api-key-command-gate.js";
|
|
6
6
|
import { vaultApiFetch, getCompanyUid } from "./secrets.js";
|
|
7
|
-
import { GROUP_ID_PATTERN, EMAIL_PATTERN, isPrivateFolderPattern, normalizeFilePrefix, toSharedFolderGlob, } from "./_patterns.js";
|
|
7
|
+
import { GROUP_ID_PATTERN, EMAIL_PATTERN, isPrivateFolderPattern, normalizeFilePrefix, OUTPOST_UID_PATTERN, toSharedFolderGlob, } from "./_patterns.js";
|
|
8
8
|
import { looksLikeCompanyUid } from "../utils/vault-api.js";
|
|
9
9
|
import { AuthError } from "../utils/auth-error.js";
|
|
10
10
|
import { formatAccessDenied, offerAccessLadder, } from "../utils/access-denied-hint.js";
|
|
@@ -159,7 +159,7 @@ export function registerFilesCommand(program) {
|
|
|
159
159
|
files
|
|
160
160
|
.command("share [paths...]")
|
|
161
161
|
.description("Share file paths. foo/ is a private folder (children stay creator-only); quote the shared-folder glob 'foo/*' so the shell passes it literally. Without --with: mint a share-session URL and open it in the browser. With --with: grant access directly to a person, group, or @all.")
|
|
162
|
-
.option("--with <principal>", "Email address, group id, or '@all' to share with every active company member")
|
|
162
|
+
.option("--with <principal>", "Email address, group id, company Outpost id (out_...), or '@all' to share with every active company member")
|
|
163
163
|
.option("--permission <level>", "Permission level (only with --with): read | write")
|
|
164
164
|
.option("--full", "Grant access to the ENTIRE vault (the '*' wildcard prefix) — no need to quote a glob. Requires --with; defaults to write permission.")
|
|
165
165
|
.option("--expires <duration>", "Token expiry duration for share-session URL (e.g. 15m, 1h, 24h). Default 15m. Max 24h.")
|
|
@@ -268,7 +268,7 @@ export function registerFilesCommand(program) {
|
|
|
268
268
|
files
|
|
269
269
|
.command("unshare <prefix>")
|
|
270
270
|
.description("Remove a named file access grant, or remove an empty ACL row or grantee-less entries without --with.")
|
|
271
|
-
.option("--with <principal>", "Person uid, agent uid, email address, group id, or '@all' to remove the grant")
|
|
271
|
+
.option("--with <principal>", "Person uid, agent uid, company Outpost id (out_...), email address, group id, or '@all' to remove the grant")
|
|
272
272
|
.action(async (prefix, opts) => {
|
|
273
273
|
try {
|
|
274
274
|
// Unshare MUTATES access with no preview. Anchored content paths remain
|
|
@@ -286,9 +286,14 @@ export function registerFilesCommand(program) {
|
|
|
286
286
|
const isEmail = !isAll && EMAIL_PATTERN.test(principal);
|
|
287
287
|
const isGroup = !isAll && GROUP_ID_PATTERN.test(principal);
|
|
288
288
|
const isAgent = !isAll && !isEmail && !isGroup && AGENT_UID_PATTERN.test(principal);
|
|
289
|
-
|
|
289
|
+
// A company Outpost (out_<ULID>) rides the person grantee slot on the wire.
|
|
290
|
+
const isPerson = !isAll &&
|
|
291
|
+
!isEmail &&
|
|
292
|
+
!isGroup &&
|
|
293
|
+
!isAgent &&
|
|
294
|
+
(PERSON_UID_PATTERN.test(principal) || OUTPOST_UID_PATTERN.test(principal));
|
|
290
295
|
if (opts.with && !isAll && !isEmail && !isGroup && !isPerson && !isAgent) {
|
|
291
|
-
console.error(chalk.red(`Invalid principal '${principal}': must be '@all', an email address, a person uid matching prs_<alphanumeric>, an agent uid matching agt_<alphanumeric>, or a group id matching grp_<alphanumeric>`));
|
|
296
|
+
console.error(chalk.red(`Invalid principal '${principal}': must be '@all', an email address, a person uid matching prs_<alphanumeric>, an agent uid matching agt_<alphanumeric>, a company Outpost id (out_<ULID>), or a group id matching grp_<alphanumeric>`));
|
|
292
297
|
process.exit(1);
|
|
293
298
|
}
|
|
294
299
|
// The vault ACL API stores first-class agent access on its backwards-
|
|
@@ -481,6 +486,9 @@ export function registerFilesCommand(program) {
|
|
|
481
486
|
if (e.granteeType === "company-wide") {
|
|
482
487
|
return "Everyone in company (including guests)";
|
|
483
488
|
}
|
|
489
|
+
if (e.granteeType === "person" && OUTPOST_UID_PATTERN.test(e.granteeId)) {
|
|
490
|
+
return `Outpost (${e.granteeId})`;
|
|
491
|
+
}
|
|
484
492
|
return e.granteeId;
|
|
485
493
|
}
|
|
486
494
|
function printEntryTable(rows, showSource) {
|
|
@@ -773,9 +781,17 @@ async function runDirectGrant(params) {
|
|
|
773
781
|
const isAll = principal === "@all";
|
|
774
782
|
const isEmail = !isAll && EMAIL_PATTERN.test(principal);
|
|
775
783
|
const isGroup = !isAll && GROUP_ID_PATTERN.test(principal);
|
|
776
|
-
|
|
784
|
+
// A company Outpost (out_<ULID>) gets member-equivalent file access and
|
|
785
|
+
// rides the person grantee slot on the wire: { granteeType: "person",
|
|
786
|
+
// granteeId: "out_..." }. hq-pro validates out_ grantees (active binding,
|
|
787
|
+
// same company) from the paired server change; older servers store it as an
|
|
788
|
+
// inert person grant.
|
|
789
|
+
const isPerson = !isAll &&
|
|
790
|
+
!isEmail &&
|
|
791
|
+
!isGroup &&
|
|
792
|
+
(PERSON_UID_PATTERN.test(principal) || OUTPOST_UID_PATTERN.test(principal));
|
|
777
793
|
if (!isAll && !isEmail && !isGroup && !isPerson) {
|
|
778
|
-
console.error(chalk.red(`Invalid principal '${principal}': must be '@all', an email address, a person uid, or a group id matching grp_<alphanumeric>`));
|
|
794
|
+
console.error(chalk.red(`Invalid principal '${principal}': must be '@all', an email address, a person uid, a company Outpost id (out_<ULID>), or a group id matching grp_<alphanumeric>`));
|
|
779
795
|
process.exit(1);
|
|
780
796
|
}
|
|
781
797
|
const granteeType = isAll
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* `HQ_NO_UPDATE_CHECK=1`; always best-effort.
|
|
21
21
|
*/
|
|
22
22
|
import { Command } from 'commander';
|
|
23
|
+
import { retrustCodexHooksAfterRescue } from '../utils/hook-trust.js';
|
|
23
24
|
export interface RescueTarget {
|
|
24
25
|
source: string;
|
|
25
26
|
/** undefined → let the script apply its own default ref (`main`). */
|
|
@@ -45,6 +46,12 @@ export declare function shouldAttemptSelfUpdate(opts: {
|
|
|
45
46
|
check?: boolean;
|
|
46
47
|
selfUpdate?: boolean;
|
|
47
48
|
}): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Re-trust HQ's Codex hooks after a rescue that changed files on disk.
|
|
51
|
+
* Exported for tests. Best-effort: a trust failure is reported but never
|
|
52
|
+
* changes the rescue's exit status, because the core update itself succeeded.
|
|
53
|
+
*/
|
|
54
|
+
export declare function retrustHooksAfterRescue(hqRoot: string, retrust?: typeof retrustCodexHooksAfterRescue): Promise<void>;
|
|
48
55
|
/** Latest release tag for a repo, e.g. `v12.3.0`. Throws on failure. */
|
|
49
56
|
export declare function getLatestReleaseTag(repo: string, token?: string): Promise<string>;
|
|
50
57
|
/** Resolve a tag to its commit SHA, dereferencing annotated tags. */
|
package/dist/commands/rescue.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { spawnSync } from 'child_process';
|
|
2
1
|
import * as fs from 'fs';
|
|
3
2
|
import * as path from 'path';
|
|
4
3
|
import * as yaml from 'js-yaml';
|
|
@@ -6,8 +5,8 @@ import chalk from 'chalk';
|
|
|
6
5
|
import { rescue } from '@indigoai-us/hq-cloud';
|
|
7
6
|
import { findHqRoot } from '../utils/manifest.js';
|
|
8
7
|
import { selfUpdateAndReexec } from '../utils/self-update.js';
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
import { retrustCodexHooksAfterRescue } from '../utils/hook-trust.js';
|
|
9
|
+
import { PROD_SOURCE, STAGING_SOURCE, resolveGhToken } from '../utils/shipped-codex-hooks.js';
|
|
11
10
|
/**
|
|
12
11
|
* Resolve the source repo + ref from the user's flags. Pure + exported for
|
|
13
12
|
* tests. `latestTag` is the resolved latest release tag (prod only); ignored
|
|
@@ -28,20 +27,27 @@ export function resolveRescueTarget(opts, latestTag) {
|
|
|
28
27
|
export function shouldAttemptSelfUpdate(opts) {
|
|
29
28
|
return opts.selfUpdate !== false && !opts.check;
|
|
30
29
|
}
|
|
31
|
-
/**
|
|
32
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Re-trust HQ's Codex hooks after a rescue that changed files on disk.
|
|
32
|
+
* Exported for tests. Best-effort: a trust failure is reported but never
|
|
33
|
+
* changes the rescue's exit status, because the core update itself succeeded.
|
|
34
|
+
*/
|
|
35
|
+
export async function retrustHooksAfterRescue(hqRoot, retrust = retrustCodexHooksAfterRescue) {
|
|
36
|
+
let root = hqRoot;
|
|
33
37
|
try {
|
|
34
|
-
|
|
35
|
-
if (res.status === 0) {
|
|
36
|
-
const tok = res.stdout.trim();
|
|
37
|
-
if (tok)
|
|
38
|
-
return tok;
|
|
39
|
-
}
|
|
38
|
+
root = fs.realpathSync(hqRoot);
|
|
40
39
|
}
|
|
41
40
|
catch {
|
|
42
|
-
//
|
|
41
|
+
// Keep the path as given; trust reports its own failure if it is unusable.
|
|
42
|
+
}
|
|
43
|
+
const result = await retrust(root);
|
|
44
|
+
if (result.status === 'trusted') {
|
|
45
|
+
console.log(`rescue: re-trusted ${result.trusted} Codex HQ hook${result.trusted === 1 ? '' : 's'}`);
|
|
46
|
+
}
|
|
47
|
+
else if (result.status === 'failed') {
|
|
48
|
+
console.warn(chalk.yellow(`rescue: could not re-trust Codex HQ hooks: ${result.reason ?? 'unknown error'}. ` +
|
|
49
|
+
'Codex will skip changed HQ hooks until you run `hq reindex`.'));
|
|
43
50
|
}
|
|
44
|
-
return process.env.GH_TOKEN || process.env.GITHUB_TOKEN || undefined;
|
|
45
51
|
}
|
|
46
52
|
function ghHeaders(token) {
|
|
47
53
|
const headers = {
|
|
@@ -178,6 +184,9 @@ export function registerRescueCommand(program) {
|
|
|
178
184
|
cloudUpdate: opts.cloudUpdate,
|
|
179
185
|
ghToken: token,
|
|
180
186
|
});
|
|
187
|
+
if (status === 0 && !opts.check) {
|
|
188
|
+
await retrustHooksAfterRescue(hqRoot);
|
|
189
|
+
}
|
|
181
190
|
process.exit(status);
|
|
182
191
|
}
|
|
183
192
|
catch (error) {
|
|
@@ -3,6 +3,13 @@ export { readSessionCompanyLock } from "../lib/session-company-lock.js";
|
|
|
3
3
|
export { buildSecretUsage, emitEnvExportsAndExit, loadRevealedSecrets, parseSecretNameList, runSecretsEnvCommand, } from "../secrets-env-action.js";
|
|
4
4
|
export type { SecretLoadResponse, SecretScriptLockMode, SecretTier, SecretUsage, SecretUsageChannel } from "../secrets-env-action.js";
|
|
5
5
|
export type { SecretScriptUsage, SecretMetadata, SecretLoadSuccessRow } from "../secrets-env-action.js";
|
|
6
|
+
/**
|
|
7
|
+
* Exit code for `hq secrets exec` run with no session id and no explicit
|
|
8
|
+
* company. 78 is EX_CONFIG from sysexits.h: the caller's invocation is missing
|
|
9
|
+
* required configuration, so retrying the same command will not help.
|
|
10
|
+
*/
|
|
11
|
+
export declare const SECRETS_EXEC_UNATTENDED_NO_COMPANY_EXIT = 78;
|
|
12
|
+
export declare const SECRETS_EXEC_UNATTENDED_NO_COMPANY_MESSAGE: string;
|
|
6
13
|
import { vaultApiFetch, getCompanyUid, getEntityUid } from "../utils/vault-api.js";
|
|
7
14
|
export type { VaultApiOptions } from "../utils/vault-api.js";
|
|
8
15
|
export { vaultApiFetch, getCompanyUid, getEntityUid };
|
|
@@ -17,6 +24,12 @@ interface SecretAclPrincipal {
|
|
|
17
24
|
granteeType: "email" | "group" | "company-wide" | "person";
|
|
18
25
|
granteeId: string;
|
|
19
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* hq-pro refuses any Outpost grant above read with 400
|
|
29
|
+
* OUTPOST_GRANT_READ_ONLY. The CLI refuses it first with the same message.
|
|
30
|
+
*/
|
|
31
|
+
export declare const OUTPOST_GRANT_READ_ONLY_CODE = "OUTPOST_GRANT_READ_ONLY";
|
|
32
|
+
export declare const OUTPOST_GRANT_READ_ONLY_MESSAGE = "Company Outposts can only be given read access to a secret.";
|
|
20
33
|
export declare function parseSecretAclPrincipal(principal: string): SecretAclPrincipal | null;
|
|
21
34
|
export interface SecretInjectionRecipe {
|
|
22
35
|
header: string;
|
package/dist/commands/secrets.js
CHANGED
|
@@ -3,6 +3,7 @@ import * as path from "node:path";
|
|
|
3
3
|
import * as yaml from "js-yaml";
|
|
4
4
|
import { assertApiKeyCompany, buildSecretUsage, extractApiMessage, highSecuritySandboxOnlyMessage, loadRevealedSecrets, normalizeScriptLockMode, normalizeSecretTier, parseSecretNameList, requestedApiKeyCompany, runSecretsEnvCommand, scopeOpts, secretsCredentialOptions, } from "../secrets-env-action.js";
|
|
5
5
|
import { readSessionCompanyLock } from "../lib/session-company-lock.js";
|
|
6
|
+
import { sessionIdFromEnv } from "../lib/core-utils/session-id.js";
|
|
6
7
|
export { readSessionCompanyLock } from "../lib/session-company-lock.js";
|
|
7
8
|
export { buildSecretUsage, emitEnvExportsAndExit, loadRevealedSecrets, parseSecretNameList, runSecretsEnvCommand, } from "../secrets-env-action.js";
|
|
8
9
|
function companyUidForSlug(root, slug) {
|
|
@@ -53,6 +54,16 @@ async function resolveApiKeyCompanyWithinLock(input) {
|
|
|
53
54
|
}
|
|
54
55
|
import chalk from "chalk";
|
|
55
56
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
57
|
+
/**
|
|
58
|
+
* Exit code for `hq secrets exec` run with no session id and no explicit
|
|
59
|
+
* company. 78 is EX_CONFIG from sysexits.h: the caller's invocation is missing
|
|
60
|
+
* required configuration, so retrying the same command will not help.
|
|
61
|
+
*/
|
|
62
|
+
export const SECRETS_EXEC_UNATTENDED_NO_COMPANY_EXIT = 78;
|
|
63
|
+
export const SECRETS_EXEC_UNATTENDED_NO_COMPANY_MESSAGE = "hq secrets exec has no session id (HQ_SESSION_ID, CLAUDE_CODE_SESSION_ID, CLAUDE_SESSION_ID, " +
|
|
64
|
+
"CODEX_SESSION_ID, CODEX_THREAD_ID and GROK_SESSION_ID are all unset), so it will not borrow a company " +
|
|
65
|
+
"from whichever interactive session last wrote workspace/sessions/.current. " +
|
|
66
|
+
"Pass --company <slug> (or --personal), or set HQ_SESSION_ID to the session this job runs under.";
|
|
56
67
|
import { removeCacheEntry, clearAllCache } from "../utils/secrets-cache.js";
|
|
57
68
|
import { isHqCliChild, releaseChildWrapperPackageUseLeaseAfterStart } from "../utils/child-wrapper-lease.js";
|
|
58
69
|
import { resolveDefaultHqRoot } from "../utils/hq-root-resolver.js";
|
|
@@ -63,7 +74,7 @@ import { isPlanGateError } from "../utils/plan-gate-error.js";
|
|
|
63
74
|
import { SECRET_NAME_PATTERN, GROUP_ID_PATTERN, EMAIL_PATTERN, OUTPOST_UID_PATTERN, } from "./_patterns.js";
|
|
64
75
|
import { describeSecretsScope, formatSecretSaved, formatSecretsListEmpty, formatSecretsListHeader, } from "./secrets-scope.js";
|
|
65
76
|
import { vaultApiFetch, getCompanyUid, getEntityUid, looksLikeCompanyUid, } from "../utils/vault-api.js";
|
|
66
|
-
import { formatApiKeyCapabilityDenial, requireApiKeyCapability, resolveVaultCredentialForCapability, } from "../utils/resolve-vault-credential.js";
|
|
77
|
+
import { formatApiKeyCapabilityDenial, peekHqApiKey, requireApiKeyCapability, resolveVaultCredentialForCapability, } from "../utils/resolve-vault-credential.js";
|
|
67
78
|
import { runSandboxJobAfterSubmission } from "../utils/secrets-sandbox-resident.js";
|
|
68
79
|
export { vaultApiFetch, getCompanyUid, getEntityUid };
|
|
69
80
|
/**
|
|
@@ -277,6 +288,12 @@ function promptSecretInteractively() {
|
|
|
277
288
|
process.stdin.resume();
|
|
278
289
|
});
|
|
279
290
|
}
|
|
291
|
+
/**
|
|
292
|
+
* hq-pro refuses any Outpost grant above read with 400
|
|
293
|
+
* OUTPOST_GRANT_READ_ONLY. The CLI refuses it first with the same message.
|
|
294
|
+
*/
|
|
295
|
+
export const OUTPOST_GRANT_READ_ONLY_CODE = "OUTPOST_GRANT_READ_ONLY";
|
|
296
|
+
export const OUTPOST_GRANT_READ_ONLY_MESSAGE = "Company Outposts can only be given read access to a secret.";
|
|
280
297
|
export function parseSecretAclPrincipal(principal) {
|
|
281
298
|
const p = principal.trim();
|
|
282
299
|
if (p === "@all") {
|
|
@@ -1164,11 +1181,23 @@ export function registerSecretsCommand(program) {
|
|
|
1164
1181
|
process.exit(1);
|
|
1165
1182
|
}
|
|
1166
1183
|
const keys = parseSecretNameList(_opts.only);
|
|
1167
|
-
const multiCompanyLockEnabled = await fastFlagEnabledDefaultOn(MULTI_COMPANY_SESSION_LOCK_FLAG);
|
|
1168
|
-
const sessionLock = multiCompanyLockEnabled ? readSessionCompanyLock(process.env) : undefined;
|
|
1169
1184
|
const parentScope = secrets.opts();
|
|
1170
1185
|
const inheritedCompany = parentScope.company;
|
|
1171
1186
|
const requestedCompany = _opts.company ?? inheritedCompany;
|
|
1187
|
+
// A launchd or cron job has no session id. Session resolution would then
|
|
1188
|
+
// fall back to workspace/sessions/.current, which names whichever
|
|
1189
|
+
// interactive session last ran a hook, and the job would read that
|
|
1190
|
+
// session's company's same-named secret. Without a session id the caller
|
|
1191
|
+
// must name its scope. HQ_API_KEY is exempt: the key is bound to one
|
|
1192
|
+
// company and never reads the session lock below.
|
|
1193
|
+
const hasCallerSession = Boolean(sessionIdFromEnv(process.env));
|
|
1194
|
+
if (!hasCallerSession && !requestedCompany && !parentScope.personal && peekHqApiKey() === undefined) {
|
|
1195
|
+
console.error(chalk.red("Error:"), SECRETS_EXEC_UNATTENDED_NO_COMPANY_MESSAGE);
|
|
1196
|
+
process.exit(SECRETS_EXEC_UNATTENDED_NO_COMPANY_EXIT);
|
|
1197
|
+
}
|
|
1198
|
+
const multiCompanyLockEnabled = await fastFlagEnabledDefaultOn(MULTI_COMPANY_SESSION_LOCK_FLAG);
|
|
1199
|
+
// Only the caller's own session id may supply a lock; never .current.
|
|
1200
|
+
const sessionLock = multiCompanyLockEnabled && hasCallerSession ? readSessionCompanyLock(process.env) : undefined;
|
|
1172
1201
|
const hasLockedCompanies = (sessionLock?.companies.length ?? 0) > 0;
|
|
1173
1202
|
const lockRoot = hasLockedCompanies ? resolveDefaultHqRoot({ env: process.env }) : undefined;
|
|
1174
1203
|
const selectedCompany = (hasLockedCompanies && requestedCompany && looksLikeCompanyUid(requestedCompany)
|
|
@@ -1319,7 +1348,7 @@ export function registerSecretsCommand(program) {
|
|
|
1319
1348
|
.command("share <path>")
|
|
1320
1349
|
.description("Share a secret with an email address, group, company Outpost (out_…), or the entire company (@all)")
|
|
1321
1350
|
.requiredOption("--with <principal>", "Email address, group id, out_ Outpost id, or @all (entire company) to share with")
|
|
1322
|
-
.
|
|
1351
|
+
.option("--permission <level>", "Permission level: read | write | admin (required, except for a company Outpost, which can only be given read and defaults to it)")
|
|
1323
1352
|
.action(async (path, opts) => {
|
|
1324
1353
|
try {
|
|
1325
1354
|
rejectIfPersonal(secrets.opts(), "share");
|
|
@@ -1327,7 +1356,8 @@ export function registerSecretsCommand(program) {
|
|
|
1327
1356
|
console.error(chalk.red(`Invalid secret path '${path}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_KEY or PROD/DB_PASSWORD)`));
|
|
1328
1357
|
process.exit(1);
|
|
1329
1358
|
}
|
|
1330
|
-
if (
|
|
1359
|
+
if (opts.permission !== undefined &&
|
|
1360
|
+
!["read", "write", "admin"].includes(opts.permission)) {
|
|
1331
1361
|
console.error(chalk.red(`Invalid permission '${opts.permission}': must be one of read, write, admin`));
|
|
1332
1362
|
process.exit(1);
|
|
1333
1363
|
}
|
|
@@ -1335,6 +1365,16 @@ export function registerSecretsCommand(program) {
|
|
|
1335
1365
|
if (!principal) {
|
|
1336
1366
|
process.exit(1);
|
|
1337
1367
|
}
|
|
1368
|
+
const isOutpost = OUTPOST_UID_PATTERN.test(principal.granteeId);
|
|
1369
|
+
if (opts.permission === undefined && !isOutpost) {
|
|
1370
|
+
console.error(chalk.red("Missing --permission: must be one of read, write, admin"));
|
|
1371
|
+
process.exit(1);
|
|
1372
|
+
}
|
|
1373
|
+
const permission = opts.permission ?? "read";
|
|
1374
|
+
if (isOutpost && permission !== "read") {
|
|
1375
|
+
console.error(chalk.red(OUTPOST_GRANT_READ_ONLY_MESSAGE));
|
|
1376
|
+
process.exit(1);
|
|
1377
|
+
}
|
|
1338
1378
|
const target = await resolveSecretsTarget("secrets:share", "secrets share", secrets.opts());
|
|
1339
1379
|
const res = await vaultApiFetch({
|
|
1340
1380
|
token: target.token,
|
|
@@ -1344,12 +1384,15 @@ export function registerSecretsCommand(program) {
|
|
|
1344
1384
|
path,
|
|
1345
1385
|
granteeType: principal.granteeType,
|
|
1346
1386
|
granteeId: principal.granteeId,
|
|
1347
|
-
permission
|
|
1387
|
+
permission,
|
|
1348
1388
|
},
|
|
1349
1389
|
});
|
|
1350
1390
|
if (!res.ok) {
|
|
1351
1391
|
const body = await res.json().catch(() => ({}));
|
|
1352
|
-
if (res.status ===
|
|
1392
|
+
if (res.status === 400 && body.code === OUTPOST_GRANT_READ_ONLY_CODE) {
|
|
1393
|
+
console.error(chalk.red(OUTPOST_GRANT_READ_ONLY_MESSAGE));
|
|
1394
|
+
}
|
|
1395
|
+
else if (res.status === 401) {
|
|
1353
1396
|
console.error(chalk.red("Not authenticated — please run `hq login`"));
|
|
1354
1397
|
}
|
|
1355
1398
|
else if (res.status === 403) {
|
|
@@ -1371,7 +1414,7 @@ export function registerSecretsCommand(program) {
|
|
|
1371
1414
|
}
|
|
1372
1415
|
process.exit(1);
|
|
1373
1416
|
}
|
|
1374
|
-
console.log(chalk.green(`Shared '${path}' with ${describeSecretAclPrincipal(principal)} (${
|
|
1417
|
+
console.log(chalk.green(`Shared '${path}' with ${describeSecretAclPrincipal(principal)} (${permission})`));
|
|
1375
1418
|
}
|
|
1376
1419
|
catch (err) {
|
|
1377
1420
|
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|