@algosuite/vo-mcp 0.2.0-beta.7 → 0.2.0-beta.8
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/bin/vo-mcp +5 -3
- package/dist/cli.js +86 -4
- package/dist/cli.js.map +4 -4
- package/dist/index.js +8 -1
- package/dist/index.js.map +3 -3
- package/dist/runner-cli.js +1074 -159
- package/dist/runner-cli.js.map +4 -4
- package/dist/runner-supervisor.js +777 -0
- package/dist/runner-supervisor.js.map +7 -0
- package/package.json +3 -2
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
import { createRequire as __cr } from 'module'; const require = __cr(import.meta.url);
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res, err) => function __init() {
|
|
5
|
+
if (err) throw err[0];
|
|
6
|
+
try {
|
|
7
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
8
|
+
} catch (e) {
|
|
9
|
+
throw err = [e], e;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
var __export = (target, all) => {
|
|
13
|
+
for (var name in all)
|
|
14
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// src/runner/control-plane-auth-stub.mjs
|
|
18
|
+
var control_plane_auth_stub_exports = {};
|
|
19
|
+
__export(control_plane_auth_stub_exports, {
|
|
20
|
+
getFirebaseAuth: () => getFirebaseAuth
|
|
21
|
+
});
|
|
22
|
+
async function getFirebaseAuth() {
|
|
23
|
+
throw new Error(
|
|
24
|
+
"vo-mcp runner: no control-plane credential. Run `vo-mcp login` first \u2014 the runner authenticates with your stored vo_credential (or set VO_CONTROL_PLANE_ADMIN_TOKEN)."
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
var init_control_plane_auth_stub = __esm({
|
|
28
|
+
"src/runner/control-plane-auth-stub.mjs"() {
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// src/runner-supervisor.mjs
|
|
33
|
+
import { spawn } from "node:child_process";
|
|
34
|
+
import { createRequire as createRequire2 } from "node:module";
|
|
35
|
+
import { fileURLToPath } from "node:url";
|
|
36
|
+
import { dirname as dirname2, join as join2 } from "node:path";
|
|
37
|
+
|
|
38
|
+
// ../../scripts/virtual-office/code-runner/control-plane-client.mjs
|
|
39
|
+
var cachedFirebaseToken = null;
|
|
40
|
+
async function resolveBearer(env) {
|
|
41
|
+
const adminToken = env.VO_CONTROL_PLANE_ADMIN_TOKEN;
|
|
42
|
+
if (adminToken) return adminToken;
|
|
43
|
+
if (cachedFirebaseToken) return cachedFirebaseToken;
|
|
44
|
+
const { getFirebaseAuth: getFirebaseAuth2 } = await Promise.resolve().then(() => (init_control_plane_auth_stub(), control_plane_auth_stub_exports));
|
|
45
|
+
const auth = await getFirebaseAuth2({ env });
|
|
46
|
+
if (!auth || !auth.idToken) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
"no control-plane credential: set VO_CONTROL_PLANE_ADMIN_TOKEN, or SMOKE_EMAIL/SMOKE_PASSWORD/SMOKE_API_KEY"
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
cachedFirebaseToken = auth.idToken;
|
|
52
|
+
return cachedFirebaseToken;
|
|
53
|
+
}
|
|
54
|
+
function createControlPlaneClient({
|
|
55
|
+
baseUrl = process.env.VO_CONTROL_PLANE_URL || "",
|
|
56
|
+
env = process.env,
|
|
57
|
+
fetchImpl = fetch
|
|
58
|
+
} = {}) {
|
|
59
|
+
if (!baseUrl) {
|
|
60
|
+
throw new Error("VO_CONTROL_PLANE_URL is required for the code-runner daemon");
|
|
61
|
+
}
|
|
62
|
+
const root = baseUrl.replace(/\/+$/, "");
|
|
63
|
+
async function req(method, path, body) {
|
|
64
|
+
const bearer = await resolveBearer(env);
|
|
65
|
+
return fetchImpl(`${root}${path}`, {
|
|
66
|
+
method,
|
|
67
|
+
headers: {
|
|
68
|
+
"content-type": "application/json",
|
|
69
|
+
authorization: `Bearer ${bearer}`
|
|
70
|
+
},
|
|
71
|
+
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
/**
|
|
76
|
+
* Claim the next pending task. Returns the task or null (empty queue).
|
|
77
|
+
* `repos` (optional `owner/name` list) and `operatorIds` (optional
|
|
78
|
+
* `operator_id` list) scope the claim so this daemon only picks up tasks it
|
|
79
|
+
* serves — the control-plane filters by both (logical AND), so another
|
|
80
|
+
* operator's task never lands on (or bills) this machine.
|
|
81
|
+
*/
|
|
82
|
+
async claim(runnerId2, repos, operatorIds, session = {}) {
|
|
83
|
+
const body = { runner_id: runnerId2 };
|
|
84
|
+
if (Array.isArray(repos) && repos.length > 0) body.repos = repos;
|
|
85
|
+
if (Array.isArray(operatorIds) && operatorIds.length > 0) body.operator_ids = operatorIds;
|
|
86
|
+
if (session.runnerInstanceId) body.runner_instance_id = session.runnerInstanceId;
|
|
87
|
+
if (session.runnerInstanceId && session.reconcileStale) body.reconcile_stale = true;
|
|
88
|
+
const res = await req("POST", "/api/v1/code-task/claim", body);
|
|
89
|
+
if (res.status === 401) {
|
|
90
|
+
cachedFirebaseToken = null;
|
|
91
|
+
throw new Error("claim unauthorized (401)");
|
|
92
|
+
}
|
|
93
|
+
if (!res.ok) throw new Error(`claim failed: HTTP ${res.status}`);
|
|
94
|
+
const json = await res.json();
|
|
95
|
+
return json && json.task ? json.task : null;
|
|
96
|
+
},
|
|
97
|
+
/**
|
|
98
|
+
* Enqueue a new code-task (used by the PR watcher to auto-dispatch a CI fix).
|
|
99
|
+
* Server derives operator/tenant from the daemon's authenticated principal.
|
|
100
|
+
* Returns the created task, or throws on a non-2xx response.
|
|
101
|
+
*/
|
|
102
|
+
async enqueueCodeTask({ repo, prompt, max_budget_usd, max_turns }) {
|
|
103
|
+
const body = { repo, prompt };
|
|
104
|
+
if (typeof max_budget_usd === "number") body.max_budget_usd = max_budget_usd;
|
|
105
|
+
if (typeof max_turns === "number") body.max_turns = max_turns;
|
|
106
|
+
const res = await req("POST", "/api/v1/code-task", body);
|
|
107
|
+
if (res.status === 401) {
|
|
108
|
+
cachedFirebaseToken = null;
|
|
109
|
+
throw new Error("enqueue unauthorized (401)");
|
|
110
|
+
}
|
|
111
|
+
if (!res.ok) throw new Error(`enqueue failed: HTTP ${res.status}`);
|
|
112
|
+
const json = await res.json();
|
|
113
|
+
return json && json.task ? json.task : null;
|
|
114
|
+
},
|
|
115
|
+
/**
|
|
116
|
+
* Resume a failed/cancelled/max-turn partial code-task. The PR watcher uses
|
|
117
|
+
* this after the runner opens a partial draft PR and CI is no longer pending.
|
|
118
|
+
*/
|
|
119
|
+
async resumeCodeTask(taskId) {
|
|
120
|
+
const res = await req("POST", `/api/v1/code-task/${encodeURIComponent(taskId)}/resume`, {});
|
|
121
|
+
if (res.status === 401) {
|
|
122
|
+
cachedFirebaseToken = null;
|
|
123
|
+
throw new Error("resume unauthorized (401)");
|
|
124
|
+
}
|
|
125
|
+
if (!res.ok) throw new Error(`resume failed: HTTP ${res.status}`);
|
|
126
|
+
const json = await res.json();
|
|
127
|
+
return json && json.task ? json.task : null;
|
|
128
|
+
},
|
|
129
|
+
/**
|
|
130
|
+
* Send a CI-green PR through the production verify-before-act merge route.
|
|
131
|
+
* The server inspects the current diff, applies deterministic blockers, runs
|
|
132
|
+
* consensus, records a receipt, and direct-merges only the inspected SHA.
|
|
133
|
+
*/
|
|
134
|
+
async mergeVerifiedPr(prNumber, automationContext) {
|
|
135
|
+
const res = await req("POST", "/api/v1/admin/pr/merge", { prNumber, automationContext });
|
|
136
|
+
if (res.status === 401) {
|
|
137
|
+
cachedFirebaseToken = null;
|
|
138
|
+
throw new Error("gated merge unauthorized (401)");
|
|
139
|
+
}
|
|
140
|
+
const json = await res.json().catch(() => ({}));
|
|
141
|
+
if (res.ok && json?.ok === true) {
|
|
142
|
+
const result = json?.result && typeof json.result === "object" ? json.result : {};
|
|
143
|
+
const status = result.merged === true || result.status === "merged" ? "merged" : result.status === "auto-merge-enabled" || String(result.action || "").includes("auto-merge") ? "queued" : "accepted";
|
|
144
|
+
return {
|
|
145
|
+
status,
|
|
146
|
+
detail: typeof result.detail === "string" ? result.detail : null,
|
|
147
|
+
actionReceiptId: typeof json.action_receipt_id === "string" ? json.action_receipt_id : null
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
if (res.status === 503 && json?.error === "verify_unavailable") {
|
|
151
|
+
return { status: "retry", reason: json.reason || "verification unavailable" };
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
status: "blocked",
|
|
155
|
+
reason: json?.reason || json?.message || json?.error || `HTTP ${res.status}`,
|
|
156
|
+
actionReceiptId: typeof json?.action_receipt_id === "string" ? json.action_receipt_id : null
|
|
157
|
+
};
|
|
158
|
+
},
|
|
159
|
+
/**
|
|
160
|
+
* Append progress / set terminal status. Returns
|
|
161
|
+
* { task } — applied
|
|
162
|
+
* { terminal: true } — task already terminal (operator cancelled): STOP
|
|
163
|
+
*/
|
|
164
|
+
async postProgress(taskId, patch) {
|
|
165
|
+
const res = await req("PATCH", `/api/v1/code-task/${taskId}/progress`, patch);
|
|
166
|
+
if (res.status === 409) return { terminal: true };
|
|
167
|
+
if (res.status === 404) return { terminal: true, missing: true };
|
|
168
|
+
if (!res.ok) throw new Error(`progress failed: HTTP ${res.status}`);
|
|
169
|
+
const json = await res.json();
|
|
170
|
+
return { task: json && json.task };
|
|
171
|
+
},
|
|
172
|
+
/** Read the current task (cancel detection). Null on 404. */
|
|
173
|
+
async getTask(taskId) {
|
|
174
|
+
const res = await req("GET", `/api/v1/code-task/${taskId}`);
|
|
175
|
+
if (res.status === 404) return null;
|
|
176
|
+
if (!res.ok) throw new Error(`getTask failed: HTTP ${res.status}`);
|
|
177
|
+
const json = await res.json();
|
|
178
|
+
return json ? json.task : null;
|
|
179
|
+
},
|
|
180
|
+
/** Fetch bounded, prompt-ready VO knowledge snippets for this task. */
|
|
181
|
+
async getTaskKnowledgeContext(taskId, { query } = {}) {
|
|
182
|
+
const body = {};
|
|
183
|
+
if (typeof query === "string" && query.trim()) body.query = query;
|
|
184
|
+
const res = await req("POST", `/api/v1/code-task/${encodeURIComponent(taskId)}/knowledge-context`, body);
|
|
185
|
+
if (res.status === 401) {
|
|
186
|
+
cachedFirebaseToken = null;
|
|
187
|
+
throw new Error("knowledge-context unauthorized (401)");
|
|
188
|
+
}
|
|
189
|
+
if (res.status === 404) return null;
|
|
190
|
+
if (!res.ok) throw new Error(`knowledge-context failed: HTTP ${res.status}`);
|
|
191
|
+
return res.json();
|
|
192
|
+
},
|
|
193
|
+
/**
|
|
194
|
+
* Report this machine's rolling-7-day Claude Code token usage (the real
|
|
195
|
+
* weekly-capacity gauge) PLUS the operator's real Claude weekly % (when
|
|
196
|
+
* available). The daemon authenticates as admin, so the target `operatorId`
|
|
197
|
+
* is named explicitly. Best-effort; throws on a non-2xx so the caller can
|
|
198
|
+
* log + move on.
|
|
199
|
+
*
|
|
200
|
+
* `tokens` = { input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens }.
|
|
201
|
+
* Optional: `claudeWeeklyPct` (number) + `claudeWeeklyResetsAt` (ISO string | null).
|
|
202
|
+
*/
|
|
203
|
+
async postWeeklyTokens({ operatorId, runnerId: runnerId2, tokens, claudeWeeklyPct, claudeWeeklyResetsAt }) {
|
|
204
|
+
const body = {
|
|
205
|
+
operator_id: operatorId,
|
|
206
|
+
runner_id: runnerId2,
|
|
207
|
+
input_tokens: tokens.input_tokens,
|
|
208
|
+
output_tokens: tokens.output_tokens,
|
|
209
|
+
cache_creation_tokens: tokens.cache_creation_tokens,
|
|
210
|
+
cache_read_tokens: tokens.cache_read_tokens
|
|
211
|
+
};
|
|
212
|
+
if (typeof claudeWeeklyPct === "number") {
|
|
213
|
+
body.claude_weekly_pct = claudeWeeklyPct;
|
|
214
|
+
}
|
|
215
|
+
if (claudeWeeklyResetsAt !== void 0) {
|
|
216
|
+
body.claude_weekly_resets_at = claudeWeeklyResetsAt;
|
|
217
|
+
}
|
|
218
|
+
const res = await req("POST", "/api/v1/weekly-tokens", body);
|
|
219
|
+
if (res.status === 401) {
|
|
220
|
+
cachedFirebaseToken = null;
|
|
221
|
+
throw new Error("weekly-tokens unauthorized (401)");
|
|
222
|
+
}
|
|
223
|
+
if (!res.ok) throw new Error(`weekly-tokens failed: HTTP ${res.status}`);
|
|
224
|
+
return true;
|
|
225
|
+
},
|
|
226
|
+
/**
|
|
227
|
+
* Send a liveness heartbeat (M2). The control-plane upserts it under the
|
|
228
|
+
* authenticated operator so the web shows a TRUE "runner online" signal.
|
|
229
|
+
* Best-effort caller; throws on 401/non-ok so the daemon can log + retry.
|
|
230
|
+
*/
|
|
231
|
+
async postHeartbeat({ runnerId: runnerId2, operatorId, uptimeSec, activeTasks, version, servedRepos, servedOperators, availableAgents, accountUsage }) {
|
|
232
|
+
const body = { runner_id: runnerId2 };
|
|
233
|
+
if (operatorId) body.operator_id = operatorId;
|
|
234
|
+
if (typeof uptimeSec === "number") body.uptime_sec = uptimeSec;
|
|
235
|
+
if (typeof activeTasks === "number") body.active_tasks = activeTasks;
|
|
236
|
+
if (version) body.version = version;
|
|
237
|
+
if (Array.isArray(servedRepos) && servedRepos.length > 0) body.served_repos = servedRepos;
|
|
238
|
+
if (Array.isArray(servedOperators) && servedOperators.length > 0) {
|
|
239
|
+
body.served_operator_ids = servedOperators;
|
|
240
|
+
}
|
|
241
|
+
if (Array.isArray(availableAgents) && availableAgents.length > 0) {
|
|
242
|
+
body.available_agents = availableAgents;
|
|
243
|
+
}
|
|
244
|
+
if (Array.isArray(accountUsage) && accountUsage.length > 0) {
|
|
245
|
+
body.account_usage = accountUsage;
|
|
246
|
+
}
|
|
247
|
+
const res = await req("POST", "/api/v1/runner/heartbeat", body);
|
|
248
|
+
if (res.status === 401) {
|
|
249
|
+
cachedFirebaseToken = null;
|
|
250
|
+
throw new Error("heartbeat unauthorized (401)");
|
|
251
|
+
}
|
|
252
|
+
if (!res.ok) throw new Error(`heartbeat failed: HTTP ${res.status}`);
|
|
253
|
+
return true;
|
|
254
|
+
},
|
|
255
|
+
/** Poll one authenticated runner's durable Mission Control action queue. */
|
|
256
|
+
async pollRunnerControl({ runnerId: runnerId2, operatorId }) {
|
|
257
|
+
const body = { runner_id: runnerId2 };
|
|
258
|
+
if (operatorId) body.operator_id = operatorId;
|
|
259
|
+
const res = await req("POST", "/api/v1/runner/control/poll", body);
|
|
260
|
+
if (res.status === 401) {
|
|
261
|
+
cachedFirebaseToken = null;
|
|
262
|
+
throw new Error("runner control poll unauthorized (401)");
|
|
263
|
+
}
|
|
264
|
+
if (!res.ok) throw new Error(`runner control poll failed: HTTP ${res.status}`);
|
|
265
|
+
const json = await res.json();
|
|
266
|
+
const action = json?.action;
|
|
267
|
+
return action && typeof action.action_id === "string" && action.action_id ? { ...action, actionId: action.action_id } : null;
|
|
268
|
+
},
|
|
269
|
+
/** Acknowledge a maintenance action after the host has restarted the child. */
|
|
270
|
+
async completeRunnerControl(actionId, { runnerId: runnerId2, operatorId, status, detail }) {
|
|
271
|
+
const body = { runner_id: runnerId2, status };
|
|
272
|
+
if (operatorId) body.operator_id = operatorId;
|
|
273
|
+
if (detail) body.detail = detail;
|
|
274
|
+
const res = await req("POST", `/api/v1/runner/control/${encodeURIComponent(actionId)}/complete`, body);
|
|
275
|
+
if (res.status === 401) {
|
|
276
|
+
cachedFirebaseToken = null;
|
|
277
|
+
throw new Error("runner control completion unauthorized (401)");
|
|
278
|
+
}
|
|
279
|
+
if (!res.ok) throw new Error(`runner control completion failed: HTTP ${res.status}`);
|
|
280
|
+
const json = await res.json();
|
|
281
|
+
return json?.action || null;
|
|
282
|
+
},
|
|
283
|
+
/**
|
|
284
|
+
* Mint a short-lived (~1h), repo-scoped GitHub App installation token for
|
|
285
|
+
* THIS runner's operator (M3). The control-plane keys the mint on the
|
|
286
|
+
* authenticated operator (ctx.operator_id), so the token covers only that
|
|
287
|
+
* operator's installation.
|
|
288
|
+
*
|
|
289
|
+
* Returns { token, expiresAt } on success. In legacy/admin mode it returns
|
|
290
|
+
* null on a miss so the caller may use ambient `gh`. In scoped-operator mode
|
|
291
|
+
* callers pass `{ required: true }`, which fails closed instead of letting a
|
|
292
|
+
* missing/mis-scoped installation fall through to the runner machine's `gh`.
|
|
293
|
+
*
|
|
294
|
+
* The minted token is only usable for push + PR if the GitHub App grants
|
|
295
|
+
* BOTH `Contents: write` (git push) AND `Pull requests: write` (gh pr
|
|
296
|
+
* create) — see docs/vo/github-app-setup-2026-06-18.md. A token missing
|
|
297
|
+
* either scope fails at push (→ ambient fallback) or at `gh pr create`.
|
|
298
|
+
*/
|
|
299
|
+
async getInstallationToken({ required = false } = {}) {
|
|
300
|
+
const fail = (reason) => {
|
|
301
|
+
if (required) throw new Error(`installation-token required: ${reason}`);
|
|
302
|
+
return null;
|
|
303
|
+
};
|
|
304
|
+
try {
|
|
305
|
+
const res = await req("POST", "/api/v1/github/installation-token", {});
|
|
306
|
+
if (!res.ok) return fail(`HTTP ${res.status}`);
|
|
307
|
+
const json = await res.json();
|
|
308
|
+
if (!json || !json.token) return fail("missing token");
|
|
309
|
+
return { token: json.token, expiresAt: json.expires_at || null };
|
|
310
|
+
} catch (err) {
|
|
311
|
+
if (required) throw err;
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
/**
|
|
316
|
+
* Read the operator's dispatch-mode config (Fast→Ultracode effort setting).
|
|
317
|
+
* Returns the mode string ('fast'|'standard'|'deep'|'ultra'|'ultracode'),
|
|
318
|
+
* defaulting to 'standard' on any error. Never throws — best-effort.
|
|
319
|
+
*/
|
|
320
|
+
async getDispatchMode() {
|
|
321
|
+
try {
|
|
322
|
+
const res = await req("GET", "/api/v1/dispatch-mode-config");
|
|
323
|
+
if (!res.ok) return "standard";
|
|
324
|
+
const json = await res.json();
|
|
325
|
+
return json?.dispatchMode || "standard";
|
|
326
|
+
} catch {
|
|
327
|
+
return "standard";
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ../../scripts/virtual-office/code-runner/runner-host-maintenance.mjs
|
|
334
|
+
import { spawnSync } from "node:child_process";
|
|
335
|
+
var DEFAULT_RUNNER_PACKAGE = "@algosuite/vo-mcp@beta";
|
|
336
|
+
var PACKAGE_SPEC_RE = /^@algosuite\/vo-mcp@(beta|latest|\d+(?:\.\d+){0,2}(?:-[\w.-]+)?)$/u;
|
|
337
|
+
function buildMaintenanceCommand(kind, { platform = process.platform, packageSpec = DEFAULT_RUNNER_PACKAGE } = {}) {
|
|
338
|
+
if (!["update", "reinstall"].includes(kind)) return null;
|
|
339
|
+
if (!PACKAGE_SPEC_RE.test(packageSpec)) throw new Error("unsafe runner package spec");
|
|
340
|
+
const command = platform === "win32" ? "npm.cmd" : "npm";
|
|
341
|
+
const args = ["install", "-g", packageSpec];
|
|
342
|
+
if (kind === "reinstall") args.push("--force");
|
|
343
|
+
return { command, args };
|
|
344
|
+
}
|
|
345
|
+
function runHostMaintenance(kind, {
|
|
346
|
+
platform = process.platform,
|
|
347
|
+
packageSpec = DEFAULT_RUNNER_PACKAGE,
|
|
348
|
+
env = process.env,
|
|
349
|
+
spawn: spawn2 = spawnSync,
|
|
350
|
+
log = () => {
|
|
351
|
+
}
|
|
352
|
+
} = {}) {
|
|
353
|
+
if (kind === "reconnect") return { ok: true, status: 0, command: null, args: [] };
|
|
354
|
+
const command = buildMaintenanceCommand(kind, { platform, packageSpec });
|
|
355
|
+
if (!command) return { ok: false, status: 2, command: null, args: [] };
|
|
356
|
+
log(`runner maintenance: ${command.command} ${command.args.join(" ")}`);
|
|
357
|
+
const result = spawn2(command.command, command.args, { stdio: "inherit", env, shell: false });
|
|
358
|
+
const status = typeof result.status === "number" ? result.status : 1;
|
|
359
|
+
return { ok: status === 0, status, command: command.command, args: command.args };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// src/cloud/credential-store.ts
|
|
363
|
+
import { homedir } from "node:os";
|
|
364
|
+
import { join, dirname } from "node:path";
|
|
365
|
+
import {
|
|
366
|
+
existsSync,
|
|
367
|
+
mkdirSync,
|
|
368
|
+
readFileSync,
|
|
369
|
+
writeFileSync,
|
|
370
|
+
chmodSync,
|
|
371
|
+
rmSync
|
|
372
|
+
} from "node:fs";
|
|
373
|
+
|
|
374
|
+
// src/cloud/keychain.ts
|
|
375
|
+
import { createRequire } from "node:module";
|
|
376
|
+
var SERVICE = "vo-mcp";
|
|
377
|
+
var ACCOUNT = "refresh-credential";
|
|
378
|
+
var cached;
|
|
379
|
+
function loadKeyring() {
|
|
380
|
+
if (cached !== void 0) return cached;
|
|
381
|
+
try {
|
|
382
|
+
const req = createRequire(import.meta.url);
|
|
383
|
+
const mod = req("@napi-rs/keyring");
|
|
384
|
+
cached = mod && typeof mod.Entry === "function" ? mod : null;
|
|
385
|
+
} catch {
|
|
386
|
+
cached = null;
|
|
387
|
+
}
|
|
388
|
+
return cached;
|
|
389
|
+
}
|
|
390
|
+
function keychainAvailable() {
|
|
391
|
+
return loadKeyring() !== null;
|
|
392
|
+
}
|
|
393
|
+
function keychainGet() {
|
|
394
|
+
const k = loadKeyring();
|
|
395
|
+
if (!k) return null;
|
|
396
|
+
try {
|
|
397
|
+
return new k.Entry(SERVICE, ACCOUNT).getPassword();
|
|
398
|
+
} catch {
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
function keychainSet(secret) {
|
|
403
|
+
const k = loadKeyring();
|
|
404
|
+
if (!k) return false;
|
|
405
|
+
try {
|
|
406
|
+
new k.Entry(SERVICE, ACCOUNT).setPassword(secret);
|
|
407
|
+
return true;
|
|
408
|
+
} catch {
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
function keychainDelete() {
|
|
413
|
+
const k = loadKeyring();
|
|
414
|
+
if (!k) return false;
|
|
415
|
+
try {
|
|
416
|
+
return new k.Entry(SERVICE, ACCOUNT).deletePassword();
|
|
417
|
+
} catch {
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// src/cloud/credential-store.ts
|
|
423
|
+
var realKeychain = {
|
|
424
|
+
available: keychainAvailable,
|
|
425
|
+
get: keychainGet,
|
|
426
|
+
set: keychainSet,
|
|
427
|
+
delete: keychainDelete
|
|
428
|
+
};
|
|
429
|
+
function credentialPath(env = process.env) {
|
|
430
|
+
const override = env["VO_MCP_CREDENTIALS_PATH"]?.trim();
|
|
431
|
+
if (override) return override;
|
|
432
|
+
return join(homedir(), ".config", "vo-mcp", "credentials.json");
|
|
433
|
+
}
|
|
434
|
+
function keychainEnabled(env, keychain) {
|
|
435
|
+
const disabled = (env["VO_MCP_DISABLE_KEYCHAIN"] ?? "").trim().toLowerCase();
|
|
436
|
+
if (disabled === "1" || disabled === "true" || disabled === "yes") return false;
|
|
437
|
+
return keychain.available();
|
|
438
|
+
}
|
|
439
|
+
function deserialize(raw) {
|
|
440
|
+
try {
|
|
441
|
+
const parsed = JSON.parse(raw);
|
|
442
|
+
const refresh = typeof parsed.refresh_token === "string" ? parsed.refresh_token.trim() : "";
|
|
443
|
+
const apiKey = typeof parsed.api_key === "string" ? parsed.api_key.trim() : "";
|
|
444
|
+
const voCred = typeof parsed.vo_credential === "string" ? parsed.vo_credential.trim() : "";
|
|
445
|
+
if (!voCred && (!refresh || !apiKey)) return null;
|
|
446
|
+
return {
|
|
447
|
+
...refresh ? { refresh_token: refresh } : {},
|
|
448
|
+
...apiKey ? { api_key: apiKey } : {},
|
|
449
|
+
...voCred ? { vo_credential: voCred } : {},
|
|
450
|
+
...typeof parsed.vo_credential_expires_at === "string" ? { vo_credential_expires_at: parsed.vo_credential_expires_at } : {},
|
|
451
|
+
...typeof parsed.email === "string" ? { email: parsed.email } : {},
|
|
452
|
+
...typeof parsed.stored_at === "string" ? { stored_at: parsed.stored_at } : {}
|
|
453
|
+
};
|
|
454
|
+
} catch {
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
function readFromFile(env) {
|
|
459
|
+
try {
|
|
460
|
+
const p = credentialPath(env);
|
|
461
|
+
if (!existsSync(p)) return null;
|
|
462
|
+
return deserialize(readFileSync(p, "utf8"));
|
|
463
|
+
} catch {
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
function readStoredCredential(env = process.env, keychain = realKeychain) {
|
|
468
|
+
if (keychainEnabled(env, keychain)) {
|
|
469
|
+
const raw = keychain.get();
|
|
470
|
+
const fromKeychain = raw ? deserialize(raw) : null;
|
|
471
|
+
if (fromKeychain) return fromKeychain;
|
|
472
|
+
}
|
|
473
|
+
return readFromFile(env);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// src/runner/supervisor-child-env.mjs
|
|
477
|
+
import { hostname as systemHostname } from "node:os";
|
|
478
|
+
|
|
479
|
+
// src/runner-readiness.mjs
|
|
480
|
+
function failed({ paired = false, operatorId = null, tenantId = null, githubReady = null, error, message }) {
|
|
481
|
+
return { ok: false, paired, operatorId, tenantId, githubReady, error, message };
|
|
482
|
+
}
|
|
483
|
+
async function responseBody(response) {
|
|
484
|
+
try {
|
|
485
|
+
const value = await response.json();
|
|
486
|
+
return value && typeof value === "object" ? value : {};
|
|
487
|
+
} catch {
|
|
488
|
+
return {};
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
function serverMessage(body, fallback) {
|
|
492
|
+
return typeof body.message === "string" && body.message.trim() ? body.message.trim() : fallback;
|
|
493
|
+
}
|
|
494
|
+
async function fetchWithTimeout(fetchImpl, url, init, timeoutMs) {
|
|
495
|
+
const controller = new AbortController();
|
|
496
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
497
|
+
try {
|
|
498
|
+
return await fetchImpl(url, { ...init, signal: controller.signal });
|
|
499
|
+
} finally {
|
|
500
|
+
clearTimeout(timer);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
async function probeRunnerReadiness({
|
|
504
|
+
controlPlaneUrl,
|
|
505
|
+
token,
|
|
506
|
+
fetchImpl = fetch,
|
|
507
|
+
requireGithub = false,
|
|
508
|
+
timeoutMs = 1e4
|
|
509
|
+
}) {
|
|
510
|
+
const base = controlPlaneUrl.replace(/\/+$/u, "");
|
|
511
|
+
const headers = { authorization: `Bearer ${token}` };
|
|
512
|
+
let identityResponse;
|
|
513
|
+
try {
|
|
514
|
+
identityResponse = await fetchWithTimeout(
|
|
515
|
+
fetchImpl,
|
|
516
|
+
`${base}/api/v1/auth/me`,
|
|
517
|
+
{ headers },
|
|
518
|
+
timeoutMs
|
|
519
|
+
);
|
|
520
|
+
} catch (error) {
|
|
521
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
522
|
+
return failed({
|
|
523
|
+
error: "control_plane_unreachable",
|
|
524
|
+
message: `Virtual Office could not be reached: ${detail}`
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
const identity = await responseBody(identityResponse);
|
|
528
|
+
if (!identityResponse.ok) {
|
|
529
|
+
return failed({
|
|
530
|
+
error: "credential_rejected",
|
|
531
|
+
message: "The saved pairing is expired or revoked. Pair this computer again."
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
if (identity.provisioned !== true || identity.role !== "operator" || typeof identity.operator_id !== "string" || !identity.operator_id.trim() || typeof identity.tenant_id !== "string" || !identity.tenant_id.trim()) {
|
|
535
|
+
return failed({
|
|
536
|
+
error: "operator_identity_missing",
|
|
537
|
+
message: "The pairing credential is valid but has no operator identity. Pair this computer again."
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
const operatorId = identity.operator_id.trim();
|
|
541
|
+
const tenantId = identity.tenant_id.trim();
|
|
542
|
+
if (!requireGithub) {
|
|
543
|
+
return {
|
|
544
|
+
ok: true,
|
|
545
|
+
paired: true,
|
|
546
|
+
operatorId,
|
|
547
|
+
tenantId,
|
|
548
|
+
githubReady: null,
|
|
549
|
+
error: null,
|
|
550
|
+
message: "Paired to Virtual Office."
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
let githubResponse;
|
|
554
|
+
try {
|
|
555
|
+
githubResponse = await fetchWithTimeout(
|
|
556
|
+
fetchImpl,
|
|
557
|
+
`${base}/api/v1/github/installation-token`,
|
|
558
|
+
{
|
|
559
|
+
method: "POST",
|
|
560
|
+
headers: { ...headers, "content-type": "application/json" },
|
|
561
|
+
body: "{}"
|
|
562
|
+
},
|
|
563
|
+
timeoutMs
|
|
564
|
+
);
|
|
565
|
+
} catch (error) {
|
|
566
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
567
|
+
return failed({
|
|
568
|
+
paired: true,
|
|
569
|
+
operatorId,
|
|
570
|
+
tenantId,
|
|
571
|
+
githubReady: false,
|
|
572
|
+
error: "github_preflight_unreachable",
|
|
573
|
+
message: `GitHub publication readiness could not be checked: ${detail}`
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
const github = await responseBody(githubResponse);
|
|
577
|
+
if (!githubResponse.ok || typeof github.token !== "string" || !github.token) {
|
|
578
|
+
const error = typeof github.error === "string" && github.error ? github.error : "github_not_ready";
|
|
579
|
+
return failed({
|
|
580
|
+
paired: true,
|
|
581
|
+
operatorId,
|
|
582
|
+
tenantId,
|
|
583
|
+
githubReady: false,
|
|
584
|
+
error,
|
|
585
|
+
message: serverMessage(github, `GitHub publication preflight failed (HTTP ${githubResponse.status}).`)
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
return {
|
|
589
|
+
ok: true,
|
|
590
|
+
paired: true,
|
|
591
|
+
operatorId,
|
|
592
|
+
tenantId,
|
|
593
|
+
githubReady: true,
|
|
594
|
+
error: null,
|
|
595
|
+
message: "Paired and ready to publish through the Algosuite GitHub App."
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
function pairedOperatorScope(readiness) {
|
|
599
|
+
return readiness?.ok === true && readiness?.paired === true && typeof readiness.operatorId === "string" && readiness.operatorId.trim() ? readiness.operatorId.trim() : null;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// src/runner/supervisor-child-env.mjs
|
|
603
|
+
function resolveSupervisorRunnerId(env = {}, hostname = systemHostname) {
|
|
604
|
+
const explicit = String(env.VO_CODE_RUNNER_ID || "").trim();
|
|
605
|
+
return explicit || `vo-code-runner-${hostname()}`;
|
|
606
|
+
}
|
|
607
|
+
function buildSupervisorChildEnv({
|
|
608
|
+
baseEnv = {},
|
|
609
|
+
controlPlaneUrl,
|
|
610
|
+
explicitAdminToken = null,
|
|
611
|
+
pairedOperatorId = null
|
|
612
|
+
} = {}) {
|
|
613
|
+
const childEnv = {
|
|
614
|
+
...baseEnv,
|
|
615
|
+
VO_CONTROL_PLANE_URL: controlPlaneUrl
|
|
616
|
+
};
|
|
617
|
+
const adminToken = typeof explicitAdminToken === "string" ? explicitAdminToken.trim() : "";
|
|
618
|
+
const operatorId = typeof pairedOperatorId === "string" ? pairedOperatorId.trim() : "";
|
|
619
|
+
if (adminToken) childEnv.VO_CONTROL_PLANE_ADMIN_TOKEN = adminToken;
|
|
620
|
+
else delete childEnv.VO_CONTROL_PLANE_ADMIN_TOKEN;
|
|
621
|
+
if (operatorId) childEnv.VO_CODE_RUNNER_OPERATOR_IDS = operatorId;
|
|
622
|
+
return childEnv;
|
|
623
|
+
}
|
|
624
|
+
async function prepareSupervisorAuth({
|
|
625
|
+
baseEnv = {},
|
|
626
|
+
storedCredential = null,
|
|
627
|
+
controlPlaneUrl,
|
|
628
|
+
probeReadiness = probeRunnerReadiness
|
|
629
|
+
} = {}) {
|
|
630
|
+
const explicitAdminToken = baseEnv.VO_CONTROL_PLANE_ADMIN_TOKEN?.trim();
|
|
631
|
+
const token = explicitAdminToken || storedCredential?.vo_credential;
|
|
632
|
+
if (!token) throw new Error("runner is not paired; run `vo-mcp pair` once on this host");
|
|
633
|
+
let operatorId = String(baseEnv.VO_CODE_RUNNER_OPERATOR_IDS || "").split(/[\s,]+/u).filter(Boolean)[0] || void 0;
|
|
634
|
+
if (!explicitAdminToken) {
|
|
635
|
+
const readiness = await probeReadiness({ controlPlaneUrl, token, requireGithub: true });
|
|
636
|
+
if (!readiness.ok) throw new Error(`runner readiness failed: ${readiness.message}`);
|
|
637
|
+
operatorId = pairedOperatorScope(readiness) || void 0;
|
|
638
|
+
if (!operatorId) throw new Error("runner readiness failed: paired operator scope is missing");
|
|
639
|
+
}
|
|
640
|
+
const childEnv = buildSupervisorChildEnv({
|
|
641
|
+
baseEnv,
|
|
642
|
+
controlPlaneUrl,
|
|
643
|
+
explicitAdminToken,
|
|
644
|
+
pairedOperatorId: explicitAdminToken ? null : operatorId
|
|
645
|
+
});
|
|
646
|
+
const clientEnv = {
|
|
647
|
+
...baseEnv,
|
|
648
|
+
VO_CONTROL_PLANE_ADMIN_TOKEN: token,
|
|
649
|
+
VO_CONTROL_PLANE_URL: controlPlaneUrl,
|
|
650
|
+
...operatorId ? { VO_CODE_RUNNER_OPERATOR_IDS: operatorId } : {}
|
|
651
|
+
};
|
|
652
|
+
return { childEnv, clientEnv, operatorId };
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// src/runner-supervisor.mjs
|
|
656
|
+
var DEFAULT_CONTROL_PLANE_URL = "https://vo-control-plane-bzjphrajaq-uc.a.run.app";
|
|
657
|
+
var POLL_MS = 5e3;
|
|
658
|
+
var CHILD_START_MS = 1500;
|
|
659
|
+
var childEntry = join2(dirname2(fileURLToPath(import.meta.url)), "runner-cli.js");
|
|
660
|
+
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
661
|
+
var runnerId = resolveSupervisorRunnerId(process.env);
|
|
662
|
+
function packageVersion() {
|
|
663
|
+
try {
|
|
664
|
+
return createRequire2(import.meta.url)("../package.json").version || "unknown";
|
|
665
|
+
} catch {
|
|
666
|
+
return "unknown";
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
function spawnChild(childEnv) {
|
|
670
|
+
return spawn(process.execPath, [childEntry], {
|
|
671
|
+
env: childEnv,
|
|
672
|
+
stdio: "inherit",
|
|
673
|
+
windowsHide: true
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
async function localStatus() {
|
|
677
|
+
try {
|
|
678
|
+
const port = Number(process.env.VO_CODE_RUNNER_CONTROL_PORT || 7787);
|
|
679
|
+
const response = await fetch(`http://127.0.0.1:${port}/status`, { signal: AbortSignal.timeout(800) });
|
|
680
|
+
if (!response.ok) return null;
|
|
681
|
+
const body = await response.json();
|
|
682
|
+
return body?.ok === true ? body : null;
|
|
683
|
+
} catch {
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
async function waitForLocalRunner(child) {
|
|
688
|
+
const deadline = Date.now() + 15e3;
|
|
689
|
+
while (Date.now() < deadline) {
|
|
690
|
+
if (child.exitCode !== null) return false;
|
|
691
|
+
if ((await localStatus())?.running === true) return true;
|
|
692
|
+
await sleep(500);
|
|
693
|
+
}
|
|
694
|
+
return false;
|
|
695
|
+
}
|
|
696
|
+
async function stopChild(child) {
|
|
697
|
+
if (!child || child.exitCode !== null) return;
|
|
698
|
+
child.kill(process.platform === "win32" ? void 0 : "SIGTERM");
|
|
699
|
+
await Promise.race([
|
|
700
|
+
new Promise((resolve) => child.once("exit", resolve)),
|
|
701
|
+
sleep(15e3)
|
|
702
|
+
]);
|
|
703
|
+
if (child.exitCode === null) child.kill("SIGKILL");
|
|
704
|
+
}
|
|
705
|
+
async function main() {
|
|
706
|
+
const stored = readStoredCredential();
|
|
707
|
+
const controlPlaneUrl = process.env.VO_CONTROL_PLANE_URL || DEFAULT_CONTROL_PLANE_URL;
|
|
708
|
+
const { childEnv, clientEnv, operatorId } = await prepareSupervisorAuth({
|
|
709
|
+
baseEnv: process.env,
|
|
710
|
+
storedCredential: stored,
|
|
711
|
+
controlPlaneUrl
|
|
712
|
+
});
|
|
713
|
+
const client = createControlPlaneClient({ baseUrl: controlPlaneUrl, env: clientEnv });
|
|
714
|
+
let child = spawnChild(childEnv);
|
|
715
|
+
let stopping = false;
|
|
716
|
+
let handling = false;
|
|
717
|
+
const respawn = () => {
|
|
718
|
+
if (!stopping && !handling && (!child || child.exitCode !== null)) child = spawnChild(childEnv);
|
|
719
|
+
};
|
|
720
|
+
child.on("exit", () => setTimeout(respawn, 2e3));
|
|
721
|
+
const shutdown = async () => {
|
|
722
|
+
if (stopping) return;
|
|
723
|
+
stopping = true;
|
|
724
|
+
await stopChild(child);
|
|
725
|
+
};
|
|
726
|
+
process.on("SIGINT", () => {
|
|
727
|
+
void shutdown().finally(() => process.exit(0));
|
|
728
|
+
});
|
|
729
|
+
process.on("SIGTERM", () => {
|
|
730
|
+
void shutdown().finally(() => process.exit(0));
|
|
731
|
+
});
|
|
732
|
+
while (!stopping) {
|
|
733
|
+
try {
|
|
734
|
+
const action = await client.pollRunnerControl({ runnerId, ...operatorId ? { operatorId } : {} });
|
|
735
|
+
if (!action) {
|
|
736
|
+
await sleep(POLL_MS);
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
handling = true;
|
|
740
|
+
const beforeStop = await localStatus();
|
|
741
|
+
if (beforeStop && Number(beforeStop.activeTasks || 0) > 0) {
|
|
742
|
+
await client.completeRunnerControl(action.actionId, {
|
|
743
|
+
runnerId,
|
|
744
|
+
...operatorId ? { operatorId } : {},
|
|
745
|
+
status: "failed",
|
|
746
|
+
detail: `deferred safely: ${beforeStop.activeTasks} active task(s); retry when the runner is idle`
|
|
747
|
+
});
|
|
748
|
+
handling = false;
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
await stopChild(child);
|
|
752
|
+
const result = runHostMaintenance(action.kind, {
|
|
753
|
+
env: clientEnv,
|
|
754
|
+
log: (message) => console.warn(`[vo-runner supervisor] ${message}`)
|
|
755
|
+
});
|
|
756
|
+
child = spawnChild(childEnv);
|
|
757
|
+
await sleep(CHILD_START_MS);
|
|
758
|
+
if (child.exitCode !== null || !await waitForLocalRunner(child)) result.ok = false;
|
|
759
|
+
await client.completeRunnerControl(action.actionId, {
|
|
760
|
+
runnerId,
|
|
761
|
+
...operatorId ? { operatorId } : {},
|
|
762
|
+
status: result.ok ? "succeeded" : "failed",
|
|
763
|
+
detail: result.ok ? `runner ${packageVersion()} reconnected` : `maintenance exited ${result.status}`
|
|
764
|
+
});
|
|
765
|
+
handling = false;
|
|
766
|
+
} catch (error) {
|
|
767
|
+
console.error(`[vo-runner supervisor] ${error instanceof Error ? error.message : String(error)}`);
|
|
768
|
+
handling = false;
|
|
769
|
+
await sleep(POLL_MS);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
main().catch((error) => {
|
|
774
|
+
console.error(`[vo-runner supervisor] fatal: ${error instanceof Error ? error.message : String(error)}`);
|
|
775
|
+
process.exitCode = 1;
|
|
776
|
+
});
|
|
777
|
+
//# sourceMappingURL=runner-supervisor.js.map
|