@indigoai-us/hq-cli 5.345.154 → 5.345.156
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 +138 -0
- package/dist/command-catalog.generated.d.ts +59 -7
- package/dist/command-catalog.generated.js +75 -7
- package/dist/commands/agent-inbox.js +2 -2
- package/dist/commands/auth.d.ts +2 -0
- package/dist/commands/auth.js +92 -3
- package/dist/commands/core.js +1 -1
- package/dist/commands/files-browse.d.ts +16 -0
- package/dist/commands/files-browse.js +52 -1
- package/dist/commands/integrations-core.d.ts +13 -1
- package/dist/commands/integrations-core.js +49 -4
- package/dist/commands/integrations.js +4 -2
- package/dist/commands/library-add.d.ts +6 -2
- package/dist/commands/library-add.js +45 -6
- package/dist/commands/library-caption.d.ts +38 -1
- package/dist/commands/library-caption.js +304 -3
- package/dist/commands/library-link.d.ts +7 -3
- package/dist/commands/library-link.js +10 -4
- package/dist/commands/library-remove.d.ts +122 -0
- package/dist/commands/library-remove.js +681 -0
- package/dist/commands/library.d.ts +26 -1
- package/dist/commands/library.js +101 -14
- package/dist/commands/secrets.d.ts +16 -0
- package/dist/commands/secrets.js +144 -8
- package/dist/commands/storage-offload.d.ts +22 -4
- package/dist/commands/storage-offload.js +196 -2
- package/dist/commands/storage.js +5 -0
- package/dist/lib/agent-kit/inbox-state.d.ts +7 -0
- package/dist/lib/agent-kit/inbox-state.js +5 -1
- package/dist/lib/agent-kit/mcp/tools.js +2 -1
- package/dist/lib/daemon/machine-login.d.ts +20 -0
- package/dist/lib/daemon/machine-login.js +53 -8
- package/dist/lib/daemon/machine-token-identity.d.ts +34 -0
- package/dist/lib/daemon/machine-token-identity.js +123 -0
- package/dist/lib/doctor/checks/sync-health.d.ts +9 -0
- package/dist/lib/doctor/checks/sync-health.js +66 -1
- package/dist/lib/doctor/company-resolution-state.d.ts +30 -0
- package/dist/lib/doctor/company-resolution-state.js +89 -0
- package/dist/lib/doctor/fix/apply.d.ts +36 -6
- package/dist/lib/doctor/fix/apply.js +128 -12
- package/dist/lib/doctor/types.d.ts +1 -1
- package/dist/lib/doctor/types.js +1 -0
- package/dist/lib/hook-engine/resolve.js +6 -2
- package/dist/lib/index-render/company-knowledge.d.ts +1 -1
- package/dist/lib/index-render/company-knowledge.js +3 -2
- package/dist/lib/index-render/index.js +44 -3
- package/dist/lib/index-render/projects.d.ts +1 -1
- package/dist/lib/index-render/projects.js +4 -2
- package/dist/lib/lanes/registry-flag.d.ts +23 -2
- package/dist/lib/lanes/registry-flag.js +256 -36
- package/dist/lib/personal-secrets-audit.d.ts +31 -0
- package/dist/lib/personal-secrets-audit.js +45 -0
- package/dist/sentry-before-send.js +1 -0
- package/dist/utils/access-denied-hint.js +6 -0
- package/dist/utils/client-health.d.ts +34 -0
- package/dist/utils/client-health.js +96 -3
- package/package.json +7 -7
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { CompanyIndexUserError, renderCompanies } from "./companies.js";
|
|
2
4
|
import { renderCompanyKnowledge } from "./company-knowledge.js";
|
|
3
5
|
import { renderOrchestrator } from "./orchestrator.js";
|
|
4
6
|
import { renderProjects } from "./projects.js";
|
|
@@ -8,12 +10,51 @@ import { renderSocialDrafts } from "./social-drafts.js";
|
|
|
8
10
|
import { renderThreads } from "./threads.js";
|
|
9
11
|
import { renderWorkers } from "./workers.js";
|
|
10
12
|
const allOrder = ["threads", "orchestrator", "companies", "projects", "company-knowledge", "public-knowledge", "workers", "reports", "social-drafts"];
|
|
13
|
+
function companyScope(target, context, args) {
|
|
14
|
+
let company;
|
|
15
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
16
|
+
let value = args[i];
|
|
17
|
+
if (value === "--company")
|
|
18
|
+
value = args[++i] ?? "";
|
|
19
|
+
else if (value.startsWith("--company="))
|
|
20
|
+
value = value.slice("--company=".length);
|
|
21
|
+
if (!value || value.startsWith("-"))
|
|
22
|
+
throw new CompanyIndexUserError(`rebuild-index ${target}: expected a company slug; unknown or incomplete option`);
|
|
23
|
+
if (company !== undefined)
|
|
24
|
+
throw new CompanyIndexUserError(`rebuild-index ${target}: supply only one company slug`);
|
|
25
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(value))
|
|
26
|
+
throw new CompanyIndexUserError(`rebuild-index ${target}: invalid company slug`);
|
|
27
|
+
company = value;
|
|
28
|
+
}
|
|
29
|
+
if (company !== undefined) {
|
|
30
|
+
const directory = path.join(context.root, "companies", company);
|
|
31
|
+
try {
|
|
32
|
+
if (!fs.lstatSync(directory).isDirectory())
|
|
33
|
+
throw new CompanyIndexUserError(`rebuild-index ${target}: company must be a real directory`);
|
|
34
|
+
const contentDirectory = path.join(directory, target === "projects" ? "projects" : "knowledge");
|
|
35
|
+
if (fs.existsSync(contentDirectory) && !fs.lstatSync(contentDirectory).isDirectory())
|
|
36
|
+
throw new CompanyIndexUserError(`rebuild-index ${target}: content must be a real directory`);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (error.code === "ENOENT")
|
|
40
|
+
throw new CompanyIndexUserError(`rebuild-index ${target}: no company directory named "${company}"`);
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return company;
|
|
45
|
+
}
|
|
11
46
|
export function renderIndexTarget(target, context, args = []) {
|
|
47
|
+
if (target === "company-knowledge" || target === "projects") {
|
|
48
|
+
if (args.some((arg) => arg === "--help" || arg === "-h")) {
|
|
49
|
+
(context.output ?? ((message) => process.stdout.write(message)))(`Usage: hq core rebuild-index ${target} [<company> | --company <company>] [--accept]\n\nWith a company, rebuild only that company's index. Without a company, retain the all-company rebuild. Help performs no writes.\n`);
|
|
50
|
+
return { written: [] };
|
|
51
|
+
}
|
|
52
|
+
const company = companyScope(target, context, args);
|
|
53
|
+
return target === "projects" ? renderProjects(context, company) : renderCompanyKnowledge(context, company);
|
|
54
|
+
}
|
|
12
55
|
switch (target) {
|
|
13
56
|
case "companies": return renderCompanies(context, args);
|
|
14
|
-
case "company-knowledge": return renderCompanyKnowledge(context);
|
|
15
57
|
case "orchestrator": return renderOrchestrator(context);
|
|
16
|
-
case "projects": return renderProjects(context);
|
|
17
58
|
case "public-knowledge": return renderPublicKnowledge(context);
|
|
18
59
|
case "reports": return renderReports(context);
|
|
19
60
|
case "social-drafts": return renderSocialDrafts(context);
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { RenderContext, RenderResult } from "./shared.js";
|
|
2
|
-
export declare function renderProjects(context: RenderContext): RenderResult;
|
|
2
|
+
export declare function renderProjects(context: RenderContext, selectedCompany?: string): RenderResult;
|
|
3
3
|
//# sourceMappingURL=projects.d.ts.map
|
|
@@ -64,9 +64,9 @@ function renderCompanyProjects(context, company, projectRoot) {
|
|
|
64
64
|
log(context, `rebuild-projects-index: wrote ${output}`);
|
|
65
65
|
return written;
|
|
66
66
|
}
|
|
67
|
-
export function renderProjects(context) {
|
|
67
|
+
export function renderProjects(context, selectedCompany) {
|
|
68
68
|
const written = [];
|
|
69
|
-
const companyDirectories = immediateEntries(context.root, "companies", "dir");
|
|
69
|
+
const companyDirectories = selectedCompany === undefined ? immediateEntries(context.root, "companies", "dir") : [at(context.root, `companies/${selectedCompany}`)];
|
|
70
70
|
for (const companyDirectory of companyDirectories) {
|
|
71
71
|
const company = basename(companyDirectory);
|
|
72
72
|
if (company.startsWith("_") || isHidden(company))
|
|
@@ -76,6 +76,8 @@ export function renderProjects(context) {
|
|
|
76
76
|
written.push(...renderCompanyProjects(context, company, projectRoot));
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
|
+
if (selectedCompany !== undefined)
|
|
80
|
+
return { written };
|
|
79
81
|
const base = "personal/projects";
|
|
80
82
|
if (!exists(context.root, base)) {
|
|
81
83
|
log(context, "rebuild-projects-index: personal/projects/ missing, skipping");
|
|
@@ -18,7 +18,15 @@ export interface ReadLanesFlagOptions {
|
|
|
18
18
|
env?: NodeJS.ProcessEnv;
|
|
19
19
|
createClient?: LanesFlagClientFactory;
|
|
20
20
|
getToken?: () => string | Promise<string>;
|
|
21
|
+
/** Second token source used once after a 401. Defaults to `getToken`, else a forced session refresh. */
|
|
22
|
+
refreshToken?: () => string | Promise<string>;
|
|
21
23
|
reportFailure?: (error: unknown) => void;
|
|
24
|
+
/** Fetch used by the default flag client; tests inject this to drive HTTP outcomes. */
|
|
25
|
+
fetch?: typeof fetch;
|
|
26
|
+
/** Last-known-good cache directory; null disables it. Defaults to the HQ state dir. */
|
|
27
|
+
cacheDir?: string | null;
|
|
28
|
+
retryBackoffMs?: number;
|
|
29
|
+
sleep?: (ms: number) => Promise<void>;
|
|
22
30
|
}
|
|
23
31
|
export declare function readLanesFlagLocalOverride(flagKey: string, env?: NodeJS.ProcessEnv): boolean | null;
|
|
24
32
|
export type LanesRegistryFlagResult = {
|
|
@@ -27,10 +35,23 @@ export type LanesRegistryFlagResult = {
|
|
|
27
35
|
} | {
|
|
28
36
|
state: "unknown";
|
|
29
37
|
};
|
|
38
|
+
export declare const LANES_FLAG_RETRY_BACKOFF_MS = 250;
|
|
39
|
+
export declare const LANES_FLAG_CACHE_FILE = "lanes-flag-cache.json";
|
|
40
|
+
/**
|
|
41
|
+
* Oldest last-known-good entry a failed read may serve. Every successful
|
|
42
|
+
* snapshot read restamps resolvedAt, so this bounds how long a value can
|
|
43
|
+
* outlive its last confirmation by the registry.
|
|
44
|
+
*/
|
|
45
|
+
export declare const LANES_FLAG_CACHE_MAX_AGE_MS: number;
|
|
46
|
+
/** Error message safe for stderr: token-like substrings removed, length bounded. */
|
|
47
|
+
export declare function sanitizeFlagErrorMessage(error: unknown): string;
|
|
30
48
|
/**
|
|
31
49
|
* Read a lanes flag once for each company identity in this CLI process. The
|
|
32
|
-
* cache
|
|
33
|
-
*
|
|
50
|
+
* in-process cache holds the in-flight or settled result. The last registry
|
|
51
|
+
* snapshot value per flag and company is also kept in the HQ state dir and
|
|
52
|
+
* used when a later read fails transiently (timeout, network error, 5xx) and
|
|
53
|
+
* the value was confirmed within LANES_FLAG_CACHE_MAX_AGE_MS. A local override
|
|
54
|
+
* is checked on every call and wins first.
|
|
34
55
|
*/
|
|
35
56
|
export declare function readLanesRegistryFlag(flagKey: string, options?: ReadLanesFlagOptions): Promise<boolean>;
|
|
36
57
|
/** Read a lanes flag while preserving registry failure as an explicit unknown state. */
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createFlagClient, readLocalOverride, } from "@indigoai-us/hq-flags-client";
|
|
2
|
-
import
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { ensureCognitoIdToken, loadCachedTokens, refreshActivePersonTokens, wantsMachineCognitoTokens, } from "../../utils/cognito-session.js";
|
|
3
5
|
import { FLAG_REGISTRY_DEFAULT_ENDPOINT, resolveFlagRegistryEndpoint, } from "../flag-registry-endpoint.js";
|
|
4
6
|
export const LANES_FLAG_API_DEFAULT_ENDPOINT = FLAG_REGISTRY_DEFAULT_ENDPOINT;
|
|
5
7
|
export const LANES_FLAG_READ_TIMEOUT_MS = 5_000;
|
|
@@ -15,15 +17,118 @@ class MissingFlagTokenError extends Error {
|
|
|
15
17
|
this.name = "MissingFlagTokenError";
|
|
16
18
|
}
|
|
17
19
|
}
|
|
20
|
+
export const LANES_FLAG_RETRY_BACKOFF_MS = 250;
|
|
21
|
+
export const LANES_FLAG_CACHE_FILE = "lanes-flag-cache.json";
|
|
22
|
+
const FLAG_CACHE_MAX_ENTRIES = 256;
|
|
23
|
+
/**
|
|
24
|
+
* Oldest last-known-good entry a failed read may serve. Every successful
|
|
25
|
+
* snapshot read restamps resolvedAt, so this bounds how long a value can
|
|
26
|
+
* outlive its last confirmation by the registry.
|
|
27
|
+
*/
|
|
28
|
+
export const LANES_FLAG_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
29
|
+
/**
|
|
30
|
+
* HQ state dir from the caller's env (`HQ_STATE_DIR`, else `<home>/.hq`).
|
|
31
|
+
* Returns null when the env names neither, so a hermetic env never touches
|
|
32
|
+
* the real home directory.
|
|
33
|
+
*/
|
|
34
|
+
function flagCacheDir(options) {
|
|
35
|
+
if (options.cacheDir !== undefined)
|
|
36
|
+
return options.cacheDir;
|
|
37
|
+
const env = options.env ?? process.env;
|
|
38
|
+
const stateDir = env.HQ_STATE_DIR?.trim();
|
|
39
|
+
if (stateDir)
|
|
40
|
+
return stateDir;
|
|
41
|
+
const home = env.HOME?.trim() || env.USERPROFILE?.trim();
|
|
42
|
+
return home ? path.join(home, ".hq") : null;
|
|
43
|
+
}
|
|
44
|
+
function readFlagCacheFile(dir) {
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(dir, LANES_FLAG_CACHE_FILE), "utf8"));
|
|
47
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
48
|
+
return {};
|
|
49
|
+
const entries = parsed.entries;
|
|
50
|
+
if (!entries || typeof entries !== "object" || Array.isArray(entries))
|
|
51
|
+
return {};
|
|
52
|
+
return entries;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function readCachedFlagValue(dir, key, now = Date.now()) {
|
|
59
|
+
const entry = readFlagCacheFile(dir)[key];
|
|
60
|
+
if (!entry || typeof entry.value !== "boolean" || typeof entry.resolvedAt !== "string")
|
|
61
|
+
return null;
|
|
62
|
+
const resolvedAt = Date.parse(entry.resolvedAt);
|
|
63
|
+
if (!Number.isFinite(resolvedAt))
|
|
64
|
+
return null;
|
|
65
|
+
const age = now - resolvedAt;
|
|
66
|
+
// A future timestamp (clock skew or a hand-edited file) is not trusted either.
|
|
67
|
+
if (age < 0 || age > LANES_FLAG_CACHE_MAX_AGE_MS)
|
|
68
|
+
return null;
|
|
69
|
+
return entry.value;
|
|
70
|
+
}
|
|
71
|
+
function writeCachedFlagValue(dir, key, value) {
|
|
72
|
+
let tmp;
|
|
73
|
+
try {
|
|
74
|
+
const entries = readFlagCacheFile(dir);
|
|
75
|
+
// Always restamp resolvedAt, even for an unchanged value: the max-age check
|
|
76
|
+
// measures time since the registry last confirmed it. Delete first so the
|
|
77
|
+
// key moves to the end and eviction drops the least recently confirmed.
|
|
78
|
+
delete entries[key];
|
|
79
|
+
entries[key] = { value, resolvedAt: new Date().toISOString() };
|
|
80
|
+
const keys = Object.keys(entries);
|
|
81
|
+
for (const stale of keys.slice(0, Math.max(0, keys.length - FLAG_CACHE_MAX_ENTRIES)))
|
|
82
|
+
delete entries[stale];
|
|
83
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
84
|
+
const target = path.join(dir, LANES_FLAG_CACHE_FILE);
|
|
85
|
+
tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
86
|
+
fs.writeFileSync(tmp, `${JSON.stringify({ version: 1, entries })}\n`, { mode: 0o600 });
|
|
87
|
+
fs.renameSync(tmp, target);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
if (tmp) {
|
|
91
|
+
try {
|
|
92
|
+
fs.unlinkSync(tmp);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Nothing to clean up.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// The cache is best-effort; a read must never fail because it cannot be saved.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const TOKENISH_PATTERNS = [
|
|
102
|
+
/bearer\s+\S+/gi,
|
|
103
|
+
/eyJ[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*/g,
|
|
104
|
+
/\b(?:hq|sk|pk|ghp|gho|xox[a-z])[_-][A-Za-z0-9_-]{8,}/gi,
|
|
105
|
+
/[A-Za-z0-9+/_-]{32,}={0,2}/g,
|
|
106
|
+
];
|
|
107
|
+
/** Error message safe for stderr: token-like substrings removed, length bounded. */
|
|
108
|
+
export function sanitizeFlagErrorMessage(error) {
|
|
109
|
+
const raw = error instanceof Error ? error.message : typeof error === "string" ? error : "";
|
|
110
|
+
let message = raw;
|
|
111
|
+
for (const pattern of TOKENISH_PATTERNS)
|
|
112
|
+
message = message.replace(pattern, "[redacted]");
|
|
113
|
+
return message.length > 200 ? `${message.slice(0, 200)}...` : message;
|
|
114
|
+
}
|
|
18
115
|
function errorClass(error) {
|
|
19
116
|
return error instanceof Error ? error.name : "UnknownError";
|
|
20
117
|
}
|
|
21
|
-
function reportReadFailure(flagKey, error, fallback) {
|
|
118
|
+
function reportReadFailure(flagKey, error, fallback, diagnostics) {
|
|
22
119
|
const suffix = flagKey.replace(/^lanes\./, "").replaceAll(/[^a-zA-Z0-9]+/g, "_");
|
|
23
120
|
process.stderr.write(`${JSON.stringify({
|
|
24
121
|
event: `lanes.${suffix}.flag_read_failed`,
|
|
25
122
|
flag: flagKey,
|
|
26
123
|
error: errorClass(error),
|
|
124
|
+
error_message: sanitizeFlagErrorMessage(error),
|
|
125
|
+
...(diagnostics?.status !== undefined ? { status: diagnostics.status } : {}),
|
|
126
|
+
...(diagnostics ? {
|
|
127
|
+
duration_ms: diagnostics.durationMs,
|
|
128
|
+
attempts: diagnostics.attempts,
|
|
129
|
+
token_refreshed: diagnostics.tokenRefreshed,
|
|
130
|
+
cache_used: diagnostics.cacheUsed,
|
|
131
|
+
} : {}),
|
|
27
132
|
fallback,
|
|
28
133
|
})}\n`);
|
|
29
134
|
}
|
|
@@ -47,8 +152,11 @@ function cacheKey(flagKey, company, endpoint) {
|
|
|
47
152
|
}
|
|
48
153
|
/**
|
|
49
154
|
* Read a lanes flag once for each company identity in this CLI process. The
|
|
50
|
-
* cache
|
|
51
|
-
*
|
|
155
|
+
* in-process cache holds the in-flight or settled result. The last registry
|
|
156
|
+
* snapshot value per flag and company is also kept in the HQ state dir and
|
|
157
|
+
* used when a later read fails transiently (timeout, network error, 5xx) and
|
|
158
|
+
* the value was confirmed within LANES_FLAG_CACHE_MAX_AGE_MS. A local override
|
|
159
|
+
* is checked on every call and wins first.
|
|
52
160
|
*/
|
|
53
161
|
export async function readLanesRegistryFlag(flagKey, options = {}) {
|
|
54
162
|
const result = await readLanesRegistryFlagResult(flagKey, options);
|
|
@@ -72,7 +180,7 @@ async function readRegistryFlagOutcome(flagKey, options, failOpen) {
|
|
|
72
180
|
const endpoint = resolveFlagRegistryEndpoint(undefined, env);
|
|
73
181
|
const key = cacheKey(flagKey, options.company, endpoint);
|
|
74
182
|
const cached = registryReads.get(key);
|
|
75
|
-
const pending = cached ?? readRegistryValue(flagKey, endpoint, options);
|
|
183
|
+
const pending = cached ?? readRegistryValue(flagKey, endpoint, key, options);
|
|
76
184
|
if (!cached)
|
|
77
185
|
registryReads.set(key, pending);
|
|
78
186
|
const outcome = await pending;
|
|
@@ -108,42 +216,89 @@ export async function readLanesRegistryFlagFailOff(flagKey, options = {}, reader
|
|
|
108
216
|
export function _resetThrownFlagReadWarningsForTests() {
|
|
109
217
|
reportedThrownFlagReads.clear();
|
|
110
218
|
}
|
|
111
|
-
|
|
219
|
+
function isTimeoutError(error) {
|
|
220
|
+
if (!(error instanceof Error))
|
|
221
|
+
return false;
|
|
222
|
+
return error.name === "AbortError" || error.name === "TimeoutError" ||
|
|
223
|
+
error.message.startsWith("hq-flags: request timed out");
|
|
224
|
+
}
|
|
225
|
+
function classifyAttempt(attempt) {
|
|
226
|
+
if (!attempt.failure)
|
|
227
|
+
return "none";
|
|
228
|
+
const status = attempt.status;
|
|
229
|
+
if (status !== undefined && status >= 400) {
|
|
230
|
+
if (status === 401)
|
|
231
|
+
return "unauthorized";
|
|
232
|
+
if (status >= 500)
|
|
233
|
+
return "transient";
|
|
234
|
+
return "permanent";
|
|
235
|
+
}
|
|
236
|
+
if (attempt.networkError || isTimeoutError(attempt.failure.error))
|
|
237
|
+
return "transient";
|
|
238
|
+
return "permanent";
|
|
239
|
+
}
|
|
240
|
+
async function defaultRefreshToken() {
|
|
241
|
+
if (wantsMachineCognitoTokens()) {
|
|
242
|
+
return ensureCognitoIdToken({ interactive: false });
|
|
243
|
+
}
|
|
244
|
+
const cached = loadCachedTokens();
|
|
245
|
+
if (!cached)
|
|
246
|
+
throw new MissingFlagTokenError();
|
|
247
|
+
const refreshed = await refreshActivePersonTokens(cached);
|
|
248
|
+
return refreshed.idToken;
|
|
249
|
+
}
|
|
250
|
+
function defaultAcquireToken() {
|
|
251
|
+
return async () => {
|
|
252
|
+
try {
|
|
253
|
+
return await ensureCognitoIdToken({ interactive: false });
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
if (error instanceof Error &&
|
|
257
|
+
error.message.startsWith("No valid HQ session")) {
|
|
258
|
+
throw new MissingFlagTokenError();
|
|
259
|
+
}
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
function requireToken(token) {
|
|
265
|
+
if (typeof token !== "string" || token.trim().length === 0) {
|
|
266
|
+
throw new MissingFlagTokenError();
|
|
267
|
+
}
|
|
268
|
+
return token;
|
|
269
|
+
}
|
|
270
|
+
async function readRegistryAttempt(flagKey, endpoint, options, acquireToken) {
|
|
112
271
|
const company = options.company;
|
|
113
272
|
const companyUid = company?.uid?.trim();
|
|
114
273
|
const companySlug = company?.slug?.trim();
|
|
274
|
+
const baseFetch = options.fetch ?? globalThis.fetch;
|
|
115
275
|
let client;
|
|
116
|
-
|
|
117
|
-
let failure;
|
|
276
|
+
const attempt = { networkError: false };
|
|
118
277
|
const rememberFailure = (error) => {
|
|
119
|
-
failure ??= { error };
|
|
278
|
+
attempt.failure ??= { error };
|
|
120
279
|
};
|
|
121
280
|
try {
|
|
122
|
-
const acquireToken = options.getToken ?? (async () => {
|
|
123
|
-
try {
|
|
124
|
-
return await ensureCognitoIdToken({ interactive: false });
|
|
125
|
-
}
|
|
126
|
-
catch (error) {
|
|
127
|
-
if (error instanceof Error &&
|
|
128
|
-
error.message.startsWith("No valid HQ session")) {
|
|
129
|
-
throw new MissingFlagTokenError();
|
|
130
|
-
}
|
|
131
|
-
throw error;
|
|
132
|
-
}
|
|
133
|
-
});
|
|
134
|
-
const getToken = async () => {
|
|
135
|
-
const token = await acquireToken();
|
|
136
|
-
if (typeof token !== "string" || token.trim().length === 0) {
|
|
137
|
-
throw new MissingFlagTokenError();
|
|
138
|
-
}
|
|
139
|
-
return token;
|
|
140
|
-
};
|
|
141
281
|
client = (options.createClient ?? createFlagClient)({
|
|
142
282
|
endpoint,
|
|
143
|
-
getToken,
|
|
283
|
+
getToken: async () => requireToken(await acquireToken()),
|
|
144
284
|
env: {},
|
|
145
285
|
refreshIntervalMs: 0,
|
|
146
286
|
requestTimeoutMs: LANES_FLAG_READ_TIMEOUT_MS,
|
|
287
|
+
...(baseFetch
|
|
288
|
+
? {
|
|
289
|
+
fetch: async (input, init) => {
|
|
290
|
+
try {
|
|
291
|
+
const res = await baseFetch(input, init);
|
|
292
|
+
attempt.status = res.status;
|
|
293
|
+
return res;
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
attempt.networkError = true;
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
}
|
|
301
|
+
: {}),
|
|
147
302
|
...(companyUid ? { companyUid } : {}),
|
|
148
303
|
...(companySlug ? { companyIdentifiers: [companySlug] } : {}),
|
|
149
304
|
onError: rememberFailure,
|
|
@@ -155,7 +310,7 @@ async function readRegistryValue(flagKey, endpoint, options) {
|
|
|
155
310
|
rememberFailure(error);
|
|
156
311
|
}
|
|
157
312
|
try {
|
|
158
|
-
resolution = client.explain(flagKey, { fallback: options.defaultValue ?? false });
|
|
313
|
+
attempt.resolution = client.explain(flagKey, { fallback: options.defaultValue ?? false });
|
|
159
314
|
}
|
|
160
315
|
catch (error) {
|
|
161
316
|
rememberFailure(error);
|
|
@@ -172,12 +327,77 @@ async function readRegistryValue(flagKey, endpoint, options) {
|
|
|
172
327
|
rememberFailure(error);
|
|
173
328
|
}
|
|
174
329
|
}
|
|
175
|
-
|
|
176
|
-
|
|
330
|
+
return attempt;
|
|
331
|
+
}
|
|
332
|
+
function isSnapshotResolution(resolution) {
|
|
333
|
+
return resolution?.source === "snapshot" || resolution?.source === "local-override";
|
|
334
|
+
}
|
|
335
|
+
async function readRegistryValue(flagKey, endpoint, key, options) {
|
|
336
|
+
const startedAt = Date.now();
|
|
337
|
+
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
338
|
+
let acquireToken = options.getToken ?? defaultAcquireToken();
|
|
339
|
+
let attempt = await readRegistryAttempt(flagKey, endpoint, options, acquireToken);
|
|
340
|
+
let attempts = 1;
|
|
341
|
+
let tokenRefreshed = false;
|
|
342
|
+
if (!isSnapshotResolution(attempt.resolution)) {
|
|
343
|
+
const kind = classifyAttempt(attempt);
|
|
344
|
+
if (kind === "transient") {
|
|
345
|
+
await sleep(options.retryBackoffMs ?? LANES_FLAG_RETRY_BACKOFF_MS);
|
|
346
|
+
attempt = await readRegistryAttempt(flagKey, endpoint, options, acquireToken);
|
|
347
|
+
attempts += 1;
|
|
348
|
+
}
|
|
349
|
+
else if (kind === "unauthorized") {
|
|
350
|
+
const refresh = options.refreshToken ?? (options.getToken ? options.getToken : defaultRefreshToken);
|
|
351
|
+
let refreshedToken;
|
|
352
|
+
try {
|
|
353
|
+
refreshedToken = requireToken(await refresh());
|
|
354
|
+
tokenRefreshed = true;
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
// Keep the original 401 as the reported failure.
|
|
358
|
+
}
|
|
359
|
+
if (refreshedToken !== undefined) {
|
|
360
|
+
const token = refreshedToken;
|
|
361
|
+
acquireToken = () => token;
|
|
362
|
+
attempt = await readRegistryAttempt(flagKey, endpoint, options, acquireToken);
|
|
363
|
+
attempts += 1;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
const durationMs = Date.now() - startedAt;
|
|
368
|
+
const diagnostics = {
|
|
369
|
+
...(attempt.status !== undefined ? { status: attempt.status } : {}),
|
|
370
|
+
durationMs,
|
|
371
|
+
attempts,
|
|
372
|
+
tokenRefreshed,
|
|
373
|
+
cacheUsed: false,
|
|
374
|
+
};
|
|
375
|
+
const cacheDir = flagCacheDir(options);
|
|
376
|
+
if (isSnapshotResolution(attempt.resolution)) {
|
|
377
|
+
const value = attempt.resolution.value;
|
|
378
|
+
if (attempt.resolution.source === "snapshot" && cacheDir)
|
|
379
|
+
writeCachedFlagValue(cacheDir, key, value);
|
|
380
|
+
return {
|
|
381
|
+
result: { state: "resolved", value },
|
|
382
|
+
...(attempt.failure ? { failure: { ...attempt.failure, diagnostics } } : {}),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
if (attempt.failure) {
|
|
386
|
+
// Only a transient failure (timeout, network error, 5xx) may serve the
|
|
387
|
+
// last-known-good value. A 401 that survived the refresh, a 403 or other
|
|
388
|
+
// 4xx, and a missing token mean the registry (or the session) refused the
|
|
389
|
+
// read, so it fails closed to the caller's default instead.
|
|
390
|
+
const transient = classifyAttempt(attempt) === "transient";
|
|
391
|
+
const cachedValue = transient && cacheDir ? readCachedFlagValue(cacheDir, key) : null;
|
|
392
|
+
if (cachedValue !== null) {
|
|
393
|
+
return {
|
|
394
|
+
result: { state: "resolved", value: cachedValue },
|
|
395
|
+
failure: { ...attempt.failure, diagnostics: { ...diagnostics, cacheUsed: true } },
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
return { result: { state: "unknown" }, failure: { ...attempt.failure, diagnostics } };
|
|
177
399
|
}
|
|
178
|
-
|
|
179
|
-
return { result: { state: "unknown" }, failure };
|
|
180
|
-
return { result: { state: "resolved", value: resolution?.value ?? false } };
|
|
400
|
+
return { result: { state: "resolved", value: attempt.resolution?.value ?? false } };
|
|
181
401
|
}
|
|
182
402
|
function reportRegistryFailure(flagKey, key, outcome, options, failOpen) {
|
|
183
403
|
if (!outcome.failure)
|
|
@@ -194,7 +414,7 @@ function reportRegistryFailure(flagKey, key, outcome, options, failOpen) {
|
|
|
194
414
|
reportFailure(outcome.failure.error);
|
|
195
415
|
return;
|
|
196
416
|
}
|
|
197
|
-
reportReadFailure(flagKey, outcome.failure.error, fallback);
|
|
417
|
+
reportReadFailure(flagKey, outcome.failure.error, fallback, outcome.failure.diagnostics);
|
|
198
418
|
}
|
|
199
419
|
export function _resetLanesRegistryFlagCacheForTests() {
|
|
200
420
|
registryReads.clear();
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Personal vault use from a company-locked session.
|
|
3
|
+
*
|
|
4
|
+
* Owner decision dq_20261020234444_f1b1ea4185: a session locked to one company
|
|
5
|
+
* may read the caller's own personal-vault secrets through `hq secrets exec
|
|
6
|
+
* --personal`, read-only, and every use is audit-logged. All other personal
|
|
7
|
+
* vault operations stay refused in such a session.
|
|
8
|
+
*/
|
|
9
|
+
/** True for a person (prs_) or agent-self (agt_) vault uid used as a scope. */
|
|
10
|
+
export declare function isEntityScopeRef(ref: string): boolean;
|
|
11
|
+
/** Relative to the HQ root. One JSON object per line. */
|
|
12
|
+
export declare const PERSONAL_SECRETS_AUDIT_RELATIVE_PATH: string;
|
|
13
|
+
export declare const PERSONAL_SCOPE_READ_ONLY_MESSAGE = "Personal scope in a company-locked session is read-only: only `hq secrets exec --personal --only <NAMES> -- <command>` is allowed.";
|
|
14
|
+
export interface PersonalSecretsAuditEntry {
|
|
15
|
+
ts: string;
|
|
16
|
+
session_id: string;
|
|
17
|
+
locked_company: string;
|
|
18
|
+
command: "secrets exec";
|
|
19
|
+
scope: "personal";
|
|
20
|
+
secret_names: string[];
|
|
21
|
+
}
|
|
22
|
+
export declare function personalSecretsAuditPath(root: string): string;
|
|
23
|
+
/**
|
|
24
|
+
* Append one audit line before any value is read. Throws when the line cannot
|
|
25
|
+
* be written, so the caller fails closed instead of reading unlogged.
|
|
26
|
+
* Records secret names only, never values.
|
|
27
|
+
*/
|
|
28
|
+
export declare function appendPersonalSecretsAudit(root: string, entry: Omit<PersonalSecretsAuditEntry, "ts" | "command" | "scope"> & {
|
|
29
|
+
ts?: string;
|
|
30
|
+
}): PersonalSecretsAuditEntry;
|
|
31
|
+
//# sourceMappingURL=personal-secrets-audit.d.ts.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Personal vault use from a company-locked session.
|
|
5
|
+
*
|
|
6
|
+
* Owner decision dq_20261020234444_f1b1ea4185: a session locked to one company
|
|
7
|
+
* may read the caller's own personal-vault secrets through `hq secrets exec
|
|
8
|
+
* --personal`, read-only, and every use is audit-logged. All other personal
|
|
9
|
+
* vault operations stay refused in such a session.
|
|
10
|
+
*/
|
|
11
|
+
/** True for a person (prs_) or agent-self (agt_) vault uid used as a scope. */
|
|
12
|
+
export function isEntityScopeRef(ref) {
|
|
13
|
+
return /^(prs|agt)_/.test(ref.trim());
|
|
14
|
+
}
|
|
15
|
+
/** Relative to the HQ root. One JSON object per line. */
|
|
16
|
+
export const PERSONAL_SECRETS_AUDIT_RELATIVE_PATH = path.join("workspace", "audit", "personal-secrets-use.jsonl");
|
|
17
|
+
export const PERSONAL_SCOPE_READ_ONLY_MESSAGE = "Personal scope in a company-locked session is read-only: only `hq secrets exec --personal --only <NAMES> -- <command>` is allowed.";
|
|
18
|
+
export function personalSecretsAuditPath(root) {
|
|
19
|
+
return path.join(root, PERSONAL_SECRETS_AUDIT_RELATIVE_PATH);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Append one audit line before any value is read. Throws when the line cannot
|
|
23
|
+
* be written, so the caller fails closed instead of reading unlogged.
|
|
24
|
+
* Records secret names only, never values.
|
|
25
|
+
*/
|
|
26
|
+
export function appendPersonalSecretsAudit(root, entry) {
|
|
27
|
+
const line = {
|
|
28
|
+
ts: entry.ts ?? new Date().toISOString(),
|
|
29
|
+
session_id: entry.session_id,
|
|
30
|
+
locked_company: entry.locked_company,
|
|
31
|
+
command: "secrets exec",
|
|
32
|
+
scope: "personal",
|
|
33
|
+
secret_names: [...entry.secret_names],
|
|
34
|
+
};
|
|
35
|
+
const file = personalSecretsAuditPath(root);
|
|
36
|
+
try {
|
|
37
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
38
|
+
fs.appendFileSync(file, `${JSON.stringify(line)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
throw new Error(`Cannot write the personal secrets audit log (${PERSONAL_SECRETS_AUDIT_RELATIVE_PATH}); refusing to read personal secrets: ${error instanceof Error ? error.message : String(error)}`);
|
|
42
|
+
}
|
|
43
|
+
return line;
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=personal-secrets-audit.js.map
|
|
@@ -31,6 +31,12 @@ function errMessage(err) {
|
|
|
31
31
|
export function isAccessDeniedError(err) {
|
|
32
32
|
const rec = errRecord(err);
|
|
33
33
|
if (rec) {
|
|
34
|
+
// A presigned storage download that failed AFTER the API authorized the
|
|
35
|
+
// key (createCompanyPresignClient). The ACL already passed, so pointing
|
|
36
|
+
// the caller at `hq access` would loop. Checked before the message regex
|
|
37
|
+
// because the message names the HTTP status.
|
|
38
|
+
if (rec.code === "FILES_STORAGE_DOWNLOAD_FAILED")
|
|
39
|
+
return false;
|
|
34
40
|
if (rec.status === 403)
|
|
35
41
|
return true;
|
|
36
42
|
const meta = rec.$metadata;
|
|
@@ -64,6 +64,17 @@ export declare const CLIENT_HEALTH_OBSERVATION_FILE = "cli-client-health.observa
|
|
|
64
64
|
* always re-observe — they have opened the store anyway, so it costs nothing.
|
|
65
65
|
*/
|
|
66
66
|
export declare const JOURNAL_OBSERVATION_TTL_MS: number;
|
|
67
|
+
/**
|
|
68
|
+
* V8 old-space cap, in MB, for the child process that enumerates journals on
|
|
69
|
+
* behalf of a plain invocation. `listJournals()` materializes every area of
|
|
70
|
+
* the local sync state store; on a large store that took a short-lived
|
|
71
|
+
* command such as `hq secrets exec` to 2.5-4 GB RSS (feedback_d70e9d63). The
|
|
72
|
+
* child either finishes inside this cap or dies, and the command never holds
|
|
73
|
+
* that memory itself.
|
|
74
|
+
*/
|
|
75
|
+
export declare const JOURNAL_OBSERVER_HEAP_MB = 512;
|
|
76
|
+
/** Wall-clock bound for the isolated journal observer. */
|
|
77
|
+
export declare const JOURNAL_OBSERVER_TIMEOUT_MS = 20000;
|
|
67
78
|
export interface CliClientHealthState {
|
|
68
79
|
/** Stable random installation identity — regenerated only if invalid. */
|
|
69
80
|
installationId: string;
|
|
@@ -120,6 +131,24 @@ interface JournalLike {
|
|
|
120
131
|
* contract ISO-UTC. Null when nothing has ever synced.
|
|
121
132
|
*/
|
|
122
133
|
export declare function latestJournalSyncTime(journals: readonly JournalLike[]): string | null;
|
|
134
|
+
/**
|
|
135
|
+
* The isolated observer could not produce a value (heap cap, timeout, crash or
|
|
136
|
+
* unreadable output). The caller stamps the observation so the next attempt
|
|
137
|
+
* waits a full TTL instead of re-running a read already known to fail here.
|
|
138
|
+
*/
|
|
139
|
+
export declare class JournalObservationUnavailableError extends Error {
|
|
140
|
+
constructor(message: string);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Run `listJournals()` in a heap-capped child process and return only the
|
|
144
|
+
* `lastSync` values. Used for plain invocations so the full store
|
|
145
|
+
* materialization never lands in the invoking command's own memory.
|
|
146
|
+
*/
|
|
147
|
+
export declare function observeJournalsIsolated(opts?: {
|
|
148
|
+
engineUrl?: string;
|
|
149
|
+
heapMb?: number;
|
|
150
|
+
timeoutMs?: number;
|
|
151
|
+
}): JournalLike[];
|
|
123
152
|
/**
|
|
124
153
|
* True when this event kind must pay for a fresh `listJournals()` read.
|
|
125
154
|
*
|
|
@@ -167,6 +196,11 @@ export interface ClientHealthDeps {
|
|
|
167
196
|
expiring?: typeof isExpiring;
|
|
168
197
|
versions?: () => VersionInfo;
|
|
169
198
|
journals?: () => readonly JournalLike[];
|
|
199
|
+
/**
|
|
200
|
+
* Journal source for plain invocations when `journals` is not set. Defaults
|
|
201
|
+
* to {@link observeJournalsIsolated}; sync events read in-process.
|
|
202
|
+
*/
|
|
203
|
+
isolatedJournals?: () => readonly JournalLike[];
|
|
170
204
|
post?: HeartbeatPoster;
|
|
171
205
|
timeoutMs?: number;
|
|
172
206
|
/** US-016: overridable for tests; defaults to {@link collectLocalFilesOverview}. */
|