machine-bridge-mcp 0.17.1 → 0.18.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/CHANGELOG.md +20 -0
- package/README.md +12 -13
- package/SECURITY.md +13 -13
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +5 -5
- package/docs/AUDIT.md +3 -3
- package/docs/CLIENTS.md +2 -2
- package/docs/GETTING_STARTED.md +13 -9
- package/docs/LOGGING.md +2 -2
- package/docs/MANAGED_JOBS.md +1 -1
- package/docs/MULTI_ACCOUNT.md +75 -358
- package/docs/OPERATIONS.md +4 -4
- package/docs/POLICY_REFERENCE.md +1 -1
- package/docs/PRIVACY.md +1 -1
- package/docs/PROJECT_STANDARDS.md +1 -1
- package/docs/TESTING.md +4 -4
- package/package.json +4 -3
- package/src/local/account-access.mjs +37 -0
- package/src/local/account-admin.mjs +105 -0
- package/src/local/bounded-output.mjs +50 -0
- package/src/local/call-registry.mjs +10 -0
- package/src/local/cli-account-admin.mjs +79 -0
- package/src/local/cli-options.mjs +9 -4
- package/src/local/cli-policy.mjs +7 -28
- package/src/local/cli.mjs +48 -78
- package/src/local/daemon-process.mjs +3 -2
- package/src/local/errors.mjs +0 -14
- package/src/local/log.mjs +1 -1
- package/src/local/managed-jobs.mjs +1 -3
- package/src/local/process-execution.mjs +90 -60
- package/src/local/relay-connection.mjs +11 -3
- package/src/local/runtime.mjs +27 -2
- package/src/local/service.mjs +7 -26
- package/src/local/shell.mjs +17 -29
- package/src/local/state-inventory.mjs +2 -2
- package/src/local/state.mjs +30 -85
- package/src/local/tool-executor.mjs +8 -2
- package/src/shared/access-contract.json +23 -0
- package/src/shared/policy-contract.json +30 -7
- package/src/worker/access.ts +46 -0
- package/src/worker/account-admin.ts +103 -0
- package/src/worker/index.ts +75 -48
- package/src/worker/oauth-state.ts +184 -2
- package/src/worker/tool-catalog.ts +13 -0
package/src/local/runtime.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import { CapabilityObserver } from "./capability-observer.mjs";
|
|
|
28
28
|
import { readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
29
29
|
import { clampInteger } from "./numbers.mjs";
|
|
30
30
|
import { isPlainRecord } from "./records.mjs";
|
|
31
|
+
import { AccountAccessGate, normalizeAccountRole } from "./account-access.mjs";
|
|
31
32
|
|
|
32
33
|
const MAX_WS_MESSAGE_BYTES = 8 * 1024 * 1024;
|
|
33
34
|
const MAX_CONCURRENT_TOOL_CALLS = 16;
|
|
@@ -100,6 +101,7 @@ export class LocalRuntime {
|
|
|
100
101
|
this.workspaceCanonicalPromise = null;
|
|
101
102
|
this.policy = normalizePolicy(policy);
|
|
102
103
|
this.policyGate = new PolicyGate(this.policy);
|
|
104
|
+
this.accountAccessGate = new AccountAccessGate();
|
|
103
105
|
this.logger = logger;
|
|
104
106
|
this.onSuperseded = typeof onSuperseded === "function" ? onSuperseded : null;
|
|
105
107
|
this.resourceStatePath = resourceStatePath ? resolve(resourceStatePath) : "";
|
|
@@ -210,6 +212,7 @@ export class LocalRuntime {
|
|
|
210
212
|
(args, context) => handler(this, args, context),
|
|
211
213
|
])),
|
|
212
214
|
policyGate: this.policyGate,
|
|
215
|
+
accountAccessGate: this.accountAccessGate,
|
|
213
216
|
callRegistry: this.callRegistry,
|
|
214
217
|
observability: this.observability,
|
|
215
218
|
logger: this.logger,
|
|
@@ -372,6 +375,7 @@ export class LocalRuntime {
|
|
|
372
375
|
callId: envelope.id,
|
|
373
376
|
origin: "relay",
|
|
374
377
|
timeoutMs: envelope.timeoutMs,
|
|
378
|
+
authorization: envelope.authorization,
|
|
375
379
|
});
|
|
376
380
|
this.send({ type: "tool_result", id: envelope.id, ok: true, result });
|
|
377
381
|
} catch (error) {
|
|
@@ -388,12 +392,21 @@ export class LocalRuntime {
|
|
|
388
392
|
return this.callRegistry.cancel(callId, reason);
|
|
389
393
|
}
|
|
390
394
|
|
|
395
|
+
handleRelayDisconnect() {
|
|
396
|
+
const cancelled = this.callRegistry.cancelOrigin("relay", "remote relay disconnected");
|
|
397
|
+
this.terminateActiveProcesses("SIGTERM", true);
|
|
398
|
+
if (cancelled > 0) {
|
|
399
|
+
this.logger.event?.("debug", "relay.calls.cancelled_on_disconnect", { cancelled_calls: cancelled });
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
391
403
|
async executeTool(tool, args, context = {}) {
|
|
392
404
|
this.lifecycle.assertOperational();
|
|
393
405
|
return this.toolExecutor.execute(tool, args, {
|
|
394
406
|
callId: context.callId,
|
|
395
407
|
origin: context.origin || "local",
|
|
396
408
|
timeoutMs: context.timeoutMs,
|
|
409
|
+
authorization: context.authorization,
|
|
397
410
|
context,
|
|
398
411
|
});
|
|
399
412
|
}
|
|
@@ -778,7 +791,7 @@ function createRelayConnection(runtime, { workerUrl, secret, expectedVersion, on
|
|
|
778
791
|
protocol_versions: MCP_SUPPORTED_PROTOCOL_VERSIONS,
|
|
779
792
|
}),
|
|
780
793
|
onMessage: (data) => handleRelayData(runtime, data),
|
|
781
|
-
onDisconnect: () => runtime.
|
|
794
|
+
onDisconnect: () => runtime.handleRelayDisconnect(),
|
|
782
795
|
onSuperseded: () => {
|
|
783
796
|
runtime.terminateActiveProcesses("SIGKILL");
|
|
784
797
|
runtime.processSessionManager.clear();
|
|
@@ -805,16 +818,28 @@ function normalizeRelayToolCall(message) {
|
|
|
805
818
|
const id = typeof message.id === "string" && message.id.length <= 256 ? message.id : "";
|
|
806
819
|
const tool = typeof message.tool === "string" && message.tool.length <= 128 ? message.tool : "";
|
|
807
820
|
const argumentsValue = message.arguments === undefined ? {} : message.arguments;
|
|
808
|
-
|
|
821
|
+
const authorization = normalizeRelayAuthorization(message.authorization);
|
|
822
|
+
if (!id || !tool || !isPlainRecord(argumentsValue) || !authorization) return { ok: false, id };
|
|
809
823
|
return {
|
|
810
824
|
ok: true,
|
|
811
825
|
id,
|
|
812
826
|
tool,
|
|
813
827
|
arguments: argumentsValue,
|
|
828
|
+
authorization,
|
|
814
829
|
timeoutMs: clampInteger(message.timeout_ms, 60_000, 1000, 610_000),
|
|
815
830
|
};
|
|
816
831
|
}
|
|
817
832
|
|
|
833
|
+
function normalizeRelayAuthorization(value) {
|
|
834
|
+
if (!isPlainRecord(value)) return null;
|
|
835
|
+
const accountId = typeof value.account_id === "string" && /^acct_[A-Za-z0-9_-]{20,96}$/.test(value.account_id) ? value.account_id : "";
|
|
836
|
+
const accountVersion = Number(value.account_version);
|
|
837
|
+
let role;
|
|
838
|
+
try { role = normalizeAccountRole(value.role); } catch { return null; }
|
|
839
|
+
if (!accountId || !Number.isInteger(accountVersion) || accountVersion < 1) return null;
|
|
840
|
+
return Object.freeze({ account_id: accountId, account_version: accountVersion, role });
|
|
841
|
+
}
|
|
842
|
+
|
|
818
843
|
|
|
819
844
|
|
|
820
845
|
function collectToolPathCandidates(error, toolArgs, workspace) {
|
package/src/local/service.mjs
CHANGED
|
@@ -72,13 +72,10 @@ export function trimAutostartLogs(stateRoot, options = {}) {
|
|
|
72
72
|
const root = expandHome(stateRoot);
|
|
73
73
|
const maxBytes = Number.isFinite(Number(options.maxBytes)) ? Math.max(1024, Number(options.maxBytes)) : 2 * 1024 * 1024;
|
|
74
74
|
const keepBytes = Math.min(maxBytes, Number.isFinite(Number(options.keepBytes)) ? Math.max(1024, Number(options.keepBytes)) : 1024 * 1024);
|
|
75
|
-
const schemaVersion = String(
|
|
76
|
-
? Number(options.schemaVersion)
|
|
77
|
-
: AUTOSTART_LOG_SCHEMA_VERSION);
|
|
75
|
+
const schemaVersion = String(AUTOSTART_LOG_SCHEMA_VERSION);
|
|
78
76
|
const logs = path.join(root, "logs");
|
|
79
77
|
const schemaFile = path.join(logs, ".log-schema");
|
|
80
|
-
const
|
|
81
|
-
let migrationComplete = true;
|
|
78
|
+
const reset = readLogSchema(schemaFile) !== schemaVersion;
|
|
82
79
|
|
|
83
80
|
for (const name of ["daemon.out.log", "daemon.err.log"]) {
|
|
84
81
|
const file = path.join(logs, name);
|
|
@@ -86,20 +83,12 @@ export function trimAutostartLogs(stateRoot, options = {}) {
|
|
|
86
83
|
try {
|
|
87
84
|
if (!existsSync(file)) continue;
|
|
88
85
|
const before = lstatSync(file);
|
|
89
|
-
if (before.isSymbolicLink() || !before.isFile())
|
|
90
|
-
if (migrate) migrationComplete = false;
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
86
|
+
if (before.isSymbolicLink() || !before.isFile()) continue;
|
|
93
87
|
const noFollow = Number(fsConstants.O_NOFOLLOW || 0);
|
|
94
88
|
fd = openSync(file, Number(fsConstants.O_RDWR) | noFollow);
|
|
95
89
|
const info = fstatSync(fd);
|
|
96
|
-
if (!info.isFile())
|
|
97
|
-
|
|
98
|
-
continue;
|
|
99
|
-
}
|
|
100
|
-
if (migrate && info.size > 0) {
|
|
101
|
-
const legacy = readLogTail(fd, info.size, maxBytes);
|
|
102
|
-
if (legacy.length) writePrivateServiceFile(path.join(logs, legacyLogName(name)), legacy);
|
|
90
|
+
if (!info.isFile()) continue;
|
|
91
|
+
if (reset) {
|
|
103
92
|
ftruncateSync(fd, 0);
|
|
104
93
|
} else if (info.size > maxBytes) {
|
|
105
94
|
const tail = readLogTail(fd, info.size, keepBytes);
|
|
@@ -108,16 +97,13 @@ export function trimAutostartLogs(stateRoot, options = {}) {
|
|
|
108
97
|
}
|
|
109
98
|
try { chmodSync(file, 0o600); } catch {}
|
|
110
99
|
} catch {
|
|
111
|
-
|
|
112
|
-
// Operational log maintenance is best effort and must not stop startup.
|
|
100
|
+
// Log maintenance is best effort and must not stop daemon startup.
|
|
113
101
|
} finally {
|
|
114
102
|
if (fd !== undefined) try { closeSync(fd); } catch {}
|
|
115
103
|
}
|
|
116
104
|
}
|
|
117
105
|
|
|
118
|
-
|
|
119
|
-
try { writePrivateServiceFile(schemaFile, `${schemaVersion}\n`); } catch {}
|
|
120
|
-
}
|
|
106
|
+
try { writePrivateServiceFile(schemaFile, `${schemaVersion}\n`); } catch {}
|
|
121
107
|
}
|
|
122
108
|
|
|
123
109
|
function readLogSchema(file) {
|
|
@@ -131,10 +117,6 @@ function readLogTail(fd, size, limit) {
|
|
|
131
117
|
return lineSafeTail(buffer);
|
|
132
118
|
}
|
|
133
119
|
|
|
134
|
-
function legacyLogName(name) {
|
|
135
|
-
return name.endsWith(".log") ? `${name.slice(0, -4)}.legacy.log` : `${name}.legacy`;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
120
|
function lineSafeTail(buffer) {
|
|
139
121
|
if (!Buffer.isBuffer(buffer) || buffer.length === 0) return Buffer.alloc(0);
|
|
140
122
|
let text = buffer.toString("utf8");
|
|
@@ -224,7 +206,6 @@ export function daemonArgs(spec) {
|
|
|
224
206
|
"--daemon-only",
|
|
225
207
|
"--workspace", spec.workspace,
|
|
226
208
|
"--state-dir", spec.stateRoot,
|
|
227
|
-
"--no-print-credentials",
|
|
228
209
|
"--log-level", "warn",
|
|
229
210
|
"--log-format", "json",
|
|
230
211
|
];
|
package/src/local/shell.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { packageRoot } from "./state.mjs";
|
|
5
|
+
import { BoundedOutput } from "./bounded-output.mjs";
|
|
5
6
|
|
|
6
7
|
export function run(command, args = [], options = {}) {
|
|
7
8
|
return new Promise((resolve, reject) => {
|
|
@@ -15,10 +16,8 @@ export function run(command, args = [], options = {}) {
|
|
|
15
16
|
detached: process.platform !== "win32",
|
|
16
17
|
windowsHide: true,
|
|
17
18
|
});
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
let stdoutTruncated = 0;
|
|
21
|
-
let stderrTruncated = 0;
|
|
19
|
+
const stdout = new BoundedOutput(maxOutputBytes);
|
|
20
|
+
const stderr = new BoundedOutput(maxOutputBytes);
|
|
22
21
|
let settled = false;
|
|
23
22
|
let timedOut = false;
|
|
24
23
|
let timer = null;
|
|
@@ -40,28 +39,18 @@ export function run(command, args = [], options = {}) {
|
|
|
40
39
|
callback();
|
|
41
40
|
};
|
|
42
41
|
if (capture) {
|
|
43
|
-
child.stdout?.on("data", chunk =>
|
|
44
|
-
|
|
45
|
-
stdout = next.value;
|
|
46
|
-
stdoutTruncated += next.truncated;
|
|
47
|
-
});
|
|
48
|
-
child.stderr?.on("data", chunk => {
|
|
49
|
-
const next = appendLimited(stderr, chunk, maxOutputBytes);
|
|
50
|
-
stderr = next.value;
|
|
51
|
-
stderrTruncated += next.truncated;
|
|
52
|
-
});
|
|
42
|
+
child.stdout?.on("data", chunk => stdout.append(chunk));
|
|
43
|
+
child.stderr?.on("data", chunk => stderr.append(chunk));
|
|
53
44
|
}
|
|
54
45
|
child.on("error", error => finish(() => {
|
|
55
|
-
const result =
|
|
46
|
+
const result = capturedResult(127, stdout, stderr, error.message);
|
|
56
47
|
if (options.allowFailure) resolve(result);
|
|
57
48
|
else reject(error);
|
|
58
49
|
}));
|
|
59
50
|
child.on("close", code => finish(() => {
|
|
60
51
|
const timeoutMessage = timedOut ? `command timed out after ${timeoutMs}ms` : "";
|
|
61
52
|
const result = {
|
|
62
|
-
|
|
63
|
-
stdout: finalizeOutput(stdout, stdoutTruncated),
|
|
64
|
-
stderr: finalizeOutput([stderr, timeoutMessage].filter(Boolean).join("\n"), stderrTruncated),
|
|
53
|
+
...capturedResult(timedOut ? 124 : code, stdout, stderr, timeoutMessage),
|
|
65
54
|
};
|
|
66
55
|
if ((!timedOut && code === 0) || options.allowFailure) resolve(result);
|
|
67
56
|
else {
|
|
@@ -92,16 +81,15 @@ function terminateCommandTree(child, force) {
|
|
|
92
81
|
}
|
|
93
82
|
}
|
|
94
83
|
|
|
95
|
-
function
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
return truncated > 0 ? `${value}\n\n[truncated ${truncated} bytes]` : value;
|
|
84
|
+
function capturedResult(code, stdout, stderr, extraStderr = "") {
|
|
85
|
+
const stderrText = [stderr.text(), extraStderr].filter(Boolean).join("\n");
|
|
86
|
+
return {
|
|
87
|
+
code,
|
|
88
|
+
stdout: stdout.text(),
|
|
89
|
+
stderr: stderrText,
|
|
90
|
+
stdout_truncated_bytes: stdout.truncatedBytes,
|
|
91
|
+
stderr_truncated_bytes: stderr.truncatedBytes,
|
|
92
|
+
};
|
|
105
93
|
}
|
|
106
94
|
|
|
107
95
|
function findWranglerCommand() {
|
|
@@ -122,7 +110,7 @@ export function workspaceShellCommand(command) {
|
|
|
122
110
|
if (process.platform === "win32") {
|
|
123
111
|
return { cmd: "powershell.exe", args: ["-NoProfile", "-NonInteractive", "-Command", command] };
|
|
124
112
|
}
|
|
125
|
-
const shell =
|
|
113
|
+
const shell = existsSync("/bin/zsh") ? "/bin/zsh" : existsSync("/bin/bash") ? "/bin/bash" : "/bin/sh";
|
|
126
114
|
const base = path.basename(shell);
|
|
127
115
|
if (base === "zsh") return { cmd: shell, args: ["-f", "-c", command] };
|
|
128
116
|
if (base === "bash") return { cmd: shell, args: ["--noprofile", "--norc", "-c", command] };
|
|
@@ -3,7 +3,7 @@ import { resolve } from "node:path";
|
|
|
3
3
|
import { activeManagedJobs } from "./managed-jobs.mjs";
|
|
4
4
|
import { inspectProcessInstance } from "./process-identity.mjs";
|
|
5
5
|
import { readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
6
|
-
import { expandHome, readDaemonLockOwner, resolveWorkspace } from "./state.mjs";
|
|
6
|
+
import { STATE_SCHEMA_VERSION, expandHome, readDaemonLockOwner, resolveWorkspace } from "./state.mjs";
|
|
7
7
|
|
|
8
8
|
const PROFILE_NAME = /^[a-f0-9]{24}$/;
|
|
9
9
|
const WORKER_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
@@ -66,7 +66,7 @@ export function knownProfileStates(stateRoot) {
|
|
|
66
66
|
const workspace = resolveWorkspace(candidate);
|
|
67
67
|
if (seen.has(workspace)) break;
|
|
68
68
|
states.push({
|
|
69
|
-
schemaVersion:
|
|
69
|
+
schemaVersion: STATE_SCHEMA_VERSION,
|
|
70
70
|
workspace: { path: workspace, hash: entry.name },
|
|
71
71
|
paths: { stateRoot: canonicalStateRoot, profileDir, statePath },
|
|
72
72
|
});
|
package/src/local/state.mjs
CHANGED
|
@@ -11,6 +11,10 @@ import { currentProcessStartTimeMs, inspectProcessInstance } from "./process-ide
|
|
|
11
11
|
export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
12
12
|
export const appName = String(serverMetadata.name);
|
|
13
13
|
const STATE_MARKER = ".machine-bridge-mcp-state";
|
|
14
|
+
const STATE_MARKER_SCHEMA = 2;
|
|
15
|
+
export const STATE_SCHEMA_VERSION = 6;
|
|
16
|
+
const GLOBAL_CONFIG_SCHEMA = 1;
|
|
17
|
+
const CORRUPT_RECOVERY = Symbol("corrupt-recovery");
|
|
14
18
|
const MAX_STATE_JSON_BYTES = 2 * 1024 * 1024;
|
|
15
19
|
const MAX_LOCK_BYTES = 64 * 1024;
|
|
16
20
|
const MAX_MARKER_BYTES = 4096;
|
|
@@ -47,9 +51,14 @@ export function loadGlobalConfig(stateRoot = defaultStateRoot()) {
|
|
|
47
51
|
const root = path.resolve(expandHome(stateRoot));
|
|
48
52
|
assertNoForeignMaintenance(root);
|
|
49
53
|
const file = configPath(root);
|
|
50
|
-
if (!existsSync(file)) return {};
|
|
54
|
+
if (!existsSync(file)) return { schemaVersion: GLOBAL_CONFIG_SCHEMA };
|
|
51
55
|
ownerOnlyFile(file);
|
|
52
|
-
|
|
56
|
+
const config = readJsonObjectOrBackup(file);
|
|
57
|
+
if (config[CORRUPT_RECOVERY]) return { schemaVersion: GLOBAL_CONFIG_SCHEMA };
|
|
58
|
+
if (config.schemaVersion !== GLOBAL_CONFIG_SCHEMA) {
|
|
59
|
+
throw new Error("global configuration schema is obsolete; remove the state root and initialize the current version");
|
|
60
|
+
}
|
|
61
|
+
return config;
|
|
53
62
|
}
|
|
54
63
|
|
|
55
64
|
export function saveGlobalConfig(config, stateRoot = defaultStateRoot()) {
|
|
@@ -58,7 +67,7 @@ export function saveGlobalConfig(config, stateRoot = defaultStateRoot()) {
|
|
|
58
67
|
const root = ensureStateRoot(requestedRoot);
|
|
59
68
|
assertNoForeignMaintenance(root);
|
|
60
69
|
const file = configPath(root);
|
|
61
|
-
atomicWriteJson(file, { ...config, updatedAt: new Date().toISOString() });
|
|
70
|
+
atomicWriteJson(file, { ...config, schemaVersion: GLOBAL_CONFIG_SCHEMA, updatedAt: new Date().toISOString() });
|
|
62
71
|
}
|
|
63
72
|
|
|
64
73
|
export function setSelectedWorkspace(workspace, stateRoot = defaultStateRoot()) {
|
|
@@ -127,26 +136,6 @@ function assertStateRootSeparatedFromWorkspace(stateRoot, workspace) {
|
|
|
127
136
|
}
|
|
128
137
|
}
|
|
129
138
|
|
|
130
|
-
function matchingLegacyProfileDir(canonicalWorkspace, stateRoot) {
|
|
131
|
-
const profilesRoot = path.join(stateRoot, "profiles");
|
|
132
|
-
if (!existsSync(profilesRoot)) return "";
|
|
133
|
-
const matches = [];
|
|
134
|
-
for (const entry of readdirSync(profilesRoot, { withFileTypes: true })) {
|
|
135
|
-
if (!entry.isDirectory() || !/^[a-f0-9]{24}$/.test(entry.name)) continue;
|
|
136
|
-
const profileDir = path.join(profilesRoot, entry.name);
|
|
137
|
-
const stateFile = path.join(profileDir, "state.json");
|
|
138
|
-
if (!existsSync(stateFile)) continue;
|
|
139
|
-
try {
|
|
140
|
-
const value = JSON.parse(readBoundedUtf8(stateFile, MAX_STATE_JSON_BYTES, "state JSON"));
|
|
141
|
-
const storedWorkspace = value?.workspace?.path;
|
|
142
|
-
if (typeof storedWorkspace !== "string") continue;
|
|
143
|
-
if (sameWorkspaceIdentity(storedWorkspace, canonicalWorkspace)) matches.push(profileDir);
|
|
144
|
-
} catch {}
|
|
145
|
-
}
|
|
146
|
-
if (matches.length > 1) throw new Error("multiple Machine Bridge profiles refer to the same canonical workspace; remove or merge the duplicate state profiles");
|
|
147
|
-
return matches[0] || "";
|
|
148
|
-
}
|
|
149
|
-
|
|
150
139
|
function sameWorkspaceIdentity(left, right) {
|
|
151
140
|
try {
|
|
152
141
|
const a = resolveWorkspace(left);
|
|
@@ -164,18 +153,18 @@ export function loadState(workspace, options = {}) {
|
|
|
164
153
|
assertNoForeignMaintenance(requestedStateRoot);
|
|
165
154
|
const stateRoot = ensureStateRoot(requestedStateRoot);
|
|
166
155
|
assertNoForeignMaintenance(stateRoot);
|
|
167
|
-
const
|
|
168
|
-
const profileDir = existsSync(canonicalProfileDir)
|
|
169
|
-
? canonicalProfileDir
|
|
170
|
-
: matchingLegacyProfileDir(canonicalWorkspace, stateRoot) || canonicalProfileDir;
|
|
156
|
+
const profileDir = profileDirForWorkspace(canonicalWorkspace, stateRoot);
|
|
171
157
|
const statePath = path.join(profileDir, "state.json");
|
|
172
158
|
ensureOwnerOnlyDir(profileDir);
|
|
173
159
|
let state = {};
|
|
174
160
|
if (existsSync(statePath)) {
|
|
175
161
|
ownerOnlyFile(statePath);
|
|
176
162
|
state = readJsonObjectOrBackup(statePath);
|
|
163
|
+
if (!state[CORRUPT_RECOVERY] && state.schemaVersion !== STATE_SCHEMA_VERSION) {
|
|
164
|
+
throw new Error("workspace state schema is obsolete; remove the state root and initialize the current version");
|
|
165
|
+
}
|
|
177
166
|
}
|
|
178
|
-
state.schemaVersion =
|
|
167
|
+
state.schemaVersion = STATE_SCHEMA_VERSION;
|
|
179
168
|
state.workspace = {
|
|
180
169
|
path: canonicalWorkspace,
|
|
181
170
|
hash: path.basename(profileDir),
|
|
@@ -185,7 +174,6 @@ export function loadState(workspace, options = {}) {
|
|
|
185
174
|
state.worker ||= {};
|
|
186
175
|
state.policy ||= {};
|
|
187
176
|
state.resources ||= {};
|
|
188
|
-
delete state.localApi;
|
|
189
177
|
return state;
|
|
190
178
|
}
|
|
191
179
|
|
|
@@ -450,7 +438,9 @@ function readJsonObjectOrBackup(filePath) {
|
|
|
450
438
|
replaceFileSync(filePath, backupPath);
|
|
451
439
|
ownerOnlyFile(backupPath);
|
|
452
440
|
pruneBackups(filePath, 3);
|
|
453
|
-
|
|
441
|
+
const recovered = {};
|
|
442
|
+
Object.defineProperty(recovered, CORRUPT_RECOVERY, { value: true });
|
|
443
|
+
return recovered;
|
|
454
444
|
}
|
|
455
445
|
}
|
|
456
446
|
|
|
@@ -466,23 +456,10 @@ function ensureStateRoot(inputRoot) {
|
|
|
466
456
|
const marker = path.join(root, STATE_MARKER);
|
|
467
457
|
if (!existsSync(marker)) {
|
|
468
458
|
const entries = readdirSync(root);
|
|
469
|
-
if (entries.length) {
|
|
470
|
-
|
|
471
|
-
throw new Error(`state root must be a dedicated directory; unexpected entries found in ${root}`);
|
|
472
|
-
}
|
|
473
|
-
if (!isRecognizableLegacyStateRoot(root)) {
|
|
474
|
-
throw new Error(`state root is non-empty but does not contain recognizable Machine Bridge state: ${root}`);
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
try {
|
|
478
|
-
createExclusiveFileSync(marker, `${JSON.stringify({ app: appName, schema: 1 })}\n`, { mode: 0o600 });
|
|
479
|
-
} catch (error) {
|
|
480
|
-
if (error?.code !== "EEXIST") throw error;
|
|
481
|
-
assertValidStateMarker(marker, { migrateLegacy: true });
|
|
482
|
-
}
|
|
483
|
-
} else {
|
|
484
|
-
assertValidStateMarker(marker, { migrateLegacy: true });
|
|
459
|
+
if (entries.length) throw new Error(`state root must be empty before initializing the current schema: ${root}`);
|
|
460
|
+
createExclusiveFileSync(marker, `${JSON.stringify({ app: appName, schema: STATE_MARKER_SCHEMA })}\n`, { mode: 0o600 });
|
|
485
461
|
}
|
|
462
|
+
assertValidStateMarker(marker);
|
|
486
463
|
ensureOwnerOnlyDir(root);
|
|
487
464
|
ownerOnlyFile(marker);
|
|
488
465
|
cleanupStaleAtomicTemps(root);
|
|
@@ -512,21 +489,16 @@ function assertSafeStateRootForRemoval(root) {
|
|
|
512
489
|
assertValidStateMarker(marker);
|
|
513
490
|
return canonical;
|
|
514
491
|
}
|
|
515
|
-
if (isRecognizableLegacyStateRoot(canonical)) return canonical;
|
|
516
492
|
throw new Error(`refusing to remove unrecognized state root without ${STATE_MARKER}: ${canonical}`);
|
|
517
493
|
}
|
|
518
494
|
|
|
519
|
-
function assertValidStateMarker(marker
|
|
495
|
+
function assertValidStateMarker(marker) {
|
|
520
496
|
const content = readBoundedUtf8(marker, MAX_MARKER_BYTES, "state marker");
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
if (content.trim() === `${appName} state root`) {
|
|
526
|
-
if (options.migrateLegacy) atomicWriteJson(marker, { app: appName, schema: 1 });
|
|
527
|
-
return;
|
|
497
|
+
let value;
|
|
498
|
+
try { value = JSON.parse(content); } catch { throw new Error(`invalid state root marker: ${marker}`); }
|
|
499
|
+
if (value?.app !== appName || value?.schema !== STATE_MARKER_SCHEMA) {
|
|
500
|
+
throw new Error("state root schema is obsolete; remove it and initialize the current version");
|
|
528
501
|
}
|
|
529
|
-
throw new Error(`invalid state root marker: ${marker}`);
|
|
530
502
|
}
|
|
531
503
|
|
|
532
504
|
function hasOnlyStateEntries(entries) {
|
|
@@ -566,33 +538,6 @@ function stateRootMatchesRecordedWorkspace(root) {
|
|
|
566
538
|
return false;
|
|
567
539
|
}
|
|
568
540
|
|
|
569
|
-
function isRecognizableLegacyStateRoot(root) {
|
|
570
|
-
const config = path.join(root, "config.json");
|
|
571
|
-
try {
|
|
572
|
-
if (existsSync(config)) {
|
|
573
|
-
const value = JSON.parse(readBoundedUtf8(config, MAX_STATE_JSON_BYTES, "config JSON"));
|
|
574
|
-
if (
|
|
575
|
-
value &&
|
|
576
|
-
typeof value.selectedWorkspace === "string" &&
|
|
577
|
-
typeof value.selectedWorkspaceHash === "string" &&
|
|
578
|
-
workspaceHash(value.selectedWorkspace) === value.selectedWorkspaceHash
|
|
579
|
-
) return true;
|
|
580
|
-
}
|
|
581
|
-
} catch {}
|
|
582
|
-
const profiles = path.join(root, "profiles");
|
|
583
|
-
if (!existsSync(profiles)) return false;
|
|
584
|
-
try {
|
|
585
|
-
for (const entry of readdirSync(profiles, { withFileTypes: true })) {
|
|
586
|
-
if (!entry.isDirectory() || !/^[a-f0-9]{24}$/.test(entry.name)) continue;
|
|
587
|
-
const stateFile = path.join(profiles, entry.name, "state.json");
|
|
588
|
-
if (!existsSync(stateFile)) continue;
|
|
589
|
-
const value = JSON.parse(readBoundedUtf8(stateFile, MAX_STATE_JSON_BYTES, "state JSON"));
|
|
590
|
-
if (value?.workspace?.hash === entry.name && typeof value?.workspace?.path === "string") return true;
|
|
591
|
-
}
|
|
592
|
-
} catch {}
|
|
593
|
-
return false;
|
|
594
|
-
}
|
|
595
|
-
|
|
596
541
|
function atomicWriteJson(filePath, value) {
|
|
597
542
|
const dir = path.dirname(filePath);
|
|
598
543
|
ensureOwnerOnlyDir(dir);
|
|
@@ -636,7 +581,7 @@ function pruneBackups(filePath, keep) {
|
|
|
636
581
|
|
|
637
582
|
export function ensureWorkerSecrets(state, options = {}) {
|
|
638
583
|
state.worker ||= {};
|
|
639
|
-
if (!state.worker.
|
|
584
|
+
if (!state.worker.accountAdminSecret || options.rotateSecrets) state.worker.accountAdminSecret = randomToken("account_admin");
|
|
640
585
|
if (!state.worker.daemonSecret || options.rotateSecrets) state.worker.daemonSecret = randomToken("daemon_secret");
|
|
641
586
|
if (!state.worker.oauthTokenVersion || options.rotateSecrets) state.worker.oauthTokenVersion = randomToken("token_version");
|
|
642
587
|
if (!state.worker.name || options.workerName) state.worker.name = options.workerName || defaultWorkerName(state.workspace.hash);
|
|
@@ -669,7 +614,7 @@ export function ownerOnlyFile(filePath) {
|
|
|
669
614
|
|
|
670
615
|
export function redactState(state) {
|
|
671
616
|
const clone = redactHomeInValue(JSON.parse(JSON.stringify(state)));
|
|
672
|
-
if (clone.worker?.
|
|
617
|
+
if (clone.worker?.accountAdminSecret) clone.worker.accountAdminSecret = "<redacted>";
|
|
673
618
|
if (clone.worker?.daemonSecret) clone.worker.daemonSecret = "<redacted>";
|
|
674
619
|
if (clone.worker?.oauthTokenVersion) clone.worker.oauthTokenVersion = "<redacted>";
|
|
675
620
|
if (clone.resources && typeof clone.resources === "object") {
|
|
@@ -5,6 +5,7 @@ export class ToolExecutor {
|
|
|
5
5
|
this.handlers = options.handlers || {};
|
|
6
6
|
this.policyGate = options.policyGate;
|
|
7
7
|
this.callRegistry = options.callRegistry;
|
|
8
|
+
this.accountAccessGate = options.accountAccessGate;
|
|
8
9
|
this.observability = options.observability;
|
|
9
10
|
this.logger = options.logger || console;
|
|
10
11
|
this.safeMessage = typeof options.safeMessage === "function" ? options.safeMessage : (error) => String(error?.message || error || "operation failed");
|
|
@@ -12,7 +13,7 @@ export class ToolExecutor {
|
|
|
12
13
|
this.pipeline = composeMiddleware([
|
|
13
14
|
lifecycleMiddleware(this.callRegistry),
|
|
14
15
|
observabilityMiddleware(this.observability, this.logger, this.safeMessage, this.slowMs),
|
|
15
|
-
authorizeMiddleware(this.policyGate),
|
|
16
|
+
authorizeMiddleware(this.policyGate, this.accountAccessGate),
|
|
16
17
|
], invokeHandler(this.handlers));
|
|
17
18
|
}
|
|
18
19
|
|
|
@@ -25,9 +26,14 @@ export function composeMiddleware(middleware, terminal) {
|
|
|
25
26
|
return middleware.reduceRight((next, current) => (operation) => current(operation, next), terminal);
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
function authorizeMiddleware(policyGate) {
|
|
29
|
+
function authorizeMiddleware(policyGate, accountAccessGate) {
|
|
29
30
|
return async (operation, next) => {
|
|
30
31
|
policyGate.assert(operation.tool);
|
|
32
|
+
if (operation.context.origin === "relay") {
|
|
33
|
+
const role = operation.request.authorization?.role;
|
|
34
|
+
if (!role) throw new Error("relay tool call is missing an account role");
|
|
35
|
+
accountAccessGate.assert(role, operation.tool);
|
|
36
|
+
}
|
|
31
37
|
return next(operation);
|
|
32
38
|
};
|
|
33
39
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"revision": 1,
|
|
3
|
+
"defaultRole": "reviewer",
|
|
4
|
+
"ownerRole": "owner",
|
|
5
|
+
"roles": {
|
|
6
|
+
"reviewer": {
|
|
7
|
+
"profile": "review",
|
|
8
|
+
"description": "Read-only access to the selected workspace."
|
|
9
|
+
},
|
|
10
|
+
"editor": {
|
|
11
|
+
"profile": "edit",
|
|
12
|
+
"description": "Read and deterministic file mutation without process execution."
|
|
13
|
+
},
|
|
14
|
+
"operator": {
|
|
15
|
+
"profile": "agent",
|
|
16
|
+
"description": "Workspace-confined file mutation and direct process execution."
|
|
17
|
+
},
|
|
18
|
+
"owner": {
|
|
19
|
+
"profile": "full",
|
|
20
|
+
"description": "Complete bridge authority, including shell, browser, applications, unrestricted paths, and account administration."
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
-
"revision":
|
|
2
|
+
"revision": 5,
|
|
3
3
|
"defaultProfile": "full",
|
|
4
|
-
"origins": [
|
|
4
|
+
"origins": [
|
|
5
|
+
"default",
|
|
6
|
+
"explicit",
|
|
7
|
+
"custom"
|
|
8
|
+
],
|
|
5
9
|
"profiles": {
|
|
6
10
|
"review": {
|
|
7
11
|
"profile": "review",
|
|
@@ -38,14 +42,33 @@
|
|
|
38
42
|
},
|
|
39
43
|
"availability": {
|
|
40
44
|
"always": {},
|
|
41
|
-
"write": {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
"
|
|
45
|
+
"write": {
|
|
46
|
+
"allowWrite": true
|
|
47
|
+
},
|
|
48
|
+
"direct-exec": {
|
|
49
|
+
"execModes": [
|
|
50
|
+
"direct",
|
|
51
|
+
"shell"
|
|
52
|
+
]
|
|
53
|
+
},
|
|
54
|
+
"write+direct-exec": {
|
|
55
|
+
"allowWrite": true,
|
|
56
|
+
"execModes": [
|
|
57
|
+
"direct",
|
|
58
|
+
"shell"
|
|
59
|
+
]
|
|
60
|
+
},
|
|
61
|
+
"shell-exec": {
|
|
62
|
+
"execModes": [
|
|
63
|
+
"shell"
|
|
64
|
+
]
|
|
65
|
+
},
|
|
45
66
|
"full": {
|
|
46
67
|
"profile": "full",
|
|
47
68
|
"allowWrite": true,
|
|
48
|
-
"execModes": [
|
|
69
|
+
"execModes": [
|
|
70
|
+
"shell"
|
|
71
|
+
],
|
|
49
72
|
"unrestrictedPaths": true,
|
|
50
73
|
"minimalEnv": false,
|
|
51
74
|
"exposeAbsolutePaths": true
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import accessContract from "../shared/access-contract.json";
|
|
2
|
+
import policyContract from "../shared/policy-contract.json";
|
|
3
|
+
import toolCatalog from "../shared/tool-catalog.json";
|
|
4
|
+
import { policyAllowsAvailability, type DaemonPolicy } from "./policy";
|
|
5
|
+
|
|
6
|
+
export type AccountRole = keyof typeof accessContract.roles;
|
|
7
|
+
|
|
8
|
+
const roles = accessContract.roles as Record<string, { profile: string }>;
|
|
9
|
+
const profiles = policyContract.profiles as unknown as Record<string, DaemonPolicy>;
|
|
10
|
+
const toolAvailability = new Map((toolCatalog as Array<{ name: string; availability: string }>).map((tool) => [tool.name, tool.availability]));
|
|
11
|
+
|
|
12
|
+
export const ACCOUNT_ACCESS_REVISION = Number(accessContract.revision);
|
|
13
|
+
export const ACCOUNT_ROLES = Object.freeze(Object.keys(roles));
|
|
14
|
+
export const OWNER_ACCOUNT_ROLE = String(accessContract.ownerRole) as AccountRole;
|
|
15
|
+
|
|
16
|
+
export function normalizeAccountRole(value: unknown): AccountRole | null {
|
|
17
|
+
const role = String(value ?? "").trim().toLowerCase();
|
|
18
|
+
return role in roles ? role as AccountRole : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function accountRolePolicy(role: AccountRole): DaemonPolicy {
|
|
22
|
+
const profileName = roles[role]?.profile;
|
|
23
|
+
const profile = profiles[profileName];
|
|
24
|
+
if (!profile) throw new Error(`account role references an unknown policy profile: ${role}`);
|
|
25
|
+
return {
|
|
26
|
+
profile: String(profile.profile),
|
|
27
|
+
origin: "explicit",
|
|
28
|
+
revision: Number(policyContract.revision),
|
|
29
|
+
allowWrite: profile.allowWrite === true,
|
|
30
|
+
allowExec: String(profile.execMode) !== "off",
|
|
31
|
+
execMode: String(profile.execMode) as DaemonPolicy["execMode"],
|
|
32
|
+
unrestrictedPaths: profile.unrestrictedPaths === true,
|
|
33
|
+
minimalEnv: profile.minimalEnv !== false,
|
|
34
|
+
exposeAbsolutePaths: profile.exposeAbsolutePaths === true,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function accountRoleAllowsTool(role: AccountRole, toolName: string): boolean {
|
|
39
|
+
if (toolName === "server_info") return true;
|
|
40
|
+
const availability = toolAvailability.get(toolName);
|
|
41
|
+
return Boolean(availability && policyAllowsAvailability(accountRolePolicy(role), availability));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function accountRoleToolNames(role: AccountRole, daemonTools: Iterable<string>): Set<string> {
|
|
45
|
+
return new Set([...daemonTools].filter((name) => accountRoleAllowsTool(role, name)));
|
|
46
|
+
}
|