@indigoai-us/hq-cli 5.77.11 → 5.77.13
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 +46 -0
- package/dist/commands/api-keys.js +53 -10
- package/dist/commands/outposts-heartbeat.d.ts +96 -0
- package/dist/commands/outposts-heartbeat.js +188 -0
- package/dist/commands/outposts.js +3 -0
- package/dist/commands/secrets.js +127 -21
- package/dist/outpost/session-heartbeat-publisher.d.ts +76 -0
- package/dist/outpost/session-heartbeat-publisher.js +117 -0
- package/dist/outpost/session-heartbeat.d.ts +210 -0
- package/dist/outpost/session-heartbeat.js +657 -0
- package/dist/utils/resolve-vault-credential.d.ts +30 -0
- package/dist/utils/resolve-vault-credential.js +48 -0
- package/dist/utils/vault-api.d.ts +8 -1
- package/dist/utils/vault-api.js +3 -2
- package/package.json +3 -1
- package/src/commands/api-keys.test.ts +75 -1
- package/src/commands/api-keys.ts +86 -10
- package/src/commands/outposts-heartbeat.test.ts +299 -0
- package/src/commands/outposts-heartbeat.ts +310 -0
- package/src/commands/outposts.ts +4 -0
- package/src/commands/secrets.test.ts +133 -0
- package/src/commands/secrets.ts +172 -29
- package/src/outpost/session-heartbeat-bounds.test.ts +195 -0
- package/src/outpost/session-heartbeat-guard.test.ts +105 -0
- package/src/outpost/session-heartbeat-publisher.test.ts +178 -0
- package/src/outpost/session-heartbeat-publisher.ts +186 -0
- package/src/outpost/session-heartbeat-retain-guard.test.ts +126 -0
- package/src/outpost/session-heartbeat.test.ts +459 -0
- package/src/outpost/session-heartbeat.ts +877 -0
- package/src/packaging.test.ts +45 -0
- package/src/utils/resolve-vault-credential.test.ts +69 -0
- package/src/utils/resolve-vault-credential.ts +60 -0
- package/src/utils/vault-api.ts +13 -2
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outpost on-box session heartbeat emitter — mission-control US-009.
|
|
3
|
+
*
|
|
4
|
+
* Runs ON the Outpost VM (not in a Lambda). On a fixed cadence it:
|
|
5
|
+
* 1. enumerates the box's local Claude Code (`~/.claude/projects/**\/<uuid>.jsonl`)
|
|
6
|
+
* and Codex (`~/.codex/session_index.jsonl` + `sessions/YYYY/MM/DD/rollout-*.jsonl`)
|
|
7
|
+
* sessions using cheap scandir + stat + BOUNDED tail/head reads only —
|
|
8
|
+
* it NEVER full-parses a multi-MB transcript;
|
|
9
|
+
* 2. summarizes them into a compact `AgentSession[]` payload with
|
|
10
|
+
* `origin="outpost"`, mirroring the local reader logic from US-002/US-003;
|
|
11
|
+
* 3. publishes that payload to the realtime fabric topic `hq/{personUid}/sessions`
|
|
12
|
+
* using the same on-box credential pattern the rest of the box uses
|
|
13
|
+
* (a server-minted, per-identity-scoped STS session vended by
|
|
14
|
+
* `POST /v1/realtime/credentials`, then an MQTT-over-WSS publish).
|
|
15
|
+
*
|
|
16
|
+
* Security (US-009 acceptance): the payload carries ONLY the AgentSession
|
|
17
|
+
* fields below — never a transcript body, prompt, token, API key, env var, or
|
|
18
|
+
* credential. `assertNoSecretsInPayload` is the runtime guard, and the unit
|
|
19
|
+
* tests assert the no-secrets-in-payload guarantee against adversarial
|
|
20
|
+
* fixtures.
|
|
21
|
+
*
|
|
22
|
+
* This module is intentionally dependency-light and pure-logic where it can be:
|
|
23
|
+
* the filesystem, clock, and publish transport are all injected so the
|
|
24
|
+
* enumeration → payload mapping and the no-secrets guarantee are unit-testable
|
|
25
|
+
* without a real VM, real MQTT, or real STS.
|
|
26
|
+
*/
|
|
27
|
+
import { promises as fs } from "node:fs";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
import { homedir } from "node:os";
|
|
30
|
+
/**
|
|
31
|
+
* Default cadence matches the desktop polling interval (~5s). Configurable via
|
|
32
|
+
* the `OUTPOST_SESSIONS_HEARTBEAT_INTERVAL_SECONDS` env var so dev/staging can
|
|
33
|
+
* dial it without a rebuild — read by `resolveCadenceSeconds`.
|
|
34
|
+
*/
|
|
35
|
+
export const DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 5;
|
|
36
|
+
/**
|
|
37
|
+
* Default liveness windows. `running` ⇐ activity within the last 2 cadence
|
|
38
|
+
* ticks (10s); `idle` out to 15m; older ⇒ `ended`. Kept generous so a session
|
|
39
|
+
* mid-think between writes isn't flapped to `ended`.
|
|
40
|
+
*/
|
|
41
|
+
export const DEFAULT_LIVENESS_THRESHOLDS = {
|
|
42
|
+
runningWithinSeconds: 10,
|
|
43
|
+
idleWithinSeconds: 15 * 60,
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Bytes to read from the tail/head of a transcript when sniffing model/cwd.
|
|
47
|
+
* A hard cap — we open, seek, read this many bytes, and close. A multi-MB
|
|
48
|
+
* transcript is NEVER read in full (performance requirement, US-002/US-003).
|
|
49
|
+
*/
|
|
50
|
+
const BOUNDED_READ_BYTES = 16 * 1024;
|
|
51
|
+
/** Whitelist of AgentSession keys allowed on the wire. Any other key ⇒ reject. */
|
|
52
|
+
const ALLOWED_PAYLOAD_KEYS = new Set([
|
|
53
|
+
"id",
|
|
54
|
+
"tool",
|
|
55
|
+
"origin",
|
|
56
|
+
"cwd",
|
|
57
|
+
"project",
|
|
58
|
+
"company",
|
|
59
|
+
"model",
|
|
60
|
+
"status",
|
|
61
|
+
"startedAt",
|
|
62
|
+
"lastActivityAt",
|
|
63
|
+
"source",
|
|
64
|
+
]);
|
|
65
|
+
/**
|
|
66
|
+
* Patterns that, if seen in a VALUE, indicate a secret leaked into the payload.
|
|
67
|
+
* Defense in depth — the real protection is that only whitelisted fields are
|
|
68
|
+
* ever copied. Matched case-insensitively.
|
|
69
|
+
*
|
|
70
|
+
* These MUST be shaped, not bare substrings. The guard throws, and a throw
|
|
71
|
+
* fails the whole tick, so a marker that matches an ordinary path silently
|
|
72
|
+
* disables reporting for the entire box until that session ages out.
|
|
73
|
+
*
|
|
74
|
+
* The original list contained `sk-` and `asia` as plain substrings. `sk-`
|
|
75
|
+
* matches `task-runner`, `flask-app`, `disk-usage`, `risk-model`; `asia`
|
|
76
|
+
* matches any path with that string in it. One badly-named directory would
|
|
77
|
+
* have taken the heartbeat down — in a change whose entire subject is failures
|
|
78
|
+
* that report healthy. Every credential-shaped pattern below therefore
|
|
79
|
+
* requires the key's actual body, not just its prefix.
|
|
80
|
+
*/
|
|
81
|
+
const SECRET_VALUE_PATTERNS = [
|
|
82
|
+
// Anthropic: the `sk-ant-` prefix is distinctive enough on its own — no
|
|
83
|
+
// English word or path segment contains it — so match it bare. A length
|
|
84
|
+
// floor here would only create a way to miss a short or truncated key.
|
|
85
|
+
{ label: "anthropic-key", re: /sk-ant-/i },
|
|
86
|
+
// OpenAI-style: sk- plus a long token body. `task-runner` cannot match:
|
|
87
|
+
// it needs 20+ token chars after the dash, and a word boundary before.
|
|
88
|
+
{ label: "openai-key", re: /\bsk-[a-z0-9]{20,}\b/i },
|
|
89
|
+
// AWS key IDs are exactly AKIA/ASIA + 16 uppercase alphanumerics.
|
|
90
|
+
{ label: "aws-access-key-id", re: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/ },
|
|
91
|
+
{ label: "aws-secret-access-key", re: /aws_secret_access_key/i },
|
|
92
|
+
{ label: "aws-access-key-id-name", re: /aws_access_key_id/i },
|
|
93
|
+
{ label: "pem-private-key", re: /-----begin[a-z ]*private key/i },
|
|
94
|
+
{ label: "authorization-header", re: /authorization:\s*\S/i },
|
|
95
|
+
{ label: "bearer-token", re: /\bbearer\s+[a-z0-9._-]{16,}/i },
|
|
96
|
+
{ label: "instance-token", re: /x-outpost-instance-token/i },
|
|
97
|
+
{ label: "refresh-token", re: /refresh[_-]?token["'\s:=]/i },
|
|
98
|
+
{ label: "password", re: /password["'\s:=]/i },
|
|
99
|
+
{ label: "secret-access-key", re: /secretaccesskey/i },
|
|
100
|
+
{ label: "session-token", re: /sessiontoken/i },
|
|
101
|
+
];
|
|
102
|
+
/**
|
|
103
|
+
* Serialized-payload ceiling, in bytes.
|
|
104
|
+
*
|
|
105
|
+
* AWS IoT Core hard-rejects publishes over 128 KiB (131,072) — the box's first
|
|
106
|
+
* real heartbeat died on exactly that. This budget sits under it with headroom
|
|
107
|
+
* for the envelope and for any field a future schema adds.
|
|
108
|
+
*/
|
|
109
|
+
export const IOT_PAYLOAD_BUDGET_BYTES = 96 * 1024;
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Topic taxonomy — single source of truth (mirrors session-policy.ts's
|
|
112
|
+
// dmTopicForPerson). Documented in docs/realtime-fabric.md.
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
/** The sessions topic for a person. `hq/{personUid}/sessions`. */
|
|
115
|
+
export function sessionsTopicForPerson(personUid) {
|
|
116
|
+
if (!personUid) {
|
|
117
|
+
throw new Error("sessionsTopicForPerson: personUid is required");
|
|
118
|
+
}
|
|
119
|
+
return `hq/${personUid}/sessions`;
|
|
120
|
+
}
|
|
121
|
+
/** Resolve the heartbeat cadence (seconds) from env, clamped to a sane floor. */
|
|
122
|
+
export function resolveCadenceSeconds(env = process.env) {
|
|
123
|
+
const raw = env.OUTPOST_SESSIONS_HEARTBEAT_INTERVAL_SECONDS;
|
|
124
|
+
const parsed = raw ? Number.parseInt(raw, 10) : NaN;
|
|
125
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
126
|
+
return DEFAULT_HEARTBEAT_INTERVAL_SECONDS;
|
|
127
|
+
}
|
|
128
|
+
// Floor at 1s — a sub-second cadence would hammer IoT with no benefit.
|
|
129
|
+
return Math.max(1, parsed);
|
|
130
|
+
}
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Status derivation (US-004 parity).
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
/**
|
|
135
|
+
* Map an mtime to a status given the thresholds and `now`. On the box we have
|
|
136
|
+
* no per-session PID cross-check (that's the desktop's job), so we only emit
|
|
137
|
+
* `running | idle | ended` — the desktop refines from there.
|
|
138
|
+
*/
|
|
139
|
+
export function deriveStatus(lastActivityMs, nowMs, thresholds = DEFAULT_LIVENESS_THRESHOLDS) {
|
|
140
|
+
const ageSeconds = (nowMs - lastActivityMs) / 1000;
|
|
141
|
+
if (ageSeconds <= thresholds.runningWithinSeconds)
|
|
142
|
+
return "running";
|
|
143
|
+
if (ageSeconds <= thresholds.idleWithinSeconds)
|
|
144
|
+
return "idle";
|
|
145
|
+
return "ended";
|
|
146
|
+
}
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Claude Code enumeration.
|
|
149
|
+
// ~/.claude/projects/<encoded-cwd>/<uuid>.jsonl
|
|
150
|
+
// The project dir name is the cwd with `/` → `-` (Claude's on-disk encoding).
|
|
151
|
+
// We stat each .jsonl for mtime (liveness) and do ONE bounded tail read to
|
|
152
|
+
// sniff the model — never a full parse.
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
const UUID_JSONL = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$/i;
|
|
155
|
+
/** Decode Claude's `-`-joined project dir back to a best-effort cwd. */
|
|
156
|
+
export function decodeClaudeProjectDir(dirName) {
|
|
157
|
+
// Claude encodes the abs cwd by replacing path separators with `-`, e.g.
|
|
158
|
+
// `/home/ec2-user/hq` → `-home-ec2-user-hq`. The transform is lossy (a real
|
|
159
|
+
// `-` in a path collides), so this is best-effort: restore leading slash and
|
|
160
|
+
// separators. The authoritative cwd, when present, comes from the transcript
|
|
161
|
+
// line itself (sniffed below).
|
|
162
|
+
const trimmed = dirName.replace(/^-/, "");
|
|
163
|
+
return "/" + trimmed.split("-").join("/");
|
|
164
|
+
}
|
|
165
|
+
/** Last path segment as a project name. */
|
|
166
|
+
function projectFromCwd(cwd) {
|
|
167
|
+
if (!cwd)
|
|
168
|
+
return null;
|
|
169
|
+
const segs = cwd.split("/").filter(Boolean);
|
|
170
|
+
return segs.length ? segs[segs.length - 1] : null;
|
|
171
|
+
}
|
|
172
|
+
/** Pull the first `model` and `cwd` string from a small chunk of JSONL. */
|
|
173
|
+
function sniffClaudeFields(chunk) {
|
|
174
|
+
let model = null;
|
|
175
|
+
let cwd = null;
|
|
176
|
+
// Bounded line walk — chunk is already capped at BOUNDED_READ_BYTES.
|
|
177
|
+
for (const line of chunk.split("\n")) {
|
|
178
|
+
const trimmed = line.trim();
|
|
179
|
+
if (!trimmed.startsWith("{"))
|
|
180
|
+
continue;
|
|
181
|
+
let obj;
|
|
182
|
+
try {
|
|
183
|
+
obj = JSON.parse(trimmed);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
continue; // partial last line from a tail read — skip
|
|
187
|
+
}
|
|
188
|
+
if (obj && typeof obj === "object") {
|
|
189
|
+
const rec = obj;
|
|
190
|
+
if (!cwd && typeof rec.cwd === "string")
|
|
191
|
+
cwd = rec.cwd;
|
|
192
|
+
if (!model && typeof rec.model === "string")
|
|
193
|
+
model = rec.model;
|
|
194
|
+
const message = rec.message;
|
|
195
|
+
if (!model && message && typeof message === "object") {
|
|
196
|
+
const m = message.model;
|
|
197
|
+
if (typeof m === "string")
|
|
198
|
+
model = m;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (model && cwd)
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
return { model, cwd };
|
|
205
|
+
}
|
|
206
|
+
async function enumerateClaude(fsp, home, nowMs, thresholds, resolveCompany) {
|
|
207
|
+
const root = join(home, ".claude", "projects");
|
|
208
|
+
const projectDirs = await fsp.readDir(root);
|
|
209
|
+
const out = [];
|
|
210
|
+
for (const projDir of projectDirs) {
|
|
211
|
+
if (!projDir.isDirectory)
|
|
212
|
+
continue;
|
|
213
|
+
const projPath = join(root, projDir.name);
|
|
214
|
+
const decodedCwd = decodeClaudeProjectDir(projDir.name);
|
|
215
|
+
const files = await fsp.readDir(projPath);
|
|
216
|
+
for (const f of files) {
|
|
217
|
+
if (!f.isFile || !UUID_JSONL.test(f.name))
|
|
218
|
+
continue;
|
|
219
|
+
const filePath = join(projPath, f.name);
|
|
220
|
+
let stat;
|
|
221
|
+
try {
|
|
222
|
+
stat = await fsp.stat(filePath);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
continue; // raced unlink — skip
|
|
226
|
+
}
|
|
227
|
+
const id = f.name.replace(/\.jsonl$/, "");
|
|
228
|
+
const status = deriveStatus(stat.mtimeMs, nowMs, thresholds);
|
|
229
|
+
// Sniff ONLY what we will publish. `ended` sessions are dropped from the
|
|
230
|
+
// payload downstream, so reading them is pure waste — and on a real box
|
|
231
|
+
// it is a LOT of waste: 9,340 transcripts × a 16 KiB bounded read is
|
|
232
|
+
// ~150 MB of disk per tick, every 5 seconds, on a machine whose disk
|
|
233
|
+
// throughput budget is the scarce resource. Statting is cheap; reading
|
|
234
|
+
// is not. This keeps the read count at the number of live sessions
|
|
235
|
+
// (~15) rather than the size of the archive.
|
|
236
|
+
let cwd = decodedCwd;
|
|
237
|
+
let model = null;
|
|
238
|
+
if (status !== "ended") {
|
|
239
|
+
// ONE bounded tail read to sniff model + authoritative cwd. Never a
|
|
240
|
+
// full parse — `readBounded` reads at most BOUNDED_READ_BYTES.
|
|
241
|
+
const tail = await fsp.readBounded(filePath, BOUNDED_READ_BYTES, "tail");
|
|
242
|
+
const sniffed = sniffClaudeFields(tail);
|
|
243
|
+
cwd = sniffed.cwd ?? decodedCwd;
|
|
244
|
+
model = sniffed.model;
|
|
245
|
+
}
|
|
246
|
+
out.push({
|
|
247
|
+
id,
|
|
248
|
+
tool: "claude",
|
|
249
|
+
origin: "outpost",
|
|
250
|
+
cwd,
|
|
251
|
+
project: projectFromCwd(cwd),
|
|
252
|
+
company: await resolveCompany(cwd),
|
|
253
|
+
model,
|
|
254
|
+
status,
|
|
255
|
+
startedAt: new Date(stat.birthtimeMs).toISOString(),
|
|
256
|
+
lastActivityAt: new Date(stat.mtimeMs).toISOString(),
|
|
257
|
+
source: filePath,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
// Codex enumeration.
|
|
265
|
+
// ~/.codex/session_index.jsonl — newline-delimited index records
|
|
266
|
+
// ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl — per-session rollouts
|
|
267
|
+
// The index is small (one line per session) so it's safe to read whole; the
|
|
268
|
+
// rollouts are NOT — we only stat them for mtime and, when the index is
|
|
269
|
+
// absent, scan the dated dirs by name + stat (never reading rollout bodies).
|
|
270
|
+
//
|
|
271
|
+
// Observed shapes (documented per the US-003 ask):
|
|
272
|
+
// index line: { "id": "<uuid>", "cwd": "/abs/path", "model": "gpt-5-codex",
|
|
273
|
+
// "timestamp": "2026-06-15T18:00:00Z", "path": "sessions/..." }
|
|
274
|
+
// rollout file name: rollout-<ISO-or-epoch>-<uuid>.jsonl
|
|
275
|
+
// Field names vary across Codex versions; we read defensively and fall back to
|
|
276
|
+
// the filename/dir structure when a field is missing.
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
const ROLLOUT_FILE = /^rollout-.*\.jsonl$/i;
|
|
279
|
+
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
280
|
+
/** Parse the small newline-delimited Codex index into records. */
|
|
281
|
+
export function parseCodexIndex(text) {
|
|
282
|
+
const out = [];
|
|
283
|
+
for (const line of text.split("\n")) {
|
|
284
|
+
const trimmed = line.trim();
|
|
285
|
+
if (!trimmed.startsWith("{"))
|
|
286
|
+
continue;
|
|
287
|
+
let obj;
|
|
288
|
+
try {
|
|
289
|
+
obj = JSON.parse(trimmed);
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
const id = (typeof obj.id === "string" && obj.id) ||
|
|
295
|
+
(typeof obj.session_id === "string" && obj.session_id) ||
|
|
296
|
+
(typeof obj.sessionId === "string" && obj.sessionId) ||
|
|
297
|
+
"";
|
|
298
|
+
if (!id)
|
|
299
|
+
continue;
|
|
300
|
+
const cwd = (typeof obj.cwd === "string" && obj.cwd) ||
|
|
301
|
+
(typeof obj.cwd_path === "string" && obj.cwd_path) ||
|
|
302
|
+
null;
|
|
303
|
+
const model = (typeof obj.model === "string" && obj.model) ||
|
|
304
|
+
(typeof obj.model_slug === "string" && obj.model_slug) ||
|
|
305
|
+
null;
|
|
306
|
+
const timestamp = (typeof obj.timestamp === "string" && obj.timestamp) ||
|
|
307
|
+
(typeof obj.updated_at === "string" && obj.updated_at) ||
|
|
308
|
+
(typeof obj.created_at === "string" && obj.created_at) ||
|
|
309
|
+
null;
|
|
310
|
+
const path = typeof obj.path === "string" ? obj.path : null;
|
|
311
|
+
out.push({ id, cwd, model, timestamp, path });
|
|
312
|
+
}
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
async function enumerateCodex(fsp, home, nowMs, thresholds, resolveCompany) {
|
|
316
|
+
const codexRoot = join(home, ".codex");
|
|
317
|
+
const out = [];
|
|
318
|
+
const seen = new Set();
|
|
319
|
+
// Rollout file paths already covered by an index record — excluded from the
|
|
320
|
+
// dir walk so an indexed session isn't double-counted when its rollout
|
|
321
|
+
// filename carries no extractable UUID (e.g. `rollout-x.jsonl`).
|
|
322
|
+
const seenRolloutPaths = new Set();
|
|
323
|
+
// 1. Read the small index (whole-file read is safe — one line per session).
|
|
324
|
+
let indexRecords = [];
|
|
325
|
+
try {
|
|
326
|
+
const indexText = await fsp.readTextFile(join(codexRoot, "session_index.jsonl"));
|
|
327
|
+
indexRecords = parseCodexIndex(indexText);
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
indexRecords = []; // no index — fall through to dir scan
|
|
331
|
+
}
|
|
332
|
+
for (const rec of indexRecords) {
|
|
333
|
+
// Liveness from the rollout file's mtime when we can locate it; else the
|
|
334
|
+
// index timestamp. We only ever STAT the rollout — never read its body.
|
|
335
|
+
let lastActivityMs = rec.timestamp ? Date.parse(rec.timestamp) : NaN;
|
|
336
|
+
let startedMs = lastActivityMs;
|
|
337
|
+
let source = join(codexRoot, "session_index.jsonl");
|
|
338
|
+
if (rec.path) {
|
|
339
|
+
const rolloutPath = join(codexRoot, rec.path);
|
|
340
|
+
// Mark covered regardless of stat success so the dir walk never
|
|
341
|
+
// re-emits this rollout as a separate session.
|
|
342
|
+
seenRolloutPaths.add(rolloutPath);
|
|
343
|
+
try {
|
|
344
|
+
const st = await fsp.stat(rolloutPath);
|
|
345
|
+
lastActivityMs = st.mtimeMs;
|
|
346
|
+
startedMs = st.birthtimeMs;
|
|
347
|
+
source = rolloutPath;
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
// rollout missing — keep the index timestamp
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (!Number.isFinite(lastActivityMs))
|
|
354
|
+
lastActivityMs = nowMs;
|
|
355
|
+
if (!Number.isFinite(startedMs))
|
|
356
|
+
startedMs = lastActivityMs;
|
|
357
|
+
seen.add(rec.id);
|
|
358
|
+
out.push({
|
|
359
|
+
id: rec.id,
|
|
360
|
+
tool: "codex",
|
|
361
|
+
origin: "outpost",
|
|
362
|
+
cwd: rec.cwd,
|
|
363
|
+
project: projectFromCwd(rec.cwd),
|
|
364
|
+
company: await resolveCompany(rec.cwd),
|
|
365
|
+
model: rec.model,
|
|
366
|
+
status: deriveStatus(lastActivityMs, nowMs, thresholds),
|
|
367
|
+
startedAt: new Date(startedMs).toISOString(),
|
|
368
|
+
lastActivityAt: new Date(lastActivityMs).toISOString(),
|
|
369
|
+
source,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
// 2. Walk sessions/YYYY/MM/DD/rollout-*.jsonl by NAME + STAT for any sessions
|
|
373
|
+
// not covered by the index (and archived_sessions likewise). Never reads
|
|
374
|
+
// rollout bodies — this is scandir + stat only.
|
|
375
|
+
for (const base of ["sessions", "archived_sessions"]) {
|
|
376
|
+
const baseRoot = join(codexRoot, base);
|
|
377
|
+
for (const rolloutPath of await walkRolloutFiles(fsp, baseRoot)) {
|
|
378
|
+
if (seenRolloutPaths.has(rolloutPath))
|
|
379
|
+
continue;
|
|
380
|
+
const idMatch = rolloutPath.match(UUID_RE);
|
|
381
|
+
const id = idMatch ? idMatch[0] : rolloutPath;
|
|
382
|
+
if (seen.has(id))
|
|
383
|
+
continue;
|
|
384
|
+
seen.add(id);
|
|
385
|
+
let st;
|
|
386
|
+
try {
|
|
387
|
+
st = await fsp.stat(rolloutPath);
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
out.push({
|
|
393
|
+
id,
|
|
394
|
+
tool: "codex",
|
|
395
|
+
origin: "outpost",
|
|
396
|
+
cwd: null,
|
|
397
|
+
project: null,
|
|
398
|
+
company: null,
|
|
399
|
+
model: null,
|
|
400
|
+
status: deriveStatus(st.mtimeMs, nowMs, thresholds),
|
|
401
|
+
startedAt: new Date(st.birthtimeMs).toISOString(),
|
|
402
|
+
lastActivityAt: new Date(st.mtimeMs).toISOString(),
|
|
403
|
+
source: rolloutPath,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return out;
|
|
408
|
+
}
|
|
409
|
+
/** Enumerate rollout-*.jsonl under sessions/YYYY/MM/DD (scandir only). */
|
|
410
|
+
async function walkRolloutFiles(fsp, baseRoot) {
|
|
411
|
+
const found = [];
|
|
412
|
+
const years = await fsp.readDir(baseRoot);
|
|
413
|
+
for (const y of years) {
|
|
414
|
+
if (!y.isDirectory)
|
|
415
|
+
continue;
|
|
416
|
+
const months = await fsp.readDir(join(baseRoot, y.name));
|
|
417
|
+
for (const mo of months) {
|
|
418
|
+
if (!mo.isDirectory)
|
|
419
|
+
continue;
|
|
420
|
+
const days = await fsp.readDir(join(baseRoot, y.name, mo.name));
|
|
421
|
+
for (const d of days) {
|
|
422
|
+
if (!d.isDirectory)
|
|
423
|
+
continue;
|
|
424
|
+
const dayPath = join(baseRoot, y.name, mo.name, d.name);
|
|
425
|
+
const files = await fsp.readDir(dayPath);
|
|
426
|
+
for (const f of files) {
|
|
427
|
+
if (f.isFile && ROLLOUT_FILE.test(f.name)) {
|
|
428
|
+
found.push(join(dayPath, f.name));
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return found;
|
|
435
|
+
}
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
// Company resolution — best-effort from HQ workspace metadata. Mirrors the
|
|
438
|
+
// local reader (US-002): a session whose HQ thread carries a company gets it
|
|
439
|
+
// tagged. We read ONLY the small meta.yaml (bounded) — never session content.
|
|
440
|
+
// On the box the workspace lives at ~/hq/workspace/sessions/<id>/meta.yaml, but
|
|
441
|
+
// the cwd→company mapping isn't reliably derivable, so v1 returns null unless a
|
|
442
|
+
// future enrichment hook is wired. Kept as an injectable seam.
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
function makeCompanyResolver() {
|
|
445
|
+
// v1: no on-box cwd→company map. Returns null; the desktop enriches from its
|
|
446
|
+
// own HQ workspace metadata after merge. Isolated here so US-004/enrichment
|
|
447
|
+
// can swap it without touching the enumerators.
|
|
448
|
+
return async () => null;
|
|
449
|
+
}
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
// Payload assembly + the no-secrets guarantee.
|
|
452
|
+
// ---------------------------------------------------------------------------
|
|
453
|
+
/**
|
|
454
|
+
* Project an arbitrary session-like object down to EXACTLY the whitelisted
|
|
455
|
+
* AgentSession fields. Any extra key (e.g. a transcript snippet, token, env
|
|
456
|
+
* var) is dropped here — this is the structural half of the no-secrets
|
|
457
|
+
* guarantee.
|
|
458
|
+
*/
|
|
459
|
+
export function toCompactSession(s) {
|
|
460
|
+
return {
|
|
461
|
+
id: s.id,
|
|
462
|
+
tool: s.tool,
|
|
463
|
+
origin: s.origin,
|
|
464
|
+
cwd: s.cwd ?? null,
|
|
465
|
+
project: s.project ?? null,
|
|
466
|
+
company: s.company ?? null,
|
|
467
|
+
model: s.model ?? null,
|
|
468
|
+
status: s.status,
|
|
469
|
+
startedAt: s.startedAt ?? null,
|
|
470
|
+
lastActivityAt: s.lastActivityAt ?? null,
|
|
471
|
+
source: s.source,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Runtime guard: throw if the payload carries any non-whitelisted key OR any
|
|
476
|
+
* value that looks like a secret. The behavioral half of the no-secrets
|
|
477
|
+
* guarantee — defense in depth on top of `toCompactSession`. Called before
|
|
478
|
+
* every publish.
|
|
479
|
+
*/
|
|
480
|
+
export function assertNoSecretsInPayload(payload) {
|
|
481
|
+
for (const session of payload.sessions) {
|
|
482
|
+
const keys = Object.keys(session);
|
|
483
|
+
for (const key of keys) {
|
|
484
|
+
if (!ALLOWED_PAYLOAD_KEYS.has(key)) {
|
|
485
|
+
throw new Error(`heartbeat payload contains non-whitelisted field "${String(key)}" — refusing to publish`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
for (const key of keys) {
|
|
489
|
+
const value = session[key];
|
|
490
|
+
if (typeof value !== "string")
|
|
491
|
+
continue;
|
|
492
|
+
// The `source` field is a path and may legitimately contain a `-` etc.;
|
|
493
|
+
// still scan it — a path should never contain credential-shaped text.
|
|
494
|
+
for (const { label, re } of SECRET_VALUE_PATTERNS) {
|
|
495
|
+
if (re.test(value)) {
|
|
496
|
+
throw new Error(`heartbeat payload field "${String(key)}" matched secret marker "${label}" — refusing to publish`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Enumerate the box's Claude + Codex sessions and build the compact,
|
|
504
|
+
* secret-free payload. Pure w.r.t. the injected fs/clock — does NOT publish.
|
|
505
|
+
*/
|
|
506
|
+
export async function collectSessions(config, deps) {
|
|
507
|
+
const now = config.now ?? (() => new Date());
|
|
508
|
+
const nowDate = now();
|
|
509
|
+
const nowMs = nowDate.getTime();
|
|
510
|
+
const home = config.home ?? homedir();
|
|
511
|
+
const thresholds = config.thresholds ?? DEFAULT_LIVENESS_THRESHOLDS;
|
|
512
|
+
const resolveCompany = makeCompanyResolver();
|
|
513
|
+
const [claude, codex] = await Promise.all([
|
|
514
|
+
enumerateClaude(deps.fs, home, nowMs, thresholds, resolveCompany),
|
|
515
|
+
enumerateCodex(deps.fs, home, nowMs, thresholds, resolveCompany),
|
|
516
|
+
]);
|
|
517
|
+
// Project EVERY session through toCompactSession so nothing but the
|
|
518
|
+
// whitelisted fields can survive into the payload.
|
|
519
|
+
const all = [...claude, ...codex].map(toCompactSession);
|
|
520
|
+
// A long-lived box accumulates an ARCHIVE, not a status. The first box this
|
|
521
|
+
// ran against held 9,340 transcripts, 15 of them active — publishing the lot
|
|
522
|
+
// produced ~1.9 MB and AWS IoT rejected it outright. The heartbeat carries
|
|
523
|
+
// what is live; history is the vault's job, not a 5-second broadcast's.
|
|
524
|
+
const live = all
|
|
525
|
+
.filter((s) => s.status !== "ended")
|
|
526
|
+
.sort((a, b) => Date.parse(b.lastActivityAt ?? "") - Date.parse(a.lastActivityAt ?? ""));
|
|
527
|
+
const sessions = fitWithinBudget(live, nowDate.toISOString());
|
|
528
|
+
const payload = {
|
|
529
|
+
type: "sessions",
|
|
530
|
+
origin: "outpost",
|
|
531
|
+
emittedAt: nowDate.toISOString(),
|
|
532
|
+
sessions,
|
|
533
|
+
totalSessions: all.length,
|
|
534
|
+
truncated: sessions.length < live.length,
|
|
535
|
+
};
|
|
536
|
+
// Belt-and-suspenders: guard before the payload ever leaves the box.
|
|
537
|
+
assertNoSecretsInPayload(payload);
|
|
538
|
+
return payload;
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Trim a recency-sorted list until the serialized envelope fits the budget.
|
|
542
|
+
*
|
|
543
|
+
* Binary search rather than a per-item loop: a box with thousands of live
|
|
544
|
+
* sessions would otherwise re-serialize the whole array on every step, and
|
|
545
|
+
* this runs every 5 seconds on a machine whose CPU is not free.
|
|
546
|
+
*/
|
|
547
|
+
function fitWithinBudget(sorted, emittedAt) {
|
|
548
|
+
const sizeOf = (n) => Buffer.byteLength(JSON.stringify({
|
|
549
|
+
type: "sessions",
|
|
550
|
+
origin: "outpost",
|
|
551
|
+
emittedAt,
|
|
552
|
+
sessions: sorted.slice(0, n),
|
|
553
|
+
totalSessions: sorted.length,
|
|
554
|
+
truncated: true,
|
|
555
|
+
}), "utf8");
|
|
556
|
+
if (sizeOf(sorted.length) <= IOT_PAYLOAD_BUDGET_BYTES) {
|
|
557
|
+
return [...sorted];
|
|
558
|
+
}
|
|
559
|
+
let lo = 0;
|
|
560
|
+
let hi = sorted.length;
|
|
561
|
+
while (lo < hi) {
|
|
562
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
563
|
+
if (sizeOf(mid) <= IOT_PAYLOAD_BUDGET_BYTES)
|
|
564
|
+
lo = mid;
|
|
565
|
+
else
|
|
566
|
+
hi = mid - 1;
|
|
567
|
+
}
|
|
568
|
+
return sorted.slice(0, lo);
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* One heartbeat tick: collect → guard → publish to `hq/{personUid}/sessions`.
|
|
572
|
+
* Best-effort and non-fatal by contract — a publish failure must not crash the
|
|
573
|
+
* box's heartbeat loop (the desktop falls back to the S3-vault heartbeat /
|
|
574
|
+
* stale-timeout, US-011). Returns the payload that was published (or attempted)
|
|
575
|
+
* so callers/tests can assert on it; re-throws nothing.
|
|
576
|
+
*/
|
|
577
|
+
export async function emitHeartbeatOnce(config, deps) {
|
|
578
|
+
const payload = await collectSessions(config, deps);
|
|
579
|
+
const topic = sessionsTopicForPerson(config.personUid);
|
|
580
|
+
try {
|
|
581
|
+
await deps.publish(topic, payload);
|
|
582
|
+
}
|
|
583
|
+
catch (err) {
|
|
584
|
+
// Non-fatal: log + continue. The loop keeps the box reporting on the next
|
|
585
|
+
// tick; the desktop already handles a missed beat via its stale timeout.
|
|
586
|
+
console.error(JSON.stringify({
|
|
587
|
+
service: "outpost-session-heartbeat",
|
|
588
|
+
step: "publish",
|
|
589
|
+
outcome: "error",
|
|
590
|
+
topic,
|
|
591
|
+
message: err instanceof Error ? err.message : String(err),
|
|
592
|
+
timestamp: new Date().toISOString(),
|
|
593
|
+
}));
|
|
594
|
+
}
|
|
595
|
+
return payload;
|
|
596
|
+
}
|
|
597
|
+
// ---------------------------------------------------------------------------
|
|
598
|
+
// Real node:fs port — bounded reads use a positioned read, never a full slurp.
|
|
599
|
+
// ---------------------------------------------------------------------------
|
|
600
|
+
/** Production FileSystemPort backed by node:fs with bounded positioned reads. */
|
|
601
|
+
export const nodeFileSystem = {
|
|
602
|
+
async readDir(path) {
|
|
603
|
+
try {
|
|
604
|
+
const entries = await fs.readdir(path, { withFileTypes: true });
|
|
605
|
+
return entries.map((e) => ({
|
|
606
|
+
name: e.name,
|
|
607
|
+
isDirectory: e.isDirectory(),
|
|
608
|
+
isFile: e.isFile(),
|
|
609
|
+
}));
|
|
610
|
+
}
|
|
611
|
+
catch (err) {
|
|
612
|
+
// A MISSING directory is expected — a box with no Codex sessions has no
|
|
613
|
+
// ~/.codex — so that degrades to empty. Anything else (EACCES, EIO, a
|
|
614
|
+
// full disk) must propagate: swallowing it publishes an empty session
|
|
615
|
+
// list, logs success, and refreshes the liveness marker while collection
|
|
616
|
+
// has actually failed. That is the silent-success shape the audit check
|
|
617
|
+
// in this same change exists to catch, so it must not be reintroduced
|
|
618
|
+
// one layer down.
|
|
619
|
+
const code = err?.code;
|
|
620
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
621
|
+
return [];
|
|
622
|
+
throw err;
|
|
623
|
+
}
|
|
624
|
+
},
|
|
625
|
+
async stat(path) {
|
|
626
|
+
const st = await fs.stat(path);
|
|
627
|
+
return {
|
|
628
|
+
mtimeMs: st.mtimeMs,
|
|
629
|
+
birthtimeMs: st.birthtimeMs,
|
|
630
|
+
size: st.size,
|
|
631
|
+
};
|
|
632
|
+
},
|
|
633
|
+
async readTextFile(path) {
|
|
634
|
+
return fs.readFile(path, "utf8");
|
|
635
|
+
},
|
|
636
|
+
async readBounded(path, maxBytes, from) {
|
|
637
|
+
let handle = null;
|
|
638
|
+
try {
|
|
639
|
+
handle = await fs.open(path, "r");
|
|
640
|
+
const st = await handle.stat();
|
|
641
|
+
const size = st.size;
|
|
642
|
+
const length = Math.min(maxBytes, size);
|
|
643
|
+
const position = from === "tail" ? Math.max(0, size - length) : 0;
|
|
644
|
+
const buf = Buffer.allocUnsafe(length);
|
|
645
|
+
const { bytesRead } = await handle.read(buf, 0, length, position);
|
|
646
|
+
return buf.subarray(0, bytesRead).toString("utf8");
|
|
647
|
+
}
|
|
648
|
+
catch {
|
|
649
|
+
return ""; // locked/missing ⇒ no sniff (best-effort)
|
|
650
|
+
}
|
|
651
|
+
finally {
|
|
652
|
+
if (handle)
|
|
653
|
+
await handle.close().catch(() => undefined);
|
|
654
|
+
}
|
|
655
|
+
},
|
|
656
|
+
};
|
|
657
|
+
//# sourceMappingURL=session-heartbeat.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Vault API keys issued by `hq api-keys create` (hq-pro). */
|
|
2
|
+
export declare const HQ_API_KEY_PREFIX = "hqk_";
|
|
3
|
+
export type VaultCredential = {
|
|
4
|
+
kind: "api-key";
|
|
5
|
+
token: string;
|
|
6
|
+
} | {
|
|
7
|
+
kind: "cognito";
|
|
8
|
+
token: string;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Raw HQ_API_KEY from the environment, trimmed. Undefined when unset/empty.
|
|
12
|
+
* Does not validate prefix — use {@link resolveVaultCredential} for that.
|
|
13
|
+
*/
|
|
14
|
+
export declare function peekHqApiKey(): string | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* Resolve vault auth for CLI commands.
|
|
17
|
+
*
|
|
18
|
+
* When `HQ_API_KEY` is set it is authoritative: must be a vault key (`hqk_…`)
|
|
19
|
+
* and Cognito is never used as a fallback (fail-closed). When unset, uses the
|
|
20
|
+
* cached Cognito session (interactive login if needed).
|
|
21
|
+
*/
|
|
22
|
+
export declare function resolveVaultCredential(options?: {
|
|
23
|
+
interactive?: boolean;
|
|
24
|
+
}): Promise<VaultCredential>;
|
|
25
|
+
/**
|
|
26
|
+
* Throw when HQ_API_KEY is set but the command only supports Cognito sessions
|
|
27
|
+
* (list, set, ACL, api-keys admin, etc.).
|
|
28
|
+
*/
|
|
29
|
+
export declare function assertCognitoOnlyCommand(commandLabel: string): void;
|
|
30
|
+
//# sourceMappingURL=resolve-vault-credential.d.ts.map
|