machine-bridge-mcp 0.2.4 → 0.3.3

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/src/local/log.mjs CHANGED
@@ -1,7 +1,6 @@
1
1
  import process from "node:process";
2
2
 
3
3
  const COLORS = {
4
- dim: "\x1b[2m",
5
4
  reset: "\x1b[0m",
6
5
  blue: "\x1b[34m",
7
6
  green: "\x1b[32m",
@@ -10,10 +9,14 @@ const COLORS = {
10
9
  gray: "\x1b[90m",
11
10
  };
12
11
 
12
+ const SENSITIVE_KEY = /(authorization|cookie|password|passwd|secret|token|api[_-]?key|private[_-]?key|credential)/i;
13
+ const SECRET_VALUE = /\b(?:mcp_password|daemon_secret|token_version|mcp_at|mcp_code)_[A-Za-z0-9_-]+\b/g;
14
+ const BEARER_VALUE = /\bBearer\s+[A-Za-z0-9._~+\/-]+=*\b/gi;
15
+
13
16
  export function createLogger(options = {}) {
14
17
  const quiet = Boolean(options.quiet);
15
18
  const verbose = Boolean(options.verbose);
16
- const component = options.component ? String(options.component) : "cli";
19
+ const component = sanitizeLogText(options.component ? String(options.component) : "cli");
17
20
  const useColor = shouldUseColor(options);
18
21
 
19
22
  const write = (stream, level, label, color, message, fields) => {
@@ -21,7 +24,7 @@ export function createLogger(options = {}) {
21
24
  if (level === "debug" && !verbose) return;
22
25
  const prefix = useColor ? `${color}${label}${COLORS.reset}` : label;
23
26
  const suffix = formatFields(fields);
24
- stream.write(`${prefix} ${component}: ${String(message)}${suffix}\n`);
27
+ stream.write(`${prefix} ${component}: ${sanitizeLogText(message)}${suffix}\n`);
25
28
  };
26
29
 
27
30
  return {
@@ -39,18 +42,37 @@ export function createLogger(options = {}) {
39
42
  }
40
43
 
41
44
  export function redactSecret(value) {
42
- const text = String(value || "");
43
- if (!text) return "<empty>";
44
- if (text.length <= 12) return "<redacted>";
45
- return `${text.slice(0, 6)}...${text.slice(-4)}`;
45
+ return value ? "<redacted>" : "<empty>";
46
46
  }
47
47
 
48
48
  export function formatFields(fields) {
49
49
  if (!fields || typeof fields !== "object" || !Object.keys(fields).length) return "";
50
- return ` ${JSON.stringify(fields, (_key, value) => {
51
- if (value instanceof Error) return value.message;
52
- return value;
53
- })}`;
50
+ return ` ${JSON.stringify(sanitizeLogValue(fields))}`;
51
+ }
52
+
53
+ export function sanitizeLogValue(value, key = "", seen = new WeakSet(), depth = 0) {
54
+ if (SENSITIVE_KEY.test(key)) return "<redacted>";
55
+ if (value instanceof Error) return sanitizeLogText(value.message || value.name);
56
+ if (typeof value === "string") return sanitizeLogText(value);
57
+ if (typeof value === "bigint") return value.toString();
58
+ if (value === null || typeof value !== "object") return value;
59
+ if (depth >= 8) return "<max-depth>";
60
+ if (seen.has(value)) return "<circular>";
61
+ seen.add(value);
62
+ if (Array.isArray(value)) return value.slice(0, 100).map(item => sanitizeLogValue(item, "", seen, depth + 1));
63
+ const out = {};
64
+ for (const [childKey, childValue] of Object.entries(value).slice(0, 100)) {
65
+ out[childKey] = sanitizeLogValue(childValue, childKey, seen, depth + 1);
66
+ }
67
+ return out;
68
+ }
69
+
70
+ export function sanitizeLogText(value) {
71
+ return String(value ?? "")
72
+ .replace(SECRET_VALUE, "<redacted-secret>")
73
+ .replace(BEARER_VALUE, "Bearer <redacted>")
74
+ .replace(/[\r\n\t]/g, match => match === "\t" ? "\\t" : "\\n")
75
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "?");
54
76
  }
55
77
 
56
78
  function shouldUseColor(options) {
@@ -1,17 +1,21 @@
1
- import { createHash } from "node:crypto";
2
- import http from "node:http";
3
- import { mkdtemp, rm } from "node:fs/promises";
1
+ import { mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
4
2
  import { tmpdir } from "node:os";
5
3
  import { join } from "node:path";
4
+ import { run } from "./shell.mjs";
5
+ import { parseArgs, validateCommandOptions, validatePositionals } from "./cli.mjs";
6
6
  import { daemonSelfTest } from "./daemon.mjs";
7
- import { startLocalApiServer } from "./api-server.mjs";
8
- import { createLogger, redactSecret } from "./log.mjs";
9
- import { acquireDaemonLock, ensureLocalApiKey, ensureWorkerSecrets, loadState, previewSecret, redactState, saveState, selectedWorkspace, setSelectedWorkspace } from "./state.mjs";
7
+ import { formatFields, redactSecret, sanitizeLogText } from "./log.mjs";
8
+ import { systemdQuote, trimAutostartLogs } from "./service.mjs";
9
+ import { acquireDaemonLock, acquireStartupLock, ensureWorkerSecrets, loadState, previewSecret, redactState, removeStateRoot, saveState, selectedWorkspace, setSelectedWorkspace, validateStateRootForRemoval } from "./state.mjs";
10
10
 
11
11
  await daemonSelfTest();
12
12
  await stateSelfTest();
13
- await apiSelfTest();
14
- console.log("local daemon/state/api self-test ok");
13
+ await cliSelfTest();
14
+ await logSelfTest();
15
+ await serviceSelfTest();
16
+ await shellSelfTest();
17
+ await workerSourceSelfTest();
18
+ console.log("local daemon/state/cli/log/service/worker self-test ok");
15
19
 
16
20
  async function stateSelfTest() {
17
21
  const stateRoot = await mkdtemp(join(tmpdir(), "mbm-state-test-"));
@@ -20,8 +24,8 @@ async function stateSelfTest() {
20
24
  setSelectedWorkspace(workspace, stateRoot);
21
25
  if (selectedWorkspace(stateRoot) !== workspace) throw new Error("selected workspace was not persisted");
22
26
  const state = loadState(workspace, { stateDir: stateRoot });
27
+ if (state.schemaVersion !== 2) throw new Error("unexpected state schema version");
23
28
  ensureWorkerSecrets(state, { rotateSecrets: true });
24
- ensureLocalApiKey(state, { rotateApiKey: true });
25
29
  const lock = acquireDaemonLock(state);
26
30
  if (!lock.acquired) throw new Error("first daemon lock acquisition failed");
27
31
  try {
@@ -35,206 +39,189 @@ async function stateSelfTest() {
35
39
  if (!relock.acquired) throw new Error("daemon lock was not released");
36
40
  relock.release();
37
41
 
42
+ const startup = acquireStartupLock(state);
43
+ if (!startup.acquired) throw new Error("startup lock acquisition failed");
44
+ const duplicateStartup = acquireStartupLock(state);
45
+ if (duplicateStartup.acquired) throw new Error("duplicate startup lock acquisition should fail");
46
+ startup.release();
47
+ const startupAgain = acquireStartupLock(state);
48
+ if (!startupAgain.acquired) throw new Error("startup lock was not released");
49
+ startupAgain.release();
50
+
38
51
  const redacted = redactState(state);
39
- if (redacted.worker.oauthPassword === state.worker.oauthPassword) throw new Error("oauthPassword was not redacted");
40
- if (redacted.worker.daemonSecret === state.worker.daemonSecret) throw new Error("daemonSecret was not redacted");
41
- if (redacted.worker.oauthTokenVersion === state.worker.oauthTokenVersion) throw new Error("oauthTokenVersion was not redacted");
42
- if (redacted.localApi.apiKey === state.localApi.apiKey) throw new Error("local API key was not redacted");
43
- if (!previewSecret(state.worker.oauthPassword).includes("...")) throw new Error("previewSecret did not preview long secret");
44
- if (!redactSecret(state.localApi.apiKey).includes("...")) throw new Error("redactSecret did not preview long secret");
52
+ if (redacted.worker.oauthPassword !== "<redacted>") throw new Error("oauthPassword was not fully redacted");
53
+ if (redacted.worker.daemonSecret !== "<redacted>") throw new Error("daemonSecret was not fully redacted");
54
+ if (redacted.worker.oauthTokenVersion !== "<redacted>") throw new Error("oauthTokenVersion was not fully redacted");
55
+ if (previewSecret(state.worker.oauthPassword) !== "<redacted>") throw new Error("previewSecret did not fully redact secret");
56
+ if (redactSecret(state.worker.daemonSecret) !== "<redacted>") throw new Error("redactSecret did not fully redact secret");
45
57
 
46
- state.localApi.upstreamUrl = "https://api.example.test/v1";
47
- state.localApi.upstreamKey = "old-upstream-key";
48
- state.localApi.upstreamModel = "old-upstream-model";
58
+ state.localApi = {
59
+ apiKey: "old-local-api-key",
60
+ upstreamUrl: "https://api.example.test/v1",
61
+ upstreamKey: "old-upstream-key",
62
+ upstreamModel: "old-upstream-model",
63
+ };
49
64
  saveState(state);
65
+ const profileEntries = await readdir(state.paths.profileDir);
66
+ if (profileEntries.some(name => name.endsWith(".tmp"))) throw new Error("atomic state write left a temporary file");
50
67
  const migrated = loadState(workspace, { stateDir: stateRoot });
51
- if ("upstreamUrl" in migrated.localApi || "upstreamKey" in migrated.localApi || "upstreamModel" in migrated.localApi) {
52
- throw new Error("legacy upstream local API state was not migrated away");
68
+ if ("localApi" in migrated) throw new Error("legacy local API state was not removed");
69
+
70
+ await writeFile(state.paths.statePath, "{not-json", "utf8");
71
+ const recovered = loadState(workspace, { stateDir: stateRoot });
72
+ if (recovered.workspace.path !== workspace) throw new Error("corrupt state recovery failed");
73
+ const backups = (await readdir(state.paths.profileDir)).filter(name => name.startsWith("state.json.corrupt-"));
74
+ if (backups.length !== 1) throw new Error("corrupt state backup was not retained exactly once");
75
+ const safeRemoval = validateStateRootForRemoval(stateRoot);
76
+ if (!safeRemoval.exists || safeRemoval.root !== state.paths.stateRoot) throw new Error("safe state root validation failed");
77
+
78
+ const legacyStateRoot = await mkdtemp(join(tmpdir(), "mbm-legacy-state-"));
79
+ try {
80
+ await writeFile(join(legacyStateRoot, ".machine-bridge-mcp-state"), "machine-bridge-mcp state root\n", "utf8");
81
+ const legacyState = loadState(workspace, { stateDir: legacyStateRoot });
82
+ const marker = JSON.parse(await readFile(join(legacyState.paths.stateRoot, ".machine-bridge-mcp-state"), "utf8"));
83
+ if (marker.app !== "machine-bridge-mcp" || marker.schema !== 1) throw new Error("legacy state marker was not migrated");
84
+ removeStateRoot(legacyStateRoot);
85
+ } finally {
86
+ await rm(legacyStateRoot, { recursive: true, force: true }).catch(() => {});
87
+ }
88
+
89
+ const lookalike = await mkdtemp(join(tmpdir(), "mbm-lookalike-state-"));
90
+ try {
91
+ await import("node:fs/promises").then(({ mkdir }) => mkdir(join(lookalike, "profiles")));
92
+ expectThrow(() => loadState(workspace, { stateDir: lookalike }), "does not contain recognizable");
93
+ } finally {
94
+ await rm(lookalike, { recursive: true, force: true }).catch(() => {});
95
+ }
96
+
97
+ const unrelated = await mkdtemp(join(tmpdir(), "mbm-unrelated-test-"));
98
+ try {
99
+ await writeFile(join(unrelated, "keep.txt"), "do not delete", "utf8");
100
+ expectThrow(() => validateStateRootForRemoval(unrelated), "unrelated entries");
101
+ if (!(await stat(join(unrelated, "keep.txt"))).isFile()) throw new Error("unsafe state root validation modified unrelated data");
102
+ } finally {
103
+ await rm(unrelated, { recursive: true, force: true }).catch(() => {});
53
104
  }
54
105
  } finally {
55
- await rm(stateRoot, { recursive: true, force: true }).catch(() => {});
106
+ try { removeStateRoot(stateRoot); } catch { await rm(stateRoot, { recursive: true, force: true }).catch(() => {}); }
56
107
  await rm(workspace, { recursive: true, force: true }).catch(() => {});
57
108
  }
58
109
  }
59
110
 
111
+ function cliSelfTest() {
112
+ const parsed = parseArgs(["--no-write", "/tmp/example", "--unrestricted-paths=false", "--worker-name", "mbm-test"]);
113
+ if (parsed.noWrite !== true || parsed._[0] !== "/tmp/example") throw new Error("boolean option consumed positional workspace");
114
+ if (parsed.unrestrictedPaths !== false || parsed.workerName !== "mbm-test") throw new Error("CLI option parsing failed");
115
+ expectThrow(() => parseArgs(["--unknown-option"]), "Unknown option");
116
+ expectThrow(() => parseArgs(["--workspace"]), "requires a value");
117
+ expectThrow(() => parseArgs(["--quiet", "--quiet"]), "Duplicate option");
118
+ expectThrow(() => parseArgs(["--quiet=maybe"]), "expects true or false");
119
+ expectThrow(() => validatePositionals("start", { _: ["one", "two"] }), "at most one positional");
120
+ expectThrow(() => validatePositionals("start", { _: ["one"], workspace: "two" }), "both positionally");
121
+ expectThrow(() => validatePositionals("uninstall", { _: ["unexpected"] }), "does not accept positional");
122
+ expectThrow(() => validateCommandOptions("uninstall", { _: [], workspace: "/tmp/project" }), "not valid for uninstall");
123
+ expectThrow(() => validateCommandOptions("doctor", { _: [], fullEnv: true }), "not valid for doctor");
124
+ validateCommandOptions("start", { _: [], unrestrictedPaths: true, noExec: true });
125
+ validatePositionals("workspace", { _: ["set", "/tmp/project"] });
126
+ validatePositionals("service", { _: ["install", "/tmp/project"] });
127
+ }
60
128
 
61
- async function apiSelfTest() {
62
- const logger = createLogger({ quiet: true, component: "api-test" });
63
- const api = await startLocalApiServer({
64
- host: "127.0.0.1",
65
- port: 0,
66
- apiKey: "local-test-key",
67
- model: "chatgpt-mcp",
68
- logger,
129
+ function logSelfTest() {
130
+ const rendered = formatFields({
131
+ token: "mcp_at_should-not-appear",
132
+ nested: { password: "secret", message: "mcp_password_abcdef\nforged" },
133
+ authorization: "Bearer abcdefghijklmnopqrstuvwxyz",
69
134
  });
70
- const base = `http://${api.host}:${api.port}`;
71
- try {
72
- if (api.apiKey !== "local-test-key") throw new Error("local API server did not expose runtime API key for CLI printing");
73
- const health = await fetch(`${base}/health`);
74
- if (health.status !== 200) throw new Error(`health returned ${health.status}`);
75
- const healthPayload = await health.json();
76
- const expectedHash = createHash("sha256").update("local-test-key").digest("hex");
77
- if (healthPayload.api_key_sha256 !== expectedHash) throw new Error("health did not expose expected API key hash");
78
- if (healthPayload.backend !== "chatgpt-mcp") throw new Error("health did not report MCP-backed backend");
79
- if (healthPayload.mcp_bridge_configured !== false) throw new Error("health should report missing MCP bridge");
80
- const unauth = await fetch(`${base}/v1/models`);
81
- if (unauth.status !== 401) throw new Error(`unauthorized models returned ${unauth.status}`);
82
- const models = await fetch(`${base}/v1/models`, { headers: { authorization: "Bearer local-test-key" } });
83
- if (models.status !== 200) throw new Error(`authorized models returned ${models.status}`);
84
- const payload = await models.json();
85
- if (payload?.data?.length !== 1 || payload.data[0].id !== "chatgpt-mcp") throw new Error("model payload should expose local MCP model");
86
- const chat = await fetch(`${base}/v1/chat/completions`, {
87
- method: "POST",
88
- headers: { authorization: "Bearer local-test-key", "content-type": "application/json" },
89
- body: JSON.stringify({ model: "chatgpt-mcp", messages: [{ role: "user", content: "hello" }] }),
90
- });
91
- if (chat.status !== 503) throw new Error(`missing MCP bridge should return 503, got ${chat.status}`);
92
- const chatPayload = await chat.json();
93
- if (!/Remote MCP bridge/.test(chatPayload?.error?.message || "")) throw new Error("missing bridge error did not clarify MCP bridge requirement");
94
-
95
- const unsupported = await fetch(`${base}/v1/responses`, {
96
- method: "POST",
97
- headers: { authorization: "Bearer local-test-key", "content-type": "application/json" },
98
- body: JSON.stringify({ input: "hello" }),
99
- });
100
- if (unsupported.status !== 501) throw new Error(`unsupported Responses endpoint returned ${unsupported.status}`);
101
- const unsupportedPayload = await unsupported.json();
102
- if (unsupportedPayload?.error?.code !== "unsupported_endpoint") throw new Error("unsupported endpoint did not return explicit error code");
103
-
104
- } finally {
105
- await api.close();
135
+ if (rendered.includes("should-not-appear") || rendered.includes("password_abcdef") || rendered.includes("Bearer abcdef")) {
136
+ throw new Error("structured log secret redaction failed");
106
137
  }
107
-
108
- await mcpSamplingSelfTest(logger);
109
- await mcpSamplingErrorSelfTest(logger);
138
+ if (rendered.includes("\nforged")) throw new Error("structured log newline injection was not escaped");
139
+ if (sanitizeLogText("ok\n[error] forged").includes("\n[error]")) throw new Error("log message newline injection was not escaped");
110
140
  }
111
141
 
112
- async function mcpSamplingSelfTest(logger) {
113
- let captured = null;
114
- const expectedErrorLogger = { ...logger, error() {} };
115
- const worker = http.createServer((req, res) => {
116
- const chunks = [];
117
- req.on("data", chunk => chunks.push(chunk));
118
- req.on("end", () => {
119
- captured = { bridgeToken: req.headers["x-bridge-token"], url: req.url, body: JSON.parse(Buffer.concat(chunks).toString("utf8")) };
120
- res.setHeader("content-type", "application/json");
121
- res.end(JSON.stringify({ ok: true, result: { role: "assistant", content: { type: "text", text: "hello from ChatGPT MCP" }, model: "chatgpt-client-model", stopReason: "endTurn" } }));
122
- });
123
- });
124
- await new Promise(resolve => worker.listen({ host: "127.0.0.1", port: 0 }, resolve));
125
- const workerPort = worker.address().port;
126
- const api = await startLocalApiServer({
127
- host: "127.0.0.1",
128
- port: 0,
129
- apiKey: "local-test-key",
130
- workerUrl: `http://127.0.0.1:${workerPort}`,
131
- daemonSecret: "daemon-test-secret",
132
- model: "chatgpt-mcp",
133
- logger: expectedErrorLogger,
134
- });
142
+ async function serviceSelfTest() {
143
+ const stateRoot = await mkdtemp(join(tmpdir(), "mbm-service-test-"));
135
144
  try {
136
- const models = await fetch(`http://${api.host}:${api.port}/v1/models`, { headers: { authorization: "Bearer local-test-key" } });
137
- if (models.status !== 200) throw new Error(`configured models returned ${models.status}`);
138
- const modelsPayload = await models.json();
139
- if (modelsPayload?.data?.length !== 1 || modelsPayload.data[0].id !== "chatgpt-mcp") throw new Error("model payload did not expose local MCP model");
140
-
141
- const image = await fetch(`http://${api.host}:${api.port}/v1/chat/completions`, {
142
- method: "POST",
143
- headers: { authorization: "Bearer local-test-key", "content-type": "application/json" },
144
- body: JSON.stringify({ model: "chatgpt-mcp", messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "https://example.test/image.png" } }] }] }),
145
- });
146
- if (image.status !== 400) throw new Error(`unsupported non-text content returned ${image.status}`);
147
- const imagePayload = await image.json();
148
- if (imagePayload?.error?.code !== "unsupported_content") throw new Error("unsupported non-text content did not return explicit error code");
149
-
150
- const response = await fetch(`http://${api.host}:${api.port}/v1/chat/completions`, {
151
- method: "POST",
152
- headers: { authorization: "Bearer local-test-key", "content-type": "application/json" },
153
- body: JSON.stringify({
154
- model: "gpt-5-hint",
155
- messages: [
156
- { role: "system", content: "Be concise." },
157
- { role: "developer", content: "Use plain text." },
158
- { role: "user", content: [{ type: "text", text: "Say hello" }] },
159
- ],
160
- max_completion_tokens: 77,
161
- }),
162
- });
163
- if (response.status !== 200) throw new Error(`MCP sampling rewrite returned ${response.status}`);
164
- if (captured?.url !== "/api/mcp/sampling") throw new Error("sampling request did not target Worker sampling endpoint");
165
- if (captured?.bridgeToken !== "daemon-test-secret") throw new Error("bridge token was not set");
166
- if (captured?.body?.messages?.[0]?.content?.text !== "Say hello") throw new Error("chat message was not converted to MCP sampling message");
167
- if (captured?.body?.systemPrompt !== "Be concise.\n\nUse plain text.") throw new Error("system/developer prompt was not forwarded");
168
- if (captured?.body?.maxTokens !== 77) throw new Error("max_completion_tokens was not converted to maxTokens");
169
- if (captured?.body?.modelPreferences?.hints?.[0]?.name !== "gpt-5-hint") throw new Error("model hint was not forwarded");
170
- const payload = await response.json();
171
- if (payload?.choices?.[0]?.message?.content !== "hello from ChatGPT MCP") throw new Error("MCP sampling result was not wrapped as chat completion");
172
- if (payload?.model !== "chatgpt-client-model") throw new Error("MCP sampling result model was not preserved");
145
+ const logs = join(stateRoot, "logs");
146
+ await writeFile(join(stateRoot, "placeholder"), "", "utf8");
147
+ await import("node:fs/promises").then(({ mkdir }) => mkdir(logs, { recursive: true }));
148
+ const file = join(logs, "daemon.err.log");
149
+ await writeFile(file, "x".repeat(4096), "utf8");
150
+ trimAutostartLogs(stateRoot, { maxBytes: 2048, keepBytes: 1024 });
151
+ if ((await stat(file)).size > 1024) throw new Error("autostart log trimming failed");
152
+ const quoted = systemdQuote("path with space/%value'\n");
153
+ if (!quoted.startsWith('"') || !quoted.includes("%%") || !quoted.includes("\\n")) throw new Error("systemd argument quoting failed");
173
154
  } finally {
174
- await api.close();
175
- await new Promise(resolve => worker.close(resolve));
155
+ await rm(stateRoot, { recursive: true, force: true }).catch(() => {});
176
156
  }
177
157
  }
178
158
 
179
- async function mcpSamplingErrorSelfTest(logger) {
180
- await withMockWorkerError(
181
- logger,
182
- 409,
183
- { error: "mcp_client_stream_missing", message: "No MCP client has an open server-to-client stream for sampling/createMessage." },
184
- async ({ base }) => {
185
- const response = await fetch(`${base}/v1/chat/completions`, {
186
- method: "POST",
187
- headers: { authorization: "Bearer local-test-key", "content-type": "application/json" },
188
- body: JSON.stringify({ model: "chatgpt-mcp", messages: [{ role: "user", content: "hello" }] }),
189
- });
190
- if (response.status !== 409) throw new Error(`missing MCP stream should return 409, got ${response.status}`);
191
- const payload = await response.json();
192
- if (payload?.error?.code !== "mcp_client_stream_missing") throw new Error("missing MCP stream error code was not preserved");
193
- if (!/MCP client|server-to-client stream/.test(payload?.error?.message || "")) throw new Error("missing MCP stream error message was not explicit");
194
- }
195
- );
159
+ async function shellSelfTest() {
160
+ const result = await run(process.execPath, ["-e", "process.stdout.write('x'.repeat(4096)); process.stderr.write('y'.repeat(4096));"], {
161
+ capture: true,
162
+ maxOutputBytes: 1024,
163
+ });
164
+ if (result.code !== 0 || !result.stdout.includes("[truncated") || !result.stderr.includes("[truncated")) {
165
+ throw new Error("bounded shell capture failed");
166
+ }
167
+ const timedOut = await run(process.execPath, ["-e", "setTimeout(() => {}, 30000)"], {
168
+ capture: true,
169
+ allowFailure: true,
170
+ timeoutMs: 50,
171
+ });
172
+ if (timedOut.code !== 124 || !timedOut.stderr.includes("timed out")) throw new Error("shell timeout handling failed");
173
+ }
196
174
 
197
- await withMockWorkerError(
198
- logger,
199
- 501,
200
- { error: "mcp_sampling_not_supported", message: "A connected MCP client did not advertise the MCP sampling capability." },
201
- async ({ base }) => {
202
- const response = await fetch(`${base}/v1/chat/completions`, {
203
- method: "POST",
204
- headers: { authorization: "Bearer local-test-key", "content-type": "application/json" },
205
- body: JSON.stringify({ model: "chatgpt-mcp", messages: [{ role: "user", content: "hello" }] }),
206
- });
207
- if (response.status !== 501) throw new Error(`missing sampling capability should return 501, got ${response.status}`);
208
- const payload = await response.json();
209
- if (payload?.error?.code !== "mcp_sampling_not_supported") throw new Error("missing sampling capability error code was not preserved");
210
- if (!/sampling capability/.test(payload?.error?.message || "")) throw new Error("missing sampling capability message was not explicit");
211
- }
212
- );
175
+ async function workerSourceSelfTest() {
176
+ const source = await readFile(new URL("../worker/index.ts", import.meta.url), "utf8");
177
+ const unawaitedAsyncRoutes = [
178
+ "return this.registerClient(request);",
179
+ "return this.authorizeSubmit(request, base);",
180
+ "return this.exchangeToken(request, base);",
181
+ "return this.acceptDaemonWebSocket(request);",
182
+ "return this.handleMcp(request, base);",
183
+ ].filter(snippet => source.includes(snippet));
184
+ if (unawaitedAsyncRoutes.length) {
185
+ throw new Error(`Worker async routes must be awaited so HttpError is caught: ${unawaitedAsyncRoutes.join(", ")}`);
186
+ }
187
+ for (const required of [
188
+ "MAX_PENDING_CALLS",
189
+ "MAX_DAEMON_MESSAGE_BYTES",
190
+ "withOAuthLock",
191
+ "oauthQueue",
192
+ "AUTH_FAILURE_LIMIT",
193
+ "OAUTH_BODY_LIMIT_BYTES",
194
+ "pending.socket !== ws",
195
+ "isJsonRpcId(candidate.id)",
196
+ "pruneRecordByExpiry(store.tokens, MAX_OAUTH_TOKENS)",
197
+ "A valid PKCE S256 challenge is required.",
198
+ "hmac-sha256:",
199
+ "DAEMON_HELLO_TIMEOUT_MS",
200
+ "async alarm()",
201
+ "storage.setAlarm",
202
+ 'role: "candidate"',
203
+ 'role: "expired"',
204
+ "daemon_hello_timeout",
205
+ "replaced by authenticated daemon",
206
+ ]) {
207
+ if (!source.includes(required)) throw new Error(`Worker hardening guard missing: ${required}`);
208
+ }
209
+ for (const removed of [
210
+ "/api/mcp/sampling",
211
+ "/api/daemon/status",
212
+ "sampling/createMessage",
213
+ 'request.headers.get("User-Agent")',
214
+ ]) {
215
+ if (source.includes(removed)) throw new Error(`obsolete or public-sensitive Worker route remains: ${removed}`);
216
+ }
213
217
  }
214
218
 
215
- async function withMockWorkerError(logger, status, payload, callback) {
216
- const expectedErrorLogger = { ...logger, error() {} };
217
- const worker = http.createServer((req, res) => {
218
- req.resume();
219
- res.statusCode = status;
220
- res.setHeader("content-type", "application/json");
221
- res.end(JSON.stringify(payload));
222
- });
223
- await new Promise(resolve => worker.listen({ host: "127.0.0.1", port: 0 }, resolve));
224
- const workerPort = worker.address().port;
225
- const api = await startLocalApiServer({
226
- host: "127.0.0.1",
227
- port: 0,
228
- apiKey: "local-test-key",
229
- workerUrl: `http://127.0.0.1:${workerPort}`,
230
- daemonSecret: "daemon-test-secret",
231
- model: "chatgpt-mcp",
232
- logger: expectedErrorLogger,
233
- });
219
+ function expectThrow(callback, pattern) {
234
220
  try {
235
- await callback({ base: `http://${api.host}:${api.port}` });
236
- } finally {
237
- await api.close();
238
- await new Promise(resolve => worker.close(resolve));
221
+ callback();
222
+ } catch (error) {
223
+ if (String(error?.message || error).includes(pattern)) return;
224
+ throw error;
239
225
  }
226
+ throw new Error(`expected throw containing: ${pattern}`);
240
227
  }
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { run } from "./shell.mjs";
@@ -38,11 +38,42 @@ export async function stopAutostart({ logger = console } = {}) {
38
38
  return run("systemctl", ["--user", "stop", "machine-bridge-mcp.service"], { capture: true, allowFailure: true });
39
39
  }
40
40
 
41
+
42
+ export function trimAutostartLogs(stateRoot, options = {}) {
43
+ const root = expandHome(stateRoot);
44
+ const maxBytes = Number.isFinite(Number(options.maxBytes)) ? Math.max(1024, Number(options.maxBytes)) : 2 * 1024 * 1024;
45
+ const keepBytes = Math.min(maxBytes, Number.isFinite(Number(options.keepBytes)) ? Math.max(1024, Number(options.keepBytes)) : 1024 * 1024);
46
+ const logs = path.join(root, "logs");
47
+ for (const name of ["daemon.out.log", "daemon.err.log"]) {
48
+ const file = path.join(logs, name);
49
+ try {
50
+ const info = statSync(file);
51
+ if (info.size > maxBytes) {
52
+ const fd = openSync(file, "r");
53
+ try {
54
+ const current = fstatSync(fd);
55
+ const length = Math.min(keepBytes, current.size);
56
+ const buffer = Buffer.alloc(length);
57
+ readSync(fd, buffer, 0, length, Math.max(0, current.size - length));
58
+ writeFileSync(file, buffer, { mode: 0o600 });
59
+ } finally {
60
+ closeSync(fd);
61
+ }
62
+ }
63
+ chmodSync(file, 0o600);
64
+ } catch {}
65
+ }
66
+ }
67
+
41
68
  function serviceSpec({ workspace, stateRoot, entryScript, policy = {} }) {
42
69
  const root = expandHome(stateRoot);
43
70
  const logs = path.join(root, "logs");
44
71
  ensureOwnerOnlyDir(root);
45
72
  ensureOwnerOnlyDir(logs);
73
+ for (const file of [path.join(logs, "daemon.out.log"), path.join(logs, "daemon.err.log")]) {
74
+ writeFileSync(file, "", { flag: "a", mode: 0o600 });
75
+ try { chmodSync(file, 0o600); } catch {}
76
+ }
46
77
  return {
47
78
  workspace,
48
79
  stateRoot: root,
@@ -54,7 +85,7 @@ function serviceSpec({ workspace, stateRoot, entryScript, policy = {} }) {
54
85
  allowWrite: policy.allowWrite !== false,
55
86
  allowExec: policy.allowExec !== false,
56
87
  minimalEnv: policy.minimalEnv !== false,
57
- apiEnabled: policy.apiEnabled !== false,
88
+ unrestrictedPaths: policy.unrestrictedPaths === true,
58
89
  },
59
90
  };
60
91
  }
@@ -72,7 +103,7 @@ function daemonArgs(spec) {
72
103
  if (spec.policy.allowWrite === false) args.push("--no-write");
73
104
  if (spec.policy.allowExec === false) args.push("--no-exec");
74
105
  if (spec.policy.minimalEnv === false) args.push("--full-env");
75
- if (spec.policy.apiEnabled === false) args.push("--no-api");
106
+ if (spec.policy.unrestrictedPaths === true) args.push("--unrestricted-paths");
76
107
  return args;
77
108
  }
78
109
 
@@ -220,8 +251,15 @@ function winQuote(value) {
220
251
  return `"${String(value).replaceAll('"', '\\"')}"`;
221
252
  }
222
253
 
223
- function systemdQuote(value) {
224
- return `'${String(value).replaceAll("'", "'\\''")}'`;
254
+ export function systemdQuote(value) {
255
+ const escaped = String(value)
256
+ .replaceAll("\\", "\\\\")
257
+ .replaceAll("\"", "\\\"")
258
+ .replaceAll("%", "%%")
259
+ .replaceAll("\n", "\\n")
260
+ .replaceAll("\r", "\\r")
261
+ .replaceAll("\t", "\\t");
262
+ return `"${escaped}"`;
225
263
  }
226
264
 
227
265
  function escapeXml(value) {