@kilogent/runner-dev 0.1.3 → 0.1.4
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/README.md +37 -11
- package/dist/cli.js +1348 -195
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
|
|
6
6
|
// src/daemon.ts
|
|
7
|
-
import
|
|
7
|
+
import fs9 from "node:fs";
|
|
8
|
+
import os5 from "node:os";
|
|
9
|
+
import path11 from "node:path";
|
|
8
10
|
import {
|
|
9
11
|
collection as collection8,
|
|
10
12
|
deleteField as deleteField2,
|
|
@@ -24,6 +26,8 @@ import {
|
|
|
24
26
|
var CLAUDE_ENGINE = {
|
|
25
27
|
id: "claude",
|
|
26
28
|
label: "Claude Code",
|
|
29
|
+
vendor: "Anthropic",
|
|
30
|
+
accountLabel: "Claude account",
|
|
27
31
|
models: [
|
|
28
32
|
{ id: "sonnet", label: "Sonnet", rates: { input: 3, cachedInput: 0.3, output: 15 } },
|
|
29
33
|
{ id: "opus", label: "Opus", rates: { input: 15, cachedInput: 1.5, output: 75 } }
|
|
@@ -41,6 +45,8 @@ var CLAUDE_ENGINE = {
|
|
|
41
45
|
key: "claudeToken",
|
|
42
46
|
label: "Claude setup-token",
|
|
43
47
|
hint: "Run `claude setup-token` on any machine signed in to Claude Code.",
|
|
48
|
+
flow: "paste",
|
|
49
|
+
setupCommand: "claude setup-token",
|
|
44
50
|
// `claude setup-token` emits `sk-ant-oat01-…`, around 100 characters. The floor is set
|
|
45
51
|
// well below that on purpose: it exists to catch a half-copied token, not to pin a format
|
|
46
52
|
// Anthropic is free to change.
|
|
@@ -58,9 +64,50 @@ var CLAUDE_ENGINE = {
|
|
|
58
64
|
}
|
|
59
65
|
};
|
|
60
66
|
|
|
67
|
+
// ../shared/dist/engines/codex.js
|
|
68
|
+
var CODEX_ENGINE = {
|
|
69
|
+
id: "codex",
|
|
70
|
+
label: "Codex CLI",
|
|
71
|
+
vendor: "OpenAI",
|
|
72
|
+
accountLabel: "ChatGPT account",
|
|
73
|
+
models: [
|
|
74
|
+
{ id: "gpt-5.5", label: "GPT-5.5", rates: { input: 5, cachedInput: 0.5, output: 30 } },
|
|
75
|
+
{ id: "gpt-5.4", label: "GPT-5.4", rates: { input: 2.5, cachedInput: 0.25, output: 15 } },
|
|
76
|
+
{ id: "gpt-5.4-mini", label: "GPT-5.4 mini", rates: { input: 0.75, cachedInput: 0.075, output: 4.5 } },
|
|
77
|
+
{ id: "gpt-5.6-terra", label: "GPT-5.6 Terra", rates: { input: 2, cachedInput: 0.2, output: 12 } },
|
|
78
|
+
{ id: "gpt-5.6-luna", label: "GPT-5.6 Luna", rates: { input: 0.2, cachedInput: 0.02, output: 1.2 } }
|
|
79
|
+
],
|
|
80
|
+
defaultModelId: "gpt-5.5",
|
|
81
|
+
capabilities: {
|
|
82
|
+
mcp: true,
|
|
83
|
+
bash: true,
|
|
84
|
+
webSearch: false,
|
|
85
|
+
reportsCost: false,
|
|
86
|
+
reportsCache: true
|
|
87
|
+
},
|
|
88
|
+
requiredSecrets: [
|
|
89
|
+
{
|
|
90
|
+
key: "codexHome",
|
|
91
|
+
label: "ChatGPT sign-in (Codex)",
|
|
92
|
+
hint: "Sign one of this Ship's machines in from Settings \u203A AI credentials \u2014 the login never leaves it.",
|
|
93
|
+
flow: "machine-login",
|
|
94
|
+
// What `codex login --device-auth` prints: four characters, a dash, five. Display only.
|
|
95
|
+
codePattern: "^[A-Z0-9]{4}-[A-Z0-9]{5}$"
|
|
96
|
+
}
|
|
97
|
+
],
|
|
98
|
+
usageWindows: {
|
|
99
|
+
label: "5-hour window",
|
|
100
|
+
// ChatGPT plans meter Codex on a rolling 5-hour window with a weekly cap on top — the same two
|
|
101
|
+
// walls Claude has, and the same reason the fallback is the SHORT one: the wall text does not
|
|
102
|
+
// say which was hit, and parking a Ship for a week on a guess is the worse mistake.
|
|
103
|
+
fallbackMs: 5 * 60 * 60 * 1e3
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
61
107
|
// ../shared/dist/engines/index.js
|
|
62
108
|
var ENGINES = {
|
|
63
|
-
claude: CLAUDE_ENGINE
|
|
109
|
+
claude: CLAUDE_ENGINE,
|
|
110
|
+
codex: CODEX_ENGINE
|
|
64
111
|
};
|
|
65
112
|
var DEFAULT_ENGINE_ID = "claude";
|
|
66
113
|
function listEngines() {
|
|
@@ -87,6 +134,10 @@ function estimateCostUsd(engineId, modelId, tokens) {
|
|
|
87
134
|
}
|
|
88
135
|
var AGENT_ENGINE_IDS = Object.freeze(listEngines().map((e) => e.id));
|
|
89
136
|
var AGENT_MODEL_IDS = Object.freeze(listEngines().flatMap((e) => e.models.map((m) => m.id)));
|
|
137
|
+
var MACHINE_LOGIN_ENGINE_IDS = Object.freeze(listEngines().filter((e) => e.requiredSecrets.some((r) => r.flow === "machine-login")).map((e) => e.id));
|
|
138
|
+
function isMachineLoginEngine(id) {
|
|
139
|
+
return !!id && MACHINE_LOGIN_ENGINE_IDS.includes(id);
|
|
140
|
+
}
|
|
90
141
|
|
|
91
142
|
// ../shared/dist/agent.js
|
|
92
143
|
var DEFAULT_AGENT_TOOLS = {
|
|
@@ -553,7 +604,7 @@ var COLLECTIONS = {
|
|
|
553
604
|
* ROOT: `crewAdmins/{uid}` — one document per PLATFORM ADMIN, the people who may operate the
|
|
554
605
|
* admin console.
|
|
555
606
|
*
|
|
556
|
-
* DENY-ALL to every client in packages/crew/firestore.rules, in both directions. `
|
|
607
|
+
* DENY-ALL to every client in packages/crew/firestore.rules, in both directions. `kilogentAdmin`
|
|
557
608
|
* resolves the caller and reads this with the Admin SDK, which bypasses rules — so no browser
|
|
558
609
|
* ever needs a verb here, and a collection nobody may read cannot leak who the operators are.
|
|
559
610
|
*
|
|
@@ -571,7 +622,7 @@ var COLLECTIONS = {
|
|
|
571
622
|
/**
|
|
572
623
|
* ROOT: `adminAudit/{entryId}` — what an admin did, with both sides of the change.
|
|
573
624
|
*
|
|
574
|
-
* Deny-all to clients; read back through `
|
|
625
|
+
* Deny-all to clients; read back through `kilogentAdmin`. Its second job is what earns it a
|
|
575
626
|
* collection: it is the ONLY history `crewConfig/assistant` has. That document is overwritten in
|
|
576
627
|
* place, so without this a bad prompt pushed to every workspace could not be rolled back to the
|
|
577
628
|
* exact text that preceded it.
|
|
@@ -600,6 +651,14 @@ var COLLECTIONS = {
|
|
|
600
651
|
* catch a forgotten credential-bearing collection.
|
|
601
652
|
*/
|
|
602
653
|
credentials: "credentials",
|
|
654
|
+
/**
|
|
655
|
+
* `ships/{shipId}/credential_logins/{credId}` — the device-code half of a MACHINE-HELD credential
|
|
656
|
+
* while it is being signed in (PRD §15.81; see credentials.ts). Readable only by the captain who
|
|
657
|
+
* asked for it: the one-time code inside is a pairing secret for fifteen minutes, and a member
|
|
658
|
+
* who saw it could sign their own account onto the Ship's machine. Snake_case for the reason
|
|
659
|
+
* `mcp_servers` gives.
|
|
660
|
+
*/
|
|
661
|
+
credentialLogins: "credential_logins",
|
|
603
662
|
/** `ships/{shipId}/integrations/{docId}` — 3rd-party connectors (GitHub App; see github.ts). */
|
|
604
663
|
integrations: "integrations",
|
|
605
664
|
/**
|
|
@@ -732,12 +791,26 @@ function autonomyRule(agent) {
|
|
|
732
791
|
var ASK_GUIDANCE = "If the task says who should approve \u2014 in its description, or because a particular person owns that area \u2014 name them in the request's `ask` so it reaches them rather than everyone.";
|
|
733
792
|
|
|
734
793
|
// ../shared/dist/credentials.js
|
|
794
|
+
var MACHINE_LOGIN_TTL_MS = 20 * 60 * 1e3;
|
|
795
|
+
function isCredentialReady(c) {
|
|
796
|
+
return c.status === void 0 || c.status === "ready";
|
|
797
|
+
}
|
|
798
|
+
function isMachineHeldCredential(c) {
|
|
799
|
+
return typeof c.runnerId === "string" && c.runnerId.length > 0;
|
|
800
|
+
}
|
|
801
|
+
function machineLoginPending(c, now) {
|
|
802
|
+
if (c.status !== "pending" || !c.login)
|
|
803
|
+
return null;
|
|
804
|
+
if (c.login.requestedAt <= (c.login.answeredAt ?? 0))
|
|
805
|
+
return null;
|
|
806
|
+
return now - c.login.requestedAt < MACHINE_LOGIN_TTL_MS ? "start" : "expire";
|
|
807
|
+
}
|
|
735
808
|
function resolveAgentCredential(agent, credentials, defaultEngine) {
|
|
736
809
|
const engine = agent.engine ?? defaultEngine;
|
|
737
810
|
if (agent.credentialId) {
|
|
738
811
|
return credentials.find((c) => c.id === agent.credentialId) ?? null;
|
|
739
812
|
}
|
|
740
|
-
const forEngine = credentials.filter((c) => c.engine === engine);
|
|
813
|
+
const forEngine = credentials.filter((c) => c.engine === engine && isCredentialReady(c));
|
|
741
814
|
const oldest = [...forEngine].sort((a, b) => a.createdAt - b.createdAt)[0];
|
|
742
815
|
return oldest ?? void 0;
|
|
743
816
|
}
|
|
@@ -814,9 +887,9 @@ function str(input, key) {
|
|
|
814
887
|
const value = input[key];
|
|
815
888
|
return typeof value === "string" && value.trim() ? value : void 0;
|
|
816
889
|
}
|
|
817
|
-
function basename(
|
|
818
|
-
const parts =
|
|
819
|
-
return parts[parts.length - 1] ??
|
|
890
|
+
function basename(path13) {
|
|
891
|
+
const parts = path13.split(/[\\/]/).filter(Boolean);
|
|
892
|
+
return parts[parts.length - 1] ?? path13;
|
|
820
893
|
}
|
|
821
894
|
function hostOf(url) {
|
|
822
895
|
try {
|
|
@@ -904,8 +977,8 @@ function builtinDetail(tool, input) {
|
|
|
904
977
|
case "Write":
|
|
905
978
|
case "Edit":
|
|
906
979
|
case "MultiEdit": {
|
|
907
|
-
const
|
|
908
|
-
return
|
|
980
|
+
const path13 = str(input, "file_path");
|
|
981
|
+
return path13 ? basename(path13) : void 0;
|
|
909
982
|
}
|
|
910
983
|
case "Glob":
|
|
911
984
|
case "Grep":
|
|
@@ -1099,6 +1172,10 @@ function firstStatusIn(statuses, category) {
|
|
|
1099
1172
|
var RUNNER_OFFLINE_AFTER_MS = 2 * 60 * 1e3;
|
|
1100
1173
|
|
|
1101
1174
|
// ../shared/dist/secrets.js
|
|
1175
|
+
function engineSecret(secrets, key) {
|
|
1176
|
+
const value = secrets?.[key];
|
|
1177
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
1178
|
+
}
|
|
1102
1179
|
function missingSecretsFor(engineId, secrets) {
|
|
1103
1180
|
const bag = secrets;
|
|
1104
1181
|
return getEngine(engineId).requiredSecrets.filter((req) => !bag?.[req.key]).map((req) => req.label);
|
|
@@ -1169,7 +1246,7 @@ import os from "node:os";
|
|
|
1169
1246
|
import path from "node:path";
|
|
1170
1247
|
|
|
1171
1248
|
// src/version.ts
|
|
1172
|
-
var RUNNER_VERSION = true ? "0.1.
|
|
1249
|
+
var RUNNER_VERSION = true ? "0.1.4" : "0.0.0-dev";
|
|
1173
1250
|
var RUNNER_PACKAGE = true ? "@kilogent/runner-dev" : "@kilogent/runner-dev";
|
|
1174
1251
|
var RUNNER_BIN = true ? "kilogent-runner-dev" : "kilogent-runner-dev";
|
|
1175
1252
|
|
|
@@ -1301,8 +1378,8 @@ function functionsBaseUrlFor(projectId) {
|
|
|
1301
1378
|
return process.env.CREW_FUNCTIONS_URL?.replace(/\/$/, "") || `https://us-central1-${projectId}.cloudfunctions.net`;
|
|
1302
1379
|
}
|
|
1303
1380
|
var CallableError = class extends Error {
|
|
1304
|
-
constructor(status,
|
|
1305
|
-
super(
|
|
1381
|
+
constructor(status, message3) {
|
|
1382
|
+
super(message3);
|
|
1306
1383
|
this.status = status;
|
|
1307
1384
|
}
|
|
1308
1385
|
status;
|
|
@@ -1335,8 +1412,8 @@ async function request(baseUrl, group, op, data, headers) {
|
|
|
1335
1412
|
|
|
1336
1413
|
// src/auth.ts
|
|
1337
1414
|
var AuthError = class extends Error {
|
|
1338
|
-
constructor(
|
|
1339
|
-
super(
|
|
1415
|
+
constructor(message3, gone = false) {
|
|
1416
|
+
super(message3);
|
|
1340
1417
|
this.gone = gone;
|
|
1341
1418
|
}
|
|
1342
1419
|
gone;
|
|
@@ -1675,8 +1752,8 @@ function isTransientFirestoreError(error) {
|
|
|
1675
1752
|
if (code === "unavailable" || code === "deadline-exceeded" || code === "internal" || code === "cancelled" || code === "aborted" || code === "resource-exhausted") {
|
|
1676
1753
|
return true;
|
|
1677
1754
|
}
|
|
1678
|
-
const
|
|
1679
|
-
return typeof
|
|
1755
|
+
const message3 = error?.message;
|
|
1756
|
+
return typeof message3 === "string" && message3.toLowerCase().includes("client is offline");
|
|
1680
1757
|
}
|
|
1681
1758
|
async function withFirestoreRetry(read, sleep2 = (ms) => new Promise((r) => setTimeout(r, ms)), delays = FIRESTORE_RETRY_DELAYS_MS) {
|
|
1682
1759
|
for (let i = 0; ; i++) {
|
|
@@ -2336,36 +2413,68 @@ async function resolveJobSecrets(input) {
|
|
|
2336
2413
|
if (resolved === void 0) {
|
|
2337
2414
|
return {
|
|
2338
2415
|
secrets: null,
|
|
2416
|
+
kind: "no-credential",
|
|
2339
2417
|
problem: `This workspace has no AI credential for ${engineId}. Add one in Settings \u203A AI credentials and the crew starts taking work again.`
|
|
2340
2418
|
};
|
|
2341
2419
|
}
|
|
2342
2420
|
if (resolved === null) {
|
|
2343
2421
|
return {
|
|
2344
2422
|
secrets: null,
|
|
2423
|
+
kind: "orphaned",
|
|
2345
2424
|
problem: `This agent is set to an AI credential that no longer exists on the Ship. Point it at another one in Settings \u203A AI credentials, or add the credential back.`
|
|
2346
2425
|
};
|
|
2347
2426
|
}
|
|
2427
|
+
const requirement = getEngine(engineId).requiredSecrets[0];
|
|
2428
|
+
const key = requirement?.key;
|
|
2429
|
+
if (!key) return { secrets: null, credentialId: resolved.id };
|
|
2430
|
+
const bag = (value) => ({
|
|
2431
|
+
updatedAt: resolved.updatedAt,
|
|
2432
|
+
updatedBy: resolved.createdBy,
|
|
2433
|
+
[key]: value,
|
|
2434
|
+
[`${key}Tail`]: resolved.tail
|
|
2435
|
+
});
|
|
2436
|
+
if (requirement.flow === "machine-login") {
|
|
2437
|
+
if (!input.machine || resolved.runnerId !== input.machine.runnerId) {
|
|
2438
|
+
return {
|
|
2439
|
+
secrets: null,
|
|
2440
|
+
kind: "other-machine",
|
|
2441
|
+
credentialId: resolved.id,
|
|
2442
|
+
problem: `The AI credential "${resolved.label}" is held by another machine on this Ship, so this machine cannot run jobs on it.`
|
|
2443
|
+
};
|
|
2444
|
+
}
|
|
2445
|
+
if (!isCredentialReady(resolved)) {
|
|
2446
|
+
return {
|
|
2447
|
+
secrets: null,
|
|
2448
|
+
kind: "not-ready",
|
|
2449
|
+
credentialId: resolved.id,
|
|
2450
|
+
problem: `The AI credential "${resolved.label}" is not signed in yet \u2014 finish the sign-in in Settings \u203A AI credentials.`
|
|
2451
|
+
};
|
|
2452
|
+
}
|
|
2453
|
+
const home = input.machine.localLogin(engineId, resolved.id);
|
|
2454
|
+
if (!home) {
|
|
2455
|
+
return {
|
|
2456
|
+
secrets: null,
|
|
2457
|
+
kind: "login-missing",
|
|
2458
|
+
credentialId: resolved.id,
|
|
2459
|
+
problem: `This machine no longer holds the sign-in for the AI credential "${resolved.label}". Delete it in Settings \u203A AI credentials and add it again.`
|
|
2460
|
+
};
|
|
2461
|
+
}
|
|
2462
|
+
return { secrets: bag(home), credentialId: resolved.id };
|
|
2463
|
+
}
|
|
2348
2464
|
const token = await loadCredentialToken(input.db, input.shipId, resolved.id);
|
|
2349
2465
|
if (!token) {
|
|
2350
2466
|
return {
|
|
2351
2467
|
secrets: null,
|
|
2468
|
+
kind: "no-token",
|
|
2469
|
+
credentialId: resolved.id,
|
|
2352
2470
|
problem: `The AI credential "${resolved.label}" has no token stored. Erase it in Settings \u203A AI credentials and add it again.`
|
|
2353
2471
|
};
|
|
2354
2472
|
}
|
|
2355
|
-
|
|
2356
|
-
if (!key) return { secrets: null };
|
|
2357
|
-
return {
|
|
2358
|
-
secrets: {
|
|
2359
|
-
updatedAt: resolved.updatedAt,
|
|
2360
|
-
updatedBy: resolved.createdBy,
|
|
2361
|
-
[key]: token,
|
|
2362
|
-
[`${key}Tail`]: resolved.tail
|
|
2363
|
-
}
|
|
2364
|
-
};
|
|
2473
|
+
return { secrets: bag(token), credentialId: resolved.id };
|
|
2365
2474
|
}
|
|
2366
2475
|
|
|
2367
2476
|
// src/jobs/assistantCredential.ts
|
|
2368
|
-
async function resolveAssistantCredential(db, shipId) {
|
|
2477
|
+
async function resolveAssistantCredential(db, shipId, machine) {
|
|
2369
2478
|
const shipSnap = await getDoc5(doc5(db, COLLECTIONS.ships, shipId));
|
|
2370
2479
|
const settings = shipSnap.data()?.settings;
|
|
2371
2480
|
const credentialId = settings?.assistant?.credentialId ?? settings?.defaultCredentialId;
|
|
@@ -2377,7 +2486,8 @@ async function resolveAssistantCredential(db, shipId) {
|
|
|
2377
2486
|
shipId,
|
|
2378
2487
|
// The synthetic agent: two fields, both of which this module has just decided.
|
|
2379
2488
|
agent: { engine, credentialId },
|
|
2380
|
-
credentials
|
|
2489
|
+
credentials,
|
|
2490
|
+
machine
|
|
2381
2491
|
});
|
|
2382
2492
|
return {
|
|
2383
2493
|
secrets,
|
|
@@ -2394,12 +2504,20 @@ import fs3 from "node:fs";
|
|
|
2394
2504
|
import os2 from "node:os";
|
|
2395
2505
|
import path3 from "node:path";
|
|
2396
2506
|
|
|
2507
|
+
// src/engines/binary.ts
|
|
2508
|
+
function engineBinaryEnvVar(engineId) {
|
|
2509
|
+
return `CREW_${engineId.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_BIN`;
|
|
2510
|
+
}
|
|
2511
|
+
function engineBinary(engineId, defaultName) {
|
|
2512
|
+
return process.env[engineBinaryEnvVar(engineId)] || defaultName;
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2397
2515
|
// src/engines/claudeEvents.ts
|
|
2398
2516
|
function stepsFromClaudeEvent(event, opts) {
|
|
2399
2517
|
const type = typeof event?.type === "string" ? event.type : "";
|
|
2400
2518
|
if (type === "assistant") {
|
|
2401
|
-
const
|
|
2402
|
-
const content = Array.isArray(
|
|
2519
|
+
const message3 = event.message;
|
|
2520
|
+
const content = Array.isArray(message3?.content) ? message3.content : [];
|
|
2403
2521
|
const steps = [];
|
|
2404
2522
|
for (const block of content) {
|
|
2405
2523
|
if (!block || typeof block !== "object") continue;
|
|
@@ -2416,11 +2534,14 @@ function stepsFromClaudeEvent(event, opts) {
|
|
|
2416
2534
|
if (type === "result") return [{ kind: "done", label: "Finished" }];
|
|
2417
2535
|
return [];
|
|
2418
2536
|
}
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2537
|
+
var MCP_TOOL_PREFIX = "mcp__";
|
|
2538
|
+
function createClaudeToolPairing() {
|
|
2539
|
+
return { names: /* @__PURE__ */ new Map() };
|
|
2540
|
+
}
|
|
2541
|
+
function blocksOf(event) {
|
|
2542
|
+
const message3 = event.message;
|
|
2543
|
+
return Array.isArray(message3?.content) ? message3.content : [];
|
|
2544
|
+
}
|
|
2424
2545
|
function resultText(block) {
|
|
2425
2546
|
const content = block.content;
|
|
2426
2547
|
if (typeof content === "string") return content;
|
|
@@ -2431,43 +2552,67 @@ function resultText(block) {
|
|
|
2431
2552
|
}
|
|
2432
2553
|
return "";
|
|
2433
2554
|
}
|
|
2434
|
-
function
|
|
2435
|
-
const
|
|
2436
|
-
|
|
2555
|
+
function mcpCallsFromClaudeEvent(event, pairing) {
|
|
2556
|
+
const type = typeof event?.type === "string" ? event.type : "";
|
|
2557
|
+
if (type === "assistant") {
|
|
2558
|
+
for (const block of blocksOf(event)) {
|
|
2559
|
+
if (!block || typeof block !== "object") continue;
|
|
2560
|
+
if (block.type === "tool_use" && typeof block.id === "string" && typeof block.name === "string") {
|
|
2561
|
+
pairing.names.set(block.id, block.name);
|
|
2562
|
+
}
|
|
2563
|
+
}
|
|
2564
|
+
return [];
|
|
2565
|
+
}
|
|
2566
|
+
if (type !== "user") return [];
|
|
2567
|
+
const calls = [];
|
|
2568
|
+
for (const block of blocksOf(event)) {
|
|
2569
|
+
if (!block || typeof block !== "object") continue;
|
|
2570
|
+
if (block.type !== "tool_result" || typeof block.tool_use_id !== "string") continue;
|
|
2571
|
+
const name = pairing.names.get(block.tool_use_id);
|
|
2572
|
+
if (!name) continue;
|
|
2573
|
+
const server = name.startsWith(MCP_TOOL_PREFIX) ? name.slice(MCP_TOOL_PREFIX.length).split("__")[0] ?? "" : "";
|
|
2574
|
+
calls.push({ server, ok: !block.is_error, text: resultText(block) });
|
|
2575
|
+
}
|
|
2576
|
+
return calls;
|
|
2437
2577
|
}
|
|
2578
|
+
|
|
2579
|
+
// src/engines/githubEnv.ts
|
|
2580
|
+
function githubSessionEnv(token) {
|
|
2581
|
+
if (!token) return {};
|
|
2582
|
+
return {
|
|
2583
|
+
GH_TOKEN: token,
|
|
2584
|
+
GITHUB_TOKEN: token,
|
|
2585
|
+
GIT_CONFIG_COUNT: "1",
|
|
2586
|
+
GIT_CONFIG_KEY_0: "url.https://x-access-token:" + token + "@github.com/.insteadOf",
|
|
2587
|
+
GIT_CONFIG_VALUE_0: "https://github.com/"
|
|
2588
|
+
};
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
// src/engines/limitWindow.ts
|
|
2592
|
+
var MAX_LIMIT_MS = 7 * 24 * 60 * 60 * 1e3 + 60 * 60 * 1e3;
|
|
2593
|
+
function saneResetAt(reported, now, maxMs = MAX_LIMIT_MS) {
|
|
2594
|
+
return Number.isFinite(reported) && reported > now && reported <= now + maxMs;
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
// src/engines/mcpHealth.ts
|
|
2598
|
+
var CONSECUTIVE_LIMIT = 2;
|
|
2599
|
+
var AUTH_MARKER = /\b401\b|unauthoriz|invalid[_ ]token|token .{0,20}expired|expired .{0,20}token|protected resource/i;
|
|
2438
2600
|
function createWorkspaceMcpWatch() {
|
|
2439
|
-
const toolNames = /* @__PURE__ */ new Map();
|
|
2440
2601
|
let consecutive = 0;
|
|
2441
2602
|
let reported = false;
|
|
2442
2603
|
return {
|
|
2443
|
-
observe(
|
|
2444
|
-
if (reported) return null;
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
if (block.type === "tool_use" && typeof block.id === "string" && typeof block.name === "string") {
|
|
2450
|
-
toolNames.set(block.id, block.name);
|
|
2451
|
-
}
|
|
2452
|
-
}
|
|
2604
|
+
observe(call) {
|
|
2605
|
+
if (reported || !call || typeof call !== "object") return null;
|
|
2606
|
+
if (call.server !== WORKSPACE_MCP_KEY) return null;
|
|
2607
|
+
const text2 = typeof call.text === "string" ? call.text : "";
|
|
2608
|
+
if (call.ok || !AUTH_MARKER.test(text2)) {
|
|
2609
|
+
consecutive = 0;
|
|
2453
2610
|
return null;
|
|
2454
2611
|
}
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
const name = toolNames.get(block.tool_use_id);
|
|
2460
|
-
if (!name || !name.startsWith(WORKSPACE_TOOL_PREFIX)) continue;
|
|
2461
|
-
if (!block.is_error || !AUTH_MARKER.test(resultText(block))) {
|
|
2462
|
-
consecutive = 0;
|
|
2463
|
-
continue;
|
|
2464
|
-
}
|
|
2465
|
-
consecutive += 1;
|
|
2466
|
-
if (consecutive < CONSECUTIVE_LIMIT) continue;
|
|
2467
|
-
reported = true;
|
|
2468
|
-
return `The Workspace MCP ("${WORKSPACE_MCP_KEY}") stopped accepting this session's credential part-way through the run: ${CONSECUTIVE_LIMIT} consecutive calls came back refused ("${resultText(block).slice(0, 160).trim()}"). Everything after that point would have run with none of this Ship's tools \u2014 no task_comment, no run_report, no chat_send \u2014 so the session was stopped rather than allowed to finish blind. The run is retried with a fresh credential.`;
|
|
2469
|
-
}
|
|
2470
|
-
return null;
|
|
2612
|
+
consecutive += 1;
|
|
2613
|
+
if (consecutive < CONSECUTIVE_LIMIT) return null;
|
|
2614
|
+
reported = true;
|
|
2615
|
+
return `The Workspace MCP ("${WORKSPACE_MCP_KEY}") stopped accepting this session's credential part-way through the run: ${CONSECUTIVE_LIMIT} consecutive calls came back refused ("${text2.slice(0, 160).trim()}"). Everything after that point would have run with none of this Ship's tools \u2014 no task_comment, no run_report, no chat_send \u2014 so the session was stopped rather than allowed to finish blind. The run is retried with a fresh credential.`;
|
|
2471
2616
|
}
|
|
2472
2617
|
};
|
|
2473
2618
|
}
|
|
@@ -2507,12 +2652,11 @@ function normalizeClaudeUsage(resultEvent, model, fallbackDurationS) {
|
|
|
2507
2652
|
}
|
|
2508
2653
|
var LIMIT_RE = /usage limit (?:reached|exceeded|has been reached)/i;
|
|
2509
2654
|
var LIMIT_RESET_RE = /\|\s*(\d{10})\b/;
|
|
2510
|
-
var MAX_LIMIT_MS = 7 * 24 * 60 * 60 * 1e3 + 60 * 60 * 1e3;
|
|
2511
2655
|
function detectClaudeLimit(text2, now, fallbackMs) {
|
|
2512
2656
|
if (!text2 || !LIMIT_RE.test(text2)) return void 0;
|
|
2513
2657
|
const match = LIMIT_RESET_RE.exec(text2);
|
|
2514
2658
|
const reported = match ? Number(match[1]) * 1e3 : NaN;
|
|
2515
|
-
const sane =
|
|
2659
|
+
const sane = saneResetAt(reported, now);
|
|
2516
2660
|
return {
|
|
2517
2661
|
resetsAt: sane ? reported : now + fallbackMs,
|
|
2518
2662
|
resetsAtReported: sane,
|
|
@@ -2625,7 +2769,7 @@ function cleanupSessionDirs(dirs) {
|
|
|
2625
2769
|
fs3.rmSync(dirs.configDir, { recursive: true, force: true });
|
|
2626
2770
|
}
|
|
2627
2771
|
async function runClaudeSession(input) {
|
|
2628
|
-
const bin =
|
|
2772
|
+
const bin = claudeBinary();
|
|
2629
2773
|
if (input.signal?.aborted) {
|
|
2630
2774
|
return {
|
|
2631
2775
|
ok: false,
|
|
@@ -2657,7 +2801,7 @@ async function runSession(input, bin, dirs) {
|
|
|
2657
2801
|
"--allowedTools",
|
|
2658
2802
|
allowedTools(input.agent, input.extraMcpServers ?? []).join(",")
|
|
2659
2803
|
];
|
|
2660
|
-
const claudeToken = input.secrets
|
|
2804
|
+
const claudeToken = engineSecret(input.secrets, CLAUDE_SECRET_KEY);
|
|
2661
2805
|
const env = {
|
|
2662
2806
|
...process.env,
|
|
2663
2807
|
// THE INIT EVENT MUST BE TRUTHFUL, and one inherited variable is enough to make it lie.
|
|
@@ -2676,16 +2820,7 @@ async function runSession(input, bin, dirs) {
|
|
|
2676
2820
|
MCP_CONNECTION_NONBLOCKING: void 0,
|
|
2677
2821
|
MCP_SERVER_CONNECTION_BATCH_SIZE: void 0,
|
|
2678
2822
|
...claudeToken ? { CLAUDE_CODE_OAUTH_TOKEN: claudeToken } : {},
|
|
2679
|
-
...input.githubToken
|
|
2680
|
-
GH_TOKEN: input.githubToken,
|
|
2681
|
-
GITHUB_TOKEN: input.githubToken,
|
|
2682
|
-
// Per-process git auth so bare `git clone https://github.com/o/r` works WITHOUT
|
|
2683
|
-
// touching the machine's ~/.gitconfig. A GitHub App installation token authenticates
|
|
2684
|
-
// as `x-access-token:<token>`; a classic PAT works the same way here.
|
|
2685
|
-
GIT_CONFIG_COUNT: "1",
|
|
2686
|
-
GIT_CONFIG_KEY_0: "url.https://x-access-token:" + input.githubToken + "@github.com/.insteadOf",
|
|
2687
|
-
GIT_CONFIG_VALUE_0: "https://github.com/"
|
|
2688
|
-
} : {}
|
|
2823
|
+
...githubSessionEnv(input.githubToken)
|
|
2689
2824
|
};
|
|
2690
2825
|
const mcpNames = {};
|
|
2691
2826
|
for (const s of input.extraMcpServers ?? []) mcpNames[s.key] = s.name || s.key;
|
|
@@ -2698,6 +2833,7 @@ async function runSession(input, bin, dirs) {
|
|
|
2698
2833
|
const expectedServers = expectedMcpServers(input.agent, input.extraMcpServers ?? []);
|
|
2699
2834
|
const mcpFailed = [];
|
|
2700
2835
|
const mcpWatch = createWorkspaceMcpWatch();
|
|
2836
|
+
const pairing = createClaudeToolPairing();
|
|
2701
2837
|
const exitCode = await new Promise((resolve) => {
|
|
2702
2838
|
const child = spawn3(bin, args, { cwd: dirs.workdir, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
2703
2839
|
const killTimer = setTimeout(() => {
|
|
@@ -2744,7 +2880,9 @@ async function runSession(input, bin, dirs) {
|
|
|
2744
2880
|
}
|
|
2745
2881
|
}
|
|
2746
2882
|
if (workspaceRequired && !mcpProblem) {
|
|
2747
|
-
|
|
2883
|
+
for (const call of mcpCallsFromClaudeEvent(event, pairing)) {
|
|
2884
|
+
stopBlindSession(mcpWatch.observe(call));
|
|
2885
|
+
}
|
|
2748
2886
|
}
|
|
2749
2887
|
if (input.onStep) {
|
|
2750
2888
|
for (const step2 of stepsFromClaudeEvent(event, { mcpNames })) input.onStep(step2);
|
|
@@ -2776,9 +2914,9 @@ async function runSession(input, bin, dirs) {
|
|
|
2776
2914
|
const durationS = Math.round((Date.now() - startedAt) / 1e3);
|
|
2777
2915
|
const { usage, result } = normalizeClaudeUsage(resultEvent, input.agent.model, durationS);
|
|
2778
2916
|
const ok2 = !mcpProblem && exitCode === 0 && !!resultEvent && !result.is_error;
|
|
2779
|
-
const
|
|
2917
|
+
const resultText3 = mcpProblem ?? result.result ?? (ok2 ? "" : `Session ended with exit code ${exitCode}${resultEvent ? "" : " and no result event"}.`);
|
|
2780
2918
|
const limit4 = ok2 ? void 0 : detectClaudeLimit(
|
|
2781
|
-
`${
|
|
2919
|
+
`${resultText3}
|
|
2782
2920
|
${stderrLines.slice(-20).join("\n")}`,
|
|
2783
2921
|
Date.now(),
|
|
2784
2922
|
engineUsageWindows(CLAUDE_DRIVER_ID)?.fallbackMs ?? 5 * 60 * 60 * 1e3
|
|
@@ -2788,12 +2926,17 @@ ${stderrLines.slice(-20).join("\n")}`,
|
|
|
2788
2926
|
transcript: `${lines.join("\n")}
|
|
2789
2927
|
`,
|
|
2790
2928
|
usage,
|
|
2791
|
-
resultText:
|
|
2929
|
+
resultText: resultText3,
|
|
2792
2930
|
...limit4 ? { limit: limit4 } : {},
|
|
2793
2931
|
...mcpFailed.length ? { mcpFailed } : {}
|
|
2794
2932
|
};
|
|
2795
2933
|
}
|
|
2796
2934
|
var CLAUDE_DRIVER_ID = "claude";
|
|
2935
|
+
var CLAUDE_SECRET_KEY = "claudeToken";
|
|
2936
|
+
var CLAUDE_SECRET_PATTERNS = Object.freeze([/sk-ant-[A-Za-z0-9_-]{8,}/g]);
|
|
2937
|
+
function claudeBinary() {
|
|
2938
|
+
return engineBinary(CLAUDE_DRIVER_ID, "claude");
|
|
2939
|
+
}
|
|
2797
2940
|
function emptyUsage() {
|
|
2798
2941
|
return {
|
|
2799
2942
|
engine: CLAUDE_DRIVER_ID,
|
|
@@ -2806,7 +2949,7 @@ function emptyUsage() {
|
|
|
2806
2949
|
};
|
|
2807
2950
|
}
|
|
2808
2951
|
async function claudeHealthCheck() {
|
|
2809
|
-
const bin =
|
|
2952
|
+
const bin = claudeBinary();
|
|
2810
2953
|
return new Promise((resolve) => {
|
|
2811
2954
|
let settled = false;
|
|
2812
2955
|
const done = (health) => {
|
|
@@ -2828,7 +2971,7 @@ async function claudeHealthCheck() {
|
|
|
2828
2971
|
done({
|
|
2829
2972
|
ok: false,
|
|
2830
2973
|
detail: `\`${bin}\` not found on PATH`,
|
|
2831
|
-
fix:
|
|
2974
|
+
fix: `Install the Claude CLI (https://claude.com/claude-code), or set ${engineBinaryEnvVar(CLAUDE_DRIVER_ID)} to its path.`
|
|
2832
2975
|
});
|
|
2833
2976
|
});
|
|
2834
2977
|
child.on("close", (code) => {
|
|
@@ -2847,12 +2990,533 @@ async function claudeHealthCheck() {
|
|
|
2847
2990
|
var claudeDriver = {
|
|
2848
2991
|
engineId: CLAUDE_DRIVER_ID,
|
|
2849
2992
|
run: runClaudeSession,
|
|
2850
|
-
healthCheck: claudeHealthCheck
|
|
2993
|
+
healthCheck: claudeHealthCheck,
|
|
2994
|
+
binary: claudeBinary,
|
|
2995
|
+
secretValues: (secrets) => {
|
|
2996
|
+
const token = engineSecret(secrets, CLAUDE_SECRET_KEY);
|
|
2997
|
+
return token ? [token] : [];
|
|
2998
|
+
},
|
|
2999
|
+
secretPatterns: CLAUDE_SECRET_PATTERNS
|
|
3000
|
+
};
|
|
3001
|
+
|
|
3002
|
+
// src/engines/codex.ts
|
|
3003
|
+
import { spawn as spawn4 } from "node:child_process";
|
|
3004
|
+
import fs4 from "node:fs";
|
|
3005
|
+
import os3 from "node:os";
|
|
3006
|
+
import path4 from "node:path";
|
|
3007
|
+
|
|
3008
|
+
// src/engines/ansi.ts
|
|
3009
|
+
var ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g;
|
|
3010
|
+
function stripAnsi(text2) {
|
|
3011
|
+
return text2.replace(ANSI_RE, "");
|
|
3012
|
+
}
|
|
3013
|
+
|
|
3014
|
+
// src/engines/codexEvents.ts
|
|
3015
|
+
function itemOf(event) {
|
|
3016
|
+
const item = event.item;
|
|
3017
|
+
return item && typeof item === "object" ? item : null;
|
|
3018
|
+
}
|
|
3019
|
+
function stepsFromCodexEvent(event, opts) {
|
|
3020
|
+
const type = typeof event?.type === "string" ? event.type : "";
|
|
3021
|
+
if (type === "turn.completed") return [{ kind: "done", label: "Finished" }];
|
|
3022
|
+
const item = itemOf(event);
|
|
3023
|
+
if (!item) return [];
|
|
3024
|
+
if (type === "item.started") {
|
|
3025
|
+
switch (item.type) {
|
|
3026
|
+
case "command_execution":
|
|
3027
|
+
return [describeToolUse("Bash", { command: item.command })];
|
|
3028
|
+
case "mcp_tool_call":
|
|
3029
|
+
if (typeof item.server === "string" && typeof item.tool === "string") {
|
|
3030
|
+
return [describeToolUse(`mcp__${item.server}__${item.tool}`, item.arguments, { mcpNames: opts?.mcpNames })];
|
|
3031
|
+
}
|
|
3032
|
+
return [];
|
|
3033
|
+
case "reasoning":
|
|
3034
|
+
return [{ kind: "thinking", label: "Thinking" }];
|
|
3035
|
+
case "web_search":
|
|
3036
|
+
return [describeToolUse("WebSearch", { query: item.query })];
|
|
3037
|
+
case "todo_list":
|
|
3038
|
+
return [describeToolUse("TodoWrite", {})];
|
|
3039
|
+
default:
|
|
3040
|
+
return [];
|
|
3041
|
+
}
|
|
3042
|
+
}
|
|
3043
|
+
if (type === "item.completed") {
|
|
3044
|
+
switch (item.type) {
|
|
3045
|
+
case "agent_message":
|
|
3046
|
+
return typeof item.text === "string" && item.text.trim() ? [describeAssistantText(item.text)] : [];
|
|
3047
|
+
case "file_change":
|
|
3048
|
+
return (item.changes ?? []).filter((c) => c && typeof c === "object" && typeof c.path === "string").map((c) => describeToolUse("Edit", { file_path: c.path }));
|
|
3049
|
+
// A reasoning item that only ever completes (no started line) still deserves its label.
|
|
3050
|
+
case "reasoning":
|
|
3051
|
+
return [];
|
|
3052
|
+
default:
|
|
3053
|
+
return [];
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
3056
|
+
return [];
|
|
3057
|
+
}
|
|
3058
|
+
function mcpCallFromCodexEvent(event) {
|
|
3059
|
+
if (event?.type !== "item.completed") return null;
|
|
3060
|
+
const item = itemOf(event);
|
|
3061
|
+
if (!item || item.type !== "mcp_tool_call" || typeof item.server !== "string") return null;
|
|
3062
|
+
const error = item.error;
|
|
3063
|
+
const errorText = typeof error === "string" ? error : error && typeof error === "object" && typeof error.message === "string" ? error.message : "";
|
|
3064
|
+
const failed = !!error || item.status === "failed";
|
|
3065
|
+
const text2 = failed ? errorText || `status "${item.status ?? "unknown"}"` : resultText2(item.result);
|
|
3066
|
+
return { server: item.server, ok: !failed, text: text2 };
|
|
3067
|
+
}
|
|
3068
|
+
function resultText2(result) {
|
|
3069
|
+
if (typeof result === "string") return result.slice(0, 400);
|
|
3070
|
+
if (result && typeof result === "object") {
|
|
3071
|
+
const content = result.content;
|
|
3072
|
+
if (Array.isArray(content)) {
|
|
3073
|
+
return content.map(
|
|
3074
|
+
(part) => part && typeof part === "object" && typeof part.text === "string" ? part.text : ""
|
|
3075
|
+
).join(" ").slice(0, 400);
|
|
3076
|
+
}
|
|
3077
|
+
try {
|
|
3078
|
+
return JSON.stringify(result).slice(0, 400);
|
|
3079
|
+
} catch {
|
|
3080
|
+
return "";
|
|
3081
|
+
}
|
|
3082
|
+
}
|
|
3083
|
+
return "";
|
|
3084
|
+
}
|
|
3085
|
+
|
|
3086
|
+
// src/engines/codex.ts
|
|
3087
|
+
var CODEX_DRIVER_ID = "codex";
|
|
3088
|
+
var CODEX_SECRET_KEY = "codexHome";
|
|
3089
|
+
var WORKSPACE_TOKEN_ENV = "CREW_MCP_TOKEN";
|
|
3090
|
+
var AUTH_FILE = "auth.json";
|
|
3091
|
+
var MCP_STARTUP_TIMEOUT_S = 60;
|
|
3092
|
+
var MCP_TOOL_TIMEOUT_S = 120;
|
|
3093
|
+
var MCP_TOOL_APPROVAL = "approve";
|
|
3094
|
+
function codexBinary() {
|
|
3095
|
+
return engineBinary(CODEX_DRIVER_ID, "codex");
|
|
3096
|
+
}
|
|
3097
|
+
function codexSandbox(agent) {
|
|
3098
|
+
return agent.autonomy <= 1 ? "read-only" : "workspace-write";
|
|
3099
|
+
}
|
|
3100
|
+
function tomlInline(value) {
|
|
3101
|
+
if (typeof value === "string") return tomlString(value);
|
|
3102
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
3103
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
3104
|
+
if (Array.isArray(value)) return `[${value.map(tomlInline).join(", ")}]`;
|
|
3105
|
+
if (value && typeof value === "object") {
|
|
3106
|
+
const entries = Object.entries(value).filter(([, v]) => v !== void 0);
|
|
3107
|
+
return `{${entries.map(([k, v]) => `${tomlKey(k)} = ${tomlInline(v)}`).join(", ")}}`;
|
|
3108
|
+
}
|
|
3109
|
+
throw new Error(`cannot express ${typeof value} in TOML`);
|
|
3110
|
+
}
|
|
3111
|
+
function tomlString(s) {
|
|
3112
|
+
let out = '"';
|
|
3113
|
+
for (const ch of s) {
|
|
3114
|
+
const code = ch.charCodeAt(0);
|
|
3115
|
+
if (ch === '"') out += '\\"';
|
|
3116
|
+
else if (ch === "\\") out += "\\\\";
|
|
3117
|
+
else if (ch === "\n") out += "\\n";
|
|
3118
|
+
else if (ch === "\r") out += "\\r";
|
|
3119
|
+
else if (ch === " ") out += "\\t";
|
|
3120
|
+
else if (code < 32 || code === 127) out += `\\u${code.toString(16).padStart(4, "0")}`;
|
|
3121
|
+
else out += ch;
|
|
3122
|
+
}
|
|
3123
|
+
return out + '"';
|
|
3124
|
+
}
|
|
3125
|
+
function tomlKey(k) {
|
|
3126
|
+
return /^[A-Za-z0-9_-]+$/.test(k) ? k : tomlString(k);
|
|
3127
|
+
}
|
|
3128
|
+
function codexMcpOverrides(input) {
|
|
3129
|
+
const overrides = [];
|
|
3130
|
+
const add = (key, table) => {
|
|
3131
|
+
overrides.push("-c", `mcp_servers.${tomlKey(key)}=${tomlInline(table)}`);
|
|
3132
|
+
};
|
|
3133
|
+
add(WORKSPACE_MCP_KEY, {
|
|
3134
|
+
url: input.mcpUrl,
|
|
3135
|
+
required: true,
|
|
3136
|
+
bearer_token_env_var: WORKSPACE_TOKEN_ENV,
|
|
3137
|
+
startup_timeout_sec: MCP_STARTUP_TIMEOUT_S,
|
|
3138
|
+
tool_timeout_sec: MCP_TOOL_TIMEOUT_S,
|
|
3139
|
+
default_tools_approval_mode: MCP_TOOL_APPROVAL,
|
|
3140
|
+
http_headers: {
|
|
3141
|
+
"X-Crew-Ship-Id": input.shipId,
|
|
3142
|
+
// Sent for the audit trail; on the runner path the server keys on the job id and reads the
|
|
3143
|
+
// acting agent and task off the job's own document (see the Claude driver's headers note).
|
|
3144
|
+
"X-Crew-Agent-Id": input.agent.id,
|
|
3145
|
+
"X-Crew-Job-Id": input.job.id,
|
|
3146
|
+
// Exactly one of these — a chat job has no task and a task job no chat.
|
|
3147
|
+
...input.job.taskId ? { "X-Crew-Task-Id": input.job.taskId } : {},
|
|
3148
|
+
...input.job.chatId ? { "X-Crew-Chat-Id": input.job.chatId } : {}
|
|
3149
|
+
}
|
|
3150
|
+
});
|
|
3151
|
+
for (const s of input.extraServers ?? []) {
|
|
3152
|
+
if (s.key === WORKSPACE_MCP_KEY) continue;
|
|
3153
|
+
const tools = s.tools?.length ? { enabled_tools: s.tools } : {};
|
|
3154
|
+
if (s.transport === "stdio") {
|
|
3155
|
+
add(s.key, {
|
|
3156
|
+
command: s.command,
|
|
3157
|
+
...s.args?.length ? { args: s.args } : {},
|
|
3158
|
+
...s.env && Object.keys(s.env).length > 0 ? { env: s.env } : {},
|
|
3159
|
+
required: false,
|
|
3160
|
+
default_tools_approval_mode: MCP_TOOL_APPROVAL,
|
|
3161
|
+
...tools
|
|
3162
|
+
});
|
|
3163
|
+
continue;
|
|
3164
|
+
}
|
|
3165
|
+
add(s.key, {
|
|
3166
|
+
url: s.url,
|
|
3167
|
+
...s.headers && Object.keys(s.headers).length > 0 ? { http_headers: s.headers } : {},
|
|
3168
|
+
required: false,
|
|
3169
|
+
default_tools_approval_mode: MCP_TOOL_APPROVAL,
|
|
3170
|
+
...tools
|
|
3171
|
+
});
|
|
3172
|
+
}
|
|
3173
|
+
return overrides;
|
|
3174
|
+
}
|
|
3175
|
+
function codexArgs(input) {
|
|
3176
|
+
const granted = effectiveAgentTools(input.agent);
|
|
3177
|
+
const sandbox = codexSandbox(input.agent);
|
|
3178
|
+
return [
|
|
3179
|
+
"exec",
|
|
3180
|
+
"--json",
|
|
3181
|
+
"--color",
|
|
3182
|
+
"never",
|
|
3183
|
+
"--ephemeral",
|
|
3184
|
+
"--ignore-user-config",
|
|
3185
|
+
"--skip-git-repo-check",
|
|
3186
|
+
"-C",
|
|
3187
|
+
input.workdir,
|
|
3188
|
+
"-s",
|
|
3189
|
+
sandbox,
|
|
3190
|
+
"-m",
|
|
3191
|
+
input.agent.model,
|
|
3192
|
+
// Never a prompt for approval: there is nobody at this terminal. Anything the sandbox refuses
|
|
3193
|
+
// is refused, and the model works around it or reports it.
|
|
3194
|
+
"-c",
|
|
3195
|
+
'approval_policy="never"',
|
|
3196
|
+
// A writing agent that works on GitHub needs the network for `git`; a drafts-only one does
|
|
3197
|
+
// not get it, because it does not get a shell that writes either.
|
|
3198
|
+
...sandbox === "workspace-write" && (granted.github.enabled || granted.bash) ? ["-c", "sandbox_workspace_write.network_access=true"] : [],
|
|
3199
|
+
...codexMcpOverrides(input),
|
|
3200
|
+
input.prompt
|
|
3201
|
+
];
|
|
3202
|
+
}
|
|
3203
|
+
function normalizeCodexUsage(usage, model, durationS) {
|
|
3204
|
+
const n = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0;
|
|
3205
|
+
const inputTokens = n(usage?.input_tokens);
|
|
3206
|
+
const cachedInputTokens = Math.min(inputTokens, n(usage?.cached_input_tokens));
|
|
3207
|
+
const outputTokens = n(usage?.output_tokens);
|
|
3208
|
+
return {
|
|
3209
|
+
engine: CODEX_DRIVER_ID,
|
|
3210
|
+
inputTokens,
|
|
3211
|
+
cachedInputTokens,
|
|
3212
|
+
outputTokens,
|
|
3213
|
+
costUsd: estimateCostUsd(CODEX_DRIVER_ID, model, { inputTokens, cachedInputTokens, outputTokens }),
|
|
3214
|
+
costReported: false,
|
|
3215
|
+
model,
|
|
3216
|
+
durationS,
|
|
3217
|
+
...usage ? { raw: { ...usage } } : {}
|
|
3218
|
+
};
|
|
3219
|
+
}
|
|
3220
|
+
var LIMIT_RE2 = /hit your usage limit|usage limit reached|usage_limit_(?:reached|exceeded)|rate_limit_exceeded/i;
|
|
3221
|
+
var LIMIT_RESET_RE2 = /resets?_at["'\s:=]+(\d{10,13})\b/;
|
|
3222
|
+
function detectCodexLimit(text2, now, fallbackMs) {
|
|
3223
|
+
if (!text2 || !LIMIT_RE2.test(text2)) return void 0;
|
|
3224
|
+
const match = LIMIT_RESET_RE2.exec(text2);
|
|
3225
|
+
const raw = match ? Number(match[1]) : NaN;
|
|
3226
|
+
const reported = Number.isFinite(raw) ? raw < 1e12 ? raw * 1e3 : raw : NaN;
|
|
3227
|
+
const sane = saneResetAt(reported, now);
|
|
3228
|
+
return {
|
|
3229
|
+
resetsAt: sane ? reported : now + fallbackMs,
|
|
3230
|
+
resetsAtReported: sane,
|
|
3231
|
+
detail: text2.trim().slice(0, 300)
|
|
3232
|
+
};
|
|
3233
|
+
}
|
|
3234
|
+
function requiredMcpFailures(stderr, expected) {
|
|
3235
|
+
const m = /required MCP servers failed to initialize:\s*(.+)$/im.exec(stderr);
|
|
3236
|
+
if (!m) return [];
|
|
3237
|
+
const out = [];
|
|
3238
|
+
for (const e of expected) {
|
|
3239
|
+
const re = new RegExp(`(?:^|[\\s,])${e.key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:\\s*([^\\n]*)`);
|
|
3240
|
+
const hit = re.exec(m[1]);
|
|
3241
|
+
if (hit) out.push({ key: e.key, label: e.label, detail: hit[1].trim().slice(0, 300) || "no reason given" });
|
|
3242
|
+
}
|
|
3243
|
+
return out;
|
|
3244
|
+
}
|
|
3245
|
+
function codexWorkspaceRefusal(failure) {
|
|
3246
|
+
return `The Codex CLI did not load ${failure.label} ("${failure.key}"): ${failure.detail}. The session would have had none of this Ship's tools \u2014 no task_comment, no run_report, no chat_send \u2014 so it was stopped instead of run blind. If this keeps happening, update the daemon and the Codex CLI on this machine.`;
|
|
3247
|
+
}
|
|
3248
|
+
var LOGIN_LOST_RE = /not logged in|login required|refresh_token_reused|could not be refreshed|please (?:try )?sign(?:ing)? in again/i;
|
|
3249
|
+
function detectLoginLost(stderr) {
|
|
3250
|
+
const line = stderr.split("\n").find((l) => LOGIN_LOST_RE.test(l));
|
|
3251
|
+
return line ? `This machine's ChatGPT sign-in is no longer usable: ${line.trim().slice(0, 200)}` : void 0;
|
|
3252
|
+
}
|
|
3253
|
+
var GUIDANCE_URL_RE = /https:\/\/auth\.openai\.com\/[^\s"'<>]+/;
|
|
3254
|
+
var GUIDANCE_CODE_RE = /\b[A-Z0-9]{4}-[A-Z0-9]{5}\b/;
|
|
3255
|
+
function decodeJwtPayload(token) {
|
|
3256
|
+
const parts = token.split(".");
|
|
3257
|
+
if (parts.length < 2) return null;
|
|
3258
|
+
try {
|
|
3259
|
+
return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
|
3260
|
+
} catch {
|
|
3261
|
+
return null;
|
|
3262
|
+
}
|
|
3263
|
+
}
|
|
3264
|
+
var codexLogin = {
|
|
3265
|
+
spawnArgs: () => ["login", "--device-auth"],
|
|
3266
|
+
logoutArgs: () => ["logout"],
|
|
3267
|
+
statusArgs: () => ["login", "status"],
|
|
3268
|
+
env: (home) => ({ CODEX_HOME: home }),
|
|
3269
|
+
isSignedIn: (home) => fs4.existsSync(path4.join(home, AUTH_FILE)),
|
|
3270
|
+
parseGuidance(text2) {
|
|
3271
|
+
const clean = stripAnsi(text2);
|
|
3272
|
+
const url = GUIDANCE_URL_RE.exec(clean)?.[0];
|
|
3273
|
+
const code = GUIDANCE_CODE_RE.exec(clean)?.[0];
|
|
3274
|
+
if (!url && !code) return null;
|
|
3275
|
+
return { ...url ? { url } : {}, ...code ? { code } : {} };
|
|
3276
|
+
},
|
|
3277
|
+
/**
|
|
3278
|
+
* The account behind a completed sign-in, off the id token's claims — decoded, NOT verified,
|
|
3279
|
+
* because nothing here trusts it for authorization: it names whose plan the credential row
|
|
3280
|
+
* shows. The refresh and access tokens are never read.
|
|
3281
|
+
*/
|
|
3282
|
+
readAccount(home) {
|
|
3283
|
+
let parsed;
|
|
3284
|
+
try {
|
|
3285
|
+
parsed = JSON.parse(fs4.readFileSync(path4.join(home, AUTH_FILE), "utf8"));
|
|
3286
|
+
} catch {
|
|
3287
|
+
return null;
|
|
3288
|
+
}
|
|
3289
|
+
const idToken = parsed.tokens?.id_token;
|
|
3290
|
+
const claims = typeof idToken === "string" ? decodeJwtPayload(idToken) : null;
|
|
3291
|
+
const auth = claims?.["https://api.openai.com/auth"] ?? {};
|
|
3292
|
+
const accountId = typeof auth.chatgpt_account_id === "string" && auth.chatgpt_account_id || typeof parsed.tokens?.account_id === "string" && parsed.tokens.account_id || "";
|
|
3293
|
+
const activeUntilRaw = auth.chatgpt_subscription_active_until;
|
|
3294
|
+
const activeUntil = typeof activeUntilRaw === "string" && !Number.isNaN(Date.parse(activeUntilRaw)) ? Date.parse(activeUntilRaw) : typeof activeUntilRaw === "number" ? activeUntilRaw < 1e12 ? activeUntilRaw * 1e3 : activeUntilRaw : void 0;
|
|
3295
|
+
return {
|
|
3296
|
+
...typeof claims?.email === "string" ? { email: claims.email } : {},
|
|
3297
|
+
...typeof claims?.name === "string" ? { name: claims.name } : {},
|
|
3298
|
+
...typeof auth.chatgpt_plan_type === "string" ? { plan: auth.chatgpt_plan_type } : {},
|
|
3299
|
+
...activeUntil !== void 0 ? { activeUntil } : {},
|
|
3300
|
+
...accountId ? { accountIdTail: accountId.slice(-4) } : {}
|
|
3301
|
+
};
|
|
3302
|
+
}
|
|
3303
|
+
};
|
|
3304
|
+
function emptyUsage2() {
|
|
3305
|
+
return {
|
|
3306
|
+
engine: CODEX_DRIVER_ID,
|
|
3307
|
+
inputTokens: 0,
|
|
3308
|
+
cachedInputTokens: 0,
|
|
3309
|
+
outputTokens: 0,
|
|
3310
|
+
costUsd: 0,
|
|
3311
|
+
costReported: false,
|
|
3312
|
+
durationS: 0
|
|
3313
|
+
};
|
|
3314
|
+
}
|
|
3315
|
+
function refused(resultText3, extra = {}) {
|
|
3316
|
+
return { ok: false, transcript: "", usage: emptyUsage2(), resultText: resultText3, ...extra };
|
|
3317
|
+
}
|
|
3318
|
+
async function runCodexSession(input) {
|
|
3319
|
+
const bin = codexBinary();
|
|
3320
|
+
if (input.signal?.aborted) {
|
|
3321
|
+
return refused("Session cancelled before it started (runner shutting down).");
|
|
3322
|
+
}
|
|
3323
|
+
const home = engineSecret(input.secrets, CODEX_SECRET_KEY);
|
|
3324
|
+
if (!home) {
|
|
3325
|
+
return refused("This machine holds no ChatGPT sign-in for this credential.");
|
|
3326
|
+
}
|
|
3327
|
+
if (!codexLogin.isSignedIn(home)) {
|
|
3328
|
+
return refused(
|
|
3329
|
+
"This machine's ChatGPT sign-in is gone \u2014 delete the credential in Settings and add it again.",
|
|
3330
|
+
{ credentialLost: "The sign-in directory on this machine no longer holds a login." }
|
|
3331
|
+
);
|
|
3332
|
+
}
|
|
3333
|
+
const workdir = fs4.mkdtempSync(path4.join(os3.tmpdir(), `crew-job-${input.job.id}-`));
|
|
3334
|
+
try {
|
|
3335
|
+
return await runSession2(input, bin, home, workdir);
|
|
3336
|
+
} finally {
|
|
3337
|
+
fs4.rmSync(workdir, { recursive: true, force: true });
|
|
3338
|
+
}
|
|
3339
|
+
}
|
|
3340
|
+
async function runSession2(input, bin, home, workdir) {
|
|
3341
|
+
const args = codexArgs({ ...input, extraServers: input.extraMcpServers, workdir });
|
|
3342
|
+
const env = {
|
|
3343
|
+
...process.env,
|
|
3344
|
+
...codexLogin.env(home),
|
|
3345
|
+
[WORKSPACE_TOKEN_ENV]: input.idToken,
|
|
3346
|
+
...githubSessionEnv(input.githubToken)
|
|
3347
|
+
};
|
|
3348
|
+
const mcpNames = {};
|
|
3349
|
+
for (const s of input.extraMcpServers ?? []) mcpNames[s.key] = s.name || s.key;
|
|
3350
|
+
const startedAt = Date.now();
|
|
3351
|
+
const lines = [];
|
|
3352
|
+
const stderrLines = [];
|
|
3353
|
+
const seen = {
|
|
3354
|
+
completed: null,
|
|
3355
|
+
failed: null,
|
|
3356
|
+
lastMessage: ""
|
|
3357
|
+
};
|
|
3358
|
+
let mcpProblem = null;
|
|
3359
|
+
const workspaceRequired = effectiveAgentTools(input.agent).workspaceMcp;
|
|
3360
|
+
const expected = [
|
|
3361
|
+
...workspaceRequired ? [{ key: WORKSPACE_MCP_KEY, label: "the Workspace MCP" }] : [],
|
|
3362
|
+
...(input.extraMcpServers ?? []).map((s) => ({ key: s.key, label: s.name || s.key }))
|
|
3363
|
+
];
|
|
3364
|
+
const mcpFailed = [];
|
|
3365
|
+
const mcpWatch = createWorkspaceMcpWatch();
|
|
3366
|
+
const exitCode = await new Promise((resolve) => {
|
|
3367
|
+
const child = spawn4(bin, args, { cwd: workdir, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
3368
|
+
const killTimer = setTimeout(() => {
|
|
3369
|
+
input.log(`Session timeout after ${Math.round(input.timeoutMs / 6e4)} min \u2014 killing.`);
|
|
3370
|
+
child.kill("SIGTERM");
|
|
3371
|
+
setTimeout(() => child.kill("SIGKILL"), 1e4).unref();
|
|
3372
|
+
}, input.timeoutMs);
|
|
3373
|
+
const onAbort = () => {
|
|
3374
|
+
input.log("Daemon shutting down \u2014 stopping the session.");
|
|
3375
|
+
child.kill("SIGTERM");
|
|
3376
|
+
setTimeout(() => child.kill("SIGKILL"), 3e3).unref();
|
|
3377
|
+
};
|
|
3378
|
+
input.signal?.addEventListener("abort", onAbort, { once: true });
|
|
3379
|
+
const detachAbort = () => input.signal?.removeEventListener("abort", onAbort);
|
|
3380
|
+
const stopBlindSession = (problem) => {
|
|
3381
|
+
if (!problem) return;
|
|
3382
|
+
mcpProblem = problem;
|
|
3383
|
+
input.log(problem);
|
|
3384
|
+
child.kill("SIGTERM");
|
|
3385
|
+
setTimeout(() => child.kill("SIGKILL"), 3e3).unref();
|
|
3386
|
+
};
|
|
3387
|
+
let buffer = "";
|
|
3388
|
+
child.stdout.on("data", (chunk) => {
|
|
3389
|
+
buffer += chunk.toString("utf8");
|
|
3390
|
+
for (; ; ) {
|
|
3391
|
+
const nl = buffer.indexOf("\n");
|
|
3392
|
+
if (nl < 0) break;
|
|
3393
|
+
const line = buffer.slice(0, nl).trim();
|
|
3394
|
+
buffer = buffer.slice(nl + 1);
|
|
3395
|
+
if (!line) continue;
|
|
3396
|
+
lines.push(line);
|
|
3397
|
+
try {
|
|
3398
|
+
const event = JSON.parse(line);
|
|
3399
|
+
if (event.type === "turn.completed") seen.completed = event;
|
|
3400
|
+
if (event.type === "turn.failed") seen.failed = event;
|
|
3401
|
+
const item = event.item;
|
|
3402
|
+
if (event.type === "item.completed" && item?.type === "agent_message" && typeof item.text === "string") {
|
|
3403
|
+
seen.lastMessage = item.text;
|
|
3404
|
+
}
|
|
3405
|
+
if (workspaceRequired && !mcpProblem) {
|
|
3406
|
+
stopBlindSession(mcpWatch.observe(mcpCallFromCodexEvent(event)));
|
|
3407
|
+
}
|
|
3408
|
+
if (input.onStep) {
|
|
3409
|
+
for (const step2 of stepsFromCodexEvent(event, { mcpNames })) input.onStep(step2);
|
|
3410
|
+
}
|
|
3411
|
+
} catch {
|
|
3412
|
+
}
|
|
3413
|
+
}
|
|
3414
|
+
});
|
|
3415
|
+
child.stderr.on("data", (chunk) => {
|
|
3416
|
+
const text2 = chunk.toString("utf8").trim();
|
|
3417
|
+
if (text2) {
|
|
3418
|
+
lines.push(JSON.stringify({ type: "stderr", text: text2 }));
|
|
3419
|
+
stderrLines.push(text2);
|
|
3420
|
+
input.log(`codex stderr: ${text2.slice(0, 200)}`);
|
|
3421
|
+
}
|
|
3422
|
+
});
|
|
3423
|
+
child.on("error", (e) => {
|
|
3424
|
+
lines.push(JSON.stringify({ type: "stderr", text: `spawn error: ${e.message}` }));
|
|
3425
|
+
stderrLines.push(`spawn error: ${e.message}`);
|
|
3426
|
+
clearTimeout(killTimer);
|
|
3427
|
+
detachAbort();
|
|
3428
|
+
resolve(127);
|
|
3429
|
+
});
|
|
3430
|
+
child.on("close", (code) => {
|
|
3431
|
+
clearTimeout(killTimer);
|
|
3432
|
+
detachAbort();
|
|
3433
|
+
resolve(code ?? 1);
|
|
3434
|
+
});
|
|
3435
|
+
});
|
|
3436
|
+
const durationS = Math.round((Date.now() - startedAt) / 1e3);
|
|
3437
|
+
const stderr = stderrLines.join("\n");
|
|
3438
|
+
const usage = normalizeCodexUsage(seen.completed?.usage ?? null, input.agent.model, durationS);
|
|
3439
|
+
for (const failure of requiredMcpFailures(stderr, expected)) {
|
|
3440
|
+
if (failure.key === WORKSPACE_MCP_KEY) {
|
|
3441
|
+
if (!mcpProblem) mcpProblem = codexWorkspaceRefusal(failure);
|
|
3442
|
+
} else if (!mcpFailed.includes(failure.key)) {
|
|
3443
|
+
input.log(`MCP connection "${failure.key}" did not load: ${failure.detail}.`);
|
|
3444
|
+
mcpFailed.push(failure.key);
|
|
3445
|
+
}
|
|
3446
|
+
}
|
|
3447
|
+
const failedError = seen.failed?.error;
|
|
3448
|
+
const failedMessage = typeof failedError?.message === "string" ? failedError.message : "";
|
|
3449
|
+
const ok2 = exitCode === 0 && seen.completed !== null && seen.failed === null && !mcpProblem;
|
|
3450
|
+
const transcript = lines.join("\n") + "\n";
|
|
3451
|
+
if (mcpProblem) {
|
|
3452
|
+
return { ok: false, transcript, usage, resultText: mcpProblem, ...mcpFailed.length ? { mcpFailed } : {} };
|
|
3453
|
+
}
|
|
3454
|
+
const failureText = [failedMessage, stderr.slice(-2e3)].filter(Boolean).join("\n");
|
|
3455
|
+
const windows = engineUsageWindows(CODEX_DRIVER_ID);
|
|
3456
|
+
const limit4 = !ok2 && windows ? detectCodexLimit(failureText, Date.now(), windows.fallbackMs) : void 0;
|
|
3457
|
+
const credentialLost = !ok2 && !limit4 ? detectLoginLost(stderr) : void 0;
|
|
3458
|
+
const resultText3 = ok2 ? seen.lastMessage : failedMessage || stderr.trim().split("\n").slice(-3).join("\n") || `Codex exited ${exitCode}`;
|
|
3459
|
+
return {
|
|
3460
|
+
ok: ok2,
|
|
3461
|
+
transcript,
|
|
3462
|
+
usage,
|
|
3463
|
+
resultText: resultText3.slice(0, 2e4),
|
|
3464
|
+
...limit4 ? { limit: limit4 } : {},
|
|
3465
|
+
...credentialLost ? { credentialLost } : {},
|
|
3466
|
+
...mcpFailed.length ? { mcpFailed } : {}
|
|
3467
|
+
};
|
|
3468
|
+
}
|
|
3469
|
+
async function codexHealthCheck() {
|
|
3470
|
+
const bin = codexBinary();
|
|
3471
|
+
return new Promise((resolve) => {
|
|
3472
|
+
let settled = false;
|
|
3473
|
+
const done = (health) => {
|
|
3474
|
+
if (settled) return;
|
|
3475
|
+
settled = true;
|
|
3476
|
+
resolve({ ...health, binary: bin });
|
|
3477
|
+
};
|
|
3478
|
+
let stdout = "";
|
|
3479
|
+
const child = spawn4(bin, ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
|
|
3480
|
+
const timer = setTimeout(() => {
|
|
3481
|
+
child.kill("SIGKILL");
|
|
3482
|
+
done({ ok: false, detail: `\`${bin} --version\` timed out`, fix: "Check the Codex CLI install." });
|
|
3483
|
+
}, 1e4);
|
|
3484
|
+
child.stdout.on("data", (chunk) => {
|
|
3485
|
+
stdout += chunk.toString("utf8");
|
|
3486
|
+
});
|
|
3487
|
+
child.on("error", () => {
|
|
3488
|
+
clearTimeout(timer);
|
|
3489
|
+
done({
|
|
3490
|
+
ok: false,
|
|
3491
|
+
detail: `\`${bin}\` not found on PATH`,
|
|
3492
|
+
fix: `Install the Codex CLI (https://developers.openai.com/codex), or set ${engineBinaryEnvVar(CODEX_DRIVER_ID)} to its path.`
|
|
3493
|
+
});
|
|
3494
|
+
});
|
|
3495
|
+
child.on("close", (code) => {
|
|
3496
|
+
clearTimeout(timer);
|
|
3497
|
+
const version = stdout.trim().split("\n")[0] || "(no version reported)";
|
|
3498
|
+
done(
|
|
3499
|
+
code === 0 ? { ok: true, detail: `${bin} \u2014 ${version}` } : { ok: false, detail: `\`${bin} --version\` exited ${code}`, fix: "Reinstall or repair the Codex CLI." }
|
|
3500
|
+
);
|
|
3501
|
+
});
|
|
3502
|
+
});
|
|
3503
|
+
}
|
|
3504
|
+
var codexDriver = {
|
|
3505
|
+
engineId: CODEX_DRIVER_ID,
|
|
3506
|
+
run: runCodexSession,
|
|
3507
|
+
healthCheck: codexHealthCheck,
|
|
3508
|
+
binary: codexBinary,
|
|
3509
|
+
// The value is a DIRECTORY, not a secret: nothing to redact, and redacting a path would scrub
|
|
3510
|
+
// every transcript line that mentioned the workdir's parent.
|
|
3511
|
+
secretValues: (_secrets) => [],
|
|
3512
|
+
secretPatterns: Object.freeze([]),
|
|
3513
|
+
login: codexLogin
|
|
2851
3514
|
};
|
|
2852
3515
|
|
|
2853
3516
|
// src/engines/index.ts
|
|
2854
3517
|
var DRIVERS = {
|
|
2855
|
-
claude: claudeDriver
|
|
3518
|
+
claude: claudeDriver,
|
|
3519
|
+
codex: codexDriver
|
|
2856
3520
|
};
|
|
2857
3521
|
function getDriver(engineId) {
|
|
2858
3522
|
return engineId && Object.hasOwn(DRIVERS, engineId) ? DRIVERS[engineId] : DRIVERS[DEFAULT_ENGINE_ID];
|
|
@@ -2976,7 +3640,7 @@ function mcpSecretsToRedact(servers) {
|
|
|
2976
3640
|
}
|
|
2977
3641
|
|
|
2978
3642
|
// src/jobs/localProbe.ts
|
|
2979
|
-
import { spawn as
|
|
3643
|
+
import { spawn as spawn5 } from "node:child_process";
|
|
2980
3644
|
var PROBE_TIMEOUT_MS = 15e3;
|
|
2981
3645
|
var PROTOCOL_VERSION = "2024-11-05";
|
|
2982
3646
|
async function probeAndReport(input) {
|
|
@@ -3018,7 +3682,7 @@ async function probe(server) {
|
|
|
3018
3682
|
}
|
|
3019
3683
|
}
|
|
3020
3684
|
async function probeStdio(server) {
|
|
3021
|
-
const child =
|
|
3685
|
+
const child = spawn5(server.command ?? "", server.args ?? [], {
|
|
3022
3686
|
// The parent's environment PLUS the Ship's, not the Ship's alone: a program started with no
|
|
3023
3687
|
// PATH cannot find node, and `npx` cannot find anything at all. The Ship's values win, which is
|
|
3024
3688
|
// what lets a connection override a variable the operator happens to have set.
|
|
@@ -3209,6 +3873,276 @@ function serversNeedingProbe(servers) {
|
|
|
3209
3873
|
);
|
|
3210
3874
|
}
|
|
3211
3875
|
|
|
3876
|
+
// src/jobs/credentialGate.ts
|
|
3877
|
+
function credentialGate(agent, credentials, runnerId, busyCredentialIds, defaultEngine) {
|
|
3878
|
+
if (!agent) return "ok";
|
|
3879
|
+
const resolved = resolveAgentCredential(agent, [...credentials], defaultEngine);
|
|
3880
|
+
if (!resolved || !isMachineHeldCredential(resolved)) return "ok";
|
|
3881
|
+
if (resolved.runnerId !== runnerId) return "other-machine";
|
|
3882
|
+
if (!isCredentialReady(resolved)) return "not-ready";
|
|
3883
|
+
if (busyCredentialIds.has(resolved.id)) return "busy";
|
|
3884
|
+
return "ok";
|
|
3885
|
+
}
|
|
3886
|
+
|
|
3887
|
+
// src/jobs/engineHome.ts
|
|
3888
|
+
import path5 from "node:path";
|
|
3889
|
+
var SEGMENT = /^[A-Za-z0-9_-]+$/;
|
|
3890
|
+
function engineCredentialHome(root, engineId, shipId, credentialId) {
|
|
3891
|
+
for (const segment of [engineId, shipId, credentialId]) {
|
|
3892
|
+
if (!SEGMENT.test(segment)) throw new Error(`not a valid path segment: ${JSON.stringify(segment)}`);
|
|
3893
|
+
}
|
|
3894
|
+
return path5.join(root, "engines", engineId, shipId, credentialId);
|
|
3895
|
+
}
|
|
3896
|
+
|
|
3897
|
+
// src/jobs/engineLogin.ts
|
|
3898
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
3899
|
+
import fs5 from "node:fs";
|
|
3900
|
+
import path6 from "node:path";
|
|
3901
|
+
var DEVICE_CODE_TTL_MS = 15 * 60 * 1e3;
|
|
3902
|
+
function machineLoginTimeoutMs() {
|
|
3903
|
+
const override = Number(process.env.CREW_MACHINE_LOGIN_TIMEOUT_MS);
|
|
3904
|
+
return Number.isFinite(override) && override > 0 ? override : DEVICE_CODE_TTL_MS + 60 * 1e3;
|
|
3905
|
+
}
|
|
3906
|
+
var GUIDANCE_SETTLE_MS = 2e3;
|
|
3907
|
+
function credentialsNeedingLogin(credentials, runnerId, now) {
|
|
3908
|
+
const start = [];
|
|
3909
|
+
const expire = [];
|
|
3910
|
+
for (const c of credentials) {
|
|
3911
|
+
if (c.runnerId !== runnerId || !isMachineLoginEngine(c.engine)) continue;
|
|
3912
|
+
const verdict = machineLoginPending(c, now);
|
|
3913
|
+
if (verdict === "start") start.push(c);
|
|
3914
|
+
else if (verdict === "expire") expire.push(c);
|
|
3915
|
+
}
|
|
3916
|
+
return { start, expire };
|
|
3917
|
+
}
|
|
3918
|
+
function credentialsToForget(previouslyHeld, current) {
|
|
3919
|
+
const live = new Set(current.map((c) => c.id));
|
|
3920
|
+
return [...previouslyHeld].filter((id) => !live.has(id));
|
|
3921
|
+
}
|
|
3922
|
+
function orphanHomes(root, shipId, liveIds) {
|
|
3923
|
+
const enginesDir = path6.join(root, "engines");
|
|
3924
|
+
let engines = [];
|
|
3925
|
+
try {
|
|
3926
|
+
engines = fs5.readdirSync(enginesDir);
|
|
3927
|
+
} catch {
|
|
3928
|
+
return [];
|
|
3929
|
+
}
|
|
3930
|
+
const out = [];
|
|
3931
|
+
for (const engineId of engines) {
|
|
3932
|
+
const shipDir = path6.join(enginesDir, engineId, shipId);
|
|
3933
|
+
let creds = [];
|
|
3934
|
+
try {
|
|
3935
|
+
creds = fs5.readdirSync(shipDir);
|
|
3936
|
+
} catch {
|
|
3937
|
+
continue;
|
|
3938
|
+
}
|
|
3939
|
+
for (const credId of creds) if (!liveIds.has(credId)) out.push(path6.join(shipDir, credId));
|
|
3940
|
+
}
|
|
3941
|
+
return out;
|
|
3942
|
+
}
|
|
3943
|
+
async function runMachineLogin(input) {
|
|
3944
|
+
const spawn8 = input.spawn ?? nodeSpawn;
|
|
3945
|
+
if (input.signal?.aborted) return { phase: "failed", error: "The runner was stopping." };
|
|
3946
|
+
try {
|
|
3947
|
+
fs5.mkdirSync(input.home, { recursive: true, mode: 448 });
|
|
3948
|
+
} catch (e) {
|
|
3949
|
+
return { phase: "failed", error: `Could not create the sign-in directory: ${message2(e)}` };
|
|
3950
|
+
}
|
|
3951
|
+
return new Promise((resolve) => {
|
|
3952
|
+
let child;
|
|
3953
|
+
try {
|
|
3954
|
+
child = spawn8(input.bin, input.driver.spawnArgs(), {
|
|
3955
|
+
env: { ...process.env, ...input.driver.env(input.home) },
|
|
3956
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
3957
|
+
});
|
|
3958
|
+
} catch (e) {
|
|
3959
|
+
resolve({ phase: "failed", error: notInstalled(input.engineId, input.bin, message2(e)) });
|
|
3960
|
+
return;
|
|
3961
|
+
}
|
|
3962
|
+
let settled = false;
|
|
3963
|
+
let text2 = "";
|
|
3964
|
+
let stderr = "";
|
|
3965
|
+
let guided = false;
|
|
3966
|
+
let settleTimer = null;
|
|
3967
|
+
let expired = false;
|
|
3968
|
+
const finish = (outcome) => {
|
|
3969
|
+
if (settled) return;
|
|
3970
|
+
settled = true;
|
|
3971
|
+
clearTimeout(deadline);
|
|
3972
|
+
if (settleTimer) clearTimeout(settleTimer);
|
|
3973
|
+
input.signal?.removeEventListener("abort", onAbort);
|
|
3974
|
+
resolve(outcome);
|
|
3975
|
+
};
|
|
3976
|
+
const guide = () => {
|
|
3977
|
+
if (guided || !text2.trim()) return;
|
|
3978
|
+
guided = true;
|
|
3979
|
+
if (settleTimer) clearTimeout(settleTimer);
|
|
3980
|
+
const parsed = input.driver.parseGuidance(text2) ?? {};
|
|
3981
|
+
try {
|
|
3982
|
+
input.onGuidance({ text: text2, ...parsed });
|
|
3983
|
+
} catch (e) {
|
|
3984
|
+
input.log(`sign-in guidance callback failed: ${message2(e)}`);
|
|
3985
|
+
}
|
|
3986
|
+
};
|
|
3987
|
+
const onOutput = (chunk, isErr) => {
|
|
3988
|
+
const clean = stripAnsi(chunk.toString("utf8"));
|
|
3989
|
+
if (isErr) stderr += clean;
|
|
3990
|
+
text2 += clean;
|
|
3991
|
+
if (guided) return;
|
|
3992
|
+
if (input.driver.parseGuidance(text2)) guide();
|
|
3993
|
+
else if (!settleTimer) settleTimer = setTimeout(guide, GUIDANCE_SETTLE_MS);
|
|
3994
|
+
};
|
|
3995
|
+
child.stdout?.on("data", (c) => onOutput(c, false));
|
|
3996
|
+
child.stderr?.on("data", (c) => onOutput(c, true));
|
|
3997
|
+
const deadline = setTimeout(() => {
|
|
3998
|
+
expired = true;
|
|
3999
|
+
input.log("Sign-in did not complete before the code expired \u2014 stopping it.");
|
|
4000
|
+
child.kill("SIGTERM");
|
|
4001
|
+
setTimeout(() => child.kill("SIGKILL"), 5e3).unref();
|
|
4002
|
+
}, input.timeoutMs);
|
|
4003
|
+
const onAbort = () => {
|
|
4004
|
+
child.kill("SIGTERM");
|
|
4005
|
+
setTimeout(() => child.kill("SIGKILL"), 3e3).unref();
|
|
4006
|
+
};
|
|
4007
|
+
input.signal?.addEventListener("abort", onAbort, { once: true });
|
|
4008
|
+
child.on("error", (e) => {
|
|
4009
|
+
finish({
|
|
4010
|
+
phase: "failed",
|
|
4011
|
+
error: e.code === "ENOENT" ? notInstalled(input.engineId, input.bin, e.message) : `Could not start the sign-in: ${e.message}`
|
|
4012
|
+
});
|
|
4013
|
+
});
|
|
4014
|
+
child.on("close", (code) => {
|
|
4015
|
+
if (expired) {
|
|
4016
|
+
finish({ phase: "expired" });
|
|
4017
|
+
return;
|
|
4018
|
+
}
|
|
4019
|
+
if (input.signal?.aborted) {
|
|
4020
|
+
finish({ phase: "failed", error: "The runner stopped before the sign-in completed." });
|
|
4021
|
+
return;
|
|
4022
|
+
}
|
|
4023
|
+
if (code === 0 && input.driver.isSignedIn(input.home)) {
|
|
4024
|
+
finish({ phase: "ready", account: input.driver.readAccount(input.home) });
|
|
4025
|
+
return;
|
|
4026
|
+
}
|
|
4027
|
+
const tail2 = stderr.trim().split("\n").slice(-3).join(" ").trim() || text2.trim().split("\n").slice(-2).join(" ").trim();
|
|
4028
|
+
finish({
|
|
4029
|
+
phase: "failed",
|
|
4030
|
+
error: code === 0 ? "The sign-in finished but left no login behind." : `The sign-in exited ${code}${tail2 ? `: ${tail2}` : "."}`
|
|
4031
|
+
});
|
|
4032
|
+
});
|
|
4033
|
+
});
|
|
4034
|
+
}
|
|
4035
|
+
function notInstalled(engineId, bin, detail) {
|
|
4036
|
+
return `\`${bin}\` is not installed on this machine (${detail}). Install the engine's CLI, or set ${engineBinaryEnvVar(engineId)} to its path.`;
|
|
4037
|
+
}
|
|
4038
|
+
async function logoutAndRemove(input) {
|
|
4039
|
+
const spawn8 = input.spawn ?? nodeSpawn;
|
|
4040
|
+
if (fs5.existsSync(input.home)) {
|
|
4041
|
+
await new Promise((resolve) => {
|
|
4042
|
+
let child;
|
|
4043
|
+
try {
|
|
4044
|
+
child = spawn8(input.bin, input.driver.logoutArgs(), {
|
|
4045
|
+
env: { ...process.env, ...input.driver.env(input.home) },
|
|
4046
|
+
stdio: "ignore"
|
|
4047
|
+
});
|
|
4048
|
+
} catch {
|
|
4049
|
+
resolve();
|
|
4050
|
+
return;
|
|
4051
|
+
}
|
|
4052
|
+
const timer = setTimeout(() => {
|
|
4053
|
+
child.kill("SIGKILL");
|
|
4054
|
+
resolve();
|
|
4055
|
+
}, 15e3);
|
|
4056
|
+
child.on("error", () => {
|
|
4057
|
+
clearTimeout(timer);
|
|
4058
|
+
resolve();
|
|
4059
|
+
});
|
|
4060
|
+
child.on("close", () => {
|
|
4061
|
+
clearTimeout(timer);
|
|
4062
|
+
resolve();
|
|
4063
|
+
});
|
|
4064
|
+
});
|
|
4065
|
+
}
|
|
4066
|
+
try {
|
|
4067
|
+
fs5.rmSync(input.home, { recursive: true, force: true });
|
|
4068
|
+
} catch (e) {
|
|
4069
|
+
input.log(`Could not remove the sign-in directory ${input.home}: ${message2(e)}`);
|
|
4070
|
+
}
|
|
4071
|
+
}
|
|
4072
|
+
async function reportLogin(input) {
|
|
4073
|
+
try {
|
|
4074
|
+
await callFunction(functionsBaseUrl(input.config), input.idToken, "crewShips", "reportCredentialLogin", {
|
|
4075
|
+
shipId: input.shipId,
|
|
4076
|
+
credentialId: input.credentialId,
|
|
4077
|
+
...input.report
|
|
4078
|
+
});
|
|
4079
|
+
} catch (e) {
|
|
4080
|
+
input.log(`Could not report the sign-in (${input.report.phase}) for ${input.credentialId}: ${message2(e)}`);
|
|
4081
|
+
}
|
|
4082
|
+
}
|
|
4083
|
+
async function machineLoginAndReport(input) {
|
|
4084
|
+
const { credential, shipId } = input;
|
|
4085
|
+
const driver = getDriver(credential.engine);
|
|
4086
|
+
const say2 = (report4) => input.idToken().then((idToken) => reportLogin({ config: input.config, idToken, shipId, credentialId: credential.id, report: report4, log: input.log })).catch((e) => input.log(`Could not mint a token to report the sign-in: ${message2(e)}`));
|
|
4087
|
+
if (!driver.login) {
|
|
4088
|
+
await say2({ phase: "failed", error: `Engine "${credential.engine}" does not sign in from a machine.` });
|
|
4089
|
+
return;
|
|
4090
|
+
}
|
|
4091
|
+
let home;
|
|
4092
|
+
try {
|
|
4093
|
+
home = engineCredentialHome(input.root, credential.engine, shipId, credential.id);
|
|
4094
|
+
} catch (e) {
|
|
4095
|
+
await say2({ phase: "failed", error: message2(e) });
|
|
4096
|
+
return;
|
|
4097
|
+
}
|
|
4098
|
+
input.log(`Signing this machine in for "${credential.label}" (${credential.engine}) \u2014 a captain asked.`);
|
|
4099
|
+
const outcome = await runMachineLogin({
|
|
4100
|
+
driver: driver.login,
|
|
4101
|
+
engineId: credential.engine,
|
|
4102
|
+
bin: driver.binary(),
|
|
4103
|
+
home,
|
|
4104
|
+
timeoutMs: machineLoginTimeoutMs(),
|
|
4105
|
+
signal: input.signal,
|
|
4106
|
+
spawn: input.spawn,
|
|
4107
|
+
log: input.log,
|
|
4108
|
+
onGuidance: (guidance) => {
|
|
4109
|
+
void say2({
|
|
4110
|
+
phase: "code",
|
|
4111
|
+
text: guidance.text,
|
|
4112
|
+
...guidance.code ? { code: guidance.code } : {},
|
|
4113
|
+
...guidance.url ? { url: guidance.url } : {},
|
|
4114
|
+
expiresAt: Date.now() + DEVICE_CODE_TTL_MS
|
|
4115
|
+
});
|
|
4116
|
+
}
|
|
4117
|
+
});
|
|
4118
|
+
if (outcome.phase === "ready") {
|
|
4119
|
+
const a = outcome.account;
|
|
4120
|
+
input.log(`Signed in for "${credential.label}"${a?.email ? ` as ${a.email}` : ""}.`);
|
|
4121
|
+
await say2({
|
|
4122
|
+
phase: "ready",
|
|
4123
|
+
...a ? { account: { ...a.email ? { email: a.email } : {}, ...a.plan ? { plan: a.plan } : {}, ...a.activeUntil ? { activeUntil: a.activeUntil } : {} } } : {},
|
|
4124
|
+
...a?.accountIdTail ? { accountIdTail: a.accountIdTail } : {}
|
|
4125
|
+
});
|
|
4126
|
+
return;
|
|
4127
|
+
}
|
|
4128
|
+
if (outcome.phase === "expired") {
|
|
4129
|
+
input.log(`The sign-in for "${credential.label}" expired before it was approved.`);
|
|
4130
|
+
try {
|
|
4131
|
+
fs5.rmSync(home, { recursive: true, force: true });
|
|
4132
|
+
} catch {
|
|
4133
|
+
}
|
|
4134
|
+
await say2({ phase: "expired" });
|
|
4135
|
+
return;
|
|
4136
|
+
}
|
|
4137
|
+
input.log(`The sign-in for "${credential.label}" failed: ${outcome.error}`);
|
|
4138
|
+
try {
|
|
4139
|
+
fs5.rmSync(home, { recursive: true, force: true });
|
|
4140
|
+
} catch {
|
|
4141
|
+
}
|
|
4142
|
+
await say2({ phase: "failed", error: outcome.error });
|
|
4143
|
+
}
|
|
4144
|
+
var message2 = (e) => e instanceof Error ? e.message : String(e);
|
|
4145
|
+
|
|
3212
4146
|
// src/jobs/engineLimits.ts
|
|
3213
4147
|
import {
|
|
3214
4148
|
collection as collection6,
|
|
@@ -3352,30 +4286,34 @@ import {
|
|
|
3352
4286
|
where as where3
|
|
3353
4287
|
} from "firebase/firestore";
|
|
3354
4288
|
import { ref as storageRef, uploadBytes } from "firebase/storage";
|
|
3355
|
-
function redactTranscript(transcript, knownSecrets) {
|
|
4289
|
+
function redactTranscript(transcript, knownSecrets, patterns = []) {
|
|
3356
4290
|
let out = transcript;
|
|
3357
4291
|
for (const secret of knownSecrets) {
|
|
3358
4292
|
if (secret && secret.length >= 8) out = out.split(secret).join("[REDACTED]");
|
|
3359
4293
|
}
|
|
3360
|
-
|
|
4294
|
+
for (const pattern of patterns) {
|
|
4295
|
+
const flags = pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g";
|
|
4296
|
+
out = out.replace(new RegExp(pattern.source, flags), "[REDACTED]");
|
|
4297
|
+
}
|
|
3361
4298
|
out = out.replace(/gh[pousr]_[A-Za-z0-9]{20,}/g, "[REDACTED]");
|
|
3362
4299
|
out = out.replace(/github_pat_[A-Za-z0-9_]{20,}/g, "[REDACTED]");
|
|
3363
4300
|
out = out.replace(/ya29\.[A-Za-z0-9_-]{20,}/g, "[REDACTED]");
|
|
3364
4301
|
out = out.replace(/crewmcp_[A-Za-z0-9]+_[a-f0-9]{64}/g, "[REDACTED]");
|
|
4302
|
+
out = out.replace(/eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/g, "[REDACTED]");
|
|
3365
4303
|
return out;
|
|
3366
4304
|
}
|
|
3367
4305
|
async function uploadTranscript(storage, shipId, jobId, redacted) {
|
|
3368
|
-
const
|
|
3369
|
-
await uploadBytes(storageRef(storage,
|
|
4306
|
+
const path13 = `crew/${shipId}/transcripts/${jobId}.jsonl`;
|
|
4307
|
+
await uploadBytes(storageRef(storage, path13), new TextEncoder().encode(redacted), {
|
|
3370
4308
|
contentType: "application/x-ndjson"
|
|
3371
4309
|
});
|
|
3372
|
-
return
|
|
4310
|
+
return path13;
|
|
3373
4311
|
}
|
|
3374
4312
|
function utcDay(millis) {
|
|
3375
4313
|
return new Date(millis).toISOString().slice(0, 10);
|
|
3376
4314
|
}
|
|
3377
|
-
function backstopReportContent(
|
|
3378
|
-
const text2 =
|
|
4315
|
+
function backstopReportContent(resultText3) {
|
|
4316
|
+
const text2 = resultText3.trim();
|
|
3379
4317
|
if (!text2) {
|
|
3380
4318
|
const none = "This run ended without writing a report, and left no final text to fall back on.";
|
|
3381
4319
|
return { report: none, summary: none };
|
|
@@ -3589,8 +4527,8 @@ function stripSessionNarration(text2) {
|
|
|
3589
4527
|
if (dropped === 0 || dropped === paragraphs.length) return text2;
|
|
3590
4528
|
return text2.slice(paragraphs[dropped].start).trim();
|
|
3591
4529
|
}
|
|
3592
|
-
function backstopReplyContent(
|
|
3593
|
-
const text2 = stripSessionNarration(
|
|
4530
|
+
function backstopReplyContent(resultText3) {
|
|
4531
|
+
const text2 = stripSessionNarration(resultText3.trim());
|
|
3594
4532
|
if (!text2) {
|
|
3595
4533
|
return "I finished that run without writing a reply. Write again to start a fresh one.";
|
|
3596
4534
|
}
|
|
@@ -3598,7 +4536,7 @@ function backstopReplyContent(resultText2) {
|
|
|
3598
4536
|
const marker = "\n\n\u2026(truncated)";
|
|
3599
4537
|
return `${text2.slice(0, MAX_CHAT_MESSAGE_CHARS - marker.length)}${marker}`;
|
|
3600
4538
|
}
|
|
3601
|
-
async function ensureChatReply(db, shipId, job,
|
|
4539
|
+
async function ensureChatReply(db, shipId, job, resultText3) {
|
|
3602
4540
|
const chatRef = doc8(db, COLLECTIONS.ships, shipId, COLLECTIONS.chats, job.chatId);
|
|
3603
4541
|
const messagesCol = collection7(chatRef, COLLECTIONS.chatMessages);
|
|
3604
4542
|
const snap = await getDocs6(
|
|
@@ -3614,7 +4552,7 @@ async function ensureChatReply(db, shipId, job, resultText2) {
|
|
|
3614
4552
|
return author?.type === "agent" && author.id === job.agentId;
|
|
3615
4553
|
});
|
|
3616
4554
|
if (replied) return "agent-replied";
|
|
3617
|
-
const content = backstopReplyContent(
|
|
4555
|
+
const content = backstopReplyContent(resultText3);
|
|
3618
4556
|
await addDoc(messagesCol, {
|
|
3619
4557
|
author: { type: "agent", id: job.agentId },
|
|
3620
4558
|
content,
|
|
@@ -3622,17 +4560,17 @@ async function ensureChatReply(db, shipId, job, resultText2) {
|
|
|
3622
4560
|
createdAt: Date.now(),
|
|
3623
4561
|
jobId: job.id
|
|
3624
4562
|
});
|
|
3625
|
-
return
|
|
4563
|
+
return resultText3.trim() ? "posted-final-text" : "posted-silence-note";
|
|
3626
4564
|
}
|
|
3627
4565
|
|
|
3628
4566
|
// src/jobs/progress.ts
|
|
3629
4567
|
var PROGRESS_FLUSH_MS = 3e3;
|
|
3630
4568
|
var PROGRESS_DENIALS_BEFORE_PROBE = 2;
|
|
3631
|
-
function redactStep(step2, secrets) {
|
|
3632
|
-
const label = clampStepText(redactTranscript(step2.label, secrets), MAX_STEP_LABEL);
|
|
4569
|
+
function redactStep(step2, secrets, patterns = []) {
|
|
4570
|
+
const label = clampStepText(redactTranscript(step2.label, secrets, patterns), MAX_STEP_LABEL);
|
|
3633
4571
|
const base = { at: step2.at, kind: step2.kind, label };
|
|
3634
4572
|
if (step2.detail === void 0) return base;
|
|
3635
|
-
const detail = clampStepText(redactTranscript(step2.detail, secrets), MAX_STEP_DETAIL);
|
|
4573
|
+
const detail = clampStepText(redactTranscript(step2.detail, secrets, patterns), MAX_STEP_DETAIL);
|
|
3636
4574
|
return detail ? { ...base, detail } : base;
|
|
3637
4575
|
}
|
|
3638
4576
|
function errorCode(error) {
|
|
@@ -3664,7 +4602,8 @@ function createProgressWriter(deps) {
|
|
|
3664
4602
|
}
|
|
3665
4603
|
if (closed || inFlight || pending.length === 0) return;
|
|
3666
4604
|
const secrets = deps.secrets();
|
|
3667
|
-
|
|
4605
|
+
const patterns = deps.patterns?.() ?? [];
|
|
4606
|
+
for (const step2 of pending) progress = pushJobStep(progress, redactStep(step2, secrets, patterns));
|
|
3668
4607
|
pending = [];
|
|
3669
4608
|
lastWriteAt = now();
|
|
3670
4609
|
inFlight = true;
|
|
@@ -3728,9 +4667,9 @@ function createProgressWriter(deps) {
|
|
|
3728
4667
|
|
|
3729
4668
|
// src/service.ts
|
|
3730
4669
|
import { spawnSync } from "node:child_process";
|
|
3731
|
-
import
|
|
3732
|
-
import
|
|
3733
|
-
import
|
|
4670
|
+
import fs6 from "node:fs";
|
|
4671
|
+
import os4 from "node:os";
|
|
4672
|
+
import path7 from "node:path";
|
|
3734
4673
|
import { fileURLToPath } from "node:url";
|
|
3735
4674
|
var SERVICE_LABEL = `com.kilogent.${RUNNER_BIN.replace(/^kilogent-/, "")}`;
|
|
3736
4675
|
var LINUX_UNIT = `${RUNNER_BIN}.service`;
|
|
@@ -3758,10 +4697,10 @@ function uid() {
|
|
|
3758
4697
|
return String(process.getuid?.() ?? 0);
|
|
3759
4698
|
}
|
|
3760
4699
|
function launchAgentPath() {
|
|
3761
|
-
return
|
|
4700
|
+
return path7.join(os4.homedir(), "Library/LaunchAgents", `${SERVICE_LABEL}.plist`);
|
|
3762
4701
|
}
|
|
3763
4702
|
function systemdUnitPath() {
|
|
3764
|
-
return
|
|
4703
|
+
return path7.join(os4.homedir(), ".config/systemd/user", LINUX_UNIT);
|
|
3765
4704
|
}
|
|
3766
4705
|
function serviceEnv() {
|
|
3767
4706
|
const env = { PATH: process.env.PATH ?? "" };
|
|
@@ -3779,19 +4718,19 @@ function removeLegacyService() {
|
|
|
3779
4718
|
for (const old of RETIRED_SERVICES) {
|
|
3780
4719
|
if (old.label === SERVICE_LABEL) continue;
|
|
3781
4720
|
if (process.platform === "darwin") {
|
|
3782
|
-
const unitPath =
|
|
3783
|
-
if (
|
|
4721
|
+
const unitPath = path7.join(os4.homedir(), "Library/LaunchAgents", `${old.label}.plist`);
|
|
4722
|
+
if (fs6.existsSync(unitPath)) {
|
|
3784
4723
|
run("launchctl", ["bootout", `gui/${uid()}/${old.label}`]);
|
|
3785
|
-
|
|
4724
|
+
fs6.rmSync(unitPath, { force: true });
|
|
3786
4725
|
removed.push(unitPath);
|
|
3787
4726
|
}
|
|
3788
4727
|
continue;
|
|
3789
4728
|
}
|
|
3790
4729
|
if (process.platform === "linux") {
|
|
3791
|
-
const unitPath =
|
|
3792
|
-
if (
|
|
4730
|
+
const unitPath = path7.join(os4.homedir(), ".config/systemd/user", old.unit);
|
|
4731
|
+
if (fs6.existsSync(unitPath)) {
|
|
3793
4732
|
run("systemctl", ["--user", "disable", "--now", old.unit]);
|
|
3794
|
-
|
|
4733
|
+
fs6.rmSync(unitPath, { force: true });
|
|
3795
4734
|
run("systemctl", ["--user", "daemon-reload"]);
|
|
3796
4735
|
removed.push(unitPath);
|
|
3797
4736
|
}
|
|
@@ -3855,9 +4794,9 @@ function plistXml() {
|
|
|
3855
4794
|
${envEntries}
|
|
3856
4795
|
</dict>
|
|
3857
4796
|
<key>StandardOutPath</key>
|
|
3858
|
-
<string>${escapeXml(
|
|
4797
|
+
<string>${escapeXml(path7.join(logDir(), "service.out.log"))}</string>
|
|
3859
4798
|
<key>StandardErrorPath</key>
|
|
3860
|
-
<string>${escapeXml(
|
|
4799
|
+
<string>${escapeXml(path7.join(logDir(), "service.err.log"))}</string>
|
|
3861
4800
|
</dict>
|
|
3862
4801
|
</plist>
|
|
3863
4802
|
`;
|
|
@@ -3903,14 +4842,14 @@ function parseSystemdCliPath(unit) {
|
|
|
3903
4842
|
function execFacts(unitPath, parse, parseEnv) {
|
|
3904
4843
|
let text2;
|
|
3905
4844
|
try {
|
|
3906
|
-
text2 =
|
|
4845
|
+
text2 = fs6.readFileSync(unitPath, "utf8");
|
|
3907
4846
|
} catch {
|
|
3908
4847
|
return {};
|
|
3909
4848
|
}
|
|
3910
4849
|
const unitEnv = parseEnv(text2);
|
|
3911
4850
|
const execPath = parse(text2);
|
|
3912
|
-
if (!execPath || !
|
|
3913
|
-
return { execPath, execMissing: !
|
|
4851
|
+
if (!execPath || !path7.isAbsolute(execPath)) return { ...unitEnv ? { unitEnv } : {} };
|
|
4852
|
+
return { execPath, execMissing: !fs6.existsSync(execPath), ...unitEnv ? { unitEnv } : {} };
|
|
3914
4853
|
}
|
|
3915
4854
|
function systemdUnit() {
|
|
3916
4855
|
const env = serviceEnv();
|
|
@@ -3936,7 +4875,7 @@ WantedBy=default.target
|
|
|
3936
4875
|
function serviceStatus() {
|
|
3937
4876
|
if (process.platform === "darwin") {
|
|
3938
4877
|
const unitPath = launchAgentPath();
|
|
3939
|
-
if (!
|
|
4878
|
+
if (!fs6.existsSync(unitPath)) return { state: "not-installed", detail: "No LaunchAgent installed." };
|
|
3940
4879
|
const exec = execFacts(unitPath, parsePlistCliPath, parsePlistEnv);
|
|
3941
4880
|
const printed = run("launchctl", ["print", `gui/${uid()}/${SERVICE_LABEL}`]);
|
|
3942
4881
|
if (!printed.ok) {
|
|
@@ -3952,7 +4891,7 @@ function serviceStatus() {
|
|
|
3952
4891
|
}
|
|
3953
4892
|
if (process.platform === "linux") {
|
|
3954
4893
|
const unitPath = systemdUnitPath();
|
|
3955
|
-
if (!
|
|
4894
|
+
if (!fs6.existsSync(unitPath)) return { state: "not-installed", detail: "No systemd user unit installed." };
|
|
3956
4895
|
const active = run("systemctl", ["--user", "is-active", LINUX_UNIT]);
|
|
3957
4896
|
return {
|
|
3958
4897
|
state: active.out === "active" ? "running" : "installed",
|
|
@@ -3974,14 +4913,14 @@ function serviceStatus() {
|
|
|
3974
4913
|
}
|
|
3975
4914
|
function installService() {
|
|
3976
4915
|
const notes = [];
|
|
3977
|
-
|
|
4916
|
+
fs6.mkdirSync(logDir(), { recursive: true, mode: 448 });
|
|
3978
4917
|
for (const unit of removeLegacyService()) {
|
|
3979
4918
|
notes.push(`Removed the previous crew-runner service (${unit}).`);
|
|
3980
4919
|
}
|
|
3981
4920
|
if (process.platform === "darwin") {
|
|
3982
4921
|
const unitPath = launchAgentPath();
|
|
3983
|
-
|
|
3984
|
-
|
|
4922
|
+
fs6.mkdirSync(path7.dirname(unitPath), { recursive: true });
|
|
4923
|
+
fs6.writeFileSync(unitPath, plistXml());
|
|
3985
4924
|
run("launchctl", ["bootout", `gui/${uid()}/${SERVICE_LABEL}`]);
|
|
3986
4925
|
const boot = retryWhileTransient(
|
|
3987
4926
|
() => run("launchctl", ["bootstrap", `gui/${uid()}`, unitPath]),
|
|
@@ -3992,16 +4931,16 @@ function installService() {
|
|
|
3992
4931
|
}
|
|
3993
4932
|
if (process.platform === "linux") {
|
|
3994
4933
|
const unitPath = systemdUnitPath();
|
|
3995
|
-
|
|
3996
|
-
|
|
4934
|
+
fs6.mkdirSync(path7.dirname(unitPath), { recursive: true });
|
|
4935
|
+
fs6.writeFileSync(unitPath, systemdUnit());
|
|
3997
4936
|
const reload = run("systemctl", ["--user", "daemon-reload"]);
|
|
3998
4937
|
if (!reload.ok) throw new ServiceError(`systemctl daemon-reload failed: ${reload.out}`);
|
|
3999
4938
|
const enable = run("systemctl", ["--user", "enable", "--now", LINUX_UNIT]);
|
|
4000
4939
|
if (!enable.ok) throw new ServiceError(`systemctl enable failed: ${enable.out}`);
|
|
4001
|
-
const linger = run("loginctl", ["show-user",
|
|
4940
|
+
const linger = run("loginctl", ["show-user", os4.userInfo().username, "--property=Linger"]);
|
|
4002
4941
|
if (!linger.out.includes("Linger=yes")) {
|
|
4003
4942
|
notes.push(
|
|
4004
|
-
`Run \`sudo loginctl enable-linger ${
|
|
4943
|
+
`Run \`sudo loginctl enable-linger ${os4.userInfo().username}\` so the daemon survives logout and starts at boot.`
|
|
4005
4944
|
);
|
|
4006
4945
|
}
|
|
4007
4946
|
return { unitPath, notes };
|
|
@@ -4032,12 +4971,12 @@ function uninstallService() {
|
|
|
4032
4971
|
removeLegacyService();
|
|
4033
4972
|
if (process.platform === "darwin") {
|
|
4034
4973
|
run("launchctl", ["bootout", `gui/${uid()}/${SERVICE_LABEL}`]);
|
|
4035
|
-
|
|
4974
|
+
fs6.rmSync(launchAgentPath(), { force: true });
|
|
4036
4975
|
return;
|
|
4037
4976
|
}
|
|
4038
4977
|
if (process.platform === "linux") {
|
|
4039
4978
|
run("systemctl", ["--user", "disable", "--now", LINUX_UNIT]);
|
|
4040
|
-
|
|
4979
|
+
fs6.rmSync(systemdUnitPath(), { force: true });
|
|
4041
4980
|
run("systemctl", ["--user", "daemon-reload"]);
|
|
4042
4981
|
return;
|
|
4043
4982
|
}
|
|
@@ -4068,7 +5007,7 @@ function restartService() {
|
|
|
4068
5007
|
}
|
|
4069
5008
|
|
|
4070
5009
|
// src/update/plan.ts
|
|
4071
|
-
import
|
|
5010
|
+
import path8 from "node:path";
|
|
4072
5011
|
|
|
4073
5012
|
// src/update/semver.ts
|
|
4074
5013
|
var DEV_VERSION = "0.0.0-dev";
|
|
@@ -4158,16 +5097,16 @@ function reconcile(state, current) {
|
|
|
4158
5097
|
return { action: "keep", state };
|
|
4159
5098
|
}
|
|
4160
5099
|
function packageRootFrom(cliPath2) {
|
|
4161
|
-
return
|
|
5100
|
+
return path8.dirname(path8.dirname(cliPath2));
|
|
4162
5101
|
}
|
|
4163
5102
|
function globalPrefixFrom(cliPath2) {
|
|
4164
5103
|
const root = packageRootFrom(cliPath2);
|
|
4165
|
-
const parts = root.split(
|
|
5104
|
+
const parts = root.split(path8.sep);
|
|
4166
5105
|
const i = parts.lastIndexOf("node_modules");
|
|
4167
5106
|
if (i < 1) return null;
|
|
4168
5107
|
const before = parts.slice(0, i);
|
|
4169
5108
|
if (before[before.length - 1] === "lib") before.pop();
|
|
4170
|
-
const prefix = before.join(
|
|
5109
|
+
const prefix = before.join(path8.sep);
|
|
4171
5110
|
return prefix || null;
|
|
4172
5111
|
}
|
|
4173
5112
|
function decideUpdate(f) {
|
|
@@ -4249,15 +5188,15 @@ async function fetchDistTags(options = {}) {
|
|
|
4249
5188
|
}
|
|
4250
5189
|
|
|
4251
5190
|
// src/update/state.ts
|
|
4252
|
-
import
|
|
4253
|
-
import
|
|
5191
|
+
import fs7 from "node:fs";
|
|
5192
|
+
import path9 from "node:path";
|
|
4254
5193
|
var UPDATE_STATE_FILE = "update.json";
|
|
4255
5194
|
function updateStatePath() {
|
|
4256
|
-
return
|
|
5195
|
+
return path9.join(configDir(), UPDATE_STATE_FILE);
|
|
4257
5196
|
}
|
|
4258
5197
|
function readUpdateState() {
|
|
4259
5198
|
try {
|
|
4260
|
-
const parsed = JSON.parse(
|
|
5199
|
+
const parsed = JSON.parse(fs7.readFileSync(updateStatePath(), "utf8"));
|
|
4261
5200
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
4262
5201
|
return parsed;
|
|
4263
5202
|
} catch {
|
|
@@ -4266,8 +5205,8 @@ function readUpdateState() {
|
|
|
4266
5205
|
}
|
|
4267
5206
|
function writeUpdateState(state) {
|
|
4268
5207
|
try {
|
|
4269
|
-
|
|
4270
|
-
|
|
5208
|
+
fs7.mkdirSync(configDir(), { recursive: true, mode: 448 });
|
|
5209
|
+
fs7.writeFileSync(updateStatePath(), `${JSON.stringify(state, null, 2)}
|
|
4271
5210
|
`, { mode: 384 });
|
|
4272
5211
|
return true;
|
|
4273
5212
|
} catch {
|
|
@@ -4276,15 +5215,15 @@ function writeUpdateState(state) {
|
|
|
4276
5215
|
}
|
|
4277
5216
|
function clearUpdateState() {
|
|
4278
5217
|
try {
|
|
4279
|
-
|
|
5218
|
+
fs7.rmSync(updateStatePath(), { force: true });
|
|
4280
5219
|
} catch {
|
|
4281
5220
|
}
|
|
4282
5221
|
}
|
|
4283
5222
|
|
|
4284
5223
|
// src/update/install.ts
|
|
4285
|
-
import { spawn as
|
|
4286
|
-
import
|
|
4287
|
-
import
|
|
5224
|
+
import { spawn as spawn6, spawnSync as spawnSync2 } from "node:child_process";
|
|
5225
|
+
import fs8 from "node:fs";
|
|
5226
|
+
import path10 from "node:path";
|
|
4288
5227
|
var INSTALL_TIMEOUT_MS = 5 * 6e4;
|
|
4289
5228
|
var STDERR_KEEP = 400;
|
|
4290
5229
|
function npmPresent() {
|
|
@@ -4306,7 +5245,7 @@ async function installGlobal(target, cliPath2, options = {}) {
|
|
|
4306
5245
|
return new Promise((resolve) => {
|
|
4307
5246
|
let child;
|
|
4308
5247
|
try {
|
|
4309
|
-
child =
|
|
5248
|
+
child = spawn6("npm", args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
4310
5249
|
} catch (e) {
|
|
4311
5250
|
resolve({ ok: false, detail: e instanceof Error ? e.message : String(e) });
|
|
4312
5251
|
return;
|
|
@@ -4339,7 +5278,7 @@ async function installGlobal(target, cliPath2, options = {}) {
|
|
|
4339
5278
|
function installedVersionAt(cliPath2) {
|
|
4340
5279
|
try {
|
|
4341
5280
|
const pkg = JSON.parse(
|
|
4342
|
-
|
|
5281
|
+
fs8.readFileSync(path10.join(packageRootFrom(cliPath2), "package.json"), "utf8")
|
|
4343
5282
|
);
|
|
4344
5283
|
const version = pkg?.version;
|
|
4345
5284
|
return typeof version === "string" ? version : null;
|
|
@@ -4390,6 +5329,13 @@ async function startDaemon() {
|
|
|
4390
5329
|
console.log(
|
|
4391
5330
|
bannerIds.length <= 1 ? `Runner ${bannerIds[0] ?? config2.runnerId} serving ${serving.size} Ship(s): ${[...serving].join(", ")}` : `Runner ${bannerIds.length} identities serving ${serving.size} Ship(s): ` + sessions.map((s) => `${s.shipId} as ${s.runnerId}`).join(", ")
|
|
4392
5331
|
);
|
|
5332
|
+
const machineLookup = (shipId) => ({
|
|
5333
|
+
runnerId: sess(shipId).runnerId,
|
|
5334
|
+
localLogin: (engineId, credentialId) => {
|
|
5335
|
+
const home = engineCredentialHome(configDir(), engineId, shipId, credentialId);
|
|
5336
|
+
return getDriver(engineId).login?.isSignedIn(home) ? home : null;
|
|
5337
|
+
}
|
|
5338
|
+
});
|
|
4393
5339
|
const logLines = [];
|
|
4394
5340
|
const log2 = (line) => {
|
|
4395
5341
|
const stamped = `${(/* @__PURE__ */ new Date()).toISOString()} ${line}`;
|
|
@@ -4444,7 +5390,7 @@ async function startDaemon() {
|
|
|
4444
5390
|
await setDoc2(
|
|
4445
5391
|
shipRunnerRef(shipId),
|
|
4446
5392
|
{
|
|
4447
|
-
hostname:
|
|
5393
|
+
hostname: os5.hostname(),
|
|
4448
5394
|
version: RUNNER_VERSION,
|
|
4449
5395
|
status: "online",
|
|
4450
5396
|
lastSeenAt: now,
|
|
@@ -4503,6 +5449,10 @@ async function startDaemon() {
|
|
|
4503
5449
|
const engineLimits = /* @__PURE__ */ new Map();
|
|
4504
5450
|
const claimBackoff = /* @__PURE__ */ new Map();
|
|
4505
5451
|
const agentEngines = /* @__PURE__ */ new Map();
|
|
5452
|
+
const agentCredentials = /* @__PURE__ */ new Map();
|
|
5453
|
+
const shipCredentials = /* @__PURE__ */ new Map();
|
|
5454
|
+
const heldCredentialIds = /* @__PURE__ */ new Map();
|
|
5455
|
+
const loginsInFlight = /* @__PURE__ */ new Map();
|
|
4506
5456
|
const unsubsByShip = /* @__PURE__ */ new Map();
|
|
4507
5457
|
const listenerError = (shipId, what) => (e) => {
|
|
4508
5458
|
if (isEnrolmentGone(e)) {
|
|
@@ -4539,7 +5489,9 @@ async function startDaemon() {
|
|
|
4539
5489
|
collection8(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents),
|
|
4540
5490
|
(snap) => {
|
|
4541
5491
|
for (const d of snap.docs) {
|
|
4542
|
-
|
|
5492
|
+
const agent = d.data();
|
|
5493
|
+
agentEngines.set(`${shipId}/${d.id}`, agentEngine(agent));
|
|
5494
|
+
agentCredentials.set(`${shipId}/${d.id}`, { engine: agent.engine, credentialId: agent.credentialId });
|
|
4543
5495
|
}
|
|
4544
5496
|
poke();
|
|
4545
5497
|
},
|
|
@@ -4594,6 +5546,93 @@ async function startDaemon() {
|
|
|
4594
5546
|
*/
|
|
4595
5547
|
(e) => console.error(`mcp connections listener error (${shipId}):`, e.message)
|
|
4596
5548
|
),
|
|
5549
|
+
/**
|
|
5550
|
+
* AI credentials (PRD §15.81), for two things only this machine can answer.
|
|
5551
|
+
*
|
|
5552
|
+
* The CLAIM GATE: a machine-held credential lives on one machine, so a queued job on it is
|
|
5553
|
+
* that machine's job — `credentialGate` reads this list before any claim. And the SIGN-IN: a
|
|
5554
|
+
* captain asks, through a callable, for THIS machine to sign in for a new credential; the
|
|
5555
|
+
* document turns `pending` bound to this runner, and the only thing in the world that can
|
|
5556
|
+
* answer is a process on this machine. §15.40's probe listener, with two more steps.
|
|
5557
|
+
*
|
|
5558
|
+
* `credentialsNeedingLogin` deliberately acts on nothing this machine has already answered,
|
|
5559
|
+
* and reports an old unanswered request STALE rather than starting it: a device code lives
|
|
5560
|
+
* fifteen minutes, and a machine that was off when the captain clicked would otherwise show
|
|
5561
|
+
* a code nobody is watching. Startup is the same case, so a daemon coming up does not sign
|
|
5562
|
+
* in to anything on its own.
|
|
5563
|
+
*
|
|
5564
|
+
* A credential that VANISHES from the list is a sign-in to forget: the callable deleted the
|
|
5565
|
+
* row, and the token on this disk is the only copy left. The first snapshot also sweeps the
|
|
5566
|
+
* directories no live credential explains — the deletions that happened while it was off.
|
|
5567
|
+
*/
|
|
5568
|
+
onSnapshot2(
|
|
5569
|
+
collection8(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.credentials),
|
|
5570
|
+
(snap) => {
|
|
5571
|
+
const list = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
|
|
5572
|
+
const runnerId = sess(shipId).runnerId;
|
|
5573
|
+
shipCredentials.set(shipId, list);
|
|
5574
|
+
const now = Date.now();
|
|
5575
|
+
const held = heldCredentialIds.get(shipId) ?? /* @__PURE__ */ new Set();
|
|
5576
|
+
const gone = credentialsToForget(held, list);
|
|
5577
|
+
for (const id of gone) {
|
|
5578
|
+
held.delete(id);
|
|
5579
|
+
loginsInFlight.get(id)?.abort();
|
|
5580
|
+
for (const dir of orphanHomes(configDir(), shipId, new Set(list.map((c) => c.id)))) {
|
|
5581
|
+
if (!dir.endsWith(`${path11.sep}${id}`)) continue;
|
|
5582
|
+
const engineId = path11.basename(path11.dirname(path11.dirname(dir)));
|
|
5583
|
+
const driver = getDriver(engineId);
|
|
5584
|
+
if (!driver.login) continue;
|
|
5585
|
+
void logoutAndRemove({ driver: driver.login, bin: driver.binary(), home: dir, log: log2 }).then(
|
|
5586
|
+
() => log2(`Forgot the sign-in for credential ${id} \u2014 the credential was deleted from Ship ${shipId}.`)
|
|
5587
|
+
);
|
|
5588
|
+
}
|
|
5589
|
+
}
|
|
5590
|
+
if (!heldCredentialIds.has(shipId)) {
|
|
5591
|
+
for (const dir of orphanHomes(configDir(), shipId, new Set(list.map((c) => c.id)))) {
|
|
5592
|
+
const engineId = path11.basename(path11.dirname(path11.dirname(dir)));
|
|
5593
|
+
const driver = getDriver(engineId);
|
|
5594
|
+
if (!driver.login) continue;
|
|
5595
|
+
void logoutAndRemove({ driver: driver.login, bin: driver.binary(), home: dir, log: log2 }).then(
|
|
5596
|
+
() => log2(`Removed a stale sign-in directory for Ship ${shipId}: ${dir}`)
|
|
5597
|
+
);
|
|
5598
|
+
}
|
|
5599
|
+
}
|
|
5600
|
+
for (const c of list) if (c.runnerId === runnerId) held.add(c.id);
|
|
5601
|
+
heldCredentialIds.set(shipId, held);
|
|
5602
|
+
const { start, expire } = credentialsNeedingLogin(list, runnerId, now);
|
|
5603
|
+
for (const credential of expire) {
|
|
5604
|
+
void sess(shipId).user.getIdToken().then(
|
|
5605
|
+
(idToken) => reportLogin({
|
|
5606
|
+
config: config2,
|
|
5607
|
+
idToken,
|
|
5608
|
+
shipId,
|
|
5609
|
+
credentialId: credential.id,
|
|
5610
|
+
report: { phase: "expired", error: "This machine was offline when the sign-in was requested. Ask again." },
|
|
5611
|
+
log: log2
|
|
5612
|
+
})
|
|
5613
|
+
).catch((e) => log2(`Could not report a stale sign-in request: ${e instanceof Error ? e.message : e}`));
|
|
5614
|
+
}
|
|
5615
|
+
for (const credential of start) {
|
|
5616
|
+
if (loginsInFlight.has(credential.id)) continue;
|
|
5617
|
+
const abort = new AbortController();
|
|
5618
|
+
loginsInFlight.set(credential.id, abort);
|
|
5619
|
+
void machineLoginAndReport({
|
|
5620
|
+
config: config2,
|
|
5621
|
+
idToken: () => sess(shipId).user.getIdToken(),
|
|
5622
|
+
shipId,
|
|
5623
|
+
credential,
|
|
5624
|
+
root: configDir(),
|
|
5625
|
+
signal: abort.signal,
|
|
5626
|
+
log: log2
|
|
5627
|
+
}).catch((e) => log2(`Sign-in for ${credential.id} ended abnormally: ${e instanceof Error ? e.message : e}`)).finally(() => loginsInFlight.delete(credential.id));
|
|
5628
|
+
}
|
|
5629
|
+
poke();
|
|
5630
|
+
},
|
|
5631
|
+
// ITS OWN ERROR HANDLER, not `listenerError`, for the reason the mcp_servers listener
|
|
5632
|
+
// gives: `credentials` is `isShipReader`, which a REVOKED machine fails, and revocation is
|
|
5633
|
+
// a state to sit through, not a removal.
|
|
5634
|
+
(e) => console.error(`credentials listener error (${shipId}):`, e.message)
|
|
5635
|
+
),
|
|
4597
5636
|
// Exhausted usage windows. The FIRST snapshot is the startup seed: a restarted daemon —
|
|
4598
5637
|
// and a second daemon on this Ship — inherits the pause instead of running a doomed
|
|
4599
5638
|
// session to rediscover it.
|
|
@@ -4632,6 +5671,20 @@ async function startDaemon() {
|
|
|
4632
5671
|
for (const key of [...agentEngines.keys()]) {
|
|
4633
5672
|
if (key.startsWith(`${shipId}/`)) agentEngines.delete(key);
|
|
4634
5673
|
}
|
|
5674
|
+
for (const key of [...agentCredentials.keys()]) {
|
|
5675
|
+
if (key.startsWith(`${shipId}/`)) agentCredentials.delete(key);
|
|
5676
|
+
}
|
|
5677
|
+
shipCredentials.delete(shipId);
|
|
5678
|
+
heldCredentialIds.delete(shipId);
|
|
5679
|
+
for (const engineId of (() => {
|
|
5680
|
+
try {
|
|
5681
|
+
return fs9.readdirSync(path11.join(configDir(), "engines"));
|
|
5682
|
+
} catch {
|
|
5683
|
+
return [];
|
|
5684
|
+
}
|
|
5685
|
+
})()) {
|
|
5686
|
+
fs9.rmSync(path11.join(configDir(), "engines", engineId, shipId), { recursive: true, force: true });
|
|
5687
|
+
}
|
|
4635
5688
|
approved.delete(shipId);
|
|
4636
5689
|
warnedUnapproved.delete(shipId);
|
|
4637
5690
|
needsRefill.delete(shipId);
|
|
@@ -4658,6 +5711,17 @@ async function startDaemon() {
|
|
|
4658
5711
|
}
|
|
4659
5712
|
}
|
|
4660
5713
|
const engineForJob = (shipId, job) => agentEngines.get(`${shipId}/${job.agentId}`) ?? DEFAULT_ENGINE_ID;
|
|
5714
|
+
const credentialVerdict = (shipId, job) => {
|
|
5715
|
+
const busy = /* @__PURE__ */ new Set();
|
|
5716
|
+
for (const r of running.values()) if (r.credentialId) busy.add(r.credentialId);
|
|
5717
|
+
return credentialGate(
|
|
5718
|
+
agentCredentials.get(`${shipId}/${job.agentId}`),
|
|
5719
|
+
shipCredentials.get(shipId) ?? [],
|
|
5720
|
+
sess(shipId).runnerId,
|
|
5721
|
+
busy,
|
|
5722
|
+
DEFAULT_ENGINE_ID
|
|
5723
|
+
);
|
|
5724
|
+
};
|
|
4661
5725
|
let dispatching = false;
|
|
4662
5726
|
let pokeRequested = false;
|
|
4663
5727
|
let shuttingDown = false;
|
|
@@ -4732,7 +5796,9 @@ async function startDaemon() {
|
|
|
4732
5796
|
// the limit map is keyed by (Ship, engine) and the engine comes from the registry via
|
|
4733
5797
|
// the agent, so the job loop still knows nothing about Claude. Skipped entries STAY in
|
|
4734
5798
|
// `pending`, which is what lets a reset resume them with only a poke.
|
|
4735
|
-
eligible: (p) => approved.get(p.shipId) === true && !isLimited(engineLimits, p.shipId, engineForJob(p.shipId, p.job), now) && //
|
|
5799
|
+
eligible: (p) => approved.get(p.shipId) === true && !isLimited(engineLimits, p.shipId, engineForJob(p.shipId, p.job), now) && // §15.81: a job on a machine-held credential is that machine's job. Skipped entries
|
|
5800
|
+
// STAY in `pending`; the credentials listener pokes when the sign-in lands.
|
|
5801
|
+
credentialVerdict(p.shipId, p.job) === "ok" && // The connection cooldown. Same "skip, don't stop" shape as the limit gate above, and
|
|
4736
5802
|
// skipped entries STAY in `pending` for the same reason — `sweepBackoff` resumes them
|
|
4737
5803
|
// with a poke. Without this the `transient` release below re-claims what it just put
|
|
4738
5804
|
// down, every three seconds, forever (jobs/claimBackoff.ts).
|
|
@@ -4763,7 +5829,9 @@ async function startDaemon() {
|
|
|
4763
5829
|
});
|
|
4764
5830
|
}
|
|
4765
5831
|
async function claim(shipId, job) {
|
|
4766
|
-
if (isLimited(engineLimits, shipId, engineForJob(shipId, job), Date.now())
|
|
5832
|
+
if (isLimited(engineLimits, shipId, engineForJob(shipId, job), Date.now()) || // Same second gate for the credential: a sibling session on the same machine sign-in can
|
|
5833
|
+
// have started between selection and here.
|
|
5834
|
+
credentialVerdict(shipId, job) !== "ok") {
|
|
4767
5835
|
pending.set(`${shipId}/${job.id}`, { shipId, job });
|
|
4768
5836
|
return null;
|
|
4769
5837
|
}
|
|
@@ -4857,10 +5925,11 @@ async function startDaemon() {
|
|
|
4857
5925
|
let failure = null;
|
|
4858
5926
|
let terminal = false;
|
|
4859
5927
|
let transient = false;
|
|
5928
|
+
let misrouted = false;
|
|
4860
5929
|
let sessionLimit = null;
|
|
4861
5930
|
let engineId = DEFAULT_ENGINE_ID;
|
|
4862
5931
|
let transcript = "";
|
|
4863
|
-
let
|
|
5932
|
+
let resultText3 = "";
|
|
4864
5933
|
let mcpConnected = [];
|
|
4865
5934
|
let usage = {
|
|
4866
5935
|
engine: DEFAULT_ENGINE_ID,
|
|
@@ -4880,6 +5949,8 @@ async function startDaemon() {
|
|
|
4880
5949
|
progress: p
|
|
4881
5950
|
}),
|
|
4882
5951
|
secrets: () => knownSecrets,
|
|
5952
|
+
// Read late for the same reason: the engine is only known once the agent is.
|
|
5953
|
+
patterns: () => getDriver(engineId).secretPatterns,
|
|
4883
5954
|
// Two consecutive DENIALS mean this job is no longer ours to write — a force-stop took it, or
|
|
4884
5955
|
// the machine was revoked. The §15.30 door: the news arrives as `permission-denied`.
|
|
4885
5956
|
//
|
|
@@ -4908,7 +5979,7 @@ async function startDaemon() {
|
|
|
4908
5979
|
try {
|
|
4909
5980
|
let secrets = null;
|
|
4910
5981
|
const forAssistant = isAssistantId(job.agentId);
|
|
4911
|
-
const assistantCredential = forAssistant ? await resolveAssistantCredential(sess(shipId).fb.db, shipId) : null;
|
|
5982
|
+
const assistantCredential = forAssistant ? await resolveAssistantCredential(sess(shipId).fb.db, shipId, machineLookup(shipId)) : null;
|
|
4912
5983
|
const packed = forAssistant && target.kind === "chat" ? {
|
|
4913
5984
|
kind: "assistant",
|
|
4914
5985
|
ctx: await loadAssistantContext(
|
|
@@ -4931,14 +6002,17 @@ async function startDaemon() {
|
|
|
4931
6002
|
db: sess(shipId).fb.db,
|
|
4932
6003
|
shipId,
|
|
4933
6004
|
agent,
|
|
4934
|
-
credentials: await loadShipCredentials(sess(shipId).fb.db, shipId).catch(() => [])
|
|
6005
|
+
credentials: await loadShipCredentials(sess(shipId).fb.db, shipId).catch(() => []),
|
|
6006
|
+
machine: machineLookup(shipId)
|
|
4935
6007
|
});
|
|
6008
|
+
slot.credentialId = resolvedSecrets.credentialId;
|
|
4936
6009
|
if (resolvedSecrets.problem) {
|
|
6010
|
+
misrouted = resolvedSecrets.kind === "other-machine" || resolvedSecrets.kind === "not-ready";
|
|
4937
6011
|
throw new Error(resolvedSecrets.problem);
|
|
4938
6012
|
}
|
|
4939
6013
|
secrets = resolvedSecrets.secrets;
|
|
4940
6014
|
const missing = missingSecretsFor(engineId, secrets);
|
|
4941
|
-
if (missing.length > 0
|
|
6015
|
+
if (missing.length > 0) {
|
|
4942
6016
|
throw new Error(
|
|
4943
6017
|
`Runner credentials are not configured for this Ship \u2014 a captain must save the following in Ship Settings: ${missing.join(", ")}.`
|
|
4944
6018
|
);
|
|
@@ -5006,7 +6080,8 @@ async function startDaemon() {
|
|
|
5006
6080
|
if (slot.abort.signal.aborted) throw new Error("Stopped before the session started.");
|
|
5007
6081
|
knownSecrets = [
|
|
5008
6082
|
idToken,
|
|
5009
|
-
|
|
6083
|
+
// The engine's own values, named by the driver — the job loop knows no engine's key.
|
|
6084
|
+
...getDriver(engineId).secretValues(secrets),
|
|
5010
6085
|
githubToken,
|
|
5011
6086
|
// §15.31. BOTH the composed header value and the bare credential — see
|
|
5012
6087
|
// `mcpSecretsToRedact` for why one of them is not enough.
|
|
@@ -5030,7 +6105,29 @@ async function startDaemon() {
|
|
|
5030
6105
|
// swallows everything.
|
|
5031
6106
|
onStep: (step2) => progress.push(step2)
|
|
5032
6107
|
});
|
|
5033
|
-
transcript = redactTranscript(
|
|
6108
|
+
transcript = redactTranscript(
|
|
6109
|
+
session.transcript,
|
|
6110
|
+
knownSecrets,
|
|
6111
|
+
getDriver(engineId).secretPatterns
|
|
6112
|
+
);
|
|
6113
|
+
if (session.credentialLost) {
|
|
6114
|
+
terminal = true;
|
|
6115
|
+
log2(`Credential unusable on this machine: ${session.credentialLost}`);
|
|
6116
|
+
const lostId = slot.credentialId;
|
|
6117
|
+
if (lostId) {
|
|
6118
|
+
void sess(shipId).user.getIdToken().then(
|
|
6119
|
+
(idToken2) => reportLogin({
|
|
6120
|
+
config: config2,
|
|
6121
|
+
idToken: idToken2,
|
|
6122
|
+
shipId,
|
|
6123
|
+
credentialId: lostId,
|
|
6124
|
+
report: { phase: "failed", error: session.credentialLost ?? "The sign-in is no longer usable." },
|
|
6125
|
+
log: log2
|
|
6126
|
+
})
|
|
6127
|
+
).catch(() => {
|
|
6128
|
+
});
|
|
6129
|
+
}
|
|
6130
|
+
}
|
|
5034
6131
|
usage = session.usage;
|
|
5035
6132
|
if (session.limit && getEngine(engineId).usageWindows) {
|
|
5036
6133
|
sessionLimit = session.limit;
|
|
@@ -5043,7 +6140,7 @@ async function startDaemon() {
|
|
|
5043
6140
|
});
|
|
5044
6141
|
armLimitTimer();
|
|
5045
6142
|
}
|
|
5046
|
-
|
|
6143
|
+
resultText3 = session.resultText;
|
|
5047
6144
|
const failed = new Set(session.mcpFailed ?? []);
|
|
5048
6145
|
mcpConnected = extraMcpServers.map((s) => s.key).filter((k) => !failed.has(k));
|
|
5049
6146
|
if (!session.ok) failure = session.resultText || "Session failed.";
|
|
@@ -5068,7 +6165,7 @@ async function startDaemon() {
|
|
|
5068
6165
|
// §15.41. A stopped run is the case that needs the backstop MOST: it was cut off
|
|
5069
6166
|
// mid-thought, so it almost certainly never reached `run_report` — and whatever it had
|
|
5070
6167
|
// got to is what the next run on this task would otherwise have to rediscover.
|
|
5071
|
-
resultText:
|
|
6168
|
+
resultText: resultText3,
|
|
5072
6169
|
mcpServers: mcpConnected
|
|
5073
6170
|
});
|
|
5074
6171
|
const by = slot.stop.by;
|
|
@@ -5118,7 +6215,7 @@ async function startDaemon() {
|
|
|
5118
6215
|
transcriptPath,
|
|
5119
6216
|
// §15.41. Only used when the session never called `run_report` — the ordinary path is
|
|
5120
6217
|
// that it did, and a real report always wins inside the transaction.
|
|
5121
|
-
resultText:
|
|
6218
|
+
resultText: resultText3,
|
|
5122
6219
|
mcpServers: mcpConnected
|
|
5123
6220
|
});
|
|
5124
6221
|
log2(`Job ${job.id} done (${usage.inputTokens}in/${usage.outputTokens}out tokens).`);
|
|
@@ -5129,7 +6226,7 @@ async function startDaemon() {
|
|
|
5129
6226
|
sess(shipId).fb.db,
|
|
5130
6227
|
shipId,
|
|
5131
6228
|
{ ...job, chatId: target.chatId },
|
|
5132
|
-
|
|
6229
|
+
resultText3
|
|
5133
6230
|
);
|
|
5134
6231
|
if (delivery === "posted-final-text") {
|
|
5135
6232
|
log2(
|
|
@@ -5150,6 +6247,13 @@ async function startDaemon() {
|
|
|
5150
6247
|
"Crew job finished",
|
|
5151
6248
|
target.kind === "task" ? `Task ${target.taskId} is done.` : delivery === "agent-replied" ? "Agent replied in a chat." : "Agent finished a chat run without replying \u2014 its answer was posted for it."
|
|
5152
6249
|
);
|
|
6250
|
+
} else if (misrouted) {
|
|
6251
|
+
const held = noteTransientRelease(claimBackoff, job.id, Date.now());
|
|
6252
|
+
await releaseJob(sess(shipId).fb.db, shipId, job, `Not this machine's job: ${failure.slice(0, 160)}`);
|
|
6253
|
+
armBackoffTimer();
|
|
6254
|
+
log2(
|
|
6255
|
+
`Job ${job.id} released \u2014 ${failure.slice(0, 160)} Attempt ${job.attempt} preserved; not re-claiming it here for ${Math.round((held.eligibleAt - Date.now()) / 1e3)}s.`
|
|
6256
|
+
);
|
|
5153
6257
|
} else if (transient) {
|
|
5154
6258
|
const held = noteTransientRelease(claimBackoff, job.id, Date.now());
|
|
5155
6259
|
await releaseJob(
|
|
@@ -5175,7 +6279,7 @@ async function startDaemon() {
|
|
|
5175
6279
|
// §15.41. A failed run still did work, and the retry — or the next run after the retry
|
|
5176
6280
|
// is spent — starts from the context pack alone. `error` is the tail for a human; this
|
|
5177
6281
|
// is the continuity for the next session, and they are read by different readers.
|
|
5178
|
-
resultText:
|
|
6282
|
+
resultText: resultText3,
|
|
5179
6283
|
mcpServers: mcpConnected
|
|
5180
6284
|
});
|
|
5181
6285
|
if (target.kind === "chat") {
|
|
@@ -5209,6 +6313,7 @@ async function startDaemon() {
|
|
|
5209
6313
|
if (limitTimer) clearTimeout(limitTimer);
|
|
5210
6314
|
if (updateDrainTimer) clearTimeout(updateDrainTimer);
|
|
5211
6315
|
for (const shipUnsubs of unsubsByShip.values()) shipUnsubs.forEach((u) => u());
|
|
6316
|
+
for (const abort of loginsInFlight.values()) abort.abort();
|
|
5212
6317
|
const inFlight = [...running.values()];
|
|
5213
6318
|
if (inFlight.length > 0) {
|
|
5214
6319
|
log2(
|
|
@@ -5387,8 +6492,8 @@ async function startDaemon() {
|
|
|
5387
6492
|
import * as clack from "@clack/prompts";
|
|
5388
6493
|
import { createColors } from "picocolors";
|
|
5389
6494
|
var CliError = class extends Error {
|
|
5390
|
-
constructor(
|
|
5391
|
-
super(
|
|
6495
|
+
constructor(message3, exitCode = 1) {
|
|
6496
|
+
super(message3);
|
|
5392
6497
|
this.exitCode = exitCode;
|
|
5393
6498
|
this.name = "CliError";
|
|
5394
6499
|
}
|
|
@@ -5433,30 +6538,30 @@ var say = {
|
|
|
5433
6538
|
intro(title) {
|
|
5434
6539
|
if (!jsonMode) clack.intro(pc.bgCyan(pc.black(` ${title} `)));
|
|
5435
6540
|
},
|
|
5436
|
-
outro(
|
|
5437
|
-
if (!jsonMode) clack.outro(
|
|
6541
|
+
outro(message3) {
|
|
6542
|
+
if (!jsonMode) clack.outro(message3);
|
|
5438
6543
|
},
|
|
5439
|
-
info(
|
|
5440
|
-
if (!jsonMode) clack.log.info(
|
|
6544
|
+
info(message3) {
|
|
6545
|
+
if (!jsonMode) clack.log.info(message3);
|
|
5441
6546
|
},
|
|
5442
|
-
success(
|
|
5443
|
-
if (!jsonMode) clack.log.success(
|
|
6547
|
+
success(message3) {
|
|
6548
|
+
if (!jsonMode) clack.log.success(message3);
|
|
5444
6549
|
},
|
|
5445
|
-
warn(
|
|
5446
|
-
if (!jsonMode) clack.log.warn(
|
|
6550
|
+
warn(message3) {
|
|
6551
|
+
if (!jsonMode) clack.log.warn(message3);
|
|
5447
6552
|
},
|
|
5448
|
-
error(
|
|
5449
|
-
if (!jsonMode) clack.log.error(
|
|
6553
|
+
error(message3) {
|
|
6554
|
+
if (!jsonMode) clack.log.error(message3);
|
|
5450
6555
|
},
|
|
5451
|
-
step(
|
|
5452
|
-
if (!jsonMode) clack.log.step(
|
|
6556
|
+
step(message3) {
|
|
6557
|
+
if (!jsonMode) clack.log.step(message3);
|
|
5453
6558
|
},
|
|
5454
6559
|
note(body, title) {
|
|
5455
6560
|
if (!jsonMode) clack.note(body, title);
|
|
5456
6561
|
},
|
|
5457
6562
|
/** Raw line straight to stdout — for `logs`, where the content IS the output. */
|
|
5458
|
-
line(
|
|
5459
|
-
process.stdout.write(`${
|
|
6563
|
+
line(message3) {
|
|
6564
|
+
process.stdout.write(`${message3}
|
|
5460
6565
|
`);
|
|
5461
6566
|
}
|
|
5462
6567
|
};
|
|
@@ -5716,8 +6821,8 @@ function setParallel(config2, value, ship2) {
|
|
|
5716
6821
|
|
|
5717
6822
|
// src/cli/commands/doctor.ts
|
|
5718
6823
|
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
5719
|
-
import
|
|
5720
|
-
import
|
|
6824
|
+
import fs10 from "node:fs";
|
|
6825
|
+
import path12 from "node:path";
|
|
5721
6826
|
import { collection as collection9, doc as doc10, getDoc as getDoc9, getDocs as getDocs8 } from "firebase/firestore";
|
|
5722
6827
|
|
|
5723
6828
|
// src/cli/session.ts
|
|
@@ -5793,7 +6898,7 @@ function serviceBinaryCheckFrom(input) {
|
|
|
5793
6898
|
if (status.state === "not-installed" || status.state === "unsupported") return null;
|
|
5794
6899
|
const pathValue = status.unitEnv?.PATH;
|
|
5795
6900
|
if (pathValue === void 0) return null;
|
|
5796
|
-
const relative = [...new Set(binaries)].filter((b) => !
|
|
6901
|
+
const relative = [...new Set(binaries)].filter((b) => !path12.isAbsolute(b));
|
|
5797
6902
|
if (relative.length === 0) return null;
|
|
5798
6903
|
const missing = relative.filter((b) => !resolves(b, pathValue));
|
|
5799
6904
|
const id = "service:path";
|
|
@@ -5809,10 +6914,10 @@ function serviceBinaryCheckFrom(input) {
|
|
|
5809
6914
|
);
|
|
5810
6915
|
}
|
|
5811
6916
|
function resolvesOnPath(binary, pathValue) {
|
|
5812
|
-
for (const dir of pathValue.split(
|
|
6917
|
+
for (const dir of pathValue.split(path12.delimiter)) {
|
|
5813
6918
|
if (!dir) continue;
|
|
5814
6919
|
try {
|
|
5815
|
-
|
|
6920
|
+
fs10.accessSync(path12.join(dir, binary), fs10.constants.X_OK);
|
|
5816
6921
|
return true;
|
|
5817
6922
|
} catch {
|
|
5818
6923
|
}
|
|
@@ -5975,6 +7080,25 @@ async function checkShips(config2) {
|
|
|
5975
7080
|
)
|
|
5976
7081
|
);
|
|
5977
7082
|
}
|
|
7083
|
+
try {
|
|
7084
|
+
const credentials = await loadShipCredentials(session.fb.db, shipId).catch(() => []);
|
|
7085
|
+
for (const cred of credentials) {
|
|
7086
|
+
if (!isMachineHeldCredential(cred) || cred.runnerId !== session.runnerId) continue;
|
|
7087
|
+
if (!isMachineLoginEngine(cred.engine)) continue;
|
|
7088
|
+
const driver = getDriver(cred.engine);
|
|
7089
|
+
const login = driver.login;
|
|
7090
|
+
if (!login) continue;
|
|
7091
|
+
const home = engineCredentialHome(configDir(), cred.engine, shipId, cred.id);
|
|
7092
|
+
const dirPresent = login.isSignedIn(home);
|
|
7093
|
+
const statusExit = dirPresent ? spawnSync3(driver.binary(), login.statusArgs(), {
|
|
7094
|
+
env: { ...process.env, ...login.env(home) },
|
|
7095
|
+
stdio: "ignore",
|
|
7096
|
+
timeout: 1e4
|
|
7097
|
+
}).status ?? 1 : 1;
|
|
7098
|
+
checks.push(machineCredentialCheckFrom(shipId, cred, { dirPresent, statusExit }));
|
|
7099
|
+
}
|
|
7100
|
+
} catch {
|
|
7101
|
+
}
|
|
5978
7102
|
try {
|
|
5979
7103
|
const snap = await getDocs8(
|
|
5980
7104
|
collection9(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.mcpServers)
|
|
@@ -5989,6 +7113,35 @@ async function checkShips(config2) {
|
|
|
5989
7113
|
}
|
|
5990
7114
|
return { checks, engines, needsGithub };
|
|
5991
7115
|
}
|
|
7116
|
+
function machineCredentialCheckFrom(shipId, cred, facts) {
|
|
7117
|
+
const id = `login:${shipId}:${cred.id}`;
|
|
7118
|
+
const label = `Ship ${shipId} \u2014 sign-in "${cred.label}"`;
|
|
7119
|
+
if (cred.status && cred.status !== "ready") {
|
|
7120
|
+
return warn(
|
|
7121
|
+
id,
|
|
7122
|
+
label,
|
|
7123
|
+
`The sign-in is ${cred.status.replace("-", " ")}.`,
|
|
7124
|
+
cred.status === "failed" || cred.status === "expired" ? "Retry it in Ship Settings \u2192 AI credentials." : "Finish it in Ship Settings \u2192 AI credentials."
|
|
7125
|
+
);
|
|
7126
|
+
}
|
|
7127
|
+
if (!facts.dirPresent) {
|
|
7128
|
+
return fail(
|
|
7129
|
+
id,
|
|
7130
|
+
label,
|
|
7131
|
+
"This machine no longer holds the sign-in.",
|
|
7132
|
+
"Delete the credential in Ship Settings \u2192 AI credentials and add it again from this machine."
|
|
7133
|
+
);
|
|
7134
|
+
}
|
|
7135
|
+
if (facts.statusExit !== 0) {
|
|
7136
|
+
return fail(
|
|
7137
|
+
id,
|
|
7138
|
+
label,
|
|
7139
|
+
"The engine no longer accepts the stored sign-in.",
|
|
7140
|
+
"Delete the credential in Ship Settings \u2192 AI credentials and add it again from this machine."
|
|
7141
|
+
);
|
|
7142
|
+
}
|
|
7143
|
+
return ok(id, label, `Signed in${cred.account?.email ? ` as ${cred.account.email}` : ""}.`);
|
|
7144
|
+
}
|
|
5992
7145
|
async function runDoctor() {
|
|
5993
7146
|
const checks = [checkNode()];
|
|
5994
7147
|
const config2 = loadConfig();
|
|
@@ -6079,8 +7232,8 @@ function report2(checks) {
|
|
|
6079
7232
|
}
|
|
6080
7233
|
|
|
6081
7234
|
// src/cli/commands/login.ts
|
|
6082
|
-
import { spawn as
|
|
6083
|
-
import
|
|
7235
|
+
import { spawn as spawn7 } from "node:child_process";
|
|
7236
|
+
import os6 from "node:os";
|
|
6084
7237
|
import { signInWithCustomToken as signInWithCustomToken2 } from "firebase/auth";
|
|
6085
7238
|
function buildLoginResult(approved, existing, fallbackProjectId, mcpUrl2) {
|
|
6086
7239
|
const approvedShips = approved.approvedShips ?? Object.keys(approved.shipKeys ?? {});
|
|
@@ -6121,7 +7274,7 @@ function buildLoginResult(approved, existing, fallbackProjectId, mcpUrl2) {
|
|
|
6121
7274
|
function openBrowser(url) {
|
|
6122
7275
|
const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
6123
7276
|
try {
|
|
6124
|
-
const child =
|
|
7277
|
+
const child = spawn7(command, args, { stdio: "ignore", detached: true });
|
|
6125
7278
|
child.on("error", () => {
|
|
6126
7279
|
});
|
|
6127
7280
|
child.unref();
|
|
@@ -6140,7 +7293,7 @@ async function loginWithDeviceFlow(options) {
|
|
|
6140
7293
|
const baseUrl = functionsBaseUrlFor(projectId);
|
|
6141
7294
|
const candidates = existingConfig ? configRunnerIdentities(existingConfig).map((i) => i.runnerId) : [];
|
|
6142
7295
|
const start = await callPublicFunction(baseUrl, "crewRunnerAuth", "startRunnerLogin", {
|
|
6143
|
-
hostname:
|
|
7296
|
+
hostname: os6.hostname(),
|
|
6144
7297
|
// The singular field stays, always: it is what an older deployment reads, and dropping it
|
|
6145
7298
|
// would silently turn every re-login into a new enrolment for the length of a rollout.
|
|
6146
7299
|
...existingConfig?.runnerId ? { runnerId: existingConfig.runnerId } : {},
|
|
@@ -6389,20 +7542,20 @@ async function runServiceStatus() {
|
|
|
6389
7542
|
}
|
|
6390
7543
|
|
|
6391
7544
|
// src/cli/commands/uninstall.ts
|
|
6392
|
-
import
|
|
7545
|
+
import fs11 from "node:fs";
|
|
6393
7546
|
async function runUninstall(options) {
|
|
6394
7547
|
const before = serviceStatus();
|
|
6395
7548
|
const dir = configDir();
|
|
6396
7549
|
const hadService = before.state !== "not-installed" && before.state !== "unsupported";
|
|
6397
7550
|
if (hadService) uninstallService();
|
|
6398
7551
|
let purged = false;
|
|
6399
|
-
if (options.purge &&
|
|
7552
|
+
if (options.purge && fs11.existsSync(dir)) {
|
|
6400
7553
|
const confirmed = await promptConfirm({
|
|
6401
7554
|
message: `Delete ${dir}? This machine loses its identity \u2014 a captain has to approve it again after reinstalling.`,
|
|
6402
7555
|
initialValue: false
|
|
6403
7556
|
});
|
|
6404
7557
|
if (confirmed) {
|
|
6405
|
-
|
|
7558
|
+
fs11.rmSync(dir, { recursive: true, force: true });
|
|
6406
7559
|
purged = true;
|
|
6407
7560
|
}
|
|
6408
7561
|
}
|
|
@@ -6834,10 +7987,10 @@ function action(handler) {
|
|
|
6834
7987
|
try {
|
|
6835
7988
|
process.exitCode = await handler(...args);
|
|
6836
7989
|
} catch (error) {
|
|
6837
|
-
const
|
|
6838
|
-
if (isJson()) process.stderr.write(`${JSON.stringify({ error:
|
|
7990
|
+
const message3 = error instanceof Error ? error.message : String(error);
|
|
7991
|
+
if (isJson()) process.stderr.write(`${JSON.stringify({ error: message3 })}
|
|
6839
7992
|
`);
|
|
6840
|
-
else say.error(
|
|
7993
|
+
else say.error(message3);
|
|
6841
7994
|
process.exitCode = error instanceof CliError ? error.exitCode : 1;
|
|
6842
7995
|
} finally {
|
|
6843
7996
|
await closeFirebase();
|