@indigoai-us/hq-cli 5.133.0 → 5.134.0
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 +8 -0
- package/dist/commands/agent-kit.d.ts +2 -0
- package/dist/commands/agent-kit.js +4 -1
- package/dist/lib/agent-kit/run/heartbeat.d.ts +14 -2
- package/dist/lib/agent-kit/run/heartbeat.js +38 -6
- package/dist/lib/bot/api.d.ts +3 -1
- package/dist/lib/bot/api.js +7 -2
- package/dist/lib/bot/run.d.ts +4 -0
- package/dist/lib/bot/run.js +30 -1
- package/dist/lib/bot/status.d.ts +4 -0
- package/dist/lib/doctor/checks/bots.js +4 -1
- package/dist/lib/heartbeat-identity.d.ts +29 -0
- package/dist/lib/heartbeat-identity.js +71 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.134.0] — 2026-09-22
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Agent-kit and local-bot heartbeat loops now stop after the server reports an
|
|
10
|
+
agent identity mismatch instead of retrying indefinitely. `hq doctor` shows
|
|
11
|
+
the configured and token agent IDs for the mismatch.
|
|
12
|
+
|
|
5
13
|
## [5.133.0] — 2026-09-22
|
|
6
14
|
|
|
7
15
|
### Fixed
|
|
@@ -23,6 +23,7 @@ import { type AgentKitPaths } from "../lib/agent-kit/paths.js";
|
|
|
23
23
|
import { type KitService } from "../lib/agent-kit/services.js";
|
|
24
24
|
import { type CronRunner, type RebootCronResult, type DetachedSpawn, type KillFn } from "../lib/agent-kit/fallback.js";
|
|
25
25
|
import { type ServiceHostPaths, type ServiceManagerDeps, type ServiceSetResult } from "../lib/service-manager/index.js";
|
|
26
|
+
import type { HeartbeatCredsSource } from "../lib/heartbeat-identity.js";
|
|
26
27
|
export declare class KitError extends Error {
|
|
27
28
|
constructor(message: string);
|
|
28
29
|
}
|
|
@@ -73,6 +74,7 @@ export interface KitRuntime {
|
|
|
73
74
|
config: KitConfig;
|
|
74
75
|
getToken: () => Promise<string>;
|
|
75
76
|
log: KitLogger;
|
|
77
|
+
credsSource?: HeartbeatCredsSource;
|
|
76
78
|
}
|
|
77
79
|
export declare function buildKitRuntime(service: KitService | "all"): KitRuntime;
|
|
78
80
|
export declare function runKitService(service: KitService, rt: KitRuntime): Promise<void>;
|
|
@@ -135,6 +135,7 @@ export function formatServiceSet(result) {
|
|
|
135
135
|
}
|
|
136
136
|
export function buildKitRuntime(service) {
|
|
137
137
|
const paths = agentKitPaths(os.homedir(), process.env);
|
|
138
|
+
const credsSource = process.env.HQ_MACHINE_CREDS_FILE?.trim() ? "explicit" : "default";
|
|
138
139
|
const creds = requireExternalCreds(paths);
|
|
139
140
|
ensureKitDirs(paths);
|
|
140
141
|
const config = readKitConfig(paths, CLI_VERSION);
|
|
@@ -145,7 +146,7 @@ export function buildKitRuntime(service) {
|
|
|
145
146
|
process.env.HQ_VAULT_API_URL = creds.apiBaseUrl;
|
|
146
147
|
const log = createKitLogger(paths, service, { echo: process.stdout.isTTY === true });
|
|
147
148
|
const getToken = () => ensureCognitoToken({ tokenSource: "machine", interactive: false });
|
|
148
|
-
return { paths, creds, config, getToken, log };
|
|
149
|
+
return { paths, creds, config, getToken, log, credsSource };
|
|
149
150
|
}
|
|
150
151
|
export async function runKitService(service, rt) {
|
|
151
152
|
rt.log("info", `${service} starting agent=${rt.creds.entityUid} cli=${CLI_VERSION}`);
|
|
@@ -167,6 +168,8 @@ export async function runKitService(service, rt) {
|
|
|
167
168
|
getToken: rt.getToken,
|
|
168
169
|
log: rt.log,
|
|
169
170
|
components: enabledKitComponents(rt.config),
|
|
171
|
+
agentUidSource: rt.credsSource === "explicit" ? "env" : "config_file",
|
|
172
|
+
credsSource: rt.credsSource ?? "default",
|
|
170
173
|
});
|
|
171
174
|
return;
|
|
172
175
|
case "inbox":
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* The last successful beat is recorded at state/last-heartbeat.json so
|
|
12
12
|
* `hq agent probe` can report its age.
|
|
13
13
|
*/
|
|
14
|
+
import { type HeartbeatAgentUidSource, type HeartbeatCredsSource, type HeartbeatIdentityMismatchReporter } from "../../heartbeat-identity.js";
|
|
14
15
|
import { type ComponentStatus } from "../creds.js";
|
|
15
16
|
import type { KitLogger } from "../log.js";
|
|
16
17
|
import { type AgentKitPaths, type KitComponent } from "../paths.js";
|
|
@@ -36,9 +37,13 @@ export interface HeartbeatBody {
|
|
|
36
37
|
}
|
|
37
38
|
export declare function buildHeartbeatBody(paths: Pick<AgentKitPaths, "stateDir">, now?: () => Date, hostInfo?: HeartbeatBody["hostInfo"], components_?: readonly KitComponent[]): HeartbeatBody;
|
|
38
39
|
export interface LastHeartbeat {
|
|
40
|
+
/** The most recent heartbeat attempt, successful or a terminal identity mismatch. */
|
|
39
41
|
at: string;
|
|
40
42
|
components: Partial<Record<KitComponent, ComponentStatus>>;
|
|
41
43
|
status: number;
|
|
44
|
+
reason?: "identity_mismatch";
|
|
45
|
+
configuredAgentUid?: string;
|
|
46
|
+
tokenAgentUid?: string;
|
|
42
47
|
}
|
|
43
48
|
export declare function writeLastHeartbeat(paths: Pick<AgentKitPaths, "stateDir" | "lastHeartbeatPath">, record: LastHeartbeat): void;
|
|
44
49
|
export declare function readLastHeartbeat(paths: Pick<AgentKitPaths, "lastHeartbeatPath">): LastHeartbeat | null;
|
|
@@ -51,12 +56,19 @@ export interface HeartbeatDeps {
|
|
|
51
56
|
log: KitLogger;
|
|
52
57
|
/** Components this kit runs; defaults to all of them. */
|
|
53
58
|
components?: readonly KitComponent[];
|
|
54
|
-
post?: (token: string, body: HeartbeatBody) => Promise<number>;
|
|
59
|
+
post?: (token: string, body: HeartbeatBody) => Promise<number | HeartbeatPostResult>;
|
|
55
60
|
now?: () => Date;
|
|
56
61
|
sleep?: (ms: number) => Promise<void>;
|
|
57
62
|
maxBeats?: number;
|
|
63
|
+
agentUidSource?: HeartbeatAgentUidSource;
|
|
64
|
+
credsSource?: HeartbeatCredsSource;
|
|
65
|
+
reportIdentityMismatch?: HeartbeatIdentityMismatchReporter;
|
|
58
66
|
}
|
|
59
|
-
export
|
|
67
|
+
export interface HeartbeatPostResult {
|
|
68
|
+
status: number;
|
|
69
|
+
bodyText?: string;
|
|
70
|
+
}
|
|
71
|
+
export declare function defaultHeartbeatPost(agentUid: string, apiBaseUrl: string): (token: string, body: HeartbeatBody) => Promise<HeartbeatPostResult>;
|
|
60
72
|
export declare function beatOnce(deps: HeartbeatDeps): Promise<boolean>;
|
|
61
73
|
export declare function runHeartbeatLoop(deps: HeartbeatDeps): Promise<void>;
|
|
62
74
|
//# sourceMappingURL=heartbeat.d.ts.map
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import * as fs from "node:fs";
|
|
15
15
|
import * as os from "node:os";
|
|
16
16
|
import { CLI_VERSION } from "../../../cli-version.js";
|
|
17
|
+
import { captureHeartbeatIdentityMismatch, isHeartbeatIdentityMismatch, parseHeartbeatFailureBody, tokenAgentUid, } from "../../heartbeat-identity.js";
|
|
17
18
|
import { vaultApiFetch } from "../../../utils/vault-api.js";
|
|
18
19
|
import { readComponentStatus } from "../creds.js";
|
|
19
20
|
import { KIT_COMPONENTS } from "../paths.js";
|
|
@@ -60,36 +61,67 @@ export function defaultHeartbeatPost(agentUid, apiBaseUrl) {
|
|
|
60
61
|
method: "POST",
|
|
61
62
|
body: body,
|
|
62
63
|
});
|
|
63
|
-
return res.status;
|
|
64
|
+
return { status: res.status, bodyText: await res.text().catch(() => "") };
|
|
64
65
|
};
|
|
65
66
|
}
|
|
66
|
-
|
|
67
|
+
async function beatOnceResult(deps) {
|
|
67
68
|
const now = deps.now ?? (() => new Date());
|
|
68
69
|
const post = deps.post ?? defaultHeartbeatPost(deps.agentUid, deps.apiBaseUrl);
|
|
69
70
|
const body = buildHeartbeatBody(deps.paths, now, undefined, deps.components ?? KIT_COMPONENTS);
|
|
70
71
|
try {
|
|
71
72
|
const token = await deps.getToken();
|
|
72
|
-
const
|
|
73
|
+
const response = await post(token, body);
|
|
74
|
+
const result = typeof response === "number" ? { status: response } : response;
|
|
75
|
+
const status = result.status;
|
|
73
76
|
const ok = status >= 200 && status < 300;
|
|
74
77
|
if (ok) {
|
|
75
78
|
writeLastHeartbeat(deps.paths, { at: now().toISOString(), components: body.components, status });
|
|
76
79
|
}
|
|
80
|
+
else {
|
|
81
|
+
const failure = parseHeartbeatFailureBody(result.bodyText);
|
|
82
|
+
if (isHeartbeatIdentityMismatch({ status, code: failure.code, message: failure.message })) {
|
|
83
|
+
const tokenUid = tokenAgentUid(token);
|
|
84
|
+
writeLastHeartbeat(deps.paths, {
|
|
85
|
+
at: now().toISOString(),
|
|
86
|
+
components: body.components,
|
|
87
|
+
status,
|
|
88
|
+
reason: "identity_mismatch",
|
|
89
|
+
configuredAgentUid: deps.agentUid,
|
|
90
|
+
...(tokenUid ? { tokenAgentUid: tokenUid } : {}),
|
|
91
|
+
});
|
|
92
|
+
const tokenLabel = tokenUid ?? "unknown";
|
|
93
|
+
deps.log("warn", `heartbeat stopped: identity mismatch configured agentUid=${deps.agentUid} token agentUid=${tokenLabel}`);
|
|
94
|
+
(deps.reportIdentityMismatch ?? captureHeartbeatIdentityMismatch)({
|
|
95
|
+
runner: "agent-kit",
|
|
96
|
+
configuredAgentUid: deps.agentUid,
|
|
97
|
+
...(tokenUid ? { tokenAgentUid: tokenUid } : {}),
|
|
98
|
+
agentUidSource: deps.agentUidSource ?? "config_file",
|
|
99
|
+
credsSource: deps.credsSource ?? "default",
|
|
100
|
+
});
|
|
101
|
+
return { ok: false, stop: true };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
77
104
|
deps.log(ok ? "info" : "warn", `heartbeat ${status} ${Object.entries(body.components)
|
|
78
105
|
.map(([name, health]) => `${name}=${health}`)
|
|
79
106
|
.join(" ")}`);
|
|
80
|
-
return ok;
|
|
107
|
+
return { ok, stop: false };
|
|
81
108
|
}
|
|
82
109
|
catch (err) {
|
|
83
110
|
deps.log("error", `heartbeat failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
84
|
-
return false;
|
|
111
|
+
return { ok: false, stop: false };
|
|
85
112
|
}
|
|
86
113
|
}
|
|
114
|
+
export async function beatOnce(deps) {
|
|
115
|
+
return (await beatOnceResult(deps)).ok;
|
|
116
|
+
}
|
|
87
117
|
export async function runHeartbeatLoop(deps) {
|
|
88
118
|
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
89
119
|
let beats = 0;
|
|
90
120
|
for (;;) {
|
|
91
|
-
await
|
|
121
|
+
const result = await beatOnceResult(deps);
|
|
92
122
|
beats += 1;
|
|
123
|
+
if (result.stop)
|
|
124
|
+
return;
|
|
93
125
|
if (deps.maxBeats !== undefined && beats >= deps.maxBeats)
|
|
94
126
|
return;
|
|
95
127
|
await sleep(deps.intervalMs);
|
package/dist/lib/bot/api.d.ts
CHANGED
|
@@ -85,8 +85,10 @@ export declare class BotApiError extends Error {
|
|
|
85
85
|
readonly status: number;
|
|
86
86
|
readonly path: string;
|
|
87
87
|
readonly code?: string | undefined;
|
|
88
|
+
readonly detail?: string | undefined;
|
|
88
89
|
readonly retryAfterMs?: number | undefined;
|
|
89
|
-
|
|
90
|
+
readonly tokenAgentUid?: string | undefined;
|
|
91
|
+
constructor(status: number, path: string, code?: string | undefined, detail?: string | undefined, retryAfterMs?: number | undefined, tokenAgentUid?: string | undefined);
|
|
90
92
|
}
|
|
91
93
|
/** Parse a Retry-After header (delta-seconds or HTTP-date) to milliseconds. */
|
|
92
94
|
export declare function retryAfterHeaderMs(value: string | null | undefined, nowMs?: number): number | undefined;
|
package/dist/lib/bot/api.js
CHANGED
|
@@ -6,18 +6,23 @@
|
|
|
6
6
|
* existing Cognito machinery. `fetch` is injectable for tests.
|
|
7
7
|
*/
|
|
8
8
|
import { vaultApiFetch } from "../../utils/vault-api.js";
|
|
9
|
+
import { tokenAgentUid } from "../heartbeat-identity.js";
|
|
9
10
|
import { assertNoSecretKeys } from "./local-config.js";
|
|
10
11
|
export class BotApiError extends Error {
|
|
11
12
|
status;
|
|
12
13
|
path;
|
|
13
14
|
code;
|
|
15
|
+
detail;
|
|
14
16
|
retryAfterMs;
|
|
15
|
-
|
|
17
|
+
tokenAgentUid;
|
|
18
|
+
constructor(status, path, code, detail, retryAfterMs, tokenAgentUid) {
|
|
16
19
|
super(`HQ API ${path} → ${status}${code ? ` ${code}` : ""}${detail ? `: ${detail}` : ""}`);
|
|
17
20
|
this.status = status;
|
|
18
21
|
this.path = path;
|
|
19
22
|
this.code = code;
|
|
23
|
+
this.detail = detail;
|
|
20
24
|
this.retryAfterMs = retryAfterMs;
|
|
25
|
+
this.tokenAgentUid = tokenAgentUid;
|
|
21
26
|
this.name = "BotApiError";
|
|
22
27
|
}
|
|
23
28
|
}
|
|
@@ -72,7 +77,7 @@ export class BotApi {
|
|
|
72
77
|
});
|
|
73
78
|
if (!accept.includes(res.status)) {
|
|
74
79
|
const err = await readError(res);
|
|
75
|
-
throw new BotApiError(res.status, path, err.code, err.detail, retryAfterHeaderMs(res.headers.get("Retry-After")));
|
|
80
|
+
throw new BotApiError(res.status, path, err.code, err.detail, retryAfterHeaderMs(res.headers.get("Retry-After")), path.endsWith("/heartbeat") ? tokenAgentUid(token) : undefined);
|
|
76
81
|
}
|
|
77
82
|
const text = await res.text();
|
|
78
83
|
return { status: res.status, body: (text ? JSON.parse(text) : {}) };
|
package/dist/lib/bot/run.d.ts
CHANGED
|
@@ -42,6 +42,7 @@ import type { BotConfig, BotKind } from "./config.js";
|
|
|
42
42
|
import { type ProgressPosterOptions } from "./progress.js";
|
|
43
43
|
import { type BotLogger } from "./log.js";
|
|
44
44
|
import { type BotRuntime, type RuntimeTurnInput, type RuntimeTurnResult } from "./runtime/index.js";
|
|
45
|
+
import { type HeartbeatAgentUidSource, type HeartbeatCredsSource, type HeartbeatIdentityMismatchReporter } from "../heartbeat-identity.js";
|
|
45
46
|
import { type BotState, type BotStatusFile } from "./status.js";
|
|
46
47
|
export declare const INBOX_POLL_INTERVAL_MS = 2000;
|
|
47
48
|
export declare const HEARTBEAT_INTERVAL_MS = 30000;
|
|
@@ -119,6 +120,9 @@ export interface BotRunDeps {
|
|
|
119
120
|
maxParallelTurns?: number;
|
|
120
121
|
/** Wait between retries while the coding tool needs a sign-in (default SIGN_IN_RETRY_MS). */
|
|
121
122
|
signInRetryMs?: number;
|
|
123
|
+
agentUidSource?: HeartbeatAgentUidSource;
|
|
124
|
+
credsSource?: HeartbeatCredsSource;
|
|
125
|
+
reportIdentityMismatch?: HeartbeatIdentityMismatchReporter;
|
|
122
126
|
}
|
|
123
127
|
export declare const KICKOFF_MESSAGE_ID = "kickoff";
|
|
124
128
|
/**
|
package/dist/lib/bot/run.js
CHANGED
|
@@ -59,6 +59,7 @@ import { dmSessionScope } from "./paths.js";
|
|
|
59
59
|
import { isDmItem, resolveReplyTarget } from "./room-policy.js";
|
|
60
60
|
import { isRuntimeSignInFailure, SIGN_IN_RETRY_MS, signInNeededText } from "./runtime-sign-in.js";
|
|
61
61
|
import { HQ_BOT_AGENT_UID_ENV } from "../../commands/onboard-bot-membership.js";
|
|
62
|
+
import { captureHeartbeatIdentityMismatch, isHeartbeatIdentityMismatch, } from "../heartbeat-identity.js";
|
|
62
63
|
import { clearBotSession, readBotSession, writeBotSession } from "./session.js";
|
|
63
64
|
import { defaultBotStatus, patchBotStatus, readBotStatus } from "./status.js";
|
|
64
65
|
export const INBOX_POLL_INTERVAL_MS = 2_000;
|
|
@@ -415,10 +416,38 @@ export async function runBot(deps) {
|
|
|
415
416
|
// runtime → model, inbox → task, sync → sync.
|
|
416
417
|
await api.heartbeat(config.agentUid, { model: modelHealth, task: "ok", sync: "ok" });
|
|
417
418
|
heartbeatFails = 0;
|
|
418
|
-
setStatus({
|
|
419
|
+
setStatus({
|
|
420
|
+
lastHeartbeatAt: now().toISOString(),
|
|
421
|
+
lastHeartbeatOk: true,
|
|
422
|
+
lastHeartbeatError: undefined,
|
|
423
|
+
lastHeartbeatReason: undefined,
|
|
424
|
+
lastHeartbeatConfiguredAgentUid: undefined,
|
|
425
|
+
lastHeartbeatTokenAgentUid: undefined,
|
|
426
|
+
});
|
|
419
427
|
await sleep(heartbeatIntervalMs);
|
|
420
428
|
}
|
|
421
429
|
catch (err) {
|
|
430
|
+
if (err instanceof BotApiError &&
|
|
431
|
+
isHeartbeatIdentityMismatch({ status: err.status, code: err.code, message: err.detail })) {
|
|
432
|
+
const tokenLabel = err.tokenAgentUid ?? "unknown";
|
|
433
|
+
const reason = `heartbeat stopped: identity mismatch configured agentUid=${config.agentUid} token agentUid=${tokenLabel}`;
|
|
434
|
+
setStatus({
|
|
435
|
+
lastHeartbeatOk: false,
|
|
436
|
+
lastHeartbeatError: reason.slice(0, 400),
|
|
437
|
+
lastHeartbeatReason: "identity_mismatch",
|
|
438
|
+
lastHeartbeatConfiguredAgentUid: config.agentUid,
|
|
439
|
+
...(err.tokenAgentUid ? { lastHeartbeatTokenAgentUid: err.tokenAgentUid } : {}),
|
|
440
|
+
});
|
|
441
|
+
log("warn", reason);
|
|
442
|
+
(deps.reportIdentityMismatch ?? captureHeartbeatIdentityMismatch)({
|
|
443
|
+
runner: "bot",
|
|
444
|
+
configuredAgentUid: config.agentUid,
|
|
445
|
+
...(err.tokenAgentUid ? { tokenAgentUid: err.tokenAgentUid } : {}),
|
|
446
|
+
agentUidSource: deps.agentUidSource ?? "config_file",
|
|
447
|
+
credsSource: deps.credsSource ?? "default",
|
|
448
|
+
});
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
422
451
|
heartbeatFails += 1;
|
|
423
452
|
const message = err instanceof Error ? err.message : String(err);
|
|
424
453
|
setStatus({ lastHeartbeatOk: false, lastHeartbeatError: message.slice(0, 400) });
|
package/dist/lib/bot/status.d.ts
CHANGED
|
@@ -19,6 +19,10 @@ export interface BotStatusFile {
|
|
|
19
19
|
lastHeartbeatOk?: boolean;
|
|
20
20
|
/** Last heartbeat error, when lastHeartbeatOk is false. */
|
|
21
21
|
lastHeartbeatError?: string;
|
|
22
|
+
/** A heartbeat stopped permanently until the bot is restarted with corrected identity config. */
|
|
23
|
+
lastHeartbeatReason?: "identity_mismatch";
|
|
24
|
+
lastHeartbeatConfiguredAgentUid?: string;
|
|
25
|
+
lastHeartbeatTokenAgentUid?: string;
|
|
22
26
|
lastInboxPollAt?: string;
|
|
23
27
|
lastInboxPollOk?: boolean;
|
|
24
28
|
/** Last inbox poll error, when lastInboxPollOk is false. */
|
|
@@ -76,11 +76,14 @@ function runChecks(deps) {
|
|
|
76
76
|
const status = readBotStatus(dir);
|
|
77
77
|
const alive = !!status && status.state === "running" && isPidAlive(status.pid);
|
|
78
78
|
if (alive && status.lastHeartbeatOk === false) {
|
|
79
|
+
const heartbeatMessage = status.lastHeartbeatReason === "identity_mismatch"
|
|
80
|
+
? `${name} heartbeat stopped: identity mismatch (configured agent ${status.lastHeartbeatConfiguredAgentUid ?? config.agentUid}; token agent ${status.lastHeartbeatTokenAgentUid ?? "unknown"}).`
|
|
81
|
+
: `${name} is running but cannot heartbeat to HQ${status.lastHeartbeatError ? ` (${status.lastHeartbeatError})` : ""}.`;
|
|
79
82
|
results.push({
|
|
80
83
|
status: "FAIL",
|
|
81
84
|
checkId: `bots.${name}.heartbeat`,
|
|
82
85
|
target: dir,
|
|
83
|
-
message:
|
|
86
|
+
message: heartbeatMessage,
|
|
84
87
|
remediation: `hq bot logs ${name}`,
|
|
85
88
|
});
|
|
86
89
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { CaptureContext } from "@sentry/node";
|
|
2
|
+
export declare const HEARTBEAT_IDENTITY_MISMATCH_CODE = "AGENT_IDENTITY_MISMATCH";
|
|
3
|
+
export type HeartbeatRunner = "agent-kit" | "bot";
|
|
4
|
+
export type HeartbeatAgentUidSource = "config_file" | "env" | "flag";
|
|
5
|
+
export type HeartbeatCredsSource = "explicit" | "default";
|
|
6
|
+
export interface HeartbeatFailure {
|
|
7
|
+
status: number;
|
|
8
|
+
code?: string;
|
|
9
|
+
message?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface HeartbeatIdentityMismatchContext {
|
|
12
|
+
runner: HeartbeatRunner;
|
|
13
|
+
configuredAgentUid: string;
|
|
14
|
+
tokenAgentUid?: string;
|
|
15
|
+
agentUidSource: HeartbeatAgentUidSource;
|
|
16
|
+
credsSource: HeartbeatCredsSource;
|
|
17
|
+
}
|
|
18
|
+
export type HeartbeatIdentityMismatchReporter = (context: HeartbeatIdentityMismatchContext) => void;
|
|
19
|
+
/** Parse only the bounded fields the heartbeat route uses for classification. */
|
|
20
|
+
export declare function parseHeartbeatFailureBody(bodyText: string | undefined): {
|
|
21
|
+
code?: string;
|
|
22
|
+
message?: string;
|
|
23
|
+
};
|
|
24
|
+
/** Match the server's registered code/name, never an arbitrary 403. */
|
|
25
|
+
export declare function isHeartbeatIdentityMismatch(failure: HeartbeatFailure): boolean;
|
|
26
|
+
/** Read the agent uid claim for diagnostics only; this never authorizes a call. */
|
|
27
|
+
export declare function tokenAgentUid(token: string): string | undefined;
|
|
28
|
+
export declare function captureHeartbeatIdentityMismatch(context: HeartbeatIdentityMismatchContext, captureMessage?: (message: string, context: CaptureContext) => string): void;
|
|
29
|
+
//# sourceMappingURL=heartbeat-identity.d.ts.map
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { CLI_VERSION } from "../cli-version.js";
|
|
2
|
+
import { Sentry } from "../sentry.js";
|
|
3
|
+
import { peekIdToken } from "../utils/id-token.js";
|
|
4
|
+
export const HEARTBEAT_IDENTITY_MISMATCH_CODE = "AGENT_IDENTITY_MISMATCH";
|
|
5
|
+
const AGENT_UID_RE = /^agt_[A-Za-z0-9_-]{1,100}$/;
|
|
6
|
+
function normalize(value) {
|
|
7
|
+
return (value ?? "").replace(/[^A-Za-z0-9]/g, "").toLowerCase();
|
|
8
|
+
}
|
|
9
|
+
function safeAgentUid(value) {
|
|
10
|
+
return typeof value === "string" && AGENT_UID_RE.test(value) ? value : undefined;
|
|
11
|
+
}
|
|
12
|
+
/** Parse only the bounded fields the heartbeat route uses for classification. */
|
|
13
|
+
export function parseHeartbeatFailureBody(bodyText) {
|
|
14
|
+
if (!bodyText)
|
|
15
|
+
return {};
|
|
16
|
+
try {
|
|
17
|
+
const body = JSON.parse(bodyText);
|
|
18
|
+
const code = typeof body.code === "string" ? body.code.slice(0, 120) : undefined;
|
|
19
|
+
const message = typeof body.error === "string"
|
|
20
|
+
? body.error.slice(0, 300)
|
|
21
|
+
: typeof body.message === "string"
|
|
22
|
+
? body.message.slice(0, 300)
|
|
23
|
+
: undefined;
|
|
24
|
+
return { ...(code ? { code } : {}), ...(message ? { message } : {}) };
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Match the server's registered code/name, never an arbitrary 403. */
|
|
31
|
+
export function isHeartbeatIdentityMismatch(failure) {
|
|
32
|
+
if (failure.status !== 403)
|
|
33
|
+
return false;
|
|
34
|
+
const code = normalize(failure.code);
|
|
35
|
+
if (code === normalize(HEARTBEAT_IDENTITY_MISMATCH_CODE) || code === "agentidentitymismatcherror")
|
|
36
|
+
return true;
|
|
37
|
+
const message = normalize(failure.message);
|
|
38
|
+
return (message.includes("heartbeatmustbereportedbytheagentsownmachineidentity") ||
|
|
39
|
+
message.includes("agentidentitymismatcherror"));
|
|
40
|
+
}
|
|
41
|
+
/** Read the agent uid claim for diagnostics only; this never authorizes a call. */
|
|
42
|
+
export function tokenAgentUid(token) {
|
|
43
|
+
const claims = peekIdToken(token);
|
|
44
|
+
return (safeAgentUid(claims["custom:entityUid"]) ??
|
|
45
|
+
safeAgentUid(claims.entityUid) ??
|
|
46
|
+
safeAgentUid(claims.sub));
|
|
47
|
+
}
|
|
48
|
+
function diagnosticUid(value) {
|
|
49
|
+
return safeAgentUid(value) ?? "unknown";
|
|
50
|
+
}
|
|
51
|
+
function captureHeartbeatIdentityMismatchMessage(message, context) {
|
|
52
|
+
return Sentry.captureMessage(message, context);
|
|
53
|
+
}
|
|
54
|
+
export function captureHeartbeatIdentityMismatch(context, captureMessage = captureHeartbeatIdentityMismatchMessage) {
|
|
55
|
+
const options = {
|
|
56
|
+
level: "warning",
|
|
57
|
+
fingerprint: ["heartbeat-identity-mismatch"],
|
|
58
|
+
tags: {
|
|
59
|
+
agent_uid_source: context.agentUidSource,
|
|
60
|
+
creds_source: context.credsSource,
|
|
61
|
+
runner: context.runner,
|
|
62
|
+
hq_cli_version: CLI_VERSION,
|
|
63
|
+
},
|
|
64
|
+
extra: {
|
|
65
|
+
configured_agent_uid: diagnosticUid(context.configuredAgentUid),
|
|
66
|
+
...(context.tokenAgentUid ? { token_agent_uid: diagnosticUid(context.tokenAgentUid) } : {}),
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
captureMessage("heartbeat identity mismatch", options);
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=heartbeat-identity.js.map
|