@indigoai-us/hq-cli 5.345.134 → 5.345.136
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 +48 -0
- package/assets/scaffold/core/scripts/worktree-gc.sh +98 -8
- package/dist/command-catalog.generated.d.ts +62 -6
- package/dist/command-catalog.generated.js +79 -7
- package/dist/command-registration-plan.d.ts +6 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/core-bucket-c-handoff-knowledge-native.d.ts +3 -1
- package/dist/commands/core-bucket-c-handoff-knowledge-native.js +8 -4
- package/dist/commands/core-checkpoint-native.d.ts +10 -0
- package/dist/commands/core-checkpoint-native.js +60 -15
- package/dist/commands/core-handoff.d.ts +2 -0
- package/dist/commands/core-handoff.js +3 -1
- package/dist/commands/core-pipeline-workflow-runner-native.js +7 -5
- package/dist/commands/daemon.js +3 -1
- package/dist/commands/fingerprint.d.ts +10 -0
- package/dist/commands/fingerprint.js +191 -0
- package/dist/commands/integrations-core.d.ts +22 -0
- package/dist/commands/integrations-core.js +25 -0
- package/dist/commands/integrations-manage.d.ts +24 -1
- package/dist/commands/integrations-manage.js +92 -17
- package/dist/commands/integrations.d.ts +11 -0
- package/dist/commands/integrations.js +100 -19
- package/dist/commands/lanes-loop.d.ts +9 -0
- package/dist/commands/lanes-loop.js +9 -1
- package/dist/commands/lanes.js +78 -55
- package/dist/commands/meetings.d.ts +2 -0
- package/dist/commands/meetings.js +57 -14
- package/dist/commands/mesh.d.ts +19 -1
- package/dist/commands/mesh.js +158 -70
- package/dist/commands/reindex.js +80 -5
- package/dist/commands/storage-offload.d.ts +93 -28
- package/dist/commands/storage-offload.js +290 -34
- package/dist/commands/storage.js +60 -7
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2550 -1748
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/index.js +4 -0
- package/dist/lib/capacity/route.js +24 -0
- package/dist/lib/core-utils/session-company.d.ts +11 -0
- package/dist/lib/core-utils/session-company.js +34 -0
- package/dist/lib/core-utils/worktree-resume.js +44 -4
- package/dist/lib/core-utils/worktree-session-owner.d.ts +27 -0
- package/dist/lib/core-utils/worktree-session-owner.js +157 -0
- package/dist/lib/daemon/catalog.d.ts +2 -0
- package/dist/lib/daemon/catalog.js +3 -3
- package/dist/lib/daemon/hq-child-bootstrap-stage.d.ts +5 -0
- package/dist/lib/daemon/hq-child-bootstrap-stage.js +3 -11
- package/dist/lib/daemon/server.js +10 -1
- package/dist/lib/daemon/supervisor.js +6 -0
- package/dist/lib/daemon/sync-runner-bundle.d.ts +5 -0
- package/dist/lib/daemon/sync-runner-bundle.js +4 -12
- package/dist/lib/doctor/checks/claude-wiring.js +2 -1
- package/dist/lib/doctor/fix/apply.d.ts +20 -0
- package/dist/lib/doctor/fix/apply.js +63 -3
- package/dist/lib/doctor/fix/remediation.d.ts +1 -1
- package/dist/lib/doctor/fix/remediation.js +16 -0
- package/dist/lib/doctor/shared.d.ts +11 -1
- package/dist/lib/doctor/shared.js +16 -2
- package/dist/lib/fingerprint/fixtures.d.ts +19 -0
- package/dist/lib/fingerprint/fixtures.js +61 -0
- package/dist/lib/fingerprint/format.d.ts +50 -0
- package/dist/lib/fingerprint/format.js +105 -0
- package/dist/lib/fingerprint/offline.d.ts +16 -0
- package/dist/lib/fingerprint/offline.js +44 -0
- package/dist/lib/fingerprint/registry-client.d.ts +74 -0
- package/dist/lib/fingerprint/registry-client.js +141 -0
- package/dist/lib/fingerprint/resolve-local.d.ts +14 -0
- package/dist/lib/fingerprint/resolve-local.js +67 -0
- package/dist/lib/fingerprint/self-core.d.ts +139 -0
- package/dist/lib/fingerprint/self-core.js +371 -0
- package/dist/lib/fingerprint/self.d.ts +44 -0
- package/dist/lib/fingerprint/self.js +191 -0
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/lanes/monitor.js +14 -2
- package/dist/lib/lanes/spawn.d.ts +19 -1
- package/dist/lib/lanes/spawn.js +50 -6
- package/dist/lib/lanes/story-ledger.d.ts +1 -0
- package/dist/lib/lanes/story-ledger.js +3 -1
- package/dist/lib/lanes/types.d.ts +12 -0
- package/dist/lib/lanes/types.js +12 -1
- package/dist/lib/mesh/live/daemon/install.d.ts +12 -0
- package/dist/lib/mesh/live/daemon/install.js +22 -0
- package/dist/lib/mesh/live/flush.js +106 -3
- package/dist/lib/mesh/live/session-events-client.d.ts +9 -0
- package/dist/lib/mesh/live/session-events-client.js +15 -0
- package/dist/lib/work-context/state.d.ts +10 -0
- package/dist/lib/work-context/state.js +5 -0
- package/dist/utils/cli-network-lifecycle.d.ts +2 -0
- package/dist/utils/cli-network-lifecycle.js +5 -1
- package/dist/utils/cognito-session.d.ts +2 -21
- package/dist/utils/cognito-session.js +3 -84
- package/dist/utils/fsync-directory.d.ts +17 -0
- package/dist/utils/fsync-directory.js +27 -0
- package/dist/utils/person-token-cache.d.ts +32 -0
- package/dist/utils/person-token-cache.js +95 -0
- package/dist/utils/vault-api.d.ts +21 -1
- package/dist/utils/vault-api.js +54 -5
- package/dist/utils/windows-hide-default.d.ts +42 -0
- package/dist/utils/windows-hide-default.js +98 -0
- package/package.json +2 -2
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The network-free part of the session fingerprint builder: session id,
|
|
3
|
+
* company, machine id, the fingerprint.yaml cache, and the synchronous offline
|
|
4
|
+
* build. Lane start and the handoff commits import this module directly, so
|
|
5
|
+
* it must never import vault-api, cognito-session, hq-cloud, or any other
|
|
6
|
+
* network client, not even lazily. The person lookup lives in self.ts.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import yaml from "js-yaml";
|
|
13
|
+
import { peekIdToken } from "../../utils/id-token.js";
|
|
14
|
+
import { build, COMPANY_RE } from "./format.js";
|
|
15
|
+
export const SESSION_ID_ENV_VARS = [
|
|
16
|
+
"HQ_SESSION_ID",
|
|
17
|
+
"CLAUDE_CODE_SESSION_ID",
|
|
18
|
+
"CLAUDE_SESSION_ID",
|
|
19
|
+
"CODEX_SESSION_ID",
|
|
20
|
+
"CODEX_THREAD_ID",
|
|
21
|
+
"GROK_SESSION_ID",
|
|
22
|
+
];
|
|
23
|
+
export const IDENTITY_LOOKUP_TIMEOUT_MS = 3000;
|
|
24
|
+
export const FINGERPRINT_FILE = "fingerprint.yaml";
|
|
25
|
+
const SESSION_ID_RE = /^[A-Za-z0-9._-]+$/;
|
|
26
|
+
export const IDENTITY_UID_RE = /^(prs|agt)_[A-Za-z0-9]{8,}$/;
|
|
27
|
+
function registrationKeys(cache) {
|
|
28
|
+
const keys = {};
|
|
29
|
+
if (typeof cache?.registered_at === "string" && cache.registered_at)
|
|
30
|
+
keys.registered_at = cache.registered_at;
|
|
31
|
+
if (typeof cache?.registration_skipped === "string" && cache.registration_skipped) {
|
|
32
|
+
keys.registration_skipped = cache.registration_skipped;
|
|
33
|
+
}
|
|
34
|
+
return keys;
|
|
35
|
+
}
|
|
36
|
+
export function resolveSessionId(env) {
|
|
37
|
+
for (const name of SESSION_ID_ENV_VARS) {
|
|
38
|
+
const value = env[name];
|
|
39
|
+
if (!value)
|
|
40
|
+
continue;
|
|
41
|
+
if (value === "." || value === "..")
|
|
42
|
+
continue;
|
|
43
|
+
if (!SESSION_ID_RE.test(value))
|
|
44
|
+
continue;
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
export function sessionDir(hqRoot, sessionId) {
|
|
50
|
+
return path.join(hqRoot, "workspace", "sessions", sessionId);
|
|
51
|
+
}
|
|
52
|
+
/** Reads a YAML mapping. Unreadable or unparseable files warn and count as missing. */
|
|
53
|
+
function readYamlRecord(file, warn) {
|
|
54
|
+
try {
|
|
55
|
+
const parsed = yaml.load(fs.readFileSync(file, "utf8"));
|
|
56
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
if (err.code === "ENOENT")
|
|
60
|
+
return null;
|
|
61
|
+
warn(`hq fingerprint: ignoring unreadable ${file}: ${err.message}`);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function resolveCompany(hqRoot, sessionId, env, warn) {
|
|
66
|
+
let company = null;
|
|
67
|
+
let from = "";
|
|
68
|
+
if (sessionId) {
|
|
69
|
+
const meta = readYamlRecord(path.join(sessionDir(hqRoot, sessionId), "meta.yaml"), warn);
|
|
70
|
+
const slug = meta?.company_slug;
|
|
71
|
+
if (typeof slug === "string" && slug.trim()) {
|
|
72
|
+
company = slug.trim();
|
|
73
|
+
from = "meta.yaml company_slug";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!company && env.HQ_COMPANY)
|
|
77
|
+
[company, from] = [env.HQ_COMPANY, "HQ_COMPANY"];
|
|
78
|
+
if (!company && env.HQ_SPAWN_COMPANY)
|
|
79
|
+
[company, from] = [env.HQ_SPAWN_COMPANY, "HQ_SPAWN_COMPANY"];
|
|
80
|
+
if (company && !COMPANY_RE.test(company)) {
|
|
81
|
+
warn(`hq fingerprint: ignoring ${from}: not a company slug`);
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
return company;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Read `<hqRoot>/.hq/machine-id`, creating it when missing. Mirrors
|
|
88
|
+
* getOrCreateMachineId in hq-cloud, which the package does not export (its
|
|
89
|
+
* exports map has no ./lib/machine-id entry). Falls back to the menubar id in
|
|
90
|
+
* ~/.hq/menubar.json before generating a new UUID, as hq-cloud does.
|
|
91
|
+
*/
|
|
92
|
+
export function readOrCreateMachineId(hqRoot) {
|
|
93
|
+
const file = path.join(hqRoot, ".hq", "machine-id");
|
|
94
|
+
try {
|
|
95
|
+
const existing = fs.readFileSync(file, "utf8").trim();
|
|
96
|
+
if (existing)
|
|
97
|
+
return existing;
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
if (err.code !== "ENOENT")
|
|
101
|
+
throw err;
|
|
102
|
+
}
|
|
103
|
+
let id = null;
|
|
104
|
+
try {
|
|
105
|
+
const menubar = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".hq", "menubar.json"), "utf8"));
|
|
106
|
+
if (typeof menubar?.machineId === "string" && menubar.machineId)
|
|
107
|
+
id = menubar.machineId;
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
const code = err.code;
|
|
111
|
+
if (code !== "ENOENT" && !(err instanceof SyntaxError))
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
id ??= randomUUID();
|
|
115
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
116
|
+
fs.writeFileSync(file, `${id}\n`);
|
|
117
|
+
return id;
|
|
118
|
+
}
|
|
119
|
+
/** Read `<hqRoot>/.hq/machine-id` without creating it. Null when it is missing or empty. */
|
|
120
|
+
export function readMachineIdIfPresent(hqRoot) {
|
|
121
|
+
try {
|
|
122
|
+
return fs.readFileSync(path.join(hqRoot, ".hq", "machine-id"), "utf8").trim() || null;
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
if (err.code === "ENOENT")
|
|
126
|
+
return null;
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Machine ids are usually UUIDs. A free-form id is hashed so the fingerprint keeps 8 hex chars. */
|
|
131
|
+
export function machineIdForFingerprint(machineId) {
|
|
132
|
+
const hex = machineId.toLowerCase().replace(/-/g, "");
|
|
133
|
+
if (/^[0-9a-f]{8,}$/.test(hex))
|
|
134
|
+
return machineId;
|
|
135
|
+
return createHash("sha256").update(machineId).digest("hex");
|
|
136
|
+
}
|
|
137
|
+
function tokenOwner(sub) {
|
|
138
|
+
return typeof sub === "string" && sub ? createHash("sha256").update(sub).digest("hex") : null;
|
|
139
|
+
}
|
|
140
|
+
function writeCacheAtomic(file, record, warn) {
|
|
141
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
142
|
+
try {
|
|
143
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
144
|
+
fs.writeFileSync(tmp, yaml.dump(record));
|
|
145
|
+
fs.renameSync(tmp, file);
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
warn(`hq fingerprint: could not write ${file}: ${err.message}`);
|
|
149
|
+
fs.rmSync(tmp, { force: true });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function kindOfUid(uid) {
|
|
153
|
+
return uid.startsWith("agt_") ? "agent" : "person";
|
|
154
|
+
}
|
|
155
|
+
export function identityContext(deps, idToken, creds) {
|
|
156
|
+
const env = deps.env ?? process.env;
|
|
157
|
+
const hqRoot = deps.hqRoot;
|
|
158
|
+
const warn = deps.warn ?? ((message) => process.stderr.write(`${message}\n`));
|
|
159
|
+
const sessionId = resolveSessionId(env);
|
|
160
|
+
const claims = idToken ? peekIdToken(idToken) : {};
|
|
161
|
+
const entityType = typeof claims["custom:entityType"] === "string" ? claims["custom:entityType"] : null;
|
|
162
|
+
const entityUid = typeof claims["custom:entityUid"] === "string" ? claims["custom:entityUid"] : null;
|
|
163
|
+
const owner = tokenOwner(claims.sub);
|
|
164
|
+
let hostKind = "laptop";
|
|
165
|
+
if (entityType === "agent" || creds)
|
|
166
|
+
hostKind = "fleet";
|
|
167
|
+
if (entityType === "outpost" ||
|
|
168
|
+
entityType === "company-outpost" ||
|
|
169
|
+
entityUid?.startsWith("out_") ||
|
|
170
|
+
creds?.entityType === "outpost" ||
|
|
171
|
+
creds?.entityType === "company-outpost") {
|
|
172
|
+
hostKind = "outpost";
|
|
173
|
+
}
|
|
174
|
+
const cacheFile = sessionId && !deps.noRoot ? path.join(sessionDir(hqRoot, sessionId), FINGERPRINT_FILE) : null;
|
|
175
|
+
const rawCache = cacheFile ? readYamlRecord(cacheFile, warn) : null;
|
|
176
|
+
// A cache written for another signed-in user says nothing about this one.
|
|
177
|
+
const cached = rawCache && (rawCache.token_owner ?? null) === owner ? rawCache : null;
|
|
178
|
+
const agentPrincipal = entityType === "agent" || creds?.entityType === "agent" || entityUid?.startsWith("agt_") === true;
|
|
179
|
+
let identityUid = null;
|
|
180
|
+
let source = "none";
|
|
181
|
+
let needsLookup = false;
|
|
182
|
+
if (entityUid && IDENTITY_UID_RE.test(entityUid)) {
|
|
183
|
+
identityUid = entityUid;
|
|
184
|
+
source = "token";
|
|
185
|
+
}
|
|
186
|
+
else if (creds?.entityUid && IDENTITY_UID_RE.test(creds.entityUid)) {
|
|
187
|
+
identityUid = creds.entityUid;
|
|
188
|
+
source = "machine-creds";
|
|
189
|
+
}
|
|
190
|
+
else if (cached?.identity_lookup_done) {
|
|
191
|
+
identityUid = typeof cached.identity_uid === "string" ? cached.identity_uid : null;
|
|
192
|
+
source = "cache";
|
|
193
|
+
}
|
|
194
|
+
else if (sessionId && !deps.offline && idToken && hostKind !== "outpost" && !agentPrincipal) {
|
|
195
|
+
needsLookup = true;
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
env,
|
|
199
|
+
hqRoot,
|
|
200
|
+
warn,
|
|
201
|
+
sessionId,
|
|
202
|
+
idToken,
|
|
203
|
+
owner,
|
|
204
|
+
hostKind,
|
|
205
|
+
cacheFile,
|
|
206
|
+
identityUid,
|
|
207
|
+
source,
|
|
208
|
+
lookupDone: cached?.identity_lookup_done === true,
|
|
209
|
+
needsLookup,
|
|
210
|
+
cachedRegistration: {
|
|
211
|
+
fingerprint: typeof rawCache?.fingerprint === "string" ? rawCache.fingerprint : null,
|
|
212
|
+
...registrationKeys(rawCache),
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* The offline build as a synchronous call, for callers that cannot await
|
|
218
|
+
* (lane start). It never does the person lookup, so the loaders are required:
|
|
219
|
+
* the default ones live in self.ts, which loads them lazily and only
|
|
220
|
+
* asynchronously.
|
|
221
|
+
*/
|
|
222
|
+
export function buildSelfFingerprintOfflineSync(deps) {
|
|
223
|
+
const warn = deps.warn ?? ((message) => process.stderr.write(`${message}\n`));
|
|
224
|
+
let idToken = null;
|
|
225
|
+
try {
|
|
226
|
+
idToken = deps.loadIdToken();
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
warn(`hq fingerprint: could not read the cached token: ${err.message}`);
|
|
230
|
+
}
|
|
231
|
+
let creds = null;
|
|
232
|
+
try {
|
|
233
|
+
creds = deps.loadMachineCreds();
|
|
234
|
+
}
|
|
235
|
+
catch (err) {
|
|
236
|
+
warn(`hq fingerprint: could not read machine creds: ${err.message}`);
|
|
237
|
+
}
|
|
238
|
+
const offlineDeps = { ...deps, offline: true };
|
|
239
|
+
return finishFingerprint(offlineDeps, identityContext(offlineDeps, idToken, creds));
|
|
240
|
+
}
|
|
241
|
+
export function finishFingerprint(deps, ctx) {
|
|
242
|
+
const result = finishFingerprintAtRoot(deps, ctx);
|
|
243
|
+
if (!deps.noRoot)
|
|
244
|
+
return result;
|
|
245
|
+
return { ...result, reason: result.reason ? `no HQ root; ${result.reason}` : "no HQ root" };
|
|
246
|
+
}
|
|
247
|
+
function finishFingerprintAtRoot(deps, ctx) {
|
|
248
|
+
const { env, hqRoot, warn, sessionId, idToken, owner, hostKind, cacheFile, identityUid, source, lookupDone } = ctx;
|
|
249
|
+
let rawMachineId = deps.noRoot ? null : env.HQ_MACHINE_ID?.trim() || null;
|
|
250
|
+
if (!rawMachineId && !deps.noRoot) {
|
|
251
|
+
try {
|
|
252
|
+
rawMachineId = (deps.readMachineId ?? (deps.readOnly ? readMachineIdIfPresent : readOrCreateMachineId))(hqRoot);
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
warn(`hq fingerprint: could not read the machine id: ${err.message}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const machineId = rawMachineId ? machineIdForFingerprint(rawMachineId) : null;
|
|
259
|
+
const company = resolveCompany(hqRoot, deps.noRoot ? null : sessionId, env, warn);
|
|
260
|
+
const identityKind = identityUid ? kindOfUid(identityUid) : null;
|
|
261
|
+
if (!identityUid && !sessionId) {
|
|
262
|
+
return {
|
|
263
|
+
fingerprint: null,
|
|
264
|
+
company,
|
|
265
|
+
identityUid: null,
|
|
266
|
+
identityKind: null,
|
|
267
|
+
machineId: rawMachineId,
|
|
268
|
+
hostKind,
|
|
269
|
+
sessionId: null,
|
|
270
|
+
source,
|
|
271
|
+
reason: idToken
|
|
272
|
+
? "no session id in the environment and no identity uid on the cached token"
|
|
273
|
+
: "no session id in the environment and not signed in",
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
if (deps.readOnly && !machineId) {
|
|
277
|
+
return {
|
|
278
|
+
fingerprint: null,
|
|
279
|
+
company,
|
|
280
|
+
identityUid,
|
|
281
|
+
identityKind,
|
|
282
|
+
machineId: null,
|
|
283
|
+
hostKind,
|
|
284
|
+
sessionId,
|
|
285
|
+
source,
|
|
286
|
+
reason: "no machine id and read-only mode does not create one",
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
let fingerprint;
|
|
290
|
+
try {
|
|
291
|
+
fingerprint = build({ company, identityUid, hostKind, machineId, sessionId });
|
|
292
|
+
}
|
|
293
|
+
catch (err) {
|
|
294
|
+
return {
|
|
295
|
+
fingerprint: null,
|
|
296
|
+
company,
|
|
297
|
+
identityUid,
|
|
298
|
+
identityKind,
|
|
299
|
+
machineId: rawMachineId,
|
|
300
|
+
hostKind,
|
|
301
|
+
sessionId,
|
|
302
|
+
source,
|
|
303
|
+
reason: `could not build fingerprint: ${err.message}`,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
if (sessionId && cacheFile && !deps.readOnly) {
|
|
307
|
+
const record = {
|
|
308
|
+
fingerprint,
|
|
309
|
+
company,
|
|
310
|
+
identity_uid: identityUid,
|
|
311
|
+
identity_kind: identityKind,
|
|
312
|
+
identity_lookup_done: lookupDone,
|
|
313
|
+
token_owner: owner,
|
|
314
|
+
machine_id: rawMachineId,
|
|
315
|
+
host_kind: hostKind,
|
|
316
|
+
session_id: sessionId,
|
|
317
|
+
built_at: (deps.now ?? (() => new Date()))().toISOString(),
|
|
318
|
+
// The fingerprint names company, identity, machine and session, so the
|
|
319
|
+
// same value means hq-pro already holds this exact record.
|
|
320
|
+
...(ctx.cachedRegistration?.fingerprint === fingerprint ? registrationKeys(ctx.cachedRegistration) : {}),
|
|
321
|
+
};
|
|
322
|
+
writeCacheAtomic(cacheFile, record, warn);
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
fingerprint,
|
|
326
|
+
company,
|
|
327
|
+
identityUid,
|
|
328
|
+
identityKind,
|
|
329
|
+
machineId: rawMachineId,
|
|
330
|
+
hostKind,
|
|
331
|
+
sessionId,
|
|
332
|
+
source,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
function cachePathFor(hqRoot, sessionId) {
|
|
336
|
+
return path.join(sessionDir(hqRoot, sessionId), FINGERPRINT_FILE);
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* The registration keys of this session's fingerprint.yaml, or null when the
|
|
340
|
+
* file is missing or holds another fingerprint (the registry step then has no
|
|
341
|
+
* record to mark).
|
|
342
|
+
*/
|
|
343
|
+
export function readRegistrationState(hqRoot, sessionId, fingerprint, warn) {
|
|
344
|
+
const cache = readYamlRecord(cachePathFor(hqRoot, sessionId), warn);
|
|
345
|
+
if (!cache || cache.fingerprint !== fingerprint)
|
|
346
|
+
return null;
|
|
347
|
+
const keys = registrationKeys(cache);
|
|
348
|
+
return { registeredAt: keys.registered_at ?? null, skipped: keys.registration_skipped ?? null };
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Writes registered_at or registration_skipped into fingerprint.yaml. Re-reads
|
|
352
|
+
* the file first and writes nothing when it now holds another fingerprint.
|
|
353
|
+
* Returns true when the file was rewritten.
|
|
354
|
+
*/
|
|
355
|
+
export function recordRegistrationState(hqRoot, sessionId, fingerprint, update, warn) {
|
|
356
|
+
const file = cachePathFor(hqRoot, sessionId);
|
|
357
|
+
const cache = readYamlRecord(file, warn);
|
|
358
|
+
if (!cache || cache.fingerprint !== fingerprint)
|
|
359
|
+
return false;
|
|
360
|
+
const record = { ...cache };
|
|
361
|
+
if ("registeredAt" in update) {
|
|
362
|
+
record.registered_at = update.registeredAt;
|
|
363
|
+
delete record.registration_skipped;
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
record.registration_skipped = update.skipped;
|
|
367
|
+
}
|
|
368
|
+
writeCacheAtomic(file, record, warn);
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
//# sourceMappingURL=self-core.js.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { RegistryFetch } from "./registry-client.js";
|
|
2
|
+
import { type MachineCredsSummary, type SelfFingerprint, type SelfFingerprintDeps } from "./self-core.js";
|
|
3
|
+
export * from "./self-core.js";
|
|
4
|
+
/** An unparseable creds file yields null, so it never marks the host as fleet. */
|
|
5
|
+
export declare function defaultLoadMachineCreds(): Promise<MachineCredsSummary | null>;
|
|
6
|
+
export declare function buildSelfFingerprint(deps: SelfFingerprintDeps): Promise<SelfFingerprint>;
|
|
7
|
+
export interface RegisterSelfDeps {
|
|
8
|
+
hqRoot: string;
|
|
9
|
+
env?: NodeJS.ProcessEnv;
|
|
10
|
+
offline?: boolean;
|
|
11
|
+
/** false when the caller passed --no-register. */
|
|
12
|
+
register?: boolean;
|
|
13
|
+
loadIdToken?: () => string | null | Promise<string | null>;
|
|
14
|
+
fetch?: RegistryFetch;
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
/** Directory for fingerprint.log. Defaults to ~/.hq. */
|
|
17
|
+
logDir?: string;
|
|
18
|
+
now?: () => Date;
|
|
19
|
+
}
|
|
20
|
+
export type RegisterSelfOutcome = {
|
|
21
|
+
status: "registered";
|
|
22
|
+
} | {
|
|
23
|
+
status: "already-registered";
|
|
24
|
+
} | {
|
|
25
|
+
status: "skipped";
|
|
26
|
+
reason: string;
|
|
27
|
+
} | {
|
|
28
|
+
status: "failed";
|
|
29
|
+
reason: string;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Records this session's fingerprint with hq-pro (POST /v1/fingerprints), at
|
|
33
|
+
* most once per session. Fire-and-forget: it never writes to stdout or stderr
|
|
34
|
+
* and never throws, so the printed fingerprint and the exit code stay the
|
|
35
|
+
* same whatever the registry does.
|
|
36
|
+
*
|
|
37
|
+
* hq-pro denies company-outpost machine tokens, so outpost hosts skip the call
|
|
38
|
+
* and record `registration_skipped: outpost`. A 403 is also final and is
|
|
39
|
+
* recorded the same way. A 404 means the registry is not deployed for this
|
|
40
|
+
* API, so it records `registration_skipped: unavailable` and the session stops
|
|
41
|
+
* calling. Any other failure is logged and retried next run.
|
|
42
|
+
*/
|
|
43
|
+
export declare function registerSelfFingerprint(self: SelfFingerprint, deps: RegisterSelfDeps): Promise<RegisterSelfOutcome>;
|
|
44
|
+
//# sourceMappingURL=self.d.ts.map
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the fingerprint for the current HQ session (`hq fingerprint self`).
|
|
3
|
+
*
|
|
4
|
+
* Sources, in order:
|
|
5
|
+
* - session id: the first valid value of HQ_SESSION_ID, CLAUDE_CODE_SESSION_ID,
|
|
6
|
+
* CLAUDE_SESSION_ID, CODEX_SESSION_ID, CODEX_THREAD_ID, GROK_SESSION_ID (same
|
|
7
|
+
* order as core/scripts/lib/session-id.sh). workspace/sessions/.current is
|
|
8
|
+
* never read: it names whichever session wrote last, not this one.
|
|
9
|
+
* - identity: `custom:entityUid` on the cached ID token, then the agent uid in
|
|
10
|
+
* the machine creds, then this session's fingerprint.yaml cache (only when
|
|
11
|
+
* it was written for the same token owner), then a person lookup (3s cap,
|
|
12
|
+
* skipped with --offline, without a session id, and for outpost and agent
|
|
13
|
+
* principals). A lookup is cached once it succeeds or the server says the
|
|
14
|
+
* caller has no person entity; transient failures retry next time.
|
|
15
|
+
* - machine: HQ_MACHINE_ID, then `<hqRoot>/.hq/machine-id`.
|
|
16
|
+
* - company: the session's meta.yaml company_slug, then HQ_COMPANY, then
|
|
17
|
+
* HQ_SPAWN_COMPANY. A value that is not a slug is replaced with `-`.
|
|
18
|
+
*
|
|
19
|
+
* The result is written to workspace/sessions/<sid>/fingerprint.yaml when a
|
|
20
|
+
* session id is known. registerSelfFingerprint (US-007) then records the
|
|
21
|
+
* fingerprint with hq-pro once per session and writes `registered_at`, or
|
|
22
|
+
* `registration_skipped` when hq-pro will never accept it (outpost hosts, a
|
|
23
|
+
* 403). It never prints and never throws; failures go to ~/.hq/fingerprint.log.
|
|
24
|
+
* registry-client is imported lazily, only on the registration path.
|
|
25
|
+
*
|
|
26
|
+
* The commit-msg hook runs `self --offline` on every commit, so this module
|
|
27
|
+
* keeps its static imports light: cognito-session, hq-cloud, and vault-api are
|
|
28
|
+
* loaded only on the paths that need them. The network-free core lives in
|
|
29
|
+
* self-core.ts so lane start and the handoff commits never reach them.
|
|
30
|
+
*/
|
|
31
|
+
import * as fs from "node:fs";
|
|
32
|
+
import { finishFingerprint, IDENTITY_LOOKUP_TIMEOUT_MS, IDENTITY_UID_RE, identityContext, machineIdForFingerprint, readRegistrationState, recordRegistrationState, } from "./self-core.js";
|
|
33
|
+
export * from "./self-core.js";
|
|
34
|
+
async function defaultLoadIdToken() {
|
|
35
|
+
const { loadCachedTokens } = await import("../../utils/cognito-session.js");
|
|
36
|
+
return loadCachedTokens()?.idToken ?? null;
|
|
37
|
+
}
|
|
38
|
+
/** An unparseable creds file yields null, so it never marks the host as fleet. */
|
|
39
|
+
export async function defaultLoadMachineCreds() {
|
|
40
|
+
const { loadMachineCreds, machineCredsFilePath } = await import("@indigoai-us/hq-cloud");
|
|
41
|
+
if (!fs.existsSync(machineCredsFilePath()))
|
|
42
|
+
return null;
|
|
43
|
+
const creds = loadMachineCreds();
|
|
44
|
+
return creds ? { entityType: creds.entityType, entityUid: creds.entityUid } : null;
|
|
45
|
+
}
|
|
46
|
+
async function defaultResolveIdentity(idToken, signal) {
|
|
47
|
+
const { resolveCallerUid } = await import("../../utils/vault-api.js");
|
|
48
|
+
return resolveCallerUid(idToken, undefined, { signal });
|
|
49
|
+
}
|
|
50
|
+
/** Races the lookup against the timeout and aborts the request when time runs out. */
|
|
51
|
+
async function withTimeout(run, ms) {
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
let timer;
|
|
54
|
+
const timeout = new Promise((_, reject) => {
|
|
55
|
+
timer = setTimeout(() => {
|
|
56
|
+
const err = new Error(`identity lookup timed out after ${ms}ms`);
|
|
57
|
+
controller.abort(err);
|
|
58
|
+
reject(err);
|
|
59
|
+
}, ms);
|
|
60
|
+
});
|
|
61
|
+
try {
|
|
62
|
+
return await Promise.race([run(controller.signal), timeout]);
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** vault-api's NoPersonEntityError, matched by name so vault-api stays lazily loaded. */
|
|
69
|
+
function isNoPersonEntityError(err) {
|
|
70
|
+
return err instanceof Error && err.name === "NoPersonEntityError";
|
|
71
|
+
}
|
|
72
|
+
export async function buildSelfFingerprint(deps) {
|
|
73
|
+
const warn = deps.warn ?? ((message) => process.stderr.write(`${message}\n`));
|
|
74
|
+
let idToken = null;
|
|
75
|
+
try {
|
|
76
|
+
idToken = await (deps.loadIdToken ?? defaultLoadIdToken)();
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
warn(`hq fingerprint: could not read the cached token: ${err.message}`);
|
|
80
|
+
}
|
|
81
|
+
let creds = null;
|
|
82
|
+
try {
|
|
83
|
+
creds = await (deps.loadMachineCreds ?? defaultLoadMachineCreds)();
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
warn(`hq fingerprint: could not read machine creds: ${err.message}`);
|
|
87
|
+
}
|
|
88
|
+
const ctx = identityContext(deps, idToken, creds);
|
|
89
|
+
if (ctx.needsLookup && ctx.idToken) {
|
|
90
|
+
const resolveIdentity = deps.resolveIdentity ?? defaultResolveIdentity;
|
|
91
|
+
const token = ctx.idToken;
|
|
92
|
+
try {
|
|
93
|
+
const resolved = await withTimeout((signal) => resolveIdentity(token, signal), deps.lookupTimeoutMs ?? IDENTITY_LOOKUP_TIMEOUT_MS);
|
|
94
|
+
ctx.lookupDone = true;
|
|
95
|
+
if (IDENTITY_UID_RE.test(resolved.uid)) {
|
|
96
|
+
ctx.identityUid = resolved.uid;
|
|
97
|
+
ctx.source = "lookup";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
// "No person entity" is a definitive answer; anything else may be transient.
|
|
102
|
+
if (isNoPersonEntityError(err))
|
|
103
|
+
ctx.lookupDone = true;
|
|
104
|
+
warn(`hq fingerprint: identity lookup failed: ${err.message}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return finishFingerprint(deps, ctx);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Records this session's fingerprint with hq-pro (POST /v1/fingerprints), at
|
|
111
|
+
* most once per session. Fire-and-forget: it never writes to stdout or stderr
|
|
112
|
+
* and never throws, so the printed fingerprint and the exit code stay the
|
|
113
|
+
* same whatever the registry does.
|
|
114
|
+
*
|
|
115
|
+
* hq-pro denies company-outpost machine tokens, so outpost hosts skip the call
|
|
116
|
+
* and record `registration_skipped: outpost`. A 403 is also final and is
|
|
117
|
+
* recorded the same way. A 404 means the registry is not deployed for this
|
|
118
|
+
* API, so it records `registration_skipped: unavailable` and the session stops
|
|
119
|
+
* calling. Any other failure is logged and retried next run.
|
|
120
|
+
*/
|
|
121
|
+
export async function registerSelfFingerprint(self, deps) {
|
|
122
|
+
const env = deps.env ?? process.env;
|
|
123
|
+
if (deps.register === false)
|
|
124
|
+
return { status: "skipped", reason: "--no-register" };
|
|
125
|
+
if (deps.offline || env.HQ_FINGERPRINT_OFFLINE === "1")
|
|
126
|
+
return { status: "skipped", reason: "offline" };
|
|
127
|
+
const { fingerprint, company, identityUid, sessionId, machineId, hostKind } = self;
|
|
128
|
+
if (!fingerprint || !company || company === "-" || !identityUid || !sessionId || !machineId) {
|
|
129
|
+
return { status: "skipped", reason: "incomplete fingerprint" };
|
|
130
|
+
}
|
|
131
|
+
// Cache read and write problems stay quiet here: the build already warned.
|
|
132
|
+
const quiet = () => { };
|
|
133
|
+
const state = readRegistrationState(deps.hqRoot, sessionId, fingerprint, quiet);
|
|
134
|
+
if (!state)
|
|
135
|
+
return { status: "skipped", reason: "no session cache" };
|
|
136
|
+
if (state.registeredAt)
|
|
137
|
+
return { status: "already-registered" };
|
|
138
|
+
if (state.skipped)
|
|
139
|
+
return { status: "skipped", reason: state.skipped };
|
|
140
|
+
try {
|
|
141
|
+
const registry = await import("./registry-client.js");
|
|
142
|
+
const log = (op, detail) => registry.appendFingerprintLog(op, detail, { dir: deps.logDir, now: deps.now });
|
|
143
|
+
if (hostKind === "outpost") {
|
|
144
|
+
log("register", `skipped outpost: hq-pro denies company-outpost machine tokens fingerprint=${fingerprint}`);
|
|
145
|
+
recordRegistrationState(deps.hqRoot, sessionId, fingerprint, { skipped: "outpost" }, quiet);
|
|
146
|
+
return { status: "skipped", reason: "outpost" };
|
|
147
|
+
}
|
|
148
|
+
let token = null;
|
|
149
|
+
try {
|
|
150
|
+
token = await (deps.loadIdToken ?? defaultLoadIdToken)();
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
log("register", `failed: could not read the cached token: ${err.name}`);
|
|
154
|
+
return { status: "failed", reason: "token unreadable" };
|
|
155
|
+
}
|
|
156
|
+
if (!token)
|
|
157
|
+
return { status: "skipped", reason: "not signed in" };
|
|
158
|
+
const result = await registry.registerFingerprint({
|
|
159
|
+
fingerprint,
|
|
160
|
+
companySlug: company,
|
|
161
|
+
identityUid,
|
|
162
|
+
machineId: machineIdForFingerprint(machineId),
|
|
163
|
+
hostKind,
|
|
164
|
+
sessionId,
|
|
165
|
+
}, token, { fetch: deps.fetch, timeoutMs: deps.timeoutMs });
|
|
166
|
+
if (result.ok) {
|
|
167
|
+
const at = (deps.now ?? (() => new Date()))().toISOString();
|
|
168
|
+
recordRegistrationState(deps.hqRoot, sessionId, fingerprint, { registeredAt: at }, quiet);
|
|
169
|
+
return { status: "registered" };
|
|
170
|
+
}
|
|
171
|
+
const detail = registry.describeFailure(result);
|
|
172
|
+
if (result.status === 403) {
|
|
173
|
+
// A denial will not change on retry; stop calling for this fingerprint.
|
|
174
|
+
log("register", `skipped forbidden: ${detail} fingerprint=${fingerprint}`);
|
|
175
|
+
recordRegistrationState(deps.hqRoot, sessionId, fingerprint, { skipped: "forbidden" }, quiet);
|
|
176
|
+
return { status: "skipped", reason: "forbidden" };
|
|
177
|
+
}
|
|
178
|
+
if (result.status === 404) {
|
|
179
|
+
// The registry route is not deployed here; stop calling for this session.
|
|
180
|
+
log("register", `skipped unavailable: ${detail} fingerprint=${fingerprint}`);
|
|
181
|
+
recordRegistrationState(deps.hqRoot, sessionId, fingerprint, { skipped: "unavailable" }, quiet);
|
|
182
|
+
return { status: "skipped", reason: "unavailable" };
|
|
183
|
+
}
|
|
184
|
+
log("register", `failed: ${detail} fingerprint=${fingerprint}`);
|
|
185
|
+
return { status: "failed", reason: detail };
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
return { status: "failed", reason: err.message };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
//# sourceMappingURL=self.js.map
|