@indigoai-us/hq-cli 5.345.197 → 5.345.198

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.
@@ -38,6 +38,31 @@ export function clearBotSession(dir, scope = "dm") {
38
38
  /* already gone */
39
39
  }
40
40
  }
41
+ const isScopeValue = (value) => typeof value === "string" || value === null;
42
+ function writeCompanyScopeMarker(dir, marker) {
43
+ const tmp = path.join(dir, `.company-scope.${randomUUID()}.tmp`);
44
+ try {
45
+ fs.writeFileSync(tmp, `${JSON.stringify(marker)}\n`, { mode: 0o600 });
46
+ fs.renameSync(tmp, path.join(dir, "company-scope.json"));
47
+ }
48
+ finally {
49
+ fs.rmSync(tmp, { force: true });
50
+ }
51
+ }
52
+ /** Move every session and DM history file into a fresh private archive folder. */
53
+ function archiveConversations(dir) {
54
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
55
+ const names = fs.readdirSync(dir).filter((name) => /^(?:session|dm-history)(?:-[A-Za-z0-9_-]+)?\.json$/.test(name));
56
+ if (!names.length)
57
+ return { names };
58
+ const archiveRoot = path.join(dir, "company-scope-archives");
59
+ fs.mkdirSync(archiveRoot, { recursive: true, mode: 0o700 });
60
+ const archiveDir = path.join(archiveRoot, randomUUID());
61
+ fs.mkdirSync(archiveDir, { mode: 0o700 });
62
+ for (const name of names)
63
+ fs.renameSync(path.join(dir, name), path.join(archiveDir, name));
64
+ return { names, archiveDir };
65
+ }
41
66
  /**
42
67
  * Run once at startup, before reading or appending conversation state. A scope
43
68
  * change must never resume a prior company's model session or replay its DM
@@ -46,13 +71,14 @@ export function clearBotSession(dir, scope = "dm") {
46
71
  * Legacy personal bots retain their existing history; company bots require
47
72
  * positive scope evidence and archive legacy, unproven conversations once.
48
73
  */
49
- export function ensureBotCompanyScope(dir, companyScopeKey) {
74
+ export function ensureBotCompanyScope(dir, companyScopeKey, options = {}) {
50
75
  const marker = path.join(dir, "company-scope.json");
51
76
  const hasMarker = fs.existsSync(marker);
52
- if (!hasMarker && companyScopeKey === undefined)
77
+ const inferred = companyScopeKey === undefined ? options.inferredSlug : undefined;
78
+ if (!hasMarker && companyScopeKey === undefined && inferred === undefined)
53
79
  return { reset: false };
54
80
  const expected = companyScopeKey ?? null;
55
- let provenPreviousScope = false;
81
+ let previous = null;
56
82
  if (hasMarker) {
57
83
  // Read errors fail closed. Invalid JSON is unproven provenance and resets.
58
84
  const body = fs.readFileSync(marker, "utf8");
@@ -63,16 +89,22 @@ export function ensureBotCompanyScope(dir, companyScopeKey) {
63
89
  catch {
64
90
  parsed = null;
65
91
  }
66
- provenPreviousScope = Boolean(parsed && typeof parsed === "object" &&
67
- parsed.v === 1 &&
68
- (typeof parsed.companyScopeKey === "string" ||
69
- parsed.companyScopeKey === null));
70
- if (parsed && typeof parsed === "object" &&
71
- parsed.v === 1 &&
72
- parsed.companyScopeKey === expected) {
73
- return { reset: false };
92
+ previous = parsed && typeof parsed === "object" && parsed.v === 1
93
+ ? parsed
94
+ : null;
95
+ if (inferred === undefined && previous !== null) {
96
+ if (previous.companyScopeKey === expected)
97
+ return { reset: false };
98
+ if (typeof previous.companyScopeKey === "string" && options.equivalentKeys?.includes(previous.companyScopeKey)) {
99
+ // The same scope under an older key: record the current key and keep everything.
100
+ writeCompanyScopeMarker(dir, { v: 1, companyScopeKey: expected });
101
+ return { reset: false };
102
+ }
74
103
  }
75
104
  }
105
+ const provenPreviousScope = previous !== null && isScopeValue(previous.companyScopeKey);
106
+ if (inferred !== undefined)
107
+ return ensureInferredScope(dir, hasMarker, previous, inferred);
76
108
  // A known company switch must settle recovery under its previous scope.
77
109
  // Legacy bots have no scope evidence: isolate their model context below,
78
110
  // but leave delivery/job markers intact for the existing recovery path.
@@ -84,26 +116,41 @@ export function ensureBotCompanyScope(dir, companyScopeKey) {
84
116
  }
85
117
  if (!provenPreviousScope)
86
118
  omitInflightRecoveryHistory(dir, pending);
87
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
88
- const names = fs.readdirSync(dir).filter((name) => /^(?:session|dm-history)(?:-[A-Za-z0-9_-]+)?\.json$/.test(name));
89
- let archiveDir;
90
- if (names.length) {
91
- const archiveRoot = path.join(dir, "company-scope-archives");
92
- fs.mkdirSync(archiveRoot, { recursive: true, mode: 0o700 });
93
- archiveDir = path.join(archiveRoot, randomUUID());
94
- fs.mkdirSync(archiveDir, { mode: 0o700 });
95
- for (const name of names)
96
- fs.renameSync(path.join(dir, name), path.join(archiveDir, name));
97
- }
119
+ const { names, archiveDir } = archiveConversations(dir);
98
120
  // Commit the new provenance only after every old conversation was isolated.
99
- const tmp = path.join(dir, `.company-scope.${randomUUID()}.tmp`);
100
- try {
101
- fs.writeFileSync(tmp, `${JSON.stringify({ v: 1, companyScopeKey: expected })}\n`, { mode: 0o600 });
102
- fs.renameSync(tmp, marker);
121
+ writeCompanyScopeMarker(dir, { v: 1, companyScopeKey: expected });
122
+ return { reset: names.length > 0, ...(archiveDir ? { archiveDir } : {}) };
123
+ }
124
+ /**
125
+ * The personal-bot path of ensureBotCompanyScope: the recorded inferred slug
126
+ * must match, or the conversations are archived. The explicit companyScopeKey
127
+ * field is carried over exactly as the explicit rule would leave it (absent,
128
+ * or null once the bot has had a marker), so a later explicit bind behaves as
129
+ * it did before inferred locks existed.
130
+ */
131
+ function ensureInferredScope(dir, hasMarker, previous, inferred) {
132
+ const previousInferred = previous !== null && isScopeValue(previous.inferredCompanySlug) ? previous.inferredCompanySlug : undefined;
133
+ const previousExplicit = previous !== null && isScopeValue(previous.companyScopeKey) ? previous.companyScopeKey : undefined;
134
+ const recordsNull = hasMarker && !(previousExplicit === undefined && previousInferred !== undefined);
135
+ const next = { v: 1, ...(recordsNull ? { companyScopeKey: null } : {}), inferredCompanySlug: inferred };
136
+ // No inferred record yet: a legacy personal bot (no marker, or an
137
+ // explicit-unbound one) keeps its history, as it did before inferred locks.
138
+ if (!hasMarker || (previousExplicit === null && previousInferred === undefined)) {
139
+ writeCompanyScopeMarker(dir, next);
140
+ return { reset: false };
103
141
  }
104
- finally {
105
- fs.rmSync(tmp, { force: true });
142
+ if (previousInferred === inferred && typeof previousExplicit !== "string")
143
+ return { reset: false };
144
+ const pending = readInflightTurns(dir, { strictIo: true });
145
+ // Leaving an explicit company keeps the explicit rule: settle its work first.
146
+ // A change in the HQ's company list cannot be undone from bot.json, so it
147
+ // never blocks the start; saved work is settled without the old history.
148
+ if (typeof previousExplicit === "string" && pending.length > 0) {
149
+ throw new Error("Bot company settings changed with pending work. Restore the previous company settings and settle pending work before switching companies.");
106
150
  }
151
+ omitInflightRecoveryHistory(dir, pending);
152
+ const { names, archiveDir } = archiveConversations(dir);
153
+ writeCompanyScopeMarker(dir, next);
107
154
  return { reset: names.length > 0, ...(archiveDir ? { archiveDir } : {}) };
108
155
  }
109
156
  //# sourceMappingURL=session.js.map
@@ -0,0 +1,2 @@
1
+ export declare function parseManifestSlugs(text: string): string[];
2
+ //# sourceMappingURL=manifest-slugs.d.ts.map
@@ -0,0 +1,26 @@
1
+ /** Leaf module: company slugs from companies/manifest.yaml, shared by the resolver and the bot runtime. */
2
+ const keepSlug = (slug) => slug !== "" && slug !== "_template" && slug !== "companies" && slug !== "unaffiliated_repos";
3
+ export function parseManifestSlugs(text) {
4
+ const slugs = new Set();
5
+ let wrapped = false;
6
+ for (const rawLine of text.split("\n")) {
7
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
8
+ if (/^companies:[ \t]*$/.test(line)) {
9
+ wrapped = true;
10
+ continue;
11
+ }
12
+ if (wrapped && /^[^ \t][^:]*:[ \t]*$/.test(line))
13
+ wrapped = false;
14
+ if (wrapped) {
15
+ const match = line.match(/^ {2}([A-Za-z][A-Za-z0-9_-]*):/);
16
+ if (match && keepSlug(match[1]))
17
+ slugs.add(match[1]);
18
+ continue;
19
+ }
20
+ const match = line.match(/^([A-Za-z][A-Za-z0-9_-]*):/);
21
+ if (match && keepSlug(match[1]))
22
+ slugs.add(match[1]);
23
+ }
24
+ return [...slugs];
25
+ }
26
+ //# sourceMappingURL=manifest-slugs.js.map
@@ -3,6 +3,13 @@ import type { SignalsStep } from "./types.js";
3
3
  export declare const EXTRACT_SIGNAL_TYPES: readonly ["action_item", "commitment", "decision", "risk", "question", "key_point", "participant_contribution", "summary"];
4
4
  export declare const DEFAULT_WORKLIST_LIMIT = 20;
5
5
  export declare const DEFAULT_TURN_TIMEOUT_MS: number;
6
+ export declare const WORKLIST_ITEM_TIMEOUT_MS = 10000;
7
+ /**
8
+ * The listing call may look up one ACL per item over the network (an item
9
+ * with no audience in its frontmatter), so its budget grows with the cap: the
10
+ * fixed script budget plus WORKLIST_ITEM_TIMEOUT_MS per item.
11
+ */
12
+ export declare function worklistTimeoutMs(limit: number): number;
6
13
  export declare const CODEX_PERMISSION_PROFILE = "hq_signals_extract";
7
14
  export declare const CANDIDATE_WRAPPER_NAME = "write-candidate.sh";
8
15
  /** The only tools a claude turn may use: Read inside the channel folder and the pinned wrapper. */
@@ -73,6 +73,15 @@ const ENTITY_TYPES = ["person", "project", "company", "concept"];
73
73
  export const DEFAULT_WORKLIST_LIMIT = 20;
74
74
  export const DEFAULT_TURN_TIMEOUT_MS = 10 * 60_000;
75
75
  const SCRIPT_TIMEOUT_MS = 60_000;
76
+ export const WORKLIST_ITEM_TIMEOUT_MS = 10_000;
77
+ /**
78
+ * The listing call may look up one ACL per item over the network (an item
79
+ * with no audience in its frontmatter), so its budget grows with the cap: the
80
+ * fixed script budget plus WORKLIST_ITEM_TIMEOUT_MS per item.
81
+ */
82
+ export function worklistTimeoutMs(limit) {
83
+ return SCRIPT_TIMEOUT_MS + Math.max(0, limit) * WORKLIST_ITEM_TIMEOUT_MS;
84
+ }
76
85
  export const CODEX_PERMISSION_PROFILE = "hq_signals_extract";
77
86
  export const CANDIDATE_WRAPPER_NAME = "write-candidate.sh";
78
87
  /** The only tools a claude turn may use: Read inside the channel folder and the pinned wrapper. */
@@ -678,7 +687,7 @@ export function createExtractStep(options = {}) {
678
687
  continue;
679
688
  }
680
689
  }
681
- const wl = ctx.spawn("bash", [worklist, ...base, "--limit", String(limit)], { cwd: ctx.hqRoot, env, timeoutMs: SCRIPT_TIMEOUT_MS });
690
+ const wl = ctx.spawn("bash", [worklist, ...base, "--limit", String(limit)], { cwd: ctx.hqRoot, env, timeoutMs: worklistTimeoutMs(limit) });
682
691
  if (wl.error || wl.status !== 0) {
683
692
  failures.push({ file: `sources/${ch.channel}`, error: `worklist failed: ${(wl.error?.message ?? wl.stderr.trim()).slice(0, 200) || `exit ${String(wl.status)}`}` });
684
693
  continue;
@@ -758,7 +767,7 @@ export function createExtractStep(options = {}) {
758
767
  itemsOut += v.kept;
759
768
  const md = ctx.spawn("bash", [worklist, ...base, "--mark-done", item.file], { cwd: ctx.hqRoot, env, timeoutMs: SCRIPT_TIMEOUT_MS });
760
769
  if (md.error || md.status !== 0) {
761
- failures.push({ file: item.file, error: `mark-done failed: ${md.stderr.trim().slice(0, 200)}` });
770
+ failures.push({ file: item.file, error: `mark-done failed: ${(md.error?.message ?? md.stderr.trim()).slice(0, 200) || `exit ${String(md.status)}`}` });
762
771
  continue;
763
772
  }
764
773
  processedAny = true;
@@ -74,6 +74,76 @@ export declare function requestedApiKeyCompany(flagCompany: string | undefined):
74
74
  * Returns the verified uid; throws before any secret is fetched otherwise.
75
75
  */
76
76
  export declare function assertApiKeyCompany(token: string, requested: string, commandLabel: string): Promise<string>;
77
+ /** The out_* uid and bound company a company Outpost token carries. */
78
+ export interface CompanyOutpostIdentity {
79
+ outpostUid: string;
80
+ companyUid: string;
81
+ }
82
+ /**
83
+ * The company Outpost identity named by the token this command will send, or
84
+ * null for any other token (person, agent, owner-delegated outpost, API key).
85
+ *
86
+ * hq-cloud says whether this process is a machine identity but exposes no
87
+ * accessor for the bound company, and hq-pro routes on the claims of the
88
+ * token it receives, so the route choice reads them from that token. The
89
+ * claims are not verified here; hq-pro verifies the token and re-checks the
90
+ * live Outpost binding on every request. A token with any company Outpost
91
+ * marker but no usable company or out_* uid fails closed rather than falling
92
+ * through to the person routes.
93
+ */
94
+ export declare function companyOutpostIdentityFromToken(token: string): CompanyOutpostIdentity | null;
95
+ /**
96
+ * True when this process will authenticate as a company Outpost: hq-cli picks
97
+ * machine tokens and hq-cloud's machine identity (a delegated session file or
98
+ * a credentials file) is a company Outpost. Local state only, no network.
99
+ * Used before a credential is resolved, where there is no token to read yet.
100
+ */
101
+ export declare function callerIsCompanyOutpost(env?: NodeJS.ProcessEnv): boolean;
102
+ /**
103
+ * The company uid a company Outpost reads secrets from. The token names its
104
+ * bound company, so an omitted `--company` uses it with no lookup. An explicit
105
+ * `--company` must name that same company: a uid is compared locally, and a
106
+ * slug is checked with GET /entity/check-slug/me, the one slug lookup hq-pro
107
+ * answers for a company Outpost (it reports a conflict only for the bound
108
+ * company). Any other company, and `--personal`, is refused.
109
+ */
110
+ export declare function resolveCompanyOutpostSecretsCompany(token: string, outpost: CompanyOutpostIdentity, scope: {
111
+ personal?: boolean;
112
+ companySlug?: string;
113
+ }): Promise<string>;
114
+ /**
115
+ * The company uid a `secrets exec` / `env` / `get` call reads from. A company
116
+ * Outpost token takes its company from the token (see
117
+ * {@link resolveCompanyOutpostSecretsCompany}); every other Cognito token keeps
118
+ * the existing {@link getEntityUid} resolution unchanged.
119
+ */
120
+ export declare function resolveSecretsReadCompanyUid(token: string, scope: {
121
+ personal?: boolean;
122
+ companySlug?: string;
123
+ }, options?: {
124
+ allowOfflineScopeFallback?: boolean;
125
+ }): Promise<string>;
126
+ /**
127
+ * The message for a 403 a company Outpost gets on the single-secret GET, or
128
+ * null when the status/body is not one of these denials.
129
+ *
130
+ * - No code: hq-pro answers a plain "read permission required" both when the
131
+ * secret is not shared with this Outpost and when the Outpost's live binding
132
+ * to its company is not active, and the response does not say which.
133
+ * - `reveal_disabled` / `env_disabled`: the secret is script-locked. The
134
+ * Outpost route evaluates the read with no script attestation, so a
135
+ * script-locked secret can never be read by an Outpost job.
136
+ */
137
+ export declare function companyOutpostReadDenialMessage(name: string, status: number, body: Record<string, unknown>, companyUid: string, outpost: CompanyOutpostIdentity): string | null;
138
+ /**
139
+ * Fail closed when HQ_MACHINE_SESSION_FILE is set but hq-cloud cannot use the
140
+ * file. The parent that set it chose that identity for this process, so an
141
+ * unreadable or malformed file must stop the read with a message naming the
142
+ * file, never fall through to another identity or a generic scope error.
143
+ * An expired but well-formed session passes here; the token request reports
144
+ * the expiry.
145
+ */
146
+ export declare function assertDelegatedMachineSessionUsable(): void;
77
147
  export declare function loadRevealedSecrets(token: string, companyUid: string, keys: string[], usage?: SecretUsage, options?: {
78
148
  allowOfflineFallback?: boolean;
79
149
  /** Filled with each returned row's scriptLock.mode (undefined when absent). */
@@ -2,11 +2,14 @@ import chalk from "chalk";
2
2
  import { writeSync } from "node:fs";
3
3
  import * as nodePath from "node:path";
4
4
  import { cognitoCachePrincipalUid } from "./utils/cognito-session.js";
5
- import { isAuthError } from "./utils/auth-error.js";
6
5
  import { DEFAULT_SECRETS_CACHE_TTL_MS, hasCacheEntry, readCache, removeCacheEntry, writeCache, } from "./utils/secrets-cache.js";
7
6
  import { computeSha256 } from "./utils/integrity.js";
8
7
  import { SECRET_NAME_PATTERN } from "./commands/_patterns.js";
9
8
  import { finishEnvExportsAndExit } from "./commands/secrets-env-exit.js";
9
+ import { DELEGATED_MACHINE_SESSION_ENV, delegatedMachineSessionFilePath, loadDelegatedMachineSession, loadMachineCreds, } from "@indigoai-us/hq-cloud";
10
+ import { AuthError, isAuthError } from "./utils/auth-error.js";
11
+ import { peekIdToken } from "./utils/id-token.js";
12
+ import { wantsMachineCognitoTokens } from "./utils/machine-principal-state.js";
10
13
  import { getEntityUid, looksLikeCompanyUid, vaultApiFetch } from "./utils/vault-api.js";
11
14
  import { HQ_API_KEY_PREFIX, resolveVaultCredentialForCapability, } from "./utils/resolve-vault-credential.js";
12
15
  import { CACHED_SECRETS_SERVER_UNAVAILABLE_MESSAGE, isSecretLoadUnavailable, isUnavailableSecretLoadHttpStatus, SecretLoadUnavailableError, } from "./utils/secret-load-availability.js";
@@ -221,10 +224,224 @@ export async function assertApiKeyCompany(token, requested, commandLabel) {
221
224
  throw new Error(`Could not verify that HQ_API_KEY belongs to ${requested} (HTTP ${response.status}` +
222
225
  `${message ? `: ${message}` : ""}).`);
223
226
  }
227
+ const COMPANY_OUTPOST_UID_PATTERN = /^out_[0-9A-HJKMNP-TV-Z]{26}$/;
228
+ const COMPANY_UID_PATTERN = /^cmp_[A-Za-z0-9]+$/;
229
+ /**
230
+ * The company Outpost identity named by the token this command will send, or
231
+ * null for any other token (person, agent, owner-delegated outpost, API key).
232
+ *
233
+ * hq-cloud says whether this process is a machine identity but exposes no
234
+ * accessor for the bound company, and hq-pro routes on the claims of the
235
+ * token it receives, so the route choice reads them from that token. The
236
+ * claims are not verified here; hq-pro verifies the token and re-checks the
237
+ * live Outpost binding on every request. A token with any company Outpost
238
+ * marker but no usable company or out_* uid fails closed rather than falling
239
+ * through to the person routes.
240
+ */
241
+ export function companyOutpostIdentityFromToken(token) {
242
+ if (token.startsWith(HQ_API_KEY_PREFIX))
243
+ return null;
244
+ const claims = peekIdToken(token);
245
+ const has = (key) => Object.prototype.hasOwnProperty.call(claims, key);
246
+ if (claims["custom:entityType"] !== "company-outpost" &&
247
+ !has("custom:ownerCompanyUid") &&
248
+ !has("custom:companyOutpostId")) {
249
+ return null;
250
+ }
251
+ const outpostUid = claims["custom:entityUid"];
252
+ const companyUid = claims["custom:ownerCompanyUid"];
253
+ if (claims["custom:entityType"] !== "company-outpost" ||
254
+ typeof outpostUid !== "string" ||
255
+ !COMPANY_OUTPOST_UID_PATTERN.test(outpostUid) ||
256
+ typeof companyUid !== "string" ||
257
+ !COMPANY_UID_PATTERN.test(companyUid)) {
258
+ throw new Error("This HQ session carries company Outpost markers but does not name its Outpost (out_…) " +
259
+ "and company (cmp_…). Refusing to read secrets with it.");
260
+ }
261
+ return { outpostUid, companyUid };
262
+ }
263
+ /**
264
+ * True when this process will authenticate as a company Outpost: hq-cli picks
265
+ * machine tokens and hq-cloud's machine identity (a delegated session file or
266
+ * a credentials file) is a company Outpost. Local state only, no network.
267
+ * Used before a credential is resolved, where there is no token to read yet.
268
+ */
269
+ export function callerIsCompanyOutpost(env = process.env) {
270
+ try {
271
+ return wantsMachineCognitoTokens({}, env) && loadMachineCreds()?.entityType === "company-outpost";
272
+ }
273
+ catch {
274
+ return false;
275
+ }
276
+ }
277
+ /**
278
+ * The company uid a company Outpost reads secrets from. The token names its
279
+ * bound company, so an omitted `--company` uses it with no lookup. An explicit
280
+ * `--company` must name that same company: a uid is compared locally, and a
281
+ * slug is checked with GET /entity/check-slug/me, the one slug lookup hq-pro
282
+ * answers for a company Outpost (it reports a conflict only for the bound
283
+ * company). Any other company, and `--personal`, is refused.
284
+ */
285
+ export async function resolveCompanyOutpostSecretsCompany(token, outpost, scope) {
286
+ if (scope.personal) {
287
+ throw new Error("A company Outpost session has no personal vault. Drop --personal; it reads its company's secrets.");
288
+ }
289
+ const ref = scope.companySlug?.trim();
290
+ if (!ref || ref === outpost.companyUid)
291
+ return outpost.companyUid;
292
+ const notBound = () => new Error(`Company '${ref}' is not the company this Outpost (${outpost.outpostUid}) is bound to ` +
293
+ `(${outpost.companyUid}). A company Outpost can read only its own company's secrets.`);
294
+ if (looksLikeCompanyUid(ref) || /^(prs|agt|out)_/.test(ref))
295
+ throw notBound();
296
+ const res = await vaultApiFetch({
297
+ token,
298
+ path: "/entity/check-slug/me",
299
+ query: { type: "company", slug: ref },
300
+ signal: AbortSignal.timeout(SECRET_LOAD_TIMEOUT_MS),
301
+ });
302
+ const body = (await res.json().catch(() => ({})));
303
+ if (!res.ok) {
304
+ if (res.status === 401)
305
+ throw companyOutpostSessionRejected();
306
+ const message = `Failed to resolve company '${ref}' for this Outpost: ${extractApiMessage(body, `HTTP ${res.status}`)}`;
307
+ if (isUnavailableSecretLoadHttpStatus(res.status))
308
+ throw new SecretLoadUnavailableError(message);
309
+ throw new Error(message);
310
+ }
311
+ if (body.available === false && body.conflictingCompanyUid === outpost.companyUid) {
312
+ return outpost.companyUid;
313
+ }
314
+ throw notBound();
315
+ }
316
+ function companyOutpostSessionRejected() {
317
+ return new AuthError("HQ rejected this company Outpost session (HTTP 401). The job runner refreshes " +
318
+ "HQ_MACHINE_SESSION_FILE; re-run the job, and if it persists check the Outpost's binding.");
319
+ }
320
+ /**
321
+ * The company uid a `secrets exec` / `env` / `get` call reads from. A company
322
+ * Outpost token takes its company from the token (see
323
+ * {@link resolveCompanyOutpostSecretsCompany}); every other Cognito token keeps
324
+ * the existing {@link getEntityUid} resolution unchanged.
325
+ */
326
+ export async function resolveSecretsReadCompanyUid(token, scope, options = {}) {
327
+ const outpost = companyOutpostIdentityFromToken(token);
328
+ if (outpost)
329
+ return resolveCompanyOutpostSecretsCompany(token, outpost, scope);
330
+ return getEntityUid(token, scope, options);
331
+ }
332
+ /**
333
+ * The message for a 403 a company Outpost gets on the single-secret GET, or
334
+ * null when the status/body is not one of these denials.
335
+ *
336
+ * - No code: hq-pro answers a plain "read permission required" both when the
337
+ * secret is not shared with this Outpost and when the Outpost's live binding
338
+ * to its company is not active, and the response does not say which.
339
+ * - `reveal_disabled` / `env_disabled`: the secret is script-locked. The
340
+ * Outpost route evaluates the read with no script attestation, so a
341
+ * script-locked secret can never be read by an Outpost job.
342
+ */
343
+ export function companyOutpostReadDenialMessage(name, status, body, companyUid, outpost) {
344
+ if (status !== 403)
345
+ return null;
346
+ if (body.code === undefined) {
347
+ return (`Failed to fetch secret '${name}': ${extractApiMessage(body, "No read permission")}. ` +
348
+ `Either the secret is not shared with this Outpost (${outpost.outpostUid}), or the Outpost's ` +
349
+ `binding to ${companyUid} is not active. To share it: ` +
350
+ `hq secrets --company ${companyUid} share ${name} --with ${outpost.outpostUid}`);
351
+ }
352
+ if (body.code === "reveal_disabled" || body.code === "env_disabled") {
353
+ return (`Failed to fetch secret '${name}': it is script-locked, and company Outpost jobs cannot ` +
354
+ `read script-locked secrets (the Outpost secret route carries no script attestation).`);
355
+ }
356
+ return null;
357
+ }
358
+ /**
359
+ * Fail closed when HQ_MACHINE_SESSION_FILE is set but hq-cloud cannot use the
360
+ * file. The parent that set it chose that identity for this process, so an
361
+ * unreadable or malformed file must stop the read with a message naming the
362
+ * file, never fall through to another identity or a generic scope error.
363
+ * An expired but well-formed session passes here; the token request reports
364
+ * the expiry.
365
+ */
366
+ export function assertDelegatedMachineSessionUsable() {
367
+ const file = delegatedMachineSessionFilePath();
368
+ if (file === null)
369
+ return;
370
+ if (loadDelegatedMachineSession() !== null)
371
+ return;
372
+ throw new Error(`${DELEGATED_MACHINE_SESSION_ENV} names ${file}, but that file is not a usable delegated machine ` +
373
+ `session (missing, unreadable, malformed, or its tokens do not describe the identity it claims). ` +
374
+ `Refusing to read secrets; the job runner must rewrite the session file.`);
375
+ }
376
+ function encodeSecretNamePath(name) {
377
+ // Encode each segment so structural `/` separators stay route separators
378
+ // for GET /secrets/{companyUid}/name/{proxy+} (same as `hq secrets get`).
379
+ return name.split("/").map(encodeURIComponent).join("/");
380
+ }
381
+ /**
382
+ * Read each key with GET /secrets/{companyUid}/name/{name}?reveal=true, the
383
+ * only secrets route hq-pro serves to a company Outpost. Throws on the first
384
+ * key that does not resolve, with the same `Failed to fetch secret '<k>':
385
+ * <reason>` shape as the batch path. Values are never written to the local
386
+ * secrets cache and there is no offline fallback: the Outpost reads only what
387
+ * the server authorizes on each run.
388
+ *
389
+ * The route evaluates the read as a plain reveal with no script usage, so a
390
+ * script-locked or high-security secret is refused by the server; the denial
391
+ * is surfaced as is (high-security gets the sandbox guidance).
392
+ */
393
+ async function loadRevealedSecretsForCompanyOutpost(token, outpost, companyUid, keys, lockModes) {
394
+ if (companyUid !== outpost.companyUid) {
395
+ throw new Error(`This company Outpost (${outpost.outpostUid}) is bound to ${outpost.companyUid} ` +
396
+ `and cannot read secrets from ${companyUid}.`);
397
+ }
398
+ const resolved = new Map();
399
+ for (const name of [...new Set(keys)]) {
400
+ const res = await vaultApiFetch({
401
+ token,
402
+ path: `/secrets/${encodeURIComponent(companyUid)}/name/${encodeSecretNamePath(name)}`,
403
+ query: { reveal: "true" },
404
+ signal: AbortSignal.timeout(SECRET_LOAD_TIMEOUT_MS),
405
+ });
406
+ const body = (await res.json().catch(() => ({})));
407
+ if (!res.ok) {
408
+ if (body.highSecurity === true ||
409
+ body.code === "high_security_denied" ||
410
+ body.code === "high_security_sandbox_only") {
411
+ throw new Error(highSecuritySandboxOnlyMessage(name));
412
+ }
413
+ if (res.status === 401)
414
+ throw companyOutpostSessionRejected();
415
+ if (res.status === 404) {
416
+ throw new Error(`Failed to fetch secret '${name}': Secret not found`);
417
+ }
418
+ const message = extractApiMessage(body, res.statusText || `HTTP ${res.status}`);
419
+ if (isUnavailableSecretLoadHttpStatus(res.status)) {
420
+ throw new SecretLoadUnavailableError(`Failed to fetch secret '${name}': ${message}`);
421
+ }
422
+ throw new Error(companyOutpostReadDenialMessage(name, res.status, body, companyUid, outpost) ??
423
+ `Failed to fetch secret '${name}': ${message}`);
424
+ }
425
+ const secret = typeof body.secret === "object" && body.secret !== null
426
+ ? body.secret
427
+ : null;
428
+ if (!secret || secret.name !== name || typeof secret.value !== "string") {
429
+ throw new Error(`Failed to fetch secret '${name}': malformed fetch response`);
430
+ }
431
+ const mode = secret.scriptLock?.mode;
432
+ lockModes?.set(name, typeof mode === "string" ? mode : undefined);
433
+ resolved.set(name, secret.value);
434
+ }
435
+ return resolved;
436
+ }
224
437
  export async function loadRevealedSecrets(token, companyUid, keys, usage, options = {}) {
225
438
  if (token.startsWith(HQ_API_KEY_PREFIX)) {
226
439
  return loadRevealedSecretsViaApiKey(token, keys, companyUid, options.lockModes);
227
440
  }
441
+ const outpost = companyOutpostIdentityFromToken(token);
442
+ if (outpost) {
443
+ return loadRevealedSecretsForCompanyOutpost(token, outpost, companyUid, keys, options.lockModes);
444
+ }
228
445
  const resolved = new Map();
229
446
  const requested = [...new Set(keys)];
230
447
  const principalUid = cognitoCachePrincipalUid(token);
@@ -469,6 +686,7 @@ export async function runSecretsEnvCommand(opts, parentScope, options = {}) {
469
686
  console.error(chalk.yellow("stdout is a terminal — values redacted. Use: source <(hq secrets env --only KEY1,KEY2)"));
470
687
  }
471
688
  const keys = parseSecretNameList(opts.only);
689
+ assertDelegatedMachineSessionUsable();
472
690
  const cred = await resolveVaultCredentialForCapability("secrets:read", "secrets env", secretsCredentialOptions());
473
691
  const apiKeyCompanyRef = cred.kind === "api-key"
474
692
  ? requestedApiKeyCompany(parentScope.company)
@@ -477,7 +695,7 @@ export async function runSecretsEnvCommand(opts, parentScope, options = {}) {
477
695
  ? (apiKeyCompanyRef
478
696
  ? await assertApiKeyCompany(cred.token, apiKeyCompanyRef, "secrets env")
479
697
  : "__api_key__")
480
- : await getEntityUid(cred.token, scopeOpts(parentScope), {
698
+ : await resolveSecretsReadCompanyUid(cred.token, scopeOpts(parentScope), {
481
699
  allowOfflineScopeFallback: true,
482
700
  });
483
701
  const usage = cred.kind === "cognito"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.197",
3
+ "version": "5.345.198",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -27,6 +27,8 @@
27
27
  ],
28
28
  "scripts": {
29
29
  "build": "node scripts/generate-dsn.mjs && node scripts/build-flag-registry-worker.mjs && tsc && node scripts/generate-command-catalog.mjs --check && node scripts/copy-schemas.mjs && node scripts/chmod-bins.mjs && node scripts/bundle-sync-runner.mjs",
30
+ "release:preflight": "node scripts/check-fleet-local-pin-published.mjs",
31
+ "release:tag": "node scripts/check-fleet-local-pin-published.mjs --release-tag",
30
32
  "prepublishOnly": "npm run build",
31
33
  "pretypecheck": "node scripts/generate-dsn.mjs && node scripts/build-flag-registry-worker.mjs",
32
34
  "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.typetest.json",
@@ -114,11 +116,11 @@
114
116
  "*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}": "eslint --cache --no-error-on-unmatched-pattern"
115
117
  },
116
118
  "optionalDependencies": {
117
- "@indigoai-us/hq-hook-win32-x64": "5.345.197",
118
- "@indigoai-us/hq-hook-win32-arm64": "5.345.197",
119
- "@indigoai-us/hq-hook-darwin-x64": "5.345.197",
120
- "@indigoai-us/hq-hook-darwin-arm64": "5.345.197",
121
- "@indigoai-us/hq-hook-linux-x64": "5.345.197",
122
- "@indigoai-us/hq-hook-linux-arm64": "5.345.197"
119
+ "@indigoai-us/hq-hook-win32-x64": "5.345.198",
120
+ "@indigoai-us/hq-hook-win32-arm64": "5.345.198",
121
+ "@indigoai-us/hq-hook-darwin-x64": "5.345.198",
122
+ "@indigoai-us/hq-hook-darwin-arm64": "5.345.198",
123
+ "@indigoai-us/hq-hook-linux-x64": "5.345.198",
124
+ "@indigoai-us/hq-hook-linux-arm64": "5.345.198"
123
125
  }
124
126
  }