@minhspark/codex-mcp-bridge 1.25.3 → 1.26.0
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/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +31 -0
- package/README.md +45 -20
- package/docs/new-macos-setup.md +47 -21
- package/docs/new-windows-setup.md +34 -9
- package/docs/project-onboarding.md +21 -8
- package/package.json +2 -1
- package/plugin.json +1 -1
- package/scripts/background-relay-hidden.vbs +11 -0
- package/scripts/install-antigravity-bridge.mjs +26 -14
- package/scripts/install-background-relay.mjs +428 -0
- package/scripts/install-native-relay.mjs +1 -1
- package/scripts/setup-background-relay.mjs +58 -0
- package/scripts/worker-relay.mjs +41 -0
- package/src/antigravity-bridge.mjs +18 -3
- package/src/antigravity-cli.mjs +13 -2
- package/src/antigravity-outbound-bridge.mjs +27 -6
- package/src/antigravity-outbound-targets.mjs +59 -4
- package/src/background-antigravity-worker.mjs +192 -0
- package/src/background-claude-worker.mjs +240 -0
- package/src/background-codex-worker.mjs +344 -0
- package/src/background-relay-client.mjs +107 -0
- package/src/background-relay-config.mjs +98 -0
- package/src/background-relay-daemon.mjs +60 -0
- package/src/background-relay-mcp.mjs +45 -0
- package/src/background-relay-protocol.mjs +54 -0
- package/src/background-relay-registration.mjs +198 -0
- package/src/background-relay-service.mjs +311 -0
- package/src/background-relay-storage.mjs +203 -0
- package/src/background-relay-storage.ps1 +64 -0
- package/src/background-relay.mjs +373 -0
- package/src/claude-bridge.mjs +6 -3
- package/src/index.mjs +7 -2
- package/src/mcp-supervisor.mjs +17 -11
- package/src/native-relay-companion.mjs +1 -1
- package/src/reload-control.mjs +2 -2
- package/src/setup.mjs +26 -9
- package/src/windows-pipe-acl.ps1 +1 -1
|
@@ -7,6 +7,7 @@ import { applyStdioMcpRegistration, stdioMcpRegistration } from "../src/codex-mc
|
|
|
7
7
|
import { isMainModule, stableInstallPath, stableNodeBin } from "../src/install-paths.mjs";
|
|
8
8
|
import { homeDir, resolveCodexBin, spawnEnv } from "../src/platform.mjs";
|
|
9
9
|
import { exitForVersionRequest } from "../src/cli-version.mjs";
|
|
10
|
+
import { createReleaseSnapshot, snapshotRoot } from "../src/release-snapshot.mjs";
|
|
10
11
|
|
|
11
12
|
const NAME = "antigravity-bridge";
|
|
12
13
|
const SIDECAR = "codex-bridge";
|
|
@@ -59,17 +60,21 @@ export function validateCodexExecutable(executable, { platform = process.platfor
|
|
|
59
60
|
return resolved;
|
|
60
61
|
}
|
|
61
62
|
|
|
62
|
-
function ownEntry(entry, expected, label, retarget = false) {
|
|
63
|
+
function ownEntry(entry, expected, label, retarget = false, supervised = false) {
|
|
63
64
|
if (entry === undefined || entry === null) return;
|
|
64
65
|
object(entry, label);
|
|
65
66
|
const script = entry.args?.[0];
|
|
66
|
-
|
|
67
|
+
const managed = supervised && Array.isArray(entry.args) && entry.args.length === 2
|
|
68
|
+
&& typeof script === "string" && path.basename(script) === "mcp-supervisor.mjs" && entry.args[1] === path.basename(expected);
|
|
69
|
+
if (!Array.isArray(entry.args) || !managed && entry.args.length !== 1 || typeof script !== "string") {
|
|
67
70
|
throw new Error(`${label} is already configured with a different entrypoint; it was not changed`);
|
|
68
71
|
}
|
|
69
|
-
|
|
72
|
+
const target = managed ? path.join(path.dirname(expected), "mcp-supervisor.mjs") : expected;
|
|
73
|
+
if (path.resolve(script) === path.resolve(target) && !managed) return;
|
|
70
74
|
const source = path.dirname(script);
|
|
71
|
-
if (!
|
|
72
|
-
|| path.basename(
|
|
75
|
+
if (!path.isAbsolute(script) || script.includes("\0") || path.resolve(script) !== path.resolve(target)
|
|
76
|
+
&& (!retarget || path.basename(script) !== path.basename(target)
|
|
77
|
+
|| path.basename(source) !== "src" || path.basename(path.dirname(source)).toLowerCase() !== "codex-mcp-bridge")) {
|
|
73
78
|
throw new Error(`${label} is already configured with a different entrypoint; it was not changed`);
|
|
74
79
|
}
|
|
75
80
|
const command = entry.command;
|
|
@@ -91,31 +96,35 @@ export function planAntigravityInstallation({ home = homeDir(), codeConfigDir =
|
|
|
91
96
|
const entry = path.join(root, "src", "antigravity-bridge.mjs");
|
|
92
97
|
const sidecarEntry = path.join(root, "src", "antigravity-sidecar.mjs");
|
|
93
98
|
const outboundEntry = path.join(root, "src", "antigravity-outbound-bridge.mjs");
|
|
99
|
+
const supervisorEntry = path.join(root, "src", "mcp-supervisor.mjs");
|
|
94
100
|
const edits = [];
|
|
95
101
|
const notices = [];
|
|
102
|
+
const runtimeCaches = new Set();
|
|
96
103
|
let backgroundPreference = null;
|
|
97
104
|
const retarget = setup && !remove;
|
|
98
105
|
let codex = null;
|
|
99
106
|
if (targets.includes("codex")) {
|
|
100
107
|
if (codexExisting && codexExisting.transport?.type !== "stdio") throw new Error("The existing Antigravity MCP transport is not stdio");
|
|
101
|
-
ownEntry(codexExisting?.transport, entry, "Codex Antigravity bridge", retarget);
|
|
108
|
+
ownEntry(codexExisting?.transport, entry, "Codex Antigravity bridge", retarget, true);
|
|
102
109
|
const existingCaller = codexExisting?.transport?.env?.ANTIGRAVITY_BRIDGE_CALLER;
|
|
103
110
|
if (existingCaller !== undefined && existingCaller !== "codex") throw new Error("The existing Codex bridge has a conflicting caller identity");
|
|
104
|
-
codex = remove ? { remove: Boolean(codexExisting) } : stdioMcpRegistration({ name: NAME, existing: codexExisting, node, entry, envOverrides: { ANTIGRAVITY_BRIDGE_CALLER: "codex" } });
|
|
111
|
+
codex = remove ? { remove: Boolean(codexExisting) } : stdioMcpRegistration({ name: NAME, existing: codexExisting, node, entry: supervisorEntry, entryArgs: [path.basename(entry)], envOverrides: { ANTIGRAVITY_BRIDGE_CALLER: "codex" } });
|
|
112
|
+
if (!remove) runtimeCaches.add(snapshotRoot({ ...process.env, ...codex.environment }));
|
|
105
113
|
}
|
|
106
114
|
if (targets.includes("claude")) {
|
|
107
115
|
const original = snapshot(path.join(codeConfigDir, ".claude.json"));
|
|
108
116
|
const value = structuredClone(original.value);
|
|
109
117
|
if (value.mcpServers !== undefined) object(value.mcpServers, "Claude mcpServers");
|
|
110
118
|
const existing = value.mcpServers?.[NAME];
|
|
111
|
-
ownEntry(existing, entry, "Claude Antigravity bridge", retarget);
|
|
119
|
+
ownEntry(existing, entry, "Claude Antigravity bridge", retarget, true);
|
|
112
120
|
if (existing?.type !== undefined && existing.type !== "stdio") throw new Error("The existing Claude bridge transport is not stdio");
|
|
113
121
|
if (existing?.env !== undefined) object(existing.env, "Claude bridge environment");
|
|
114
122
|
if (existing?.env?.ANTIGRAVITY_BRIDGE_CALLER !== undefined && existing.env.ANTIGRAVITY_BRIDGE_CALLER !== "claude") throw new Error("The existing Claude bridge has a conflicting caller identity");
|
|
115
123
|
if (remove) { if (existing) delete value.mcpServers[NAME]; }
|
|
116
124
|
else {
|
|
117
125
|
value.mcpServers ??= {};
|
|
118
|
-
value.mcpServers[NAME] = { ...existing, type: "stdio", command: node, args: [entry], env: { ...existing?.env, ANTIGRAVITY_BRIDGE_CALLER: "claude" } };
|
|
126
|
+
value.mcpServers[NAME] = { ...existing, type: "stdio", command: node, args: [supervisorEntry, path.basename(entry)], env: { ...existing?.env, ANTIGRAVITY_BRIDGE_CALLER: "claude" } };
|
|
127
|
+
runtimeCaches.add(snapshotRoot({ ...process.env, ...value.mcpServers[NAME].env }));
|
|
119
128
|
}
|
|
120
129
|
const edit = changed(original, value);
|
|
121
130
|
if (edit) edits.push(edit);
|
|
@@ -126,7 +135,7 @@ export function planAntigravityInstallation({ home = homeDir(), codeConfigDir =
|
|
|
126
135
|
const mcp = structuredClone(originalMcp.value);
|
|
127
136
|
if (mcp.mcpServers !== undefined) object(mcp.mcpServers, "Antigravity mcpServers");
|
|
128
137
|
const outbound = mcp.mcpServers?.[OUTBOUND];
|
|
129
|
-
ownEntry(outbound, outboundEntry, "Antigravity outbound bridge", retarget);
|
|
138
|
+
ownEntry(outbound, outboundEntry, "Antigravity outbound bridge", retarget, true);
|
|
130
139
|
if (outbound?.type !== undefined && outbound.type !== "stdio" || outbound?.url !== undefined || outbound?.serverUrl !== undefined || outbound?.httpUrl !== undefined) throw new Error("The existing Antigravity outbound bridge transport is not stdio");
|
|
131
140
|
if (outbound?.env !== undefined) object(outbound.env, "Antigravity outbound bridge environment");
|
|
132
141
|
if (Object.keys(outbound?.env ?? {}).some((name) => /^(?:CLAUDE|CODEX_|ANTIGRAVITY_BRIDGE_CALLER$)/i.test(name))) throw new Error("The existing Antigravity outbound bridge contains a foreign caller environment; review it before installation");
|
|
@@ -136,7 +145,8 @@ export function planAntigravityInstallation({ home = homeDir(), codeConfigDir =
|
|
|
136
145
|
if (remove) { if (outbound) delete mcp.mcpServers[OUTBOUND]; }
|
|
137
146
|
else {
|
|
138
147
|
mcp.mcpServers ??= {};
|
|
139
|
-
mcp.mcpServers[OUTBOUND] = { ...outbound, command: node, args: [outboundEntry] };
|
|
148
|
+
mcp.mcpServers[OUTBOUND] = { ...outbound, command: node, args: [supervisorEntry, path.basename(outboundEntry)] };
|
|
149
|
+
runtimeCaches.add(snapshotRoot({ ...process.env, ...outbound?.env }));
|
|
140
150
|
}
|
|
141
151
|
const mcpEdit = changed(originalMcp, mcp);
|
|
142
152
|
if (mcpEdit) edits.push(mcpEdit);
|
|
@@ -180,7 +190,8 @@ export function planAntigravityInstallation({ home = homeDir(), codeConfigDir =
|
|
|
180
190
|
if (backgroundPreference.detail) notices.push(backgroundPreference.detail);
|
|
181
191
|
}
|
|
182
192
|
}
|
|
183
|
-
|
|
193
|
+
if (!remove) notices.push("Running direct Antigravity MCP processes retain their current version until the host reconnects normally. Future supervised workers update automatically when quiescent.");
|
|
194
|
+
return { name: NAME, targets: [...new Set(targets)], remove, edits, notices, codex, entry, sidecarEntry, outboundEntry, supervisorEntry, runtimeCaches: [...runtimeCaches], backgroundPreference };
|
|
184
195
|
}
|
|
185
196
|
|
|
186
197
|
function assertCurrent(edit) {
|
|
@@ -224,7 +235,7 @@ export function installerArguments(args) {
|
|
|
224
235
|
return { targets: args.includes("--all") || !selected.length ? ["codex", "claude", "desktop"] : selected, remove: args.includes("--remove"), dryRun: args.includes("--dry-run"), setup: args.includes("--setup"), help: args.includes("--help") };
|
|
225
236
|
}
|
|
226
237
|
|
|
227
|
-
export function runAntigravityInstaller(args = process.argv.slice(2)) {
|
|
238
|
+
export function runAntigravityInstaller(args = process.argv.slice(2), { prepareRuntime = createReleaseSnapshot } = {}) {
|
|
228
239
|
const options = installerArguments(args);
|
|
229
240
|
if (options.help) {
|
|
230
241
|
console.log("Usage: antigravity-mcp-bridge-install [--all|--codex|--claude|--desktop] [--remove] [--dry-run] [--setup]");
|
|
@@ -244,7 +255,7 @@ export function runAntigravityInstaller(args = process.argv.slice(2)) {
|
|
|
244
255
|
}
|
|
245
256
|
const plan = planAntigravityInstallation({ ...options, home, root, node, codexExisting });
|
|
246
257
|
if (!options.remove) {
|
|
247
|
-
for (const entry of [plan.entry, ...(options.targets.includes("desktop") ? [plan.sidecarEntry, plan.outboundEntry] : [])]) {
|
|
258
|
+
for (const entry of [plan.supervisorEntry, plan.entry, ...(options.targets.includes("desktop") ? [plan.sidecarEntry, plan.outboundEntry] : [])]) {
|
|
248
259
|
if (!fs.existsSync(entry)) throw new Error(`Missing bridge entrypoint: ${entry}`);
|
|
249
260
|
}
|
|
250
261
|
}
|
|
@@ -252,6 +263,7 @@ export function runAntigravityInstaller(args = process.argv.slice(2)) {
|
|
|
252
263
|
console.log(JSON.stringify({ dryRun: true, remove: plan.remove, targets: plan.targets, files: plan.edits.map((edit) => ({ path: edit.file, action: edit.next === null ? "remove" : "write" })), notices: plan.notices, backgroundPreference: plan.backgroundPreference, codex: plan.codex ? { name: NAME, action: options.remove ? "remove" : "register" } : null }, null, 2));
|
|
253
264
|
return plan;
|
|
254
265
|
}
|
|
266
|
+
for (const cache of plan.runtimeCaches) prepareRuntime(root, { cache });
|
|
255
267
|
for (const edit of plan.edits) assertCurrent(edit);
|
|
256
268
|
if (plan.codex) {
|
|
257
269
|
if (options.remove) { if (plan.codex.remove) run(["mcp", "remove", NAME]); }
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
import { callBackgroundRelay } from "../src/background-relay-client.mjs";
|
|
9
|
+
import { readRelayConfiguration, writeRelayJson } from "../src/background-relay-config.mjs";
|
|
10
|
+
import { RELAY_SERVICE_OWNER, relayServiceRuntimeId } from "../src/background-relay-service.mjs";
|
|
11
|
+
import { isMainModule, stableNodeBin } from "../src/install-paths.mjs";
|
|
12
|
+
import { readRegularConfigFile } from "../src/project-policy.mjs";
|
|
13
|
+
|
|
14
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
15
|
+
const TASK_SCRIPT = String.raw`$ErrorActionPreference = 'Stop'
|
|
16
|
+
$relayRequest = [Console]::In.ReadToEnd() | ConvertFrom-Json
|
|
17
|
+
try {
|
|
18
|
+
$relayScheduler = New-Object -ComObject Schedule.Service
|
|
19
|
+
$relayScheduler.Connect()
|
|
20
|
+
$relayFolder = $relayScheduler.GetFolder('\')
|
|
21
|
+
if ($relayRequest.action -eq 'register') {
|
|
22
|
+
$null = $relayFolder.RegisterTask($relayRequest.name, $relayRequest.xml, 6, $null, $null, 3, $null)
|
|
23
|
+
}
|
|
24
|
+
$relayTask = $null
|
|
25
|
+
try { $relayTask = $relayFolder.GetTask($relayRequest.name) }
|
|
26
|
+
catch {
|
|
27
|
+
$relayException = $_.Exception
|
|
28
|
+
$relayMissing = $false
|
|
29
|
+
while ($null -ne $relayException) {
|
|
30
|
+
if ($relayException.HResult -eq -2147024894) { $relayMissing = $true }
|
|
31
|
+
$relayException = $relayException.InnerException
|
|
32
|
+
}
|
|
33
|
+
if (-not $relayMissing) { throw }
|
|
34
|
+
}
|
|
35
|
+
if ($relayRequest.action -eq 'start') {
|
|
36
|
+
if ($null -eq $relayTask) { throw 'Missing task' }
|
|
37
|
+
$null = $relayTask.Run($null)
|
|
38
|
+
}
|
|
39
|
+
$relayUser = [Security.Principal.WindowsIdentity]::GetCurrent().Name
|
|
40
|
+
if ($null -eq $relayTask) {
|
|
41
|
+
@{ exists = $false; user = $relayUser } | ConvertTo-Json -Compress
|
|
42
|
+
} else {
|
|
43
|
+
$relayDefinition = $relayTask.Definition
|
|
44
|
+
$relayAction = $relayDefinition.Actions.Item(1)
|
|
45
|
+
$relayPrincipalId = [string]$relayDefinition.Principal.UserId
|
|
46
|
+
$relayPrincipalSid = $null
|
|
47
|
+
try {
|
|
48
|
+
if ($relayPrincipalId -match '^S-\d+(?:-\d+)+$') {
|
|
49
|
+
$relayPrincipalSid = [Security.Principal.SecurityIdentifier]::new($relayPrincipalId).Value
|
|
50
|
+
} else {
|
|
51
|
+
$relayPrincipalSid = [Security.Principal.NTAccount]::new($relayPrincipalId).Translate([Security.Principal.SecurityIdentifier]).Value
|
|
52
|
+
}
|
|
53
|
+
} catch {}
|
|
54
|
+
@{ exists = $true; user = $relayUser; enabled = [bool]$relayTask.Enabled; state = [int]$relayTask.State;
|
|
55
|
+
description = [string]$relayDefinition.RegistrationInfo.Description;
|
|
56
|
+
command = [string]$relayAction.Path; arguments = [string]$relayAction.Arguments;
|
|
57
|
+
actions = [int]$relayDefinition.Actions.Count; logonType = [int]$relayDefinition.Principal.LogonType;
|
|
58
|
+
principalId = $relayPrincipalId; principalSid = $relayPrincipalSid; userSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value;
|
|
59
|
+
runLevel = [int]$relayDefinition.Principal.RunLevel; hidden = [bool]$relayDefinition.Settings.Hidden;
|
|
60
|
+
executionTimeLimit = [string]$relayDefinition.Settings.ExecutionTimeLimit;
|
|
61
|
+
multipleInstances = [int]$relayDefinition.Settings.MultipleInstances } | ConvertTo-Json -Compress
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
@{ error = 'TASK_SCHEDULER_UNAVAILABLE' } | ConvertTo-Json -Compress
|
|
65
|
+
exit 1
|
|
66
|
+
}`;
|
|
67
|
+
|
|
68
|
+
function execute(command, args, options) {
|
|
69
|
+
try { return { status: 0, stdout: execFileSync(command, args, options), stderr: "" }; }
|
|
70
|
+
catch (error) { return { status: Number.isInteger(error.status) ? error.status : 1, stdout: error.stdout ?? "", stderr: "" }; }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function installFailure(code) { return Object.assign(new Error(code), { code }); }
|
|
74
|
+
const escapeXml = (value) => String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
75
|
+
const quoteWindows = (value) => `"${String(value).replace(/(\\*)"/g, "$1$1\\\"").replace(/(\\+)$/g, "$1$1")}"`;
|
|
76
|
+
const quoteSystemd = (value, expandDollar = true) => `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%").replace(/\$/g, () => expandDollar ? "$$" : "$")}"`;
|
|
77
|
+
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
78
|
+
|
|
79
|
+
function startupLocation(input, segments) {
|
|
80
|
+
if (typeof input !== "string" || !path.isAbsolute(input) || /[\u0000-\u001f\u007f]/.test(input)) throw installFailure("SERVICE_STORAGE_UNSAFE");
|
|
81
|
+
let ancestor = path.resolve(input);
|
|
82
|
+
const suffix = [];
|
|
83
|
+
while (true) {
|
|
84
|
+
try { fs.lstatSync(ancestor); break; }
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (error.code !== "ENOENT" || ancestor === path.dirname(ancestor)) throw installFailure("SERVICE_STORAGE_UNSAFE");
|
|
87
|
+
suffix.unshift(path.basename(ancestor));
|
|
88
|
+
ancestor = path.dirname(ancestor);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
ancestor = fs.realpathSync.native(ancestor);
|
|
92
|
+
const root = path.resolve(ancestor, ...suffix);
|
|
93
|
+
const file = path.resolve(root, ...segments);
|
|
94
|
+
if (!file.startsWith(root.endsWith(path.sep) ? root : `${root}${path.sep}`)) throw installFailure("SERVICE_STORAGE_UNSAFE");
|
|
95
|
+
const location = { root, file, ancestor, identities: new Map() };
|
|
96
|
+
startupDirectories(location);
|
|
97
|
+
return location;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function startupDirectories(location, create = false) {
|
|
101
|
+
const relative = path.relative(location.ancestor, path.dirname(location.file));
|
|
102
|
+
if (path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`)) throw installFailure("SERVICE_STORAGE_UNSAFE");
|
|
103
|
+
let current = location.ancestor;
|
|
104
|
+
for (const segment of ["", ...relative.split(path.sep).filter(Boolean)]) {
|
|
105
|
+
if (segment) current = path.join(current, segment);
|
|
106
|
+
let entry;
|
|
107
|
+
try { entry = fs.lstatSync(current, { bigint: true }); }
|
|
108
|
+
catch (error) {
|
|
109
|
+
if (error.code !== "ENOENT" || location.identities.has(current)) throw installFailure("SERVICE_CONFIGURATION_CHANGED");
|
|
110
|
+
if (!create) return;
|
|
111
|
+
fs.mkdirSync(current, { mode: 0o700 });
|
|
112
|
+
entry = fs.lstatSync(current, { bigint: true });
|
|
113
|
+
}
|
|
114
|
+
const identity = `${entry.dev}:${entry.ino}:${entry.birthtimeNs}`;
|
|
115
|
+
if (!entry.isDirectory() || entry.isSymbolicLink() || path.relative(current, fs.realpathSync.native(current))) throw installFailure("SERVICE_STORAGE_UNSAFE");
|
|
116
|
+
if (location.identities.has(current) && location.identities.get(current) !== identity) throw installFailure("SERVICE_CONFIGURATION_CHANGED");
|
|
117
|
+
location.identities.set(current, identity);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function startupText(location) {
|
|
122
|
+
startupDirectories(location);
|
|
123
|
+
return readRegularConfigFile(location.file, { missing: true, maxBytes: 128 * 1024 });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function sameStartupFile(location, candidate) {
|
|
127
|
+
if (typeof candidate !== "string" || !path.isAbsolute(candidate) || path.basename(candidate) !== path.basename(location.file)) return false;
|
|
128
|
+
try {
|
|
129
|
+
const entry = fs.lstatSync(candidate);
|
|
130
|
+
return entry.isFile() && !entry.isSymbolicLink() && path.relative(location.file, fs.realpathSync.native(candidate)) === "";
|
|
131
|
+
} catch { return false; }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function atomicText(location, content, previous) {
|
|
135
|
+
startupDirectories(location, true);
|
|
136
|
+
const file = location.file;
|
|
137
|
+
if (startupText(location) !== previous) throw installFailure("SERVICE_CONFIGURATION_CHANGED");
|
|
138
|
+
const directory = path.dirname(file);
|
|
139
|
+
const temporary = path.resolve(directory, `.${path.basename(file)}.${randomUUID()}.tmp`);
|
|
140
|
+
if (!temporary.startsWith(directory.endsWith(path.sep) ? directory : `${directory}${path.sep}`)) throw installFailure("SERVICE_STORAGE_UNSAFE");
|
|
141
|
+
let descriptor;
|
|
142
|
+
let identity;
|
|
143
|
+
const sameTemporary = (entry) => entry.isFile() && !entry.isSymbolicLink() && entry.nlink === 1n
|
|
144
|
+
&& ["dev", "ino", "birthtimeNs"].every((field) => entry[field] === identity[field]);
|
|
145
|
+
try {
|
|
146
|
+
descriptor = fs.openSync(temporary, "wx", 0o600);
|
|
147
|
+
identity = fs.fstatSync(descriptor, { bigint: true });
|
|
148
|
+
fs.writeFileSync(descriptor, content);
|
|
149
|
+
fs.fsyncSync(descriptor);
|
|
150
|
+
fs.closeSync(descriptor);
|
|
151
|
+
descriptor = undefined;
|
|
152
|
+
if (startupText(location) !== previous || !sameTemporary(fs.lstatSync(temporary, { bigint: true }))) throw installFailure("SERVICE_CONFIGURATION_CHANGED");
|
|
153
|
+
fs.renameSync(temporary, file);
|
|
154
|
+
} finally {
|
|
155
|
+
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
156
|
+
if (identity) {
|
|
157
|
+
startupDirectories(location);
|
|
158
|
+
let remaining;
|
|
159
|
+
try { remaining = fs.lstatSync(temporary, { bigint: true }); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
160
|
+
if (remaining) {
|
|
161
|
+
if (!sameTemporary(remaining)) throw installFailure("SERVICE_CONFIGURATION_CHANGED");
|
|
162
|
+
fs.unlinkSync(temporary);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function taskXml({ command, args, user, enabled }) {
|
|
169
|
+
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
170
|
+
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
171
|
+
<RegistrationInfo><Description>${escapeXml(RELAY_SERVICE_OWNER)}</Description></RegistrationInfo>
|
|
172
|
+
<Triggers><LogonTrigger><Enabled>true</Enabled><UserId>${escapeXml(user)}</UserId></LogonTrigger></Triggers>
|
|
173
|
+
<Principals><Principal id="Author"><UserId>${escapeXml(user)}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
|
|
174
|
+
<Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy><DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries><StopIfGoingOnBatteries>false</StopIfGoingOnBatteries><AllowHardTerminate>false</AllowHardTerminate><StartWhenAvailable>true</StartWhenAvailable><AllowStartOnDemand>true</AllowStartOnDemand><Enabled>${enabled}</Enabled><Hidden>true</Hidden><ExecutionTimeLimit>PT0S</ExecutionTimeLimit><RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure></Settings>
|
|
175
|
+
<Actions Context="Author"><Exec><Command>${escapeXml(command)}</Command><Arguments>${escapeXml(args)}</Arguments></Exec></Actions>
|
|
176
|
+
</Task>\n`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function savedEnvironment(env, config) {
|
|
180
|
+
const result = { AGENT_RELAY_DIRECTORY: config.paths.root, AGENT_RELAY_SERVICE_OWNER: RELAY_SERVICE_OWNER };
|
|
181
|
+
for (const name of ["HOME", "PATH", "CODEX_HOME", "CLAUDE_CONFIG_DIR", "XDG_CONFIG_HOME"]) {
|
|
182
|
+
if (typeof env[name] === "string" && env[name] && !/[\u0000-\u001f\u007f]/.test(env[name])) result[name] = env[name];
|
|
183
|
+
}
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function plist(label, node, service, environment, enabled) {
|
|
188
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
189
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
190
|
+
<plist version="1.0"><dict>
|
|
191
|
+
<key>Label</key><string>${escapeXml(label)}</string>
|
|
192
|
+
<key>ProgramArguments</key><array><string>${escapeXml(node)}</string><string>${escapeXml(service)}</string></array>
|
|
193
|
+
<key>EnvironmentVariables</key><dict>${Object.entries(environment).map(([key, value]) => `<key>${escapeXml(key)}</key><string>${escapeXml(value)}</string>`).join("")}</dict>
|
|
194
|
+
<key>RunAtLoad</key><${enabled ? "true" : "false"}/><key>Disabled</key><${enabled ? "false" : "true"}/>
|
|
195
|
+
<key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
|
|
196
|
+
<key>ThrottleInterval</key><integer>10</integer><key>ProcessType</key><string>Background</string>
|
|
197
|
+
<key>StandardOutPath</key><string>/dev/null</string><key>StandardErrorPath</key><string>/dev/null</string>
|
|
198
|
+
</dict></plist>\n`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function unit(node, service, environment) {
|
|
202
|
+
return `[Unit]
|
|
203
|
+
Description=${RELAY_SERVICE_OWNER}
|
|
204
|
+
StartLimitIntervalSec=0
|
|
205
|
+
|
|
206
|
+
[Service]
|
|
207
|
+
Type=simple
|
|
208
|
+
ExecStart=${quoteSystemd(node)} ${quoteSystemd(service)}
|
|
209
|
+
${Object.entries(environment).map(([name, value]) => `Environment=${quoteSystemd(`${name}=${value}`, false)}`).join("\n")}
|
|
210
|
+
Restart=on-failure
|
|
211
|
+
RestartSec=10
|
|
212
|
+
TimeoutStopSec=infinity
|
|
213
|
+
StandardOutput=null
|
|
214
|
+
StandardError=null
|
|
215
|
+
|
|
216
|
+
[Install]
|
|
217
|
+
WantedBy=default.target
|
|
218
|
+
`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function installWindows(context) {
|
|
222
|
+
const { env, config, root, node, service, label, invoke, mutate } = context;
|
|
223
|
+
const command = path.win32.join(env.SystemRoot || env.WINDIR || "C:\\Windows", "System32", "wscript.exe");
|
|
224
|
+
const launcher = path.join(root, "scripts", "background-relay-hidden.vbs");
|
|
225
|
+
if (![node, service, launcher].every((value) => !/["%\r\n\0]/.test(value))) throw installFailure("SERVICE_LAUNCH_PATH_UNSUPPORTED");
|
|
226
|
+
if (!fs.existsSync(launcher)) throw installFailure("SERVICE_RUNTIME_MISSING");
|
|
227
|
+
if (context.nativeRun && !fs.existsSync(command)) throw installFailure("SERVICE_HIDDEN_LAUNCHER_UNAVAILABLE");
|
|
228
|
+
const args = ["//B", "//Nologo", quoteWindows(launcher), quoteWindows(node), quoteWindows(service), quoteWindows(config.paths.root)].join(" ");
|
|
229
|
+
const powershell = path.win32.join(env.SystemRoot || env.WINDIR || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
230
|
+
const task = async (action, extra = {}) => {
|
|
231
|
+
const response = await invoke(powershell, ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-EncodedCommand", Buffer.from(TASK_SCRIPT, "utf16le").toString("base64")], {
|
|
232
|
+
input: JSON.stringify({ action, name: label, ...extra }),
|
|
233
|
+
});
|
|
234
|
+
let value;
|
|
235
|
+
try { value = JSON.parse(response.stdout); } catch { throw installFailure("TASK_SCHEDULER_UNAVAILABLE"); }
|
|
236
|
+
if (response.status !== 0 || value.error || typeof value.exists !== "boolean") throw installFailure("TASK_SCHEDULER_UNAVAILABLE");
|
|
237
|
+
return value;
|
|
238
|
+
};
|
|
239
|
+
let current = await task("query");
|
|
240
|
+
if (current.exists && current.description !== RELAY_SERVICE_OWNER) throw installFailure("SERVICE_CONFIGURATION_CONFLICT");
|
|
241
|
+
const sameUser = (value) => typeof value.principalSid === "string" && /^S-\d+(?:-\d+)+$/i.test(value.principalSid)
|
|
242
|
+
&& typeof value.userSid === "string" && value.principalSid.toLowerCase() === value.userSid.toLowerCase();
|
|
243
|
+
if (current.exists && !sameUser(current)) throw installFailure("SERVICE_CONFIGURATION_CONFLICT");
|
|
244
|
+
const enabled = config.enabled && (!current.exists || current.enabled === true);
|
|
245
|
+
const matches = (value) => value.exists && value.description === RELAY_SERVICE_OWNER && value.command.toLowerCase() === command.toLowerCase()
|
|
246
|
+
&& value.arguments === args && value.actions === 1 && value.logonType === 3 && value.runLevel === 0
|
|
247
|
+
&& value.hidden === true && value.executionTimeLimit === "PT0S" && value.multipleInstances === 2 && value.enabled === enabled && sameUser(value);
|
|
248
|
+
const changed = !matches(current);
|
|
249
|
+
if (changed && mutate) {
|
|
250
|
+
if (typeof current.user !== "string" || !current.user || /[\r\n\0]/.test(current.user)) throw installFailure("TASK_SCHEDULER_UNAVAILABLE");
|
|
251
|
+
await task("register", { xml: taskXml({ command, args, user: current.user, enabled }) });
|
|
252
|
+
current = await task("query");
|
|
253
|
+
}
|
|
254
|
+
if (mutate && !matches(current)) throw installFailure("SERVICE_READBACK_FAILED");
|
|
255
|
+
if (mutate) await context.prepareRuntime();
|
|
256
|
+
let started = false;
|
|
257
|
+
if (enabled && mutate && current.state !== 4) { await task("start"); started = true; }
|
|
258
|
+
return { configured: matches(current), enabled, changed, started, registration: label, activationPending: changed && current.state === 4 };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function installMac(context) {
|
|
262
|
+
const { env, config, node, service, label, invoke, mutate, uid } = context;
|
|
263
|
+
if (!Number.isSafeInteger(uid) || uid < 0) throw installFailure("SERVICE_USER_UNAVAILABLE");
|
|
264
|
+
const home = env.HOME || env.USERPROFILE || os.homedir();
|
|
265
|
+
const location = startupLocation(home, ["Library", "LaunchAgents", `${label}.plist`]);
|
|
266
|
+
const file = location.file;
|
|
267
|
+
const previous = startupText(location);
|
|
268
|
+
if (previous !== null && !previous.includes(escapeXml(RELAY_SERVICE_OWNER))) throw installFailure("SERVICE_CONFIGURATION_CONFLICT");
|
|
269
|
+
const domain = `gui/${uid}`;
|
|
270
|
+
const disabled = await invoke("/bin/launchctl", ["print-disabled", domain]);
|
|
271
|
+
if (disabled.status !== 0) throw installFailure("LAUNCH_AGENT_UNAVAILABLE");
|
|
272
|
+
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
273
|
+
const userDisabled = new RegExp(`"${escaped}"\\s*=>\\s*true`).test(disabled.stdout)
|
|
274
|
+
|| /<key>Disabled<\/key>\s*<true\s*\/>/.test(previous ?? "");
|
|
275
|
+
const enabled = config.enabled && !userDisabled;
|
|
276
|
+
const wanted = plist(label, node, service, savedEnvironment(env, config), enabled);
|
|
277
|
+
const loaded = await invoke("/bin/launchctl", ["print", `${domain}/${label}`]);
|
|
278
|
+
if (loaded.status === 0 && previous === null) throw installFailure("SERVICE_CONFIGURATION_CONFLICT");
|
|
279
|
+
if (loaded.status !== 0 && loaded.status !== 113) throw installFailure("LAUNCH_AGENT_UNAVAILABLE");
|
|
280
|
+
const changed = previous !== wanted;
|
|
281
|
+
if (changed && mutate) atomicText(location, wanted, previous);
|
|
282
|
+
const configured = startupText(location) === wanted;
|
|
283
|
+
if (mutate && !configured) throw installFailure("SERVICE_READBACK_FAILED");
|
|
284
|
+
if (mutate) await context.prepareRuntime();
|
|
285
|
+
let started = false;
|
|
286
|
+
if (enabled && mutate) {
|
|
287
|
+
const args = loaded.status === 0 ? ["kickstart", `${domain}/${label}`] : ["bootstrap", domain, file];
|
|
288
|
+
if (loaded.status !== 0 || !/\bstate = running\b/.test(loaded.stdout)) {
|
|
289
|
+
if ((await invoke("/bin/launchctl", args)).status !== 0) throw installFailure("SERVICE_START_FAILED");
|
|
290
|
+
started = true;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return { configured, enabled, changed, started, registration: label, activationPending: changed && loaded.status === 0 };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function installLinux(context) {
|
|
297
|
+
const { env, config, node, service, label, invoke, mutate } = context;
|
|
298
|
+
const home = env.HOME || env.USERPROFILE || os.homedir();
|
|
299
|
+
const location = startupLocation(env.XDG_CONFIG_HOME || path.join(home, ".config"), ["systemd", "user", `${label}.service`]);
|
|
300
|
+
const file = location.file;
|
|
301
|
+
const query = async () => {
|
|
302
|
+
const response = await invoke("systemctl", ["--user", "show", `${label}.service`, "--property=LoadState,UnitFileState,ActiveState,FragmentPath", "--no-pager"]);
|
|
303
|
+
const values = Object.fromEntries(response.stdout.split(/\r?\n/).filter((line) => line.includes("=")).map((line) => [line.slice(0, line.indexOf("=")), line.slice(line.indexOf("=") + 1)]));
|
|
304
|
+
if (!values.LoadState || response.status !== 0 && values.LoadState !== "not-found") throw installFailure("USER_SYSTEMD_UNAVAILABLE");
|
|
305
|
+
return values;
|
|
306
|
+
};
|
|
307
|
+
const values = await query();
|
|
308
|
+
if (values.UnitFileState === "masked" || values.UnitFileState === "masked-runtime") return { configured: false, enabled: false, changed: false, started: false, registration: label, code: "SERVICE_MASKED" };
|
|
309
|
+
const previous = startupText(location);
|
|
310
|
+
if (previous !== null && !previous.includes(`Description=${RELAY_SERVICE_OWNER}\n`)
|
|
311
|
+
|| values.FragmentPath && !sameStartupFile(location, values.FragmentPath)) throw installFailure("SERVICE_CONFIGURATION_CONFLICT");
|
|
312
|
+
const userDisabled = previous !== null && ["disabled", "disabled-runtime"].includes(values.UnitFileState);
|
|
313
|
+
const enabled = config.enabled && !userDisabled;
|
|
314
|
+
const wanted = unit(node, service, savedEnvironment(env, config));
|
|
315
|
+
const changed = previous !== wanted;
|
|
316
|
+
if (changed && mutate) {
|
|
317
|
+
atomicText(location, wanted, previous);
|
|
318
|
+
if ((await invoke("systemctl", ["--user", "daemon-reload"])).status !== 0) throw installFailure("USER_SYSTEMD_UNAVAILABLE");
|
|
319
|
+
}
|
|
320
|
+
if (enabled && mutate && !["enabled", "enabled-runtime"].includes(values.UnitFileState)) {
|
|
321
|
+
if ((await invoke("systemctl", ["--user", "enable", `${label}.service`])).status !== 0) throw installFailure("SERVICE_REGISTRATION_FAILED");
|
|
322
|
+
}
|
|
323
|
+
if (mutate) await context.prepareRuntime();
|
|
324
|
+
let started = false;
|
|
325
|
+
if (enabled && mutate && values.ActiveState !== "active") {
|
|
326
|
+
if ((await invoke("systemctl", ["--user", "start", `${label}.service`])).status !== 0) throw installFailure("SERVICE_START_FAILED");
|
|
327
|
+
started = true;
|
|
328
|
+
}
|
|
329
|
+
const verified = mutate ? await query() : values;
|
|
330
|
+
const configured = startupText(location) === wanted
|
|
331
|
+
&& verified.LoadState === "loaded" && sameStartupFile(location, verified.FragmentPath)
|
|
332
|
+
&& (!enabled || ["enabled", "enabled-runtime"].includes(verified.UnitFileState));
|
|
333
|
+
if (mutate && !configured) throw installFailure("SERVICE_READBACK_FAILED");
|
|
334
|
+
return { configured, enabled, changed, started, registration: label };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export async function installBackgroundRelay({ env = process.env, platform = process.platform, root = ROOT, run = execute,
|
|
338
|
+
dryRun = false, check = false, health = callBackgroundRelay, sleep = pause, pollAttempts = 15, uid = process.getuid?.() } = {}) {
|
|
339
|
+
const base = { configured: false, enabled: false, started: false, liveVerified: false, ready: false };
|
|
340
|
+
let config;
|
|
341
|
+
try {
|
|
342
|
+
config = readRelayConfiguration({ env, platform, missing: true });
|
|
343
|
+
if (!config) return { ...base, code: "WORKER_RELAY_NOT_CONFIGURED", reason: "Configure the background relay before installing its user service" };
|
|
344
|
+
if (!["win32", "darwin", "linux"].includes(platform)) return { ...base, code: "SERVICE_PLATFORM_UNSUPPORTED", reason: "Background relay user startup is unsupported on this platform" };
|
|
345
|
+
if (!path.isAbsolute(root)) throw installFailure("SERVICE_RUNTIME_MISSING");
|
|
346
|
+
root = fs.realpathSync.native(root);
|
|
347
|
+
const node = stableNodeBin(env);
|
|
348
|
+
const service = path.join(root, "src", "background-relay-service.mjs");
|
|
349
|
+
if (!path.isAbsolute(root) || !fs.existsSync(service) || !fs.existsSync(path.join(root, "src", "background-relay-daemon.mjs"))) throw installFailure("SERVICE_RUNTIME_MISSING");
|
|
350
|
+
let packageVersion;
|
|
351
|
+
try { packageVersion = JSON.parse(readRegularConfigFile(path.join(root, "package.json"), { maxBytes: 128 * 1024 })).version; }
|
|
352
|
+
catch { throw installFailure("SERVICE_RUNTIME_INVALID"); }
|
|
353
|
+
if (typeof packageVersion !== "string" || !/^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.+-]+)?$/.test(packageVersion) || packageVersion.length > 96) throw installFailure("SERVICE_RUNTIME_INVALID");
|
|
354
|
+
if ([root, config.paths.root, node].some((value) => /[\u0000-\u001f\u007f]/.test(value))) throw installFailure("SERVICE_LAUNCH_PATH_UNSUPPORTED");
|
|
355
|
+
const hash = createHash("sha256").update(platform === "win32" ? config.paths.root.toLowerCase() : config.paths.root).digest("hex").slice(0, 12);
|
|
356
|
+
const label = platform === "win32" ? `MinhSpark Agent Relay ${hash}` : `com.minhspark.agent-relay.${hash}`;
|
|
357
|
+
const invoke = async (command, args, extra = {}) => {
|
|
358
|
+
const result = await run(command, args, { env, encoding: "utf8", windowsHide: true, shell: false, timeout: 15000, maxBuffer: 256 * 1024, stdio: ["pipe", "pipe", "pipe"], ...extra });
|
|
359
|
+
return { status: result?.status ?? result?.exitCode ?? 0, stdout: String(result?.stdout ?? "") };
|
|
360
|
+
};
|
|
361
|
+
const runtimeFile = path.join(config.paths.root, "service-runtime.json");
|
|
362
|
+
const desiredRuntime = { schemaVersion: 1, owner: RELAY_SERVICE_OWNER, packageRoot: root, nodeExecutable: node, packageVersion,
|
|
363
|
+
environment: Object.fromEntries(Object.entries(savedEnvironment(env, config)).filter(([name]) => !name.startsWith("AGENT_RELAY_"))) };
|
|
364
|
+
const expectedRuntimeId = relayServiceRuntimeId(desiredRuntime);
|
|
365
|
+
const readRuntime = () => {
|
|
366
|
+
const raw = readRegularConfigFile(runtimeFile, { missing: true, maxBytes: 64 * 1024 });
|
|
367
|
+
if (raw === null) return null;
|
|
368
|
+
let value;
|
|
369
|
+
try { value = JSON.parse(raw); } catch { throw installFailure("SERVICE_RUNTIME_INVALID"); }
|
|
370
|
+
if (value?.schemaVersion !== 1 || value.owner !== RELAY_SERVICE_OWNER) throw installFailure("SERVICE_CONFIGURATION_CONFLICT");
|
|
371
|
+
return value;
|
|
372
|
+
};
|
|
373
|
+
const previousRuntime = readRuntime();
|
|
374
|
+
const runtimeChanged = JSON.stringify(previousRuntime) !== JSON.stringify(desiredRuntime);
|
|
375
|
+
const prepareRuntime = async () => {
|
|
376
|
+
if (JSON.stringify(readRuntime()) !== JSON.stringify(previousRuntime)) throw installFailure("SERVICE_CONFIGURATION_CHANGED");
|
|
377
|
+
if (runtimeChanged) writeRelayJson(runtimeFile, desiredRuntime);
|
|
378
|
+
if (JSON.stringify(readRuntime()) !== JSON.stringify(desiredRuntime)) throw installFailure("SERVICE_READBACK_FAILED");
|
|
379
|
+
};
|
|
380
|
+
const context = { env, config, root, node, service, label, invoke, mutate: !dryRun && !check, uid, nativeRun: run === execute, prepareRuntime };
|
|
381
|
+
const installed = await ({ win32: installWindows, darwin: installMac, linux: installLinux })[platform](context);
|
|
382
|
+
installed.configured = installed.configured && JSON.stringify(readRuntime()) === JSON.stringify(desiredRuntime);
|
|
383
|
+
const result = { ...base, ...installed, dryRun, deliveryEnabled: installed.enabled, liveVerified: false,
|
|
384
|
+
expectedVersion: packageVersion, runtimeChanged, activationPending: installed.activationPending === true || runtimeChanged && previousRuntime !== null };
|
|
385
|
+
if (!installed.enabled) {
|
|
386
|
+
result.notice = config.enabled ? "User startup is intentionally disabled; the installer preserved that choice" : "Background relay is intentionally disabled in its configuration";
|
|
387
|
+
return result;
|
|
388
|
+
}
|
|
389
|
+
if (!installed.configured || dryRun) return result;
|
|
390
|
+
const attempts = check ? 1 : Math.max(1, Math.min(30, pollAttempts));
|
|
391
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
392
|
+
try {
|
|
393
|
+
const status = await health("status", {}, { env, connection: { endpoint: config.endpoint, token: config.token, workerId: null }, timeoutMs: 1000 });
|
|
394
|
+
const serviceState = JSON.parse(readRegularConfigFile(path.join(config.paths.root, "service-status.json"), { maxBytes: 64 * 1024 }));
|
|
395
|
+
const owned = status?.schemaVersion === 1 && status.transportReady === true && status.desktopRequired === false
|
|
396
|
+
&& Number.isSafeInteger(status.pid) && status.pid > 0 && serviceState?.owner === RELAY_SERVICE_OWNER
|
|
397
|
+
&& serviceState.status === "running" && serviceState.daemonPid === status.pid;
|
|
398
|
+
if (owned && typeof status.version === "string" && /^[A-Za-z0-9.+_-]{1,96}$/.test(status.version)) result.version = status.version;
|
|
399
|
+
if (owned && (serviceState.runtimeId !== expectedRuntimeId || status.runtimeId !== expectedRuntimeId || status.version !== packageVersion)) result.activationPending = true;
|
|
400
|
+
if (owned && serviceState.runtimeId === expectedRuntimeId && status.runtimeId === expectedRuntimeId && status.version === packageVersion) {
|
|
401
|
+
return { ...result, activationPending: false, liveVerified: true, ready: status.accepting === true, pid: status.pid,
|
|
402
|
+
version: typeof status.version === "string" && /^[A-Za-z0-9.+_-]{1,96}$/.test(status.version) ? status.version : null,
|
|
403
|
+
...(status.accepting === true ? {} : { code: "SERVICE_DRAINING", notice: "The relay is finishing existing work before accepting new jobs" }) };
|
|
404
|
+
}
|
|
405
|
+
} catch {}
|
|
406
|
+
if (attempt + 1 < attempts) await sleep(250);
|
|
407
|
+
}
|
|
408
|
+
return { ...result, code: result.activationPending ? "SERVICE_UPDATE_PENDING" : "SERVICE_NOT_READY",
|
|
409
|
+
reason: result.activationPending ? "User startup is configured; the running service has not yet verified the desired idle runtime update"
|
|
410
|
+
: "User startup is configured, but its owned daemon did not report healthy transport" };
|
|
411
|
+
} catch (error) {
|
|
412
|
+
const allowed = /^(?:SERVICE_[A-Z_]+|TASK_SCHEDULER_UNAVAILABLE|LAUNCH_AGENT_UNAVAILABLE|USER_SYSTEMD_UNAVAILABLE|WORKER_CONFIG_INVALID|WORKER_STORAGE_UNSAFE)$/;
|
|
413
|
+
const code = typeof error.code === "string" && allowed.test(error.code) ? error.code : "SERVICE_INSTALL_FAILED";
|
|
414
|
+
return { ...base, code, reason: "The background relay user service could not be safely configured or verified" };
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (isMainModule(process.argv[1], import.meta.url)) {
|
|
419
|
+
const args = process.argv.slice(2);
|
|
420
|
+
if (args.some((arg) => !["--check", "--dry-run", "--json"].includes(arg)) || args.includes("--check") && args.includes("--dry-run")) {
|
|
421
|
+
process.stderr.write("Usage: install-background-relay.mjs [--check|--dry-run] [--json]\n");
|
|
422
|
+
process.exitCode = 2;
|
|
423
|
+
} else {
|
|
424
|
+
const result = await installBackgroundRelay({ check: args.includes("--check"), dryRun: args.includes("--dry-run") });
|
|
425
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
426
|
+
if (!result.configured && !args.includes("--dry-run") && result.code !== "SERVICE_MASKED") process.exitCode = 1;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
@@ -25,7 +25,7 @@ import { removeRelayFile } from "../src/native-relay-files.mjs";
|
|
|
25
25
|
|
|
26
26
|
exitForVersionRequest(import.meta.url);
|
|
27
27
|
|
|
28
|
-
const VERSION = "1.
|
|
28
|
+
const VERSION = "1.26.0";
|
|
29
29
|
const root = stableInstallPath(path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."));
|
|
30
30
|
const entry = path.join(root, "src", "mcp-supervisor.mjs");
|
|
31
31
|
const entryArgs = ["native-relay-companion.mjs"];
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { isMainModule } from "../src/install-paths.mjs";
|
|
6
|
+
import { backgroundAdapters } from "../src/background-relay.mjs";
|
|
7
|
+
import { BACKGROUND_AGENTS, canStartBackgroundWorker, configureRelay, readRelayConfiguration, relayFailure } from "../src/background-relay-config.mjs";
|
|
8
|
+
import { configureBackgroundWorkerMcp } from "../src/background-relay-registration.mjs";
|
|
9
|
+
import { installBackgroundRelay } from "./install-background-relay.mjs";
|
|
10
|
+
import { diagnosticText, withBridgeDiagnostic } from "../src/recipient-preflight.mjs";
|
|
11
|
+
|
|
12
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
13
|
+
|
|
14
|
+
export async function setupBackgroundRelay({ env = process.env, platform = process.platform, root = ROOT, check = false, dryRun = false,
|
|
15
|
+
adapters, readConfig = readRelayConfiguration, configure = configureRelay, registration = configureBackgroundWorkerMcp, service = installBackgroundRelay } = {}) {
|
|
16
|
+
if (dryRun) return { configured: false, dryRun: true, liveVerified: false,
|
|
17
|
+
planned: ["Independent relay configuration", "Scoped worker MCP communication", "Per-user background service"], opensApps: false, opensChats: false };
|
|
18
|
+
const workers = adapters ?? await backgroundAdapters();
|
|
19
|
+
const saved = readConfig({ env, platform, missing: true });
|
|
20
|
+
const readiness = await Promise.all(BACKGROUND_AGENTS.map(async agent => {
|
|
21
|
+
try { return { agent, ...await workers[agent].inspect({ env, platform, executable: saved?.providers[agent]?.executable ?? undefined }) }; }
|
|
22
|
+
catch (error) { return { agent, available: false, authenticated: null, ready: false, code: error.code ?? "WORKER_INSPECTION_FAILED" }; }
|
|
23
|
+
}));
|
|
24
|
+
const config = check ? saved
|
|
25
|
+
: configure({ env, platform });
|
|
26
|
+
if (!config) throw relayFailure("WORKER_RELAY_NOT_CONFIGURED", "The independent relay has not been configured; rerun the package setup command");
|
|
27
|
+
const mcp = registration({ env, platform, root, check });
|
|
28
|
+
const installed = await service({ env, platform, root, check });
|
|
29
|
+
const notices = [
|
|
30
|
+
"Background tasks have separate CLI identities and receipts; they never resume Desktop chats.",
|
|
31
|
+
...(!fs.existsSync(config.projectPolicy) ? ["No shared project policy is saved yet. Explicitly authorize a selected project with bridge-projects allow-project before sending background work."] : []),
|
|
32
|
+
...(mcp.blocked ? [mcp.reason ?? "An existing permission or disabled-server choice blocks worker MCP communication; that choice was preserved."] : []),
|
|
33
|
+
...readiness.filter(item => !item.available || item.authenticated === false).map(item => `${item.agent}: ${item.code ?? "WORKER_NOT_READY"}; CLI authentication and installation remain separate from Desktop sign-in.`),
|
|
34
|
+
...(installed.code ? [diagnosticText(`${installed.code}: ${installed.notice ?? installed.reason ?? "Inspect worker-relay status for service readiness."}`)]
|
|
35
|
+
: installed.notice ? [diagnosticText(installed.notice)] : []),
|
|
36
|
+
];
|
|
37
|
+
return { configured: mcp.configured === true && installed.configured === true, enabled: config.enabled,
|
|
38
|
+
liveVerified: installed.liveVerified === true, transportReady: installed.ready === true,
|
|
39
|
+
desktopRequired: false, opensApps: false, opensChats: false, notices,
|
|
40
|
+
workerReadiness: readiness.map(({ agent, available, authenticated, ready, version, code }) => ({ agent, available, authenticated, ready, version, code,
|
|
41
|
+
canAcceptWork: config.enabled && config.providers[agent].enabled && canStartBackgroundWorker(agent, { available, authenticated, ready, code }) })),
|
|
42
|
+
permissions: { blocked: mcp.blocked === true, ...(mcp.code ? { code: mcp.code } : {}) },
|
|
43
|
+
service: { configured: installed.configured, started: installed.started, activationPending: installed.activationPending, code: installed.code, version: installed.version } };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (isMainModule(process.argv[1], import.meta.url)) {
|
|
47
|
+
const args = process.argv.slice(2);
|
|
48
|
+
try {
|
|
49
|
+
if (args.some(arg => !["--check", "--dry-run", "--json"].includes(arg))) throw relayFailure("WORKER_SETUP_ARGUMENT_INVALID", "Unsupported background relay setup argument");
|
|
50
|
+
const result = await setupBackgroundRelay({ check: args.includes("--check"), dryRun: args.includes("--dry-run") });
|
|
51
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
52
|
+
if (!result.configured && !result.dryRun) process.exitCode = 1;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
const result = withBridgeDiagnostic({ isError: true, content: [] }, error, { component: "background-relay", operation: "setup" });
|
|
55
|
+
process.stderr.write(result.content.map(item => item.text ?? "").join("\n") + "\n");
|
|
56
|
+
process.exitCode = 1;
|
|
57
|
+
}
|
|
58
|
+
}
|