@gleapai/kai-bridge 0.2.9 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/runner/acp-runner.mjs +69 -10
- package/runner/lib/acp/mapper.mjs +31 -1
- package/runner/lib/contract.mjs +21 -2
- package/runner/personas/claude/kai-verifier.md +84 -0
- package/runner/personas/codex/kai-verifier.md +84 -0
- package/runner/tools/verify-mcp.mjs +442 -0
- package/src/api.mjs +102 -1
- package/src/companions.mjs +101 -0
- package/src/daemon.mjs +1502 -147
- package/src/deps.mjs +113 -0
- package/src/executor.mjs +16 -4
- package/src/ports.mjs +134 -0
- package/src/preview-errors.mjs +283 -0
- package/src/preview-login.mjs +610 -0
- package/src/preview.mjs +777 -106
- package/src/selfupdate.mjs +26 -0
- package/src/service.mjs +11 -0
- package/src/setup.mjs +30 -2
- package/src/verify.mjs +387 -0
- package/src/workspace.mjs +60 -1
package/src/daemon.mjs
CHANGED
|
@@ -11,9 +11,15 @@
|
|
|
11
11
|
// bridge.repo.clone { commandId, remote, name }
|
|
12
12
|
// bridge.profile.login{ commandId, profileId }
|
|
13
13
|
// bridge.rescan {}
|
|
14
|
+
// bridge.verify.login.start { commandId, requestId, sessionId, repoKey, previewNeeded } — headed sign-in window (see preview-login.mjs)
|
|
15
|
+
// bridge.verify.login.done { commandId, requestId } — "Mark done": export now
|
|
16
|
+
// bridge.verify.login.cancel { commandId, requestId }
|
|
17
|
+
// bridge.verify.evidence.retry { commandId?, turnId } — re-upload a kept evidence dir → PUT …/verification/artifacts
|
|
18
|
+
// bridge.session.close { sessionId } — stops the preview, aborts the session's running turns
|
|
14
19
|
|
|
15
20
|
import { spawn } from "node:child_process";
|
|
16
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { execFile } from "node:child_process";
|
|
17
23
|
import { join, resolve as resolvePath } from "node:path";
|
|
18
24
|
import { homedir, platform } from "node:os";
|
|
19
25
|
|
|
@@ -23,16 +29,54 @@ import { KAI_HOME, defaultConfig, loadConfig, saveConfig } from "./config.mjs";
|
|
|
23
29
|
import { runTurn } from "./executor.mjs";
|
|
24
30
|
import { createManagedProfile, describeProfiles, managedConfigDir, ambientConfigDir, openLoginTerminal, probeUsageLimits } from "./profiles.mjs";
|
|
25
31
|
import { defaultRoots, groupByRepo, preferredCloneRoot, scanRoots, toDeviceRepoReport } from "./repos.mjs";
|
|
26
|
-
import { discardChanges, collectChanges, commitAndPush, copyPrimaryEnvFiles, currentBranch, materializeBinding, sessionSlug, worktreePath, ensureCommitExcludes } from "./workspace.mjs";
|
|
27
|
-
import { ServiceRunner, detectDevConfig, previewMcpServer, readDevConfig } from "./preview.mjs";
|
|
32
|
+
import { describeBranchChanges, discardChanges, collectChanges, commitAndPush, copyPrimaryEnvFiles, currentBranch, currentHead, materializeBinding, sessionSlug, worktreePath, ensureCommitExcludes } from "./workspace.mjs";
|
|
33
|
+
import { ServiceRunner, detectDevConfig, effectiveReload, ensurePreviewBrowser, isPortListening, previewMcpServer, readDevConfig } from "./preview.mjs";
|
|
34
|
+
import { ARTIFACT_MAX_AGE_MS, buildVerificationPayload, createStageHeartbeat, deriveApiAuthHeader, redactJsonlFile, sweepArtifacts, sweepOldArtifacts, unionArtifacts, writeSecretsFile } from "./verify.mjs";
|
|
35
|
+
import { PreviewError, envRedactionValues, previewErrorPayload, toPreviewErrorPayload } from "./preview-errors.mjs";
|
|
36
|
+
import { buildCloneCommand, collectCompanions, prefersSsh, resolveCompanionRemote } from "./companions.mjs";
|
|
37
|
+
import { establishedConnections } from "./ports.mjs";
|
|
38
|
+
import {
|
|
39
|
+
buildOriginMap,
|
|
40
|
+
captureLogin,
|
|
41
|
+
displayAvailable,
|
|
42
|
+
filterStorageState,
|
|
43
|
+
invertOriginMap,
|
|
44
|
+
launchOptionsFor,
|
|
45
|
+
loadPlaywright,
|
|
46
|
+
mergeFinalState,
|
|
47
|
+
normalizeOrigin,
|
|
48
|
+
parseStorageStateOutput,
|
|
49
|
+
preferredStablePort,
|
|
50
|
+
probeLogin,
|
|
51
|
+
redactDeep,
|
|
52
|
+
redactionSet,
|
|
53
|
+
rewriteStorageState,
|
|
54
|
+
storageStateIsEmpty,
|
|
55
|
+
} from "./preview-login.mjs";
|
|
28
56
|
import { describeHarnesses, installHarness, probeHarnessAuth } from "./harnesses.mjs";
|
|
29
57
|
import { probeHarnessModels } from "./models.mjs";
|
|
30
|
-
import { decideUpdate, fetchLatestVersion, installVersion, installedVersion, isNewer } from "./selfupdate.mjs";
|
|
58
|
+
import { decideRestart, decideUpdate, fetchLatestVersion, installVersion, installedVersion, installedVersionOrNull, isNewer } from "./selfupdate.mjs";
|
|
31
59
|
import { dirname } from "node:path";
|
|
32
60
|
import { fileURLToPath } from "node:url";
|
|
33
61
|
|
|
62
|
+
const PROBE_TIMEOUT_COLD_BOOT_MS = 60_000;
|
|
63
|
+
const CLONE_TIMEOUT_MS = 3 * 60_000;
|
|
64
|
+
const COMPANION_MAX_DEPTH = 3;
|
|
34
65
|
const RUNNER_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "runner");
|
|
35
66
|
|
|
67
|
+
/** A captured landing URL moved onto the current origin map (null when its origin is not running). */
|
|
68
|
+
function rewriteUrl(url, originMap) {
|
|
69
|
+
const origin = normalizeOrigin(url);
|
|
70
|
+
const to = origin && originMap ? originMap.get(origin) : null;
|
|
71
|
+
if (!to) return null;
|
|
72
|
+
try {
|
|
73
|
+
const u = new URL(String(url));
|
|
74
|
+
return `${to}${u.pathname}${u.search}${u.hash}`;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
36
80
|
/** Lock paths held by daemons in THIS process (cross-process uses the file). */
|
|
37
81
|
const HELD_LOCKS = new Set();
|
|
38
82
|
const REALTIME_RETRY_MS = 15_000;
|
|
@@ -123,6 +167,11 @@ export class BridgeDaemon {
|
|
|
123
167
|
this.updateAvailable = null; // newer registry version, when one exists
|
|
124
168
|
this.updateError = null; // why the last self-update did not apply
|
|
125
169
|
this.updating = false; // npm is replacing our files — refuse new turns
|
|
170
|
+
this.logins = new Map(); // requestId → { sessionId, repoKey, handle } — open sign-in windows (preview-login.mjs)
|
|
171
|
+
this.verifyLocks = new Map(); // `${userId}:${repoKey}` → promise chain (one probe/refresh of a sign-in at a time)
|
|
172
|
+
this.previewStatusListeners = new Map(); // sessionId → (message) => void — the booting heartbeat of a verify turn
|
|
173
|
+
this.unknownDevKeysLogged = new Set(); // "<repo>:<key>" — unknown dev.yaml keys are logged once
|
|
174
|
+
this.restartPending = false; // files on disk are a different version than the one we loaded; restart when idle
|
|
126
175
|
}
|
|
127
176
|
|
|
128
177
|
async scanRepos() {
|
|
@@ -148,6 +197,10 @@ export class BridgeDaemon {
|
|
|
148
197
|
updateAvailable: this.updateAvailable || null,
|
|
149
198
|
updateError: this.updateError || null,
|
|
150
199
|
autoUpdate: this.config.autoUpdate !== false,
|
|
200
|
+
// Verify sign-in: can this machine open a headed Chrome window, and
|
|
201
|
+
// which sign-in requests are open right now (replayed after sleep).
|
|
202
|
+
capabilities: { display: this.displayAvailable() },
|
|
203
|
+
activeLogins: [...this.logins.keys()],
|
|
151
204
|
});
|
|
152
205
|
}
|
|
153
206
|
|
|
@@ -166,6 +219,12 @@ export class BridgeDaemon {
|
|
|
166
219
|
// to report its turns. Tell the server before doing anything else,
|
|
167
220
|
// so those sessions settle instead of spinning.
|
|
168
221
|
await this.reportInterruptedTurns();
|
|
222
|
+
// Dev servers a killed daemon left behind would hold the declared ports
|
|
223
|
+
// (→ port_busy on the next preview) — end them; then drop evidence dirs
|
|
224
|
+
// nobody retried for two days.
|
|
225
|
+
this.killOrphanedServices();
|
|
226
|
+
const swept = sweepOldArtifacts(join(this.kaiHome, "artifacts"), { maxAgeMs: ARTIFACT_MAX_AGE_MS });
|
|
227
|
+
if (swept.length) this.log("info", "artifacts.swept", { count: swept.length });
|
|
169
228
|
await this.scanRepos();
|
|
170
229
|
// The first hello must not kill the daemon: the server may be
|
|
171
230
|
// restarting (deploys, local nodemon) — retry with backoff instead
|
|
@@ -231,6 +290,18 @@ export class BridgeDaemon {
|
|
|
231
290
|
if (this.stopped || this.updating) return;
|
|
232
291
|
// Tests and embedded hosts (the Desktop app ships its own copy).
|
|
233
292
|
if (process.env.KAI_BRIDGE_NO_SELF_UPDATE === "1") return;
|
|
293
|
+
// Someone ran `npm i -g` (or an older daemon's update landed) without
|
|
294
|
+
// restarting us: the files on disk are not the code we run. Restart
|
|
295
|
+
// into them as soon as no turn is running — BEFORE asking the registry,
|
|
296
|
+
// which needs network this check must not depend on.
|
|
297
|
+
const onDisk = installedVersionOrNull();
|
|
298
|
+
const restart = decideRestart({ loaded: VERSION, installed: onDisk, running: this.running.size + this.logins.size + this.services.size });
|
|
299
|
+
this.restartPending = restart.action === "defer";
|
|
300
|
+
if (restart.action === "restart") {
|
|
301
|
+
this.log("info", "update.restart.stale", { loaded: VERSION, installed: onDisk });
|
|
302
|
+
this.restartForUpdate();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
234
305
|
let latest;
|
|
235
306
|
try {
|
|
236
307
|
latest = await fetchLatestVersion();
|
|
@@ -242,7 +313,10 @@ export class BridgeDaemon {
|
|
|
242
313
|
const decision = decideUpdate({
|
|
243
314
|
current: VERSION,
|
|
244
315
|
latest,
|
|
245
|
-
|
|
316
|
+
// An open sign-in window is work in flight too: replacing our files
|
|
317
|
+
// under a headed Chrome the user is typing into loses the capture —
|
|
318
|
+
// and so is a live preview someone may be looking at right now.
|
|
319
|
+
running: this.running.size + this.logins.size + this.services.size,
|
|
246
320
|
autoUpdate: this.config.autoUpdate !== false,
|
|
247
321
|
lastAttempt: this.config.selfUpdate?.lastAttempt ?? null,
|
|
248
322
|
});
|
|
@@ -278,12 +352,12 @@ export class BridgeDaemon {
|
|
|
278
352
|
return;
|
|
279
353
|
}
|
|
280
354
|
this.log("info", "update.installed", { version, restarting: true });
|
|
281
|
-
this.restartForUpdate();
|
|
355
|
+
await this.restartForUpdate();
|
|
282
356
|
}
|
|
283
357
|
|
|
284
358
|
/** Hand the machine to the new version: the service restarts us. */
|
|
285
|
-
restartForUpdate() {
|
|
286
|
-
this.stop();
|
|
359
|
+
async restartForUpdate() {
|
|
360
|
+
await this.stop();
|
|
287
361
|
if (platform() === "win32") {
|
|
288
362
|
// Task Scheduler only starts at logon — respawn ourselves instead.
|
|
289
363
|
const child = spawn(process.execPath, [process.argv[1], "start"], { detached: true, stdio: "ignore", windowsHide: true });
|
|
@@ -372,6 +446,13 @@ export class BridgeDaemon {
|
|
|
372
446
|
}
|
|
373
447
|
}
|
|
374
448
|
|
|
449
|
+
/**
|
|
450
|
+
* Shut down: abort turns, stop every preview (telling the dashboard why —
|
|
451
|
+
* `stopped` / `daemon_restarted`, so the card offers Start instead of
|
|
452
|
+
* showing dead links), close sign-in windows. Resolves once the preview
|
|
453
|
+
* reports have been given a bounded chance to land (the self-update
|
|
454
|
+
* restart awaits it; sync callers may ignore the promise).
|
|
455
|
+
*/
|
|
375
456
|
stop() {
|
|
376
457
|
this.stopped = true;
|
|
377
458
|
clearInterval(this.heartbeat);
|
|
@@ -380,9 +461,18 @@ export class BridgeDaemon {
|
|
|
380
461
|
clearInterval(this.updateTimer);
|
|
381
462
|
if (this.realtimeRetry) clearTimeout(this.realtimeRetry);
|
|
382
463
|
for (const entry of this.running.values()) entry.ctrl.abort();
|
|
383
|
-
|
|
464
|
+
const reports = [];
|
|
465
|
+
for (const [sessionId, runner] of this.services) {
|
|
466
|
+
runner.stopAll();
|
|
467
|
+
this.clearPreviewIdleTimer(sessionId);
|
|
468
|
+
reports.push(this.api.sessionPreview(sessionId, { status: "stopped", urls: [], previews: [], errorCode: "daemon_restarted", error: "kai-bridge restarted — start the preview again." }).catch(() => {}));
|
|
469
|
+
}
|
|
470
|
+
this.services.clear();
|
|
471
|
+
this.writePreviewPids([]);
|
|
472
|
+
void this.teardownLogins(() => true, "daemon stopping");
|
|
384
473
|
this.realtime?.disconnect?.();
|
|
385
474
|
this.releaseLock();
|
|
475
|
+
return Promise.race([Promise.allSettled(reports), new Promise((r) => setTimeout(r, 3_000).unref?.())]).then(() => undefined);
|
|
386
476
|
}
|
|
387
477
|
|
|
388
478
|
// ── single instance ────────────────────────────────────────────────
|
|
@@ -437,38 +527,57 @@ export class BridgeDaemon {
|
|
|
437
527
|
return join(this.kaiHome, "state", "inflight.json");
|
|
438
528
|
}
|
|
439
529
|
|
|
530
|
+
/** `[{ turnId, sessionId?, agent?, artifactsDir? }]` — pre-0.5.0 files held bare ids. */
|
|
440
531
|
readInflight() {
|
|
441
532
|
try {
|
|
442
|
-
|
|
533
|
+
const raw = JSON.parse(readFileSync(this.inflightPath, "utf8"));
|
|
534
|
+
return (Array.isArray(raw) ? raw : []).map((e) => (typeof e === "string" ? { turnId: e } : e)).filter((e) => e && typeof e.turnId === "string");
|
|
443
535
|
} catch {
|
|
444
536
|
return [];
|
|
445
537
|
}
|
|
446
538
|
}
|
|
447
539
|
|
|
448
|
-
writeInflight(
|
|
540
|
+
writeInflight(entries) {
|
|
449
541
|
try {
|
|
450
542
|
mkdirSync(join(this.kaiHome, "state"), { recursive: true });
|
|
451
|
-
writeFileSync(this.inflightPath, JSON.stringify(
|
|
543
|
+
writeFileSync(this.inflightPath, JSON.stringify(entries));
|
|
452
544
|
} catch {
|
|
453
545
|
/* best effort */
|
|
454
546
|
}
|
|
455
547
|
}
|
|
456
548
|
|
|
457
|
-
rememberInflight(turnId) {
|
|
458
|
-
|
|
549
|
+
rememberInflight(turnId, meta = {}) {
|
|
550
|
+
const rest = this.readInflight().filter((e) => e.turnId !== turnId);
|
|
551
|
+
this.writeInflight([...rest, { turnId, ...meta }]);
|
|
459
552
|
}
|
|
460
553
|
|
|
461
554
|
forgetInflight(turnId) {
|
|
462
|
-
this.writeInflight(this.readInflight().filter((
|
|
555
|
+
this.writeInflight(this.readInflight().filter((e) => e.turnId !== turnId));
|
|
463
556
|
}
|
|
464
557
|
|
|
465
|
-
/**
|
|
558
|
+
/**
|
|
559
|
+
* Turns this machine was running when it was killed — report them dead.
|
|
560
|
+
* A verify turn also gets a `blocked` report ("kai-bridge restarted
|
|
561
|
+
* mid-run") carrying whatever evidence the browser had written, so the
|
|
562
|
+
* card shows the partial recording instead of spinning.
|
|
563
|
+
*/
|
|
466
564
|
async reportInterruptedTurns() {
|
|
467
|
-
const
|
|
468
|
-
if (!
|
|
565
|
+
const entries = this.readInflight();
|
|
566
|
+
if (!entries.length) return;
|
|
469
567
|
this.writeInflight([]);
|
|
470
|
-
for (const
|
|
471
|
-
|
|
568
|
+
for (const entry of entries) {
|
|
569
|
+
const { turnId } = entry;
|
|
570
|
+
this.log("warn", "turn.interrupted", { turnId, agent: entry.agent });
|
|
571
|
+
if (entry.agent === "kai-verifier") {
|
|
572
|
+
const uploads = entry.artifactsDir ? await this.uploadSweptEvidence(turnId, entry.artifactsDir) : { uploads: [], failed: 0 };
|
|
573
|
+
await this.api
|
|
574
|
+
.turnVerification(turnId, {
|
|
575
|
+
...buildVerificationPayload(null, uploads.uploads, { fallbackReason: "kai-bridge restarted mid-run", evidence: { failed: uploads.failed, ...(uploads.failed && entry.artifactsDir ? { kept: entry.artifactsDir } : {}) } }),
|
|
576
|
+
blockedCode: "other",
|
|
577
|
+
})
|
|
578
|
+
.catch((err) => this.log("warn", "verify.interrupted.report.failed", { turnId, error: err?.message }));
|
|
579
|
+
if (entry.artifactsDir && uploads.failed === 0) rmSync(entry.artifactsDir, { recursive: true, force: true });
|
|
580
|
+
}
|
|
472
581
|
await this.api
|
|
473
582
|
.turnResult(turnId, {
|
|
474
583
|
status: "failed",
|
|
@@ -478,6 +587,70 @@ export class BridgeDaemon {
|
|
|
478
587
|
}
|
|
479
588
|
}
|
|
480
589
|
|
|
590
|
+
// ── preview processes across restarts ─────────────────────────────
|
|
591
|
+
get previewPidsPath() {
|
|
592
|
+
return join(this.kaiHome, "state", "preview-pids.json");
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
readPreviewPids() {
|
|
596
|
+
try {
|
|
597
|
+
const raw = JSON.parse(readFileSync(this.previewPidsPath, "utf8"));
|
|
598
|
+
return Array.isArray(raw) ? raw.filter((e) => e && Number.isInteger(e.pid)) : [];
|
|
599
|
+
} catch {
|
|
600
|
+
return [];
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
writePreviewPids(entries) {
|
|
605
|
+
try {
|
|
606
|
+
mkdirSync(join(this.kaiHome, "state"), { recursive: true });
|
|
607
|
+
writeFileSync(this.previewPidsPath, JSON.stringify(entries));
|
|
608
|
+
} catch {
|
|
609
|
+
/* best effort */
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** ServiceRunner hook: every dev-server pid is persisted while it lives. */
|
|
614
|
+
trackServicePid(sessionId, name, pid, op) {
|
|
615
|
+
if (!Number.isInteger(pid)) return;
|
|
616
|
+
const rest = this.readPreviewPids().filter((e) => e.pid !== pid);
|
|
617
|
+
this.writePreviewPids(op === "add" ? [...rest, { pid, name, sessionId, startedAt: new Date().toISOString() }] : rest);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Dev servers from a previous daemon life (crash, kill -9) are still
|
|
622
|
+
* bound to their ports: end their process groups (Windows: the tree) and
|
|
623
|
+
* forget them. Pids are checked for liveness first; a reused pid that no
|
|
624
|
+
* longer looks like our service is left alone.
|
|
625
|
+
*/
|
|
626
|
+
killOrphanedServices({ kill = process.kill, platform = process.platform } = {}) {
|
|
627
|
+
const entries = this.readPreviewPids();
|
|
628
|
+
this.writePreviewPids([]);
|
|
629
|
+
let killed = 0;
|
|
630
|
+
for (const e of entries) {
|
|
631
|
+
try {
|
|
632
|
+
kill(e.pid, 0); // alive?
|
|
633
|
+
} catch {
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
try {
|
|
637
|
+
if (platform === "win32") spawn("taskkill", ["/PID", String(e.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }).on("error", () => {});
|
|
638
|
+
else {
|
|
639
|
+
try {
|
|
640
|
+
kill(-e.pid, "SIGTERM");
|
|
641
|
+
} catch {
|
|
642
|
+
kill(e.pid, "SIGTERM");
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
killed += 1;
|
|
646
|
+
this.log("warn", "preview.orphan.killed", { pid: e.pid, name: e.name, sessionId: e.sessionId });
|
|
647
|
+
} catch {
|
|
648
|
+
/* already gone */
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return killed;
|
|
652
|
+
}
|
|
653
|
+
|
|
481
654
|
/**
|
|
482
655
|
* The realtime client retries a refused connection forever, but two
|
|
483
656
|
* states are terminal: six failed reconnects of an ESTABLISHED socket
|
|
@@ -528,11 +701,27 @@ export class BridgeDaemon {
|
|
|
528
701
|
}
|
|
529
702
|
try {
|
|
530
703
|
const pending = await this.api.pendingTurns();
|
|
704
|
+
// Cancels the server settled while we were away (reaper / watchdog):
|
|
705
|
+
// abort the orphaned run instead of reporting into a turn that ended,
|
|
706
|
+
// and never "recover" a turn that was cancelled in the same breath.
|
|
707
|
+
const cancelled = new Set((pending?.cancelledTurnIds || []).map(String));
|
|
708
|
+
for (const turnId of cancelled) {
|
|
709
|
+
const run = this.running.get(turnId);
|
|
710
|
+
if (!run) continue;
|
|
711
|
+
this.log("info", "turn.cancel.replayed", { turnId });
|
|
712
|
+
run.ctrl.abort();
|
|
713
|
+
}
|
|
531
714
|
for (const turn of pending?.turns || []) {
|
|
532
|
-
if (this.running.has(turn.turnId)) continue;
|
|
715
|
+
if (this.running.has(turn.turnId) || cancelled.has(String(turn.turnId))) continue;
|
|
533
716
|
this.log("info", "turn.recovered", { turnId: turn.turnId });
|
|
534
717
|
void this.startTurn(turn).catch((err) => this.log("error", "turn.recover.failed", { error: err.message }));
|
|
535
718
|
}
|
|
719
|
+
// Sign-in requests published while we were away: open their windows now.
|
|
720
|
+
for (const req of pending?.loginRequests || []) {
|
|
721
|
+
if (!req?.requestId || this.logins.has(req.requestId)) continue;
|
|
722
|
+
this.log("info", "verify.login.recovered", { requestId: req.requestId });
|
|
723
|
+
void this.loginStart(req).catch((err) => this.log("error", "verify.login.recover.failed", { error: err.message }));
|
|
724
|
+
}
|
|
536
725
|
} catch (err) {
|
|
537
726
|
// Older server without the endpoint: the server-side sweep still
|
|
538
727
|
// fails orphaned turns, so this is a nice-to-have, not a must.
|
|
@@ -612,11 +801,35 @@ export class BridgeDaemon {
|
|
|
612
801
|
case "bridge.harness.install":
|
|
613
802
|
return this.harnessInstall(data);
|
|
614
803
|
case "bridge.session.close":
|
|
804
|
+
// A closed session has no turn left to run: abort ours for it (the
|
|
805
|
+
// Server has already settled them; the runner just stops burning).
|
|
806
|
+
for (const [turnId, entry] of this.running) {
|
|
807
|
+
if (entry.sessionId === data.sessionId) {
|
|
808
|
+
this.log("info", "turn.abort.session_closed", { turnId, sessionId: data.sessionId });
|
|
809
|
+
entry.ctrl.abort();
|
|
810
|
+
}
|
|
811
|
+
}
|
|
615
812
|
this.services.get(data.sessionId)?.stopAll();
|
|
616
813
|
this.services.delete(data.sessionId);
|
|
617
814
|
this.clearPreviewIdleTimer(data.sessionId);
|
|
815
|
+
// A sign-in window opened for this session has nothing to sign in to any more.
|
|
816
|
+
await this.teardownLogins((l) => l.sessionId === data.sessionId, "session closed");
|
|
817
|
+
// Pre-0.4.0 daemons kept a browser profile per session; drop leftovers.
|
|
818
|
+
rmSync(join(this.kaiHome, "browser", String(data.sessionId).replace(/[^\w.-]/g, "_")), { recursive: true, force: true });
|
|
618
819
|
return;
|
|
820
|
+
case "bridge.verify.login.start":
|
|
821
|
+
return this.loginStart(data);
|
|
822
|
+
case "bridge.verify.login.done":
|
|
823
|
+
return this.loginDone(data);
|
|
824
|
+
case "bridge.verify.login.cancel":
|
|
825
|
+
return this.loginCancel(data);
|
|
826
|
+
case "bridge.verify.evidence.retry":
|
|
827
|
+
return this.retryEvidence(data);
|
|
619
828
|
case "bridge.preview.start":
|
|
829
|
+
// A preview the USER started stays up until they stop it (or it
|
|
830
|
+
// idles out); one that Verify booted is released after the run.
|
|
831
|
+
(this.manualPreviews ??= new Set()).add(data.sessionId);
|
|
832
|
+
this.verifyOwnedPreviews?.delete(data.sessionId);
|
|
620
833
|
return this.previewStart(data);
|
|
621
834
|
case "bridge.preview.stop":
|
|
622
835
|
return this.previewStop(data);
|
|
@@ -636,148 +849,270 @@ export class BridgeDaemon {
|
|
|
636
849
|
* services inside the session's EXISTING worktrees. Resolves paths by
|
|
637
850
|
* existence only — materializing checkouts is the turn path's job, so
|
|
638
851
|
* a pruned worktree is an error, not a re-clone.
|
|
852
|
+
*
|
|
853
|
+
* Every failure is a structured error (`previewErrorPayload`): code +
|
|
854
|
+
* kind + repo/service + detail, classified from the whole log tail BEFORE
|
|
855
|
+
* truncation. `companionRemotes` (Server-resolved `{ key: remote }`) is
|
|
856
|
+
* how non-github companions get cloned; `note` survives onto error writes.
|
|
639
857
|
*/
|
|
640
|
-
async previewStart({ sessionId, title, repos }) {
|
|
641
|
-
|
|
642
|
-
|
|
858
|
+
async previewStart({ sessionId, title, repos, companionRemotes = null }) {
|
|
859
|
+
let lastNote = null;
|
|
860
|
+
const skipped = [];
|
|
861
|
+
// Every report is also RETURNED: the verify turn boots the preview
|
|
862
|
+
// through this same path and needs the final status + preview URLs.
|
|
863
|
+
const report = async (payload) => {
|
|
864
|
+
if (payload.note) lastNote = payload.note;
|
|
865
|
+
const body = payload.status === "error" ? { ...payload, urls: payload.urls ?? [], previews: payload.previews ?? [], ...(lastNote && !payload.note ? { note: lastNote } : {}), ...(skipped.length && !payload.skipped ? { skipped } : {}) } : payload;
|
|
866
|
+
await this.api.sessionPreview(sessionId, body).catch((err) => this.log("warn", "preview.report.failed", { error: err.message }));
|
|
867
|
+
return body;
|
|
868
|
+
};
|
|
869
|
+
const fail = (err, ctx = {}) => report(toPreviewErrorPayload(err, { ...ctx, skipped }));
|
|
643
870
|
await report({ status: "starting" });
|
|
871
|
+
let runner = null;
|
|
644
872
|
try {
|
|
645
|
-
// Pass 1 — resolve every session repo (checkout, env, config)
|
|
646
|
-
// collect the companion repos their configs ask for, before booting
|
|
647
|
-
// anything: companions must boot FIRST so `${port:x}` cross-refs
|
|
648
|
-
// resolve in the shared per-session port map.
|
|
873
|
+
// Pass 1 — resolve every session repo (checkout, env, config).
|
|
649
874
|
const resolved = [];
|
|
650
|
-
const companionWants = new Map(); // repo key → { optional }
|
|
651
875
|
for (const r of repos || []) {
|
|
652
876
|
const group = this.repoGroups.find((g) => g.key === r.key);
|
|
653
877
|
if (!group) {
|
|
654
|
-
|
|
655
|
-
return;
|
|
878
|
+
return fail(new PreviewError(`Repository ${r.key} is not checked out on this device.`, { code: "companion_missing", repo: r.key }), { repo: r.key });
|
|
656
879
|
}
|
|
657
880
|
const mode = r.mode || this.config.repoModes?.[r.key] || "worktree";
|
|
658
|
-
const cwd = mode === "local" ? group.primary.path :
|
|
659
|
-
if (!existsSync(cwd)) {
|
|
660
|
-
|
|
661
|
-
status: "error",
|
|
662
|
-
error: `The workspace for this session is gone on this device — send Kai a message to recreate it, then start the preview again.`,
|
|
663
|
-
});
|
|
664
|
-
return;
|
|
881
|
+
const cwd = mode === "local" ? group.primary.path : this.findSessionWorktree(group.name, sessionId, title);
|
|
882
|
+
if (!cwd || !existsSync(cwd)) {
|
|
883
|
+
return fail(new PreviewError(`The workspace for this session is gone on this device — send Kai a message to recreate it, then start the preview again.`, { code: "other", repo: r.key }), { repo: r.key });
|
|
665
884
|
}
|
|
666
885
|
// Gitignored env files never reach a fresh worktree — copy the
|
|
667
886
|
// primary checkout's so env-dependent apps (the dashboard itself)
|
|
668
|
-
// don't boot blank
|
|
887
|
+
// don't boot blank (per service cwd too — ServiceRunner). Never
|
|
888
|
+
// overwrites existing files.
|
|
669
889
|
if (mode !== "local") {
|
|
670
890
|
const copied = copyPrimaryEnvFiles(group.primary.path, cwd);
|
|
671
891
|
if (copied.length) this.log("info", "preview.env.copied", { cwd, copied });
|
|
672
892
|
}
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
// heuristic. Closes the verified-before-config-merge window.
|
|
678
|
-
const config =
|
|
679
|
-
readDevConfig(cwd) ??
|
|
680
|
-
(cwd !== group.primary.path ? readDevConfig(group.primary.path) : null) ??
|
|
681
|
-
detectDevConfig(cwd);
|
|
682
|
-
if (!config) continue;
|
|
683
|
-
if (config.error) {
|
|
684
|
-
await report({ status: "error", error: config.error });
|
|
685
|
-
return;
|
|
893
|
+
const config = this.resolveDevConfig(cwd, group, r.key);
|
|
894
|
+
if (!config) {
|
|
895
|
+
skipped.push({ repo: r.key, reason: "no_dev_config" });
|
|
896
|
+
continue;
|
|
686
897
|
}
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
// Required by any config → required overall.
|
|
691
|
-
companionWants.set(c.repo, { optional: (prev ? prev.optional : true) && c.optional });
|
|
898
|
+
if (config.needsConfig) {
|
|
899
|
+
skipped.push({ repo: r.key, reason: "monorepo", hint: config.hint });
|
|
900
|
+
continue;
|
|
692
901
|
}
|
|
693
|
-
|
|
902
|
+
if (config.error) return fail(new PreviewError(config.error, { code: "no_dev_config", repo: r.key }), { repo: r.key });
|
|
903
|
+
resolved.push({ key: r.key, cwd, config, mode, envSource: mode === "local" ? null : group.primary.path });
|
|
694
904
|
}
|
|
695
905
|
if (!resolved.length) {
|
|
696
|
-
|
|
697
|
-
return
|
|
906
|
+
const mono = skipped.find((sk) => sk.reason === "monorepo");
|
|
907
|
+
return fail(
|
|
908
|
+
new PreviewError(
|
|
909
|
+
mono ? `${mono.repo} is a ${mono.hint} — add .gleap/dev.yaml (services + preview) so Kai knows which app to run.` : "No dev config found — add .gleap/dev.yaml to the repo (services + preview).",
|
|
910
|
+
{ code: "no_dev_config", repo: skipped[0]?.repo, hint: mono?.hint },
|
|
911
|
+
),
|
|
912
|
+
);
|
|
698
913
|
}
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
//
|
|
702
|
-
//
|
|
914
|
+
if (skipped.length) await report({ status: "starting", note: `Skipped ${skipped.map((sk) => `${sk.repo} (${sk.reason === "monorepo" ? sk.hint : "no dev config"})`).join(", ")}.` });
|
|
915
|
+
|
|
916
|
+
// Pass 2 — companions (BFS, depth ≤ 3): clone when missing (remote
|
|
917
|
+
// from the Server, never guessed for non-github), read their config
|
|
918
|
+
// from the PRIMARY checkout; a companion without a config is
|
|
919
|
+
// `companion_unconfigured` (once — no retry loop).
|
|
920
|
+
const remotes = companionRemotes && typeof companionRemotes === "object" ? companionRemotes : {};
|
|
921
|
+
const { companions } = await collectCompanions({
|
|
922
|
+
roots: resolved.map((r) => ({ key: r.key, config: r.config })),
|
|
923
|
+
maxDepth: COMPANION_MAX_DEPTH,
|
|
924
|
+
loadConfig: async (key) => {
|
|
925
|
+
try {
|
|
926
|
+
const group = await this.ensureCompanionCheckout(key, report, { remotes });
|
|
927
|
+
const config = this.resolveDevConfig(group.primary.path, group, key);
|
|
928
|
+
if (!config || config.needsConfig) return { group, unconfigured: true, hint: config?.hint ?? null };
|
|
929
|
+
if (config.error) return { group, error: config.error };
|
|
930
|
+
return { group, config };
|
|
931
|
+
} catch (err) {
|
|
932
|
+
return { failure: err };
|
|
933
|
+
}
|
|
934
|
+
},
|
|
935
|
+
});
|
|
936
|
+
for (const c of companions) {
|
|
937
|
+
if (c.config) continue;
|
|
938
|
+
if (c.optional) {
|
|
939
|
+
this.log("warn", "preview.companion.skipped", { sessionId, repo: c.key, reason: c.failure?.message || (c.unconfigured ? "unconfigured" : c.error) });
|
|
940
|
+
skipped.push({ repo: c.key, reason: c.unconfigured ? "no_dev_config" : "not_on_device" });
|
|
941
|
+
continue;
|
|
942
|
+
}
|
|
943
|
+
if (c.failure) return fail(c.failure, { repo: c.key });
|
|
944
|
+
if (c.unconfigured) {
|
|
945
|
+
return fail(new PreviewError(`Companion ${c.key} has no .gleap/dev.yaml${c.hint ? ` (${c.hint})` : ""} — configure it so its services can run alongside.`, { code: "companion_unconfigured", repo: c.key, hint: c.hint }), { repo: c.key });
|
|
946
|
+
}
|
|
947
|
+
return fail(new PreviewError(`Companion ${c.key}: ${c.error}`, { code: "no_dev_config", repo: c.key }), { repo: c.key });
|
|
948
|
+
}
|
|
949
|
+
const bootable = companions.filter((c) => c.config);
|
|
950
|
+
|
|
951
|
+
runner = this.runnerFor(sessionId);
|
|
952
|
+
// Pass 3 — ports for EVERY config (companions + session repos) before
|
|
953
|
+
// any boot, so `${port:x}` cross-references resolve whatever the order.
|
|
954
|
+
for (const c of bootable) await runner.assignPorts(c.group.primary.path, c.config, { mode: "local", repoKey: c.key });
|
|
955
|
+
for (const r of resolved) await runner.assignPorts(r.cwd, r.config, { mode: r.mode, repoKey: r.key, envSource: r.envSource });
|
|
956
|
+
|
|
957
|
+
// Pass 4 — boot companions (deepest first, local mode: an already
|
|
958
|
+
// running dev server of that repo is adopted), then the session repos
|
|
959
|
+
// (worktree mode boots its own copy: it must serve the changed code).
|
|
703
960
|
const companionPreviews = [];
|
|
704
|
-
for (const
|
|
961
|
+
for (const c of bootable) {
|
|
705
962
|
try {
|
|
706
|
-
const
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
if (started.preview) companionPreviews.push({ repo: key, ...started.preview });
|
|
963
|
+
const started = await this.bootRepoWithConfig(runner, c.group.primary.path, c.config, "local", c.key);
|
|
964
|
+
if (started.preview) {
|
|
965
|
+
const { adoptedCwd, ...preview } = started.preview;
|
|
966
|
+
companionPreviews.push({ repo: c.key, role: "companion", ...preview, ...this.describeCheckout(adoptedCwd || c.group.primary.path) });
|
|
967
|
+
}
|
|
712
968
|
} catch (err) {
|
|
713
|
-
if (optional) {
|
|
714
|
-
this.log("warn", "preview.companion.skipped", { sessionId, repo: key, error: err.message });
|
|
969
|
+
if (c.optional) {
|
|
970
|
+
this.log("warn", "preview.companion.skipped", { sessionId, repo: c.key, error: err.message });
|
|
971
|
+
skipped.push({ repo: c.key, reason: "not_on_device" });
|
|
715
972
|
continue;
|
|
716
973
|
}
|
|
717
|
-
|
|
718
|
-
this.services.delete(sessionId);
|
|
719
|
-
await report({ status: "error", error: `Companion ${key}: ${err.message}` });
|
|
720
|
-
return;
|
|
974
|
+
throw err;
|
|
721
975
|
}
|
|
722
976
|
}
|
|
723
|
-
// Pass 3 — the session repos themselves (worktree mode boots its
|
|
724
|
-
// own copy on a free port: it must serve the changed code).
|
|
725
977
|
const previews = [];
|
|
726
978
|
for (const { key, cwd, config, mode } of resolved) {
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
runner.stopAll();
|
|
732
|
-
this.services.delete(sessionId);
|
|
733
|
-
await report({ status: "error", error: err.message });
|
|
734
|
-
return;
|
|
979
|
+
const started = await this.bootRepoWithConfig(runner, cwd, config, mode, key);
|
|
980
|
+
if (started.preview) {
|
|
981
|
+
const { adoptedCwd, ...preview } = started.preview;
|
|
982
|
+
previews.push({ repo: key, ...preview, ...this.describeCheckout(adoptedCwd || cwd) });
|
|
735
983
|
}
|
|
736
984
|
}
|
|
985
|
+
const urls = [...previews, ...companionPreviews];
|
|
986
|
+
// The landing page is a session repo's preview (never a companion's).
|
|
987
|
+
const landing = previews[0] ?? companionPreviews[0] ?? null;
|
|
737
988
|
this.armPreviewIdleTimer(sessionId);
|
|
738
|
-
|
|
989
|
+
return report({ status: "running", previews: urls, urls, landingUrl: landing?.url ?? null, ...(skipped.length ? { skipped } : {}) });
|
|
739
990
|
} catch (err) {
|
|
740
|
-
this.log("error", "preview.start.failed", { sessionId, error: err.message });
|
|
741
|
-
|
|
991
|
+
this.log("error", "preview.start.failed", { sessionId, error: err.message, code: err?.code });
|
|
992
|
+
runner?.stopAll();
|
|
993
|
+
this.services.delete(sessionId);
|
|
994
|
+
this.clearPreviewIdleTimer(sessionId);
|
|
995
|
+
return fail(err);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Config resolution order: this worktree's own config, then the PRIMARY
|
|
1001
|
+
* checkout's (a repo verified by the setup agent has its dev.yaml on an
|
|
1002
|
+
* unmerged config PR — session worktrees cut from the base branch don't
|
|
1003
|
+
* carry it yet), then the package.json heuristic (which answers
|
|
1004
|
+
* `{ needsConfig }` for workspace roots). Unknown dev.yaml keys are
|
|
1005
|
+
* logged once per repo. Closes the verified-before-config-merge window.
|
|
1006
|
+
*/
|
|
1007
|
+
resolveDevConfig(cwd, group, repoKey) {
|
|
1008
|
+
const config = readDevConfig(cwd) ?? (group && cwd !== group.primary.path ? readDevConfig(group.primary.path) : null) ?? detectDevConfig(cwd);
|
|
1009
|
+
// Lazily created: embedded hosts (and tests) build a daemon without
|
|
1010
|
+
// running the constructor — a missing bookkeeping map must never turn
|
|
1011
|
+
// a bootable preview into "Cannot read properties of undefined".
|
|
1012
|
+
this.unknownDevKeysLogged ??= new Set();
|
|
1013
|
+
for (const key of config?.unknownKeys || []) {
|
|
1014
|
+
const id = `${repoKey}:${key}`;
|
|
1015
|
+
if (this.unknownDevKeysLogged.has(id)) continue;
|
|
1016
|
+
this.unknownDevKeysLogged.add(id);
|
|
1017
|
+
this.log("warn", "preview.config.unknown_key", { repo: repoKey, key });
|
|
1018
|
+
}
|
|
1019
|
+
return config;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/** `{ branch, commit, dirty }` of a checkout for the preview's `urls[]` entries (best-effort). */
|
|
1023
|
+
describeCheckout(cwd) {
|
|
1024
|
+
const out = {};
|
|
1025
|
+
try {
|
|
1026
|
+
const branch = currentBranch(cwd);
|
|
1027
|
+
if (branch) out.branch = branch;
|
|
1028
|
+
const commit = currentHead(cwd);
|
|
1029
|
+
if (commit) out.commit = commit;
|
|
1030
|
+
out.dirty = collectChanges(cwd).files.length;
|
|
1031
|
+
} catch {
|
|
1032
|
+
/* not a git checkout */
|
|
1033
|
+
}
|
|
1034
|
+
return out;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* The session's worktree for a repo: the exact slug when the title is
|
|
1039
|
+
* known; otherwise (sign-in commands carry only the session id) the one
|
|
1040
|
+
* directory under `~/.kai/worktrees/<repo>/` that ends in the session's
|
|
1041
|
+
* id suffix — slugs are `<title>-<last 8 of the id>`, unique per session.
|
|
1042
|
+
*/
|
|
1043
|
+
findSessionWorktree(repoName, sessionId, title) {
|
|
1044
|
+
const exact = worktreePath(this.kaiHome, repoName, sessionSlug(sessionId, title));
|
|
1045
|
+
if (existsSync(exact)) return exact;
|
|
1046
|
+
const suffix = `-${String(sessionId || "").slice(-8)}`;
|
|
1047
|
+
if (suffix.length < 2) return exact;
|
|
1048
|
+
try {
|
|
1049
|
+
const dir = join(this.kaiHome, "worktrees", repoName);
|
|
1050
|
+
const hits = readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory() && d.name.endsWith(suffix));
|
|
1051
|
+
return hits.length === 1 ? join(dir, hits[0].name) : exact;
|
|
1052
|
+
} catch {
|
|
1053
|
+
return exact;
|
|
742
1054
|
}
|
|
743
1055
|
}
|
|
744
1056
|
|
|
745
1057
|
/**
|
|
746
1058
|
* Boot one repo's services with an already-resolved config; throws a
|
|
747
|
-
*
|
|
1059
|
+
* `PreviewError` carrying the classified log line + code when a service
|
|
748
1060
|
* never becomes ready — a "running" preview must not 404.
|
|
749
1061
|
*/
|
|
750
|
-
async bootRepoWithConfig(runner, cwd, config, mode) {
|
|
751
|
-
const started = await runner.start(cwd, config, { mode });
|
|
1062
|
+
async bootRepoWithConfig(runner, cwd, config, mode, repoKey = null, opts = {}) {
|
|
1063
|
+
const started = await runner.start(cwd, config, { mode, repoKey, ...opts });
|
|
752
1064
|
const dead = started.services.find((s) => !s.adopted && !s.ready);
|
|
753
1065
|
if (dead) {
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
1066
|
+
throw new PreviewError(`${dead.name} did not start${dead.error ? ` — ${dead.error}` : ""}`, {
|
|
1067
|
+
code: dead.errorCode || "service_crashed",
|
|
1068
|
+
repo: repoKey,
|
|
1069
|
+
service: dead.name,
|
|
1070
|
+
detail: { ...(dead.errorDetail || {}), logPath: dead.logPath },
|
|
1071
|
+
});
|
|
760
1072
|
}
|
|
761
1073
|
return started;
|
|
762
1074
|
}
|
|
763
1075
|
|
|
764
1076
|
/**
|
|
765
|
-
* A companion repo must exist on this machine. Cloned → its group.
|
|
766
|
-
*
|
|
767
|
-
*
|
|
1077
|
+
* A companion repo must exist on this machine. Cloned → its group. Not
|
|
1078
|
+
* cloned → clone it (remote from the Server's `companionRemotes`, or the
|
|
1079
|
+
* key itself for github.com; SSH when the machine's checkouts use SSH)
|
|
1080
|
+
* into the preferred root and rescan. Without a remote the daemon
|
|
1081
|
+
* cannot guess → `companion_missing`; a failed clone →
|
|
1082
|
+
* `companion_clone_failed` with the exact command to run by hand.
|
|
768
1083
|
*/
|
|
769
|
-
async ensureCompanionCheckout(key, report) {
|
|
1084
|
+
async ensureCompanionCheckout(key, report, { remotes = {} } = {}) {
|
|
770
1085
|
let group = this.repoGroups.find((g) => g.key === key);
|
|
771
1086
|
if (group) return group;
|
|
772
|
-
const
|
|
773
|
-
|
|
774
|
-
|
|
1087
|
+
const name = String(key).split("/").pop();
|
|
1088
|
+
const remote = resolveCompanionRemote({ key, remotes, useSsh: prefersSsh(this.repoGroups) });
|
|
1089
|
+
if (!remote || !name) {
|
|
1090
|
+
throw new PreviewError(`Companion ${key} is not cloned on this device and Gleap knows no remote for it — clone it, then start the preview again.`, {
|
|
1091
|
+
code: "companion_missing",
|
|
1092
|
+
repo: key,
|
|
1093
|
+
detail: { remote: remote ?? null },
|
|
1094
|
+
});
|
|
775
1095
|
}
|
|
776
1096
|
await report({ status: "starting", error: null, note: `Cloning ${name}…` });
|
|
777
|
-
this.log("info", "preview.companion.clone", { key });
|
|
778
|
-
|
|
1097
|
+
this.log("info", "preview.companion.clone", { key, remote });
|
|
1098
|
+
let target = null;
|
|
1099
|
+
try {
|
|
1100
|
+
target = await this.cloneRepo({ remote, name, timeoutMs: CLONE_TIMEOUT_MS });
|
|
1101
|
+
} catch (err) {
|
|
1102
|
+
throw new PreviewError(`Companion ${key} could not be cloned: ${err.message}`, {
|
|
1103
|
+
code: "companion_clone_failed",
|
|
1104
|
+
repo: key,
|
|
1105
|
+
detail: { remote, cloneCommand: buildCloneCommand({ remote, target: err.target || target || name }) },
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
779
1108
|
group = this.repoGroups.find((g) => g.key === key);
|
|
780
|
-
if (!group)
|
|
1109
|
+
if (!group) {
|
|
1110
|
+
throw new PreviewError(`Companion ${key}: the clone finished but the repository was not discovered — its remote resolves to a different key. Point Gleap at the checkout (Locate…).`, {
|
|
1111
|
+
code: "companion_missing",
|
|
1112
|
+
repo: key,
|
|
1113
|
+
detail: { remote, cloneCommand: buildCloneCommand({ remote, target }) },
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
781
1116
|
return group;
|
|
782
1117
|
}
|
|
783
1118
|
|
|
@@ -785,26 +1120,43 @@ export class BridgeDaemon {
|
|
|
785
1120
|
* Previews are for looking at, not for hosting: stop everything after
|
|
786
1121
|
* 30 idle minutes (Lukas 08-26 — was 4h). Re-armed on every (re)start,
|
|
787
1122
|
* cleared on manual stop and session close. Turns never boot services
|
|
788
|
-
* themselves — they only reuse this runner while it is alive.
|
|
1123
|
+
* themselves — they only reuse this runner while it is alive. "Idle"
|
|
1124
|
+
* also means nobody is connected: established connections on a service
|
|
1125
|
+
* port (a browser tab left open, the phone) re-arm the timer.
|
|
789
1126
|
*/
|
|
790
1127
|
armPreviewIdleTimer(sessionId) {
|
|
791
1128
|
this.previewIdleTimers ??= new Map();
|
|
792
1129
|
this.clearPreviewIdleTimer(sessionId);
|
|
793
|
-
const t = setTimeout(() => {
|
|
1130
|
+
const t = setTimeout(async () => {
|
|
794
1131
|
this.previewIdleTimers.delete(sessionId);
|
|
795
1132
|
const runner = this.services.get(sessionId);
|
|
796
1133
|
if (!runner) return;
|
|
1134
|
+
if (await this.previewHasConnections(runner)) {
|
|
1135
|
+
this.log("info", "preview.idle.connected", { sessionId });
|
|
1136
|
+
if (this.services.get(sessionId) === runner) this.armPreviewIdleTimer(sessionId);
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
797
1139
|
runner.stopAll();
|
|
798
1140
|
this.services.delete(sessionId);
|
|
799
1141
|
this.log("info", "preview.idle.stopped", { sessionId });
|
|
800
1142
|
this.api
|
|
801
|
-
.sessionPreview(sessionId, { status: "stopped", error: "Stopped automatically after 30 minutes." })
|
|
1143
|
+
.sessionPreview(sessionId, { status: "stopped", urls: [], previews: [], errorCode: "idle_stopped", error: "Stopped automatically after 30 minutes." })
|
|
802
1144
|
.catch((err) => this.log("warn", "preview.report.failed", { error: err.message }));
|
|
803
1145
|
}, this.previewIdleMs ?? 30 * 60 * 1000);
|
|
804
1146
|
t.unref?.();
|
|
805
1147
|
this.previewIdleTimers.set(sessionId, t);
|
|
806
1148
|
}
|
|
807
1149
|
|
|
1150
|
+
/** Any ESTABLISHED connection on one of the runner's (non-adopted) ports? Injectable for tests. */
|
|
1151
|
+
async previewHasConnections(runner) {
|
|
1152
|
+
const count = this.countConnections ?? ((port) => establishedConnections(port));
|
|
1153
|
+
for (const [name, port] of Object.entries(runner.ports || {})) {
|
|
1154
|
+
if (runner.adopted?.has(name)) continue;
|
|
1155
|
+
if ((await count(port).catch(() => 0)) > 0) return true;
|
|
1156
|
+
}
|
|
1157
|
+
return false;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
808
1160
|
clearPreviewIdleTimer(sessionId) {
|
|
809
1161
|
const t = this.previewIdleTimers?.get(sessionId);
|
|
810
1162
|
if (t) clearTimeout(t);
|
|
@@ -812,17 +1164,63 @@ export class BridgeDaemon {
|
|
|
812
1164
|
}
|
|
813
1165
|
|
|
814
1166
|
async previewStop({ sessionId }) {
|
|
1167
|
+
this.manualPreviews?.delete(sessionId);
|
|
1168
|
+
this.verifyOwnedPreviews?.delete(sessionId);
|
|
1169
|
+
// A sign-in window for this session has nothing left to sign in to.
|
|
1170
|
+
await this.teardownLogins((l) => l.sessionId === sessionId, "preview stopped");
|
|
815
1171
|
this.services.get(sessionId)?.stopAll();
|
|
816
1172
|
this.services.delete(sessionId);
|
|
817
1173
|
this.clearPreviewIdleTimer(sessionId);
|
|
818
|
-
await this.api.sessionPreview(sessionId, { status: "stopped" }).catch((err) => this.log("warn", "preview.report.failed", { error: err.message }));
|
|
1174
|
+
await this.api.sessionPreview(sessionId, { status: "stopped", urls: [], previews: [] }).catch((err) => this.log("warn", "preview.report.failed", { error: err.message }));
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* A service that had become ready died on its own (crash on the first
|
|
1179
|
+
* real request, OOM, the user killed it): the preview is broken NOW. Stop
|
|
1180
|
+
* the rest, drop the runner (Retry reboots cleanly) and tell the
|
|
1181
|
+
* dashboard why — with the classified log line.
|
|
1182
|
+
*/
|
|
1183
|
+
onServiceDied(sessionId, info) {
|
|
1184
|
+
const runner = this.services.get(sessionId);
|
|
1185
|
+
if (!runner) return Promise.resolve();
|
|
1186
|
+
this.log("warn", "preview.service.died", { sessionId, name: info.name, code: info.code, errorCode: info.errorCode });
|
|
1187
|
+
runner.stopAll();
|
|
1188
|
+
this.services.delete(sessionId);
|
|
1189
|
+
this.clearPreviewIdleTimer(sessionId);
|
|
1190
|
+
return this.api
|
|
1191
|
+
.sessionPreview(
|
|
1192
|
+
sessionId,
|
|
1193
|
+
previewErrorPayload({
|
|
1194
|
+
code: info.errorCode === "service_crashed" || !info.errorCode ? "service_crashed" : info.errorCode,
|
|
1195
|
+
error: `${info.name} stopped: ${info.error}`,
|
|
1196
|
+
repo: info.repoKey,
|
|
1197
|
+
service: info.name,
|
|
1198
|
+
detail: { ...(info.detail || {}), logPath: info.logPath, exitCode: info.code },
|
|
1199
|
+
}),
|
|
1200
|
+
)
|
|
1201
|
+
.catch((err) => this.log("warn", "preview.report.failed", { error: err.message }));
|
|
819
1202
|
}
|
|
820
1203
|
|
|
821
1204
|
/** One ServiceRunner per session — created only by the on-demand preview; turns reuse it. */
|
|
822
|
-
runnerFor(sessionId
|
|
1205
|
+
runnerFor(sessionId) {
|
|
823
1206
|
let runner = this.services.get(sessionId);
|
|
824
1207
|
if (!runner) {
|
|
825
|
-
|
|
1208
|
+
// Stable ports (hash of repo + service in 43000-43999, when free) keep
|
|
1209
|
+
// a saved sign-in's origins matching from one session to the next.
|
|
1210
|
+
runner = new ServiceRunner({
|
|
1211
|
+
kaiHome: this.kaiHome,
|
|
1212
|
+
sessionId,
|
|
1213
|
+
log: this.log,
|
|
1214
|
+
onStatus: (message) => {
|
|
1215
|
+
this.log("info", "preview.status", { sessionId, message });
|
|
1216
|
+
this.previewStatusListeners?.get(sessionId)?.(message);
|
|
1217
|
+
},
|
|
1218
|
+
preferredPort: ({ repoKey, service }) => preferredStablePort(repoKey, service),
|
|
1219
|
+
...(this.describeListener ? { describeListener: this.describeListener } : {}),
|
|
1220
|
+
...(this.previewSettleMs != null ? { settleMs: this.previewSettleMs } : {}),
|
|
1221
|
+
onServiceExit: (info) => this.onServiceDied(sessionId, info),
|
|
1222
|
+
onProcess: (name, pid, op) => this.trackServicePid(sessionId, name, pid, op),
|
|
1223
|
+
});
|
|
826
1224
|
this.services.set(sessionId, runner);
|
|
827
1225
|
}
|
|
828
1226
|
return runner;
|
|
@@ -843,6 +1241,7 @@ export class BridgeDaemon {
|
|
|
843
1241
|
title: turn.title,
|
|
844
1242
|
});
|
|
845
1243
|
bound.push({ key: r.key, ...ws });
|
|
1244
|
+
if (ws.deps) this.log("info", "deps.seed", { repo: r.key, ...ws.deps });
|
|
846
1245
|
// Remember the choice per repo (the UI asks once, then sticks).
|
|
847
1246
|
this.config.repoModes = { ...(this.config.repoModes || {}), [r.key]: mode };
|
|
848
1247
|
}
|
|
@@ -901,11 +1300,16 @@ export class BridgeDaemon {
|
|
|
901
1300
|
const { turnId } = turn;
|
|
902
1301
|
if (this.running.has(turnId)) return;
|
|
903
1302
|
const ctrl = new AbortController();
|
|
904
|
-
const entry = { ctrl, control: null };
|
|
1303
|
+
const entry = { ctrl, control: null, sessionId: turn.sessionId };
|
|
905
1304
|
this.running.set(turnId, entry);
|
|
906
1305
|
const releaseAwake = keepAwake();
|
|
907
1306
|
let outcome = null;
|
|
908
|
-
|
|
1307
|
+
// Verify turns (`agent: kai-verifier`): preview booted up front, browser
|
|
1308
|
+
// evidence collected into an artifacts dir, report uploaded after the
|
|
1309
|
+
// turn — see prepareVerifyTurn / finishVerification.
|
|
1310
|
+
const isVerify = turn.agent === "kai-verifier";
|
|
1311
|
+
let verify = null;
|
|
1312
|
+
this.rememberInflight(turnId, { sessionId: turn.sessionId, agent: turn.agent ?? null });
|
|
909
1313
|
const batcher = createEventBatcher({ api: this.api, turnId, onError: (err) => this.log("warn", "events.post.failed", { error: err.message }) });
|
|
910
1314
|
try {
|
|
911
1315
|
const profile = resolveProfiles(this.config, this.kaiHome).find((p) => p.id === turn.profileId) ?? { id: "gleap-key", kind: "gleap-key", harness: turn.harness };
|
|
@@ -915,73 +1319,143 @@ export class BridgeDaemon {
|
|
|
915
1319
|
// local paths — the prompt lists them.
|
|
916
1320
|
const workDir = bound[0]?.cwd;
|
|
917
1321
|
if (!workDir) throw new Error("Turn has no repositories.");
|
|
918
|
-
const repoNote = this.repoBrief(turn, bound);
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
1322
|
+
const repoNote = isVerify ? this.verifyRepoBrief(bound) : this.repoBrief(turn, bound);
|
|
1323
|
+
let previewNote = "";
|
|
1324
|
+
let mcpServers = turn.mcpServers;
|
|
1325
|
+
if (isVerify) {
|
|
1326
|
+
verify = await this.prepareVerifyTurn(turn, bound);
|
|
1327
|
+
if (!verify.ok) {
|
|
1328
|
+
// Nothing to test against (preview dead) or a sign-in wall the
|
|
1329
|
+
// user must clear first: file a blocked report and close the turn
|
|
1330
|
+
// cleanly without spending an agent run.
|
|
1331
|
+
await this.api
|
|
1332
|
+
.turnVerification(turnId, { ...buildVerificationPayload(null, [], { fallbackReason: verify.error }), ...(verify.blocked || {}) })
|
|
1333
|
+
.catch((err) => this.log("warn", "verify.report.failed", { turnId, error: err.message }));
|
|
1334
|
+
outcome = {
|
|
1335
|
+
status: "completed",
|
|
1336
|
+
result: null,
|
|
1337
|
+
changes: bound.map((b) => ({ key: b.key, mode: b.mode, branch: b.branch, base: b.base, cwd: b.cwd, files: [], push: false })),
|
|
1338
|
+
profileId: profile.id,
|
|
1339
|
+
};
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
previewNote = verify.note;
|
|
1343
|
+
// The evidence dir is where a restart mid-run finds partial footage.
|
|
1344
|
+
this.rememberInflight(turnId, { sessionId: turn.sessionId, agent: turn.agent, artifactsDir: verify.artifactsDir });
|
|
1345
|
+
mcpServers = [
|
|
1346
|
+
...(turn.mcpServers || []),
|
|
1347
|
+
previewMcpServer(RUNNER_DIR, {
|
|
1348
|
+
outputDir: verify.artifactsDir,
|
|
1349
|
+
// `storage` = browser_storage_state for the final sign-in refresh;
|
|
1350
|
+
// restoring an arbitrary state file is never the agent's call.
|
|
1351
|
+
caps: ["devtools", "testing", "storage"],
|
|
1352
|
+
secretsFile: verify.secretsFile,
|
|
1353
|
+
storageStateFile: verify.storageStateFile,
|
|
1354
|
+
allowedOrigins: verify.storageStateFile ? verify.allowedOrigins : null,
|
|
1355
|
+
disabledTools: ["browser_set_storage_state"],
|
|
1356
|
+
ignoreHttpsErrors: !!verify.ignoreHttpsErrors,
|
|
1357
|
+
}),
|
|
1358
|
+
];
|
|
1359
|
+
await this.postVerifyStage(turnId, "verifying");
|
|
1360
|
+
} else {
|
|
1361
|
+
// Previews are manual-only (dashboard "Start preview") — a turn
|
|
1362
|
+
// never boots dev servers on its own. When the user already has a
|
|
1363
|
+
// preview running for this session, describe it to the agent and
|
|
1364
|
+
// hand it the Playwright MCP so it can verify in a real browser.
|
|
1365
|
+
const live = await this.describeLivePreview(turn, bound, batcher);
|
|
1366
|
+
previewNote = live.note;
|
|
1367
|
+
if (live.hasLivePreview) mcpServers = [...(turn.mcpServers || []), previewMcpServer(RUNNER_DIR)];
|
|
1368
|
+
}
|
|
925
1369
|
const res = await runTurn({
|
|
926
1370
|
turn: { ...turn, task: `${turn.task}${repoNote}${previewNote}`, mcpServers },
|
|
927
1371
|
profile,
|
|
928
1372
|
workDir,
|
|
929
1373
|
kaiHome: this.kaiHome,
|
|
1374
|
+
// The verify MCP's request policy (KAI_VERIFY_*) — host-only, never in the prompt.
|
|
1375
|
+
extraEnv: verify?.env ?? null,
|
|
930
1376
|
signal: ctrl.signal,
|
|
931
1377
|
onSpawn: (handle) => {
|
|
932
1378
|
entry.control = handle.control;
|
|
933
1379
|
},
|
|
934
|
-
onEvent: (ev) =>
|
|
1380
|
+
onEvent: (ev) => {
|
|
1381
|
+
if (verify) {
|
|
1382
|
+
// The tester is using the preview — it must not idle-stop
|
|
1383
|
+
// under it. And the report carries LOCAL paths: keep it here,
|
|
1384
|
+
// the uploaded version goes out after the turn.
|
|
1385
|
+
this.armPreviewIdleTimer(turn.sessionId);
|
|
1386
|
+
if (ev?.type === "verify_report") {
|
|
1387
|
+
verify.report = ev.report && typeof ev.report === "object" ? ev.report : null;
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
// Injected sign-in values (cookies, tokens) must never reach
|
|
1391
|
+
// the Server in a tool row or snapshot.
|
|
1392
|
+
if (verify.redact?.size) ev = redactDeep(ev, verify.redact);
|
|
1393
|
+
}
|
|
1394
|
+
batcher.push(ev);
|
|
1395
|
+
},
|
|
935
1396
|
onLog: (l) => this.log("debug", "runner", { line: l.slice(0, 500) }),
|
|
936
1397
|
});
|
|
937
1398
|
await batcher.flush();
|
|
938
1399
|
const completed = !ctrl.signal.aborted && res.code === 0 && !res.rateLimited;
|
|
1400
|
+
// Plan and verify turns are read-only: worktrees are restored, local
|
|
1401
|
+
// checkouts only reported, nothing is ever committed or pushed.
|
|
1402
|
+
const readOnlyTurn = !!turn.planMode || isVerify;
|
|
939
1403
|
const changes = [...bound, ...this.adoptSessionWorktrees(turn, bound)].map((b) => {
|
|
940
1404
|
ensureCommitExcludes(b.cwd, { allowDevConfig: !!turn.allowDevConfig });
|
|
941
|
-
// A
|
|
1405
|
+
// A read-only turn must leave the worktree as it found it — see
|
|
942
1406
|
// discardChanges. Local checkouts are the user's; only report.
|
|
943
|
-
if (
|
|
1407
|
+
if (readOnlyTurn) {
|
|
944
1408
|
const leaked = b.mode === "worktree" ? discardChanges(b.cwd).discarded : collectChanges(b.cwd).files;
|
|
945
1409
|
if (leaked.length > 0) {
|
|
946
|
-
|
|
1410
|
+
const mode = turn.planMode ? "Plan mode" : "Verification";
|
|
1411
|
+
this.log("warn", `${turn.planMode ? "plan" : "verify"}.changes`, { turnId, repo: b.key, mode: b.mode, files: leaked.length, discarded: b.mode === "worktree" });
|
|
947
1412
|
batcher.push({
|
|
948
1413
|
type: "text",
|
|
949
1414
|
message:
|
|
950
1415
|
b.mode === "worktree"
|
|
951
|
-
?
|
|
952
|
-
:
|
|
1416
|
+
? `${mode} is read-only — ${leaked.length} file change${leaked.length === 1 ? "" : "s"} made during ${turn.planMode ? "planning" : "verification"} ${leaked.length === 1 ? "was" : "were"} discarded${turn.planMode ? "; the build starts from the plan" : ""}.`
|
|
1417
|
+
: `${mode} is read-only, but ${leaked.length} file change${leaked.length === 1 ? "" : "s"} landed in your local checkout of ${b.key} — review them${turn.planMode ? " before building" : ""}.`,
|
|
953
1418
|
});
|
|
954
1419
|
}
|
|
955
1420
|
}
|
|
956
1421
|
const diff = collectChanges(b.cwd);
|
|
957
1422
|
// Build turns in worktree mode publish the session branch so the
|
|
958
|
-
// Server can open the PR;
|
|
959
|
-
const shouldPush = completed && b.mode === "worktree" && !
|
|
1423
|
+
// Server can open the PR; read-only turns and local mode never push.
|
|
1424
|
+
const shouldPush = completed && b.mode === "worktree" && !readOnlyTurn && diff.files.length > 0;
|
|
960
1425
|
const push = shouldPush
|
|
961
1426
|
? commitAndPush(b.cwd, {
|
|
962
1427
|
branch: b.branch,
|
|
963
1428
|
allowDevConfig: !!turn.allowDevConfig,
|
|
964
1429
|
message: `${turn.title || "Kai Code changes"}\n\nSession ${turn.sessionId} · run on ${this.config.device?.name || "a paired device"}`,
|
|
965
1430
|
})
|
|
966
|
-
:
|
|
1431
|
+
: isVerify
|
|
1432
|
+
? false
|
|
1433
|
+
: null;
|
|
967
1434
|
// `cwd` travels with the change so the dashboard can point at
|
|
968
1435
|
// work that stayed on this machine (files but no push).
|
|
969
|
-
|
|
1436
|
+
// Pushed → the commit list + diff stat travel with the change so the
|
|
1437
|
+
// Server can write a real pull request description.
|
|
1438
|
+
const described = push?.pushed ? describeBranchChanges(b.cwd, b.base) : null;
|
|
1439
|
+
return { key: b.key, mode: b.mode, branch: b.branch, base: b.base, cwd: b.cwd, adopted: b.adopted || undefined, ...diff, push, ...(described ? described : {}) };
|
|
970
1440
|
});
|
|
971
|
-
// The
|
|
1441
|
+
// The read-only notices above were queued AFTER the post-turn
|
|
972
1442
|
// flush; land them before the result closes the turn (the Server
|
|
973
1443
|
// answers 410 for events on an ended turn).
|
|
974
1444
|
await batcher.flush();
|
|
1445
|
+
if (verify) {
|
|
1446
|
+
const finished = await this.finishVerification(turn, { ...verify, workDir, bound }, res, ctrl.signal.aborted);
|
|
1447
|
+
await this.settleVerifyPreview(turn, finished?.status).catch((err) => this.log("warn", "verify.preview.release.failed", { turnId, error: err.message }));
|
|
1448
|
+
}
|
|
975
1449
|
// Built OUTSIDE the report call: if posting the result throws, the
|
|
976
1450
|
// catch below must not turn a finished turn into a failed one. The
|
|
977
1451
|
// work is already committed and pushed at this point.
|
|
978
1452
|
outcome = {
|
|
979
1453
|
status: ctrl.signal.aborted ? "cancelled" : res.rateLimited ? "rate_limited" : res.code === 0 ? "completed" : "failed",
|
|
980
1454
|
exitCode: res.code,
|
|
981
|
-
result: res.result,
|
|
1455
|
+
result: verify?.redact?.size ? redactDeep(res.result, verify.redact) : res.result,
|
|
982
1456
|
// The runner's own failure text (e.g. the engine's usage-limit
|
|
983
1457
|
// message) — the Server prefers this over its generic fallback.
|
|
984
|
-
...(res.lastError && res.code !== 0 ? { error: res.lastError.replace(/^acp-runner: /, "").slice(0, 500) } : {}),
|
|
1458
|
+
...(res.lastError && res.code !== 0 ? { error: redactDeep(res.lastError.replace(/^acp-runner: /, "").slice(0, 500), verify?.redact) } : {}),
|
|
985
1459
|
changes,
|
|
986
1460
|
profileId: profile.id,
|
|
987
1461
|
};
|
|
@@ -1000,10 +1474,842 @@ export class BridgeDaemon {
|
|
|
1000
1474
|
})
|
|
1001
1475
|
.catch((err) => this.log("error", "result.lost", { turnId, error: err.message }));
|
|
1002
1476
|
}
|
|
1477
|
+
// Test credentials and the injected / final sign-in state live on
|
|
1478
|
+
// disk only for the duration of the turn.
|
|
1479
|
+
for (const f of [verify?.secretsFile, verify?.storageStateFile, verify?.finalStateFile]) if (f) rmSync(f, { force: true });
|
|
1480
|
+
verify?.release?.();
|
|
1003
1481
|
this.forgetInflight(turnId);
|
|
1004
1482
|
releaseAwake();
|
|
1005
1483
|
this.running.delete(turnId);
|
|
1006
|
-
if (this.updatePending && this.running.size === 0) void this.checkForUpdate();
|
|
1484
|
+
if ((this.updatePending || this.restartPending) && this.running.size === 0 && this.logins.size === 0) void this.checkForUpdate();
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
// ── verify turns ───────────────────────────────────────────────────
|
|
1489
|
+
/** Overridable seam (tests, embedded hosts): a browser the Playwright MCP can launch. */
|
|
1490
|
+
ensurePreviewBrowser() {
|
|
1491
|
+
return ensurePreviewBrowser({ runnerDir: RUNNER_DIR, log: this.log });
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
/** Seams for the sign-in flows (tests inject a fake Playwright / probe / capture). */
|
|
1495
|
+
loadPlaywright() {
|
|
1496
|
+
return loadPlaywright(RUNNER_DIR);
|
|
1497
|
+
}
|
|
1498
|
+
displayAvailable() {
|
|
1499
|
+
return displayAvailable();
|
|
1500
|
+
}
|
|
1501
|
+
probeLogin(opts) {
|
|
1502
|
+
return probeLogin(opts);
|
|
1503
|
+
}
|
|
1504
|
+
captureLogin(opts) {
|
|
1505
|
+
return captureLogin(opts);
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
/** Where a verify turn's injected sign-in state lives (0600, deleted with the turn). */
|
|
1509
|
+
loginStateDir() {
|
|
1510
|
+
return join(this.kaiHome, "state", "preview-login");
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
/** The agent's final browser-state export — inside the turn's evidence dir (the MCP's only writable root besides the repo). */
|
|
1514
|
+
finalStateFileFor(artifactsDir) {
|
|
1515
|
+
return join(artifactsDir, "state", "final-storage-state.json");
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/** Best-effort stage transition for the dashboard's Verify card (booting → verifying → saving). */
|
|
1519
|
+
async postVerifyStage(turnId, stage, note) {
|
|
1520
|
+
// The note often relays the agent's last line — markdown emphasis
|
|
1521
|
+
// ("**Confirming…**") must not reach the card as literal asterisks.
|
|
1522
|
+
const plain = typeof note === "string" ? note.replace(/[#*`>_]/g, "").replace(/\s+/g, " ").trim().slice(0, 200) : "";
|
|
1523
|
+
try {
|
|
1524
|
+
await this.api.turnVerificationStage(turnId, { stage, ...(plain ? { note: plain } : {}) });
|
|
1525
|
+
} catch (err) {
|
|
1526
|
+
this.log("debug", "verify.stage.failed", { turnId, stage, error: err?.message });
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
/**
|
|
1531
|
+
* One probe / refresh of a sign-in at a time per user + repo: two verify
|
|
1532
|
+
* turns racing the same record would both refresh it (one 409s) and
|
|
1533
|
+
* could probe a preview the other is still booting.
|
|
1534
|
+
*/
|
|
1535
|
+
withVerifyLock(key, fn) {
|
|
1536
|
+
return this.acquireVerifyLock(key).then(async (release) => {
|
|
1537
|
+
try {
|
|
1538
|
+
return await fn();
|
|
1539
|
+
} finally {
|
|
1540
|
+
release();
|
|
1541
|
+
}
|
|
1542
|
+
});
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
/**
|
|
1546
|
+
* Acquire the per user+repo verify lock; resolves with `release()`. A
|
|
1547
|
+
* turn that injected a saved sign-in keeps it until its final refresh
|
|
1548
|
+
* has landed: two agent runs presenting the same refresh token in
|
|
1549
|
+
* parallel is exactly what rotation-based IdPs revoke on.
|
|
1550
|
+
*/
|
|
1551
|
+
acquireVerifyLock(key) {
|
|
1552
|
+
this.verifyLockHolders ??= new Set(); // keys currently HELD (the map also lists waiters)
|
|
1553
|
+
this.verifyLocks ??= new Map();
|
|
1554
|
+
const prev = this.verifyLocks.get(key) ?? Promise.resolve();
|
|
1555
|
+
let release;
|
|
1556
|
+
const held = new Promise((resolve) => {
|
|
1557
|
+
let done = false;
|
|
1558
|
+
release = () => {
|
|
1559
|
+
if (done) return;
|
|
1560
|
+
done = true;
|
|
1561
|
+
this.verifyLockHolders.delete(key);
|
|
1562
|
+
resolve();
|
|
1563
|
+
};
|
|
1564
|
+
});
|
|
1565
|
+
const next = prev.catch(() => {}).then(() => held);
|
|
1566
|
+
this.verifyLocks.set(key, next);
|
|
1567
|
+
next.finally(() => {
|
|
1568
|
+
if (this.verifyLocks.get(key) === next) this.verifyLocks.delete(key);
|
|
1569
|
+
});
|
|
1570
|
+
return prev
|
|
1571
|
+
.catch(() => {})
|
|
1572
|
+
.then(() => {
|
|
1573
|
+
this.verifyLockHolders.add(key);
|
|
1574
|
+
return release;
|
|
1575
|
+
});
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
/** The verifier's repo brief: where the checkouts are, and that they are read-only. */
|
|
1579
|
+
verifyRepoBrief(bound) {
|
|
1580
|
+
const lines = ["", "", "Repositories in this session (read-only for this verification turn — you cannot commit or push, and every file change is discarded when the turn ends):"];
|
|
1581
|
+
for (const b of bound) lines.push(`- ${b.key}: ${b.cwd}${b.branch ? ` (branch ${b.branch}${b.base ? `, base ${b.base}` : ""})` : ""}`);
|
|
1582
|
+
return lines.join("\n");
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
/**
|
|
1586
|
+
* dev.yaml `auth.storageState: <command>` — run in the repo checkout,
|
|
1587
|
+
* stdout is Playwright storageState JSON (or the path of a JSON file).
|
|
1588
|
+
* Replaces the user's saved sign-in for this repo. Null on any failure.
|
|
1589
|
+
*/
|
|
1590
|
+
async storageStateFromCommand(command, cwd) {
|
|
1591
|
+
return new Promise((resolveP) => {
|
|
1592
|
+
const cb = (err, stdout) => {
|
|
1593
|
+
if (err) {
|
|
1594
|
+
this.log("warn", "verify.login.command.failed", { error: err.message });
|
|
1595
|
+
return resolveP(null);
|
|
1596
|
+
}
|
|
1597
|
+
resolveP(parseStorageStateOutput(stdout, { read: (p) => readFileSync(p, "utf8") }));
|
|
1598
|
+
};
|
|
1599
|
+
const opts = { cwd, timeout: 60_000, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, BROWSER: "none" } };
|
|
1600
|
+
if (process.platform === "darwin") execFile("/bin/zsh", ["-lc", command], opts, cb);
|
|
1601
|
+
else execFile(command, [], { ...opts, shell: true }, cb);
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
/**
|
|
1606
|
+
* Boot (or re-describe) the session's preview, make sure a browser can
|
|
1607
|
+
* launch, decide the sign-in (dev.yaml command → saved record → none),
|
|
1608
|
+
* PROBE it headless, and lay out the turn's evidence dir + state files +
|
|
1609
|
+
* optional secrets file. Resolves `{ ok: false, error, blocked? }`
|
|
1610
|
+
* (`blocked` = `{ blockedCode, loginPath?, firstTime? }` for the report)
|
|
1611
|
+
* or `{ ok: true, artifactsDir, secretsFile, storageStateFile,
|
|
1612
|
+
* finalStateFile, allowedOrigins, injected, redact, note, report: null }`.
|
|
1613
|
+
*/
|
|
1614
|
+
async prepareVerifyTurn(turn, bound = []) {
|
|
1615
|
+
const repoKeys = (turn.repos || []).map((r) => r.key).filter(Boolean).sort();
|
|
1616
|
+
const lockKey = `${turn.userId ?? turn.ownerUserId ?? "device"}:${repoKeys.join(",")}`;
|
|
1617
|
+
if (this.verifyLockHolders?.has(lockKey)) {
|
|
1618
|
+
// Another verify run of this repo holds its sign-in — say so instead
|
|
1619
|
+
// of sitting on "Starting the app…" until it finishes.
|
|
1620
|
+
this.log("info", "verify.lock.wait", { turnId: turn.turnId, lockKey });
|
|
1621
|
+
await this.postVerifyStage(turn.turnId, "booting", "Waiting for another verification of this repo on this machine to finish…");
|
|
1622
|
+
}
|
|
1623
|
+
const release = await this.acquireVerifyLock(lockKey);
|
|
1624
|
+
let result;
|
|
1625
|
+
try {
|
|
1626
|
+
result = await this.prepareVerifyTurnLocked(turn, bound);
|
|
1627
|
+
} catch (err) {
|
|
1628
|
+
release();
|
|
1629
|
+
throw err;
|
|
1630
|
+
}
|
|
1631
|
+
if (result?.ok && result.injected) {
|
|
1632
|
+
// Hold the repo's sign-in for the whole run — released by startTurn's
|
|
1633
|
+
// finally (after finishVerification refreshed the record).
|
|
1634
|
+
return { ...result, release };
|
|
1635
|
+
}
|
|
1636
|
+
release();
|
|
1637
|
+
return result;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
async prepareVerifyTurnLocked(turn, bound) {
|
|
1641
|
+
const { turnId, sessionId } = turn;
|
|
1642
|
+
// Lazy browser check — setup usually did this; a machine that skipped
|
|
1643
|
+
// it downloads Chromium now (best-effort: without a browser the agent
|
|
1644
|
+
// reports `blocked` itself).
|
|
1645
|
+
const browser = await this.ensurePreviewBrowser().catch((err) => ({ ok: false, error: err?.message }));
|
|
1646
|
+
if (!browser?.ok) {
|
|
1647
|
+
// No browser, no test — say so with the exact command (Linux/Windows
|
|
1648
|
+
// machines without Chrome and a failed Chromium download) instead of
|
|
1649
|
+
// letting the agent discover it on its first navigate.
|
|
1650
|
+
this.log("warn", "verify.browser.unavailable", { turnId, error: browser?.error });
|
|
1651
|
+
return {
|
|
1652
|
+
ok: false,
|
|
1653
|
+
error: `No browser is available on this machine for the verification: ${browser?.error || "Chromium is not installed."}${browser?.command ? ` Run \`${browser.command}\` on this machine, then retry.` : ""}`,
|
|
1654
|
+
blocked: { blockedCode: "no_browser" },
|
|
1655
|
+
};
|
|
1656
|
+
}
|
|
1657
|
+
const channel = browser?.browser === "chrome" ? "chrome" : null;
|
|
1658
|
+
// previewStart is idempotent on a live runner (booted services are
|
|
1659
|
+
// only re-described) and reports starting/running to the dashboard
|
|
1660
|
+
// exactly like the manual button. A cold boot is its own stage, and
|
|
1661
|
+
// every runner status line during it (installing, still starting,
|
|
1662
|
+
// ready) reaches the Verify card as a `booting` note — the heartbeat.
|
|
1663
|
+
const coldBoot = !this.services.has(sessionId);
|
|
1664
|
+
if (coldBoot && !this.manualPreviews?.has(sessionId)) (this.verifyOwnedPreviews ??= new Set()).add(sessionId);
|
|
1665
|
+
if (coldBoot) await this.postVerifyStage(turnId, "booting");
|
|
1666
|
+
const heartbeat = createStageHeartbeat((note) => this.postVerifyStage(turnId, "booting", note), { minIntervalMs: this.stageHeartbeatMs ?? 5_000 });
|
|
1667
|
+
this.previewStatusListeners ??= new Map();
|
|
1668
|
+
this.previewStatusListeners.set(sessionId, heartbeat.note);
|
|
1669
|
+
let preview;
|
|
1670
|
+
try {
|
|
1671
|
+
preview = await this.previewStart({ sessionId, title: turn.title, repos: turn.repos, companionRemotes: turn.companionRemotes ?? null });
|
|
1672
|
+
} finally {
|
|
1673
|
+
this.previewStatusListeners?.delete(sessionId);
|
|
1674
|
+
heartbeat.stop();
|
|
1675
|
+
}
|
|
1676
|
+
if (preview?.status !== "running") {
|
|
1677
|
+
// The preview's structured error IS the diagnosis: its code becomes the
|
|
1678
|
+
// verification's blockedCode (companion_missing, port_busy, deps_failed,
|
|
1679
|
+
// …) so the card offers the right verb; `preview_unreachable` only
|
|
1680
|
+
// when nothing more specific is known.
|
|
1681
|
+
const code = preview?.errorCode && preview.errorCode !== "other" ? preview.errorCode : "preview_unreachable";
|
|
1682
|
+
return {
|
|
1683
|
+
ok: false,
|
|
1684
|
+
error: preview?.error ? `The preview could not be started: ${preview.error}` : "The preview could not be started.",
|
|
1685
|
+
blocked: { blockedCode: code },
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1688
|
+
// After a fix turn, services that do not hot-reload (`reload: restart`,
|
|
1689
|
+
// the default for APIs) must run the fixed code before the probe.
|
|
1690
|
+
const restartNotes = await this.restartChangedServices(turn, sessionId);
|
|
1691
|
+
const artifactsDir = join(this.kaiHome, "artifacts", String(turnId).replace(/[^\w.-]/g, "_"));
|
|
1692
|
+
mkdirSync(artifactsDir, { recursive: true });
|
|
1693
|
+
let secretsFile = null;
|
|
1694
|
+
let secretNames = [];
|
|
1695
|
+
if (turn.verifySecrets && typeof turn.verifySecrets === "object") {
|
|
1696
|
+
const dir = join(this.kaiHome, "state", "verify-secrets");
|
|
1697
|
+
mkdirSync(dir, { recursive: true });
|
|
1698
|
+
const path = join(dir, `${String(turnId).replace(/[^\w.-]/g, "_")}.env`);
|
|
1699
|
+
secretNames = writeSecretsFile(path, turn.verifySecrets);
|
|
1700
|
+
if (secretNames.length > 0) secretsFile = path;
|
|
1701
|
+
}
|
|
1702
|
+
const previews = preview.previews || [];
|
|
1703
|
+
const runner = this.services.get(sessionId);
|
|
1704
|
+
const services = runner?.describeServices?.() ?? previews.map((p) => ({ name: p.name, url: p.url }));
|
|
1705
|
+
const previewOrigins = [...new Set([...services.map((s) => normalizeOrigin(s.url)), ...previews.map((p) => normalizeOrigin(p.url))].filter(Boolean))];
|
|
1706
|
+
const primaryKey = (turn.repos || [])[0]?.key;
|
|
1707
|
+
// The landing page is a session repo's web preview (an API has no UI to land on).
|
|
1708
|
+
const webPreviews = previews.filter((p) => p.kind !== "api");
|
|
1709
|
+
const landingUrl = webPreviews.find((p) => p.repo === primaryKey)?.url ?? webPreviews[0]?.url ?? previews.find((p) => p.repo === primaryKey)?.url ?? previews[0]?.url ?? services[0]?.url ?? null;
|
|
1710
|
+
|
|
1711
|
+
// ── sign-in: dev.yaml command → saved record → none ──────────────
|
|
1712
|
+
const primary = bound?.[0] ?? null;
|
|
1713
|
+
const devConfig = primary?.cwd ? readDevConfig(primary.cwd) : null;
|
|
1714
|
+
const auth = devConfig?.auth ?? null;
|
|
1715
|
+
// Every dev.yaml in the session (external origins, verify policy) — the
|
|
1716
|
+
// primary's wins on conflicts.
|
|
1717
|
+
const configs = (bound || []).map((b) => (b?.cwd ? readDevConfig(b.cwd) : null)).filter(Boolean);
|
|
1718
|
+
const external = [...new Set(configs.flatMap((c) => c.external || []))];
|
|
1719
|
+
// The state filter accepts the declared `external` origins too: the
|
|
1720
|
+
// browser is allowed to talk to them, so a token the app keeps there is
|
|
1721
|
+
// part of "signed in". Cookies stay localhost-only (filterStorageState).
|
|
1722
|
+
const stateOrigins = [...new Set([...previewOrigins, ...external])];
|
|
1723
|
+
let injected = null; // { source: "command" | "record", state, record?, originMap? }
|
|
1724
|
+
if (auth?.storageState && primary?.cwd) {
|
|
1725
|
+
const state = await this.storageStateFromCommand(auth.storageState, primary.cwd);
|
|
1726
|
+
if (state) injected = { source: "command", state: filterStorageState(state, stateOrigins).state, record: null, originMap: null };
|
|
1727
|
+
else this.log("warn", "verify.login.command.empty", { turnId });
|
|
1728
|
+
} else if (turn.loginRecordAvailable) {
|
|
1729
|
+
const record = await this.api.turnPreviewLogin(turnId).catch((err) => {
|
|
1730
|
+
this.log("warn", "verify.login.record.failed", { turnId, error: err?.message });
|
|
1731
|
+
return null;
|
|
1732
|
+
});
|
|
1733
|
+
if (record?.storageState) {
|
|
1734
|
+
const originMap = buildOriginMap(record.services, services);
|
|
1735
|
+
const state = rewriteStorageState(record.storageState, originMap);
|
|
1736
|
+
this.log("info", "verify.login.record", { turnId, version: record.version, mapped: originMap.size, cookies: state.cookies.length, origins: state.origins.length });
|
|
1737
|
+
injected = { source: "record", state, record, originMap };
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
// ── probe: does the preview consider us signed in? ───────────────
|
|
1742
|
+
// An API-only preview (no web service) has nothing to render a login
|
|
1743
|
+
// wall on — the agent's http_request answers 401/403 → needs_login.
|
|
1744
|
+
let probe = { result: "unknown", storageState: null, loginPath: null };
|
|
1745
|
+
const hasWebService = services.some((svc) => svc.kind !== "api") || webPreviews.length > 0;
|
|
1746
|
+
if (landingUrl && hasWebService) {
|
|
1747
|
+
const pw = this.loadPlaywright();
|
|
1748
|
+
probe = await this.probeLogin({
|
|
1749
|
+
pw,
|
|
1750
|
+
launchOptions: launchOptionsFor({ headless: true, browser: channel }),
|
|
1751
|
+
storageState: injected?.state ?? null,
|
|
1752
|
+
url: injected?.record?.landingUrl ? rewriteUrl(injected.record.landingUrl, injected.originMap) ?? landingUrl : landingUrl,
|
|
1753
|
+
previewOrigins,
|
|
1754
|
+
loginPaths: injected?.record?.loginPaths ?? [],
|
|
1755
|
+
loginCheck: auth?.loginCheck ?? null,
|
|
1756
|
+
// A dev server that just booted still compiles its first page (vite's
|
|
1757
|
+
// cold transform of a large app takes 30-60 s) — give it room, or the
|
|
1758
|
+
// probe answers `unknown` and a login wall goes unnoticed.
|
|
1759
|
+
timeoutMs: coldBoot ? PROBE_TIMEOUT_COLD_BOOT_MS : undefined,
|
|
1760
|
+
log: this.log,
|
|
1761
|
+
});
|
|
1762
|
+
}
|
|
1763
|
+
// "Continue without signing in" (`previewLoginOptional`) means: run
|
|
1764
|
+
// unauthenticated rather than block — whether there is no record at all
|
|
1765
|
+
// or the saved one no longer works.
|
|
1766
|
+
if (probe.result === "needs_login" && turn.previewLoginOptional === true) {
|
|
1767
|
+
this.log("info", "verify.login.optional", { turnId, hadRecord: injected !== null });
|
|
1768
|
+
injected = null;
|
|
1769
|
+
probe = { result: "unknown", storageState: null, loginPath: probe.loginPath };
|
|
1770
|
+
}
|
|
1771
|
+
if (probe.result === "needs_login") {
|
|
1772
|
+
rmSync(artifactsDir, { recursive: true, force: true });
|
|
1773
|
+
if (secretsFile) rmSync(secretsFile, { force: true });
|
|
1774
|
+
const firstTime = injected === null;
|
|
1775
|
+
return {
|
|
1776
|
+
ok: false,
|
|
1777
|
+
error: firstTime
|
|
1778
|
+
? "The preview asks for a sign-in. Sign in once on this device so Kai can test the app."
|
|
1779
|
+
: "The saved sign-in for this preview no longer works — sign in again on this device.",
|
|
1780
|
+
blocked: { blockedCode: "needs_login", ...(probe.loginPath ? { loginPath: probe.loginPath } : {}), firstTime },
|
|
1781
|
+
};
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
// ── state files for the MCP ──────────────────────────────────────
|
|
1785
|
+
let storageStateFile = null;
|
|
1786
|
+
let finalStateFile = null;
|
|
1787
|
+
let redact = new Set();
|
|
1788
|
+
let probeExport = null;
|
|
1789
|
+
if (injected && !storageStateIsEmpty(injected.state)) {
|
|
1790
|
+
const dir = this.loginStateDir();
|
|
1791
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
1792
|
+
const safe = String(turnId).replace(/[^\w.-]/g, "_");
|
|
1793
|
+
storageStateFile = join(dir, `${safe}.json`);
|
|
1794
|
+
// The persona's final `browser_storage_state` export must land INSIDE
|
|
1795
|
+
// the MCP's `--output-dir`: the Playwright MCP refuses any `filename`
|
|
1796
|
+
// outside its output dir / the client cwd (`File access denied …
|
|
1797
|
+
// outside allowed roots`), so a path under ~/.kai/state would make the
|
|
1798
|
+
// agent's last action fail on every signed-in run and the record would
|
|
1799
|
+
// never refresh. A `.json` is not an artifact extension — the sweep
|
|
1800
|
+
// never uploads it — and it is read before the evidence dir is deleted.
|
|
1801
|
+
finalStateFile = this.finalStateFileFor(artifactsDir);
|
|
1802
|
+
mkdirSync(dirname(finalStateFile), { recursive: true, mode: 0o700 });
|
|
1803
|
+
// The probe context's own export (with IndexedDB) when the probe ran;
|
|
1804
|
+
// the rewritten record otherwise (probe unknown → proceed anyway).
|
|
1805
|
+
probeExport = probe.result === "ok" && probe.storageState ? filterStorageState(probe.storageState, stateOrigins).state : injected.state;
|
|
1806
|
+
writeFileSync(storageStateFile, JSON.stringify(probeExport), { mode: 0o600 });
|
|
1807
|
+
redact = new Set([...redactionSet(injected.state), ...redactionSet(probeExport)]);
|
|
1808
|
+
}
|
|
1809
|
+
// `.env*` values of the repos + service dirs are secrets too: a tool row
|
|
1810
|
+
// that echoes one (a config dump, an error message) must not carry it
|
|
1811
|
+
// to the Server.
|
|
1812
|
+
for (const value of this.envRedactionValues(bound, services)) redact.add(value);
|
|
1813
|
+
const allowedOrigins = [...previewOrigins, ...external, "http://localhost:*", "http://127.0.0.1:*", "https://localhost:*", "https://127.0.0.1:*"];
|
|
1814
|
+
// ── API evidence: the verify MCP's request policy ───────────────
|
|
1815
|
+
const apiServices = services.filter((svc) => svc.kind === "api");
|
|
1816
|
+
const verifyPolicy = configs.find((c) => c.verify)?.verify ?? devConfig?.verify ?? { readOnly: null, endpoints: [], loginVia: null };
|
|
1817
|
+
const readOnly = verifyPolicy.readOnly ?? apiServices.length > 0;
|
|
1818
|
+
const env = {
|
|
1819
|
+
KAI_VERIFY_READ_ONLY: readOnly ? "1" : "0",
|
|
1820
|
+
KAI_VERIFY_ORIGINS: [...new Set([...previewOrigins, ...external])].join(";"),
|
|
1821
|
+
KAI_VERIFY_EVIDENCE_DIR: artifactsDir,
|
|
1822
|
+
};
|
|
1823
|
+
// The app's own API credential (dev.yaml `auth.apiToken`) read from the
|
|
1824
|
+
// injected sign-in → `Authorization: Bearer …` for http_request. The
|
|
1825
|
+
// value goes to the MCP by env only and joins the redaction set.
|
|
1826
|
+
const apiAuth = deriveApiAuthHeader(auth?.apiToken, probeExport ?? injected?.state ?? null, services);
|
|
1827
|
+
if (apiAuth) {
|
|
1828
|
+
env.KAI_VERIFY_AUTH_HEADER = apiAuth.value;
|
|
1829
|
+
if (apiAuth.token.length >= 8) redact.add(apiAuth.token);
|
|
1830
|
+
}
|
|
1831
|
+
return {
|
|
1832
|
+
ok: true,
|
|
1833
|
+
artifactsDir,
|
|
1834
|
+
secretsFile,
|
|
1835
|
+
storageStateFile,
|
|
1836
|
+
finalStateFile,
|
|
1837
|
+
allowedOrigins,
|
|
1838
|
+
previewOrigins,
|
|
1839
|
+
stateOrigins,
|
|
1840
|
+
external,
|
|
1841
|
+
// Companions run the code they have checked out — their HEADs travel
|
|
1842
|
+
// with the report so "verified" can never be claimed for a stale API.
|
|
1843
|
+
companionRevisions: previews
|
|
1844
|
+
.filter((p) => p.role === "companion" && p.repo && p.commit)
|
|
1845
|
+
.map((p) => ({ repo: p.repo, commit: p.commit, role: "companion", ...(p.branch ? { branch: p.branch } : {}), ...(p.dirty > 0 ? { dirty: p.dirty } : {}) })),
|
|
1846
|
+
ignoreHttpsErrors: !!runner?.usesHttps?.(),
|
|
1847
|
+
env,
|
|
1848
|
+
readOnly,
|
|
1849
|
+
injected: injected ? { ...injected, probeExport } : null,
|
|
1850
|
+
redact,
|
|
1851
|
+
note: this.verifyPreviewNote(sessionId, previews, secretNames, artifactsDir, {
|
|
1852
|
+
signedIn: !!storageStateFile,
|
|
1853
|
+
finalStateFile,
|
|
1854
|
+
services,
|
|
1855
|
+
readOnly,
|
|
1856
|
+
endpoints: verifyPolicy.endpoints || [],
|
|
1857
|
+
external,
|
|
1858
|
+
restartNotes,
|
|
1859
|
+
apiAuth: !!apiAuth,
|
|
1860
|
+
}),
|
|
1861
|
+
report: null,
|
|
1862
|
+
};
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
/**
|
|
1866
|
+
* After a fix turn (`turn.changedRepos` = repo keys the fix touched):
|
|
1867
|
+
* restart the changed repos' services whose reload mode is `restart`
|
|
1868
|
+
* (declared, or the default for `kind: api`) on their same ports. Adopted
|
|
1869
|
+
* services are the user's own dev server — never restarted, the task
|
|
1870
|
+
* note says so. Returns the note lines for the agent.
|
|
1871
|
+
*/
|
|
1872
|
+
async restartChangedServices(turn, sessionId) {
|
|
1873
|
+
const changed = Array.isArray(turn.changedRepos) ? turn.changedRepos.map((k) => String(k).toLowerCase()) : [];
|
|
1874
|
+
const runner = this.services.get(sessionId);
|
|
1875
|
+
if (!changed.length || !runner) return [];
|
|
1876
|
+
const notes = [];
|
|
1877
|
+
const byRoot = new Map(); // repoRoot → { key, names }
|
|
1878
|
+
for (const svc of runner.describeServices()) {
|
|
1879
|
+
if (!svc.repoKey || !changed.includes(String(svc.repoKey).toLowerCase()) || !svc.repoRoot) continue;
|
|
1880
|
+
const meta = runner.meta?.get(svc.name);
|
|
1881
|
+
if (effectiveReload(meta?.svc, svc.kind) !== "restart") continue;
|
|
1882
|
+
if (svc.adopted) {
|
|
1883
|
+
notes.push(`${svc.name} is your already-running dev server and was not restarted — make sure it serves the fixed code.`);
|
|
1884
|
+
continue;
|
|
1885
|
+
}
|
|
1886
|
+
const entry = byRoot.get(svc.repoRoot) ?? { key: svc.repoKey, names: [] };
|
|
1887
|
+
entry.names.push(svc.name);
|
|
1888
|
+
byRoot.set(svc.repoRoot, entry);
|
|
1889
|
+
}
|
|
1890
|
+
for (const [repoRoot, { key, names }] of byRoot) {
|
|
1891
|
+
await this.postVerifyStage(turn.turnId, "booting", `Restarting ${names.join(", ")} with the fix…`);
|
|
1892
|
+
this.log("info", "verify.restart", { turnId: turn.turnId, repo: key, services: names });
|
|
1893
|
+
try {
|
|
1894
|
+
const restarted = await runner.restart(repoRoot, { only: names });
|
|
1895
|
+
const dead = restarted.find((r) => r.restarted && !r.ready);
|
|
1896
|
+
if (dead) notes.push(`${dead.name} did not come back after the restart${dead.error ? ` — ${dead.error}` : ""} (see ${dead.logPath}).`);
|
|
1897
|
+
else notes.push(`${names.join(", ")} ${names.length === 1 ? "was" : "were"} restarted with the fix.`);
|
|
1898
|
+
} catch (err) {
|
|
1899
|
+
this.log("warn", "verify.restart.failed", { turnId: turn.turnId, repo: key, error: err?.message });
|
|
1900
|
+
notes.push(`${names.join(", ")} could not be restarted with the fix: ${err?.message}`);
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
return notes;
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
/** Secrets from `.env*` files at the repo roots + service cwds (values ≥ 12 chars, placeholders excluded). */
|
|
1907
|
+
envRedactionValues(bound, services) {
|
|
1908
|
+
const dirs = new Set([...(bound || []).map((b) => b?.cwd).filter(Boolean), ...(services || []).map((svc) => svc?.cwd).filter(Boolean)]);
|
|
1909
|
+
const texts = [];
|
|
1910
|
+
for (const dir of dirs) {
|
|
1911
|
+
let names = [];
|
|
1912
|
+
try {
|
|
1913
|
+
names = readdirSync(dir).filter((n) => n === ".env" || (n.startsWith(".env.") && !/\.(example|sample|template)$/.test(n)));
|
|
1914
|
+
} catch {
|
|
1915
|
+
continue;
|
|
1916
|
+
}
|
|
1917
|
+
for (const name of names) {
|
|
1918
|
+
try {
|
|
1919
|
+
texts.push(readFileSync(join(dir, name), "utf8"));
|
|
1920
|
+
} catch {
|
|
1921
|
+
/* unreadable */
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
return envRedactionValues(texts);
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
/** Task suffix for the verifier: the preview URLs (web + API services), where evidence goes, the secrets by name, the sign-in state, the request policy. */
|
|
1929
|
+
verifyPreviewNote(sessionId, previews, secretNames = [], artifactsDir = null, { signedIn = false, finalStateFile = null, services = null, readOnly = false, endpoints = [], external = [], restartNotes = [], apiAuth = false } = {}) {
|
|
1930
|
+
const runner = this.services.get(sessionId);
|
|
1931
|
+
const apiNames = new Set((services || []).filter((svc) => svc.kind === "api").map((svc) => svc.name));
|
|
1932
|
+
const webPreviews = previews.filter((p) => !apiNames.has(p.name));
|
|
1933
|
+
const lines = webPreviews.map((p) => {
|
|
1934
|
+
const logPath = runner && !p.adopted ? join(runner.logDir, `${p.name}.log`) : null;
|
|
1935
|
+
return `- ${p.repo}${p.name && p.name !== p.repo ? ` (${p.name})` : ""}: ${p.url}${p.adopted ? " (your already-running dev server)" : ""}${logPath ? ` · logs: ${logPath}` : ""}`;
|
|
1936
|
+
});
|
|
1937
|
+
const parts = ["", "", "This is a verification turn: do not modify files, do not commit."];
|
|
1938
|
+
if (lines.length > 0) {
|
|
1939
|
+
parts.push(`Dev services running for this session:\n${lines.join("\n")}`);
|
|
1940
|
+
parts.push(
|
|
1941
|
+
"Use the `gleap_preview` browser tools (browser_navigate, browser_snapshot, browser_start_video, browser_video_chapter, browser_take_screenshot, browser_stop_video, …) against these URLs. " +
|
|
1942
|
+
"Tail the service logs with the Read/Bash tools if a page fails to load.",
|
|
1943
|
+
);
|
|
1944
|
+
}
|
|
1945
|
+
const apiServices = (services || []).filter((svc) => svc.kind === "api");
|
|
1946
|
+
if (apiServices.length > 0) {
|
|
1947
|
+
const apiLines = apiServices.map((svc) => {
|
|
1948
|
+
const logPath = runner && !svc.adopted ? join(runner.logDir, `${svc.name}.log`) : null;
|
|
1949
|
+
return `- ${svc.repoKey ? `${svc.repoKey} (${svc.name})` : svc.name}: ${svc.url}${svc.openapi ? ` · OpenAPI: ${svc.openapi}` : ""}${svc.adopted ? " (your already-running dev server)" : ""}${logPath ? ` · logs: ${logPath}` : ""}`;
|
|
1950
|
+
});
|
|
1951
|
+
parts.push(
|
|
1952
|
+
`API services (no UI):\n${apiLines.join("\n")}\n` +
|
|
1953
|
+
`Exercise them with the \`http_request\` tool from the \`kai_verify\` MCP server (one call per check, \`check\` set to what it proves) — the HTTP transcript it records is uploaded with your report as evidence; curl is not available. ` +
|
|
1954
|
+
`Resolve real paths from the OpenAPI spec (or the router files) before calling. ` +
|
|
1955
|
+
(readOnly
|
|
1956
|
+
? "This run is READ-ONLY (shared database): only GET/HEAD/OPTIONS go through — anything else is refused and recorded as skipped; list such endpoints under `untested`. "
|
|
1957
|
+
: "Writes are allowed in this run — prefer creating throwaway records and say what you created in the report. ") +
|
|
1958
|
+
(apiAuth ? "The app's own authentication is added to your requests automatically; never paste tokens. " : "A 401/403 means the API needs a sign-in you do not have: report `blocked` / `needs_login` with the endpoint's path, never a failed check. ") +
|
|
1959
|
+
(endpoints.length ? `Endpoints the repo asks you to cover: ${endpoints.join(", ")}. ` : "") +
|
|
1960
|
+
(lines.length === 0 ? "There is no web UI in this run — no recording is needed; the transcript and the report are the evidence." : ""),
|
|
1961
|
+
);
|
|
1962
|
+
}
|
|
1963
|
+
if (lines.length === 0 && apiServices.length === 0) {
|
|
1964
|
+
parts.push(`Dev services running for this session:\n${previews.map((p) => `- ${p.repo}: ${p.url}`).join("\n") || "- (none)"}`);
|
|
1965
|
+
}
|
|
1966
|
+
if (external.length > 0) parts.push(`External origins the app talks to (allowed for the browser and http_request): ${external.join(", ")}.`);
|
|
1967
|
+
if (restartNotes.length > 0) parts.push(restartNotes.join(" "));
|
|
1968
|
+
if (artifactsDir) {
|
|
1969
|
+
// The MCP resolves a relative `filename` against the CLIENT
|
|
1970
|
+
// workspace (the repo worktree), where it would be discarded with
|
|
1971
|
+
// the turn — only absolute paths under the output dir (or no
|
|
1972
|
+
// filename at all) reach the evidence dir.
|
|
1973
|
+
parts.push(
|
|
1974
|
+
`Evidence directory: ${artifactsDir} — every screenshot and video in it is uploaded with your report. Pass every screenshot/video \`filename\` as an ABSOLUTE path inside it (e.g. \`${join(artifactsDir, "01-home.png")}\`, \`${join(artifactsDir, "verification.webm")}\`), never a bare relative name, and list the paths the tools print back in your report.`,
|
|
1975
|
+
);
|
|
1976
|
+
}
|
|
1977
|
+
if (secretNames.length > 0) {
|
|
1978
|
+
parts.push(
|
|
1979
|
+
`Test credentials are available as secrets: ${secretNames.join(", ")}. Type the secret NAME into the form field (e.g. \`${secretNames[0]}\`) — the browser substitutes the real value and masks it in every response. Do not ask the user for these.`,
|
|
1980
|
+
);
|
|
1981
|
+
}
|
|
1982
|
+
if (signedIn && finalStateFile) {
|
|
1983
|
+
parts.push(
|
|
1984
|
+
`The browser is already signed in to the preview (the user's saved sign-in is loaded). Final browser state path: ${finalStateFile} — as your LAST browser action, after \`browser_stop_video\` and before \`report_verification\`, call \`browser_storage_state\` with exactly this absolute path as \`filename\` so the sign-in stays fresh. ` +
|
|
1985
|
+
`Never read, list or copy anything under ${this.loginStateDir()} — it is the daemon's private state. Never paste cookies, tokens or storage values into the report, todos or summary.`,
|
|
1986
|
+
);
|
|
1987
|
+
} else {
|
|
1988
|
+
parts.push(
|
|
1989
|
+
"If a page asks you to sign in (password field, one-time code, a Sign in / Log in button, a redirect to an identity provider) and no secrets are listed, do not ask the user and do not type into the form: stop the video, screenshot the wall, and file `report_verification` with `status: blocked`, `blockedCode: needs_login` and `loginPath` set to the wall's URL path.",
|
|
1990
|
+
);
|
|
1991
|
+
}
|
|
1992
|
+
parts.push("If the change has nothing a tester can exercise from the running app, file `report_verification` with `status: blocked` and `blockedCode: not_verifiable` — never invent a check. A `passed` report needs at least one real check.");
|
|
1993
|
+
return parts.join("\n\n").replace(/^\n\n\n\n/, "\n\n");
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
/**
|
|
1997
|
+
* After a verify turn: sweep the evidence dir (the agent may have
|
|
1998
|
+
* forgotten the recording), upload every file, then post the report
|
|
1999
|
+
* with URLs in place of paths (redacted: nothing from the injected
|
|
2000
|
+
* sign-in may leak). No report from the agent → `blocked` with the swept
|
|
2001
|
+
* evidence; a report with zero checks → `blocked` / `no_report` (the
|
|
2002
|
+
* payload builder). A turn CANCELLED before any report skips the uploads
|
|
2003
|
+
* altogether (nobody asked for that footage). `evidence: { failed, kept }`
|
|
2004
|
+
* tells the Server how many uploads failed and where the files still are
|
|
2005
|
+
* (`bridge.verify.evidence.retry` re-uploads them). Then, when a saved
|
|
2006
|
+
* sign-in was injected and the agent left its final `browser_storage_state`
|
|
2007
|
+
* export, refresh the record (CAS on the version we read). The dir is
|
|
2008
|
+
* deleted only when everything landed; otherwise it stays for the retry /
|
|
2009
|
+
* `kai-bridge doctor`.
|
|
2010
|
+
*/
|
|
2011
|
+
async finishVerification(turn, verify, res, cancelled) {
|
|
2012
|
+
const { turnId } = turn;
|
|
2013
|
+
await this.postVerifyStage(turnId, "saving");
|
|
2014
|
+
const redact = verify.redact?.size ? (v) => redactDeep(v, verify.redact) : (v) => v;
|
|
2015
|
+
const report = verify.report ? redact(verify.report) : null;
|
|
2016
|
+
// Verify turns never push, so the Server cannot derive the tested heads
|
|
2017
|
+
// from `changes[]` — stamp them here (repo key → HEAD of the checkout).
|
|
2018
|
+
// Companions booted alongside carry `role: "companion"` (their HEAD is
|
|
2019
|
+
// the code the API under test actually served).
|
|
2020
|
+
const revisions = [
|
|
2021
|
+
...(verify.bound || [])
|
|
2022
|
+
.map((b) => ({ repo: b.key, commit: currentHead(b.cwd) }))
|
|
2023
|
+
.filter((r) => r.repo && r.commit),
|
|
2024
|
+
...(verify.companionRevisions || []),
|
|
2025
|
+
];
|
|
2026
|
+
const fallbackReason = cancelled
|
|
2027
|
+
? "The verification was cancelled before Kai filed a report."
|
|
2028
|
+
: res.code !== 0
|
|
2029
|
+
? `The verification turn failed${res.lastError ? `: ${res.lastError.replace(/^acp-runner: /, "").slice(0, 300)}` : ""}.`
|
|
2030
|
+
: "Kai finished without filing a verification report";
|
|
2031
|
+
let uploads = [];
|
|
2032
|
+
let uploadFailures = 0;
|
|
2033
|
+
if (cancelled && !report) {
|
|
2034
|
+
this.log("info", "verify.cancelled.no_uploads", { turnId });
|
|
2035
|
+
} else {
|
|
2036
|
+
const swept = await this.uploadSweptEvidence(turnId, verify.artifactsDir, { report, redact: verify.redact, workDir: verify.workDir, signedIn: !!verify.storageStateFile });
|
|
2037
|
+
uploads = swept.uploads;
|
|
2038
|
+
uploadFailures = swept.failed;
|
|
2039
|
+
}
|
|
2040
|
+
const evidence = { failed: uploadFailures, ...(uploadFailures > 0 ? { kept: verify.artifactsDir } : {}) };
|
|
2041
|
+
const payload = redact({ ...buildVerificationPayload(report, uploads, { fallbackReason, cancelled, evidence, readOnly: typeof verify.readOnly === "boolean" ? verify.readOnly : null }), revisions });
|
|
2042
|
+
let reported = false;
|
|
2043
|
+
try {
|
|
2044
|
+
await this.api.turnVerification(turnId, payload, {
|
|
2045
|
+
onRetry: (err, attempt, delay) => this.log("warn", "verify.report.retry", { turnId, attempt, nextInMs: delay, error: err.message }),
|
|
2046
|
+
});
|
|
2047
|
+
reported = true;
|
|
2048
|
+
} catch (err) {
|
|
2049
|
+
this.log("error", "verify.report.failed", { turnId, error: err.message });
|
|
2050
|
+
}
|
|
2051
|
+
this.log("info", "verify.reported", { turnId, status: payload.status, blockedCode: payload.blockedCode, checks: payload.checks.length, artifacts: uploads.length, uploadFailures, reported });
|
|
2052
|
+
// The final-state export lives in the evidence dir: refresh the record
|
|
2053
|
+
// from it BEFORE the dir is deleted.
|
|
2054
|
+
await this.refreshPreviewLogin(turn, verify);
|
|
2055
|
+
if ((reported && uploadFailures === 0) || (cancelled && !report)) rmSync(verify.artifactsDir, { recursive: true, force: true });
|
|
2056
|
+
else this.log("warn", "verify.artifacts.kept", { turnId, dir: verify.artifactsDir });
|
|
2057
|
+
return { status: payload.status };
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
/**
|
|
2061
|
+
* A preview that Verify booted for itself is released once the run has
|
|
2062
|
+
* settled — a manual preview keeps running until the user stops it. A
|
|
2063
|
+
* `failed` run with fix attempts left keeps the app up: the fix turn's
|
|
2064
|
+
* auto re-verify is seconds away and the Server restarts changed services.
|
|
2065
|
+
*/
|
|
2066
|
+
async settleVerifyPreview(turn, status) {
|
|
2067
|
+
const sessionId = turn?.sessionId;
|
|
2068
|
+
if (!sessionId || !this.verifyOwnedPreviews?.has(sessionId)) return false;
|
|
2069
|
+
if (this.manualPreviews?.has(sessionId)) return false;
|
|
2070
|
+
const loop = turn.loop && typeof turn.loop === "object" ? turn.loop : null;
|
|
2071
|
+
const retryPending = status === "failed" && loop && Number(loop.attempt ?? 0) < Number(loop.max ?? 0);
|
|
2072
|
+
if (retryPending) return false;
|
|
2073
|
+
this.log("info", "verify.preview.released", { sessionId, status });
|
|
2074
|
+
await this.previewStop({ sessionId });
|
|
2075
|
+
return true;
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
/**
|
|
2079
|
+
* Sweep an evidence dir, union it with the report's declared artifacts,
|
|
2080
|
+
* redact the HTTP transcript in place, upload everything. Resolves
|
|
2081
|
+
* `{ uploads: [{ artifact, url }], failed }`. Trace archives are dropped
|
|
2082
|
+
* from signed-in runs (they contain every request with its cookies).
|
|
2083
|
+
*/
|
|
2084
|
+
async uploadSweptEvidence(turnId, artifactsDir, { report = null, redact = null, workDir = null, signedIn = false } = {}) {
|
|
2085
|
+
const swept = sweepArtifacts(artifactsDir).filter((p) => !(signedIn && /\.zip$/i.test(p)));
|
|
2086
|
+
const artifacts = unionArtifacts(report, swept, { outputDir: artifactsDir, workDir }).filter((a) => !(signedIn && a.kind === "trace"));
|
|
2087
|
+
const uploads = [];
|
|
2088
|
+
let failed = 0;
|
|
2089
|
+
for (const artifact of artifacts) {
|
|
2090
|
+
if (artifact.kind === "requests") redactJsonlFile(artifact.path, redact);
|
|
2091
|
+
try {
|
|
2092
|
+
const { url } = await this.api.uploadArtifact(turnId, artifact.path, artifact.contentType, {
|
|
2093
|
+
onRetry: (err, attempt, delay) => this.log("warn", "verify.upload.retry", { turnId, file: artifact.path, attempt, nextInMs: delay, error: err.message }),
|
|
2094
|
+
});
|
|
2095
|
+
uploads.push({ artifact, url });
|
|
2096
|
+
} catch (err) {
|
|
2097
|
+
failed += 1;
|
|
2098
|
+
this.log("warn", "verify.upload.failed", { turnId, file: artifact.path, error: err.message });
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
return { uploads, failed, swept: swept.length };
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
/**
|
|
2105
|
+
* `bridge.verify.evidence.retry { turnId }`: the evidence dir kept after
|
|
2106
|
+
* failed uploads is swept and uploaded again; the Server unions the
|
|
2107
|
+
* artifacts by URL (`PUT …/verification/artifacts`) and clears
|
|
2108
|
+
* `evidenceMissing`. The dir goes once everything landed.
|
|
2109
|
+
*/
|
|
2110
|
+
async retryEvidence({ commandId, turnId }) {
|
|
2111
|
+
const ack = (payload) => (commandId ? this.api.commandAck(commandId, payload).catch((err) => this.log("warn", "verify.evidence.ack.failed", { turnId, error: err?.message })) : Promise.resolve());
|
|
2112
|
+
if (!turnId) return ack({ ok: false, error: "evidence retry without a turnId" });
|
|
2113
|
+
const dir = join(this.kaiHome, "artifacts", String(turnId).replace(/[^\w.-]/g, "_"));
|
|
2114
|
+
if (!existsSync(dir)) {
|
|
2115
|
+
this.log("warn", "verify.evidence.retry.missing", { turnId, dir });
|
|
2116
|
+
return ack({ ok: false, error: "The evidence for this run is no longer on this machine." });
|
|
2117
|
+
}
|
|
2118
|
+
const { uploads, failed, swept } = await this.uploadSweptEvidence(turnId, dir);
|
|
2119
|
+
const artifacts = uploads.map((u) => ({ label: u.artifact.label, url: u.url, contentType: u.artifact.contentType, kind: u.artifact.kind, ...(u.artifact.count > 0 ? { count: u.artifact.count } : {}) }));
|
|
2120
|
+
const evidence = { failed, ...(failed > 0 ? { kept: dir } : {}) };
|
|
2121
|
+
try {
|
|
2122
|
+
await this.api.putVerificationArtifacts(turnId, { artifacts, evidence });
|
|
2123
|
+
} catch (err) {
|
|
2124
|
+
this.log("error", "verify.evidence.retry.failed", { turnId, error: err?.message });
|
|
2125
|
+
return ack({ ok: false, error: err?.message || String(err), uploaded: artifacts.length, failed });
|
|
2126
|
+
}
|
|
2127
|
+
this.log("info", "verify.evidence.retried", { turnId, swept, uploaded: artifacts.length, failed });
|
|
2128
|
+
if (failed === 0) rmSync(dir, { recursive: true, force: true });
|
|
2129
|
+
return ack({ ok: true, uploaded: artifacts.length, failed });
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
/**
|
|
2133
|
+
* The agent's final `browser_storage_state` export (cookies + localStorage
|
|
2134
|
+
* — what the app may have rotated during the run) merged over the probe
|
|
2135
|
+
* export's IndexedDB, filtered to the preview origins, mapped BACK onto
|
|
2136
|
+
* the record's captured origins, and PUT with the version we read. 409 =
|
|
2137
|
+
* another run refreshed first → ours is discarded. Only for injected
|
|
2138
|
+
* records (a dev.yaml command owns its own state).
|
|
2139
|
+
*/
|
|
2140
|
+
async refreshPreviewLogin(turn, verify) {
|
|
2141
|
+
const { turnId } = turn;
|
|
2142
|
+
const record = verify.injected?.record;
|
|
2143
|
+
if (!record?.id || verify.injected.source !== "record" || !verify.finalStateFile) return;
|
|
2144
|
+
let final;
|
|
2145
|
+
try {
|
|
2146
|
+
if (!existsSync(verify.finalStateFile)) {
|
|
2147
|
+
this.log("info", "verify.login.refresh.skipped", { turnId, reason: "no final state" });
|
|
2148
|
+
return;
|
|
2149
|
+
}
|
|
2150
|
+
final = JSON.parse(readFileSync(verify.finalStateFile, "utf8"));
|
|
2151
|
+
} catch (err) {
|
|
2152
|
+
this.log("warn", "verify.login.refresh.unreadable", { turnId, error: err?.message });
|
|
2153
|
+
return;
|
|
2154
|
+
}
|
|
2155
|
+
const merged = mergeFinalState(verify.injected.probeExport, final);
|
|
2156
|
+
const { state: filtered, counts } = filterStorageState(merged, verify.stateOrigins ?? verify.previewOrigins);
|
|
2157
|
+
const back = rewriteStorageState(filtered, invertOriginMap(verify.injected.originMap));
|
|
2158
|
+
if (storageStateIsEmpty(back)) {
|
|
2159
|
+
this.log("info", "verify.login.refresh.skipped", { turnId, reason: "empty state" });
|
|
2160
|
+
return;
|
|
2161
|
+
}
|
|
2162
|
+
try {
|
|
2163
|
+
await this.api.refreshPreviewLogin(record.id, { storageState: back, basedOnVersion: record.version });
|
|
2164
|
+
this.log("info", "verify.login.refreshed", { turnId, recordId: record.id, basedOnVersion: record.version, ...counts });
|
|
2165
|
+
} catch (err) {
|
|
2166
|
+
this.log(err?.status === 409 ? "info" : "warn", err?.status === 409 ? "verify.login.refresh.stale" : "verify.login.refresh.failed", { turnId, recordId: record.id, error: err?.message });
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
// ── sign-in windows (bridge.verify.login.*) ────────────────────────
|
|
2171
|
+
/**
|
|
2172
|
+
* Open a headed Chrome at the preview for the user to sign in; the
|
|
2173
|
+
* capture uploads the filtered storage state against `requestId`. Acks
|
|
2174
|
+
* `{ ok: true, opened: true }` once the window is up, `{ ok: false,
|
|
2175
|
+
* error }` when this machine cannot (updating, no display, preview dead).
|
|
2176
|
+
* Idempotent per requestId (the Server replays after sleep).
|
|
2177
|
+
*/
|
|
2178
|
+
async loginStart({ commandId, requestId, sessionId, repoKey, previewNeeded, title, repos }) {
|
|
2179
|
+
const ack = (payload) => (commandId ? this.api.commandAck(commandId, payload).catch((err) => this.log("warn", "verify.login.ack.failed", { requestId, error: err?.message })) : Promise.resolve());
|
|
2180
|
+
const status = (payload) => this.api.previewLoginStatus(requestId, payload).catch((err) => this.log("warn", "verify.login.status.failed", { requestId, error: err?.message }));
|
|
2181
|
+
if (!requestId) {
|
|
2182
|
+
await ack({ ok: false, error: "Sign-in request without a requestId." });
|
|
2183
|
+
return;
|
|
2184
|
+
}
|
|
2185
|
+
if (this.logins.has(requestId)) {
|
|
2186
|
+
await ack({ ok: true, opened: true, replayed: true });
|
|
2187
|
+
return;
|
|
2188
|
+
}
|
|
2189
|
+
const entry = { requestId, sessionId, repoKey, handle: null, startedAt: Date.now() };
|
|
2190
|
+
this.logins.set(requestId, entry);
|
|
2191
|
+
try {
|
|
2192
|
+
if (this.updating) throw new Error("This machine is updating kai-bridge — try again in a minute.");
|
|
2193
|
+
if (!this.displayAvailable()) throw new Error("This machine has no display to open a sign-in window on.");
|
|
2194
|
+
const pw = this.loadPlaywright();
|
|
2195
|
+
if (!pw?.chromium) throw new Error("Playwright is not installed next to kai-bridge — reinstall @gleapai/kai-bridge.");
|
|
2196
|
+
const browser = await this.ensurePreviewBrowser().catch((err) => ({ ok: false, error: err?.message }));
|
|
2197
|
+
if (!browser?.ok) throw new Error(browser?.error || "No browser is available on this machine.");
|
|
2198
|
+
// Liveness first: booting a cold preview can take minutes, and the
|
|
2199
|
+
// Server fails a request nobody acknowledged as "device unreachable".
|
|
2200
|
+
// `accepted` moves the request off pending without claiming a window.
|
|
2201
|
+
await ack({ ok: true, accepted: true });
|
|
2202
|
+
// The preview must be up for the user to sign in to — boot it (or,
|
|
2203
|
+
// when it already runs, only re-describe it: previewStart is
|
|
2204
|
+
// idempotent on a live runner and we need its URLs either way).
|
|
2205
|
+
// `repos`/`title` may be absent on the command; then the request's
|
|
2206
|
+
// repo is the whole session and the worktree is looked up by id.
|
|
2207
|
+
const sessionRepos = Array.isArray(repos) && repos.length ? repos : [{ key: repoKey }];
|
|
2208
|
+
this.log("info", "verify.login.preview", { requestId, sessionId, previewNeeded: !!previewNeeded, live: this.services.has(sessionId) });
|
|
2209
|
+
const preview = await this.previewStart({ sessionId, title, repos: sessionRepos });
|
|
2210
|
+
if (preview?.status !== "running") throw new Error(preview?.error ? `The preview could not be started: ${preview.error}` : "The preview could not be started.");
|
|
2211
|
+
entry.previews = preview.previews || [];
|
|
2212
|
+
const runner = this.services.get(sessionId);
|
|
2213
|
+
const services = runner?.describeServices?.() ?? (entry.previews || []).map((p) => ({ name: p.name, url: p.url }));
|
|
2214
|
+
const previewOrigins = [...new Set([...services.map((s) => normalizeOrigin(s.url)), ...(entry.previews || []).map((p) => normalizeOrigin(p.url))].filter(Boolean))];
|
|
2215
|
+
// dev.yaml `external` origins may hold part of the app's own sign-in
|
|
2216
|
+
// state — keepable, but never "back on the app" for the detection.
|
|
2217
|
+
const external = [...new Set([...new Set((services || []).map((s) => s.repoRoot).filter(Boolean))].flatMap((root) => readDevConfig(root)?.external || []))];
|
|
2218
|
+
const url = (entry.previews || []).find((p) => p.repo === repoKey)?.url ?? (entry.previews || [])[0]?.url ?? services[0]?.url;
|
|
2219
|
+
if (!url) throw new Error("The preview has no URL to open.");
|
|
2220
|
+
// Keep the preview alive while the window is open (the idle timer
|
|
2221
|
+
// would otherwise stop the app under the user's nose).
|
|
2222
|
+
this.armPreviewIdleTimer(sessionId);
|
|
2223
|
+
const handle = await this.captureLogin({
|
|
2224
|
+
pw,
|
|
2225
|
+
launchOptions: launchOptionsFor({ headless: false, browser: browser.browser === "chrome" ? "chrome" : null }),
|
|
2226
|
+
url,
|
|
2227
|
+
previewOrigins,
|
|
2228
|
+
stateOrigins: [...previewOrigins, ...external],
|
|
2229
|
+
services: services.map((s) => ({ name: s.name, origin: normalizeOrigin(s.url) })),
|
|
2230
|
+
onStatus: (st, extra) => {
|
|
2231
|
+
this.log("info", "verify.login.status", { requestId, status: st, ...(extra?.error ? { error: extra.error } : {}) });
|
|
2232
|
+
return status({ status: st, ...(extra?.error ? { error: String(extra.error) } : {}) });
|
|
2233
|
+
},
|
|
2234
|
+
onSaved: async (payload, counts) => {
|
|
2235
|
+
await this.api.uploadPreviewLogin(requestId, payload, {
|
|
2236
|
+
tries: 3,
|
|
2237
|
+
onRetry: (err, attempt, delay) => this.log("warn", "verify.login.upload.retry", { requestId, attempt, nextInMs: delay, error: err.message }),
|
|
2238
|
+
});
|
|
2239
|
+
this.log("info", "verify.login.saved", { requestId, repoKey, ...counts, loginPaths: payload.loginPaths.length });
|
|
2240
|
+
},
|
|
2241
|
+
log: this.log,
|
|
2242
|
+
});
|
|
2243
|
+
void handle.finished.then((outcome) => {
|
|
2244
|
+
this.logins.delete(requestId);
|
|
2245
|
+
this.log("info", "verify.login.finished", { requestId, status: outcome?.status, error: outcome?.error });
|
|
2246
|
+
this.hello().catch(() => {});
|
|
2247
|
+
if ((this.updatePending || this.restartPending) && this.running.size === 0 && this.logins.size === 0) void this.checkForUpdate();
|
|
2248
|
+
});
|
|
2249
|
+
if (entry.cancelled) {
|
|
2250
|
+
// Cancelled while Chrome was still launching (the Server has already
|
|
2251
|
+
// settled the request): close the window it just opened, no ack of
|
|
2252
|
+
// `opened`, no status (the cancel already answered).
|
|
2253
|
+
this.log("info", "verify.login.cancelled.launching", { requestId });
|
|
2254
|
+
await handle.cancel();
|
|
2255
|
+
await ack({ ok: false, error: "The sign-in was cancelled before the window opened." });
|
|
2256
|
+
return;
|
|
2257
|
+
}
|
|
2258
|
+
entry.handle = handle;
|
|
2259
|
+
await ack({ ok: true, opened: true });
|
|
2260
|
+
await status({ status: "opened" });
|
|
2261
|
+
// Re-announce right away: `activeLogins` is how the dashboard knows a window is open.
|
|
2262
|
+
await this.hello().catch((err) => this.log("warn", "verify.login.hello.failed", { requestId, error: err?.message }));
|
|
2263
|
+
} catch (err) {
|
|
2264
|
+
this.logins.delete(requestId);
|
|
2265
|
+
this.log("error", "verify.login.start.failed", { requestId, error: err?.message });
|
|
2266
|
+
await ack({ ok: false, error: err?.message || String(err) });
|
|
2267
|
+
await status({ status: "failed", error: err?.message || String(err) });
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
/** "Mark done": export whatever the window holds now (the heuristic may have missed the sign-in). */
|
|
2272
|
+
async loginDone({ commandId, requestId }) {
|
|
2273
|
+
try {
|
|
2274
|
+
const entry = this.logins.get(requestId);
|
|
2275
|
+
if (!entry?.handle) throw new Error("No sign-in window is open for this request on this machine.");
|
|
2276
|
+
await entry.handle.done();
|
|
2277
|
+
if (commandId) await this.api.commandAck(commandId, { ok: true });
|
|
2278
|
+
} catch (err) {
|
|
2279
|
+
this.log("warn", "verify.login.done.failed", { requestId, error: err?.message });
|
|
2280
|
+
if (commandId) await this.api.commandAck(commandId, { ok: false, error: err?.message || String(err) }).catch(() => {});
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
async loginCancel({ commandId, requestId }) {
|
|
2285
|
+
try {
|
|
2286
|
+
const entry = this.logins.get(requestId);
|
|
2287
|
+
if (!entry) throw new Error("No sign-in window is open for this request on this machine.");
|
|
2288
|
+
if (entry.handle) await entry.handle.cancel();
|
|
2289
|
+
// Still launching: loginStart closes the window as soon as it is up.
|
|
2290
|
+
else entry.cancelled = true;
|
|
2291
|
+
if (commandId) await this.api.commandAck(commandId, { ok: true });
|
|
2292
|
+
} catch (err) {
|
|
2293
|
+
this.log("warn", "verify.login.cancel.failed", { requestId, error: err?.message });
|
|
2294
|
+
if (commandId) await this.api.commandAck(commandId, { ok: false, error: err?.message || String(err) }).catch(() => {});
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
/** Close every sign-in window matching `filter` (session close, shutdown). */
|
|
2299
|
+
async teardownLogins(filter, reason) {
|
|
2300
|
+
if (!this.logins?.size) return;
|
|
2301
|
+
const victims = [...this.logins.values()].filter((l) => {
|
|
2302
|
+
try {
|
|
2303
|
+
return filter(l);
|
|
2304
|
+
} catch {
|
|
2305
|
+
return false;
|
|
2306
|
+
}
|
|
2307
|
+
});
|
|
2308
|
+
for (const l of victims) {
|
|
2309
|
+
this.log("info", "verify.login.teardown", { requestId: l.requestId, reason });
|
|
2310
|
+
this.logins.delete(l.requestId);
|
|
2311
|
+
l.cancelled = true; // still launching → loginStart closes it on arrival
|
|
2312
|
+
await l.handle?.cancel?.().catch(() => {});
|
|
1007
2313
|
}
|
|
1008
2314
|
}
|
|
1009
2315
|
|
|
@@ -1082,21 +2388,70 @@ export class BridgeDaemon {
|
|
|
1082
2388
|
return { note: notes.join(""), hasLivePreview: previews.length > 0 };
|
|
1083
2389
|
}
|
|
1084
2390
|
|
|
1085
|
-
|
|
2391
|
+
/**
|
|
2392
|
+
* Clone `remote` into the preferred root as `name`. Hardened for
|
|
2393
|
+
* unattended runs: no credential prompts (`GIT_TERMINAL_PROMPT=0`,
|
|
2394
|
+
* `GIT_ASKPASS=echo`), stderr captured for the error, bounded by
|
|
2395
|
+
* `timeoutMs` (default 3 min). Resolves the checkout path; throws an Error
|
|
2396
|
+
* carrying `.target` and the stderr tail. As a command handler
|
|
2397
|
+
* (`bridge.repo.clone`) it acks ok/error instead of throwing.
|
|
2398
|
+
*/
|
|
2399
|
+
async cloneRepo({ commandId, remote, name, timeoutMs = CLONE_TIMEOUT_MS }) {
|
|
1086
2400
|
// Prefer the directory the machine's repos already live in (e.g.
|
|
1087
2401
|
// ~/Documents/Gleap), not the bare scan root that discovered them
|
|
1088
2402
|
// (~/Documents) — "Clone here" should land next to the other checkouts.
|
|
1089
2403
|
const root = preferredCloneRoot(this.repoGroups, (this.config.roots || [])[0] ?? defaultRoots()[0]);
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
}
|
|
2404
|
+
const fail = async (err) => {
|
|
2405
|
+
if (commandId) {
|
|
2406
|
+
await this.api.commandAck(commandId, { ok: false, error: err.message, ...(err.cloneCommand ? { cloneCommand: err.cloneCommand } : {}) }).catch(() => {});
|
|
2407
|
+
return null;
|
|
2408
|
+
}
|
|
2409
|
+
throw err;
|
|
2410
|
+
};
|
|
2411
|
+
if (!root) return fail(new Error("No scan root to clone into — add one with `kai-bridge repo roots add <dir>`."));
|
|
2412
|
+
const target = join(root, String(name || "").replace(/[^\w.-]/g, "-") || "repo");
|
|
2413
|
+
if (existsSync(target)) {
|
|
2414
|
+
const err = new Error(`${target} already exists — remove it or point Gleap at it (Locate…).`);
|
|
2415
|
+
err.target = target;
|
|
2416
|
+
return fail(err);
|
|
2417
|
+
}
|
|
2418
|
+
try {
|
|
2419
|
+
await new Promise((resolve, reject) => {
|
|
2420
|
+
const p = spawn("git", ["clone", remote, target], {
|
|
2421
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
2422
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "echo", SSH_ASKPASS: "echo", GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND || "ssh -o BatchMode=yes" },
|
|
2423
|
+
});
|
|
2424
|
+
let stderr = "";
|
|
2425
|
+
p.stderr.on("data", (d) => {
|
|
2426
|
+
stderr = `${stderr}${d}`.slice(-4000);
|
|
2427
|
+
});
|
|
2428
|
+
const timer = setTimeout(() => {
|
|
2429
|
+
try {
|
|
2430
|
+
p.kill("SIGTERM");
|
|
2431
|
+
} catch {}
|
|
2432
|
+
reject(Object.assign(new Error(`git clone timed out after ${Math.round(timeoutMs / 60_000)} minutes`), { target, stderr }));
|
|
2433
|
+
}, timeoutMs);
|
|
2434
|
+
p.on("close", (code) => {
|
|
2435
|
+
clearTimeout(timer);
|
|
2436
|
+
if (code === 0) return resolve();
|
|
2437
|
+
const tail = stderr.trim().split("\n").filter(Boolean).slice(-2).join(" · ").slice(0, 300);
|
|
2438
|
+
reject(Object.assign(new Error(`git clone exited ${code}${tail ? ` — ${tail}` : ""}`), { target, stderr, code }));
|
|
2439
|
+
});
|
|
2440
|
+
p.on("error", (err) => {
|
|
2441
|
+
clearTimeout(timer);
|
|
2442
|
+
reject(Object.assign(err, { target }));
|
|
2443
|
+
});
|
|
2444
|
+
});
|
|
2445
|
+
} catch (err) {
|
|
2446
|
+
this.log("error", "repo.clone.failed", { remote, target, error: err.message });
|
|
2447
|
+
rmSync(target, { recursive: true, force: true });
|
|
2448
|
+
err.cloneCommand = buildCloneCommand({ remote, target });
|
|
2449
|
+
return fail(err);
|
|
2450
|
+
}
|
|
1097
2451
|
await this.scanRepos();
|
|
1098
|
-
await this.hello();
|
|
2452
|
+
await this.hello().catch(() => {});
|
|
1099
2453
|
if (commandId) await this.api.commandAck(commandId, { ok: true, path: target });
|
|
2454
|
+
return target;
|
|
1100
2455
|
}
|
|
1101
2456
|
|
|
1102
2457
|
/**
|