@indigoai-us/hq-cli 5.345.196 → 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.
- package/CHANGELOG.md +85 -0
- package/dist/command-catalog.generated.d.ts +287 -6
- package/dist/command-catalog.generated.js +352 -6
- package/dist/commands/core-resolve-company-native.d.ts +2 -1
- package/dist/commands/core-resolve-company-native.js +2 -24
- package/dist/commands/deploy-domains.d.ts +14 -2
- package/dist/commands/deploy-domains.js +726 -42
- package/dist/commands/integrations-connect.js +41 -3
- package/dist/commands/secrets.js +27 -6
- package/dist/lib/bot/company-bind.d.ts +34 -0
- package/dist/lib/bot/company-bind.js +112 -0
- package/dist/lib/bot/config.js +3 -1
- package/dist/lib/bot/fleet-local.d.ts +12 -0
- package/dist/lib/bot/fleet-local.js +40 -10
- package/dist/lib/bot/run.js +35 -8
- package/dist/lib/bot/session.d.ts +19 -1
- package/dist/lib/bot/session.js +75 -28
- package/dist/lib/core-utils/manifest-slugs.d.ts +2 -0
- package/dist/lib/core-utils/manifest-slugs.js +26 -0
- package/dist/lib/signals/steps/extract.d.ts +7 -0
- package/dist/lib/signals/steps/extract.js +11 -2
- package/dist/secrets-env-action.d.ts +70 -0
- package/dist/secrets-env-action.js +220 -2
- package/package.json +9 -7
package/dist/lib/bot/session.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
67
|
-
parsed
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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,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:
|
|
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
|
|
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.
|
|
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.
|
|
118
|
-
"@indigoai-us/hq-hook-win32-arm64": "5.345.
|
|
119
|
-
"@indigoai-us/hq-hook-darwin-x64": "5.345.
|
|
120
|
-
"@indigoai-us/hq-hook-darwin-arm64": "5.345.
|
|
121
|
-
"@indigoai-us/hq-hook-linux-x64": "5.345.
|
|
122
|
-
"@indigoai-us/hq-hook-linux-arm64": "5.345.
|
|
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
|
}
|