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.
- package/lib/agent_tools.ts +18 -1
- package/lib/bridge_core.ts +76 -17
- package/lib/bridge_spawn.ts +1 -1
- package/lib/control_stream.ts +3 -2
- package/lib/coverage_watch.ts +244 -0
- package/lib/fingerprint.ts +1 -1
- package/lib/hidden_proc.ts +99 -0
- package/lib/parent_watchdog.ts +1 -1
- package/lib/presence_record.ts +358 -0
- package/lib/process_start.ts +132 -10
- package/lib/runner_enroll.ts +26 -4
- package/lib/runner_exec.ts +229 -7
- package/lib/runner_registry.ts +413 -0
- package/lib/runner_relaunch.ts +181 -0
- package/lib/runner_roster.ts +521 -0
- package/lib/runner_stream.ts +66 -35
- package/lib/urls.ts +3 -1
- package/package.json +1 -1
- package/viber-channel.ts +65 -25
- package/viber-codex-bridge.ts +43 -9
|
@@ -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
|
+
}
|
package/lib/process_start.ts
CHANGED
|
@@ -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 "
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
+
}
|
package/lib/runner_enroll.ts
CHANGED
|
@@ -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
|
+
}
|