viber-channel 0.8.19 → 0.8.21

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.
@@ -0,0 +1,358 @@
1
+ /**
2
+ * presence_record.ts — the local presence record of one agent (#627 step-01).
3
+ *
4
+ * #627 moves agent presence off the per-agent network heartbeat: the machine's
5
+ * runner becomes the ONLY party that tells the server who is here and who left.
6
+ * To do that it must know, locally, which instances live on this machine. Each
7
+ * agent therefore drops one small JSON file per instance:
8
+ *
9
+ * <lockDir>/presence/<backendKey>/<instance_id>.json
10
+ *
11
+ * - `lockDir` is `resolveLockDir()` — the same machine-wide directory as the
12
+ * channel locks, so tests redirect it with `VIBER_LOCK_DIR` like everything else.
13
+ * - `backendKey` hashes the IDENTITY base URL **verbatim** (#480: never normalize
14
+ * it — a trailing slash would move every record and the runner would see no one).
15
+ * A runner only reads the directory of its own backend, so dev and staging
16
+ * agents on the same machine never mix.
17
+ * - Temp files `<id>.json.<pid>.tmp` live in the same directory: a reader must
18
+ * match `RECORD_FILE_RE` strictly.
19
+ *
20
+ * ⚠ This module is the ONLY place the path and the hash are computed. The runner
21
+ * (step-02/03) imports it; a second copy would be the drift this repo keeps paying
22
+ * for.
23
+ *
24
+ * Process identity. The record carries the pid of the process that holds the
25
+ * channel (this bun process — not claude.exe, not the launcher, whose PPID chain is
26
+ * broken by design, #512) and its start time, measured by `process_start.ts`. The
27
+ * runner measures the pid again with the SAME expression and compares with ZERO
28
+ * tolerance: any difference means the pid now belongs to another process. The
29
+ * probe is asynchronous, so `start_time_ms` is null for the first moments of an
30
+ * agent's life — a runner treats that as "cannot tell", never as a departure.
31
+ *
32
+ * End of life. The agent records a FACT, not a verdict: `state: "ended"` with an
33
+ * `ended_reason` code. Whether that end means "closed on purpose" (removed from the
34
+ * web) or "down" (greyed, relaunchable) is decided in ONE place, by the runner
35
+ * (step-03). The channel cannot tell a `/exit` from a crash of claude.exe, a window
36
+ * closed with the cross, or a reboot — all of them arrive as the same stdin EOF —
37
+ * and JP's rule is to grey rather than lose. A process that dies without running
38
+ * its exit path leaves an `active` record behind.
39
+ */
40
+ import { createHash } from "node:crypto";
41
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
42
+ import { dirname, join } from "node:path";
43
+ import { assertHashKey } from "./hash_key.js";
44
+ import { resolveLockDir } from "./lock_dir.js";
45
+ import { processStartTimeAsync } from "./process_start.js";
46
+
47
+ export const PRESENCE_SCHEMA_VERSION = 1;
48
+
49
+ /** How often the live record is re-checked for a conversation change. */
50
+ export const PRESENCE_REFRESH_MS = 15_000;
51
+
52
+ const INSTANCE_ID_RE = /^[0-9a-f]{32}$/i;
53
+
54
+ /** A record file name — and nothing else in the directory (temp files included). */
55
+ export const RECORD_FILE_RE = /^[0-9a-f]{32}\.json$/;
56
+
57
+ export type PresenceState = "active" | "ended";
58
+
59
+ /**
60
+ * How an agent's life ended, as observed from inside it. Codes, not prose: the
61
+ * runner's policy (step-03) switches on them.
62
+ *
63
+ * - `revoked` the server revoked the instance (stop event / control stop)
64
+ * - `lease-lost` another holder took the conversation (#400)
65
+ * - `once-completed` a `--once` bridge finished its one message
66
+ * - `stdin-end` claude's pipe closed: /exit, crash, window closed, reboot
67
+ * - `signal` SIGINT / SIGTERM: Ctrl+C, console closed, shutdown
68
+ * - `shutdown` any other bridge shutdown (token expired, refresh failed, …)
69
+ */
70
+ export type PresenceEndReason =
71
+ | "revoked"
72
+ | "lease-lost"
73
+ | "once-completed"
74
+ | "stdin-end"
75
+ | "signal"
76
+ | "shutdown";
77
+
78
+ const END_REASONS: ReadonlySet<string> = new Set<PresenceEndReason>([
79
+ "revoked",
80
+ "lease-lost",
81
+ "once-completed",
82
+ "stdin-end",
83
+ "signal",
84
+ "shutdown",
85
+ ]);
86
+
87
+ export interface PresenceRecord {
88
+ schema_version: number;
89
+ instance_id: string;
90
+ pid: number;
91
+ /** The name the agent registered with (`{prefix}-{role}` for a runner launch);
92
+ * the runner joins it to its registry entry (step-02). Null when unnamed. */
93
+ label: string | null;
94
+ /** The project the instance belongs to: a label is unique per project, not per
95
+ * machine, so the runner joins on (project_id, label). Null when unknown. */
96
+ project_id: number | null;
97
+ /** The job object name the gateway imposed on this launch (`VIBER_JOB_NAME`, set
98
+ * by vibe-master). Unique per ATTEMPT: the exact launch identity, which a name
99
+ * is not. Null for an agent the gateway did not launch. */
100
+ job_name: string | null;
101
+ /** Epoch ms the carrier process started; null until the async probe answers. */
102
+ start_time_ms: number | null;
103
+ /** Epoch ms of the FIRST write. */
104
+ created_at: number;
105
+ /** Epoch ms of this write. */
106
+ written_at: number;
107
+ conversation_id: string | null;
108
+ state: PresenceState;
109
+ /** Set when `state` is "ended", null otherwise. */
110
+ ended_reason: PresenceEndReason | null;
111
+ }
112
+
113
+ /** Stable directory key of one backend: sha256 of the identity URL, verbatim. */
114
+ export function presenceBackendKey(identityBaseUrl: string): string {
115
+ assertHashKey(identityBaseUrl, "presenceBackendKey(identityBaseUrl)", { allowEmpty: false });
116
+ return createHash("sha256").update(identityBaseUrl).digest("hex").slice(0, 16);
117
+ }
118
+
119
+ export function presenceDir(identityBaseUrl: string, lockDir: string = resolveLockDir()): string {
120
+ return join(lockDir, "presence", presenceBackendKey(identityBaseUrl));
121
+ }
122
+
123
+ export function isInstanceId(value: string): boolean {
124
+ return INSTANCE_ID_RE.test(value);
125
+ }
126
+
127
+ export function presenceRecordPath(
128
+ identityBaseUrl: string,
129
+ instanceId: string,
130
+ lockDir?: string,
131
+ ): string {
132
+ if (!isInstanceId(instanceId)) {
133
+ // The id becomes a file name: never let an unexpected value pick the path.
134
+ throw new Error(`presenceRecordPath: not an instance id: ${JSON.stringify(instanceId)}`);
135
+ }
136
+ return join(presenceDir(identityBaseUrl, lockDir), `${instanceId.toLowerCase()}.json`);
137
+ }
138
+
139
+ /** Atomic write: a reader sees the old record or the new one, never half of one. */
140
+ export function writePresenceRecord(path: string, record: PresenceRecord): void {
141
+ mkdirSync(dirname(path), { recursive: true });
142
+ const tmp = `${path}.${process.pid}.tmp`;
143
+ writeFileSync(tmp, JSON.stringify(record), "utf8");
144
+ try {
145
+ renameSync(tmp, path);
146
+ } catch (err) {
147
+ try {
148
+ unlinkSync(tmp);
149
+ } catch {
150
+ // the tmp may already be gone; the rename error is the one that matters
151
+ }
152
+ throw err;
153
+ }
154
+ }
155
+
156
+ export type PresenceRead =
157
+ | { kind: "ok"; record: PresenceRecord }
158
+ | { kind: "absent" }
159
+ | { kind: "unreadable"; error: string };
160
+
161
+ /**
162
+ * Read and validate one record. `unreadable` is NOT `absent`: a runner must never
163
+ * turn a file it could not parse into a departure (plan 627, design point 4).
164
+ */
165
+ export function readPresenceRecord(path: string): PresenceRead {
166
+ let raw: string;
167
+ try {
168
+ raw = readFileSync(path, "utf8");
169
+ } catch (err) {
170
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return { kind: "absent" };
171
+ return { kind: "unreadable", error: String(err) };
172
+ }
173
+ let parsed: unknown;
174
+ try {
175
+ parsed = JSON.parse(raw);
176
+ } catch (err) {
177
+ return { kind: "unreadable", error: `invalid JSON: ${String(err)}` };
178
+ }
179
+ const record = asPresenceRecord(parsed);
180
+ return record === undefined
181
+ ? { kind: "unreadable", error: "invalid record shape" }
182
+ : { kind: "ok", record };
183
+ }
184
+
185
+ function isEpoch(v: unknown): boolean {
186
+ return typeof v === "number" && Number.isFinite(v) && v > 0;
187
+ }
188
+
189
+ function asPresenceRecord(value: unknown): PresenceRecord | undefined {
190
+ if (typeof value !== "object" || value === null) return undefined;
191
+ const v = value as Record<string, unknown>;
192
+ if (v.schema_version !== PRESENCE_SCHEMA_VERSION) return undefined;
193
+ const okId = typeof v.instance_id === "string" && isInstanceId(v.instance_id);
194
+ const okPid = typeof v.pid === "number" && Number.isInteger(v.pid) && v.pid > 0;
195
+ const okStart = v.start_time_ms === null || isEpoch(v.start_time_ms);
196
+ const okTimes = isEpoch(v.created_at) && isEpoch(v.written_at);
197
+ const okConv = v.conversation_id === null || typeof v.conversation_id === "string";
198
+ const okLabel = v.label === null || typeof v.label === "string";
199
+ const okJob = v.job_name === null || typeof v.job_name === "string";
200
+ const okProject = v.project_id === null || (typeof v.project_id === "number" && Number.isInteger(v.project_id));
201
+ const okState =
202
+ (v.state === "active" && v.ended_reason === null) ||
203
+ (v.state === "ended" && typeof v.ended_reason === "string" && END_REASONS.has(v.ended_reason));
204
+ if (!(okId && okPid && okLabel && okProject && okJob && okStart && okTimes && okConv && okState)) return undefined;
205
+ return v as unknown as PresenceRecord;
206
+ }
207
+
208
+ /** Back-off between start-time probes; the last value repeats. */
209
+ export const PROBE_RETRY_DELAYS_MS: readonly number[] = [5_000, 15_000, 30_000, 60_000, 300_000];
210
+
211
+ export interface PresenceRecordOptions {
212
+ identityBaseUrl: string;
213
+ instanceId: string;
214
+ /** The registered name; blank or absent → null. */
215
+ label?: string | null;
216
+ /** The project of the instance (auth.json `project_id`). */
217
+ projectId?: number | null;
218
+ /** The gateway-imposed job name; defaults to `VIBER_JOB_NAME` (blank → null). */
219
+ jobName?: string | null;
220
+ /** Read on every refresh: the agent may be re-bound to another conversation. */
221
+ getConversationId: () => string | null;
222
+ lockDir?: string;
223
+ pid?: number;
224
+ now?: () => number;
225
+ /** The carrier's start time; defaults to the async `process_start` probe. */
226
+ probeStartTime?: (pid: number) => Promise<number | undefined>;
227
+ probeRetryDelaysMs?: readonly number[];
228
+ sleep?: (ms: number) => Promise<void>;
229
+ refreshMs?: number;
230
+ log?: (line: string) => void;
231
+ setTimer?: (fn: () => void, ms: number) => unknown;
232
+ clearTimer?: (handle: unknown) => void;
233
+ }
234
+
235
+ export interface PresenceRecordHandle {
236
+ /** Record how this life ended. Synchronous: safe right before exit. */
237
+ markEnded: (reason: PresenceEndReason) => void;
238
+ /** Stop refreshing without touching the file. Idempotent. */
239
+ stop: () => void;
240
+ /** Settles once the start-time probe has been written (tests). */
241
+ ready: Promise<void>;
242
+ }
243
+
244
+ /**
245
+ * Write this agent's record now and keep it in step with its conversation.
246
+ *
247
+ * Failures are logged, never thrown: the record serves the runner, and an agent
248
+ * must not die because its presence file could not be written. The runner then
249
+ * simply does not see it — the conservative direction.
250
+ */
251
+ export function startPresenceRecord(opts: PresenceRecordOptions): PresenceRecordHandle {
252
+ const log = opts.log ?? ((line: string) => process.stderr.write(line));
253
+ const now = opts.now ?? Date.now;
254
+ const pid = opts.pid ?? process.pid;
255
+ const setTimer =
256
+ opts.setTimer ?? ((fn: () => void, ms: number) => {
257
+ const h = setInterval(fn, ms);
258
+ (h as { unref?: () => void }).unref?.();
259
+ return h as unknown;
260
+ });
261
+ const clearTimer =
262
+ opts.clearTimer ?? ((h: unknown) => clearInterval(h as ReturnType<typeof setInterval>));
263
+
264
+ let path: string;
265
+ try {
266
+ path = presenceRecordPath(opts.identityBaseUrl, opts.instanceId, opts.lockDir);
267
+ } catch (err) {
268
+ log(`[presence] record disabled: ${String(err)}\n`);
269
+ return { markEnded: () => {}, stop: () => {}, ready: Promise.resolve() };
270
+ }
271
+
272
+ const createdAt = now();
273
+ const label = opts.label?.trim() ? opts.label.trim() : null;
274
+ const rawJob = opts.jobName !== undefined ? opts.jobName : (process.env.VIBER_JOB_NAME ?? null);
275
+ const jobName = rawJob?.trim() ? rawJob.trim() : null;
276
+ let startTime: number | null = null;
277
+ let lastConversation: string | null | undefined;
278
+ let ended = false;
279
+ let timer: unknown;
280
+
281
+ const build = (state: PresenceState, reason: PresenceEndReason | null): PresenceRecord => ({
282
+ schema_version: PRESENCE_SCHEMA_VERSION,
283
+ instance_id: opts.instanceId.toLowerCase(),
284
+ pid,
285
+ label,
286
+ project_id: opts.projectId ?? null,
287
+ job_name: jobName,
288
+ start_time_ms: startTime,
289
+ created_at: createdAt,
290
+ written_at: now(),
291
+ conversation_id: opts.getConversationId(),
292
+ state,
293
+ ended_reason: reason,
294
+ });
295
+
296
+ const write = (state: PresenceState, reason: PresenceEndReason | null, attempts = 1): void => {
297
+ const record = build(state, reason);
298
+ for (let i = 1; i <= attempts; i++) {
299
+ try {
300
+ writePresenceRecord(path, record);
301
+ lastConversation = record.conversation_id;
302
+ return;
303
+ } catch (err) {
304
+ // A rename can fail transiently on Windows (antivirus, a concurrent reader).
305
+ if (i === attempts) log(`[presence] write failed (${path}): ${String(err)}\n`);
306
+ }
307
+ }
308
+ };
309
+
310
+ const stop = (): void => {
311
+ if (timer !== undefined) clearTimer(timer);
312
+ timer = undefined;
313
+ };
314
+
315
+ write("active", null);
316
+ timer = setTimer(() => {
317
+ if (ended) return;
318
+ if (opts.getConversationId() !== lastConversation) write("active", null);
319
+ }, opts.refreshMs ?? PRESENCE_REFRESH_MS);
320
+
321
+ // A record without a start time is never vouched for, so its agent would stay
322
+ // paused (step-06) for good: the probe is RETRIED until it answers (Opus, step-03).
323
+ const probe = opts.probeStartTime ?? ((p: number) => processStartTimeAsync(p));
324
+ const retryDelays = opts.probeRetryDelaysMs ?? PROBE_RETRY_DELAYS_MS;
325
+ const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => {
326
+ const t = setTimeout(r, ms);
327
+ (t as { unref?: () => void }).unref?.();
328
+ }));
329
+ const ready = (async () => {
330
+ for (let attempt = 0; !ended; attempt++) {
331
+ let ms: number | undefined;
332
+ try {
333
+ ms = await probe(pid);
334
+ } catch (err) {
335
+ log(`[presence] start-time probe failed: ${String(err)}\n`);
336
+ }
337
+ if (ms !== undefined) {
338
+ startTime = ms;
339
+ if (!ended) write("active", null);
340
+ return;
341
+ }
342
+ if (attempt === 0) log("[presence] start time unknown — retrying; the runner will not vouch for this record until then\n");
343
+ await sleep(retryDelays[Math.min(attempt, retryDelays.length - 1)] as number);
344
+ }
345
+ })();
346
+
347
+ return {
348
+ markEnded: (reason: PresenceEndReason) => {
349
+ if (ended) return;
350
+ ended = true;
351
+ stop();
352
+ // The last write of this life: one retry is worth it.
353
+ write("ended", reason, 2);
354
+ },
355
+ stop,
356
+ ready,
357
+ };
358
+ }
@@ -22,8 +22,8 @@
22
22
  * Returning `undefined` anywhere keeps the caller's conservative answer, but it is now
23
23
  * the exception rather than the rule off Windows.
24
24
  */
25
- import { execSync } from "node:child_process";
26
- import { readFileSync } from "node:fs";
25
+ import { exec, execSync } from "./hidden_proc.js";
26
+ import { readdirSync, readFileSync } from "node:fs";
27
27
 
28
28
  /**
29
29
  * Filesystem mtimes and OS start-times do not share a clock granularity, and a real
@@ -77,22 +77,48 @@ function powershellCommand(): string {
77
77
  : `"${OS_ROOT_AT_STARTUP}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"`;
78
78
  }
79
79
 
80
+ /**
81
+ * The ONE Windows probe command. #627 compares the start time an agent records for
82
+ * itself with the one its runner measures later, with ZERO tolerance — which is only
83
+ * sound because both come from this exact expression (see viber-gateway
84
+ * `start_time.ts` for the measurement that proved two renderings disagree).
85
+ */
86
+ function win32StartTimeCommand(pid: number): string {
87
+ return (
88
+ `${powershellCommand()} -NoProfile -Command "$p = Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}'; ` +
89
+ `if ($p) { [int64]($p.CreationDate.ToUniversalTime() - [datetime]'1970-01-01').TotalMilliseconds }"`
90
+ );
91
+ }
92
+
93
+ function parseEpochLine(out: string): number | undefined {
94
+ const line = out.split(/\r?\n/).find((l) => l.trim() !== "");
95
+ if (line === undefined) return undefined;
96
+ const ms = Number.parseInt(line.trim(), 10);
97
+ return Number.isFinite(ms) && ms > 0 ? ms : undefined;
98
+ }
99
+
80
100
  function startTimeWin32(pid: number): number | undefined {
81
101
  try {
82
- const out = execSync(
83
- `${powershellCommand()} -NoProfile -Command "$p = Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}'; ` +
84
- `if ($p) { [int64]($p.CreationDate.ToUniversalTime() - [datetime]'1970-01-01').TotalMilliseconds }"`,
85
- { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] },
102
+ return parseEpochLine(
103
+ execSync(win32StartTimeCommand(pid), {
104
+ encoding: "utf8",
105
+ timeout: 3000,
106
+ stdio: ["ignore", "pipe", "ignore"],
107
+ }),
86
108
  );
87
- const line = out.split(/\r?\n/).find((l) => l.trim() !== "");
88
- if (line === undefined) return undefined;
89
- const ms = Number.parseInt(line.trim(), 10);
90
- return Number.isFinite(ms) && ms > 0 ? ms : undefined;
91
109
  } catch {
92
110
  return undefined;
93
111
  }
94
112
  }
95
113
 
114
+ function startTimeWin32Async(pid: number): Promise<number | undefined> {
115
+ return new Promise((resolve) => {
116
+ exec(win32StartTimeCommand(pid), { encoding: "utf8", timeout: 3000 }, (err, stdout) => {
117
+ resolve(err ? undefined : parseEpochLine(stdout));
118
+ });
119
+ });
120
+ }
121
+
96
122
  /** Linux: field 22 of /proc/<pid>/stat (ticks since boot) + /proc/stat btime. */
97
123
  function startTimeLinux(pid: number): number | undefined {
98
124
  try {
@@ -154,3 +180,99 @@ export function classifyLegacyHolder(input: {
154
180
  if (startedAt > fileMtimeMs + tolerance) return "recycled";
155
181
  return "legacy-holder";
156
182
  }
183
+
184
+ /**
185
+ * Same answer as `processStartTime`, without blocking the event loop (#627). An agent
186
+ * records its own start time at startup; a synchronous PowerShell probe there would
187
+ * stall the channel for the probe's whole duration.
188
+ */
189
+ export async function processStartTimeAsync(
190
+ pid: number,
191
+ now: () => number = Date.now,
192
+ ): Promise<number | undefined> {
193
+ if (!Number.isInteger(pid) || pid <= 0) return undefined;
194
+ const raw =
195
+ process.platform === "win32"
196
+ ? await startTimeWin32Async(pid)
197
+ : process.platform === "linux"
198
+ ? startTimeLinux(pid)
199
+ : undefined;
200
+ if (raw === undefined) return undefined;
201
+ if (raw > now() + START_TIME_TOLERANCE_MS) return undefined;
202
+ return raw;
203
+ }
204
+
205
+ /**
206
+ * Every live process's start time, in ONE probe (#627 step-03). The runner checks all
207
+ * its agents' records each heartbeat; one probe per record would cost a PowerShell
208
+ * launch each.
209
+ *
210
+ * ⚠ The per-process expression is the SAME as `win32StartTimeCommand`'s, applied to
211
+ * `$_`: the runner compares the result with the record's self-measured start time with
212
+ * ZERO tolerance, which only holds if both sides are one expression.
213
+ *
214
+ * ⚠ Returns `undefined` — "the instrument did not see" — unless the enumeration
215
+ * contains THIS process with a start time (the positive control). A caller must then
216
+ * conclude nothing: an empty or partial table would read every agent as dead.
217
+ */
218
+ export async function enumerateStartTimes(
219
+ selfPid: number = process.pid,
220
+ ): Promise<Map<number, number | undefined> | undefined> {
221
+ const table =
222
+ process.platform === "win32"
223
+ ? await enumerateWin32()
224
+ : process.platform === "linux"
225
+ ? enumerateLinux()
226
+ : undefined;
227
+ if (table === undefined) return undefined;
228
+ if (table.get(selfPid) === undefined) return undefined; // control failed
229
+ return table;
230
+ }
231
+
232
+ /**
233
+ * How long one enumeration may take. The runner's heartbeat waits for it, and the
234
+ * runner goes offline after 90 s without a beat (`ONLINE_WINDOW_SECONDS`): at 30 s
235
+ * a slow PowerShell could stretch the beat to 60 s. Past this bound the beat leaves
236
+ * WITHOUT a roster ("nothing reported") rather than late (Opus, step-03 review).
237
+ */
238
+ export const ENUMERATION_TIMEOUT_MS = 10_000;
239
+
240
+ function enumerateWin32(): Promise<Map<number, number | undefined> | undefined> {
241
+ const command =
242
+ `${powershellCommand()} -NoProfile -NonInteractive -Command "Get-CimInstance Win32_Process | ForEach-Object { ` +
243
+ `$p = $_; if ($p.CreationDate) { '' + $p.ProcessId + ' ' + [int64]($p.CreationDate.ToUniversalTime() - [datetime]'1970-01-01').TotalMilliseconds } ` +
244
+ `else { '' + $p.ProcessId + ' -' } }"`;
245
+ return new Promise((resolve) => {
246
+ exec(command, { encoding: "utf8", timeout: ENUMERATION_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 }, (err, stdout) => {
247
+ if (err) return resolve(undefined);
248
+ resolve(parseStartTimeTable(stdout));
249
+ });
250
+ });
251
+ }
252
+
253
+ /** `<pid> <epoch-ms>` or `<pid> -` per line. Exported for its witness. */
254
+ export function parseStartTimeTable(text: string): Map<number, number | undefined> | undefined {
255
+ const table = new Map<number, number | undefined>();
256
+ for (const line of text.split(/\r?\n/)) {
257
+ const m = line.trim().match(/^(\d+) (\d+|-)$/);
258
+ if (!m) continue;
259
+ const pid = Number.parseInt(m[1] as string, 10);
260
+ const ms = m[2] === "-" ? undefined : Number.parseInt(m[2] as string, 10);
261
+ table.set(pid, ms !== undefined && Number.isFinite(ms) && ms > 0 ? ms : undefined);
262
+ }
263
+ return table.size === 0 ? undefined : table;
264
+ }
265
+
266
+ function enumerateLinux(): Map<number, number | undefined> | undefined {
267
+ try {
268
+ const table = new Map<number, number | undefined>();
269
+ for (const name of readdirSync("/proc")) {
270
+ if (!/^\d+$/.test(name)) continue;
271
+ const pid = Number.parseInt(name, 10);
272
+ table.set(pid, startTimeLinux(pid));
273
+ }
274
+ return table.size === 0 ? undefined : table;
275
+ } catch {
276
+ return undefined;
277
+ }
278
+ }
@@ -85,9 +85,9 @@ function sleep(ms: number): Promise<void> {
85
85
  return new Promise((resolve) => setTimeout(resolve, ms));
86
86
  }
87
87
 
88
- function writeRunnerJson(
88
+ export function writeRunnerJson(
89
89
  cwd: string,
90
- data: { baseUrl: string; runnerId: string; runnerToken: string; machineFingerprint: string },
90
+ data: { baseUrl: string; identityBaseUrl: string; runnerId: string; runnerToken: string; machineFingerprint: string },
91
91
  ): string {
92
92
  const viberDir = join(cwd, ".viber");
93
93
  mkdirSync(viberDir, { recursive: true });
@@ -96,6 +96,10 @@ function writeRunnerJson(
96
96
  const payload = {
97
97
  schema_version: 1,
98
98
  base_url: data.baseUrl,
99
+ // #627: the WEB host the machine enrolled on, verbatim — the backend identity its
100
+ // agents hash their presence records under. `base_url` may be the announced API
101
+ // host (#480) and is not an identity.
102
+ identity_base_url: data.identityBaseUrl,
99
103
  runner_id: data.runnerId,
100
104
  // The bearer — a local-code-execution capability. Stored client-side (the
101
105
  // server keeps only its SHA-256). We request owner-only perms below, but note
@@ -184,12 +188,13 @@ export async function runRunnerEnroll(
184
188
  body: JSON.stringify({ wait_token: waitToken }),
185
189
  });
186
190
  if (resp.status === 200) {
187
- const data = (await resp.json().catch(() => ({}))) as { runner_id?: string };
191
+ const data = (await resp.json().catch(() => ({}))) as { runner_id?: string; api_base_url?: string };
188
192
  if (!data.runner_id || typeof data.runner_id !== "string") {
189
193
  throw new Error("Consume succeeded but returned no runner_id — server response malformed.");
190
194
  }
191
195
  const path = writeRunnerJson(cwd, {
192
- baseUrl,
196
+ baseUrl: runnerBaseUrl(baseUrl, data.api_base_url),
197
+ identityBaseUrl: baseUrl,
193
198
  runnerId: data.runner_id,
194
199
  runnerToken,
195
200
  machineFingerprint: fingerprint,
@@ -208,3 +213,20 @@ export async function runRunnerEnroll(
208
213
  }
209
214
  throw new Error(`Enrollment timed out after ${MAX_POLL_DURATION_MS / 60_000} minutes.`);
210
215
  }
216
+
217
+ /**
218
+ * #632: the host the runner TALKS to. The enrollment URL is the WEB host, which sits behind
219
+ * Cloudflare Access and answers the runner with a 302 to a login page (measured in qa, #613).
220
+ * The server announces its agent API host; take it when it is a trusted Viber origin, else keep
221
+ * the web host (an older server announces nothing, and that is the pre-#632 behaviour).
222
+ */
223
+ export function runnerBaseUrl(webBaseUrl: string, announced: string | undefined): string {
224
+ if (typeof announced !== "string") return webBaseUrl;
225
+ let origin: string;
226
+ try {
227
+ origin = new URL(announced).origin;
228
+ } catch {
229
+ return webBaseUrl;
230
+ }
231
+ return isTrustedViberOrigin(origin) ? origin : webBaseUrl;
232
+ }