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.
- package/dist/cli.js +200 -15
- package/dist/index.js +125 -13
- 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
|
|
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,
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
3849
|
-
let session;
|
|
4008
|
+
let supabase, session;
|
|
3850
4009
|
try {
|
|
3851
|
-
session = await
|
|
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
|
-
|
|
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
|
|
2421
|
+
import { randomUUID as randomUUID7 } from "node:crypto";
|
|
2340
2422
|
|
|
2341
2423
|
// src/agentSession.ts
|
|
2342
|
-
import { randomUUID as
|
|
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 =
|
|
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)
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
3788
|
-
let session;
|
|
3896
|
+
let supabase, session;
|
|
3789
3897
|
try {
|
|
3790
|
-
session = await
|
|
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
|
-
|
|
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.
|
|
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": {
|