machine-bridge-mcp 1.2.11 → 2.0.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 +14 -1
- package/README.md +10 -5
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +18 -10
- package/docs/AUDIT.md +25 -3
- package/docs/CLIENTS.md +2 -2
- package/docs/ENGINEERING.md +2 -2
- package/docs/GETTING_STARTED.md +1 -1
- package/docs/LOCAL_AUTHORIZATION.md +111 -0
- package/docs/LOGGING.md +4 -4
- package/docs/MULTI_ACCOUNT.md +5 -5
- package/docs/OPERATIONS.md +30 -5
- package/docs/OVERVIEW.md +12 -8
- package/docs/PROJECT_STANDARDS.md +1 -1
- package/docs/RELEASING.md +3 -3
- package/docs/TESTING.md +5 -2
- package/docs/THREAT_MODEL.md +7 -6
- package/docs/UPGRADING.md +10 -6
- package/package.json +4 -2
- package/scripts/check-plan.mjs +2 -0
- package/scripts/local-release-acceptance.mjs +2 -2
- package/scripts/release-acceptance.mjs +7 -4
- package/src/local/account-admin.mjs +28 -3
- package/src/local/cli-approval.mjs +117 -0
- package/src/local/cli-options.mjs +8 -2
- package/src/local/cli.mjs +13 -2
- package/src/local/device-identity.mjs +108 -0
- package/src/local/operation-authorization.mjs +366 -0
- package/src/local/operation-risk.mjs +221 -0
- package/src/local/operation-state-lock.mjs +92 -0
- package/src/local/relay-connection.mjs +12 -5
- package/src/local/runtime-relay.mjs +13 -5
- package/src/local/runtime.mjs +13 -7
- package/src/local/state.mjs +9 -3
- package/src/local/tool-executor.mjs +4 -2
- package/src/local/worker-deployment.mjs +4 -3
- package/src/local/worker-secret-file.mjs +2 -1
- package/src/shared/admin-auth.d.mts +10 -0
- package/src/shared/admin-auth.mjs +36 -0
- package/src/shared/daemon-auth.d.mts +20 -0
- package/src/shared/daemon-auth.mjs +55 -0
- package/src/worker/account-admin.ts +73 -4
- package/src/worker/daemon-auth.ts +205 -0
- package/src/worker/daemon-sockets.ts +15 -2
- package/src/worker/index.ts +59 -10
- package/src/worker/nonce-store.ts +79 -0
- package/src/worker/oauth-controller.ts +11 -6
- package/src/worker/oauth-refresh-families.ts +172 -0
- package/src/worker/oauth-state.ts +48 -3
- package/src/worker/oauth-tokens.ts +49 -40
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const OPERATION_APPROVAL_SCOPES = Object.freeze([
|
|
5
|
+
"shell",
|
|
6
|
+
"external-write",
|
|
7
|
+
"sensitive-write",
|
|
8
|
+
"external-read",
|
|
9
|
+
"sensitive-read",
|
|
10
|
+
"browser-session",
|
|
11
|
+
"data-export",
|
|
12
|
+
"persistent-job",
|
|
13
|
+
"application-control",
|
|
14
|
+
"credential-operation",
|
|
15
|
+
"full",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const SHELL_TOOLS = new Set(["exec_command", "run_process", "start_process", "run_local_command", "read_process", "write_process", "kill_process"]);
|
|
19
|
+
const PERSISTENT_TOOLS = new Set(["stage_job", "start_job", "list_jobs", "read_job", "cancel_job"]);
|
|
20
|
+
const APPLICATION_CONTROL_TOOLS = new Set(["open_local_application", "inspect_local_application", "operate_local_application"]);
|
|
21
|
+
const CREDENTIAL_TOOLS = new Set(["generate_ssh_key_resource"]);
|
|
22
|
+
const BROWSER_PROFILE_TOOLS = new Set([
|
|
23
|
+
"pair_browser_extension", "browser_list_tabs", "browser_manage_tabs", "browser_get_source",
|
|
24
|
+
"browser_inspect_page", "browser_wait", "browser_action", "browser_fill_form", "browser_screenshot",
|
|
25
|
+
]);
|
|
26
|
+
const AUTOMATIC_TOOLS = new Set([
|
|
27
|
+
"server_info", "project_overview", "list_local_applications", "browser_status", "list_roots",
|
|
28
|
+
"diagnose_runtime", "list_local_resources",
|
|
29
|
+
]);
|
|
30
|
+
const FILE_WRITE_TOOLS = new Set(["write_file", "edit_file"]);
|
|
31
|
+
const FILE_READ_TOOLS = new Set([
|
|
32
|
+
"session_bootstrap", "resolve_task_capabilities", "agent_context", "list_local_skills", "load_local_skill", "list_local_commands",
|
|
33
|
+
"list_dir", "list_files", "read_file", "view_image", "search_text",
|
|
34
|
+
"git_status", "git_diff", "git_log", "git_show",
|
|
35
|
+
]);
|
|
36
|
+
const SENSITIVE_SEGMENTS = new Set([
|
|
37
|
+
".ssh", ".aws", ".azure", ".gnupg", ".kube", ".docker", ".npmrc", ".pypirc",
|
|
38
|
+
"keychains", "cookies", "login data", "credentials", "secrets",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
export function reviewedOperationToolNames() {
|
|
42
|
+
return new Set([
|
|
43
|
+
...AUTOMATIC_TOOLS,
|
|
44
|
+
...SHELL_TOOLS,
|
|
45
|
+
...PERSISTENT_TOOLS,
|
|
46
|
+
...APPLICATION_CONTROL_TOOLS,
|
|
47
|
+
...CREDENTIAL_TOOLS,
|
|
48
|
+
...BROWSER_PROFILE_TOOLS,
|
|
49
|
+
...FILE_WRITE_TOOLS,
|
|
50
|
+
...FILE_READ_TOOLS,
|
|
51
|
+
"browser_upload_files",
|
|
52
|
+
"apply_patch",
|
|
53
|
+
]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function classifyOperation(tool, args = {}, options = {}) {
|
|
57
|
+
const name = String(tool || "");
|
|
58
|
+
if (SHELL_TOOLS.has(name)) return requirement("shell", "remote shell or process control", name, args);
|
|
59
|
+
if (PERSISTENT_TOOLS.has(name)) return requirement("persistent-job", "persistent managed job", name, args);
|
|
60
|
+
if (name === "operate_local_application" && args.value_resource) {
|
|
61
|
+
return requirement(["application-control", "data-export"], "desktop application control using protected local data", name, {
|
|
62
|
+
application: args.application,
|
|
63
|
+
action: args.action,
|
|
64
|
+
value_resource: args.value_resource,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (APPLICATION_CONTROL_TOOLS.has(name)) return requirement("application-control", "desktop application control", name, args);
|
|
68
|
+
if (CREDENTIAL_TOOLS.has(name)) return requirement("credential-operation", "credential or key operation", name, args);
|
|
69
|
+
if (name === "browser_upload_files") {
|
|
70
|
+
return requirement(["browser-session", "data-export"], "existing browser profile file upload", name, args);
|
|
71
|
+
}
|
|
72
|
+
if (name === "browser_action" && args.value_resource) {
|
|
73
|
+
return requirement(["browser-session", "data-export"], "existing browser profile input from protected local data", name, {
|
|
74
|
+
action: args.action,
|
|
75
|
+
tab_id: args.tab_id,
|
|
76
|
+
value_resource: args.value_resource,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if (name === "browser_fill_form" && Array.isArray(args.fields) && args.fields.some((field) => field?.value_resource || field?.sensitive === true)) {
|
|
80
|
+
return requirement(["browser-session", "data-export"], "existing browser profile form containing protected local data", name, {
|
|
81
|
+
tab_id: args.tab_id,
|
|
82
|
+
resource_fields: args.fields.filter((field) => field?.value_resource || field?.sensitive === true).length,
|
|
83
|
+
submit: args.submit === true,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (BROWSER_PROFILE_TOOLS.has(name)) {
|
|
87
|
+
return requirement("browser-session", "access to the existing browser profile", name, {
|
|
88
|
+
action: args.action,
|
|
89
|
+
tab_id: args.tab_id,
|
|
90
|
+
frame_id: args.frame_id,
|
|
91
|
+
submit: args.submit === true,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
if (FILE_WRITE_TOOLS.has(name)) {
|
|
95
|
+
const target = await canonicalWritePath(args.path, options);
|
|
96
|
+
const scopes = pathWriteScopes(options.workspace, target);
|
|
97
|
+
if (scopes.length) return requirement(scopes, pathWriteCategory(scopes), name, { path: target });
|
|
98
|
+
}
|
|
99
|
+
if (name === "apply_patch") {
|
|
100
|
+
const paths = patchPaths(args.patch);
|
|
101
|
+
const targets = [];
|
|
102
|
+
for (const candidate of paths) targets.push(await canonicalWritePath(candidate, options));
|
|
103
|
+
const scopes = normalizeScopes(targets.flatMap((target) => pathWriteScopes(options.workspace, target)));
|
|
104
|
+
if (scopes.length) return requirement(scopes, pathWriteCategory(scopes, "patch"), name, { paths: targets });
|
|
105
|
+
}
|
|
106
|
+
if (FILE_READ_TOOLS.has(name) && args.path !== undefined) {
|
|
107
|
+
const target = await canonicalExistingPath(args.path, options);
|
|
108
|
+
const scopes = [];
|
|
109
|
+
if (!isWithin(options.workspace, target)) scopes.push("external-read");
|
|
110
|
+
if (isSensitivePath(target)) scopes.push("sensitive-read");
|
|
111
|
+
if (scopes.length) return requirement(scopes, pathReadCategory(scopes), name, { path: target });
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function requirement(scopes, category, tool, target) {
|
|
117
|
+
const normalized = normalizeScopes(Array.isArray(scopes) ? scopes : [scopes]);
|
|
118
|
+
if (!normalized.length) throw new Error("operation approval requirement is missing a scope");
|
|
119
|
+
return {
|
|
120
|
+
scope: normalized[0],
|
|
121
|
+
scopes: normalized,
|
|
122
|
+
category,
|
|
123
|
+
targetHash: createHash("sha256").update(JSON.stringify({ tool, target: redactTarget(target) })).digest("hex"),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function normalizeScopes(scopes) {
|
|
128
|
+
const requested = new Set(scopes.map((scope) => String(scope || "")));
|
|
129
|
+
return OPERATION_APPROVAL_SCOPES.filter((scope) => requested.has(scope));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function pathWriteScopes(workspace, target) {
|
|
133
|
+
const scopes = [];
|
|
134
|
+
if (!isWithin(workspace, target)) scopes.push("external-write");
|
|
135
|
+
if (isSensitiveWritePath(target)) scopes.push("sensitive-write");
|
|
136
|
+
return scopes;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function pathWriteCategory(scopes, subject = "write") {
|
|
140
|
+
const external = scopes.includes("external-write");
|
|
141
|
+
const sensitive = scopes.includes("sensitive-write");
|
|
142
|
+
if (external && sensitive) return `${subject} outside the selected workspace to a credential or persistence-sensitive path`;
|
|
143
|
+
if (sensitive) return `${subject} to a credential or persistence-sensitive path`;
|
|
144
|
+
return `${subject} outside the selected workspace`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function pathReadCategory(scopes) {
|
|
148
|
+
const external = scopes.includes("external-read");
|
|
149
|
+
const sensitive = scopes.includes("sensitive-read");
|
|
150
|
+
if (external && sensitive) return "read outside the selected workspace from a credential-sensitive location";
|
|
151
|
+
if (sensitive) return "read from a credential-sensitive location";
|
|
152
|
+
return "read outside the selected workspace";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function redactTarget(value) {
|
|
156
|
+
if (!value || typeof value !== "object") return String(value || "");
|
|
157
|
+
const out = {};
|
|
158
|
+
for (const [key, item] of Object.entries(value)) {
|
|
159
|
+
if (["content", "command", "stdin", "value", "old_text", "new_text", "patch"].includes(key)) {
|
|
160
|
+
out[key] = `<sha256:${createHash("sha256").update(String(item || "")).digest("hex")}>`;
|
|
161
|
+
} else out[key] = item;
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function canonicalExistingPath(value, options) {
|
|
167
|
+
if (typeof options.resolveExistingPath === "function") return options.resolveExistingPath(value);
|
|
168
|
+
return path.resolve(options.workspace, String(value || "."));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function canonicalWritePath(value, options) {
|
|
172
|
+
if (typeof options.resolveWritePath === "function") return options.resolveWritePath(value);
|
|
173
|
+
return path.resolve(options.workspace, String(value || "."));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isWithin(workspace, target) {
|
|
177
|
+
const relative = path.relative(path.resolve(workspace), path.resolve(target));
|
|
178
|
+
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function isSensitivePath(target) {
|
|
182
|
+
const lower = path.resolve(target).toLowerCase();
|
|
183
|
+
const segments = lower.split(/[\\/]+/);
|
|
184
|
+
if (segments.some((segment) => SENSITIVE_SEGMENTS.has(segment))) return true;
|
|
185
|
+
const base = path.basename(lower);
|
|
186
|
+
return isSensitiveEnvironmentFile(base) || /(?:token|secret|credential|private[-_]?key)/.test(base);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function isSensitiveWritePath(target) {
|
|
190
|
+
const lower = path.resolve(target).toLowerCase();
|
|
191
|
+
const normalized = lower.split(path.sep).join("/");
|
|
192
|
+
const base = path.basename(lower);
|
|
193
|
+
if (isSensitivePath(lower)) return true;
|
|
194
|
+
if ([".zshrc", ".bashrc", ".bash_profile", ".profile", ".gitconfig", ".netrc", ".curlrc", "authorized_keys", "sshd_config", "sudoers"].includes(base)) return true;
|
|
195
|
+
return normalized.includes("/.git/hooks/")
|
|
196
|
+
|| normalized.endsWith("/.git/config")
|
|
197
|
+
|| normalized.endsWith("/.config/git/config")
|
|
198
|
+
|| normalized.includes("/library/launchagents/")
|
|
199
|
+
|| normalized.includes("/library/launchdaemons/")
|
|
200
|
+
|| normalized.includes("/.config/autostart/")
|
|
201
|
+
|| normalized.includes("/.config/systemd/user/")
|
|
202
|
+
|| normalized.includes("/start menu/programs/startup/")
|
|
203
|
+
|| normalized.endsWith("/.config/fish/config.fish");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function isSensitiveEnvironmentFile(base) {
|
|
207
|
+
if (base === ".env") return true;
|
|
208
|
+
if (!base.startsWith(".env.")) return false;
|
|
209
|
+
return ![".env.example", ".env.sample", ".env.template", ".env.defaults"].includes(base);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function patchPaths(patch) {
|
|
213
|
+
const paths = [];
|
|
214
|
+
for (const line of String(patch || "").split(/\r?\n/)) {
|
|
215
|
+
const match = /^(?:\*\*\* (?:Add|Update|Delete) File:|\*\*\* Move to:|\+\+\+|---)\s+(.+)$/.exec(line);
|
|
216
|
+
if (!match) continue;
|
|
217
|
+
const value = match[1].replace(/^[ab]\//, "").trim();
|
|
218
|
+
if (value && value !== "/dev/null") paths.push(value);
|
|
219
|
+
}
|
|
220
|
+
return paths;
|
|
221
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createExclusiveFileSync, removeOwnedJsonFileSync } from "./exclusive-file.mjs";
|
|
5
|
+
import { createMonotonicDeadline } from "./monotonic-deadline.mjs";
|
|
6
|
+
import { currentProcessStartTimeMs, inspectProcessInstance } from "./process-identity.mjs";
|
|
7
|
+
import { ensureOwnerOnlyDirectorySync, readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
8
|
+
|
|
9
|
+
const LOCK_PURPOSE = "operation-authorization";
|
|
10
|
+
const LOCK_FILE = "operation-authorization.lock";
|
|
11
|
+
const MAX_LOCK_BYTES = 8 * 1024;
|
|
12
|
+
const DEFAULT_WAIT_MS = 5_000;
|
|
13
|
+
const DEFAULT_POLL_MS = 25;
|
|
14
|
+
|
|
15
|
+
export async function withOperationStateLock(root, callback, options = {}) {
|
|
16
|
+
if (typeof callback !== "function") throw new TypeError("operation-state lock requires a callback");
|
|
17
|
+
const requestedRoot = String(root || "").trim();
|
|
18
|
+
if (!requestedRoot) throw new Error("operation-state lock root is missing");
|
|
19
|
+
const directory = path.resolve(requestedRoot);
|
|
20
|
+
ensureOwnerOnlyDirectorySync(directory);
|
|
21
|
+
const lockPath = path.join(directory, LOCK_FILE);
|
|
22
|
+
const timeoutMs = positiveInteger(options.timeoutMs, DEFAULT_WAIT_MS);
|
|
23
|
+
const pollMs = positiveInteger(options.pollMs, DEFAULT_POLL_MS);
|
|
24
|
+
const deadline = createMonotonicDeadline(timeoutMs);
|
|
25
|
+
const owner = lockOwner();
|
|
26
|
+
|
|
27
|
+
while (true) {
|
|
28
|
+
try {
|
|
29
|
+
createExclusiveFileSync(lockPath, `${JSON.stringify(owner, null, 2)}\n`, { mode: 0o600 });
|
|
30
|
+
break;
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (error?.code !== "EEXIST") throw error;
|
|
33
|
+
const existing = readLockOwner(lockPath);
|
|
34
|
+
if (!existing) throw new Error("operation authorization lock is malformed; inspect the owner-only state directory");
|
|
35
|
+
if (existing.purpose !== LOCK_PURPOSE) throw new Error("operation authorization lock contains mismatched purpose metadata");
|
|
36
|
+
const identity = inspectProcessInstance(existing, { maxAgeMs: Number.POSITIVE_INFINITY });
|
|
37
|
+
if (identity.reclaimable) {
|
|
38
|
+
if (removeOwnedJsonFileSync(lockPath, { token: existing.token, purpose: LOCK_PURPOSE }, { maxBytes: MAX_LOCK_BYTES })) continue;
|
|
39
|
+
}
|
|
40
|
+
if (deadline.expired()) {
|
|
41
|
+
const pid = Number.isInteger(existing.pid) ? `pid ${existing.pid}` : "unknown process";
|
|
42
|
+
throw new Error(`operation authorization state is busy (${pid}); retry after the current approval operation finishes`);
|
|
43
|
+
}
|
|
44
|
+
await delay(Math.min(pollMs, Math.max(1, deadline.remainingMs())));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let result;
|
|
49
|
+
let callbackError;
|
|
50
|
+
try {
|
|
51
|
+
result = await callback();
|
|
52
|
+
} catch (error) {
|
|
53
|
+
callbackError = error;
|
|
54
|
+
}
|
|
55
|
+
const released = removeOwnedJsonFileSync(lockPath, { token: owner.token, purpose: LOCK_PURPOSE }, { maxBytes: MAX_LOCK_BYTES });
|
|
56
|
+
if (!released) throw new Error("operation authorization lock changed before release; approval state may require inspection");
|
|
57
|
+
if (callbackError) throw callbackError;
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function lockOwner() {
|
|
62
|
+
return {
|
|
63
|
+
pid: process.pid,
|
|
64
|
+
token: randomBytes(16).toString("hex"),
|
|
65
|
+
purpose: LOCK_PURPOSE,
|
|
66
|
+
startedAt: new Date().toISOString(),
|
|
67
|
+
processStartedAt: new Date(currentProcessStartTimeMs()).toISOString(),
|
|
68
|
+
entryScript: String(process.argv[1] || "").slice(0, 4096),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readLockOwner(file) {
|
|
73
|
+
if (!existsSync(file)) return null;
|
|
74
|
+
let parsed;
|
|
75
|
+
try { parsed = JSON.parse(readBoundedRegularFileSync(file, MAX_LOCK_BYTES, "operation authorization lock").toString("utf8")); } catch { return null; }
|
|
76
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
77
|
+
if (!Number.isInteger(parsed.pid) || parsed.pid <= 0) return null;
|
|
78
|
+
if (!/^[a-f0-9]{32}$/.test(String(parsed.token || ""))) return null;
|
|
79
|
+
if (parsed.purpose !== LOCK_PURPOSE) return null;
|
|
80
|
+
if (!Number.isFinite(Date.parse(String(parsed.startedAt || "")))) return null;
|
|
81
|
+
if (!Number.isFinite(Date.parse(String(parsed.processStartedAt || "")))) return null;
|
|
82
|
+
return parsed;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function positiveInteger(value, fallback) {
|
|
86
|
+
const number = Number(value);
|
|
87
|
+
return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function delay(milliseconds) {
|
|
91
|
+
return new Promise((resolvePromise) => { setTimeout(resolvePromise, milliseconds); });
|
|
92
|
+
}
|
|
@@ -23,10 +23,9 @@ const DEFAULT_SCHEDULER = Object.freeze({
|
|
|
23
23
|
export class RelayConnection {
|
|
24
24
|
constructor(options = {}) {
|
|
25
25
|
this.workerUrl = normalizeWorkerUrl(options.workerUrl);
|
|
26
|
-
if (typeof options.secret !== "string" || options.secret.length < 16) throw new Error("daemon secret is missing or too short");
|
|
27
|
-
this.secret = options.secret;
|
|
28
26
|
this.logger = options.logger || console;
|
|
29
27
|
this.helloMessage = typeof options.helloMessage === "function" ? options.helloMessage : () => ({ type: "hello" });
|
|
28
|
+
this.connectionHeaders = typeof options.connectionHeaders === "function" ? options.connectionHeaders : () => ({});
|
|
30
29
|
this.expectedServer = String(options.expectedServer || "");
|
|
31
30
|
this.expectedVersion = String(options.expectedVersion || "");
|
|
32
31
|
this.onMessage = typeof options.onMessage === "function" ? options.onMessage : () => {};
|
|
@@ -164,6 +163,13 @@ export class RelayConnection {
|
|
|
164
163
|
return false;
|
|
165
164
|
}
|
|
166
165
|
this.logger.debug?.("remote relay welcome received");
|
|
166
|
+
Promise.resolve(this.helloMessage(message)).then((hello) => {
|
|
167
|
+
if (this.socket !== socket || this.closed || this.authenticated) return;
|
|
168
|
+
if (!this.sendOnSocket(socket, hello)) this.failPermanently("relay_authentication_failed");
|
|
169
|
+
}).catch((error) => {
|
|
170
|
+
this.logger.debug?.("could not create daemon authentication proof", { error_class: classifyOperationalError(error) });
|
|
171
|
+
this.failPermanently("relay_authentication_failed");
|
|
172
|
+
});
|
|
167
173
|
return true;
|
|
168
174
|
}
|
|
169
175
|
|
|
@@ -273,9 +279,11 @@ export class RelayConnection {
|
|
|
273
279
|
let socket;
|
|
274
280
|
try {
|
|
275
281
|
const proxy = this.proxyAgentForUrl(wsUrl);
|
|
282
|
+
const headers = this.connectionHeaders();
|
|
283
|
+
if (!headers || typeof headers !== "object" || Array.isArray(headers)) throw new Error("relay connection headers are invalid");
|
|
276
284
|
this.networkRoute = proxy?.agent ? "proxy" : "direct";
|
|
277
285
|
socket = new this.WebSocketClass(wsUrl, {
|
|
278
|
-
headers
|
|
286
|
+
headers,
|
|
279
287
|
maxPayload: this.maxPayload,
|
|
280
288
|
...(proxy?.agent ? { agent: proxy.agent } : {}),
|
|
281
289
|
});
|
|
@@ -308,8 +316,7 @@ export class RelayConnection {
|
|
|
308
316
|
return;
|
|
309
317
|
}
|
|
310
318
|
this.lastInboundAt = this.now();
|
|
311
|
-
this.logger.debug?.("remote relay transport opened; awaiting
|
|
312
|
-
if (!this.sendOnSocket(socket, this.helloMessage())) return;
|
|
319
|
+
this.logger.debug?.("remote relay transport opened; awaiting device challenge");
|
|
313
320
|
this.clearTimer("handshakeTimer", "clearTimeout");
|
|
314
321
|
this.handshakeTimer = this.scheduler.setTimeout(() => {
|
|
315
322
|
if (this.socket !== socket || this.closed || this.ready) return;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
2
|
import { RelayConnection } from "./relay-connection.mjs";
|
|
3
|
+
import { createDaemonAuthentication, createDaemonPreflightHeaders } from "./device-identity.mjs";
|
|
3
4
|
import { MCP_SUPPORTED_PROTOCOL_VERSIONS, SERVER_NAME } from "./tools.mjs";
|
|
4
5
|
import { normalizeAccountRole } from "./account-access.mjs";
|
|
5
6
|
import { clampInteger } from "./numbers.mjs";
|
|
@@ -7,21 +8,27 @@ import { isPlainRecord } from "./records.mjs";
|
|
|
7
8
|
|
|
8
9
|
export const MAX_RELAY_MESSAGE_BYTES = 8 * 1024 * 1024;
|
|
9
10
|
|
|
10
|
-
export function createRuntimeRelayConnection(runtime, { workerUrl,
|
|
11
|
+
export function createRuntimeRelayConnection(runtime, { workerUrl, deviceIdentity, expectedVersion, onFatal }) {
|
|
11
12
|
if (!workerUrl) return null;
|
|
12
13
|
return new RelayConnection({
|
|
13
14
|
workerUrl,
|
|
14
|
-
secret,
|
|
15
15
|
logger: runtime.logger,
|
|
16
16
|
maxPayload: MAX_RELAY_MESSAGE_BYTES,
|
|
17
17
|
expectedServer: SERVER_NAME,
|
|
18
18
|
expectedVersion: String(expectedVersion || ""),
|
|
19
|
-
|
|
19
|
+
connectionHeaders: () => createDaemonPreflightHeaders(
|
|
20
|
+
deviceIdentity,
|
|
21
|
+
workerUrl,
|
|
22
|
+
SERVER_NAME,
|
|
23
|
+
String(expectedVersion || ""),
|
|
24
|
+
),
|
|
25
|
+
helloMessage: async (welcome) => ({
|
|
20
26
|
type: "hello",
|
|
21
27
|
instance_id: runtime.relayInstanceId,
|
|
22
28
|
tools: runtime.tools(),
|
|
23
29
|
policy: runtime.policy,
|
|
24
30
|
protocol_versions: MCP_SUPPORTED_PROTOCOL_VERSIONS,
|
|
31
|
+
authentication: await createDaemonAuthentication(deviceIdentity, welcome, runtime.relayInstanceId),
|
|
25
32
|
}),
|
|
26
33
|
onMessage: (data, relayContext) => handleRelayData(runtime, data, relayContext),
|
|
27
34
|
onDisconnect: () => runtime.handleRelayDisconnect(),
|
|
@@ -82,8 +89,9 @@ function normalizeRelayAuthorization(value) {
|
|
|
82
89
|
if (!isPlainRecord(value)) return null;
|
|
83
90
|
const accountId = typeof value.account_id === "string" && /^acct_[A-Za-z0-9_-]{20,96}$/.test(value.account_id) ? value.account_id : "";
|
|
84
91
|
const accountVersion = Number(value.account_version);
|
|
92
|
+
const clientId = typeof value.client_id === "string" && /^mcp_client_[A-Za-z0-9_-]{43}$/.test(value.client_id) ? value.client_id : "";
|
|
85
93
|
let role;
|
|
86
94
|
try { role = normalizeAccountRole(value.role); } catch { return null; }
|
|
87
|
-
if (!accountId || !Number.isInteger(accountVersion) || accountVersion < 1) return null;
|
|
88
|
-
return Object.freeze({ account_id: accountId, account_version: accountVersion, role });
|
|
95
|
+
if (!accountId || !clientId || !Number.isInteger(accountVersion) || accountVersion < 1) return null;
|
|
96
|
+
return Object.freeze({ account_id: accountId, account_version: accountVersion, client_id: clientId, role });
|
|
89
97
|
}
|
package/src/local/runtime.mjs
CHANGED
|
@@ -31,6 +31,7 @@ import { AccountAccessGate } from "./account-access.mjs";
|
|
|
31
31
|
import { buildProjectOverview, buildRuntimeInfo } from "./runtime-reporting.mjs";
|
|
32
32
|
import { diagnoseRuntime as runRuntimeDiagnostics } from "./runtime-diagnostics.mjs";
|
|
33
33
|
import { bindRuntimeToolHandlers, runtimeToolHandlerNames as registeredRuntimeToolHandlerNames } from "./runtime-tool-handlers.mjs";
|
|
34
|
+
import { OperationAuthorizer } from "./operation-authorization.mjs";
|
|
34
35
|
import { createRuntimeRelayConnection, normalizeRelayResumeCalls, normalizeRelayToolCall } from "./runtime-relay.mjs";
|
|
35
36
|
import { RelayCallRecovery } from "./relay-call-recovery.mjs";
|
|
36
37
|
import { assertContainedPath, createRuntimeDir, redactRuntimeErrorMessage, stateRootFromProfileStatePath } from "./runtime-paths.mjs";
|
|
@@ -46,9 +47,8 @@ export function runtimeToolHandlerNames() {
|
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
export class LocalRuntime {
|
|
49
|
-
constructor({ workerUrl = "",
|
|
50
|
+
constructor({ workerUrl = "", deviceIdentity = null, expectedRelayVersion = "", workspace, policy, logger = console, onSuperseded = null, onFatal = null, jobRoot = "", approvalRoot = "", resources = {}, resourceStatePath = "", browserStateRoot = "", agentHome = process.env.HOME || process.env.USERPROFILE || "", codexHome = process.env.CODEX_HOME || "", recoverJobs = true, applicationAutomation = {} }) {
|
|
50
51
|
const remoteWorkerUrl = workerUrl ? String(workerUrl) : "";
|
|
51
|
-
const remoteSecret = secret || "";
|
|
52
52
|
this.workspaceInput = resolve(workspace || process.cwd());
|
|
53
53
|
this.workspace = realpathSync.native ? realpathSync.native(this.workspaceInput) : realpathSync(this.workspaceInput);
|
|
54
54
|
this.workspaceCanonicalPromise = null;
|
|
@@ -155,6 +155,12 @@ export class LocalRuntime {
|
|
|
155
155
|
readResourceText,
|
|
156
156
|
throwIfCancelled: (context) => this.throwIfCancelled(context),
|
|
157
157
|
});
|
|
158
|
+
this.operationAuthorizer = new OperationAuthorizer({
|
|
159
|
+
workspace: this.workspace,
|
|
160
|
+
root: approvalRoot,
|
|
161
|
+
resolveExistingPath: (value) => this.resolveExistingPath(value),
|
|
162
|
+
resolveWritePath: (value) => this.resolveWritePath(value),
|
|
163
|
+
});
|
|
158
164
|
this.browserBridgeManager = new BrowserBridgeManager({
|
|
159
165
|
policy: this.policy,
|
|
160
166
|
authorizeTool: (tool) => this.policyGate.assert(tool),
|
|
@@ -169,6 +175,7 @@ export class LocalRuntime {
|
|
|
169
175
|
handlers: bindRuntimeToolHandlers(this),
|
|
170
176
|
policyGate: this.policyGate,
|
|
171
177
|
accountAccessGate: this.accountAccessGate,
|
|
178
|
+
operationAuthorizer: this.operationAuthorizer,
|
|
172
179
|
callRegistry: this.callRegistry,
|
|
173
180
|
observability: this.observability,
|
|
174
181
|
logger: this.logger,
|
|
@@ -176,7 +183,7 @@ export class LocalRuntime {
|
|
|
176
183
|
slowMs: SLOW_TOOL_CALL_MS,
|
|
177
184
|
});
|
|
178
185
|
this.relay = createRuntimeRelayConnection(this, {
|
|
179
|
-
workerUrl: remoteWorkerUrl,
|
|
186
|
+
workerUrl: remoteWorkerUrl, deviceIdentity, expectedVersion: expectedRelayVersion, onFatal,
|
|
180
187
|
});
|
|
181
188
|
this.relayCallRecovery = new RelayCallRecovery({
|
|
182
189
|
logger: this.logger,
|
|
@@ -210,7 +217,7 @@ export class LocalRuntime {
|
|
|
210
217
|
}
|
|
211
218
|
|
|
212
219
|
async start() {
|
|
213
|
-
if (!this.relay) throw new Error("remote daemon start requires a Worker URL and
|
|
220
|
+
if (!this.relay) throw new Error("remote daemon start requires a Worker URL and device identity");
|
|
214
221
|
if (!this.lifecycle.beginStart()) return;
|
|
215
222
|
if (this.policy.profile === "full") {
|
|
216
223
|
void this.browserBridgeManager.ensureStarted().catch((error) => {
|
|
@@ -601,8 +608,8 @@ export class LocalRuntime {
|
|
|
601
608
|
|
|
602
609
|
async resolveWritePath(inputPath = ".") {
|
|
603
610
|
const candidate = this.resolvePath(inputPath);
|
|
604
|
-
if (this.policy.unrestrictedPaths) return candidate;
|
|
605
611
|
const candidateInfo = await lstat(candidate).catch(() => null);
|
|
612
|
+
if (candidateInfo?.isSymbolicLink()) throw new Error("refusing to overwrite a symbolic link");
|
|
606
613
|
let ancestor = candidate;
|
|
607
614
|
while (!(await lstat(ancestor).catch(() => null))) {
|
|
608
615
|
const parent = dirname(ancestor);
|
|
@@ -610,8 +617,7 @@ export class LocalRuntime {
|
|
|
610
617
|
ancestor = parent;
|
|
611
618
|
}
|
|
612
619
|
const [workspace, canonicalAncestor] = await Promise.all([this.canonicalWorkspace(), realpath(ancestor)]);
|
|
613
|
-
assertContainedPath(workspace, canonicalAncestor);
|
|
614
|
-
if (candidateInfo?.isSymbolicLink()) throw new Error("refusing to overwrite a symbolic link");
|
|
620
|
+
if (!this.policy.unrestrictedPaths) assertContainedPath(workspace, canonicalAncestor);
|
|
615
621
|
const suffix = relative(ancestor, candidate);
|
|
616
622
|
return suffix ? resolve(canonicalAncestor, suffix) : canonicalAncestor;
|
|
617
623
|
}
|
package/src/local/state.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import serverMetadata from "../shared/server-metadata.json" with { type: "json"
|
|
|
7
7
|
import { replaceFileSync } from "./atomic-fs.mjs";
|
|
8
8
|
import { createExclusiveFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
|
|
9
9
|
import { createMonotonicDeadline } from "./monotonic-deadline.mjs";
|
|
10
|
+
import { createDeviceIdentity, validateDeviceIdentity } from "./device-identity.mjs";
|
|
10
11
|
import { currentProcessStartTimeMs, inspectProcessInstance } from "./process-identity.mjs";
|
|
11
12
|
import { chmodRegularFileSync, ensureOwnerOnlyDirectorySync, readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
12
13
|
|
|
@@ -591,9 +592,14 @@ function pruneBackups(filePath, keep) {
|
|
|
591
592
|
|
|
592
593
|
export function ensureWorkerSecrets(state, options = {}) {
|
|
593
594
|
state.worker ||= {};
|
|
595
|
+
const enrollingDeviceIdentity = !state.worker.deviceIdentity;
|
|
594
596
|
if (!state.worker.accountAdminSecret || options.rotateSecrets) state.worker.accountAdminSecret = randomToken("account_admin");
|
|
595
|
-
if (
|
|
596
|
-
|
|
597
|
+
if (enrollingDeviceIdentity || options.rotateSecrets) state.worker.deviceIdentity = createDeviceIdentity();
|
|
598
|
+
else validateDeviceIdentity(state.worker.deviceIdentity);
|
|
599
|
+
delete state.worker.daemonSecret;
|
|
600
|
+
if (!state.worker.oauthTokenVersion || enrollingDeviceIdentity || options.rotateSecrets) {
|
|
601
|
+
state.worker.oauthTokenVersion = randomToken("token_version");
|
|
602
|
+
}
|
|
597
603
|
|
|
598
604
|
const requestedName = options.workerName || "";
|
|
599
605
|
if (!state.worker.name) {
|
|
@@ -635,7 +641,7 @@ export function ownerOnlyFile(filePath) {
|
|
|
635
641
|
export function redactState(state) {
|
|
636
642
|
const clone = redactHomeInValue(JSON.parse(JSON.stringify(state)));
|
|
637
643
|
if (clone.worker?.accountAdminSecret) clone.worker.accountAdminSecret = "<redacted>";
|
|
638
|
-
if (clone.worker?.
|
|
644
|
+
if (clone.worker?.deviceIdentity?.privateJwk?.d) clone.worker.deviceIdentity.privateJwk.d = "<redacted>";
|
|
639
645
|
if (clone.worker?.oauthTokenVersion) clone.worker.oauthTokenVersion = "<redacted>";
|
|
640
646
|
if (clone.resources && typeof clone.resources === "object") {
|
|
641
647
|
for (const value of Object.values(clone.resources)) {
|
|
@@ -7,6 +7,7 @@ export class ToolExecutor {
|
|
|
7
7
|
this.policyGate = options.policyGate;
|
|
8
8
|
this.callRegistry = options.callRegistry;
|
|
9
9
|
this.accountAccessGate = options.accountAccessGate;
|
|
10
|
+
this.operationAuthorizer = options.operationAuthorizer;
|
|
10
11
|
this.observability = options.observability;
|
|
11
12
|
this.logger = options.logger || console;
|
|
12
13
|
this.safeMessage = typeof options.safeMessage === "function" ? options.safeMessage : (error) => String(error?.message || error || "operation failed");
|
|
@@ -14,7 +15,7 @@ export class ToolExecutor {
|
|
|
14
15
|
this.pipeline = composeMiddleware([
|
|
15
16
|
lifecycleMiddleware(this.callRegistry),
|
|
16
17
|
observabilityMiddleware(this.observability, this.logger, this.safeMessage, this.slowMs),
|
|
17
|
-
authorizeMiddleware(this.policyGate, this.accountAccessGate),
|
|
18
|
+
authorizeMiddleware(this.policyGate, this.accountAccessGate, this.operationAuthorizer),
|
|
18
19
|
], invokeHandler(this.handlers));
|
|
19
20
|
}
|
|
20
21
|
|
|
@@ -27,13 +28,14 @@ export function composeMiddleware(middleware, terminal) {
|
|
|
27
28
|
return middleware.reduceRight((next, current) => (operation) => current(operation, next), terminal);
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
function authorizeMiddleware(policyGate, accountAccessGate) {
|
|
31
|
+
function authorizeMiddleware(policyGate, accountAccessGate, operationAuthorizer) {
|
|
31
32
|
return async (operation, next) => {
|
|
32
33
|
policyGate.assert(operation.tool);
|
|
33
34
|
if (operation.context.origin === "relay") {
|
|
34
35
|
const role = operation.request.authorization?.role;
|
|
35
36
|
if (!role) throw new Error("relay tool call is missing an account role");
|
|
36
37
|
accountAccessGate.assert(role, operation.tool);
|
|
38
|
+
await operationAuthorizer?.authorize(operation);
|
|
37
39
|
}
|
|
38
40
|
return next(operation);
|
|
39
41
|
};
|
|
@@ -4,6 +4,7 @@ import path, { resolve } from "node:path";
|
|
|
4
4
|
import { runWrangler } from "./shell.mjs";
|
|
5
5
|
import { packageRoot, saveState } from "./state.mjs";
|
|
6
6
|
import { withWorkerSecretsFile } from "./worker-secret-file.mjs";
|
|
7
|
+
import { publicDeviceJwkJson } from "./device-identity.mjs";
|
|
7
8
|
import {
|
|
8
9
|
normalizeWorkerOrigin,
|
|
9
10
|
retryWorkerHealth,
|
|
@@ -81,11 +82,11 @@ export function workerDeploymentFingerprint(state, options = {}) {
|
|
|
81
82
|
const root = resolve(options.packageRoot || packageRoot);
|
|
82
83
|
const keyMaterial = [
|
|
83
84
|
String(state.worker.accountAdminSecret || ""),
|
|
84
|
-
|
|
85
|
+
publicDeviceJwkJson(state.worker.deviceIdentity),
|
|
85
86
|
String(state.worker.oauthTokenVersion || ""),
|
|
86
87
|
].join("\0");
|
|
87
88
|
const fingerprint = createHmac("sha256", keyMaterial);
|
|
88
|
-
fingerprint.update("mbm-worker-deploy-
|
|
89
|
+
fingerprint.update("mbm-worker-deploy-v4");
|
|
89
90
|
fingerprint.update(String(state.worker.name || ""));
|
|
90
91
|
for (const file of workerDeployHashFiles(root)) {
|
|
91
92
|
fingerprint.update(path.relative(root, file));
|
|
@@ -118,7 +119,7 @@ export function workerUrlMatchesName(workerUrl, workerName) {
|
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
function hasCompleteWorkerState(worker = {}) {
|
|
121
|
-
return Boolean(worker.url && worker.mcpServerUrl && worker.accountAdminSecret && worker.
|
|
122
|
+
return Boolean(worker.url && worker.mcpServerUrl && worker.accountAdminSecret && worker.deviceIdentity && worker.oauthTokenVersion && worker.name);
|
|
122
123
|
}
|
|
123
124
|
|
|
124
125
|
function workerDeployHashFiles(root) {
|
|
@@ -3,6 +3,7 @@ import { lstatSync, readdirSync, unlinkSync } from "node:fs";
|
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { createExclusiveFileSync } from "./exclusive-file.mjs";
|
|
5
5
|
import { currentProcessStartTimeMs, inspectProcessInstance } from "./process-identity.mjs";
|
|
6
|
+
import { publicDeviceJwkJson } from "./device-identity.mjs";
|
|
6
7
|
import { chmodRegularFileSync, ensureOwnerOnlyDirectorySync } from "./secure-file.mjs";
|
|
7
8
|
|
|
8
9
|
const SECRET_FILE_PATTERN = /^worker-secrets-(\d+)-(\d+)(?:-p(\d+))?(?:-([a-f0-9]+))?\.json$/;
|
|
@@ -17,7 +18,7 @@ export async function withWorkerSecretsFile(state, callback, options = {}) {
|
|
|
17
18
|
const tempPath = resolve(dir, `worker-secrets-${process.pid}-${createdAt}-p${processStartedAt}-${random}.json`);
|
|
18
19
|
const payload = {
|
|
19
20
|
ACCOUNT_ADMIN_SECRET: state.worker.accountAdminSecret,
|
|
20
|
-
|
|
21
|
+
DAEMON_DEVICE_PUBLIC_KEY: publicDeviceJwkJson(state.worker.deviceIdentity),
|
|
21
22
|
OAUTH_TOKEN_VERSION: state.worker.oauthTokenVersion,
|
|
22
23
|
};
|
|
23
24
|
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const ADMIN_AUTH_SCHEME: "hmac-sha256-v1";
|
|
2
|
+
export const ADMIN_AUTH_TTL_SECONDS: number;
|
|
3
|
+
export function adminAuthTranscript(input: {
|
|
4
|
+
origin: unknown;
|
|
5
|
+
method: unknown;
|
|
6
|
+
pathname: unknown;
|
|
7
|
+
bodyHash: unknown;
|
|
8
|
+
issuedAt: unknown;
|
|
9
|
+
nonce: unknown;
|
|
10
|
+
}): string;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const ADMIN_AUTH_SCHEME = "hmac-sha256-v1";
|
|
2
|
+
export const ADMIN_AUTH_TTL_SECONDS = 5 * 60;
|
|
3
|
+
|
|
4
|
+
export function adminAuthTranscript(input = {}) {
|
|
5
|
+
const origin = requiredOrigin(input.origin);
|
|
6
|
+
const method = requiredToken(input.method, "method", /^[A-Z]{3,10}$/);
|
|
7
|
+
const pathname = requiredToken(input.pathname, "pathname", /^\/[A-Za-z0-9/_-]{1,255}$/);
|
|
8
|
+
const bodyHash = requiredToken(input.bodyHash, "body hash", /^[a-f0-9]{64}$/);
|
|
9
|
+
const issuedAt = requiredInteger(input.issuedAt, "issued at");
|
|
10
|
+
const nonce = requiredToken(input.nonce, "nonce", /^[A-Za-z0-9_-]{32,128}$/);
|
|
11
|
+
return [ADMIN_AUTH_SCHEME, origin, method, pathname, bodyHash, issuedAt, nonce].join("\0");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
function requiredOrigin(value) {
|
|
16
|
+
let url;
|
|
17
|
+
try { url = new URL(String(value || "")); } catch { throw new Error("admin authentication origin is invalid"); }
|
|
18
|
+
const secure = url.protocol === "https:";
|
|
19
|
+
const loopback = url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
|
|
20
|
+
if ((!secure && !loopback) || url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
|
|
21
|
+
throw new Error("admin authentication origin is invalid");
|
|
22
|
+
}
|
|
23
|
+
return url.origin;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function requiredToken(value, label, pattern) {
|
|
27
|
+
const text = String(value || "");
|
|
28
|
+
if (!pattern.test(text)) throw new Error(`admin authentication ${label} is invalid`);
|
|
29
|
+
return text;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function requiredInteger(value, label) {
|
|
33
|
+
const number = Number(value);
|
|
34
|
+
if (!Number.isSafeInteger(number) || number <= 0) throw new Error(`admin authentication ${label} is invalid`);
|
|
35
|
+
return String(number);
|
|
36
|
+
}
|