@gleapai/kai-bridge 0.2.8 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/runner/acp-runner.mjs +157 -10
- package/runner/lib/acp/mapper.mjs +31 -1
- package/runner/lib/contract.mjs +21 -2
- package/runner/personas/claude/kai-verifier.md +84 -0
- package/runner/personas/codex/kai-verifier.md +84 -0
- package/runner/tools/verify-mcp.mjs +442 -0
- package/src/api.mjs +102 -1
- package/src/companions.mjs +101 -0
- package/src/daemon.mjs +1535 -150
- package/src/deps.mjs +113 -0
- package/src/executor.mjs +35 -5
- package/src/ports.mjs +134 -0
- package/src/preview-errors.mjs +283 -0
- package/src/preview-login.mjs +610 -0
- package/src/preview.mjs +777 -106
- package/src/selfupdate.mjs +26 -0
- package/src/service.mjs +11 -0
- package/src/setup.mjs +30 -2
- package/src/verify.mjs +387 -0
- package/src/workspace.mjs +60 -1
package/src/daemon.mjs
CHANGED
|
@@ -6,12 +6,20 @@
|
|
|
6
6
|
// protocol, the same channel family the dashboard uses):
|
|
7
7
|
// bridge.turn.start { commandId, turnId, sessionId, profileId, repos:[{key, mode, base, carryUncommitted}], ...AgentRunOpts }
|
|
8
8
|
// bridge.turn.cancel { turnId }
|
|
9
|
+
// bridge.turn.steer { turnId, steer: { id, text } } — inject into the running turn;
|
|
10
|
+
// answered with a {type:"steer", id, outcome} turn event
|
|
9
11
|
// bridge.repo.clone { commandId, remote, name }
|
|
10
12
|
// bridge.profile.login{ commandId, profileId }
|
|
11
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
|
|
12
19
|
|
|
13
20
|
import { spawn } from "node:child_process";
|
|
14
|
-
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";
|
|
15
23
|
import { join, resolve as resolvePath } from "node:path";
|
|
16
24
|
import { homedir, platform } from "node:os";
|
|
17
25
|
|
|
@@ -21,16 +29,54 @@ import { KAI_HOME, defaultConfig, loadConfig, saveConfig } from "./config.mjs";
|
|
|
21
29
|
import { runTurn } from "./executor.mjs";
|
|
22
30
|
import { createManagedProfile, describeProfiles, managedConfigDir, ambientConfigDir, openLoginTerminal, probeUsageLimits } from "./profiles.mjs";
|
|
23
31
|
import { defaultRoots, groupByRepo, preferredCloneRoot, scanRoots, toDeviceRepoReport } from "./repos.mjs";
|
|
24
|
-
import { discardChanges, collectChanges, commitAndPush, copyPrimaryEnvFiles, currentBranch, materializeBinding, sessionSlug, worktreePath, ensureCommitExcludes } from "./workspace.mjs";
|
|
25
|
-
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";
|
|
26
56
|
import { describeHarnesses, installHarness, probeHarnessAuth } from "./harnesses.mjs";
|
|
27
57
|
import { probeHarnessModels } from "./models.mjs";
|
|
28
|
-
import { decideUpdate, fetchLatestVersion, installVersion, installedVersion, isNewer } from "./selfupdate.mjs";
|
|
58
|
+
import { decideRestart, decideUpdate, fetchLatestVersion, installVersion, installedVersion, installedVersionOrNull, isNewer } from "./selfupdate.mjs";
|
|
29
59
|
import { dirname } from "node:path";
|
|
30
60
|
import { fileURLToPath } from "node:url";
|
|
31
61
|
|
|
62
|
+
const PROBE_TIMEOUT_COLD_BOOT_MS = 60_000;
|
|
63
|
+
const CLONE_TIMEOUT_MS = 3 * 60_000;
|
|
64
|
+
const COMPANION_MAX_DEPTH = 3;
|
|
32
65
|
const RUNNER_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "runner");
|
|
33
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
|
+
|
|
34
80
|
/** Lock paths held by daemons in THIS process (cross-process uses the file). */
|
|
35
81
|
const HELD_LOCKS = new Set();
|
|
36
82
|
const REALTIME_RETRY_MS = 15_000;
|
|
@@ -112,7 +158,7 @@ export class BridgeDaemon {
|
|
|
112
158
|
this.log = log;
|
|
113
159
|
this.api = new BridgeApi({ apiBase: config.apiBase, token: config.device?.token });
|
|
114
160
|
this.realtimeFactory = realtimeFactory;
|
|
115
|
-
this.running = new Map(); // turnId → AbortController
|
|
161
|
+
this.running = new Map(); // turnId → { ctrl: AbortController, control?: (obj) => boolean }
|
|
116
162
|
this.services = new Map(); // sessionId → ServiceRunner (lives across turns)
|
|
117
163
|
this.repoGroups = [];
|
|
118
164
|
this.usageByProfile = new Map(); // profileId → plan-usage snapshot (claude only)
|
|
@@ -121,6 +167,11 @@ export class BridgeDaemon {
|
|
|
121
167
|
this.updateAvailable = null; // newer registry version, when one exists
|
|
122
168
|
this.updateError = null; // why the last self-update did not apply
|
|
123
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
|
|
124
175
|
}
|
|
125
176
|
|
|
126
177
|
async scanRepos() {
|
|
@@ -146,6 +197,10 @@ export class BridgeDaemon {
|
|
|
146
197
|
updateAvailable: this.updateAvailable || null,
|
|
147
198
|
updateError: this.updateError || null,
|
|
148
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()],
|
|
149
204
|
});
|
|
150
205
|
}
|
|
151
206
|
|
|
@@ -164,6 +219,12 @@ export class BridgeDaemon {
|
|
|
164
219
|
// to report its turns. Tell the server before doing anything else,
|
|
165
220
|
// so those sessions settle instead of spinning.
|
|
166
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 });
|
|
167
228
|
await this.scanRepos();
|
|
168
229
|
// The first hello must not kill the daemon: the server may be
|
|
169
230
|
// restarting (deploys, local nodemon) — retry with backoff instead
|
|
@@ -229,6 +290,18 @@ export class BridgeDaemon {
|
|
|
229
290
|
if (this.stopped || this.updating) return;
|
|
230
291
|
// Tests and embedded hosts (the Desktop app ships its own copy).
|
|
231
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
|
+
}
|
|
232
305
|
let latest;
|
|
233
306
|
try {
|
|
234
307
|
latest = await fetchLatestVersion();
|
|
@@ -240,7 +313,10 @@ export class BridgeDaemon {
|
|
|
240
313
|
const decision = decideUpdate({
|
|
241
314
|
current: VERSION,
|
|
242
315
|
latest,
|
|
243
|
-
|
|
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,
|
|
244
320
|
autoUpdate: this.config.autoUpdate !== false,
|
|
245
321
|
lastAttempt: this.config.selfUpdate?.lastAttempt ?? null,
|
|
246
322
|
});
|
|
@@ -276,12 +352,12 @@ export class BridgeDaemon {
|
|
|
276
352
|
return;
|
|
277
353
|
}
|
|
278
354
|
this.log("info", "update.installed", { version, restarting: true });
|
|
279
|
-
this.restartForUpdate();
|
|
355
|
+
await this.restartForUpdate();
|
|
280
356
|
}
|
|
281
357
|
|
|
282
358
|
/** Hand the machine to the new version: the service restarts us. */
|
|
283
|
-
restartForUpdate() {
|
|
284
|
-
this.stop();
|
|
359
|
+
async restartForUpdate() {
|
|
360
|
+
await this.stop();
|
|
285
361
|
if (platform() === "win32") {
|
|
286
362
|
// Task Scheduler only starts at logon — respawn ourselves instead.
|
|
287
363
|
const child = spawn(process.execPath, [process.argv[1], "start"], { detached: true, stdio: "ignore", windowsHide: true });
|
|
@@ -370,6 +446,13 @@ export class BridgeDaemon {
|
|
|
370
446
|
}
|
|
371
447
|
}
|
|
372
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
|
+
*/
|
|
373
456
|
stop() {
|
|
374
457
|
this.stopped = true;
|
|
375
458
|
clearInterval(this.heartbeat);
|
|
@@ -377,10 +460,19 @@ export class BridgeDaemon {
|
|
|
377
460
|
clearInterval(this.modelsTimer);
|
|
378
461
|
clearInterval(this.updateTimer);
|
|
379
462
|
if (this.realtimeRetry) clearTimeout(this.realtimeRetry);
|
|
380
|
-
for (const
|
|
381
|
-
|
|
463
|
+
for (const entry of this.running.values()) entry.ctrl.abort();
|
|
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");
|
|
382
473
|
this.realtime?.disconnect?.();
|
|
383
474
|
this.releaseLock();
|
|
475
|
+
return Promise.race([Promise.allSettled(reports), new Promise((r) => setTimeout(r, 3_000).unref?.())]).then(() => undefined);
|
|
384
476
|
}
|
|
385
477
|
|
|
386
478
|
// ── single instance ────────────────────────────────────────────────
|
|
@@ -435,38 +527,57 @@ export class BridgeDaemon {
|
|
|
435
527
|
return join(this.kaiHome, "state", "inflight.json");
|
|
436
528
|
}
|
|
437
529
|
|
|
530
|
+
/** `[{ turnId, sessionId?, agent?, artifactsDir? }]` — pre-0.5.0 files held bare ids. */
|
|
438
531
|
readInflight() {
|
|
439
532
|
try {
|
|
440
|
-
|
|
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");
|
|
441
535
|
} catch {
|
|
442
536
|
return [];
|
|
443
537
|
}
|
|
444
538
|
}
|
|
445
539
|
|
|
446
|
-
writeInflight(
|
|
540
|
+
writeInflight(entries) {
|
|
447
541
|
try {
|
|
448
542
|
mkdirSync(join(this.kaiHome, "state"), { recursive: true });
|
|
449
|
-
writeFileSync(this.inflightPath, JSON.stringify(
|
|
543
|
+
writeFileSync(this.inflightPath, JSON.stringify(entries));
|
|
450
544
|
} catch {
|
|
451
545
|
/* best effort */
|
|
452
546
|
}
|
|
453
547
|
}
|
|
454
548
|
|
|
455
|
-
rememberInflight(turnId) {
|
|
456
|
-
|
|
549
|
+
rememberInflight(turnId, meta = {}) {
|
|
550
|
+
const rest = this.readInflight().filter((e) => e.turnId !== turnId);
|
|
551
|
+
this.writeInflight([...rest, { turnId, ...meta }]);
|
|
457
552
|
}
|
|
458
553
|
|
|
459
554
|
forgetInflight(turnId) {
|
|
460
|
-
this.writeInflight(this.readInflight().filter((
|
|
555
|
+
this.writeInflight(this.readInflight().filter((e) => e.turnId !== turnId));
|
|
461
556
|
}
|
|
462
557
|
|
|
463
|
-
/**
|
|
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
|
+
*/
|
|
464
564
|
async reportInterruptedTurns() {
|
|
465
|
-
const
|
|
466
|
-
if (!
|
|
565
|
+
const entries = this.readInflight();
|
|
566
|
+
if (!entries.length) return;
|
|
467
567
|
this.writeInflight([]);
|
|
468
|
-
for (const
|
|
469
|
-
|
|
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
|
+
}
|
|
470
581
|
await this.api
|
|
471
582
|
.turnResult(turnId, {
|
|
472
583
|
status: "failed",
|
|
@@ -476,6 +587,70 @@ export class BridgeDaemon {
|
|
|
476
587
|
}
|
|
477
588
|
}
|
|
478
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
|
+
|
|
479
654
|
/**
|
|
480
655
|
* The realtime client retries a refused connection forever, but two
|
|
481
656
|
* states are terminal: six failed reconnects of an ESTABLISHED socket
|
|
@@ -526,11 +701,27 @@ export class BridgeDaemon {
|
|
|
526
701
|
}
|
|
527
702
|
try {
|
|
528
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
|
+
}
|
|
529
714
|
for (const turn of pending?.turns || []) {
|
|
530
|
-
if (this.running.has(turn.turnId)) continue;
|
|
715
|
+
if (this.running.has(turn.turnId) || cancelled.has(String(turn.turnId))) continue;
|
|
531
716
|
this.log("info", "turn.recovered", { turnId: turn.turnId });
|
|
532
717
|
void this.startTurn(turn).catch((err) => this.log("error", "turn.recover.failed", { error: err.message }));
|
|
533
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
|
+
}
|
|
534
725
|
} catch (err) {
|
|
535
726
|
// Older server without the endpoint: the server-side sweep still
|
|
536
727
|
// fails orphaned turns, so this is a nice-to-have, not a must.
|
|
@@ -591,8 +782,10 @@ export class BridgeDaemon {
|
|
|
591
782
|
}
|
|
592
783
|
return this.startTurn(data);
|
|
593
784
|
case "bridge.turn.cancel":
|
|
594
|
-
this.running.get(data.turnId)?.abort();
|
|
785
|
+
this.running.get(data.turnId)?.ctrl.abort();
|
|
595
786
|
return;
|
|
787
|
+
case "bridge.turn.steer":
|
|
788
|
+
return this.steerTurn(data);
|
|
596
789
|
case "bridge.rescan":
|
|
597
790
|
await this.scanRepos();
|
|
598
791
|
// A rescan is the user's "look again" — refresh the model lists too
|
|
@@ -608,11 +801,35 @@ export class BridgeDaemon {
|
|
|
608
801
|
case "bridge.harness.install":
|
|
609
802
|
return this.harnessInstall(data);
|
|
610
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
|
+
}
|
|
611
812
|
this.services.get(data.sessionId)?.stopAll();
|
|
612
813
|
this.services.delete(data.sessionId);
|
|
613
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 });
|
|
614
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);
|
|
615
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);
|
|
616
833
|
return this.previewStart(data);
|
|
617
834
|
case "bridge.preview.stop":
|
|
618
835
|
return this.previewStop(data);
|
|
@@ -632,148 +849,270 @@ export class BridgeDaemon {
|
|
|
632
849
|
* services inside the session's EXISTING worktrees. Resolves paths by
|
|
633
850
|
* existence only — materializing checkouts is the turn path's job, so
|
|
634
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.
|
|
635
857
|
*/
|
|
636
|
-
async previewStart({ sessionId, title, repos }) {
|
|
637
|
-
|
|
638
|
-
|
|
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 }));
|
|
639
870
|
await report({ status: "starting" });
|
|
871
|
+
let runner = null;
|
|
640
872
|
try {
|
|
641
|
-
// Pass 1 — resolve every session repo (checkout, env, config)
|
|
642
|
-
// collect the companion repos their configs ask for, before booting
|
|
643
|
-
// anything: companions must boot FIRST so `${port:x}` cross-refs
|
|
644
|
-
// resolve in the shared per-session port map.
|
|
873
|
+
// Pass 1 — resolve every session repo (checkout, env, config).
|
|
645
874
|
const resolved = [];
|
|
646
|
-
const companionWants = new Map(); // repo key → { optional }
|
|
647
875
|
for (const r of repos || []) {
|
|
648
876
|
const group = this.repoGroups.find((g) => g.key === r.key);
|
|
649
877
|
if (!group) {
|
|
650
|
-
|
|
651
|
-
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 });
|
|
652
879
|
}
|
|
653
880
|
const mode = r.mode || this.config.repoModes?.[r.key] || "worktree";
|
|
654
|
-
const cwd = mode === "local" ? group.primary.path :
|
|
655
|
-
if (!existsSync(cwd)) {
|
|
656
|
-
|
|
657
|
-
status: "error",
|
|
658
|
-
error: `The workspace for this session is gone on this device — send Kai a message to recreate it, then start the preview again.`,
|
|
659
|
-
});
|
|
660
|
-
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 });
|
|
661
884
|
}
|
|
662
885
|
// Gitignored env files never reach a fresh worktree — copy the
|
|
663
886
|
// primary checkout's so env-dependent apps (the dashboard itself)
|
|
664
|
-
// don't boot blank
|
|
887
|
+
// don't boot blank (per service cwd too — ServiceRunner). Never
|
|
888
|
+
// overwrites existing files.
|
|
665
889
|
if (mode !== "local") {
|
|
666
890
|
const copied = copyPrimaryEnvFiles(group.primary.path, cwd);
|
|
667
891
|
if (copied.length) this.log("info", "preview.env.copied", { cwd, copied });
|
|
668
892
|
}
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
// heuristic. Closes the verified-before-config-merge window.
|
|
674
|
-
const config =
|
|
675
|
-
readDevConfig(cwd) ??
|
|
676
|
-
(cwd !== group.primary.path ? readDevConfig(group.primary.path) : null) ??
|
|
677
|
-
detectDevConfig(cwd);
|
|
678
|
-
if (!config) continue;
|
|
679
|
-
if (config.error) {
|
|
680
|
-
await report({ status: "error", error: config.error });
|
|
681
|
-
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;
|
|
682
897
|
}
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
// Required by any config → required overall.
|
|
687
|
-
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;
|
|
688
901
|
}
|
|
689
|
-
|
|
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 });
|
|
690
904
|
}
|
|
691
905
|
if (!resolved.length) {
|
|
692
|
-
|
|
693
|
-
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
|
+
);
|
|
913
|
+
}
|
|
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 });
|
|
694
948
|
}
|
|
695
|
-
const
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
//
|
|
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).
|
|
699
960
|
const companionPreviews = [];
|
|
700
|
-
for (const
|
|
961
|
+
for (const c of bootable) {
|
|
701
962
|
try {
|
|
702
|
-
const
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
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
|
+
}
|
|
708
968
|
} catch (err) {
|
|
709
|
-
if (optional) {
|
|
710
|
-
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" });
|
|
711
972
|
continue;
|
|
712
973
|
}
|
|
713
|
-
|
|
714
|
-
this.services.delete(sessionId);
|
|
715
|
-
await report({ status: "error", error: `Companion ${key}: ${err.message}` });
|
|
716
|
-
return;
|
|
974
|
+
throw err;
|
|
717
975
|
}
|
|
718
976
|
}
|
|
719
|
-
// Pass 3 — the session repos themselves (worktree mode boots its
|
|
720
|
-
// own copy on a free port: it must serve the changed code).
|
|
721
977
|
const previews = [];
|
|
722
978
|
for (const { key, cwd, config, mode } of resolved) {
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
runner.stopAll();
|
|
728
|
-
this.services.delete(sessionId);
|
|
729
|
-
await report({ status: "error", error: err.message });
|
|
730
|
-
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) });
|
|
731
983
|
}
|
|
732
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;
|
|
733
988
|
this.armPreviewIdleTimer(sessionId);
|
|
734
|
-
|
|
989
|
+
return report({ status: "running", previews: urls, urls, landingUrl: landing?.url ?? null, ...(skipped.length ? { skipped } : {}) });
|
|
735
990
|
} catch (err) {
|
|
736
|
-
this.log("error", "preview.start.failed", { sessionId, error: err.message });
|
|
737
|
-
|
|
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;
|
|
738
1054
|
}
|
|
739
1055
|
}
|
|
740
1056
|
|
|
741
1057
|
/**
|
|
742
1058
|
* Boot one repo's services with an already-resolved config; throws a
|
|
743
|
-
*
|
|
1059
|
+
* `PreviewError` carrying the classified log line + code when a service
|
|
744
1060
|
* never becomes ready — a "running" preview must not 404.
|
|
745
1061
|
*/
|
|
746
|
-
async bootRepoWithConfig(runner, cwd, config, mode) {
|
|
747
|
-
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 });
|
|
748
1064
|
const dead = started.services.find((s) => !s.adopted && !s.ready);
|
|
749
1065
|
if (dead) {
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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
|
+
});
|
|
756
1072
|
}
|
|
757
1073
|
return started;
|
|
758
1074
|
}
|
|
759
1075
|
|
|
760
1076
|
/**
|
|
761
|
-
* A companion repo must exist on this machine. Cloned → its group.
|
|
762
|
-
*
|
|
763
|
-
*
|
|
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.
|
|
764
1083
|
*/
|
|
765
|
-
async ensureCompanionCheckout(key, report) {
|
|
1084
|
+
async ensureCompanionCheckout(key, report, { remotes = {} } = {}) {
|
|
766
1085
|
let group = this.repoGroups.find((g) => g.key === key);
|
|
767
1086
|
if (group) return group;
|
|
768
|
-
const
|
|
769
|
-
|
|
770
|
-
|
|
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
|
+
});
|
|
771
1095
|
}
|
|
772
1096
|
await report({ status: "starting", error: null, note: `Cloning ${name}…` });
|
|
773
|
-
this.log("info", "preview.companion.clone", { key });
|
|
774
|
-
|
|
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
|
+
}
|
|
775
1108
|
group = this.repoGroups.find((g) => g.key === key);
|
|
776
|
-
if (!group)
|
|
1109
|
+
if (!group) {
|
|
1110
|
+
throw new PreviewError(`Companion ${key}: the clone finished but the repository was not discovered — its remote resolves to a different key. Point Gleap at the checkout (Locate…).`, {
|
|
1111
|
+
code: "companion_missing",
|
|
1112
|
+
repo: key,
|
|
1113
|
+
detail: { remote, cloneCommand: buildCloneCommand({ remote, target }) },
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
777
1116
|
return group;
|
|
778
1117
|
}
|
|
779
1118
|
|
|
@@ -781,26 +1120,43 @@ export class BridgeDaemon {
|
|
|
781
1120
|
* Previews are for looking at, not for hosting: stop everything after
|
|
782
1121
|
* 30 idle minutes (Lukas 08-26 — was 4h). Re-armed on every (re)start,
|
|
783
1122
|
* cleared on manual stop and session close. Turns never boot services
|
|
784
|
-
* 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.
|
|
785
1126
|
*/
|
|
786
1127
|
armPreviewIdleTimer(sessionId) {
|
|
787
1128
|
this.previewIdleTimers ??= new Map();
|
|
788
1129
|
this.clearPreviewIdleTimer(sessionId);
|
|
789
|
-
const t = setTimeout(() => {
|
|
1130
|
+
const t = setTimeout(async () => {
|
|
790
1131
|
this.previewIdleTimers.delete(sessionId);
|
|
791
1132
|
const runner = this.services.get(sessionId);
|
|
792
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
|
+
}
|
|
793
1139
|
runner.stopAll();
|
|
794
1140
|
this.services.delete(sessionId);
|
|
795
1141
|
this.log("info", "preview.idle.stopped", { sessionId });
|
|
796
1142
|
this.api
|
|
797
|
-
.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." })
|
|
798
1144
|
.catch((err) => this.log("warn", "preview.report.failed", { error: err.message }));
|
|
799
1145
|
}, this.previewIdleMs ?? 30 * 60 * 1000);
|
|
800
1146
|
t.unref?.();
|
|
801
1147
|
this.previewIdleTimers.set(sessionId, t);
|
|
802
1148
|
}
|
|
803
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
|
+
|
|
804
1160
|
clearPreviewIdleTimer(sessionId) {
|
|
805
1161
|
const t = this.previewIdleTimers?.get(sessionId);
|
|
806
1162
|
if (t) clearTimeout(t);
|
|
@@ -808,17 +1164,63 @@ export class BridgeDaemon {
|
|
|
808
1164
|
}
|
|
809
1165
|
|
|
810
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");
|
|
811
1171
|
this.services.get(sessionId)?.stopAll();
|
|
812
1172
|
this.services.delete(sessionId);
|
|
813
1173
|
this.clearPreviewIdleTimer(sessionId);
|
|
814
|
-
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 }));
|
|
815
1202
|
}
|
|
816
1203
|
|
|
817
1204
|
/** One ServiceRunner per session — created only by the on-demand preview; turns reuse it. */
|
|
818
|
-
runnerFor(sessionId
|
|
1205
|
+
runnerFor(sessionId) {
|
|
819
1206
|
let runner = this.services.get(sessionId);
|
|
820
1207
|
if (!runner) {
|
|
821
|
-
|
|
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
|
+
});
|
|
822
1224
|
this.services.set(sessionId, runner);
|
|
823
1225
|
}
|
|
824
1226
|
return runner;
|
|
@@ -839,6 +1241,7 @@ export class BridgeDaemon {
|
|
|
839
1241
|
title: turn.title,
|
|
840
1242
|
});
|
|
841
1243
|
bound.push({ key: r.key, ...ws });
|
|
1244
|
+
if (ws.deps) this.log("info", "deps.seed", { repo: r.key, ...ws.deps });
|
|
842
1245
|
// Remember the choice per repo (the UI asks once, then sticks).
|
|
843
1246
|
this.config.repoModes = { ...(this.config.repoModes || {}), [r.key]: mode };
|
|
844
1247
|
}
|
|
@@ -897,10 +1300,16 @@ export class BridgeDaemon {
|
|
|
897
1300
|
const { turnId } = turn;
|
|
898
1301
|
if (this.running.has(turnId)) return;
|
|
899
1302
|
const ctrl = new AbortController();
|
|
900
|
-
|
|
1303
|
+
const entry = { ctrl, control: null, sessionId: turn.sessionId };
|
|
1304
|
+
this.running.set(turnId, entry);
|
|
901
1305
|
const releaseAwake = keepAwake();
|
|
902
1306
|
let outcome = null;
|
|
903
|
-
|
|
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 });
|
|
904
1313
|
const batcher = createEventBatcher({ api: this.api, turnId, onError: (err) => this.log("warn", "events.post.failed", { error: err.message }) });
|
|
905
1314
|
try {
|
|
906
1315
|
const profile = resolveProfiles(this.config, this.kaiHome).find((p) => p.id === turn.profileId) ?? { id: "gleap-key", kind: "gleap-key", harness: turn.harness };
|
|
@@ -910,70 +1319,143 @@ export class BridgeDaemon {
|
|
|
910
1319
|
// local paths — the prompt lists them.
|
|
911
1320
|
const workDir = bound[0]?.cwd;
|
|
912
1321
|
if (!workDir) throw new Error("Turn has no repositories.");
|
|
913
|
-
const repoNote = this.repoBrief(turn, bound);
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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
|
+
}
|
|
920
1369
|
const res = await runTurn({
|
|
921
1370
|
turn: { ...turn, task: `${turn.task}${repoNote}${previewNote}`, mcpServers },
|
|
922
1371
|
profile,
|
|
923
1372
|
workDir,
|
|
924
1373
|
kaiHome: this.kaiHome,
|
|
1374
|
+
// The verify MCP's request policy (KAI_VERIFY_*) — host-only, never in the prompt.
|
|
1375
|
+
extraEnv: verify?.env ?? null,
|
|
925
1376
|
signal: ctrl.signal,
|
|
926
|
-
|
|
1377
|
+
onSpawn: (handle) => {
|
|
1378
|
+
entry.control = handle.control;
|
|
1379
|
+
},
|
|
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
|
+
},
|
|
927
1396
|
onLog: (l) => this.log("debug", "runner", { line: l.slice(0, 500) }),
|
|
928
1397
|
});
|
|
929
1398
|
await batcher.flush();
|
|
930
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;
|
|
931
1403
|
const changes = [...bound, ...this.adoptSessionWorktrees(turn, bound)].map((b) => {
|
|
932
1404
|
ensureCommitExcludes(b.cwd, { allowDevConfig: !!turn.allowDevConfig });
|
|
933
|
-
// A
|
|
1405
|
+
// A read-only turn must leave the worktree as it found it — see
|
|
934
1406
|
// discardChanges. Local checkouts are the user's; only report.
|
|
935
|
-
if (
|
|
1407
|
+
if (readOnlyTurn) {
|
|
936
1408
|
const leaked = b.mode === "worktree" ? discardChanges(b.cwd).discarded : collectChanges(b.cwd).files;
|
|
937
1409
|
if (leaked.length > 0) {
|
|
938
|
-
|
|
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" });
|
|
939
1412
|
batcher.push({
|
|
940
1413
|
type: "text",
|
|
941
1414
|
message:
|
|
942
1415
|
b.mode === "worktree"
|
|
943
|
-
?
|
|
944
|
-
:
|
|
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" : ""}.`,
|
|
945
1418
|
});
|
|
946
1419
|
}
|
|
947
1420
|
}
|
|
948
1421
|
const diff = collectChanges(b.cwd);
|
|
949
1422
|
// Build turns in worktree mode publish the session branch so the
|
|
950
|
-
// Server can open the PR;
|
|
951
|
-
const shouldPush = completed && b.mode === "worktree" && !
|
|
1423
|
+
// Server can open the PR; read-only turns and local mode never push.
|
|
1424
|
+
const shouldPush = completed && b.mode === "worktree" && !readOnlyTurn && diff.files.length > 0;
|
|
952
1425
|
const push = shouldPush
|
|
953
1426
|
? commitAndPush(b.cwd, {
|
|
954
1427
|
branch: b.branch,
|
|
955
1428
|
allowDevConfig: !!turn.allowDevConfig,
|
|
956
1429
|
message: `${turn.title || "Kai Code changes"}\n\nSession ${turn.sessionId} · run on ${this.config.device?.name || "a paired device"}`,
|
|
957
1430
|
})
|
|
958
|
-
:
|
|
1431
|
+
: isVerify
|
|
1432
|
+
? false
|
|
1433
|
+
: null;
|
|
959
1434
|
// `cwd` travels with the change so the dashboard can point at
|
|
960
1435
|
// work that stayed on this machine (files but no push).
|
|
961
|
-
|
|
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 : {}) };
|
|
962
1440
|
});
|
|
963
|
-
// The
|
|
1441
|
+
// The read-only notices above were queued AFTER the post-turn
|
|
964
1442
|
// flush; land them before the result closes the turn (the Server
|
|
965
1443
|
// answers 410 for events on an ended turn).
|
|
966
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
|
+
}
|
|
967
1449
|
// Built OUTSIDE the report call: if posting the result throws, the
|
|
968
1450
|
// catch below must not turn a finished turn into a failed one. The
|
|
969
1451
|
// work is already committed and pushed at this point.
|
|
970
1452
|
outcome = {
|
|
971
1453
|
status: ctrl.signal.aborted ? "cancelled" : res.rateLimited ? "rate_limited" : res.code === 0 ? "completed" : "failed",
|
|
972
1454
|
exitCode: res.code,
|
|
973
|
-
result: res.result,
|
|
1455
|
+
result: verify?.redact?.size ? redactDeep(res.result, verify.redact) : res.result,
|
|
974
1456
|
// The runner's own failure text (e.g. the engine's usage-limit
|
|
975
1457
|
// message) — the Server prefers this over its generic fallback.
|
|
976
|
-
...(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) } : {}),
|
|
977
1459
|
changes,
|
|
978
1460
|
profileId: profile.id,
|
|
979
1461
|
};
|
|
@@ -992,11 +1474,865 @@ export class BridgeDaemon {
|
|
|
992
1474
|
})
|
|
993
1475
|
.catch((err) => this.log("error", "result.lost", { turnId, error: err.message }));
|
|
994
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?.();
|
|
995
1481
|
this.forgetInflight(turnId);
|
|
996
1482
|
releaseAwake();
|
|
997
1483
|
this.running.delete(turnId);
|
|
998
|
-
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…");
|
|
999
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(() => {});
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
/**
|
|
2317
|
+
* Mid-turn steering: forward the message to the running runner's stdin
|
|
2318
|
+
* control channel. The runner answers with a `steer` turn event
|
|
2319
|
+
* (outcome injected / promptRequired / …) that rides the normal event
|
|
2320
|
+
* batcher. When the turn is not running here (already ended, never
|
|
2321
|
+
* started, laptop was asleep) we answer `promptRequired` ourselves so
|
|
2322
|
+
* the Server queues the message for the next turn instead of waiting
|
|
2323
|
+
* for its timeout.
|
|
2324
|
+
*/
|
|
2325
|
+
async steerTurn({ turnId, steer }) {
|
|
2326
|
+
const id = String(steer?.id || "");
|
|
2327
|
+
const text = typeof steer?.text === "string" ? steer.text : "";
|
|
2328
|
+
if (!turnId || !id || !text.trim()) return;
|
|
2329
|
+
const entry = this.running.get(turnId);
|
|
2330
|
+
const delivered = entry?.control ? entry.control({ type: "steer", id, text }) : false;
|
|
2331
|
+
this.log("info", delivered ? "turn.steer" : "turn.steer.miss", { turnId, id, running: !!entry });
|
|
2332
|
+
if (delivered) return;
|
|
2333
|
+
await this.api
|
|
2334
|
+
.turnEvents(turnId, [{ type: "steer", id, outcome: "promptRequired" }])
|
|
2335
|
+
.catch((err) => this.log("warn", "turn.steer.report.failed", { turnId, id, error: err?.message }));
|
|
1000
2336
|
}
|
|
1001
2337
|
|
|
1002
2338
|
/**
|
|
@@ -1052,21 +2388,70 @@ export class BridgeDaemon {
|
|
|
1052
2388
|
return { note: notes.join(""), hasLivePreview: previews.length > 0 };
|
|
1053
2389
|
}
|
|
1054
2390
|
|
|
1055
|
-
|
|
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 }) {
|
|
1056
2400
|
// Prefer the directory the machine's repos already live in (e.g.
|
|
1057
2401
|
// ~/Documents/Gleap), not the bare scan root that discovered them
|
|
1058
2402
|
// (~/Documents) — "Clone here" should land next to the other checkouts.
|
|
1059
2403
|
const root = preferredCloneRoot(this.repoGroups, (this.config.roots || [])[0] ?? defaultRoots()[0]);
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
}
|
|
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
|
+
}
|
|
1067
2451
|
await this.scanRepos();
|
|
1068
|
-
await this.hello();
|
|
2452
|
+
await this.hello().catch(() => {});
|
|
1069
2453
|
if (commandId) await this.api.commandAck(commandId, { ok: true, path: target });
|
|
2454
|
+
return target;
|
|
1070
2455
|
}
|
|
1071
2456
|
|
|
1072
2457
|
/**
|