@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/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
- running: this.running.size,
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
- for (const runner of this.services.values()) runner.stopAll();
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
- return JSON.parse(readFileSync(this.inflightPath, "utf8"));
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(ids) {
540
+ writeInflight(entries) {
449
541
  try {
450
542
  mkdirSync(join(this.kaiHome, "state"), { recursive: true });
451
- writeFileSync(this.inflightPath, JSON.stringify(ids));
543
+ writeFileSync(this.inflightPath, JSON.stringify(entries));
452
544
  } catch {
453
545
  /* best effort */
454
546
  }
455
547
  }
456
548
 
457
- rememberInflight(turnId) {
458
- this.writeInflight([...new Set([...this.readInflight(), turnId])]);
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((id) => id !== turnId));
555
+ this.writeInflight(this.readInflight().filter((e) => e.turnId !== turnId));
463
556
  }
464
557
 
465
- /** Turns this machine was running when it was killed — report them dead. */
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 ids = this.readInflight();
468
- if (!ids.length) return;
565
+ const entries = this.readInflight();
566
+ if (!entries.length) return;
469
567
  this.writeInflight([]);
470
- for (const turnId of ids) {
471
- this.log("warn", "turn.interrupted", { turnId });
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
- const report = (payload) =>
642
- this.api.sessionPreview(sessionId, payload).catch((err) => this.log("warn", "preview.report.failed", { error: err.message }));
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) and
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
- await report({ status: "error", error: `Repository ${r.key} is not checked out on this device.` });
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 : worktreePath(this.kaiHome, group.name, sessionSlug(sessionId, title));
659
- if (!existsSync(cwd)) {
660
- await report({
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. Never overwrites existing files.
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
- // Config resolution order: this worktree's own config, then the
674
- // PRIMARY checkout's (a repo verified by the setup agent has its
675
- // dev.yaml on an unmerged config PR — session worktrees cut from
676
- // the base branch don't carry it yet), then the package.json
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
- for (const c of config.companions || []) {
688
- if ((repos || []).some((sr) => sr.key === c.repo)) continue; // already part of the session
689
- const prev = companionWants.get(c.repo);
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
- resolved.push({ key: r.key, cwd, config, mode });
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
- await report({ status: "error", error: "No dev config found — add .gleap/dev.yaml to the repo (services + preview)." });
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
- const runner = this.runnerFor(sessionId, (message) => this.log("info", "preview.status", { sessionId, message }));
700
- // Pass 2 — companions: clone when missing, then boot from the
701
- // PRIMARY checkout in local mode, so an already-running dev server
702
- // on the declared port is adopted instead of duplicated.
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 [key, { optional }] of companionWants) {
961
+ for (const c of bootable) {
705
962
  try {
706
- const group = await this.ensureCompanionCheckout(key, report);
707
- const config = readDevConfig(group.primary.path) ?? detectDevConfig(group.primary.path);
708
- if (!config) throw new Error(`no dev config — add .gleap/dev.yaml to ${key}`);
709
- if (config.error) throw new Error(config.error);
710
- const started = await this.bootRepoWithConfig(runner, group.primary.path, config, "local");
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
- runner.stopAll();
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
- try {
728
- const started = await this.bootRepoWithConfig(runner, cwd, config, mode);
729
- if (started.preview) previews.push({ repo: key, ...started.preview });
730
- } catch (err) {
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
- await report({ status: "running", previews: [...previews, ...companionPreviews] });
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
- await report({ status: "error", error: err.message });
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
- * readable error (with the failing service's log tail) when a service
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
- let tail = "";
755
- try {
756
- const raw = readFileSync(dead.logPath, "utf8");
757
- tail = raw.trim().split("\n").slice(-3).join(" · ").slice(0, 300);
758
- } catch {}
759
- throw new Error(`${dead.name} did not start${tail ? ` — ${tail}` : ""}`);
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
- * Not cloned → clone it into the preferred root (github.com keys only
767
- * — the key itself is the remote address) and rescan.
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 [host, owner, name] = String(key).split("/");
773
- if (host !== "github.com" || !owner || !name) {
774
- throw new Error(`not cloned on this device — clone it, then start the preview again`);
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
- await this.cloneRepo({ remote: `https://github.com/${owner}/${name}.git`, name });
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) throw new Error(`clone finished but the repository was not discovered — check the daemon log`);
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, onStatus) {
1205
+ runnerFor(sessionId) {
823
1206
  let runner = this.services.get(sessionId);
824
1207
  if (!runner) {
825
- runner = new ServiceRunner({ kaiHome: this.kaiHome, sessionId, log: this.log, onStatus });
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
- this.rememberInflight(turnId);
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
- // Previews are manual-only (dashboard "Start preview") — a turn
920
- // never boots dev servers on its own. When the user already has a
921
- // preview running for this session, describe it to the agent and
922
- // hand it the Playwright MCP so it can verify in a real browser.
923
- const { note: previewNote, hasLivePreview } = await this.describeLivePreview(turn, bound, batcher);
924
- const mcpServers = hasLivePreview ? [...(turn.mcpServers || []), previewMcpServer(RUNNER_DIR)] : turn.mcpServers;
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) => batcher.push(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 plan turn must leave the worktree as it found it — see
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 (turn.planMode) {
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
- this.log("warn", "plan.changes", { turnId, repo: b.key, mode: b.mode, files: leaked.length, discarded: b.mode === "worktree" });
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
- ? `Plan mode is read-only — ${leaked.length} file change${leaked.length === 1 ? "" : "s"} made during planning ${leaked.length === 1 ? "was" : "were"} discarded; the build starts from the plan.`
952
- : `Plan mode is read-only, but ${leaked.length} file change${leaked.length === 1 ? "" : "s"} landed in your local checkout of ${b.key} — review them before building.`,
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; plan turns and local mode never push.
959
- const shouldPush = completed && b.mode === "worktree" && !turn.planMode && diff.files.length > 0;
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
- : null;
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
- return { key: b.key, mode: b.mode, branch: b.branch, base: b.base, cwd: b.cwd, adopted: b.adopted || undefined, ...diff, push };
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 plan-mode notices above were queued AFTER the post-turn
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
- async cloneRepo({ commandId, remote, name }) {
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
- if (!root) throw new Error("No scan root to clone into — add one with `kai-bridge repo roots add <dir>`.");
1091
- const target = join(root, name);
1092
- await new Promise((resolve, reject) => {
1093
- const p = spawn("git", ["clone", remote, target], { stdio: "ignore" });
1094
- p.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`git clone exited ${code}`))));
1095
- p.on("error", reject);
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
  /**