@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
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { execFileSync, spawn as spawnProcess } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
+
|
|
7
|
+
import { callBackgroundRelay } from "./background-relay-client.mjs";
|
|
8
|
+
import { privateRelayDirectory, readRelayConfiguration, relayPaths, writeRelayJson } from "./background-relay-config.mjs";
|
|
9
|
+
import { isMainModule, stableNodeBin } from "./install-paths.mjs";
|
|
10
|
+
import { directoryBinding, readRegularConfigFile } from "./project-policy.mjs";
|
|
11
|
+
|
|
12
|
+
export const RELAY_SERVICE_OWNER = "@minhspark/codex-mcp-bridge/background-relay-service/v1";
|
|
13
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
14
|
+
const SOURCE = fileURLToPath(import.meta.url);
|
|
15
|
+
const sourceHash = () => createHash("sha256").update(fs.readFileSync(SOURCE)).digest("hex");
|
|
16
|
+
const SOURCE_HASH = sourceHash();
|
|
17
|
+
const DIAGNOSTIC_LIMIT = 64 * 1024;
|
|
18
|
+
const MESSAGES = {
|
|
19
|
+
SERVICE_CONFIG_INVALID: "The background relay configuration could not be loaded",
|
|
20
|
+
SERVICE_STORAGE_UNSAFE: "Background relay service storage could not be verified",
|
|
21
|
+
SERVICE_LOCK_OCCUPIED: "Another background relay service owns the service lock",
|
|
22
|
+
SERVICE_PROCESS_IDENTITY_UNAVAILABLE: "The background relay service process identity could not be verified",
|
|
23
|
+
DAEMON_START_FAILED: "The background relay daemon could not be started",
|
|
24
|
+
DAEMON_EXITED: "The background relay daemon exited unexpectedly; active work must be checked before retrying",
|
|
25
|
+
SERVICE_RUNTIME_FAILED: "The background relay service could not continue",
|
|
26
|
+
SERVICE_RUNTIME_INVALID: "The desired background relay runtime could not be verified",
|
|
27
|
+
SERVICE_MIGRATION_PENDING: "The service could not request an idle runtime update; existing jobs were left running",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function serviceFailure(code) {
|
|
31
|
+
return Object.assign(new Error(MESSAGES[code]), { code });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function daemonDiagnostic(line) {
|
|
35
|
+
if (Buffer.byteLength(line) > 8192) return null;
|
|
36
|
+
let value;
|
|
37
|
+
try { value = JSON.parse(line)?.diagnostic; } catch { return null; }
|
|
38
|
+
const validCode = (code) => typeof code === "string" && /^[A-Z][A-Z0-9_]{0,79}$/.test(code);
|
|
39
|
+
if (value?.schemaVersion !== 1 || value.component !== "background-relay" || !validCode(value.code)) return null;
|
|
40
|
+
const deliveryState = ["not_sent", "sent", "unknown", "not_applicable"].includes(value.deliveryState) ? value.deliveryState : "unknown";
|
|
41
|
+
return { schemaVersion: 1, component: "background-relay", code: value.code,
|
|
42
|
+
causeCodes: Array.isArray(value.causeCodes) ? [...new Set(value.causeCodes.filter(validCode))].filter((code) => code !== value.code).slice(0, 8) : [],
|
|
43
|
+
operation: ["startup", "watch_update"].includes(value.operation) ? value.operation : "startup",
|
|
44
|
+
phase: ["startup", "preflight", "inspection", "configuration", "storage", "transport", "relay", "delivery"].includes(value.phase) ? value.phase : "startup",
|
|
45
|
+
deliveryState, sent: deliveryState === "not_sent" ? false : deliveryState === "sent" ? true : null };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function alive(pid) {
|
|
49
|
+
try { process.kill(pid, 0); return true; }
|
|
50
|
+
catch (error) { return error.code !== "ESRCH"; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function processIdentity(pid) {
|
|
54
|
+
try {
|
|
55
|
+
const options = { windowsHide: true, shell: false, encoding: "utf8", timeout: 30000, maxBuffer: 4096, stdio: ["ignore", "pipe", "pipe"] };
|
|
56
|
+
const output = process.platform === "win32"
|
|
57
|
+
? execFileSync(path.join(process.env.SystemRoot || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"),
|
|
58
|
+
["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", `[System.Diagnostics.Process]::GetProcessById(${pid}).StartTime.ToUniversalTime().ToFileTimeUtc().ToString()`], options)
|
|
59
|
+
: execFileSync("/bin/ps", ["-o", "lstart=", "-p", String(pid)], { ...options, env: { ...process.env, LC_ALL: "C", TZ: "UTC" } });
|
|
60
|
+
const value = output.trim();
|
|
61
|
+
return value && value.length < 128 && !/[\u0000-\u001f\u007f]/.test(value) ? value : null;
|
|
62
|
+
} catch { return null; }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function relayServiceRuntimeId(runtime) {
|
|
66
|
+
return createHash("sha256").update(JSON.stringify([runtime.packageRoot, runtime.nodeExecutable, runtime.packageVersion ?? null, runtime.environment ?? {}])).digest("hex");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function readRelayServiceRuntime(directory, { root = ROOT, nodeExecutable = stableNodeBin() } = {}) {
|
|
70
|
+
let text;
|
|
71
|
+
try { text = readRegularConfigFile(path.join(directory, "service-runtime.json"), { missing: true, maxBytes: 64 * 1024 }); }
|
|
72
|
+
catch { throw serviceFailure("SERVICE_RUNTIME_INVALID"); }
|
|
73
|
+
if (text === null) return { schemaVersion: 1, owner: RELAY_SERVICE_OWNER, packageRoot: root, nodeExecutable };
|
|
74
|
+
let value;
|
|
75
|
+
try { value = JSON.parse(text); } catch { throw serviceFailure("SERVICE_RUNTIME_INVALID"); }
|
|
76
|
+
if (value?.schemaVersion !== 1 || value.owner !== RELAY_SERVICE_OWNER
|
|
77
|
+
|| value.packageVersion !== undefined && (typeof value.packageVersion !== "string" || !/^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.+-]+)?$/.test(value.packageVersion) || value.packageVersion.length > 96)
|
|
78
|
+
|| ![value.packageRoot, value.nodeExecutable].every((entry) => typeof entry === "string" && path.isAbsolute(entry) && !/[\u0000-\u001f\u007f]/.test(entry))
|
|
79
|
+
|| value.environment !== undefined && (!value.environment || typeof value.environment !== "object" || Array.isArray(value.environment)
|
|
80
|
+
|| Object.entries(value.environment).some(([name, entry]) => !["HOME", "PATH", "CODEX_HOME", "CLAUDE_CONFIG_DIR", "XDG_CONFIG_HOME"].includes(name)
|
|
81
|
+
|| typeof entry !== "string" || !entry || /[\u0000-\u001f\u007f]/.test(entry)))
|
|
82
|
+
|| ![path.join(value.packageRoot, "src", "background-relay-service.mjs"), path.join(value.packageRoot, "src", "background-relay-daemon.mjs"), value.nodeExecutable].every((entry) => {
|
|
83
|
+
try { return fs.statSync(entry).isFile(); } catch { return false; }
|
|
84
|
+
})) throw serviceFailure("SERVICE_RUNTIME_INVALID");
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function acquireRelayServiceLock(root, { pid = process.pid, isAlive = alive, identity = processIdentity } = {}) {
|
|
89
|
+
if (!Number.isSafeInteger(pid) || pid < 1) throw serviceFailure("SERVICE_PROCESS_IDENTITY_UNAVAILABLE");
|
|
90
|
+
if (typeof root !== "string" || !path.isAbsolute(root) || /[\u0000-\u001f\u007f]/.test(root)) throw serviceFailure("SERVICE_STORAGE_UNSAFE");
|
|
91
|
+
privateRelayDirectory(root);
|
|
92
|
+
const binding = directoryBinding(root);
|
|
93
|
+
const file = path.resolve(binding.path, "service.lock");
|
|
94
|
+
if (!file.startsWith(binding.path.endsWith(path.sep) ? binding.path : `${binding.path}${path.sep}`)) throw serviceFailure("SERVICE_STORAGE_UNSAFE");
|
|
95
|
+
const assertDirectory = () => {
|
|
96
|
+
const current = directoryBinding(root);
|
|
97
|
+
if (current.path !== binding.path || current.identity !== binding.identity) throw serviceFailure("SERVICE_STORAGE_UNSAFE");
|
|
98
|
+
};
|
|
99
|
+
const sameFile = (expected) => {
|
|
100
|
+
const current = fs.lstatSync(file, { bigint: true });
|
|
101
|
+
return current.isFile() && !current.isSymbolicLink() && current.nlink === 1n
|
|
102
|
+
&& ["dev", "ino", "birthtimeNs"].every((field) => current[field] === expected[field]);
|
|
103
|
+
};
|
|
104
|
+
const nonce = randomUUID();
|
|
105
|
+
const processStart = identity(pid);
|
|
106
|
+
if (typeof processStart !== "string" || !processStart) throw serviceFailure("SERVICE_PROCESS_IDENTITY_UNAVAILABLE");
|
|
107
|
+
const value = JSON.stringify({ schemaVersion: 1, owner: RELAY_SERVICE_OWNER, pid, processStart, nonce });
|
|
108
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
109
|
+
let descriptor;
|
|
110
|
+
try {
|
|
111
|
+
assertDirectory();
|
|
112
|
+
descriptor = fs.openSync(file, "wx", 0o600);
|
|
113
|
+
const created = fs.fstatSync(descriptor, { bigint: true });
|
|
114
|
+
fs.writeFileSync(descriptor, value);
|
|
115
|
+
fs.fsyncSync(descriptor);
|
|
116
|
+
fs.closeSync(descriptor);
|
|
117
|
+
descriptor = undefined;
|
|
118
|
+
return { acquired: true, release() {
|
|
119
|
+
assertDirectory();
|
|
120
|
+
if (readRegularConfigFile(file, { missing: true, maxBytes: 4096 }) === value) {
|
|
121
|
+
if (!sameFile(created)) throw serviceFailure("SERVICE_STORAGE_UNSAFE");
|
|
122
|
+
assertDirectory();
|
|
123
|
+
fs.unlinkSync(file);
|
|
124
|
+
}
|
|
125
|
+
} };
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (descriptor !== undefined) { try { fs.closeSync(descriptor); } catch {} }
|
|
128
|
+
if (error.code !== "EEXIST") throw serviceFailure("SERVICE_STORAGE_UNSAFE");
|
|
129
|
+
let raw;
|
|
130
|
+
let existing;
|
|
131
|
+
let before;
|
|
132
|
+
try {
|
|
133
|
+
assertDirectory();
|
|
134
|
+
before = fs.lstatSync(file, { bigint: true });
|
|
135
|
+
raw = readRegularConfigFile(file, { maxBytes: 4096 });
|
|
136
|
+
existing = JSON.parse(raw);
|
|
137
|
+
} catch { throw serviceFailure("SERVICE_STORAGE_UNSAFE"); }
|
|
138
|
+
if (existing?.owner !== RELAY_SERVICE_OWNER || existing.schemaVersion !== 1
|
|
139
|
+
|| !Number.isSafeInteger(existing.pid) || existing.pid < 1 || typeof existing.nonce !== "string"
|
|
140
|
+
|| typeof existing.processStart !== "string" || !existing.processStart) throw serviceFailure("SERVICE_STORAGE_UNSAFE");
|
|
141
|
+
if (isAlive(existing.pid)) {
|
|
142
|
+
const currentIdentity = identity(existing.pid);
|
|
143
|
+
if (!currentIdentity) throw serviceFailure("SERVICE_PROCESS_IDENTITY_UNAVAILABLE");
|
|
144
|
+
if (currentIdentity === existing.processStart) return { acquired: false, release() {} };
|
|
145
|
+
}
|
|
146
|
+
assertDirectory();
|
|
147
|
+
if (readRegularConfigFile(file, { maxBytes: 4096 }) !== raw || !sameFile(before)) throw serviceFailure("SERVICE_STORAGE_UNSAFE");
|
|
148
|
+
try { fs.unlinkSync(file); }
|
|
149
|
+
catch { throw serviceFailure("SERVICE_STORAGE_UNSAFE"); }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
throw serviceFailure("SERVICE_LOCK_OCCUPIED");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function wait(ms, signal) {
|
|
156
|
+
return new Promise((resolve) => {
|
|
157
|
+
if (signal?.aborted) return resolve();
|
|
158
|
+
const finish = () => { clearTimeout(timer); signal?.removeEventListener("abort", finish); resolve(); };
|
|
159
|
+
const timer = setTimeout(finish, ms);
|
|
160
|
+
signal?.addEventListener("abort", finish, { once: true });
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function serviceEnvironment(env, root) {
|
|
165
|
+
return { ...Object.fromEntries(Object.entries(env).filter(([name, value]) => value !== undefined
|
|
166
|
+
&& !/^(?:AGENT_RELAY_|CODEX_BRIDGE_|CLAUDE_BRIDGE_|CODEX_NATIVE_RELAY_|CODEX_APP_TOOLS_PIPE_PATH$|CODEX_APP_SERVER_URL$|CODEX_INTERNAL_ORIGINATOR_OVERRIDE$|CODEX_THREAD_ID$|CODEX_SESSION_ID$|CODEX_RELAY_ID$)/i.test(name))), AGENT_RELAY_DIRECTORY: root };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function runBackgroundRelayService({ env = process.env, platform = process.platform, root = ROOT,
|
|
170
|
+
spawn = spawnProcess, signal, sleep = wait, now = Date.now, readConfig = readRelayConfiguration,
|
|
171
|
+
writeState = writeRelayJson, acquireLock = acquireRelayServiceLock, changed = () => sourceHash() !== SOURCE_HASH,
|
|
172
|
+
readRuntime = readRelayServiceRuntime, callRelay = callBackgroundRelay, watchIntervalMs = 2000 } = {}) {
|
|
173
|
+
let config;
|
|
174
|
+
try { config = readConfig({ env, platform }); }
|
|
175
|
+
catch { throw serviceFailure("SERVICE_CONFIG_INVALID"); }
|
|
176
|
+
if (!config.enabled) return { status: "disabled" };
|
|
177
|
+
const lock = acquireLock(config.paths.root);
|
|
178
|
+
if (!lock.acquired) return { status: "already-running" };
|
|
179
|
+
const statusFile = path.join(config.paths.root, "service-status.json");
|
|
180
|
+
const errorFile = path.join(config.paths.root, "last-service-error.json");
|
|
181
|
+
let failures = 0;
|
|
182
|
+
const state = (status, extra = {}) => writeState(statusFile, { schemaVersion: 1, owner: RELAY_SERVICE_OWNER,
|
|
183
|
+
pid: process.pid, status, updatedAt: new Date(now()).toISOString(), ...extra });
|
|
184
|
+
const record = (code, extra = {}) => writeState(errorFile, { schemaVersion: 1, owner: RELAY_SERVICE_OWNER,
|
|
185
|
+
code, message: MESSAGES[code], at: new Date(now()).toISOString(), ...extra });
|
|
186
|
+
try {
|
|
187
|
+
while (!signal?.aborted) {
|
|
188
|
+
try { config = readConfig({ env, platform }); }
|
|
189
|
+
catch { record("SERVICE_CONFIG_INVALID"); state("failed"); throw serviceFailure("SERVICE_CONFIG_INVALID"); }
|
|
190
|
+
if (!config.enabled) { state("disabled"); return { status: "disabled" }; }
|
|
191
|
+
const runtime = readRuntime(config.paths.root, { root, nodeExecutable: stableNodeBin(env) });
|
|
192
|
+
if (path.resolve(runtime.packageRoot) !== path.resolve(root)) {
|
|
193
|
+
state("reloading");
|
|
194
|
+
return { status: "reload", moduleFile: path.join(runtime.packageRoot, "src", "background-relay-service.mjs") };
|
|
195
|
+
}
|
|
196
|
+
const runtimeId = relayServiceRuntimeId(runtime);
|
|
197
|
+
const childEnv = { ...serviceEnvironment(env, config.paths.root), ...runtime.environment, AGENT_RELAY_RUNTIME_ID: runtimeId };
|
|
198
|
+
const started = now();
|
|
199
|
+
state("starting", { consecutiveFailures: failures, runtimeId });
|
|
200
|
+
const outcome = await new Promise((resolve) => {
|
|
201
|
+
let child;
|
|
202
|
+
let settled = false;
|
|
203
|
+
let checking = false;
|
|
204
|
+
let poll;
|
|
205
|
+
let diagnostic = null;
|
|
206
|
+
let stderr = "";
|
|
207
|
+
let stderrBytes = 0;
|
|
208
|
+
let stderrTruncated = false;
|
|
209
|
+
const captureDiagnostic = (chunk) => {
|
|
210
|
+
if (settled) return;
|
|
211
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
212
|
+
const remaining = DIAGNOSTIC_LIMIT - stderrBytes;
|
|
213
|
+
if (bytes.length > remaining) stderrTruncated = true;
|
|
214
|
+
if (remaining <= 0) return;
|
|
215
|
+
const bounded = bytes.subarray(0, remaining);
|
|
216
|
+
stderrBytes += bounded.length;
|
|
217
|
+
stderr += bounded.toString("utf8");
|
|
218
|
+
let newline;
|
|
219
|
+
while ((newline = stderr.indexOf("\n")) >= 0) {
|
|
220
|
+
diagnostic = daemonDiagnostic(stderr.slice(0, newline)) ?? diagnostic;
|
|
221
|
+
stderr = stderr.slice(newline + 1);
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
const finish = (value) => {
|
|
225
|
+
if (settled) return;
|
|
226
|
+
settled = true;
|
|
227
|
+
clearInterval(poll);
|
|
228
|
+
signal?.removeEventListener("abort", stop);
|
|
229
|
+
diagnostic = daemonDiagnostic(stderr) ?? diagnostic;
|
|
230
|
+
resolve({ ...value, ...(diagnostic ? { diagnostic } : {}), ...(stderrTruncated ? { stderrTruncated: true } : {}) });
|
|
231
|
+
};
|
|
232
|
+
const stop = () => { try { child?.kill("SIGTERM"); } catch {} };
|
|
233
|
+
signal?.addEventListener("abort", stop, { once: true });
|
|
234
|
+
try {
|
|
235
|
+
child = spawn(runtime.nodeExecutable, [path.join(root, "src", "background-relay-daemon.mjs")], {
|
|
236
|
+
cwd: root, env: childEnv, windowsHide: true, shell: false, stdio: ["ignore", "ignore", "pipe"],
|
|
237
|
+
});
|
|
238
|
+
child.stderr?.on("error", () => {});
|
|
239
|
+
child.stderr?.on("data", captureDiagnostic);
|
|
240
|
+
child.on("spawn", () => {
|
|
241
|
+
if (signal?.aborted) return stop();
|
|
242
|
+
try { state("running", { daemonPid: Number.isSafeInteger(child.pid) ? child.pid : null, consecutiveFailures: failures, runtimeId }); }
|
|
243
|
+
catch { finish({ code: null, failure: "SERVICE_STORAGE_UNSAFE" }); stop(); return; }
|
|
244
|
+
poll = setInterval(async () => {
|
|
245
|
+
if (checking || settled || signal?.aborted) return;
|
|
246
|
+
checking = true;
|
|
247
|
+
try {
|
|
248
|
+
const desired = readRuntime(config.paths.root, { root, nodeExecutable: stableNodeBin(env) });
|
|
249
|
+
if (relayServiceRuntimeId(desired) !== runtimeId || changed()) {
|
|
250
|
+
await callRelay("drain", {}, { env: childEnv, connection: { endpoint: config.endpoint, token: config.token, workerId: null }, timeoutMs: 1000 });
|
|
251
|
+
}
|
|
252
|
+
} catch {
|
|
253
|
+
if (!settled) { try { record("SERVICE_MIGRATION_PENDING"); } catch {} }
|
|
254
|
+
} finally { checking = false; }
|
|
255
|
+
}, watchIntervalMs);
|
|
256
|
+
});
|
|
257
|
+
child.on("error", () => finish({ code: null, failure: "DAEMON_START_FAILED" }));
|
|
258
|
+
child.on("close", (code) => finish({ code: Number.isInteger(code) ? code : null }));
|
|
259
|
+
} catch { finish({ code: null, failure: "DAEMON_START_FAILED" }); }
|
|
260
|
+
});
|
|
261
|
+
if (signal?.aborted) { state("stopped"); return { status: "stopped" }; }
|
|
262
|
+
if (outcome.failure === "SERVICE_STORAGE_UNSAFE") throw serviceFailure("SERVICE_STORAGE_UNSAFE");
|
|
263
|
+
if (outcome.code === 75) {
|
|
264
|
+
failures = 0;
|
|
265
|
+
const desired = readRuntime(config.paths.root, { root, nodeExecutable: stableNodeBin(env) });
|
|
266
|
+
if (changed() || path.resolve(desired.packageRoot) !== path.resolve(root)) {
|
|
267
|
+
state("reloading");
|
|
268
|
+
return { status: "reload", moduleFile: path.join(desired.packageRoot, "src", "background-relay-service.mjs") };
|
|
269
|
+
}
|
|
270
|
+
state("restarting", { reason: "idle-code-reload", restartDelayMs: 250 });
|
|
271
|
+
await sleep(250, signal);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
failures = now() - started >= 60000 ? 1 : failures + 1;
|
|
275
|
+
const delay = Math.min(30000, 500 * 2 ** Math.min(failures - 1, 10));
|
|
276
|
+
const code = outcome.failure ?? "DAEMON_EXITED";
|
|
277
|
+
record(code, { daemonExitCode: outcome.code, consecutiveFailures: failures, restartDelayMs: delay,
|
|
278
|
+
...(outcome.diagnostic ? { daemonDiagnostic: outcome.diagnostic } : {}),
|
|
279
|
+
...(outcome.stderrTruncated ? { daemonDiagnosticTruncated: true } : {}) });
|
|
280
|
+
state("restarting", { code, consecutiveFailures: failures, restartDelayMs: delay });
|
|
281
|
+
await sleep(delay, signal);
|
|
282
|
+
}
|
|
283
|
+
state("stopped");
|
|
284
|
+
return { status: "stopped" };
|
|
285
|
+
} finally { lock.release(); }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (!new URL(import.meta.url).search && isMainModule(process.argv[1], import.meta.url)) {
|
|
289
|
+
const controller = new AbortController();
|
|
290
|
+
const stop = () => controller.abort();
|
|
291
|
+
process.on("SIGINT", stop);
|
|
292
|
+
process.on("SIGTERM", stop);
|
|
293
|
+
try {
|
|
294
|
+
let run = runBackgroundRelayService;
|
|
295
|
+
while (true) {
|
|
296
|
+
const result = await run({ signal: controller.signal });
|
|
297
|
+
if (result.status !== "reload") break;
|
|
298
|
+
run = (await import(`${result.moduleFile ? pathToFileURL(result.moduleFile).href : import.meta.url}?reload=${randomUUID()}`)).runBackgroundRelayService;
|
|
299
|
+
}
|
|
300
|
+
} catch (error) {
|
|
301
|
+
const code = Object.hasOwn(MESSAGES, error.code) ? error.code : "SERVICE_RUNTIME_FAILED";
|
|
302
|
+
try { writeRelayJson(path.join(relayPaths().root, "last-service-error.json"), {
|
|
303
|
+
schemaVersion: 1, owner: RELAY_SERVICE_OWNER, code, message: MESSAGES[code], at: new Date().toISOString(),
|
|
304
|
+
}); } catch {}
|
|
305
|
+
process.stderr.write(`${code}: ${MESSAGES[code]}\n`);
|
|
306
|
+
process.exitCode = 1;
|
|
307
|
+
} finally {
|
|
308
|
+
process.removeListener("SIGINT", stop);
|
|
309
|
+
process.removeListener("SIGTERM", stop);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const caches = new WeakMap();
|
|
8
|
+
const writes = new WeakMap();
|
|
9
|
+
const helper = fileURLToPath(new URL("./background-relay-storage.ps1", import.meta.url));
|
|
10
|
+
const unsafe = () => Object.assign(new Error("Relay storage must be owned by the current user and inaccessible to other users"), { code: "WORKER_STORAGE_UNSAFE", phase: "storage", deliveryState: "not_sent", sent: false });
|
|
11
|
+
const changed = () => Object.assign(new Error("Relay storage kept changing during privacy verification; retry after the current update finishes"), { code: "WORKER_STORAGE_CHANGED", phase: "storage", deliveryState: "not_sent", sent: false });
|
|
12
|
+
|
|
13
|
+
function targetPath(input) {
|
|
14
|
+
if (typeof input !== "string" || !path.isAbsolute(input) || input.includes("\0")) throw unsafe();
|
|
15
|
+
const target = path.resolve(input);
|
|
16
|
+
if (target === path.parse(target).root || target === path.resolve(os.homedir())) throw unsafe();
|
|
17
|
+
return target;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function inspectPath(target, kind, missing = false) {
|
|
21
|
+
let stat;
|
|
22
|
+
try { stat = fs.lstatSync(target, { bigint: true }); }
|
|
23
|
+
catch (error) { if (missing && error.code === "ENOENT") return null; throw unsafe(); }
|
|
24
|
+
if (stat.isSymbolicLink() || kind === "directory" && !stat.isDirectory() || kind === "file" && (!stat.isFile() || stat.nlink !== 1n)) throw unsafe();
|
|
25
|
+
return stat;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const identity = stat => [stat.dev, stat.ino, stat.birthtimeNs, stat.ctimeNs, stat.mtimeNs, stat.size, stat.mode, stat.nlink].join(":");
|
|
29
|
+
const cacheKey = (target, kind) => `${kind}:${target.toLowerCase()}`;
|
|
30
|
+
const sameNode = (left, right) => left.dev === right.dev && left.ino === right.ino;
|
|
31
|
+
const sameObject = (left, right) => ["dev", "ino", "birthtimeNs", "uid", "mode"].every(key => left[key] === right[key])
|
|
32
|
+
&& (left.isDirectory() && right.isDirectory() || left.nlink === right.nlink);
|
|
33
|
+
const validReadback = (value, kind) => value?.ownerTrusted === true && value?.allowedPrincipalsOnly === true && value?.currentUserFullControl === true
|
|
34
|
+
&& value?.noReparse === true && (kind !== "directory" || value?.protected === true) && value?.kind === kind;
|
|
35
|
+
|
|
36
|
+
function runWindowsHelper(target, kind, mode, run, input, timeout = 15000) {
|
|
37
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => key.toLowerCase() !== "psmodulepath"));
|
|
38
|
+
const windows = process.env.SystemRoot || "C:\\Windows";
|
|
39
|
+
env.PSModulePath = path.win32.join(windows, "System32", "WindowsPowerShell", "v1.0", "Modules");
|
|
40
|
+
try {
|
|
41
|
+
const response = run(path.win32.join(windows, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"),
|
|
42
|
+
["-NoProfile", "-NonInteractive", "-File", helper, "-LiteralPath", target, "-Kind", kind, "-Mode", mode],
|
|
43
|
+
{ env, windowsHide: true, shell: false, stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"], input,
|
|
44
|
+
timeout, maxBuffer: mode === "batch" ? 2 * 1024 * 1024 : 16384, encoding: "utf8" });
|
|
45
|
+
if (response && typeof response === "object" && !Buffer.isBuffer(response) && (response.status ?? response.exitCode ?? 0) !== 0) throw unsafe();
|
|
46
|
+
return JSON.parse(typeof response === "object" && !Buffer.isBuffer(response) ? response.stdout : String(response));
|
|
47
|
+
} catch { throw unsafe(); }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function windowsPrivacy(target, kind, mode, stat, run) {
|
|
51
|
+
let cache = caches.get(run);
|
|
52
|
+
if (!cache) { cache = new Map(); caches.set(run, cache); }
|
|
53
|
+
const key = cacheKey(target, kind);
|
|
54
|
+
const previous = cache.get(key);
|
|
55
|
+
if (previous?.identity === identity(stat)) return stat;
|
|
56
|
+
const deadline = Date.now() + 15000;
|
|
57
|
+
try {
|
|
58
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
59
|
+
const remaining = deadline - Date.now();
|
|
60
|
+
if (remaining <= 0) throw changed();
|
|
61
|
+
const result = runWindowsHelper(target, kind, mode, run, undefined, remaining);
|
|
62
|
+
const current = inspectPath(target, kind);
|
|
63
|
+
if (current.dev !== stat.dev || current.ino !== stat.ino || current.birthtimeNs !== stat.birthtimeNs || !validReadback(result, kind)) throw unsafe();
|
|
64
|
+
if (mode === "assert" && identity(current) !== identity(stat)) { stat = current; continue; }
|
|
65
|
+
cache.set(key, { identity: identity(current) });
|
|
66
|
+
if (cache.size > 2048) cache.delete(cache.keys().next().value);
|
|
67
|
+
return current;
|
|
68
|
+
}
|
|
69
|
+
throw changed();
|
|
70
|
+
} catch (error) { cache.delete(key); throw error; }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function assertPrivacy(target, kind, stat, { platform = process.platform, run = execFileSync } = {}) {
|
|
74
|
+
if (platform === "win32") return windowsPrivacy(target, kind, "assert", stat, run);
|
|
75
|
+
if (typeof process.getuid !== "function" || stat.uid !== BigInt(process.getuid()) || (stat.mode & 0o7777n) !== (kind === "directory" ? 0o700n : 0o600n)) throw unsafe();
|
|
76
|
+
return stat;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function assertPrivateRelayDirectory(input, options = {}) {
|
|
80
|
+
const target = targetPath(input);
|
|
81
|
+
return assertPrivacy(target, "directory", inspectPath(target, "directory"), options);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function assertPrivateRelayFile(input, { missing = false, ...options } = {}) {
|
|
85
|
+
const target = targetPath(input);
|
|
86
|
+
assertPrivateRelayDirectory(path.dirname(target), options);
|
|
87
|
+
const stat = inspectPath(target, "file", missing);
|
|
88
|
+
return stat === null ? null : assertPrivacy(target, "file", stat, options);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function assertPrivateRelayFiles(inputs, { platform = process.platform, run = execFileSync } = {}) {
|
|
92
|
+
if (!Array.isArray(inputs) || inputs.length > 1024) throw unsafe();
|
|
93
|
+
if (!inputs.length) return [];
|
|
94
|
+
const files = inputs.map(targetPath);
|
|
95
|
+
const directory = path.dirname(files[0]);
|
|
96
|
+
const names = files.map(file => path.basename(file));
|
|
97
|
+
if (files.some(file => path.dirname(file) !== directory) || names.some(name => /[\\/:]/.test(name))
|
|
98
|
+
|| new Set(files.map(file => platform === "win32" ? file.toLowerCase() : file)).size !== files.length) throw unsafe();
|
|
99
|
+
const input = JSON.stringify({ files: names });
|
|
100
|
+
if (Buffer.byteLength(input) > 1024 * 1024) throw unsafe();
|
|
101
|
+
const before = inspectPath(directory, "directory");
|
|
102
|
+
const states = files.map(file => inspectPath(file, "file"));
|
|
103
|
+
if (platform !== "win32") {
|
|
104
|
+
assertPrivacy(directory, "directory", before, { platform, run });
|
|
105
|
+
return states.map((stat, index) => assertPrivacy(files[index], "file", stat, { platform, run }));
|
|
106
|
+
}
|
|
107
|
+
let cache = caches.get(run);
|
|
108
|
+
if (!cache) { cache = new Map(); caches.set(run, cache); }
|
|
109
|
+
const pending = files.flatMap((file, index) => cache.get(cacheKey(file, "file"))?.identity === identity(states[index]) ? [] : [index]);
|
|
110
|
+
if (!pending.length && cache.get(cacheKey(directory, "directory"))?.identity === identity(before)) return states;
|
|
111
|
+
try {
|
|
112
|
+
const result = runWindowsHelper(directory, "directory", "batch", run, JSON.stringify({ files: pending.map(index => names[index]) }));
|
|
113
|
+
if (!validReadback(result?.directory, "directory") || !Array.isArray(result?.files) || result.files.length !== pending.length) throw unsafe();
|
|
114
|
+
for (let index = 0; index < pending.length; index++) {
|
|
115
|
+
if (result.files[index]?.name !== names[pending[index]] || !validReadback(result.files[index]?.state, "file")) throw unsafe();
|
|
116
|
+
}
|
|
117
|
+
const currentDirectory = inspectPath(directory, "directory");
|
|
118
|
+
const current = files.map(file => inspectPath(file, "file"));
|
|
119
|
+
if (identity(before) !== identity(currentDirectory) || current.some((stat, index) => identity(stat) !== identity(states[index]))) throw unsafe();
|
|
120
|
+
cache.set(cacheKey(directory, "directory"), { identity: identity(currentDirectory) });
|
|
121
|
+
for (const index of pending) cache.set(cacheKey(files[index], "file"), { identity: identity(current[index]) });
|
|
122
|
+
while (cache.size > 2048) cache.delete(cache.keys().next().value);
|
|
123
|
+
return current;
|
|
124
|
+
} catch {
|
|
125
|
+
cache.delete(cacheKey(directory, "directory"));
|
|
126
|
+
for (const file of files) cache.delete(cacheKey(file, "file"));
|
|
127
|
+
throw unsafe();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function protectPrivateRelayDirectory(input, { platform = process.platform, run = execFileSync } = {}) {
|
|
132
|
+
const target = targetPath(input);
|
|
133
|
+
try { fs.mkdirSync(target, { recursive: true, mode: 0o700 }); } catch { throw unsafe(); }
|
|
134
|
+
const before = inspectPath(target, "directory");
|
|
135
|
+
if (platform === "win32") return windowsPrivacy(target, "directory", "protect", before, run);
|
|
136
|
+
if (typeof process.getuid !== "function" || before.uid !== BigInt(process.getuid())) throw unsafe();
|
|
137
|
+
let descriptor;
|
|
138
|
+
try {
|
|
139
|
+
descriptor = fs.openSync(target, fs.constants.O_RDONLY | (fs.constants.O_DIRECTORY ?? 0) | (fs.constants.O_NOFOLLOW ?? 0));
|
|
140
|
+
const opened = fs.fstatSync(descriptor, { bigint: true });
|
|
141
|
+
if (!opened.isDirectory() || opened.dev !== before.dev || opened.ino !== before.ino || opened.uid !== before.uid) throw unsafe();
|
|
142
|
+
if ((opened.mode & 0o7777n) !== 0o700n) fs.fchmodSync(descriptor, 0o700);
|
|
143
|
+
} catch { throw unsafe(); }
|
|
144
|
+
finally { if (descriptor !== undefined) fs.closeSync(descriptor); }
|
|
145
|
+
const after = inspectPath(target, "directory");
|
|
146
|
+
if (before.dev !== after.dev || before.ino !== after.ino) throw unsafe();
|
|
147
|
+
return assertPrivacy(target, "directory", after, { platform, run });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function beginPrivateRelayWrite(input, { platform = process.platform, run = execFileSync } = {}) {
|
|
151
|
+
const file = targetPath(input);
|
|
152
|
+
const directory = path.dirname(file);
|
|
153
|
+
protectPrivateRelayDirectory(directory, { platform, run });
|
|
154
|
+
assertPrivateRelayFile(file, { missing: true, platform, run });
|
|
155
|
+
const before = assertPrivateRelayDirectory(directory, { platform, run });
|
|
156
|
+
const ticket = Object.freeze({});
|
|
157
|
+
writes.set(ticket, { file, directory, before, platform, run });
|
|
158
|
+
return ticket;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function bindPrivateRelayWrite(ticket, temporary, descriptor) {
|
|
162
|
+
const pending = writes.get(ticket);
|
|
163
|
+
if (!pending || pending.temporary || typeof temporary !== "string" || path.dirname(temporary) !== pending.directory
|
|
164
|
+
|| !path.basename(temporary).startsWith(`.${path.basename(pending.file)}.`) || !temporary.endsWith(".tmp") || !Number.isInteger(descriptor)) throw unsafe();
|
|
165
|
+
const opened = fs.fstatSync(descriptor, { bigint: true });
|
|
166
|
+
const current = inspectPath(temporary, "file");
|
|
167
|
+
if (!opened.isFile() || opened.nlink !== 1n || identity(opened) !== identity(current)) throw unsafe();
|
|
168
|
+
if (pending.platform !== "win32") assertPrivacy(temporary, "file", current, pending);
|
|
169
|
+
pending.temporary = temporary;
|
|
170
|
+
pending.created = opened;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function abortPrivateRelayWrite(ticket) {
|
|
174
|
+
const pending = writes.get(ticket);
|
|
175
|
+
if (!pending) return;
|
|
176
|
+
writes.delete(ticket);
|
|
177
|
+
const cache = caches.get(pending.run);
|
|
178
|
+
cache?.delete(cacheKey(pending.directory, "directory"));
|
|
179
|
+
cache?.delete(cacheKey(pending.file, "file"));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function finishPrivateRelayWrite(ticket) {
|
|
183
|
+
const pending = writes.get(ticket);
|
|
184
|
+
if (!pending?.created) throw unsafe();
|
|
185
|
+
try {
|
|
186
|
+
const directory = inspectPath(pending.directory, "directory");
|
|
187
|
+
const file = inspectPath(pending.file, "file");
|
|
188
|
+
if (pending.created.size !== file.size || fs.existsSync(pending.temporary)) throw unsafe();
|
|
189
|
+
if (pending.platform === "win32") {
|
|
190
|
+
if (!sameObject(pending.before, directory) || !sameObject(pending.created, file) || pending.created.mtimeNs !== file.mtimeNs) throw unsafe();
|
|
191
|
+
const cache = caches.get(pending.run);
|
|
192
|
+
if (cache?.get(cacheKey(pending.directory, "directory"))?.identity !== identity(pending.before)) throw unsafe();
|
|
193
|
+
cache.set(cacheKey(pending.directory, "directory"), { identity: identity(directory) });
|
|
194
|
+
cache.set(cacheKey(pending.file, "file"), { identity: identity(file) });
|
|
195
|
+
if (cache.size > 2048) cache.delete(cache.keys().next().value);
|
|
196
|
+
} else {
|
|
197
|
+
if (!sameNode(pending.before, directory) || !sameNode(pending.created, file)) throw unsafe();
|
|
198
|
+
assertPrivacy(pending.directory, "directory", directory, pending);
|
|
199
|
+
assertPrivacy(pending.file, "file", file, pending);
|
|
200
|
+
}
|
|
201
|
+
writes.delete(ticket);
|
|
202
|
+
} catch (error) { abortPrivateRelayWrite(ticket); throw error; }
|
|
203
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
param(
|
|
2
|
+
[Parameter(Mandatory = $true)][string]$LiteralPath,
|
|
3
|
+
[Parameter(Mandatory = $true)][ValidateSet('directory', 'file')][string]$Kind,
|
|
4
|
+
[Parameter(Mandatory = $true)][ValidateSet('protect', 'assert', 'batch')][string]$Mode
|
|
5
|
+
)
|
|
6
|
+
$ErrorActionPreference = 'Stop'
|
|
7
|
+
[Console]::InputEncoding = New-Object Text.UTF8Encoding($false)
|
|
8
|
+
[Console]::OutputEncoding = New-Object Text.UTF8Encoding($false)
|
|
9
|
+
function Get-PrivateStorageState([string]$EntryPath, [string]$EntryKind, [bool]$Protect) {
|
|
10
|
+
$item = Get-Item -LiteralPath $EntryPath -Force
|
|
11
|
+
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Unsafe path' }
|
|
12
|
+
if (($EntryKind -eq 'directory') -ne [bool]$item.PSIsContainer) { throw 'Wrong path type' }
|
|
13
|
+
if ($Protect -and $EntryKind -ne 'directory') { throw 'Invalid protection scope' }
|
|
14
|
+
$acl = Get-Acl -LiteralPath $EntryPath
|
|
15
|
+
$owner = $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value
|
|
16
|
+
if ($trusted -notcontains $owner) { throw 'Untrusted owner' }
|
|
17
|
+
if ($Protect) {
|
|
18
|
+
$replacement = New-Object Security.AccessControl.DirectorySecurity
|
|
19
|
+
$replacement.SetAccessRuleProtection($true, $false)
|
|
20
|
+
foreach ($sidValue in $trusted) {
|
|
21
|
+
$sid = New-Object Security.Principal.SecurityIdentifier($sidValue)
|
|
22
|
+
$rule = New-Object Security.AccessControl.FileSystemAccessRule($sid, [Security.AccessControl.FileSystemRights]::FullControl, ([Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [Security.AccessControl.InheritanceFlags]::ObjectInherit), [Security.AccessControl.PropagationFlags]::None, [Security.AccessControl.AccessControlType]::Allow)
|
|
23
|
+
$replacement.AddAccessRule($rule)
|
|
24
|
+
}
|
|
25
|
+
[IO.Directory]::SetAccessControl($EntryPath, $replacement)
|
|
26
|
+
$acl = Get-Acl -LiteralPath $EntryPath
|
|
27
|
+
}
|
|
28
|
+
$ownerTrusted = $trusted -contains $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value
|
|
29
|
+
$allowedOnly = $true
|
|
30
|
+
$userFullControl = $false
|
|
31
|
+
$rules = $acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])
|
|
32
|
+
foreach ($rule in $rules) {
|
|
33
|
+
if ($trusted -notcontains $rule.IdentityReference.Value -or $rule.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow) { $allowedOnly = $false }
|
|
34
|
+
if ($rule.IdentityReference.Value -eq $currentSid.Value -and $rule.AccessControlType -eq [Security.AccessControl.AccessControlType]::Allow -and ($rule.FileSystemRights -band [Security.AccessControl.FileSystemRights]::FullControl) -eq [Security.AccessControl.FileSystemRights]::FullControl -and ($rule.PropagationFlags -band [Security.AccessControl.PropagationFlags]::InheritOnly) -eq 0) { $userFullControl = $true }
|
|
35
|
+
}
|
|
36
|
+
$item = Get-Item -LiteralPath $EntryPath -Force
|
|
37
|
+
return @{ ownerTrusted = $ownerTrusted; allowedPrincipalsOnly = $allowedOnly; currentUserFullControl = $userFullControl; protected = $acl.AreAccessRulesProtected; noReparse = (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0); kind = $EntryKind }
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
$currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User
|
|
41
|
+
$trusted = @($currentSid.Value, 'S-1-5-18', 'S-1-5-32-544') | Select-Object -Unique
|
|
42
|
+
if ($Mode -eq 'batch') {
|
|
43
|
+
if ($Kind -ne 'directory') { throw 'Invalid batch scope' }
|
|
44
|
+
$encoded = [Console]::In.ReadToEnd()
|
|
45
|
+
if ($encoded.Length -gt 1048576) { throw 'Batch limit exceeded' }
|
|
46
|
+
$request = ConvertFrom-Json -InputObject $encoded
|
|
47
|
+
if ($request.files -isnot [Array] -or $request.files.Count -gt 1024) { throw 'Invalid batch' }
|
|
48
|
+
$directoryState = Get-PrivateStorageState $LiteralPath 'directory' $false
|
|
49
|
+
if (-not ($directoryState.ownerTrusted -and $directoryState.allowedPrincipalsOnly -and $directoryState.currentUserFullControl -and $directoryState.protected -and $directoryState.noReparse)) { throw 'Unsafe directory' }
|
|
50
|
+
$results = New-Object 'System.Collections.Generic.List[object]'
|
|
51
|
+
foreach ($entryName in $request.files) {
|
|
52
|
+
if ($entryName -isnot [string] -or $entryName.Length -eq 0 -or $entryName -match '[\\/:]' -or $entryName -eq '.' -or $entryName -eq '..') { throw 'Invalid batch path' }
|
|
53
|
+
$entryPath = [IO.Path]::Combine($LiteralPath, $entryName)
|
|
54
|
+
$entryState = Get-PrivateStorageState $entryPath 'file' $false
|
|
55
|
+
$results.Add(@{ name = $entryName; state = $entryState })
|
|
56
|
+
}
|
|
57
|
+
@{ directory = $directoryState; files = @($results.ToArray()) } | ConvertTo-Json -Compress -Depth 6
|
|
58
|
+
} else {
|
|
59
|
+
Get-PrivateStorageState $LiteralPath $Kind ($Mode -eq 'protect') | ConvertTo-Json -Compress
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
[Console]::Error.WriteLine('Private relay storage verification failed')
|
|
63
|
+
exit 1
|
|
64
|
+
}
|