agent-board-runner 0.1.13 → 0.1.15
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 +378 -54
- package/dist/index.js +303 -52
- 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
|
|
@@ -2394,7 +2527,7 @@ async function ensureBranchWatchers(supabase, config) {
|
|
|
2394
2527
|
// src/columnActions.ts
|
|
2395
2528
|
import { exec } from "node:child_process";
|
|
2396
2529
|
import { join as join12 } from "node:path";
|
|
2397
|
-
import { promisify as
|
|
2530
|
+
import { promisify as promisify5 } from "node:util";
|
|
2398
2531
|
|
|
2399
2532
|
// src/orchestrator.ts
|
|
2400
2533
|
import { randomUUID as randomUUID7 } from "node:crypto";
|
|
@@ -2600,10 +2733,164 @@ ${list}`;
|
|
|
2600
2733
|
}
|
|
2601
2734
|
|
|
2602
2735
|
// src/mcpContextNote.ts
|
|
2603
|
-
function buildInstructionsWithMcpContext(instructions, cardId, projectId) {
|
|
2604
|
-
|
|
2736
|
+
function buildInstructionsWithMcpContext(instructions, cardId, projectId, approvedCommands) {
|
|
2737
|
+
const base = `${instructions}
|
|
2605
2738
|
|
|
2606
2739
|
Agent Board MCP: this card's id is \`${cardId}\` and its project id is \`${projectId}\` \u2014 pass these directly to board tools (\`update_card\`, \`create_card\`, \`add_card_comment\`, etc.), no lookup needed. If this task turns out too large for one session, use \`create_card\` to split it into subtask cards (chain them with \`prerequisiteCardId\` when one depends on another) instead of trying to fit everything here. If part of the delivery needs a human to do something you can't do yourself (rotate a secret, approve something in a third-party dashboard, delete a resource manually, etc.), register each step with \`add_manual_actions\` instead of just mentioning it in your final message \u2014 that's the only place humans will reliably see it. Before finishing, post a short summary of what you did as a comment on this card via \`add_card_comment\` so the project's humans have visibility without opening this terminal.`;
|
|
2740
|
+
if (approvedCommands.length === 0) return base;
|
|
2741
|
+
const names = approvedCommands.map((command2) => command2.description ? `${command2.name} (${command2.description})` : command2.name).join(", ");
|
|
2742
|
+
return `${base}
|
|
2743
|
+
|
|
2744
|
+
This project already has approved custom command templates you can invoke instead of running the equivalent shell command yourself or proposing a near-duplicate: ${names}. Call \`list_project_commands\` first to see each one's declared arguments, then \`run_project_command\` to invoke it.`;
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
// src/projectCommands.ts
|
|
2748
|
+
import { execFile as execFile4 } from "node:child_process";
|
|
2749
|
+
import { promisify as promisify4 } from "node:util";
|
|
2750
|
+
|
|
2751
|
+
// src/runnerRpcServer.ts
|
|
2752
|
+
import { parse as parse3 } from "node:url";
|
|
2753
|
+
import { WebSocketServer as WebSocketServer2 } from "ws";
|
|
2754
|
+
var handlers = /* @__PURE__ */ new Map();
|
|
2755
|
+
function registerRunnerRpcHandler(method, handler) {
|
|
2756
|
+
handlers.set(method, handler);
|
|
2757
|
+
}
|
|
2758
|
+
function isRpcRequestMessage(value) {
|
|
2759
|
+
if (!value || typeof value !== "object") return false;
|
|
2760
|
+
const candidate = value;
|
|
2761
|
+
return candidate.type === "request" && typeof candidate.id === "string" && typeof candidate.method === "string";
|
|
2762
|
+
}
|
|
2763
|
+
async function handleRequest2(socket, message) {
|
|
2764
|
+
const handler = handlers.get(message.method);
|
|
2765
|
+
let response;
|
|
2766
|
+
if (!handler) {
|
|
2767
|
+
response = { type: "response", id: message.id, ok: false, error: `Unknown runner RPC method: ${message.method}` };
|
|
2768
|
+
} else {
|
|
2769
|
+
try {
|
|
2770
|
+
const result = await handler(message.payload);
|
|
2771
|
+
response = { type: "response", id: message.id, ok: true, result };
|
|
2772
|
+
} catch (err) {
|
|
2773
|
+
response = { type: "response", id: message.id, ok: false, error: err instanceof Error ? err.message : "Internal runner error." };
|
|
2774
|
+
}
|
|
2775
|
+
}
|
|
2776
|
+
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(response));
|
|
2777
|
+
}
|
|
2778
|
+
function attachRunnerRpcServer() {
|
|
2779
|
+
const wss = new WebSocketServer2({ noServer: true });
|
|
2780
|
+
wss.on("connection", (socket, request) => {
|
|
2781
|
+
const { query } = parse3(request.url ?? "", true);
|
|
2782
|
+
const entry = authenticateAgent(query);
|
|
2783
|
+
if (!entry) {
|
|
2784
|
+
logger.runner.warn("RUNNER-RPC", "Runner RPC WS rejected: invalid or expired agent ticket.");
|
|
2785
|
+
socket.close(4401, "Invalid or expired ticket");
|
|
2786
|
+
return;
|
|
2787
|
+
}
|
|
2788
|
+
socket.on("message", (raw) => {
|
|
2789
|
+
let parsed;
|
|
2790
|
+
try {
|
|
2791
|
+
parsed = JSON.parse(raw.toString());
|
|
2792
|
+
} catch {
|
|
2793
|
+
return;
|
|
2794
|
+
}
|
|
2795
|
+
if (!isRpcRequestMessage(parsed)) return;
|
|
2796
|
+
void handleRequest2(socket, parsed);
|
|
2797
|
+
});
|
|
2798
|
+
});
|
|
2799
|
+
logger.runner.info("RUNNER-RPC", "Runner RPC WebSocket handler attached.");
|
|
2800
|
+
return wss;
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
// src/vaultSecrets.ts
|
|
2804
|
+
async function resolveProjectSecretsEnv(supabase, projectId, repoId) {
|
|
2805
|
+
try {
|
|
2806
|
+
const { data, error } = await supabase.rpc("resolve_project_secrets", { p_project_id: projectId, p_repo_id: repoId });
|
|
2807
|
+
if (error) throw error;
|
|
2808
|
+
const rows = data ?? [];
|
|
2809
|
+
const env = {};
|
|
2810
|
+
for (const row of rows) env[row.name] = row.value;
|
|
2811
|
+
if (rows.length > 0) {
|
|
2812
|
+
logger.runner.info(
|
|
2813
|
+
"VAULT",
|
|
2814
|
+
`Resolved ${rows.length} secret(s) for project ${projectId}${repoId ? ` (repo ${repoId})` : ""}: ${rows.map((r) => r.name).join(", ")}.`
|
|
2815
|
+
);
|
|
2816
|
+
}
|
|
2817
|
+
return env;
|
|
2818
|
+
} catch (err) {
|
|
2819
|
+
logger.runner.error("VAULT", `Failed to resolve secrets for project ${projectId}${repoId ? ` (repo ${repoId})` : ""}:`, err);
|
|
2820
|
+
return {};
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
// src/projectCommands.ts
|
|
2825
|
+
function registerProjectCommandRpcHandler(supabase, config) {
|
|
2826
|
+
registerRunnerRpcHandler("run_project_command", (payload) => runProjectCommand(supabase, config, payload));
|
|
2827
|
+
}
|
|
2828
|
+
async function listApprovedProjectCommands(supabase, projectId) {
|
|
2829
|
+
try {
|
|
2830
|
+
const { data, error } = await supabase.rpc("resolve_project_commands", { p_project_id: projectId, p_repo_id: null });
|
|
2831
|
+
if (error) throw error;
|
|
2832
|
+
return (data ?? []).map((row) => ({ name: row.name, description: row.description }));
|
|
2833
|
+
} catch (err) {
|
|
2834
|
+
logger.runner.warn("PROJECT_COMMAND", `Failed to resolve approved commands for project ${projectId}:`, err);
|
|
2835
|
+
return [];
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
var execFileAsync4 = promisify4(execFile4);
|
|
2839
|
+
var OUTPUT_TAIL_CHARS = 2e4;
|
|
2840
|
+
var COMMAND_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
2841
|
+
var MAX_BUFFER = 10 * 1024 * 1024;
|
|
2842
|
+
var SECRET_PLACEHOLDER_RE = /\{\{secret:([A-Z][A-Z0-9_]*)\}\}/g;
|
|
2843
|
+
function isRunProjectCommandPayload(value) {
|
|
2844
|
+
if (!value || typeof value !== "object") return false;
|
|
2845
|
+
const candidate = value;
|
|
2846
|
+
return typeof candidate.projectId === "string" && Array.isArray(candidate.argv) && candidate.argv.length > 0 && candidate.argv.every((entry) => typeof entry === "string");
|
|
2847
|
+
}
|
|
2848
|
+
function tail(text) {
|
|
2849
|
+
return text.length > OUTPUT_TAIL_CHARS ? text.slice(text.length - OUTPUT_TAIL_CHARS) : text;
|
|
2850
|
+
}
|
|
2851
|
+
async function runProjectCommand(supabase, config, payload) {
|
|
2852
|
+
if (!isRunProjectCommandPayload(payload)) throw new Error("Malformed run_project_command payload.");
|
|
2853
|
+
const { projectId, argv } = payload;
|
|
2854
|
+
const registered = config.registeredProjects.find((project) => project.id === projectId);
|
|
2855
|
+
if (!registered) throw new Error("This runner does not have that project registered.");
|
|
2856
|
+
const secretsEnv = await resolveProjectSecretsEnv(supabase, projectId, null);
|
|
2857
|
+
const referencedSecretNames = /* @__PURE__ */ new Set();
|
|
2858
|
+
const resolvedArgv = argv.map(
|
|
2859
|
+
(part) => part.replace(SECRET_PLACEHOLDER_RE, (_match, name) => {
|
|
2860
|
+
const value = secretsEnv[name];
|
|
2861
|
+
if (value === void 0) throw new Error(`Secret "${name}" referenced by this command is no longer configured for this project.`);
|
|
2862
|
+
referencedSecretNames.add(name);
|
|
2863
|
+
return value;
|
|
2864
|
+
})
|
|
2865
|
+
);
|
|
2866
|
+
function redact(text) {
|
|
2867
|
+
let redacted = text;
|
|
2868
|
+
for (const name of referencedSecretNames) {
|
|
2869
|
+
const value = secretsEnv[name];
|
|
2870
|
+
if (value) redacted = redacted.split(value).join("***");
|
|
2871
|
+
}
|
|
2872
|
+
return redacted;
|
|
2873
|
+
}
|
|
2874
|
+
try {
|
|
2875
|
+
const { stdout, stderr } = await execFileAsync4(resolvedArgv[0], resolvedArgv.slice(1), {
|
|
2876
|
+
cwd: registered.repoPath,
|
|
2877
|
+
env: process.env,
|
|
2878
|
+
timeout: COMMAND_TIMEOUT_MS,
|
|
2879
|
+
maxBuffer: MAX_BUFFER
|
|
2880
|
+
});
|
|
2881
|
+
logger.runner.info("PROJECT_COMMAND", `Ran "${argv[0]}" for project ${projectId} (exit 0).`);
|
|
2882
|
+
return { ok: true, exitCode: 0, stdout: tail(redact(stdout)), stderr: tail(redact(stderr)) };
|
|
2883
|
+
} catch (err) {
|
|
2884
|
+
const failure = err;
|
|
2885
|
+
const exitCode = typeof failure.code === "number" ? failure.code : null;
|
|
2886
|
+
logger.runner.warn("PROJECT_COMMAND", `"${argv[0]}" failed for project ${projectId} (exit ${exitCode ?? "?"}).`);
|
|
2887
|
+
return {
|
|
2888
|
+
ok: false,
|
|
2889
|
+
exitCode,
|
|
2890
|
+
stdout: tail(redact(failure.stdout ?? "")),
|
|
2891
|
+
stderr: tail(redact(failure.stderr ?? failure.message))
|
|
2892
|
+
};
|
|
2893
|
+
}
|
|
2607
2894
|
}
|
|
2608
2895
|
|
|
2609
2896
|
// src/prototypeGateNote.ts
|
|
@@ -2719,7 +3006,20 @@ async function enqueueCard(supabase, config, card) {
|
|
|
2719
3006
|
inFlightCardIds.add(card.id);
|
|
2720
3007
|
try {
|
|
2721
3008
|
const active = await getActiveSessionForCard(supabase, card.id);
|
|
2722
|
-
if (active)
|
|
3009
|
+
if (active) {
|
|
3010
|
+
if (active.runnerId !== config.runnerId) {
|
|
3011
|
+
const holderHeartbeat = active.runnerId === null ? null : (await getRunnerHeartbeats(supabase, [active.runnerId])).get(active.runnerId) ?? null;
|
|
3012
|
+
if (active.runnerId !== null && !isRunnerHeartbeatStale(holderHeartbeat)) return;
|
|
3013
|
+
logger.card.warn(
|
|
3014
|
+
card.id,
|
|
3015
|
+
`Active session ${active.id} belongs to runner ${active.runnerId ?? "(none)"}, which looks offline \u2014 reclaiming for ${config.runnerId}.`
|
|
3016
|
+
);
|
|
3017
|
+
await updateSession(supabase, active.id, { status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
3018
|
+
activeSessionByCard.delete(card.id);
|
|
3019
|
+
} else {
|
|
3020
|
+
return;
|
|
3021
|
+
}
|
|
3022
|
+
}
|
|
2723
3023
|
await createQueuedSession(supabase, config, card);
|
|
2724
3024
|
} finally {
|
|
2725
3025
|
inFlightCardIds.delete(card.id);
|
|
@@ -2891,6 +3191,7 @@ async function spawnSessionPty(supabase, config, entry) {
|
|
|
2891
3191
|
});
|
|
2892
3192
|
} else {
|
|
2893
3193
|
const attachmentPaths = await downloadCardAttachments(supabase, cardId);
|
|
3194
|
+
const approvedCommands = await listApprovedProjectCommands(supabase, card.project_id);
|
|
2894
3195
|
const instructions = buildInstructionsWithMcpContext(
|
|
2895
3196
|
buildInstructionsWithPrototypeGate(
|
|
2896
3197
|
buildInstructionsWithFileReferences(
|
|
@@ -2900,7 +3201,8 @@ async function spawnSessionPty(supabase, config, entry) {
|
|
|
2900
3201
|
card.prototype_first
|
|
2901
3202
|
),
|
|
2902
3203
|
cardId,
|
|
2903
|
-
card.project_id
|
|
3204
|
+
card.project_id,
|
|
3205
|
+
approvedCommands
|
|
2904
3206
|
);
|
|
2905
3207
|
const spawned = spawnAgentPty(registered.repoPath, cardId, card.title, instructions, sessionId, ticket, card.plan_first);
|
|
2906
3208
|
pty = spawned.pty;
|
|
@@ -3099,32 +3401,11 @@ async function restartCardSession(supabase, config, card) {
|
|
|
3099
3401
|
await enqueueCard(supabase, config, card);
|
|
3100
3402
|
}
|
|
3101
3403
|
|
|
3102
|
-
// src/vaultSecrets.ts
|
|
3103
|
-
async function resolveProjectSecretsEnv(supabase, projectId, repoId) {
|
|
3104
|
-
try {
|
|
3105
|
-
const { data, error } = await supabase.rpc("resolve_project_secrets", { p_project_id: projectId, p_repo_id: repoId });
|
|
3106
|
-
if (error) throw error;
|
|
3107
|
-
const rows = data ?? [];
|
|
3108
|
-
const env = {};
|
|
3109
|
-
for (const row of rows) env[row.name] = row.value;
|
|
3110
|
-
if (rows.length > 0) {
|
|
3111
|
-
logger.runner.info(
|
|
3112
|
-
"VAULT",
|
|
3113
|
-
`Resolved ${rows.length} secret(s) for project ${projectId}${repoId ? ` (repo ${repoId})` : ""}: ${rows.map((r) => r.name).join(", ")}.`
|
|
3114
|
-
);
|
|
3115
|
-
}
|
|
3116
|
-
return env;
|
|
3117
|
-
} catch (err) {
|
|
3118
|
-
logger.runner.error("VAULT", `Failed to resolve secrets for project ${projectId}${repoId ? ` (repo ${repoId})` : ""}:`, err);
|
|
3119
|
-
return {};
|
|
3120
|
-
}
|
|
3121
|
-
}
|
|
3122
|
-
|
|
3123
3404
|
// src/columnActions.ts
|
|
3124
|
-
var execAsync =
|
|
3125
|
-
var
|
|
3126
|
-
function
|
|
3127
|
-
return text.length >
|
|
3405
|
+
var execAsync = promisify5(exec);
|
|
3406
|
+
var OUTPUT_TAIL_CHARS2 = 4e3;
|
|
3407
|
+
function tail2(text) {
|
|
3408
|
+
return text.length > OUTPUT_TAIL_CHARS2 ? text.slice(text.length - OUTPUT_TAIL_CHARS2) : text;
|
|
3128
3409
|
}
|
|
3129
3410
|
function resolveTargets(project, children, repoRoot, column, timing) {
|
|
3130
3411
|
if (children.length > 0) {
|
|
@@ -3138,11 +3419,11 @@ var EXEC_OPTIONS = { maxBuffer: 10 * 1024 * 1024 };
|
|
|
3138
3419
|
async function runCommand(target, extraEnv) {
|
|
3139
3420
|
try {
|
|
3140
3421
|
const { stdout, stderr } = await execAsync(target.command, { ...EXEC_OPTIONS, cwd: target.cwd, env: { ...process.env, ...extraEnv } });
|
|
3141
|
-
return { ok: true, output:
|
|
3422
|
+
return { ok: true, output: tail2(`${stdout}${stderr}`) };
|
|
3142
3423
|
} catch (err) {
|
|
3143
3424
|
const { stdout, stderr } = err;
|
|
3144
3425
|
const message = err instanceof Error ? err.message : String(err);
|
|
3145
|
-
return { ok: false, output:
|
|
3426
|
+
return { ok: false, output: tail2(`${stdout ?? ""}${stderr ?? ""}` || message) };
|
|
3146
3427
|
}
|
|
3147
3428
|
}
|
|
3148
3429
|
var inFlightColumns = /* @__PURE__ */ new Set();
|
|
@@ -3473,12 +3754,12 @@ function stopAll() {
|
|
|
3473
3754
|
}
|
|
3474
3755
|
|
|
3475
3756
|
// src/git.ts
|
|
3476
|
-
import { execFile as
|
|
3477
|
-
import { promisify as
|
|
3478
|
-
var
|
|
3757
|
+
import { execFile as execFile5 } from "node:child_process";
|
|
3758
|
+
import { promisify as promisify6 } from "node:util";
|
|
3759
|
+
var execFileAsync5 = promisify6(execFile5);
|
|
3479
3760
|
async function pushBranchToMain(repoPath) {
|
|
3480
3761
|
try {
|
|
3481
|
-
await
|
|
3762
|
+
await execFileAsync5("git", ["push", "origin", "HEAD:main"], { cwd: repoPath });
|
|
3482
3763
|
return { ok: true };
|
|
3483
3764
|
} catch (err) {
|
|
3484
3765
|
const stderr = err.stderr;
|
|
@@ -3590,10 +3871,24 @@ async function syncRegisteredProjects(supabase, config, registeredPaths) {
|
|
|
3590
3871
|
const localById = new Map(config.registeredProjects.map((project) => [project.id, project]));
|
|
3591
3872
|
const linkedRunnerIds = await listProjectRunnerIds(supabase, config.runnerId);
|
|
3592
3873
|
let changed = false;
|
|
3874
|
+
const otherReferenceRunnerIds = remoteProjects.filter((remote) => localById.has(remote.id) && remote.runner_id !== config.runnerId).map((remote) => remote.runner_id);
|
|
3875
|
+
const referenceHeartbeats = await getRunnerHeartbeats(supabase, otherReferenceRunnerIds);
|
|
3593
3876
|
for (const remote of remoteProjects) {
|
|
3594
3877
|
const local = localById.get(remote.id);
|
|
3595
3878
|
if (local) {
|
|
3596
|
-
|
|
3879
|
+
let isReferenceRunner = remote.runner_id === config.runnerId;
|
|
3880
|
+
if (!isReferenceRunner && isRunnerHeartbeatStale(referenceHeartbeats.get(remote.runner_id) ?? null)) {
|
|
3881
|
+
try {
|
|
3882
|
+
await claimReferenceRunner(supabase, remote.id, config.runnerId);
|
|
3883
|
+
isReferenceRunner = true;
|
|
3884
|
+
logger.runner.info(
|
|
3885
|
+
"PROJECT_SYNC",
|
|
3886
|
+
`Claimed reference runner for project ${remote.id} \u2014 previous one (${remote.runner_id}) looks offline.`
|
|
3887
|
+
);
|
|
3888
|
+
} catch (err) {
|
|
3889
|
+
logger.runner.error("PROJECT_SYNC", `Failed to claim reference runner for project ${remote.id}:`, err);
|
|
3890
|
+
}
|
|
3891
|
+
}
|
|
3597
3892
|
if (local.isReferenceRunner !== isReferenceRunner) {
|
|
3598
3893
|
local.isReferenceRunner = isReferenceRunner;
|
|
3599
3894
|
changed = true;
|
|
@@ -3724,14 +4019,14 @@ async function checkPendingDevServerRestarts(supabase, config) {
|
|
|
3724
4019
|
}
|
|
3725
4020
|
|
|
3726
4021
|
// src/terminalServer.ts
|
|
3727
|
-
import { parse as
|
|
3728
|
-
import { WebSocketServer as
|
|
4022
|
+
import { parse as parse4 } from "node:url";
|
|
4023
|
+
import { WebSocketServer as WebSocketServer3 } from "ws";
|
|
3729
4024
|
var FRAME_TYPE_RESIZE = 0;
|
|
3730
4025
|
var FRAME_TYPE_IMAGE = 1;
|
|
3731
4026
|
function attachTerminalServer() {
|
|
3732
|
-
const wss = new
|
|
4027
|
+
const wss = new WebSocketServer3({ noServer: true });
|
|
3733
4028
|
wss.on("connection", (socket, request) => {
|
|
3734
|
-
const { query } =
|
|
4029
|
+
const { query } = parse4(request.url ?? "", true);
|
|
3735
4030
|
const sessionId = typeof query.sessionId === "string" ? query.sessionId : null;
|
|
3736
4031
|
const ticket = typeof query.ticket === "string" ? query.ticket : null;
|
|
3737
4032
|
if (!sessionId || !ticket) {
|
|
@@ -3837,7 +4132,7 @@ async function startRunner() {
|
|
|
3837
4132
|
logger.runner.error("BOOT", 'No runner set up yet. Run "agent-board-runner start --path <repos-root>" first.');
|
|
3838
4133
|
process.exit(1);
|
|
3839
4134
|
}
|
|
3840
|
-
const config = applyRunnerName(applyAllowedRoot(loadedConfig));
|
|
4135
|
+
const config = ensureRecoveryKeypair(applyRunnerName(applyAllowedRoot(loadedConfig)));
|
|
3841
4136
|
if (!config.allowedRootPath) {
|
|
3842
4137
|
logger.runner.error(
|
|
3843
4138
|
"BOOT",
|
|
@@ -3845,10 +4140,9 @@ async function startRunner() {
|
|
|
3845
4140
|
);
|
|
3846
4141
|
process.exit(1);
|
|
3847
4142
|
}
|
|
3848
|
-
|
|
3849
|
-
let session;
|
|
4143
|
+
let supabase, session;
|
|
3850
4144
|
try {
|
|
3851
|
-
session = await
|
|
4145
|
+
({ client: supabase, session } = await ensureSession(config));
|
|
3852
4146
|
} catch (error) {
|
|
3853
4147
|
logger.runner.error("BOOT", error instanceof Error ? error.message : String(error));
|
|
3854
4148
|
process.exit(1);
|
|
@@ -3860,7 +4154,7 @@ async function startRunner() {
|
|
|
3860
4154
|
if (event === "SIGNED_OUT") {
|
|
3861
4155
|
logger.runner.error(
|
|
3862
4156
|
"AUTH",
|
|
3863
|
-
|
|
4157
|
+
"Supabase session was lost (refresh failed or was revoked) \u2014 exiting so the process manager restarts this runner; the next boot recovers automatically."
|
|
3864
4158
|
);
|
|
3865
4159
|
process.exit(1);
|
|
3866
4160
|
}
|
|
@@ -3885,6 +4179,10 @@ async function startRunner() {
|
|
|
3885
4179
|
user_id: config.ownerUserId,
|
|
3886
4180
|
name: config.runnerName,
|
|
3887
4181
|
auth_token_hash: config.authToken,
|
|
4182
|
+
// Re-sent on every boot with a real session — idempotent (same local key every time) and
|
|
4183
|
+
// what makes automatic recovery possible the next time this runner loses its session, see
|
|
4184
|
+
// ensureSession()/recoverSession() in auth.ts.
|
|
4185
|
+
recovery_public_key: config.recoveryPublicKey,
|
|
3888
4186
|
version: RUNNER_VERSION
|
|
3889
4187
|
});
|
|
3890
4188
|
if (runnerError) {
|
|
@@ -3909,6 +4207,8 @@ async function startRunner() {
|
|
|
3909
4207
|
const httpServer = createServer2();
|
|
3910
4208
|
const terminalWss = attachTerminalServer();
|
|
3911
4209
|
const agentEventsWss = attachAgentEventsServer();
|
|
4210
|
+
const runnerRpcWss = attachRunnerRpcServer();
|
|
4211
|
+
registerProjectCommandRpcHandler(supabase, config);
|
|
3912
4212
|
attachFilesServer(httpServer, supabase, config);
|
|
3913
4213
|
httpServer.on("upgrade", (request, socket, head) => {
|
|
3914
4214
|
const pathname = (request.url ?? "").split("?")[0];
|
|
@@ -3916,6 +4216,8 @@ async function startRunner() {
|
|
|
3916
4216
|
terminalWss.handleUpgrade(request, socket, head, (ws) => terminalWss.emit("connection", ws, request));
|
|
3917
4217
|
} else if (pathname === "/agent-events") {
|
|
3918
4218
|
agentEventsWss.handleUpgrade(request, socket, head, (ws) => agentEventsWss.emit("connection", ws, request));
|
|
4219
|
+
} else if (pathname === "/runner-rpc") {
|
|
4220
|
+
runnerRpcWss.handleUpgrade(request, socket, head, (ws) => runnerRpcWss.emit("connection", ws, request));
|
|
3919
4221
|
} else {
|
|
3920
4222
|
socket.destroy();
|
|
3921
4223
|
}
|
|
@@ -4064,7 +4366,7 @@ function parseArgs(argv) {
|
|
|
4064
4366
|
}
|
|
4065
4367
|
function bootstrapConfig() {
|
|
4066
4368
|
const existing = loadConfig();
|
|
4067
|
-
if (existing) return existing;
|
|
4369
|
+
if (existing) return ensureRecoveryKeypair(existing);
|
|
4068
4370
|
const identity = createRunnerIdentity();
|
|
4069
4371
|
const config = {
|
|
4070
4372
|
...identity,
|
|
@@ -4081,6 +4383,22 @@ async function cmdLogin() {
|
|
|
4081
4383
|
const { email } = await login();
|
|
4082
4384
|
console.log(`Signed in as ${email}.`);
|
|
4083
4385
|
}
|
|
4386
|
+
async function cmdRequestApproval(args2) {
|
|
4387
|
+
if (!args2.email) {
|
|
4388
|
+
console.error(
|
|
4389
|
+
"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."
|
|
4390
|
+
);
|
|
4391
|
+
process.exit(1);
|
|
4392
|
+
}
|
|
4393
|
+
const config = bootstrapConfig();
|
|
4394
|
+
const { email } = await requestApproval({
|
|
4395
|
+
runnerName: args2.name ?? config.runnerName,
|
|
4396
|
+
hostname: hostname2(),
|
|
4397
|
+
expectedEmail: args2.email
|
|
4398
|
+
});
|
|
4399
|
+
console.log(`Aprovado! Sess\xE3o criada para ${email}.`);
|
|
4400
|
+
console.log("A partir de agora, se este runner perder a sess\xE3o, ele se recupera sozinho (sem precisar rodar isto de novo).");
|
|
4401
|
+
}
|
|
4084
4402
|
async function cmdLogout() {
|
|
4085
4403
|
await logout();
|
|
4086
4404
|
console.log("Signed out.");
|
|
@@ -4111,6 +4429,9 @@ switch (command) {
|
|
|
4111
4429
|
case "login":
|
|
4112
4430
|
cmdLogin().catch(fail);
|
|
4113
4431
|
break;
|
|
4432
|
+
case "request-approval":
|
|
4433
|
+
cmdRequestApproval(args).catch(fail);
|
|
4434
|
+
break;
|
|
4114
4435
|
case "logout":
|
|
4115
4436
|
cmdLogout().catch(fail);
|
|
4116
4437
|
break;
|
|
@@ -4121,8 +4442,11 @@ switch (command) {
|
|
|
4121
4442
|
cmdStart(args).catch(fail);
|
|
4122
4443
|
break;
|
|
4123
4444
|
default:
|
|
4124
|
-
console.log("Usage: agent-board-runner <login|logout|whoami|start>");
|
|
4445
|
+
console.log("Usage: agent-board-runner <login|request-approval|logout|whoami|start>");
|
|
4125
4446
|
console.log(" login sign in with Google");
|
|
4447
|
+
console.log(" request-approval --email <email> [--name <nome>]");
|
|
4448
|
+
console.log(" login sem navegador: aprove pela web (AGENT_BOARD_WEB_URL precisa estar configurada)");
|
|
4449
|
+
console.log(" s\xF3 precisa ser rodado uma vez \u2014 depois disso, `start` recupera a sess\xE3o sozinho se ela cair");
|
|
4126
4450
|
console.log(" logout sign out and clear the local session");
|
|
4127
4451
|
console.log(" whoami show the signed-in account");
|
|
4128
4452
|
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";
|
|
@@ -2333,13 +2415,13 @@ async function ensureBranchWatchers(supabase, config) {
|
|
|
2333
2415
|
// src/columnActions.ts
|
|
2334
2416
|
import { exec } from "node:child_process";
|
|
2335
2417
|
import { join as join12 } from "node:path";
|
|
2336
|
-
import { promisify as
|
|
2418
|
+
import { promisify as promisify5 } 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
|
[
|
|
@@ -2539,10 +2621,164 @@ ${list}`;
|
|
|
2539
2621
|
}
|
|
2540
2622
|
|
|
2541
2623
|
// src/mcpContextNote.ts
|
|
2542
|
-
function buildInstructionsWithMcpContext(instructions, cardId, projectId) {
|
|
2543
|
-
|
|
2624
|
+
function buildInstructionsWithMcpContext(instructions, cardId, projectId, approvedCommands) {
|
|
2625
|
+
const base = `${instructions}
|
|
2544
2626
|
|
|
2545
2627
|
Agent Board MCP: this card's id is \`${cardId}\` and its project id is \`${projectId}\` \u2014 pass these directly to board tools (\`update_card\`, \`create_card\`, \`add_card_comment\`, etc.), no lookup needed. If this task turns out too large for one session, use \`create_card\` to split it into subtask cards (chain them with \`prerequisiteCardId\` when one depends on another) instead of trying to fit everything here. If part of the delivery needs a human to do something you can't do yourself (rotate a secret, approve something in a third-party dashboard, delete a resource manually, etc.), register each step with \`add_manual_actions\` instead of just mentioning it in your final message \u2014 that's the only place humans will reliably see it. Before finishing, post a short summary of what you did as a comment on this card via \`add_card_comment\` so the project's humans have visibility without opening this terminal.`;
|
|
2628
|
+
if (approvedCommands.length === 0) return base;
|
|
2629
|
+
const names = approvedCommands.map((command) => command.description ? `${command.name} (${command.description})` : command.name).join(", ");
|
|
2630
|
+
return `${base}
|
|
2631
|
+
|
|
2632
|
+
This project already has approved custom command templates you can invoke instead of running the equivalent shell command yourself or proposing a near-duplicate: ${names}. Call \`list_project_commands\` first to see each one's declared arguments, then \`run_project_command\` to invoke it.`;
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
// src/projectCommands.ts
|
|
2636
|
+
import { execFile as execFile4 } from "node:child_process";
|
|
2637
|
+
import { promisify as promisify4 } from "node:util";
|
|
2638
|
+
|
|
2639
|
+
// src/runnerRpcServer.ts
|
|
2640
|
+
import { parse as parse3 } from "node:url";
|
|
2641
|
+
import { WebSocketServer as WebSocketServer2 } from "ws";
|
|
2642
|
+
var handlers = /* @__PURE__ */ new Map();
|
|
2643
|
+
function registerRunnerRpcHandler(method, handler) {
|
|
2644
|
+
handlers.set(method, handler);
|
|
2645
|
+
}
|
|
2646
|
+
function isRpcRequestMessage(value) {
|
|
2647
|
+
if (!value || typeof value !== "object") return false;
|
|
2648
|
+
const candidate = value;
|
|
2649
|
+
return candidate.type === "request" && typeof candidate.id === "string" && typeof candidate.method === "string";
|
|
2650
|
+
}
|
|
2651
|
+
async function handleRequest2(socket, message) {
|
|
2652
|
+
const handler = handlers.get(message.method);
|
|
2653
|
+
let response;
|
|
2654
|
+
if (!handler) {
|
|
2655
|
+
response = { type: "response", id: message.id, ok: false, error: `Unknown runner RPC method: ${message.method}` };
|
|
2656
|
+
} else {
|
|
2657
|
+
try {
|
|
2658
|
+
const result = await handler(message.payload);
|
|
2659
|
+
response = { type: "response", id: message.id, ok: true, result };
|
|
2660
|
+
} catch (err) {
|
|
2661
|
+
response = { type: "response", id: message.id, ok: false, error: err instanceof Error ? err.message : "Internal runner error." };
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(response));
|
|
2665
|
+
}
|
|
2666
|
+
function attachRunnerRpcServer() {
|
|
2667
|
+
const wss = new WebSocketServer2({ noServer: true });
|
|
2668
|
+
wss.on("connection", (socket, request) => {
|
|
2669
|
+
const { query } = parse3(request.url ?? "", true);
|
|
2670
|
+
const entry = authenticateAgent(query);
|
|
2671
|
+
if (!entry) {
|
|
2672
|
+
logger.runner.warn("RUNNER-RPC", "Runner RPC WS rejected: invalid or expired agent ticket.");
|
|
2673
|
+
socket.close(4401, "Invalid or expired ticket");
|
|
2674
|
+
return;
|
|
2675
|
+
}
|
|
2676
|
+
socket.on("message", (raw) => {
|
|
2677
|
+
let parsed;
|
|
2678
|
+
try {
|
|
2679
|
+
parsed = JSON.parse(raw.toString());
|
|
2680
|
+
} catch {
|
|
2681
|
+
return;
|
|
2682
|
+
}
|
|
2683
|
+
if (!isRpcRequestMessage(parsed)) return;
|
|
2684
|
+
void handleRequest2(socket, parsed);
|
|
2685
|
+
});
|
|
2686
|
+
});
|
|
2687
|
+
logger.runner.info("RUNNER-RPC", "Runner RPC WebSocket handler attached.");
|
|
2688
|
+
return wss;
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
// src/vaultSecrets.ts
|
|
2692
|
+
async function resolveProjectSecretsEnv(supabase, projectId, repoId) {
|
|
2693
|
+
try {
|
|
2694
|
+
const { data, error } = await supabase.rpc("resolve_project_secrets", { p_project_id: projectId, p_repo_id: repoId });
|
|
2695
|
+
if (error) throw error;
|
|
2696
|
+
const rows = data ?? [];
|
|
2697
|
+
const env = {};
|
|
2698
|
+
for (const row of rows) env[row.name] = row.value;
|
|
2699
|
+
if (rows.length > 0) {
|
|
2700
|
+
logger.runner.info(
|
|
2701
|
+
"VAULT",
|
|
2702
|
+
`Resolved ${rows.length} secret(s) for project ${projectId}${repoId ? ` (repo ${repoId})` : ""}: ${rows.map((r) => r.name).join(", ")}.`
|
|
2703
|
+
);
|
|
2704
|
+
}
|
|
2705
|
+
return env;
|
|
2706
|
+
} catch (err) {
|
|
2707
|
+
logger.runner.error("VAULT", `Failed to resolve secrets for project ${projectId}${repoId ? ` (repo ${repoId})` : ""}:`, err);
|
|
2708
|
+
return {};
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
// src/projectCommands.ts
|
|
2713
|
+
function registerProjectCommandRpcHandler(supabase, config) {
|
|
2714
|
+
registerRunnerRpcHandler("run_project_command", (payload) => runProjectCommand(supabase, config, payload));
|
|
2715
|
+
}
|
|
2716
|
+
async function listApprovedProjectCommands(supabase, projectId) {
|
|
2717
|
+
try {
|
|
2718
|
+
const { data, error } = await supabase.rpc("resolve_project_commands", { p_project_id: projectId, p_repo_id: null });
|
|
2719
|
+
if (error) throw error;
|
|
2720
|
+
return (data ?? []).map((row) => ({ name: row.name, description: row.description }));
|
|
2721
|
+
} catch (err) {
|
|
2722
|
+
logger.runner.warn("PROJECT_COMMAND", `Failed to resolve approved commands for project ${projectId}:`, err);
|
|
2723
|
+
return [];
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2726
|
+
var execFileAsync4 = promisify4(execFile4);
|
|
2727
|
+
var OUTPUT_TAIL_CHARS = 2e4;
|
|
2728
|
+
var COMMAND_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
2729
|
+
var MAX_BUFFER = 10 * 1024 * 1024;
|
|
2730
|
+
var SECRET_PLACEHOLDER_RE = /\{\{secret:([A-Z][A-Z0-9_]*)\}\}/g;
|
|
2731
|
+
function isRunProjectCommandPayload(value) {
|
|
2732
|
+
if (!value || typeof value !== "object") return false;
|
|
2733
|
+
const candidate = value;
|
|
2734
|
+
return typeof candidate.projectId === "string" && Array.isArray(candidate.argv) && candidate.argv.length > 0 && candidate.argv.every((entry) => typeof entry === "string");
|
|
2735
|
+
}
|
|
2736
|
+
function tail(text) {
|
|
2737
|
+
return text.length > OUTPUT_TAIL_CHARS ? text.slice(text.length - OUTPUT_TAIL_CHARS) : text;
|
|
2738
|
+
}
|
|
2739
|
+
async function runProjectCommand(supabase, config, payload) {
|
|
2740
|
+
if (!isRunProjectCommandPayload(payload)) throw new Error("Malformed run_project_command payload.");
|
|
2741
|
+
const { projectId, argv } = payload;
|
|
2742
|
+
const registered = config.registeredProjects.find((project) => project.id === projectId);
|
|
2743
|
+
if (!registered) throw new Error("This runner does not have that project registered.");
|
|
2744
|
+
const secretsEnv = await resolveProjectSecretsEnv(supabase, projectId, null);
|
|
2745
|
+
const referencedSecretNames = /* @__PURE__ */ new Set();
|
|
2746
|
+
const resolvedArgv = argv.map(
|
|
2747
|
+
(part) => part.replace(SECRET_PLACEHOLDER_RE, (_match, name) => {
|
|
2748
|
+
const value = secretsEnv[name];
|
|
2749
|
+
if (value === void 0) throw new Error(`Secret "${name}" referenced by this command is no longer configured for this project.`);
|
|
2750
|
+
referencedSecretNames.add(name);
|
|
2751
|
+
return value;
|
|
2752
|
+
})
|
|
2753
|
+
);
|
|
2754
|
+
function redact(text) {
|
|
2755
|
+
let redacted = text;
|
|
2756
|
+
for (const name of referencedSecretNames) {
|
|
2757
|
+
const value = secretsEnv[name];
|
|
2758
|
+
if (value) redacted = redacted.split(value).join("***");
|
|
2759
|
+
}
|
|
2760
|
+
return redacted;
|
|
2761
|
+
}
|
|
2762
|
+
try {
|
|
2763
|
+
const { stdout, stderr } = await execFileAsync4(resolvedArgv[0], resolvedArgv.slice(1), {
|
|
2764
|
+
cwd: registered.repoPath,
|
|
2765
|
+
env: process.env,
|
|
2766
|
+
timeout: COMMAND_TIMEOUT_MS,
|
|
2767
|
+
maxBuffer: MAX_BUFFER
|
|
2768
|
+
});
|
|
2769
|
+
logger.runner.info("PROJECT_COMMAND", `Ran "${argv[0]}" for project ${projectId} (exit 0).`);
|
|
2770
|
+
return { ok: true, exitCode: 0, stdout: tail(redact(stdout)), stderr: tail(redact(stderr)) };
|
|
2771
|
+
} catch (err) {
|
|
2772
|
+
const failure = err;
|
|
2773
|
+
const exitCode = typeof failure.code === "number" ? failure.code : null;
|
|
2774
|
+
logger.runner.warn("PROJECT_COMMAND", `"${argv[0]}" failed for project ${projectId} (exit ${exitCode ?? "?"}).`);
|
|
2775
|
+
return {
|
|
2776
|
+
ok: false,
|
|
2777
|
+
exitCode,
|
|
2778
|
+
stdout: tail(redact(failure.stdout ?? "")),
|
|
2779
|
+
stderr: tail(redact(failure.stderr ?? failure.message))
|
|
2780
|
+
};
|
|
2781
|
+
}
|
|
2546
2782
|
}
|
|
2547
2783
|
|
|
2548
2784
|
// src/prototypeGateNote.ts
|
|
@@ -2658,7 +2894,20 @@ async function enqueueCard(supabase, config, card) {
|
|
|
2658
2894
|
inFlightCardIds.add(card.id);
|
|
2659
2895
|
try {
|
|
2660
2896
|
const active = await getActiveSessionForCard(supabase, card.id);
|
|
2661
|
-
if (active)
|
|
2897
|
+
if (active) {
|
|
2898
|
+
if (active.runnerId !== config.runnerId) {
|
|
2899
|
+
const holderHeartbeat = active.runnerId === null ? null : (await getRunnerHeartbeats(supabase, [active.runnerId])).get(active.runnerId) ?? null;
|
|
2900
|
+
if (active.runnerId !== null && !isRunnerHeartbeatStale(holderHeartbeat)) return;
|
|
2901
|
+
logger.card.warn(
|
|
2902
|
+
card.id,
|
|
2903
|
+
`Active session ${active.id} belongs to runner ${active.runnerId ?? "(none)"}, which looks offline \u2014 reclaiming for ${config.runnerId}.`
|
|
2904
|
+
);
|
|
2905
|
+
await updateSession(supabase, active.id, { status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
2906
|
+
activeSessionByCard.delete(card.id);
|
|
2907
|
+
} else {
|
|
2908
|
+
return;
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2662
2911
|
await createQueuedSession(supabase, config, card);
|
|
2663
2912
|
} finally {
|
|
2664
2913
|
inFlightCardIds.delete(card.id);
|
|
@@ -2818,7 +3067,7 @@ async function spawnSessionPty(supabase, config, entry) {
|
|
|
2818
3067
|
);
|
|
2819
3068
|
return false;
|
|
2820
3069
|
}
|
|
2821
|
-
const ticket =
|
|
3070
|
+
const ticket = randomUUID7();
|
|
2822
3071
|
let pty;
|
|
2823
3072
|
let newClaudeSessionId = null;
|
|
2824
3073
|
let resumeConversationMissing = false;
|
|
@@ -2830,6 +3079,7 @@ async function spawnSessionPty(supabase, config, entry) {
|
|
|
2830
3079
|
});
|
|
2831
3080
|
} else {
|
|
2832
3081
|
const attachmentPaths = await downloadCardAttachments(supabase, cardId);
|
|
3082
|
+
const approvedCommands = await listApprovedProjectCommands(supabase, card.project_id);
|
|
2833
3083
|
const instructions = buildInstructionsWithMcpContext(
|
|
2834
3084
|
buildInstructionsWithPrototypeGate(
|
|
2835
3085
|
buildInstructionsWithFileReferences(
|
|
@@ -2839,7 +3089,8 @@ async function spawnSessionPty(supabase, config, entry) {
|
|
|
2839
3089
|
card.prototype_first
|
|
2840
3090
|
),
|
|
2841
3091
|
cardId,
|
|
2842
|
-
card.project_id
|
|
3092
|
+
card.project_id,
|
|
3093
|
+
approvedCommands
|
|
2843
3094
|
);
|
|
2844
3095
|
const spawned = spawnAgentPty(registered.repoPath, cardId, card.title, instructions, sessionId, ticket, card.plan_first);
|
|
2845
3096
|
pty = spawned.pty;
|
|
@@ -3038,32 +3289,11 @@ async function restartCardSession(supabase, config, card) {
|
|
|
3038
3289
|
await enqueueCard(supabase, config, card);
|
|
3039
3290
|
}
|
|
3040
3291
|
|
|
3041
|
-
// src/vaultSecrets.ts
|
|
3042
|
-
async function resolveProjectSecretsEnv(supabase, projectId, repoId) {
|
|
3043
|
-
try {
|
|
3044
|
-
const { data, error } = await supabase.rpc("resolve_project_secrets", { p_project_id: projectId, p_repo_id: repoId });
|
|
3045
|
-
if (error) throw error;
|
|
3046
|
-
const rows = data ?? [];
|
|
3047
|
-
const env = {};
|
|
3048
|
-
for (const row of rows) env[row.name] = row.value;
|
|
3049
|
-
if (rows.length > 0) {
|
|
3050
|
-
logger.runner.info(
|
|
3051
|
-
"VAULT",
|
|
3052
|
-
`Resolved ${rows.length} secret(s) for project ${projectId}${repoId ? ` (repo ${repoId})` : ""}: ${rows.map((r) => r.name).join(", ")}.`
|
|
3053
|
-
);
|
|
3054
|
-
}
|
|
3055
|
-
return env;
|
|
3056
|
-
} catch (err) {
|
|
3057
|
-
logger.runner.error("VAULT", `Failed to resolve secrets for project ${projectId}${repoId ? ` (repo ${repoId})` : ""}:`, err);
|
|
3058
|
-
return {};
|
|
3059
|
-
}
|
|
3060
|
-
}
|
|
3061
|
-
|
|
3062
3292
|
// src/columnActions.ts
|
|
3063
|
-
var execAsync =
|
|
3064
|
-
var
|
|
3065
|
-
function
|
|
3066
|
-
return text.length >
|
|
3293
|
+
var execAsync = promisify5(exec);
|
|
3294
|
+
var OUTPUT_TAIL_CHARS2 = 4e3;
|
|
3295
|
+
function tail2(text) {
|
|
3296
|
+
return text.length > OUTPUT_TAIL_CHARS2 ? text.slice(text.length - OUTPUT_TAIL_CHARS2) : text;
|
|
3067
3297
|
}
|
|
3068
3298
|
function resolveTargets(project, children, repoRoot, column, timing) {
|
|
3069
3299
|
if (children.length > 0) {
|
|
@@ -3077,11 +3307,11 @@ var EXEC_OPTIONS = { maxBuffer: 10 * 1024 * 1024 };
|
|
|
3077
3307
|
async function runCommand(target, extraEnv) {
|
|
3078
3308
|
try {
|
|
3079
3309
|
const { stdout, stderr } = await execAsync(target.command, { ...EXEC_OPTIONS, cwd: target.cwd, env: { ...process.env, ...extraEnv } });
|
|
3080
|
-
return { ok: true, output:
|
|
3310
|
+
return { ok: true, output: tail2(`${stdout}${stderr}`) };
|
|
3081
3311
|
} catch (err) {
|
|
3082
3312
|
const { stdout, stderr } = err;
|
|
3083
3313
|
const message = err instanceof Error ? err.message : String(err);
|
|
3084
|
-
return { ok: false, output:
|
|
3314
|
+
return { ok: false, output: tail2(`${stdout ?? ""}${stderr ?? ""}` || message) };
|
|
3085
3315
|
}
|
|
3086
3316
|
}
|
|
3087
3317
|
var inFlightColumns = /* @__PURE__ */ new Set();
|
|
@@ -3412,12 +3642,12 @@ function stopAll() {
|
|
|
3412
3642
|
}
|
|
3413
3643
|
|
|
3414
3644
|
// src/git.ts
|
|
3415
|
-
import { execFile as
|
|
3416
|
-
import { promisify as
|
|
3417
|
-
var
|
|
3645
|
+
import { execFile as execFile5 } from "node:child_process";
|
|
3646
|
+
import { promisify as promisify6 } from "node:util";
|
|
3647
|
+
var execFileAsync5 = promisify6(execFile5);
|
|
3418
3648
|
async function pushBranchToMain(repoPath) {
|
|
3419
3649
|
try {
|
|
3420
|
-
await
|
|
3650
|
+
await execFileAsync5("git", ["push", "origin", "HEAD:main"], { cwd: repoPath });
|
|
3421
3651
|
return { ok: true };
|
|
3422
3652
|
} catch (err) {
|
|
3423
3653
|
const stderr = err.stderr;
|
|
@@ -3529,10 +3759,24 @@ async function syncRegisteredProjects(supabase, config, registeredPaths) {
|
|
|
3529
3759
|
const localById = new Map(config.registeredProjects.map((project) => [project.id, project]));
|
|
3530
3760
|
const linkedRunnerIds = await listProjectRunnerIds(supabase, config.runnerId);
|
|
3531
3761
|
let changed = false;
|
|
3762
|
+
const otherReferenceRunnerIds = remoteProjects.filter((remote) => localById.has(remote.id) && remote.runner_id !== config.runnerId).map((remote) => remote.runner_id);
|
|
3763
|
+
const referenceHeartbeats = await getRunnerHeartbeats(supabase, otherReferenceRunnerIds);
|
|
3532
3764
|
for (const remote of remoteProjects) {
|
|
3533
3765
|
const local = localById.get(remote.id);
|
|
3534
3766
|
if (local) {
|
|
3535
|
-
|
|
3767
|
+
let isReferenceRunner = remote.runner_id === config.runnerId;
|
|
3768
|
+
if (!isReferenceRunner && isRunnerHeartbeatStale(referenceHeartbeats.get(remote.runner_id) ?? null)) {
|
|
3769
|
+
try {
|
|
3770
|
+
await claimReferenceRunner(supabase, remote.id, config.runnerId);
|
|
3771
|
+
isReferenceRunner = true;
|
|
3772
|
+
logger.runner.info(
|
|
3773
|
+
"PROJECT_SYNC",
|
|
3774
|
+
`Claimed reference runner for project ${remote.id} \u2014 previous one (${remote.runner_id}) looks offline.`
|
|
3775
|
+
);
|
|
3776
|
+
} catch (err) {
|
|
3777
|
+
logger.runner.error("PROJECT_SYNC", `Failed to claim reference runner for project ${remote.id}:`, err);
|
|
3778
|
+
}
|
|
3779
|
+
}
|
|
3536
3780
|
if (local.isReferenceRunner !== isReferenceRunner) {
|
|
3537
3781
|
local.isReferenceRunner = isReferenceRunner;
|
|
3538
3782
|
changed = true;
|
|
@@ -3663,14 +3907,14 @@ async function checkPendingDevServerRestarts(supabase, config) {
|
|
|
3663
3907
|
}
|
|
3664
3908
|
|
|
3665
3909
|
// src/terminalServer.ts
|
|
3666
|
-
import { parse as
|
|
3667
|
-
import { WebSocketServer as
|
|
3910
|
+
import { parse as parse4 } from "node:url";
|
|
3911
|
+
import { WebSocketServer as WebSocketServer3 } from "ws";
|
|
3668
3912
|
var FRAME_TYPE_RESIZE = 0;
|
|
3669
3913
|
var FRAME_TYPE_IMAGE = 1;
|
|
3670
3914
|
function attachTerminalServer() {
|
|
3671
|
-
const wss = new
|
|
3915
|
+
const wss = new WebSocketServer3({ noServer: true });
|
|
3672
3916
|
wss.on("connection", (socket, request) => {
|
|
3673
|
-
const { query } =
|
|
3917
|
+
const { query } = parse4(request.url ?? "", true);
|
|
3674
3918
|
const sessionId = typeof query.sessionId === "string" ? query.sessionId : null;
|
|
3675
3919
|
const ticket = typeof query.ticket === "string" ? query.ticket : null;
|
|
3676
3920
|
if (!sessionId || !ticket) {
|
|
@@ -3776,7 +4020,7 @@ async function startRunner() {
|
|
|
3776
4020
|
logger.runner.error("BOOT", 'No runner set up yet. Run "agent-board-runner start --path <repos-root>" first.');
|
|
3777
4021
|
process.exit(1);
|
|
3778
4022
|
}
|
|
3779
|
-
const config = applyRunnerName(applyAllowedRoot(loadedConfig));
|
|
4023
|
+
const config = ensureRecoveryKeypair(applyRunnerName(applyAllowedRoot(loadedConfig)));
|
|
3780
4024
|
if (!config.allowedRootPath) {
|
|
3781
4025
|
logger.runner.error(
|
|
3782
4026
|
"BOOT",
|
|
@@ -3784,10 +4028,9 @@ async function startRunner() {
|
|
|
3784
4028
|
);
|
|
3785
4029
|
process.exit(1);
|
|
3786
4030
|
}
|
|
3787
|
-
|
|
3788
|
-
let session;
|
|
4031
|
+
let supabase, session;
|
|
3789
4032
|
try {
|
|
3790
|
-
session = await
|
|
4033
|
+
({ client: supabase, session } = await ensureSession(config));
|
|
3791
4034
|
} catch (error) {
|
|
3792
4035
|
logger.runner.error("BOOT", error instanceof Error ? error.message : String(error));
|
|
3793
4036
|
process.exit(1);
|
|
@@ -3799,7 +4042,7 @@ async function startRunner() {
|
|
|
3799
4042
|
if (event === "SIGNED_OUT") {
|
|
3800
4043
|
logger.runner.error(
|
|
3801
4044
|
"AUTH",
|
|
3802
|
-
|
|
4045
|
+
"Supabase session was lost (refresh failed or was revoked) \u2014 exiting so the process manager restarts this runner; the next boot recovers automatically."
|
|
3803
4046
|
);
|
|
3804
4047
|
process.exit(1);
|
|
3805
4048
|
}
|
|
@@ -3824,6 +4067,10 @@ async function startRunner() {
|
|
|
3824
4067
|
user_id: config.ownerUserId,
|
|
3825
4068
|
name: config.runnerName,
|
|
3826
4069
|
auth_token_hash: config.authToken,
|
|
4070
|
+
// Re-sent on every boot with a real session — idempotent (same local key every time) and
|
|
4071
|
+
// what makes automatic recovery possible the next time this runner loses its session, see
|
|
4072
|
+
// ensureSession()/recoverSession() in auth.ts.
|
|
4073
|
+
recovery_public_key: config.recoveryPublicKey,
|
|
3827
4074
|
version: RUNNER_VERSION
|
|
3828
4075
|
});
|
|
3829
4076
|
if (runnerError) {
|
|
@@ -3848,6 +4095,8 @@ async function startRunner() {
|
|
|
3848
4095
|
const httpServer = createServer();
|
|
3849
4096
|
const terminalWss = attachTerminalServer();
|
|
3850
4097
|
const agentEventsWss = attachAgentEventsServer();
|
|
4098
|
+
const runnerRpcWss = attachRunnerRpcServer();
|
|
4099
|
+
registerProjectCommandRpcHandler(supabase, config);
|
|
3851
4100
|
attachFilesServer(httpServer, supabase, config);
|
|
3852
4101
|
httpServer.on("upgrade", (request, socket, head) => {
|
|
3853
4102
|
const pathname = (request.url ?? "").split("?")[0];
|
|
@@ -3855,6 +4104,8 @@ async function startRunner() {
|
|
|
3855
4104
|
terminalWss.handleUpgrade(request, socket, head, (ws) => terminalWss.emit("connection", ws, request));
|
|
3856
4105
|
} else if (pathname === "/agent-events") {
|
|
3857
4106
|
agentEventsWss.handleUpgrade(request, socket, head, (ws) => agentEventsWss.emit("connection", ws, request));
|
|
4107
|
+
} else if (pathname === "/runner-rpc") {
|
|
4108
|
+
runnerRpcWss.handleUpgrade(request, socket, head, (ws) => runnerRpcWss.emit("connection", ws, request));
|
|
3858
4109
|
} else {
|
|
3859
4110
|
socket.destroy();
|
|
3860
4111
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-board-runner",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
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": {
|