agent-board-runner 0.1.13 → 0.1.14

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.
Files changed (3) hide show
  1. package/dist/cli.js +200 -15
  2. package/dist/index.js +125 -13
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -7,13 +7,14 @@ import { resolve as resolve4 } from "node:path";
7
7
 
8
8
  // src/auth.ts
9
9
  import { createClient } from "@supabase/supabase-js";
10
+ import { createPrivateKey, randomInt, sign } from "node:crypto";
10
11
  import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
11
12
  import { createServer } from "node:http";
12
13
  import { join as join2 } from "node:path";
13
14
  import open from "open";
14
15
 
15
16
  // src/config.ts
16
- import { randomUUID } from "node:crypto";
17
+ import { generateKeyPairSync, randomUUID } from "node:crypto";
17
18
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
19
  import { hostname, homedir } from "node:os";
19
20
  import { join, resolve } from "node:path";
@@ -27,8 +28,21 @@ function saveConfig(config) {
27
28
  if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
28
29
  writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
29
30
  }
31
+ function generateRecoveryKeypair() {
32
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519", {
33
+ publicKeyEncoding: { type: "spki", format: "pem" },
34
+ privateKeyEncoding: { type: "pkcs8", format: "pem" }
35
+ });
36
+ return { recoveryPublicKey: publicKey, recoveryPrivateKey: privateKey };
37
+ }
30
38
  function createRunnerIdentity() {
31
- return { runnerId: randomUUID(), authToken: randomUUID() };
39
+ return { runnerId: randomUUID(), authToken: randomUUID(), ...generateRecoveryKeypair() };
40
+ }
41
+ function ensureRecoveryKeypair(config) {
42
+ if (config.recoveryPublicKey && config.recoveryPrivateKey) return config;
43
+ Object.assign(config, generateRecoveryKeypair());
44
+ saveConfig(config);
45
+ return config;
32
46
  }
33
47
  function applyAllowedRoot(config, explicitPath) {
34
48
  const raw = explicitPath ?? process.env.AGENT_BOARD_ALLOWED_ROOT;
@@ -162,6 +176,110 @@ async function logout() {
162
176
  await supabase.auth.signOut();
163
177
  if (existsSync2(SESSION_FILE)) rmSync(SESSION_FILE);
164
178
  }
179
+ function resolveWebUrl() {
180
+ const url = process.env.AGENT_BOARD_WEB_URL;
181
+ if (!url) {
182
+ throw new Error(
183
+ "AGENT_BOARD_WEB_URL is not set \u2014 point it at the deployed agent-board web app (e.g. https://board.example.com) to use `request-approval` on a machine with no browser."
184
+ );
185
+ }
186
+ return url.replace(/\/+$/, "");
187
+ }
188
+ var CONFIRMATION_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
189
+ function generateConfirmationCode() {
190
+ let code = "";
191
+ for (let i = 0; i < 6; i += 1) {
192
+ code += CONFIRMATION_CODE_ALPHABET[randomInt(CONFIRMATION_CODE_ALPHABET.length)];
193
+ }
194
+ return code;
195
+ }
196
+ async function postJson(url, body) {
197
+ const response = await fetch(url, {
198
+ method: "POST",
199
+ headers: { "Content-Type": "application/json" },
200
+ body: JSON.stringify(body)
201
+ });
202
+ const data = await response.json().catch(() => ({}));
203
+ if (!response.ok) throw new Error(data.error ?? `Request to ${url} failed with status ${response.status}`);
204
+ return data;
205
+ }
206
+ var POLL_INTERVAL_MS = 4e3;
207
+ var POLL_TIMEOUT_MS = 15 * 60 * 1e3;
208
+ async function requestApproval(options) {
209
+ const webUrl = resolveWebUrl();
210
+ const confirmationCode = generateConfirmationCode();
211
+ const { requestId } = await postJson(`${webUrl}/api/public/runner-approval/request`, {
212
+ runnerName: options.runnerName,
213
+ hostname: options.hostname,
214
+ confirmationCode,
215
+ expectedEmail: options.expectedEmail
216
+ });
217
+ console.log("");
218
+ console.log("Este runner precisa ser aprovado pela web antes de continuar.");
219
+ console.log(` C\xF3digo de confirma\xE7\xE3o: ${confirmationCode}`);
220
+ console.log(` Runner: ${options.runnerName} (${options.hostname})`);
221
+ console.log(` Abra ${webUrl}/perfil, encontre este runner em "Runners pendentes de aprova\xE7\xE3o" e digite o c\xF3digo acima.`);
222
+ console.log("Aguardando aprova\xE7\xE3o...");
223
+ console.log("");
224
+ const deadline = Date.now() + POLL_TIMEOUT_MS;
225
+ let approved = false;
226
+ while (Date.now() < deadline) {
227
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
228
+ const statusUrl = `${webUrl}/api/public/runner-approval/status?requestId=${encodeURIComponent(requestId)}`;
229
+ const statusResponse = await fetch(statusUrl);
230
+ const { status } = await statusResponse.json();
231
+ if (status === "approved") {
232
+ approved = true;
233
+ break;
234
+ }
235
+ if (status === "expired" || !statusResponse.ok) {
236
+ throw new Error("A solicita\xE7\xE3o de aprova\xE7\xE3o expirou antes de ser aprovada. Rode `request-approval` de novo.");
237
+ }
238
+ }
239
+ if (!approved) {
240
+ throw new Error("Tempo esgotado esperando aprova\xE7\xE3o. Rode `request-approval` de novo.");
241
+ }
242
+ const { accessToken, refreshToken, email } = await postJson(
243
+ `${webUrl}/api/public/runner-approval/claim`,
244
+ { requestId, confirmationCode }
245
+ );
246
+ const supabase = createAuthedSupabaseClient();
247
+ const { error } = await supabase.auth.setSession({ access_token: accessToken, refresh_token: refreshToken });
248
+ if (error) throw error;
249
+ return { email };
250
+ }
251
+ async function recoverSession(config) {
252
+ const webUrl = resolveWebUrl();
253
+ let nonce;
254
+ try {
255
+ ({ nonce } = await postJson(`${webUrl}/api/public/runner-recovery/challenge`, {
256
+ runnerId: config.runnerId
257
+ }));
258
+ } catch (error2) {
259
+ throw new Error(
260
+ `Recupera\xE7\xE3o autom\xE1tica indispon\xEDvel para este runner (${error2 instanceof Error ? error2.message : error2}). Rode "agent-board-runner request-approval --email <seu-email>" para configur\xE1-lo (uma \xFAnica vez).`
261
+ );
262
+ }
263
+ const signature = sign(null, Buffer.from(nonce, "utf8"), createPrivateKey(config.recoveryPrivateKey)).toString("base64");
264
+ const { accessToken, refreshToken, email } = await postJson(
265
+ `${webUrl}/api/public/runner-recovery/verify`,
266
+ { runnerId: config.runnerId, signature }
267
+ );
268
+ const supabase = createAuthedSupabaseClient();
269
+ const { error } = await supabase.auth.setSession({ access_token: accessToken, refresh_token: refreshToken });
270
+ if (error) throw error;
271
+ console.log(`Sess\xE3o recuperada automaticamente (${email}) \u2014 sem interven\xE7\xE3o manual.`);
272
+ return { email };
273
+ }
274
+ async function ensureSession(config) {
275
+ const probe = createAuthedSupabaseClient();
276
+ const existing = await getSession(probe);
277
+ if (existing) return { client: probe, session: existing };
278
+ await recoverSession(config);
279
+ const client = createAuthedSupabaseClient();
280
+ const session = await requireSession(client);
281
+ return { client, session };
282
+ }
165
283
 
166
284
  // ../../packages/shared-types/dist/index.js
167
285
  var BOARD_COLUMNS = [
@@ -834,11 +952,26 @@ async function updateSession(supabase, sessionId, changes, activityContext) {
834
952
  }
835
953
  }
836
954
  async function getActiveSessionForCard(supabase, cardId) {
837
- const { data, error } = await supabase.from("sessions").select("id, status, agent_state").eq("card_id", cardId).in("status", ["queued", "running", "waiting_input", "paused"]).order("created_at", { ascending: false }).limit(1).maybeSingle();
955
+ const { data, error } = await supabase.from("sessions").select("id, status, agent_state, runner_id").eq("card_id", cardId).in("status", ["queued", "running", "waiting_input", "paused"]).order("created_at", { ascending: false }).limit(1).maybeSingle();
838
956
  if (error) throw error;
839
957
  if (!data) return null;
840
958
  const row = data;
841
- return { id: row.id, status: row.status, agentState: row.agent_state };
959
+ return { id: row.id, status: row.status, agentState: row.agent_state, runnerId: row.runner_id };
960
+ }
961
+ var STALE_RUNNER_HEARTBEAT_MS = 8 * 6e4;
962
+ async function getRunnerHeartbeats(supabase, runnerIds) {
963
+ const uniqueIds = Array.from(new Set(runnerIds));
964
+ if (uniqueIds.length === 0) return /* @__PURE__ */ new Map();
965
+ const { data, error } = await supabase.from("runners").select("id, last_heartbeat_at").in("id", uniqueIds);
966
+ if (error) throw error;
967
+ return new Map(data.map((row) => [row.id, row.last_heartbeat_at]));
968
+ }
969
+ function isRunnerHeartbeatStale(lastHeartbeatAt) {
970
+ return lastHeartbeatAt === null || Date.now() - new Date(lastHeartbeatAt).getTime() > STALE_RUNNER_HEARTBEAT_MS;
971
+ }
972
+ async function claimReferenceRunner(supabase, projectId, runnerId) {
973
+ const { error } = await supabase.from("projects").update({ runner_id: runnerId }).eq("id", projectId);
974
+ if (error) throw error;
842
975
  }
843
976
  async function pauseRunningSession(supabase, sessionId, activityContext) {
844
977
  const { data, error } = await supabase.from("sessions").update({ status: "paused" }).eq("id", sessionId).eq("status", "running").select("id");
@@ -1487,7 +1620,7 @@ function ensureMcpServersTrusted(dirPath) {
1487
1620
  var SESSION_ID = "usage-monitor";
1488
1621
  var TICKET = randomUUID4();
1489
1622
  var SCRATCH_DIR = join8(tmpdir5(), "agent-board-usage-monitor");
1490
- var POLL_INTERVAL_MS = Number(process.env.AGENT_BOARD_USAGE_POLL_INTERVAL_MS ?? 15 * 6e4);
1623
+ var POLL_INTERVAL_MS2 = Number(process.env.AGENT_BOARD_USAGE_POLL_INTERVAL_MS ?? 15 * 6e4);
1491
1624
  var IDLE_MS = 2e3;
1492
1625
  var HARD_TIMEOUT_MS = 6e4;
1493
1626
  function usageMonitorSessionId() {
@@ -1544,7 +1677,7 @@ function startUsageMonitor() {
1544
1677
  mkdirSync3(SCRATCH_DIR, { recursive: true });
1545
1678
  ensureDirTrusted(SCRATCH_DIR);
1546
1679
  pollUsageOnce();
1547
- setInterval(pollUsageOnce, POLL_INTERVAL_MS);
1680
+ setInterval(pollUsageOnce, POLL_INTERVAL_MS2);
1548
1681
  }
1549
1682
 
1550
1683
  // src/filesServer.ts
@@ -2719,7 +2852,20 @@ async function enqueueCard(supabase, config, card) {
2719
2852
  inFlightCardIds.add(card.id);
2720
2853
  try {
2721
2854
  const active = await getActiveSessionForCard(supabase, card.id);
2722
- if (active) return;
2855
+ if (active) {
2856
+ if (active.runnerId !== config.runnerId) {
2857
+ const holderHeartbeat = active.runnerId === null ? null : (await getRunnerHeartbeats(supabase, [active.runnerId])).get(active.runnerId) ?? null;
2858
+ if (active.runnerId !== null && !isRunnerHeartbeatStale(holderHeartbeat)) return;
2859
+ logger.card.warn(
2860
+ card.id,
2861
+ `Active session ${active.id} belongs to runner ${active.runnerId ?? "(none)"}, which looks offline \u2014 reclaiming for ${config.runnerId}.`
2862
+ );
2863
+ await updateSession(supabase, active.id, { status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString() });
2864
+ activeSessionByCard.delete(card.id);
2865
+ } else {
2866
+ return;
2867
+ }
2868
+ }
2723
2869
  await createQueuedSession(supabase, config, card);
2724
2870
  } finally {
2725
2871
  inFlightCardIds.delete(card.id);
@@ -3590,10 +3736,24 @@ async function syncRegisteredProjects(supabase, config, registeredPaths) {
3590
3736
  const localById = new Map(config.registeredProjects.map((project) => [project.id, project]));
3591
3737
  const linkedRunnerIds = await listProjectRunnerIds(supabase, config.runnerId);
3592
3738
  let changed = false;
3739
+ const otherReferenceRunnerIds = remoteProjects.filter((remote) => localById.has(remote.id) && remote.runner_id !== config.runnerId).map((remote) => remote.runner_id);
3740
+ const referenceHeartbeats = await getRunnerHeartbeats(supabase, otherReferenceRunnerIds);
3593
3741
  for (const remote of remoteProjects) {
3594
3742
  const local = localById.get(remote.id);
3595
3743
  if (local) {
3596
- const isReferenceRunner = remote.runner_id === config.runnerId;
3744
+ let isReferenceRunner = remote.runner_id === config.runnerId;
3745
+ if (!isReferenceRunner && isRunnerHeartbeatStale(referenceHeartbeats.get(remote.runner_id) ?? null)) {
3746
+ try {
3747
+ await claimReferenceRunner(supabase, remote.id, config.runnerId);
3748
+ isReferenceRunner = true;
3749
+ logger.runner.info(
3750
+ "PROJECT_SYNC",
3751
+ `Claimed reference runner for project ${remote.id} \u2014 previous one (${remote.runner_id}) looks offline.`
3752
+ );
3753
+ } catch (err) {
3754
+ logger.runner.error("PROJECT_SYNC", `Failed to claim reference runner for project ${remote.id}:`, err);
3755
+ }
3756
+ }
3597
3757
  if (local.isReferenceRunner !== isReferenceRunner) {
3598
3758
  local.isReferenceRunner = isReferenceRunner;
3599
3759
  changed = true;
@@ -3837,7 +3997,7 @@ async function startRunner() {
3837
3997
  logger.runner.error("BOOT", 'No runner set up yet. Run "agent-board-runner start --path <repos-root>" first.');
3838
3998
  process.exit(1);
3839
3999
  }
3840
- const config = applyRunnerName(applyAllowedRoot(loadedConfig));
4000
+ const config = ensureRecoveryKeypair(applyRunnerName(applyAllowedRoot(loadedConfig)));
3841
4001
  if (!config.allowedRootPath) {
3842
4002
  logger.runner.error(
3843
4003
  "BOOT",
@@ -3845,10 +4005,9 @@ async function startRunner() {
3845
4005
  );
3846
4006
  process.exit(1);
3847
4007
  }
3848
- const supabase = createAuthedSupabaseClient();
3849
- let session;
4008
+ let supabase, session;
3850
4009
  try {
3851
- session = await requireSession(supabase);
4010
+ ({ client: supabase, session } = await ensureSession(config));
3852
4011
  } catch (error) {
3853
4012
  logger.runner.error("BOOT", error instanceof Error ? error.message : String(error));
3854
4013
  process.exit(1);
@@ -3860,7 +4019,7 @@ async function startRunner() {
3860
4019
  if (event === "SIGNED_OUT") {
3861
4020
  logger.runner.error(
3862
4021
  "AUTH",
3863
- 'Supabase session was lost (refresh failed or was revoked) \u2014 every write from now on will fail row-level security checks. Run "agent-board-runner login" again and restart the runner.'
4022
+ "Supabase session was lost (refresh failed or was revoked) \u2014 exiting so the process manager restarts this runner; the next boot recovers automatically."
3864
4023
  );
3865
4024
  process.exit(1);
3866
4025
  }
@@ -3885,6 +4044,10 @@ async function startRunner() {
3885
4044
  user_id: config.ownerUserId,
3886
4045
  name: config.runnerName,
3887
4046
  auth_token_hash: config.authToken,
4047
+ // Re-sent on every boot with a real session — idempotent (same local key every time) and
4048
+ // what makes automatic recovery possible the next time this runner loses its session, see
4049
+ // ensureSession()/recoverSession() in auth.ts.
4050
+ recovery_public_key: config.recoveryPublicKey,
3888
4051
  version: RUNNER_VERSION
3889
4052
  });
3890
4053
  if (runnerError) {
@@ -4064,7 +4227,7 @@ function parseArgs(argv) {
4064
4227
  }
4065
4228
  function bootstrapConfig() {
4066
4229
  const existing = loadConfig();
4067
- if (existing) return existing;
4230
+ if (existing) return ensureRecoveryKeypair(existing);
4068
4231
  const identity = createRunnerIdentity();
4069
4232
  const config = {
4070
4233
  ...identity,
@@ -4081,6 +4244,22 @@ async function cmdLogin() {
4081
4244
  const { email } = await login();
4082
4245
  console.log(`Signed in as ${email}.`);
4083
4246
  }
4247
+ async function cmdRequestApproval(args2) {
4248
+ if (!args2.email) {
4249
+ console.error(
4250
+ "Use: agent-board-runner request-approval --email <seu-email-no-agent-board> [--name <nome-do-runner>]\nSem --email, ningu\xE9m saberia de quem \xE9 esta solicita\xE7\xE3o na tela de aprova\xE7\xE3o."
4251
+ );
4252
+ process.exit(1);
4253
+ }
4254
+ const config = bootstrapConfig();
4255
+ const { email } = await requestApproval({
4256
+ runnerName: args2.name ?? config.runnerName,
4257
+ hostname: hostname2(),
4258
+ expectedEmail: args2.email
4259
+ });
4260
+ console.log(`Aprovado! Sess\xE3o criada para ${email}.`);
4261
+ console.log("A partir de agora, se este runner perder a sess\xE3o, ele se recupera sozinho (sem precisar rodar isto de novo).");
4262
+ }
4084
4263
  async function cmdLogout() {
4085
4264
  await logout();
4086
4265
  console.log("Signed out.");
@@ -4111,6 +4290,9 @@ switch (command) {
4111
4290
  case "login":
4112
4291
  cmdLogin().catch(fail);
4113
4292
  break;
4293
+ case "request-approval":
4294
+ cmdRequestApproval(args).catch(fail);
4295
+ break;
4114
4296
  case "logout":
4115
4297
  cmdLogout().catch(fail);
4116
4298
  break;
@@ -4121,8 +4303,11 @@ switch (command) {
4121
4303
  cmdStart(args).catch(fail);
4122
4304
  break;
4123
4305
  default:
4124
- console.log("Usage: agent-board-runner <login|logout|whoami|start>");
4306
+ console.log("Usage: agent-board-runner <login|request-approval|logout|whoami|start>");
4125
4307
  console.log(" login sign in with Google");
4308
+ console.log(" request-approval --email <email> [--name <nome>]");
4309
+ console.log(" login sem navegador: aprove pela web (AGENT_BOARD_WEB_URL precisa estar configurada)");
4310
+ console.log(" s\xF3 precisa ser rodado uma vez \u2014 depois disso, `start` recupera a sess\xE3o sozinho se ela cair");
4126
4311
  console.log(" logout sign out and clear the local session");
4127
4312
  console.log(" whoami show the signed-in account");
4128
4313
  console.log(" start [--path <repos-root>] run the runner (repos root can also come from AGENT_BOARD_ALLOWED_ROOT)");
package/dist/index.js CHANGED
@@ -672,11 +672,26 @@ async function updateSession(supabase, sessionId, changes, activityContext) {
672
672
  }
673
673
  }
674
674
  async function getActiveSessionForCard(supabase, cardId) {
675
- const { data, error } = await supabase.from("sessions").select("id, status, agent_state").eq("card_id", cardId).in("status", ["queued", "running", "waiting_input", "paused"]).order("created_at", { ascending: false }).limit(1).maybeSingle();
675
+ const { data, error } = await supabase.from("sessions").select("id, status, agent_state, runner_id").eq("card_id", cardId).in("status", ["queued", "running", "waiting_input", "paused"]).order("created_at", { ascending: false }).limit(1).maybeSingle();
676
676
  if (error) throw error;
677
677
  if (!data) return null;
678
678
  const row = data;
679
- return { id: row.id, status: row.status, agentState: row.agent_state };
679
+ return { id: row.id, status: row.status, agentState: row.agent_state, runnerId: row.runner_id };
680
+ }
681
+ var STALE_RUNNER_HEARTBEAT_MS = 8 * 6e4;
682
+ async function getRunnerHeartbeats(supabase, runnerIds) {
683
+ const uniqueIds = Array.from(new Set(runnerIds));
684
+ if (uniqueIds.length === 0) return /* @__PURE__ */ new Map();
685
+ const { data, error } = await supabase.from("runners").select("id, last_heartbeat_at").in("id", uniqueIds);
686
+ if (error) throw error;
687
+ return new Map(data.map((row) => [row.id, row.last_heartbeat_at]));
688
+ }
689
+ function isRunnerHeartbeatStale(lastHeartbeatAt) {
690
+ return lastHeartbeatAt === null || Date.now() - new Date(lastHeartbeatAt).getTime() > STALE_RUNNER_HEARTBEAT_MS;
691
+ }
692
+ async function claimReferenceRunner(supabase, projectId, runnerId) {
693
+ const { error } = await supabase.from("projects").update({ runner_id: runnerId }).eq("id", projectId);
694
+ if (error) throw error;
680
695
  }
681
696
  async function pauseRunningSession(supabase, sessionId, activityContext) {
682
697
  const { data, error } = await supabase.from("sessions").update({ status: "paused" }).eq("id", sessionId).eq("status", "running").select("id");
@@ -2165,11 +2180,13 @@ function attachAgentEventsServer() {
2165
2180
 
2166
2181
  // src/auth.ts
2167
2182
  import { createClient } from "@supabase/supabase-js";
2183
+ import { createPrivateKey, randomInt, sign } from "node:crypto";
2168
2184
  import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync4, rmSync as rmSync2, writeFileSync as writeFileSync7 } from "node:fs";
2169
2185
  import { join as join9 } from "node:path";
2170
2186
  import open from "open";
2171
2187
 
2172
2188
  // src/config.ts
2189
+ import { generateKeyPairSync, randomUUID as randomUUID5 } from "node:crypto";
2173
2190
  import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync6 } from "node:fs";
2174
2191
  import { hostname, homedir as homedir4 } from "node:os";
2175
2192
  import { join as join8, resolve as resolve2 } from "node:path";
@@ -2183,6 +2200,19 @@ function saveConfig(config) {
2183
2200
  if (!existsSync3(CONFIG_DIR)) mkdirSync3(CONFIG_DIR, { recursive: true });
2184
2201
  writeFileSync6(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
2185
2202
  }
2203
+ function generateRecoveryKeypair() {
2204
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519", {
2205
+ publicKeyEncoding: { type: "spki", format: "pem" },
2206
+ privateKeyEncoding: { type: "pkcs8", format: "pem" }
2207
+ });
2208
+ return { recoveryPublicKey: publicKey, recoveryPrivateKey: privateKey };
2209
+ }
2210
+ function ensureRecoveryKeypair(config) {
2211
+ if (config.recoveryPublicKey && config.recoveryPrivateKey) return config;
2212
+ Object.assign(config, generateRecoveryKeypair());
2213
+ saveConfig(config);
2214
+ return config;
2215
+ }
2186
2216
  function applyAllowedRoot(config, explicitPath) {
2187
2217
  const raw = explicitPath ?? process.env.AGENT_BOARD_ALLOWED_ROOT;
2188
2218
  if (!raw) return config;
@@ -2263,6 +2293,58 @@ async function requireSession(supabase) {
2263
2293
  }
2264
2294
  return session;
2265
2295
  }
2296
+ function resolveWebUrl() {
2297
+ const url = process.env.AGENT_BOARD_WEB_URL;
2298
+ if (!url) {
2299
+ throw new Error(
2300
+ "AGENT_BOARD_WEB_URL is not set \u2014 point it at the deployed agent-board web app (e.g. https://board.example.com) to use `request-approval` on a machine with no browser."
2301
+ );
2302
+ }
2303
+ return url.replace(/\/+$/, "");
2304
+ }
2305
+ async function postJson(url, body) {
2306
+ const response = await fetch(url, {
2307
+ method: "POST",
2308
+ headers: { "Content-Type": "application/json" },
2309
+ body: JSON.stringify(body)
2310
+ });
2311
+ const data = await response.json().catch(() => ({}));
2312
+ if (!response.ok) throw new Error(data.error ?? `Request to ${url} failed with status ${response.status}`);
2313
+ return data;
2314
+ }
2315
+ var POLL_TIMEOUT_MS = 15 * 60 * 1e3;
2316
+ async function recoverSession(config) {
2317
+ const webUrl = resolveWebUrl();
2318
+ let nonce;
2319
+ try {
2320
+ ({ nonce } = await postJson(`${webUrl}/api/public/runner-recovery/challenge`, {
2321
+ runnerId: config.runnerId
2322
+ }));
2323
+ } catch (error2) {
2324
+ throw new Error(
2325
+ `Recupera\xE7\xE3o autom\xE1tica indispon\xEDvel para este runner (${error2 instanceof Error ? error2.message : error2}). Rode "agent-board-runner request-approval --email <seu-email>" para configur\xE1-lo (uma \xFAnica vez).`
2326
+ );
2327
+ }
2328
+ const signature = sign(null, Buffer.from(nonce, "utf8"), createPrivateKey(config.recoveryPrivateKey)).toString("base64");
2329
+ const { accessToken, refreshToken, email } = await postJson(
2330
+ `${webUrl}/api/public/runner-recovery/verify`,
2331
+ { runnerId: config.runnerId, signature }
2332
+ );
2333
+ const supabase = createAuthedSupabaseClient();
2334
+ const { error } = await supabase.auth.setSession({ access_token: accessToken, refresh_token: refreshToken });
2335
+ if (error) throw error;
2336
+ console.log(`Sess\xE3o recuperada automaticamente (${email}) \u2014 sem interven\xE7\xE3o manual.`);
2337
+ return { email };
2338
+ }
2339
+ async function ensureSession(config) {
2340
+ const probe = createAuthedSupabaseClient();
2341
+ const existing = await getSession(probe);
2342
+ if (existing) return { client: probe, session: existing };
2343
+ await recoverSession(config);
2344
+ const client = createAuthedSupabaseClient();
2345
+ const session = await requireSession(client);
2346
+ return { client, session };
2347
+ }
2266
2348
 
2267
2349
  // src/branchWatcher.ts
2268
2350
  import { execFile as execFile3 } from "node:child_process";
@@ -2336,10 +2418,10 @@ import { join as join12 } from "node:path";
2336
2418
  import { promisify as promisify4 } from "node:util";
2337
2419
 
2338
2420
  // src/orchestrator.ts
2339
- import { randomUUID as randomUUID6 } from "node:crypto";
2421
+ import { randomUUID as randomUUID7 } from "node:crypto";
2340
2422
 
2341
2423
  // src/agentSession.ts
2342
- import { randomUUID as randomUUID5 } from "node:crypto";
2424
+ import { randomUUID as randomUUID6 } from "node:crypto";
2343
2425
  import { spawn as spawn3 } from "node-pty";
2344
2426
 
2345
2427
  // src/sessionName.ts
@@ -2367,7 +2449,7 @@ var PLAN_MODE_INDICATOR = /plan mode on/i;
2367
2449
  function spawnAgentPty(repoPath, cardId, cardTitle, instructions, sessionId, ticket, planFirst) {
2368
2450
  ensureDirTrusted(repoPath);
2369
2451
  ensureMcpServersTrusted(repoPath);
2370
- const claudeSessionId = randomUUID5();
2452
+ const claudeSessionId = randomUUID6();
2371
2453
  const pty = spawn3(
2372
2454
  CLAUDE_BINARY,
2373
2455
  [
@@ -2658,7 +2740,20 @@ async function enqueueCard(supabase, config, card) {
2658
2740
  inFlightCardIds.add(card.id);
2659
2741
  try {
2660
2742
  const active = await getActiveSessionForCard(supabase, card.id);
2661
- if (active) return;
2743
+ if (active) {
2744
+ if (active.runnerId !== config.runnerId) {
2745
+ const holderHeartbeat = active.runnerId === null ? null : (await getRunnerHeartbeats(supabase, [active.runnerId])).get(active.runnerId) ?? null;
2746
+ if (active.runnerId !== null && !isRunnerHeartbeatStale(holderHeartbeat)) return;
2747
+ logger.card.warn(
2748
+ card.id,
2749
+ `Active session ${active.id} belongs to runner ${active.runnerId ?? "(none)"}, which looks offline \u2014 reclaiming for ${config.runnerId}.`
2750
+ );
2751
+ await updateSession(supabase, active.id, { status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString() });
2752
+ activeSessionByCard.delete(card.id);
2753
+ } else {
2754
+ return;
2755
+ }
2756
+ }
2662
2757
  await createQueuedSession(supabase, config, card);
2663
2758
  } finally {
2664
2759
  inFlightCardIds.delete(card.id);
@@ -2818,7 +2913,7 @@ async function spawnSessionPty(supabase, config, entry) {
2818
2913
  );
2819
2914
  return false;
2820
2915
  }
2821
- const ticket = randomUUID6();
2916
+ const ticket = randomUUID7();
2822
2917
  let pty;
2823
2918
  let newClaudeSessionId = null;
2824
2919
  let resumeConversationMissing = false;
@@ -3529,10 +3624,24 @@ async function syncRegisteredProjects(supabase, config, registeredPaths) {
3529
3624
  const localById = new Map(config.registeredProjects.map((project) => [project.id, project]));
3530
3625
  const linkedRunnerIds = await listProjectRunnerIds(supabase, config.runnerId);
3531
3626
  let changed = false;
3627
+ const otherReferenceRunnerIds = remoteProjects.filter((remote) => localById.has(remote.id) && remote.runner_id !== config.runnerId).map((remote) => remote.runner_id);
3628
+ const referenceHeartbeats = await getRunnerHeartbeats(supabase, otherReferenceRunnerIds);
3532
3629
  for (const remote of remoteProjects) {
3533
3630
  const local = localById.get(remote.id);
3534
3631
  if (local) {
3535
- const isReferenceRunner = remote.runner_id === config.runnerId;
3632
+ let isReferenceRunner = remote.runner_id === config.runnerId;
3633
+ if (!isReferenceRunner && isRunnerHeartbeatStale(referenceHeartbeats.get(remote.runner_id) ?? null)) {
3634
+ try {
3635
+ await claimReferenceRunner(supabase, remote.id, config.runnerId);
3636
+ isReferenceRunner = true;
3637
+ logger.runner.info(
3638
+ "PROJECT_SYNC",
3639
+ `Claimed reference runner for project ${remote.id} \u2014 previous one (${remote.runner_id}) looks offline.`
3640
+ );
3641
+ } catch (err) {
3642
+ logger.runner.error("PROJECT_SYNC", `Failed to claim reference runner for project ${remote.id}:`, err);
3643
+ }
3644
+ }
3536
3645
  if (local.isReferenceRunner !== isReferenceRunner) {
3537
3646
  local.isReferenceRunner = isReferenceRunner;
3538
3647
  changed = true;
@@ -3776,7 +3885,7 @@ async function startRunner() {
3776
3885
  logger.runner.error("BOOT", 'No runner set up yet. Run "agent-board-runner start --path <repos-root>" first.');
3777
3886
  process.exit(1);
3778
3887
  }
3779
- const config = applyRunnerName(applyAllowedRoot(loadedConfig));
3888
+ const config = ensureRecoveryKeypair(applyRunnerName(applyAllowedRoot(loadedConfig)));
3780
3889
  if (!config.allowedRootPath) {
3781
3890
  logger.runner.error(
3782
3891
  "BOOT",
@@ -3784,10 +3893,9 @@ async function startRunner() {
3784
3893
  );
3785
3894
  process.exit(1);
3786
3895
  }
3787
- const supabase = createAuthedSupabaseClient();
3788
- let session;
3896
+ let supabase, session;
3789
3897
  try {
3790
- session = await requireSession(supabase);
3898
+ ({ client: supabase, session } = await ensureSession(config));
3791
3899
  } catch (error) {
3792
3900
  logger.runner.error("BOOT", error instanceof Error ? error.message : String(error));
3793
3901
  process.exit(1);
@@ -3799,7 +3907,7 @@ async function startRunner() {
3799
3907
  if (event === "SIGNED_OUT") {
3800
3908
  logger.runner.error(
3801
3909
  "AUTH",
3802
- 'Supabase session was lost (refresh failed or was revoked) \u2014 every write from now on will fail row-level security checks. Run "agent-board-runner login" again and restart the runner.'
3910
+ "Supabase session was lost (refresh failed or was revoked) \u2014 exiting so the process manager restarts this runner; the next boot recovers automatically."
3803
3911
  );
3804
3912
  process.exit(1);
3805
3913
  }
@@ -3824,6 +3932,10 @@ async function startRunner() {
3824
3932
  user_id: config.ownerUserId,
3825
3933
  name: config.runnerName,
3826
3934
  auth_token_hash: config.authToken,
3935
+ // Re-sent on every boot with a real session — idempotent (same local key every time) and
3936
+ // what makes automatic recovery possible the next time this runner loses its session, see
3937
+ // ensureSession()/recoverSession() in auth.ts.
3938
+ recovery_public_key: config.recoveryPublicKey,
3827
3939
  version: RUNNER_VERSION
3828
3940
  });
3829
3941
  if (runnerError) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-board-runner",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "Local runner for Agent Board — signs in with your own Supabase account (no shared secrets) and drives Claude Code against repos under your local repos root.",
5
5
  "type": "module",
6
6
  "bin": {