@indigoai-us/hq-cli 5.345.83 → 5.345.85
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 +11 -0
- package/dist/command-catalog.generated.d.ts +4 -0
- package/dist/command-catalog.generated.js +5 -0
- package/dist/commands/lanes-checks.js +32 -2
- package/dist/commands/lanes-session-start.d.ts +1 -1
- package/dist/commands/lanes-session-start.js +57 -7
- package/dist/commands/secrets.js +11 -2
- package/dist/lib/lanes/flag-keys.d.ts +8 -0
- package/dist/lib/lanes/flag-keys.js +8 -0
- package/dist/lib/lanes/legacy-pool.d.ts +35 -0
- package/dist/lib/lanes/legacy-pool.js +209 -0
- package/dist/lib/lanes/reaper.d.ts +13 -0
- package/dist/lib/lanes/reaper.js +43 -5
- package/dist/secrets-env-action.d.ts +17 -0
- package/dist/secrets-env-action.js +59 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.345.85] — 2026-10-11
|
|
6
|
+
- Session-start orphan reports and reap leases are keyed by HQ root, report ended-session waiting lanes, and surface the latest completed orphan count in startup context even after a skipped sweep. `hq lanes reap --legacy` also identifies abandoned legacy conduct pool loops and only stops verified process identities when `--stop --reason` is supplied.
|
|
7
|
+
|
|
8
|
+
## [5.345.84] — 2026-10-11
|
|
9
|
+
- `hq secrets exec` and `hq secrets env` with `HQ_API_KEY` now refuse a
|
|
10
|
+
`--company` (or `HQ_COMPANY_UID`) that the key was not minted for. They
|
|
11
|
+
exit 1 with "HQ_API_KEY belongs to a different company than <uid>". They
|
|
12
|
+
used to ignore the flag and inject the key company's same-named secret.
|
|
13
|
+
A company slug under an API key is also refused, with a request for the
|
|
14
|
+
`cmp_` uid. Without `--company`, the key's own company is used as before.
|
|
15
|
+
|
|
5
16
|
## [5.345.83] — 2026-10-11
|
|
6
17
|
- HQ-root scaffolds now package the shared runtime used by generated shell forwarders.
|
|
7
18
|
- Lanes hook installation now replaces stale hq-cli drain registrations across
|
|
@@ -11941,6 +11941,10 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
11941
11941
|
readonly flags: "--stop";
|
|
11942
11942
|
readonly description: "Stop candidate process trees when the hq-flags stop key is enabled";
|
|
11943
11943
|
readonly hidden: false;
|
|
11944
|
+
}, {
|
|
11945
|
+
readonly flags: "--legacy";
|
|
11946
|
+
readonly description: "Include legacy conduct pool loop lanes in the report";
|
|
11947
|
+
readonly hidden: false;
|
|
11944
11948
|
}, {
|
|
11945
11949
|
readonly flags: "--reason <text>";
|
|
11946
11950
|
readonly description: "Required audit reason when --stop is set";
|
|
@@ -14522,6 +14522,11 @@ export const COMMAND_CATALOG = [
|
|
|
14522
14522
|
"description": "Stop candidate process trees when the hq-flags stop key is enabled",
|
|
14523
14523
|
"hidden": false
|
|
14524
14524
|
},
|
|
14525
|
+
{
|
|
14526
|
+
"flags": "--legacy",
|
|
14527
|
+
"description": "Include legacy conduct pool loop lanes in the report",
|
|
14528
|
+
"hidden": false
|
|
14529
|
+
},
|
|
14525
14530
|
{
|
|
14526
14531
|
"flags": "--reason <text>",
|
|
14527
14532
|
"description": "Required audit reason when --stop is set",
|
|
@@ -14,6 +14,9 @@ import { assertNoSecretText } from "../lib/lanes/text-scan.js";
|
|
|
14
14
|
import { checkCompanyPath } from "../lib/lanes/checked-company-path.js";
|
|
15
15
|
import { findOrphanedBareIdDropboxes } from "../lib/lanes/orphaned-dropbox.js";
|
|
16
16
|
import { parseReapMinAge, reapLocalLanes } from "../lib/lanes/reaper.js";
|
|
17
|
+
import { readLanesRegistryFlagFailOpen } from "../lib/lanes/registry-flag.js";
|
|
18
|
+
import { LANE_REAP_STOP_FLAG, LANE_ORPHAN_SWEEP_LEGACY_FLAG, LANE_ORPHAN_SWEEP_ROOT_KEYING_FLAG, LANE_ORPHAN_SWEEP_WAITING_FLAG } from "../lib/lanes/flag-keys.js";
|
|
19
|
+
import { findLegacyPoolLanes } from "../lib/lanes/legacy-pool.js";
|
|
17
20
|
export function mappingIncludesStory(mapping, storyId) {
|
|
18
21
|
return (mapping.storyPrefix === undefined || storyId.startsWith(mapping.storyPrefix))
|
|
19
22
|
&& (mapping.excludePrefix === undefined || !storyId.startsWith(mapping.excludePrefix));
|
|
@@ -849,6 +852,24 @@ async function orphanReport(root, company, now, minAgeMinutes, userIdleHours, de
|
|
|
849
852
|
assertNoSecretText("stop reason", reason);
|
|
850
853
|
const unscoped = options.unscoped === true;
|
|
851
854
|
const orphans = orphanFindings(root, company, now, minAgeMinutes, deps, undefined, unscoped, userIdleHours);
|
|
855
|
+
const legacyEnabled = await readLanesRegistryFlagFailOpen(LANE_ORPHAN_SWEEP_LEGACY_FLAG, { env: process.env, defaultValue: true });
|
|
856
|
+
const legacyOrphans = legacyEnabled
|
|
857
|
+
? findLegacyPoolLanes(root, { ...deps.reaperDeps, sessionState: deps.sessionState, now: () => now }).filter((finding) => unscoped ? !finding.company_slug : !company || finding.company_slug === company.slug)
|
|
858
|
+
: [];
|
|
859
|
+
const legacyRows = legacyOrphans.map((finding) => ({
|
|
860
|
+
lane_id: finding.lane_id,
|
|
861
|
+
senior: { kind: "session", id: finding.session_id },
|
|
862
|
+
senior_state: "ended",
|
|
863
|
+
reason: finding.reason,
|
|
864
|
+
company_source: "legacy_conduct_pool",
|
|
865
|
+
...(finding.company_slug ? { company: finding.company_slug } : {}),
|
|
866
|
+
age_minutes: finding.age_minutes,
|
|
867
|
+
host: { kind: "local", id: "legacy" },
|
|
868
|
+
legacy: true,
|
|
869
|
+
pid: finding.pid,
|
|
870
|
+
run_dir: finding.run_dir,
|
|
871
|
+
stoppable: finding.reason === "legacy_conduct_session_gone",
|
|
872
|
+
}));
|
|
852
873
|
const output = (action, stopped) => {
|
|
853
874
|
if (options.human) {
|
|
854
875
|
const companyText = unscoped ? "unscoped" : company?.slug ?? "unknown";
|
|
@@ -866,7 +887,10 @@ async function orphanReport(root, company, now, minAgeMinutes, userIdleHours, de
|
|
|
866
887
|
`${finding.host.kind}:${finding.host.id}`,
|
|
867
888
|
].join("\t"));
|
|
868
889
|
}
|
|
869
|
-
|
|
890
|
+
for (const finding of legacyOrphans) {
|
|
891
|
+
console.log([finding.lane_id, `session:${finding.session_id}`, "ended", finding.reason, "legacy_conduct_pool", finding.age_minutes, "", "local:legacy"].join("\t"));
|
|
892
|
+
}
|
|
893
|
+
if (orphans.length + legacyOrphans.length === 0)
|
|
870
894
|
console.log("(none)");
|
|
871
895
|
if (stopped !== undefined) {
|
|
872
896
|
console.log("stopped:");
|
|
@@ -884,7 +908,7 @@ async function orphanReport(root, company, now, minAgeMinutes, userIdleHours, de
|
|
|
884
908
|
min_age_minutes: minAgeMinutes,
|
|
885
909
|
user_idle_hours: userIdleHours,
|
|
886
910
|
...(reason === undefined ? {} : { reason }),
|
|
887
|
-
orphans,
|
|
911
|
+
orphans: [...orphans, ...legacyRows],
|
|
888
912
|
...(stopped === undefined ? {} : { stopped }),
|
|
889
913
|
}, null, 2));
|
|
890
914
|
};
|
|
@@ -913,6 +937,7 @@ export function registerLaneChecksCommand(parent, deps, wrap) {
|
|
|
913
937
|
.option("--min-age <minutes>", "Minimum lane age in minutes (default: 120)")
|
|
914
938
|
.option("--apply-stale-state", "Remove stale worker and watcher pid markers")
|
|
915
939
|
.option("--stop", "Stop candidate process trees when the hq-flags stop key is enabled")
|
|
940
|
+
.option("--legacy", "Include legacy conduct pool loop lanes in the report")
|
|
916
941
|
.option("--reason <text>", "Required audit reason when --stop is set")
|
|
917
942
|
.option("--json", "Print machine-readable JSON (default)")
|
|
918
943
|
.action(wrap(async (options) => {
|
|
@@ -928,6 +953,7 @@ export function registerLaneChecksCommand(parent, deps, wrap) {
|
|
|
928
953
|
minAgeMinutes,
|
|
929
954
|
stop: options.stop === true,
|
|
930
955
|
applyStaleState: options.applyStaleState === true,
|
|
956
|
+
legacy: options.legacy === true,
|
|
931
957
|
reason: reason ?? "Orphaned lane: owning session is confirmed ended",
|
|
932
958
|
json: true,
|
|
933
959
|
}, {
|
|
@@ -936,6 +962,10 @@ export function registerLaneChecksCommand(parent, deps, wrap) {
|
|
|
936
962
|
sessionState: deps.sessionState,
|
|
937
963
|
stopEnabled: deps.stopEnabled,
|
|
938
964
|
stopLane: deps.stopLane,
|
|
965
|
+
rootScopedStateEnabled: () => readLanesRegistryFlagFailOpen(LANE_ORPHAN_SWEEP_ROOT_KEYING_FLAG, { env: process.env, defaultValue: true }),
|
|
966
|
+
waitingSeniorEnabled: () => readLanesRegistryFlagFailOpen(LANE_ORPHAN_SWEEP_WAITING_FLAG, { env: process.env, defaultValue: true }),
|
|
967
|
+
legacyEnabled: () => readLanesRegistryFlagFailOpen(LANE_ORPHAN_SWEEP_LEGACY_FLAG, { env: process.env, defaultValue: true }),
|
|
968
|
+
legacyStopEnabled: () => readLanesRegistryFlagFailOpen(LANE_REAP_STOP_FLAG, { env: process.env, defaultValue: true }),
|
|
939
969
|
});
|
|
940
970
|
console.log(JSON.stringify(report, null, 2));
|
|
941
971
|
}));
|
|
@@ -10,7 +10,7 @@ export interface SessionStartOptions {
|
|
|
10
10
|
startSweep?: (root: string, env: NodeJS.ProcessEnv) => void;
|
|
11
11
|
}
|
|
12
12
|
/** Starts the CL-4 read-only orphan report once per host, without holding SessionStart open. */
|
|
13
|
-
export declare function startSessionStartOrphanSweep(root: string, env?: NodeJS.ProcessEnv, spawnDetached?: typeof spawn): void;
|
|
13
|
+
export declare function startSessionStartOrphanSweep(root: string, env?: NodeJS.ProcessEnv, spawnDetached?: typeof spawn, rootScoped?: boolean): void;
|
|
14
14
|
/** SessionStart-compatible, fail-open conduct bootstrap. */
|
|
15
15
|
export declare function runLaneSessionStart(options?: SessionStartOptions): Promise<void>;
|
|
16
16
|
export declare function registerLaneSessionStartCommand(lanes: Command): void;
|
|
@@ -2,10 +2,10 @@ import fs from "node:fs";
|
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
6
6
|
import { resolveLanesHqRoot } from "../lib/lanes/hq-root.js";
|
|
7
7
|
import { readLanesRegistryFlagFailOpen } from "../lib/lanes/registry-flag.js";
|
|
8
|
-
import { LANE_CONDUCT_SESSION_START_FLAG } from "../lib/lanes/flag-keys.js";
|
|
8
|
+
import { LANE_CONDUCT_SESSION_START_FLAG, LANE_ORPHAN_SWEEP_CONTEXT_FLAG, LANE_ORPHAN_SWEEP_ROOT_KEYING_FLAG } from "../lib/lanes/flag-keys.js";
|
|
9
9
|
const CODEX_DEFAULT_KEYS = ["conduct_engine", "conduct_child_model", "conduct_child_effort", "conduct_default_source"];
|
|
10
10
|
function readSessionMetaValue(text, key) {
|
|
11
11
|
const line = text.split(/\r?\n/).find((entry) => new RegExp(`^\\s*${key}:`).test(entry));
|
|
@@ -115,12 +115,55 @@ function extractConductorBlock(core) {
|
|
|
115
115
|
return core.slice(start + "<!-- inject:start -->".length, end).replace(/^\r?\n/, "").replace(/\r?\n$/, "");
|
|
116
116
|
}
|
|
117
117
|
const ORPHAN_SWEEP_LOCK_TTL_MS = 15 * 60_000;
|
|
118
|
+
function rootKeyFor(root) {
|
|
119
|
+
let resolved = path.resolve(root);
|
|
120
|
+
try {
|
|
121
|
+
resolved = fs.realpathSync(root);
|
|
122
|
+
}
|
|
123
|
+
catch { /* the canonicalized path remains the fallback */ }
|
|
124
|
+
return createHash("sha256").update(resolved).digest("hex").slice(0, 12);
|
|
125
|
+
}
|
|
126
|
+
function readOrphanCount(root, env, rootScoped, onError) {
|
|
127
|
+
try {
|
|
128
|
+
const stateRoot = env.XDG_STATE_HOME || path.join(os.homedir(), ".local", "state");
|
|
129
|
+
const host = os.hostname().replace(/[^A-Za-z0-9._-]/g, "_") || "unknown-host";
|
|
130
|
+
const rootKey = rootKeyFor(root);
|
|
131
|
+
const prefix = rootScoped ? `orphans-${rootKey}-` : "orphans-";
|
|
132
|
+
const dir = path.join(stateRoot, "hq-cli", "lanes-orphan-reports", host);
|
|
133
|
+
const candidates = fs.readdirSync(dir)
|
|
134
|
+
.filter((name) => name.startsWith(prefix) && name.endsWith(".json"))
|
|
135
|
+
.map((name) => ({ name, mtime: fs.statSync(path.join(dir, name)).mtimeMs }))
|
|
136
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
137
|
+
// Reports accumulate, so read newest first and stop at the first one that
|
|
138
|
+
// actually swept; skipped and unreadable reports are passed over.
|
|
139
|
+
for (const candidate of candidates) {
|
|
140
|
+
let report;
|
|
141
|
+
try {
|
|
142
|
+
report = JSON.parse(fs.readFileSync(path.join(dir, candidate.name), "utf8"));
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
onError?.(error);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (report.skipped)
|
|
149
|
+
continue;
|
|
150
|
+
return Array.isArray(report.lanes) ? report.lanes.filter((lane) => lane && typeof lane === "object" && lane.orphaned === true).length : 0;
|
|
151
|
+
}
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
onError?.(error);
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
118
159
|
/** Starts the CL-4 read-only orphan report once per host, without holding SessionStart open. */
|
|
119
|
-
export function startSessionStartOrphanSweep(root, env = process.env, spawnDetached = spawn) {
|
|
160
|
+
export function startSessionStartOrphanSweep(root, env = process.env, spawnDetached = spawn, rootScoped = true) {
|
|
120
161
|
const stateRoot = env.XDG_STATE_HOME || path.join(os.homedir(), ".local", "state");
|
|
121
162
|
const host = os.hostname().replace(/[^A-Za-z0-9._-]/g, "_") || "unknown-host";
|
|
163
|
+
const rootKey = rootKeyFor(root);
|
|
164
|
+
const rootSuffix = rootScoped ? `-${rootKey}` : "";
|
|
122
165
|
const stateDir = path.join(stateRoot, "hq-cli", "lanes-session-start", host);
|
|
123
|
-
const lockPath = path.join(stateDir,
|
|
166
|
+
const lockPath = path.join(stateDir, `orphan-sweep${rootSuffix}.lock`);
|
|
124
167
|
const reportDir = path.join(stateRoot, "hq-cli", "lanes-orphan-reports", host);
|
|
125
168
|
const ownerToken = randomUUID();
|
|
126
169
|
try {
|
|
@@ -174,11 +217,11 @@ export function startSessionStartOrphanSweep(root, env = process.env, spawnDetac
|
|
|
174
217
|
"const start=()=>{let lease;try{lease=JSON.parse(fs.readFileSync(lock,'utf8'))}catch{}if(lease?.owner_token!==token){if(Date.now()<Number(lease?.started_at||0)+1000)return setTimeout(start,10);process.exit(0)}",
|
|
175
218
|
"try{fs.writeFileSync(lock,JSON.stringify({...lease,pid:process.pid}))}catch{}",
|
|
176
219
|
"const tmp=report+'.tmp';let output;try{output=fs.createWriteStream(tmp,{flags:'wx',mode:0o600})}catch{cleanup();process.exit(0)}",
|
|
177
|
-
"const child=cp.spawn(process.execPath,[cli,'lanes','_reap','--json'],{stdio:['ignore','pipe','ignore'],env:{...process.env,HQ_ROOT:root,HQ_NO_UPDATE_CHECK:'1'}});",
|
|
220
|
+
"const child=cp.spawn(process.execPath,[cli,'lanes','_reap','--json','--legacy'],{stdio:['ignore','pipe','ignore'],env:{...process.env,HQ_ROOT:root,HQ_NO_UPDATE_CHECK:'1'}});",
|
|
178
221
|
"child.stdout.pipe(output);child.on('error',()=>{output.destroy();try{fs.rmSync(tmp,{force:true})}catch{}cleanup()});",
|
|
179
222
|
"child.on('exit',(code)=>output.end(()=>{try{if(code===0)fs.renameSync(tmp,report);else fs.rmSync(tmp,{force:true})}catch{}cleanup()}));};start();",
|
|
180
223
|
].join("");
|
|
181
|
-
const reportPath = path.join(reportDir, `orphans-${startedAt}-${ownerToken}.json`);
|
|
224
|
+
const reportPath = path.join(reportDir, `orphans-${rootScoped ? `${rootKey}-` : ""}${startedAt}-${ownerToken}.json`);
|
|
182
225
|
const launcher = spawnDetached(process.execPath, ["-e", wrapper, cli, lockPath, root, ownerToken, reportPath], {
|
|
183
226
|
detached: true,
|
|
184
227
|
stdio: "ignore",
|
|
@@ -245,7 +288,14 @@ export async function runLaneSessionStart(options = {}) {
|
|
|
245
288
|
});
|
|
246
289
|
if (!flagEnabled)
|
|
247
290
|
return;
|
|
248
|
-
|
|
291
|
+
const rootScoped = await readLanesRegistryFlagFailOpen(LANE_ORPHAN_SWEEP_ROOT_KEYING_FLAG, { env, defaultValue: true });
|
|
292
|
+
(options.startSweep ?? ((r, e) => startSessionStartOrphanSweep(r, e, spawn, rootScoped)))(root, env);
|
|
293
|
+
const contextEnabled = await readLanesRegistryFlagFailOpen(LANE_ORPHAN_SWEEP_CONTEXT_FLAG, { env, defaultValue: true });
|
|
294
|
+
if (contextEnabled) {
|
|
295
|
+
const count = readOrphanCount(root, env, rootScoped, (error) => debug(`hq lanes session-start: orphan report read failed (${error instanceof Error ? error.name : "Error"})`));
|
|
296
|
+
if (count > 0)
|
|
297
|
+
stdout.write(`HQ orphaned lanes: ${count}; inspect with hq lanes orphans\n`);
|
|
298
|
+
}
|
|
249
299
|
if (sessionWasDisabled(root, env, payload.session_id ?? payload.sessionId))
|
|
250
300
|
return;
|
|
251
301
|
const autoConduct = env.HQ_AUTO_CONDUCT ?? "";
|
package/dist/commands/secrets.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import * as yaml from "js-yaml";
|
|
4
|
-
import { buildSecretUsage, extractApiMessage, highSecuritySandboxOnlyMessage, loadRevealedSecrets, normalizeScriptLockMode, normalizeSecretTier, parseSecretNameList, runSecretsEnvCommand, scopeOpts, secretsCredentialOptions, } from "../secrets-env-action.js";
|
|
4
|
+
import { assertApiKeyCompany, buildSecretUsage, extractApiMessage, highSecuritySandboxOnlyMessage, loadRevealedSecrets, normalizeScriptLockMode, normalizeSecretTier, parseSecretNameList, requestedApiKeyCompany, runSecretsEnvCommand, scopeOpts, secretsCredentialOptions, } from "../secrets-env-action.js";
|
|
5
5
|
export { buildSecretUsage, emitEnvExportsAndExit, loadRevealedSecrets, parseSecretNameList, runSecretsEnvCommand, } from "../secrets-env-action.js";
|
|
6
6
|
export function readSessionCompanyLock(env = process.env) {
|
|
7
7
|
const root = resolveDefaultHqRoot({ env });
|
|
@@ -1284,8 +1284,17 @@ export function registerSecretsCommand(program) {
|
|
|
1284
1284
|
...(requestedCompany ? { requestedCompany } : {}),
|
|
1285
1285
|
})
|
|
1286
1286
|
: undefined;
|
|
1287
|
+
// Without a session lock, an explicit --company / HQ_COMPANY_UID must
|
|
1288
|
+
// still match the key's company: it used to be ignored, and the key's
|
|
1289
|
+
// own same-named secret was injected instead.
|
|
1290
|
+
const unlockedApiKeyCompanyRef = cred.kind === "api-key" && !hasLockedCompanies
|
|
1291
|
+
? requestedApiKeyCompany(requestedCompany)
|
|
1292
|
+
: undefined;
|
|
1287
1293
|
const companyUid = cred.kind === "api-key"
|
|
1288
|
-
? (apiKeyCompany?.companyUid
|
|
1294
|
+
? (apiKeyCompany?.companyUid
|
|
1295
|
+
?? (unlockedApiKeyCompanyRef
|
|
1296
|
+
? await assertApiKeyCompany(cred.token, unlockedApiKeyCompanyRef, "secrets exec")
|
|
1297
|
+
: "__api_key__"))
|
|
1289
1298
|
: await getEntityUid(cred.token, secretScope, { allowOfflineScopeFallback: true });
|
|
1290
1299
|
const readSecrets = async () => loadRevealedSecrets(cred.token, companyUid, keys, cred.kind === "cognito"
|
|
1291
1300
|
? await buildSecretUsage("exec", _opts.script, _opts.scriptId)
|
|
@@ -46,4 +46,12 @@ export declare const LANE_WORKER_CONTINUITY_FLAG = "lanes.worker-continuity";
|
|
|
46
46
|
export declare const LANE_CONDUCT_LOOP_FLAG = "lanes.conduct-loop";
|
|
47
47
|
export declare const LANE_ENVELOPE_REPAIR_GUIDANCE_FLAG = "lanes.envelope-repair-guidance";
|
|
48
48
|
export declare const LANE_LOOP_HANDOFF_REPAIR_FLAG = "lanes.loop-handoff-repair";
|
|
49
|
+
/** Adds the latest root-scoped orphan count to SessionStart context. */
|
|
50
|
+
export declare const LANE_ORPHAN_SWEEP_CONTEXT_FLAG = "lanes.orphan-sweep-context-line";
|
|
51
|
+
/** Separates lane reap leases and reports by resolved HQ root. */
|
|
52
|
+
export declare const LANE_ORPHAN_SWEEP_ROOT_KEYING_FLAG = "lanes.orphan-sweep-root-keying";
|
|
53
|
+
/** Reports awaiting-input lanes whose owning session ended. */
|
|
54
|
+
export declare const LANE_ORPHAN_SWEEP_WAITING_FLAG = "lanes.orphan-sweep-waiting-senior";
|
|
55
|
+
/** Reports and optionally stops abandoned legacy conduct pool loops. */
|
|
56
|
+
export declare const LANE_ORPHAN_SWEEP_LEGACY_FLAG = "lanes.orphan-sweep-legacy-pool";
|
|
49
57
|
//# sourceMappingURL=flag-keys.d.ts.map
|
|
@@ -46,4 +46,12 @@ export const LANE_WORKER_CONTINUITY_FLAG = "lanes.worker-continuity";
|
|
|
46
46
|
export const LANE_CONDUCT_LOOP_FLAG = "lanes.conduct-loop";
|
|
47
47
|
export const LANE_ENVELOPE_REPAIR_GUIDANCE_FLAG = "lanes.envelope-repair-guidance";
|
|
48
48
|
export const LANE_LOOP_HANDOFF_REPAIR_FLAG = "lanes.loop-handoff-repair";
|
|
49
|
+
/** Adds the latest root-scoped orphan count to SessionStart context. */
|
|
50
|
+
export const LANE_ORPHAN_SWEEP_CONTEXT_FLAG = "lanes.orphan-sweep-context-line";
|
|
51
|
+
/** Separates lane reap leases and reports by resolved HQ root. */
|
|
52
|
+
export const LANE_ORPHAN_SWEEP_ROOT_KEYING_FLAG = "lanes.orphan-sweep-root-keying";
|
|
53
|
+
/** Reports awaiting-input lanes whose owning session ended. */
|
|
54
|
+
export const LANE_ORPHAN_SWEEP_WAITING_FLAG = "lanes.orphan-sweep-waiting-senior";
|
|
55
|
+
/** Reports and optionally stops abandoned legacy conduct pool loops. */
|
|
56
|
+
export const LANE_ORPHAN_SWEEP_LEGACY_FLAG = "lanes.orphan-sweep-legacy-pool";
|
|
49
57
|
//# sourceMappingURL=flag-keys.js.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface LegacyPoolFinding {
|
|
2
|
+
lane_id: string;
|
|
3
|
+
legacy: true;
|
|
4
|
+
orphaned: boolean;
|
|
5
|
+
reason: string;
|
|
6
|
+
age_minutes: number;
|
|
7
|
+
session_id: string;
|
|
8
|
+
company_slug?: string;
|
|
9
|
+
pid: number;
|
|
10
|
+
process_starttime: number | null;
|
|
11
|
+
loop_started_at: string;
|
|
12
|
+
run_dir: string;
|
|
13
|
+
would_stop?: boolean;
|
|
14
|
+
stopped?: boolean;
|
|
15
|
+
stop_error?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface LegacyPoolDeps {
|
|
18
|
+
processes?: () => Array<{
|
|
19
|
+
pid: number;
|
|
20
|
+
ppid: number;
|
|
21
|
+
args: string;
|
|
22
|
+
}>;
|
|
23
|
+
isAlive?: (pid: number) => boolean;
|
|
24
|
+
readStarttime?: (pid: number) => number | null;
|
|
25
|
+
signal?: (pid: number, signal: NodeJS.Signals) => void;
|
|
26
|
+
snapshotProcess?: (pid: number) => {
|
|
27
|
+
args: string;
|
|
28
|
+
startedAt: number | null;
|
|
29
|
+
} | null;
|
|
30
|
+
sessionState?: (root: string, sessionId: string, now: Date) => string | null;
|
|
31
|
+
now?: () => Date;
|
|
32
|
+
}
|
|
33
|
+
export declare function findLegacyPoolLanes(root: string, deps?: LegacyPoolDeps): LegacyPoolFinding[];
|
|
34
|
+
export declare function stopLegacyPoolLane(root: string, finding: LegacyPoolFinding, deps?: LegacyPoolDeps): boolean;
|
|
35
|
+
//# sourceMappingURL=legacy-pool.d.ts.map
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { readProcStarttime } from "./spawn.js";
|
|
5
|
+
// The runner writes loop.json after node start-up and a CPU sample, measured at
|
|
6
|
+
// 0.8 to 1.8 s past the whole second `ps lstart` reports. A process that reused
|
|
7
|
+
// the pid started after the recorded runner was alive at started_at, so its
|
|
8
|
+
// lstart cannot fall a full second before started_at; the upper bound only
|
|
9
|
+
// caps how slow a start-up is believed.
|
|
10
|
+
const LOOP_START_WINDOW_MS = 60_000;
|
|
11
|
+
function loopStartMatches(processStartedAt, loopStartedAt) {
|
|
12
|
+
if (processStartedAt === null || !Number.isFinite(loopStartedAt))
|
|
13
|
+
return false;
|
|
14
|
+
const delta = loopStartedAt - processStartedAt;
|
|
15
|
+
return delta > -1_000 && delta < LOOP_START_WINDOW_MS;
|
|
16
|
+
}
|
|
17
|
+
function poolSlots(text) {
|
|
18
|
+
const slots = [];
|
|
19
|
+
let inBlock = false;
|
|
20
|
+
let current;
|
|
21
|
+
const save = () => {
|
|
22
|
+
if (current?.workerId && current.status && current.pid && current.runDir)
|
|
23
|
+
slots.push(current);
|
|
24
|
+
current = undefined;
|
|
25
|
+
};
|
|
26
|
+
for (const line of text.split(/\r?\n/)) {
|
|
27
|
+
if (!inBlock) {
|
|
28
|
+
if (line === "conduct_pool:")
|
|
29
|
+
inBlock = true;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (/^[^ \t]/.test(line)) {
|
|
33
|
+
save();
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
const worker = /^ {2}- worker_id:\s*"?([^"\s]+)"?\s*$/.exec(line);
|
|
37
|
+
if (worker) {
|
|
38
|
+
save();
|
|
39
|
+
current = { workerId: worker[1] };
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (!current)
|
|
43
|
+
continue;
|
|
44
|
+
const field = /^ {4}(status|pid|run_dir):\s*(.*?)\s*$/.exec(line);
|
|
45
|
+
if (!field)
|
|
46
|
+
continue;
|
|
47
|
+
const value = field[2].replace(/^"|"$/g, "");
|
|
48
|
+
if (field[1] === "status")
|
|
49
|
+
current.status = value;
|
|
50
|
+
if (field[1] === "pid" && /^\d+$/.test(value))
|
|
51
|
+
current.pid = Number(value);
|
|
52
|
+
if (field[1] === "run_dir")
|
|
53
|
+
current.runDir = value;
|
|
54
|
+
}
|
|
55
|
+
save();
|
|
56
|
+
return slots;
|
|
57
|
+
}
|
|
58
|
+
function processSnapshot(pid) {
|
|
59
|
+
const argsResult = spawnSync("ps", ["-ww", "-p", String(pid), "-o", "args="], { encoding: "utf8", windowsHide: true });
|
|
60
|
+
if (argsResult.status !== 0)
|
|
61
|
+
return null;
|
|
62
|
+
const startResult = spawnSync("ps", ["-p", String(pid), "-o", "lstart="], { encoding: "utf8", windowsHide: true });
|
|
63
|
+
const startedAt = startResult.status === 0 ? Date.parse(startResult.stdout.trim()) : NaN;
|
|
64
|
+
return { args: argsResult.stdout.trim(), startedAt: Number.isFinite(startedAt) ? startedAt : null };
|
|
65
|
+
}
|
|
66
|
+
function readProcessList() {
|
|
67
|
+
const result = spawnSync("ps", ["-ww", "-axo", "pid=,ppid=,args="], { encoding: "utf8", windowsHide: true });
|
|
68
|
+
if (result.status !== 0)
|
|
69
|
+
return [];
|
|
70
|
+
return result.stdout.split("\n").flatMap((line) => {
|
|
71
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
|
|
72
|
+
return match ? [{ pid: Number(match[1]), ppid: Number(match[2]), args: match[3] ?? "" }] : [];
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
function hasLiveSessionAncestor(pid, processes) {
|
|
76
|
+
const byPid = new Map(processes.map((proc) => [proc.pid, proc]));
|
|
77
|
+
let current = byPid.get(pid);
|
|
78
|
+
const seen = new Set();
|
|
79
|
+
for (let hops = 0; current && current.pid > 1 && hops < 64; hops++) {
|
|
80
|
+
if (seen.has(current.pid))
|
|
81
|
+
return true;
|
|
82
|
+
seen.add(current.pid);
|
|
83
|
+
const argv = current.args.trim().split(/\s+/);
|
|
84
|
+
const base = (arg) => arg?.split(/[\\/]/).at(-1)?.toLowerCase();
|
|
85
|
+
const names = new Set(["claude", "codex", "codex-code-mode-host", "grok", "grok-cli"]);
|
|
86
|
+
if (names.has(base(argv[0]) ?? "") || ((["bash", "sh", "zsh", "dash", "ksh", "node", "env"].includes(base(argv[0]) ?? "")) && names.has(base(argv[1]) ?? "")))
|
|
87
|
+
return true;
|
|
88
|
+
current = byPid.get(current.ppid);
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
function sessionCompany(meta) {
|
|
93
|
+
return /^company_slug:\s*["']?([A-Za-z0-9._-]+)["']?\s*$/m.exec(meta)?.[1];
|
|
94
|
+
}
|
|
95
|
+
function loopRecords(root) {
|
|
96
|
+
const sessions = path.join(root, "workspace", "sessions");
|
|
97
|
+
const runnerRoot = path.resolve(root, "workspace", "tmp", "workflow-runner");
|
|
98
|
+
let sessionIds;
|
|
99
|
+
try {
|
|
100
|
+
sessionIds = fs.readdirSync(sessions);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
const records = [];
|
|
106
|
+
for (const sessionId of sessionIds) {
|
|
107
|
+
const metaPath = path.join(sessions, sessionId, "meta.yaml");
|
|
108
|
+
let slots;
|
|
109
|
+
let meta;
|
|
110
|
+
try {
|
|
111
|
+
meta = fs.readFileSync(metaPath, "utf8");
|
|
112
|
+
slots = poolSlots(meta);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const companySlug = sessionCompany(meta);
|
|
118
|
+
for (const slot of slots) {
|
|
119
|
+
if (!Number.isSafeInteger(slot.pid) || slot.pid <= 1 || !["running", "waiting"].includes(slot.status))
|
|
120
|
+
continue;
|
|
121
|
+
const runDir = path.resolve(slot.runDir);
|
|
122
|
+
if (!runDir.startsWith(`${runnerRoot}${path.sep}`))
|
|
123
|
+
continue;
|
|
124
|
+
let loop;
|
|
125
|
+
try {
|
|
126
|
+
loop = JSON.parse(fs.readFileSync(path.join(runDir, "loop.json"), "utf8"));
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (loop.pid !== slot.pid || path.resolve(loop.run_dir ?? "") !== runDir)
|
|
132
|
+
continue;
|
|
133
|
+
records.push({ sessionId, ...(companySlug ? { companySlug } : {}), slot, loop });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return records;
|
|
137
|
+
}
|
|
138
|
+
export function findLegacyPoolLanes(root, deps = {}) {
|
|
139
|
+
const processes = (deps.processes ?? (() => {
|
|
140
|
+
const result = spawnSync("ps", ["-ww", "-axo", "pid=,ppid=,args="], { encoding: "utf8", windowsHide: true });
|
|
141
|
+
if (result.status !== 0)
|
|
142
|
+
return [];
|
|
143
|
+
return result.stdout.split("\n").flatMap((line) => {
|
|
144
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
|
|
145
|
+
return match ? [{ pid: Number(match[1]), ppid: Number(match[2]), args: match[3] ?? "" }] : [];
|
|
146
|
+
});
|
|
147
|
+
}))();
|
|
148
|
+
const alive = deps.isAlive ?? ((pid) => { try {
|
|
149
|
+
process.kill(pid, 0);
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return false;
|
|
154
|
+
} });
|
|
155
|
+
const readStart = deps.readStarttime ?? readProcStarttime;
|
|
156
|
+
const now = deps.now?.() ?? new Date();
|
|
157
|
+
return loopRecords(root).flatMap(({ sessionId, companySlug, slot, loop }) => {
|
|
158
|
+
if (!alive(slot.pid))
|
|
159
|
+
return [];
|
|
160
|
+
const proc = (deps.snapshotProcess ?? processSnapshot)(slot.pid);
|
|
161
|
+
if (!proc)
|
|
162
|
+
return [];
|
|
163
|
+
const argsMatch = /(?:^|[\\/])workflow-runner\.mjs(?:\s|$)/.test(proc.args) && /(?:^|\s)--loop(?:\s|$)/.test(proc.args);
|
|
164
|
+
const identityMatches = argsMatch && loopStartMatches(proc.startedAt, Date.parse(loop.started_at ?? ""));
|
|
165
|
+
const ownerState = deps.sessionState?.(root, sessionId, now) ?? null;
|
|
166
|
+
if (deps.sessionState ? ownerState !== "ended" && ownerState !== "lost" : hasLiveSessionAncestor(slot.pid, processes))
|
|
167
|
+
return [];
|
|
168
|
+
const laneId = `legacy-${sessionId}-${slot.workerId}`;
|
|
169
|
+
return [{
|
|
170
|
+
lane_id: laneId,
|
|
171
|
+
legacy: true,
|
|
172
|
+
orphaned: true,
|
|
173
|
+
reason: identityMatches ? "legacy_conduct_session_gone" : "legacy_pid_reused_or_identity_unproven",
|
|
174
|
+
age_minutes: Number.isFinite(Date.parse(loop.started_at ?? ""))
|
|
175
|
+
? Math.max(0, Math.floor((now.getTime() - Date.parse(loop.started_at)) / 60_000))
|
|
176
|
+
: 0,
|
|
177
|
+
session_id: sessionId,
|
|
178
|
+
...(companySlug ? { company_slug: companySlug } : {}),
|
|
179
|
+
pid: slot.pid,
|
|
180
|
+
process_starttime: readStart(slot.pid),
|
|
181
|
+
loop_started_at: loop.started_at ?? "",
|
|
182
|
+
run_dir: slot.runDir,
|
|
183
|
+
...(!identityMatches ? { would_stop: false } : {}),
|
|
184
|
+
}];
|
|
185
|
+
}).sort((a, b) => a.lane_id.localeCompare(b.lane_id));
|
|
186
|
+
}
|
|
187
|
+
export function stopLegacyPoolLane(root, finding, deps = {}) {
|
|
188
|
+
const processes = (deps.processes ?? readProcessList)();
|
|
189
|
+
const proc = (deps.snapshotProcess ?? processSnapshot)(finding.pid);
|
|
190
|
+
const readStart = deps.readStarttime ?? readProcStarttime;
|
|
191
|
+
if (!proc || finding.process_starttime === null || readStart(finding.pid) !== finding.process_starttime || !loopStartMatches(proc.startedAt, Date.parse(finding.loop_started_at)) || !/(?:^|[\\/])workflow-runner\.mjs(?:\s|$)/.test(proc.args) || !/(?:^|\s)--loop(?:\s|$)/.test(proc.args))
|
|
192
|
+
return false;
|
|
193
|
+
const signal = deps.signal ?? ((pid, value) => process.kill(pid, value));
|
|
194
|
+
if (processes.length > 0 && hasLiveSessionAncestor(finding.pid, processes))
|
|
195
|
+
return false;
|
|
196
|
+
if (deps.sessionState) {
|
|
197
|
+
const ownerState = deps.sessionState(root, finding.session_id, deps.now?.() ?? new Date());
|
|
198
|
+
if (ownerState !== "ended" && ownerState !== "lost")
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
signal(finding.pid, "SIGTERM");
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
//# sourceMappingURL=legacy-pool.js.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { LaneRecord } from "./types.js";
|
|
2
|
+
import { type LegacyPoolFinding } from "./legacy-pool.js";
|
|
2
3
|
export declare const DEFAULT_REAP_MIN_AGE_MINUTES = 120;
|
|
3
4
|
export declare const REAP_SINGLE_FLIGHT_LOCK_STALE_MS: number;
|
|
4
5
|
export declare const REAP_RECENT_SWEEP_MS: number;
|
|
@@ -20,6 +21,8 @@ export interface ReapFinding {
|
|
|
20
21
|
stop_complete?: boolean;
|
|
21
22
|
stop_error?: string;
|
|
22
23
|
stale_state?: string[];
|
|
24
|
+
legacy?: true;
|
|
25
|
+
run_dir?: string;
|
|
23
26
|
}
|
|
24
27
|
export interface ReapDeps {
|
|
25
28
|
now?: () => Date;
|
|
@@ -31,6 +34,15 @@ export interface ReapDeps {
|
|
|
31
34
|
stopEnabled?: (lane: LaneRecord) => Promise<boolean> | boolean;
|
|
32
35
|
stopLane?: (laneId: string, reason: string) => Promise<void>;
|
|
33
36
|
sleep?: (ms: number) => void;
|
|
37
|
+
rootScopedStateEnabled?: () => Promise<boolean> | boolean;
|
|
38
|
+
waitingSeniorEnabled?: () => Promise<boolean> | boolean;
|
|
39
|
+
legacyEnabled?: () => Promise<boolean> | boolean;
|
|
40
|
+
legacyStopEnabled?: () => Promise<boolean> | boolean;
|
|
41
|
+
stopLegacy?: (finding: LegacyPoolFinding) => boolean;
|
|
42
|
+
snapshotProcess?: (pid: number) => {
|
|
43
|
+
args: string;
|
|
44
|
+
startedAt: number | null;
|
|
45
|
+
} | null;
|
|
34
46
|
}
|
|
35
47
|
export declare function parseReapMinAge(value: string | undefined): number;
|
|
36
48
|
export declare function isProcessRunning(pid: number): boolean;
|
|
@@ -39,6 +51,7 @@ export interface ReapOptions {
|
|
|
39
51
|
minAgeMinutes: number;
|
|
40
52
|
stop: boolean;
|
|
41
53
|
applyStaleState: boolean;
|
|
54
|
+
legacy?: boolean;
|
|
42
55
|
reason: string;
|
|
43
56
|
json: boolean;
|
|
44
57
|
}
|
package/dist/lib/lanes/reaper.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Session-start orphan lane sweep. Every destructive action is opt-in and gated. */
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
3
4
|
import * as os from "node:os";
|
|
4
5
|
import * as path from "node:path";
|
|
5
6
|
import { spawnSync } from "node:child_process";
|
|
@@ -7,6 +8,7 @@ import { laneRunsOnHost } from "./admission.js";
|
|
|
7
8
|
import { laneRunDir, isPidRecordAlive, readPidRecord, readProcStarttime } from "./spawn.js";
|
|
8
9
|
import { readAllLanes, readLane } from "./store.js";
|
|
9
10
|
import { LanesError } from "./errors.js";
|
|
11
|
+
import { findLegacyPoolLanes, stopLegacyPoolLane } from "./legacy-pool.js";
|
|
10
12
|
export const DEFAULT_REAP_MIN_AGE_MINUTES = 120;
|
|
11
13
|
export const REAP_SINGLE_FLIGHT_LOCK_STALE_MS = 60 * 60 * 1000;
|
|
12
14
|
export const REAP_RECENT_SWEEP_MS = 30 * 60 * 1000;
|
|
@@ -15,6 +17,14 @@ const staleMarkerSnapshots = new WeakMap();
|
|
|
15
17
|
function safeHostComponent(hostname) {
|
|
16
18
|
return hostname.replace(/[^A-Za-z0-9._-]/g, "_") || "unknown-host";
|
|
17
19
|
}
|
|
20
|
+
function resolvedRootKey(root) {
|
|
21
|
+
let resolved = path.resolve(root);
|
|
22
|
+
try {
|
|
23
|
+
resolved = fs.realpathSync(root);
|
|
24
|
+
}
|
|
25
|
+
catch { /* use the normalized input path */ }
|
|
26
|
+
return createHash("sha256").update(resolved).digest("hex").slice(0, 12);
|
|
27
|
+
}
|
|
18
28
|
function reaperStateDirectory() {
|
|
19
29
|
const stateRoot = process.env.HQ_LANES_REAPER_STATE_HOME?.trim() ||
|
|
20
30
|
process.env.XDG_STATE_HOME?.trim() || path.join(os.homedir(), ".local", "state");
|
|
@@ -231,8 +241,15 @@ export async function classifyLocalLanes(root, now, minAgeMinutes, deps = {}) {
|
|
|
231
241
|
const sessionState = deps.sessionState?.(root, lane.senior.id, now) ?? null;
|
|
232
242
|
if (sessionState !== "ended" && sessionState !== "lost")
|
|
233
243
|
finding.reason = sessionState === "live" ? "senior_session_alive" : "senior_session_not_proven_dead";
|
|
234
|
-
else if (lane.state !== "running")
|
|
235
|
-
|
|
244
|
+
else if (lane.state !== "running") {
|
|
245
|
+
const waitingEnabled = deps.waitingSeniorEnabled ? await deps.waitingSeniorEnabled() : true;
|
|
246
|
+
if (waitingEnabled && ["queued", "awaiting_input", "blocked"].includes(lane.state)) {
|
|
247
|
+
finding.orphaned = true;
|
|
248
|
+
finding.reason = "senior_ended_while_waiting";
|
|
249
|
+
}
|
|
250
|
+
else
|
|
251
|
+
finding.reason = "lane_not_running";
|
|
252
|
+
}
|
|
236
253
|
else if (lane.keep_alive === true || lane.keep_alive_standing === true)
|
|
237
254
|
finding.reason = "keep_alive";
|
|
238
255
|
else if (lane.senior_history.some((entry) => entry.reason === "reparented"))
|
|
@@ -276,7 +293,10 @@ export async function reapLocalLanes(root, options, deps = {}) {
|
|
|
276
293
|
const stateDir = reaperStateDirectory();
|
|
277
294
|
fs.mkdirSync(stateDir, { recursive: true });
|
|
278
295
|
const host = safeHostComponent(deps.hostname?.() ?? os.hostname());
|
|
279
|
-
const
|
|
296
|
+
const rootScoped = deps.rootScopedStateEnabled ? await deps.rootScopedStateEnabled() : true;
|
|
297
|
+
const rootKey = resolvedRootKey(root);
|
|
298
|
+
const stateKey = rootScoped ? `${host}-${rootKey}` : host;
|
|
299
|
+
const lockDir = path.join(stateDir, `.lock-${stateKey}`);
|
|
280
300
|
try {
|
|
281
301
|
fs.mkdirSync(lockDir);
|
|
282
302
|
}
|
|
@@ -301,7 +321,7 @@ export async function reapLocalLanes(root, options, deps = {}) {
|
|
|
301
321
|
return { ok: true, action: "lanes.reap", generated_at: now.toISOString(), min_age_minutes: options.minAgeMinutes, mode: "report", stop_enabled: false, skipped: "already_running", lanes: [] };
|
|
302
322
|
}
|
|
303
323
|
}
|
|
304
|
-
const stamp = path.join(stateDir, `.last-sweep-${
|
|
324
|
+
const stamp = path.join(stateDir, `.last-sweep-${stateKey}`);
|
|
305
325
|
if (!options.stop && !options.applyStaleState) {
|
|
306
326
|
try {
|
|
307
327
|
const stampText = fs.readFileSync(stamp, "utf8").trim();
|
|
@@ -319,6 +339,9 @@ export async function reapLocalLanes(root, options, deps = {}) {
|
|
|
319
339
|
}
|
|
320
340
|
try {
|
|
321
341
|
const findings = await classifyLocalLanes(root, now, options.minAgeMinutes, deps);
|
|
342
|
+
const legacyEnabled = options.legacy && (deps.legacyEnabled ? await deps.legacyEnabled() : true);
|
|
343
|
+
const legacyFindings = legacyEnabled ? findLegacyPoolLanes(root, deps) : [];
|
|
344
|
+
findings.push(...legacyFindings);
|
|
322
345
|
const isAlive = deps.isAlive ?? isProcessRunning;
|
|
323
346
|
const readStart = deps.readStarttime ?? readProcStarttime;
|
|
324
347
|
const clearStaleMarkerIfUnchanged = (finding, marker) => {
|
|
@@ -341,10 +364,25 @@ export async function reapLocalLanes(root, options, deps = {}) {
|
|
|
341
364
|
clearStaleMarkerIfUnchanged(finding, marker);
|
|
342
365
|
}
|
|
343
366
|
if (!finding.orphaned || !options.stop || finding.pid === undefined) {
|
|
344
|
-
if (finding.orphaned && options.stop)
|
|
367
|
+
if (finding.orphaned && options.stop && finding.pid !== undefined)
|
|
345
368
|
finding.would_stop = true;
|
|
346
369
|
continue;
|
|
347
370
|
}
|
|
371
|
+
if (finding.legacy) {
|
|
372
|
+
const enabled = await (deps.legacyEnabled?.() ?? true) && await (deps.legacyStopEnabled?.() ?? false);
|
|
373
|
+
stopEnabled ||= enabled;
|
|
374
|
+
if (!enabled || finding.reason === "legacy_pid_reused_or_identity_unproven") {
|
|
375
|
+
finding.would_stop = false;
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
const legacy = finding;
|
|
379
|
+
const stopped = (deps.stopLegacy ?? ((item) => stopLegacyPoolLane(root, item, deps)))(legacy);
|
|
380
|
+
finding.stopped = stopped;
|
|
381
|
+
finding.would_stop = !stopped;
|
|
382
|
+
if (!stopped)
|
|
383
|
+
finding.reason = "legacy_identity_changed_before_stop";
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
348
386
|
const lane = readLane(root, finding.lane_id);
|
|
349
387
|
const enabled = await (deps.stopEnabled?.(lane) ?? false);
|
|
350
388
|
stopEnabled ||= enabled;
|
|
@@ -57,6 +57,23 @@ export declare function normalizeScriptLockMode(mode?: string): SecretScriptLock
|
|
|
57
57
|
export declare function extractApiMessage(body: Record<string, unknown>, fallback: string): string;
|
|
58
58
|
export declare function buildSecretUsage(channel: SecretUsageChannel, scriptPath?: string, scriptId?: string, attestationLevel?: string): Promise<SecretUsage>;
|
|
59
59
|
export declare function parseSecretNameList(input: string[] | undefined): string[];
|
|
60
|
+
/**
|
|
61
|
+
* The company a caller explicitly asked for while authenticating with
|
|
62
|
+
* HQ_API_KEY: `--company`, else HQ_COMPANY_UID. Undefined means "the key's own
|
|
63
|
+
* company", which needs no check.
|
|
64
|
+
*/
|
|
65
|
+
export declare function requestedApiKeyCompany(flagCompany: string | undefined): string | undefined;
|
|
66
|
+
/**
|
|
67
|
+
* Refuse an explicitly requested company that the API key is not bound to.
|
|
68
|
+
*
|
|
69
|
+
* A vault API key can only read its own company's secrets. Before this check,
|
|
70
|
+
* `hq secrets exec --company <other>` under HQ_API_KEY ignored the flag and
|
|
71
|
+
* injected the key company's same-named secret with exit 0, which read as a
|
|
72
|
+
* cross-company leak (P0 triage, 2026-10-11). The probe is the keyed list
|
|
73
|
+
* route, which the server 403s with "not scoped to this company" on mismatch.
|
|
74
|
+
* Returns the verified uid; throws before any secret is fetched otherwise.
|
|
75
|
+
*/
|
|
76
|
+
export declare function assertApiKeyCompany(token: string, requested: string, commandLabel: string): Promise<string>;
|
|
60
77
|
export declare function loadRevealedSecrets(token: string, companyUid: string, keys: string[], usage?: SecretUsage, options?: {
|
|
61
78
|
allowOfflineFallback?: boolean;
|
|
62
79
|
}): Promise<Map<string, string>>;
|
|
@@ -7,7 +7,7 @@ import { DEFAULT_SECRETS_CACHE_TTL_MS, hasCacheEntry, readCache, removeCacheEntr
|
|
|
7
7
|
import { computeSha256 } from "./utils/integrity.js";
|
|
8
8
|
import { SECRET_NAME_PATTERN } from "./commands/_patterns.js";
|
|
9
9
|
import { finishEnvExportsAndExit } from "./commands/secrets-env-exit.js";
|
|
10
|
-
import { getEntityUid, vaultApiFetch } from "./utils/vault-api.js";
|
|
10
|
+
import { getEntityUid, looksLikeCompanyUid, vaultApiFetch } from "./utils/vault-api.js";
|
|
11
11
|
import { HQ_API_KEY_PREFIX, resolveVaultCredentialForCapability, } from "./utils/resolve-vault-credential.js";
|
|
12
12
|
import { CACHED_SECRETS_SERVER_UNAVAILABLE_MESSAGE, isSecretLoadUnavailable, isUnavailableSecretLoadHttpStatus, SecretLoadUnavailableError, } from "./utils/secret-load-availability.js";
|
|
13
13
|
const MAX_BATCH_NAMES = 100;
|
|
@@ -124,16 +124,22 @@ export function parseSecretNameList(input) {
|
|
|
124
124
|
// Requests are chunked at MAX_BATCH_NAMES and throw on the FIRST unresolved key
|
|
125
125
|
// with the same `Failed to fetch secret '<k>': <reason>` shape the per-key GET
|
|
126
126
|
// path used — never swallows a failure.
|
|
127
|
-
|
|
127
|
+
//
|
|
128
|
+
// When the caller names a company uid, it rides in the body so the server can
|
|
129
|
+
// refuse a key bound to a different company. Without it the fetch route reads
|
|
130
|
+
// from the key's own company, which made `--company <other>` silently return
|
|
131
|
+
// the key company's same-named secret (P0 triage, 2026-10-11).
|
|
132
|
+
async function loadRevealedSecretsViaApiKey(token, keys, companyUid) {
|
|
128
133
|
const resolved = new Map();
|
|
129
134
|
const requested = [...new Set(keys)];
|
|
130
135
|
const cacheScope = "__api_key__";
|
|
136
|
+
const scopedCompanyUid = companyUid && looksLikeCompanyUid(companyUid) ? companyUid : undefined;
|
|
131
137
|
for (const name of requested) {
|
|
132
138
|
const res = await vaultApiFetch({
|
|
133
139
|
token,
|
|
134
140
|
path: "/v1/keys/secrets/fetch",
|
|
135
141
|
method: "POST",
|
|
136
|
-
body: { name },
|
|
142
|
+
body: scopedCompanyUid ? { name, companyUid: scopedCompanyUid } : { name },
|
|
137
143
|
signal: AbortSignal.timeout(SECRET_LOAD_TIMEOUT_MS),
|
|
138
144
|
});
|
|
139
145
|
const body = (await res.json().catch(() => ({})));
|
|
@@ -168,9 +174,52 @@ async function loadRevealedSecretsViaApiKey(token, keys) {
|
|
|
168
174
|
}
|
|
169
175
|
return resolved;
|
|
170
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* The company a caller explicitly asked for while authenticating with
|
|
179
|
+
* HQ_API_KEY: `--company`, else HQ_COMPANY_UID. Undefined means "the key's own
|
|
180
|
+
* company", which needs no check.
|
|
181
|
+
*/
|
|
182
|
+
export function requestedApiKeyCompany(flagCompany) {
|
|
183
|
+
const ref = flagCompany?.trim() || process.env.HQ_COMPANY_UID?.trim();
|
|
184
|
+
return ref ? ref : undefined;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Refuse an explicitly requested company that the API key is not bound to.
|
|
188
|
+
*
|
|
189
|
+
* A vault API key can only read its own company's secrets. Before this check,
|
|
190
|
+
* `hq secrets exec --company <other>` under HQ_API_KEY ignored the flag and
|
|
191
|
+
* injected the key company's same-named secret with exit 0, which read as a
|
|
192
|
+
* cross-company leak (P0 triage, 2026-10-11). The probe is the keyed list
|
|
193
|
+
* route, which the server 403s with "not scoped to this company" on mismatch.
|
|
194
|
+
* Returns the verified uid; throws before any secret is fetched otherwise.
|
|
195
|
+
*/
|
|
196
|
+
export async function assertApiKeyCompany(token, requested, commandLabel) {
|
|
197
|
+
if (!looksLikeCompanyUid(requested)) {
|
|
198
|
+
throw new Error(`HQ_API_KEY is set; '${commandLabel}' --company must be the company uid (cmp_…) the key ` +
|
|
199
|
+
`was minted for (resolving a slug needs a Cognito session). Got '${requested}'. ` +
|
|
200
|
+
`Omit --company to use the key's own company.`);
|
|
201
|
+
}
|
|
202
|
+
const response = await vaultApiFetch({
|
|
203
|
+
token,
|
|
204
|
+
path: `/v1/keys/secrets/${encodeURIComponent(requested)}`,
|
|
205
|
+
});
|
|
206
|
+
if (response.ok)
|
|
207
|
+
return requested;
|
|
208
|
+
if (response.status === 401)
|
|
209
|
+
throw new Error("HQ_API_KEY is invalid or no longer active.");
|
|
210
|
+
const body = (await response.json().catch(() => ({})));
|
|
211
|
+
const message = typeof body.error === "string" ? body.error : "";
|
|
212
|
+
if (response.status === 403 && /not scoped to this company/i.test(message)) {
|
|
213
|
+
throw new Error(`HQ_API_KEY belongs to a different company than ${requested}. An API key can only read ` +
|
|
214
|
+
`its own company's secrets. Omit --company (and HQ_COMPANY_UID) to use the key's company, ` +
|
|
215
|
+
`or use a key minted for ${requested}.`);
|
|
216
|
+
}
|
|
217
|
+
throw new Error(`Could not verify that HQ_API_KEY belongs to ${requested} (HTTP ${response.status}` +
|
|
218
|
+
`${message ? `: ${message}` : ""}).`);
|
|
219
|
+
}
|
|
171
220
|
export async function loadRevealedSecrets(token, companyUid, keys, usage, options = {}) {
|
|
172
221
|
if (token.startsWith(HQ_API_KEY_PREFIX)) {
|
|
173
|
-
return loadRevealedSecretsViaApiKey(token, keys);
|
|
222
|
+
return loadRevealedSecretsViaApiKey(token, keys, companyUid);
|
|
174
223
|
}
|
|
175
224
|
const resolved = new Map();
|
|
176
225
|
const requested = [...new Set(keys)];
|
|
@@ -416,8 +465,13 @@ export async function runSecretsEnvCommand(opts, parentScope, options = {}) {
|
|
|
416
465
|
}
|
|
417
466
|
const keys = parseSecretNameList(opts.only);
|
|
418
467
|
const cred = await resolveVaultCredentialForCapability("secrets:read", "secrets env", secretsCredentialOptions());
|
|
468
|
+
const apiKeyCompanyRef = cred.kind === "api-key"
|
|
469
|
+
? requestedApiKeyCompany(parentScope.company)
|
|
470
|
+
: undefined;
|
|
419
471
|
const companyUid = cred.kind === "api-key"
|
|
420
|
-
?
|
|
472
|
+
? (apiKeyCompanyRef
|
|
473
|
+
? await assertApiKeyCompany(cred.token, apiKeyCompanyRef, "secrets env")
|
|
474
|
+
: "__api_key__")
|
|
421
475
|
: await getEntityUid(cred.token, scopeOpts(parentScope), {
|
|
422
476
|
allowOfflineScopeFallback: true,
|
|
423
477
|
});
|