machine-bridge-mcp 1.2.10 → 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 +23 -1
- package/CONTRIBUTING.md +1 -1
- package/README.md +11 -6
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +23 -11
- package/docs/AUDIT.md +37 -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 +7 -5
- package/docs/MULTI_ACCOUNT.md +5 -5
- package/docs/OPERATIONS.md +33 -7
- package/docs/OVERVIEW.md +12 -8
- package/docs/PROJECT_STANDARDS.md +1 -1
- package/docs/RELEASING.md +4 -4
- package/docs/TESTING.md +9 -2
- package/docs/THREAT_MODEL.md +7 -6
- package/docs/TOOL_REFERENCE.md +4 -4
- package/docs/UPGRADING.md +10 -6
- package/package.json +8 -2
- package/scripts/check-plan.mjs +5 -0
- package/scripts/check-runner.mjs +75 -0
- package/scripts/coverage-check.mjs +1 -1
- package/scripts/github-backlog.mjs +116 -0
- package/scripts/github-push.mjs +4 -0
- package/scripts/local-release-acceptance.mjs +2 -2
- package/scripts/release-acceptance.mjs +36 -17
- package/scripts/run-checks.mjs +11 -21
- package/src/local/account-admin.mjs +28 -3
- package/src/local/bounded-output.mjs +20 -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/errors.mjs +5 -1
- package/src/local/execution-limits.mjs +6 -1
- 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/process-execution.mjs +94 -14
- package/src/local/process-output-stream.mjs +82 -0
- package/src/local/process-result-projection.mjs +25 -0
- package/src/local/process-sessions.mjs +37 -51
- package/src/local/relay-connection.mjs +12 -5
- package/src/local/runtime-relay.mjs +13 -5
- package/src/local/runtime.mjs +14 -7
- package/src/local/secure-file.mjs +24 -4
- package/src/local/state.mjs +9 -3
- package/src/local/tool-executor.mjs +4 -2
- package/src/local/tools.mjs +4 -9
- 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/shared/result-projection.d.mts +6 -0
- package/src/shared/result-projection.json +4 -0
- package/src/shared/result-projection.mjs +50 -0
- package/src/shared/server-metadata.json +1 -0
- package/src/shared/tool-catalog.json +4 -4
- 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/errors.ts +18 -4
- package/src/worker/index.ts +59 -10
- package/src/worker/mcp-jsonrpc.ts +4 -2
- 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,116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
8
|
+
|
|
9
|
+
export function closingIssueNumbers(messages) {
|
|
10
|
+
const numbers = new Set();
|
|
11
|
+
const pattern = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?[ \t]+#(\d+)\b/gi;
|
|
12
|
+
for (const match of String(messages || "").matchAll(pattern)) numbers.add(Number(match[1]));
|
|
13
|
+
return numbers;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function backlogBlockers({ issues = [], pullRequests = [], branch = "", commitMessages = "" }) {
|
|
17
|
+
const closing = closingIssueNumbers(commitMessages);
|
|
18
|
+
const issueBlockers = issues.filter((issue) => !closing.has(Number(issue.number)));
|
|
19
|
+
const pullRequestBlockers = pullRequests.filter((pull) => !(
|
|
20
|
+
String(pull.headRefName || "") === branch
|
|
21
|
+
&& pull.headRepository
|
|
22
|
+
&& pull.headRepository === pull.baseRepository
|
|
23
|
+
));
|
|
24
|
+
return { issueBlockers, pullRequestBlockers, closing: [...closing].sort((left, right) => left - right) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function assertGitHubBacklogReady(options = {}) {
|
|
28
|
+
const run = options.run || runCommand;
|
|
29
|
+
const cwd = options.cwd || root;
|
|
30
|
+
const branch = options.branch || output(run, "git", ["branch", "--show-current"], cwd);
|
|
31
|
+
if (!branch) throw new Error("cannot inspect GitHub backlog from a detached HEAD");
|
|
32
|
+
const commitMessages = output(run, "git", ["log", "--format=%B%x00", "origin/main..HEAD"], cwd);
|
|
33
|
+
const issueRows = pagedJson(output(run, "gh", [
|
|
34
|
+
"api", "--paginate", "--slurp", "repos/{owner}/{repo}/issues?state=open&per_page=100",
|
|
35
|
+
], cwd), "open GitHub issues");
|
|
36
|
+
const pullRows = pagedJson(output(run, "gh", [
|
|
37
|
+
"api", "--paginate", "--slurp", "repos/{owner}/{repo}/pulls?state=open&per_page=100",
|
|
38
|
+
], cwd), "open GitHub pull requests");
|
|
39
|
+
const issues = issueRows
|
|
40
|
+
.filter((item) => !item.pull_request)
|
|
41
|
+
.map((item) => ({ number: item.number, title: item.title, url: item.html_url }));
|
|
42
|
+
const pullRequests = pullRows.map((item) => ({
|
|
43
|
+
number: item.number,
|
|
44
|
+
title: item.title,
|
|
45
|
+
headRefName: item.head?.ref,
|
|
46
|
+
headRepository: item.head?.repo?.full_name,
|
|
47
|
+
baseRepository: item.base?.repo?.full_name,
|
|
48
|
+
url: item.html_url,
|
|
49
|
+
}));
|
|
50
|
+
const blockers = backlogBlockers({ issues, pullRequests, branch, commitMessages });
|
|
51
|
+
if (!blockers.issueBlockers.length && !blockers.pullRequestBlockers.length) {
|
|
52
|
+
const covered = blockers.closing.length ? `; closing issue(s): ${blockers.closing.map((number) => `#${number}`).join(", ")}` : "";
|
|
53
|
+
return { branch, issues, pullRequests, ...blockers, message: `GitHub backlog gate passed${covered}.` };
|
|
54
|
+
}
|
|
55
|
+
throw new Error(formatBlockers(blockers, branch));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function formatBlockers(blockers, branch) {
|
|
59
|
+
const lines = ["GitHub backlog is not ready for another push."];
|
|
60
|
+
if (blockers.issueBlockers.length) {
|
|
61
|
+
lines.push("Open issues not closed by a commit in origin/main..HEAD:");
|
|
62
|
+
for (const issue of blockers.issueBlockers) lines.push(`- #${issue.number} ${boundedTitle(issue.title)}${issue.url ? ` (${issue.url})` : ""}`);
|
|
63
|
+
}
|
|
64
|
+
if (blockers.pullRequestBlockers.length) {
|
|
65
|
+
lines.push(`Open pull requests other than the current branch (${branch}):`);
|
|
66
|
+
for (const pull of blockers.pullRequestBlockers) lines.push(`- #${pull.number} ${boundedTitle(pull.title)} [${pull.headRefName || "unknown branch"}]${pull.url ? ` (${pull.url})` : ""}`);
|
|
67
|
+
}
|
|
68
|
+
lines.push("Resolve or close every blocker before pushing. The current branch may cover an issue with a standard closing keyword such as 'Closes #47'.");
|
|
69
|
+
return lines.join("\n");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function boundedTitle(value) {
|
|
73
|
+
return String(value || "untitled").replace(/[\r\n]+/g, " ").slice(0, 200);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function pagedJson(text, label) {
|
|
77
|
+
let pages;
|
|
78
|
+
try { pages = JSON.parse(text || "[]"); }
|
|
79
|
+
catch (error) { throw new Error(`${label} response was not valid JSON: ${error.message}`); }
|
|
80
|
+
if (!Array.isArray(pages) || pages.some((page) => !Array.isArray(page))) {
|
|
81
|
+
throw new Error(`${label} response was not a slurped page array`);
|
|
82
|
+
}
|
|
83
|
+
return pages.flat();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function output(run, command, args, cwd) {
|
|
87
|
+
const result = run(command, args, cwd);
|
|
88
|
+
return String(result.stdout || "").trim();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function runCommand(command, args, cwd) {
|
|
92
|
+
const result = spawnSync(command, args, {
|
|
93
|
+
cwd,
|
|
94
|
+
encoding: "utf8",
|
|
95
|
+
env: process.env,
|
|
96
|
+
stdio: "pipe",
|
|
97
|
+
windowsHide: true,
|
|
98
|
+
shell: false,
|
|
99
|
+
});
|
|
100
|
+
if (result.error) throw result.error;
|
|
101
|
+
if (result.status !== 0) {
|
|
102
|
+
const detail = String(result.stderr || result.stdout || "").trim();
|
|
103
|
+
throw new Error(`${command} ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
|
|
104
|
+
}
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
109
|
+
try {
|
|
110
|
+
const result = assertGitHubBacklogReady();
|
|
111
|
+
console.log(result.message);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
console.error(`GitHub backlog gate failed: ${error?.message || error}`);
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
}
|
package/scripts/github-push.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { spawnSync } from "node:child_process";
|
|
|
4
4
|
import { dirname, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { verifyCurrentReleaseAcceptance } from "./release-acceptance.mjs";
|
|
7
|
+
import { assertGitHubBacklogReady } from "./github-backlog.mjs";
|
|
7
8
|
|
|
8
9
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
10
|
|
|
@@ -13,6 +14,9 @@ try {
|
|
|
13
14
|
const branch = output("git", ["branch", "--show-current"]);
|
|
14
15
|
if (!branch) throw new Error("cannot push from a detached HEAD");
|
|
15
16
|
if (branch === "main") throw new Error("direct pushes to main are prohibited; push a reviewed branch and merge through a pull request");
|
|
17
|
+
run("git", ["fetch", "origin", "main", "--prune"]);
|
|
18
|
+
const backlog = assertGitHubBacklogReady({ cwd: root, branch });
|
|
19
|
+
console.log(backlog.message);
|
|
16
20
|
|
|
17
21
|
const acceptance = verifyCurrentReleaseAcceptance(root);
|
|
18
22
|
if (acceptance.required) {
|
|
@@ -53,9 +53,9 @@ function prepareCandidate() {
|
|
|
53
53
|
writeFileSync(candidateManifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
|
|
54
54
|
const phrase = confirmationPhrase(pkg.name, pkg.version);
|
|
55
55
|
console.log(`Release candidate created: ${join(candidateDirectory, metadata.filename)}`);
|
|
56
|
-
console.log("
|
|
56
|
+
console.log("After the repository owner explicitly authorizes this exact candidate in the active conversation, the coding agent starts it through Machine Bridge with:");
|
|
57
57
|
console.log("npm run release:candidate:start -- --allow-worker-deploy");
|
|
58
|
-
console.log("
|
|
58
|
+
console.log("The owner does not need to run a terminal authorization command. The coding agent keeps the candidate running while verifying connection readiness and representative functionality through Machine Bridge.");
|
|
59
59
|
console.log("After that observed live verification succeeds, the coding agent records acceptance with:");
|
|
60
60
|
console.log(`npm run release:accept -- --confirm \"${phrase}\"`);
|
|
61
61
|
console.log("Automated tests alone do not authorize acceptance or the first GitHub push.");
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import {
|
|
3
|
-
lstatSync,
|
|
4
3
|
mkdtempSync,
|
|
5
4
|
readFileSync,
|
|
6
5
|
rmSync,
|
|
@@ -8,22 +7,27 @@ import {
|
|
|
8
7
|
import { tmpdir } from "node:os";
|
|
9
8
|
import { join } from "node:path";
|
|
10
9
|
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { readBoundedRegularFileSync } from "../src/local/secure-file.mjs";
|
|
11
11
|
|
|
12
12
|
export const ACCEPTANCE_SCHEMA_VERSION = 1;
|
|
13
13
|
export const ACCEPTANCE_POLICY_VERSION = "1.2.8";
|
|
14
14
|
export const AGENT_VERIFIED_ACCEPTANCE_VERSION = "1.2.9";
|
|
15
|
-
export const
|
|
15
|
+
export const AGENT_OPERATED_ACCEPTANCE_VERSION = "2.0.0";
|
|
16
|
+
export const ACCEPTANCE_CONFIRMATION = "owner-authorized-agent-operated-local-candidate";
|
|
17
|
+
export const OWNER_STARTED_ACCEPTANCE_CONFIRMATION = "owner-started-agent-verified-local-candidate";
|
|
16
18
|
export const LEGACY_ACCEPTANCE_CONFIRMATION = "repository-owner-local-test";
|
|
17
19
|
const MAX_ACCEPTANCE_BYTES = 64 * 1024;
|
|
20
|
+
const MAX_RELEASE_TARBALL_BYTES = 64 * 1024 * 1024;
|
|
18
21
|
|
|
19
22
|
export function requiresLocalAcceptance(version) {
|
|
20
23
|
return compareVersions(parseVersion(version), parseVersion(ACCEPTANCE_POLICY_VERSION)) >= 0;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
export function acceptanceConfirmationForVersion(version) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
const parsed = parseVersion(version);
|
|
28
|
+
if (compareVersions(parsed, parseVersion(AGENT_OPERATED_ACCEPTANCE_VERSION)) >= 0) return ACCEPTANCE_CONFIRMATION;
|
|
29
|
+
if (compareVersions(parsed, parseVersion(AGENT_VERIFIED_ACCEPTANCE_VERSION)) >= 0) return OWNER_STARTED_ACCEPTANCE_CONFIRMATION;
|
|
30
|
+
return LEGACY_ACCEPTANCE_CONFIRMATION;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
export function acceptancePath(root, version) {
|
|
@@ -76,20 +80,25 @@ export function packProject(root, destination) {
|
|
|
76
80
|
|
|
77
81
|
export function readAcceptance(root, version) {
|
|
78
82
|
const path = acceptancePath(root, version);
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
let bytes;
|
|
84
|
+
try {
|
|
85
|
+
bytes = readBoundedRegularFileSync(
|
|
86
|
+
path, MAX_ACCEPTANCE_BYTES, "release acceptance record",
|
|
87
|
+
{ verifyPathIdentity: true },
|
|
88
|
+
);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
const message = String(error?.message || error);
|
|
91
|
+
if (message.includes("exceeds")) throw new Error(`release acceptance record exceeds ${MAX_ACCEPTANCE_BYTES} bytes`);
|
|
92
|
+
if (message.includes("regular file") || message.includes("symbolic link") || message.includes("identity changed")) {
|
|
93
|
+
throw new Error(`release acceptance record must be a regular file: ${path}`);
|
|
94
|
+
}
|
|
95
|
+
throw error;
|
|
85
96
|
}
|
|
86
|
-
let value;
|
|
87
97
|
try {
|
|
88
|
-
|
|
98
|
+
return JSON.parse(bytes.toString("utf8"));
|
|
89
99
|
} catch (error) {
|
|
90
100
|
throw new Error(`release acceptance record is not valid JSON: ${error.message}`);
|
|
91
101
|
}
|
|
92
|
-
return value;
|
|
93
102
|
}
|
|
94
103
|
|
|
95
104
|
export function verifyAcceptanceRecord(record, metadata) {
|
|
@@ -134,9 +143,19 @@ export function verifyCurrentReleaseAcceptance(root) {
|
|
|
134
143
|
}
|
|
135
144
|
|
|
136
145
|
export function verifyTarball(path, metadata) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
146
|
+
let bytes;
|
|
147
|
+
try {
|
|
148
|
+
bytes = readBoundedRegularFileSync(
|
|
149
|
+
path, MAX_RELEASE_TARBALL_BYTES, "release candidate tarball",
|
|
150
|
+
{ verifyPathIdentity: true },
|
|
151
|
+
);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
const message = String(error?.message || error);
|
|
154
|
+
if (message.includes("regular file") || message.includes("symbolic link") || message.includes("identity changed")) {
|
|
155
|
+
throw new Error("release candidate tarball is not a regular file");
|
|
156
|
+
}
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
140
159
|
const shasum = createHash("sha1").update(bytes).digest("hex");
|
|
141
160
|
const integrity = `sha512-${createHash("sha512").update(bytes).digest("base64")}`;
|
|
142
161
|
if (shasum !== metadata.shasum || integrity !== metadata.integrity) {
|
package/scripts/run-checks.mjs
CHANGED
|
@@ -1,29 +1,19 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { performance } from "node:perf_hooks";
|
|
3
1
|
import { checkTasks } from "./check-plan.mjs";
|
|
2
|
+
import { runVerificationPlan } from "./check-runner.mjs";
|
|
4
3
|
|
|
5
4
|
const mode = process.argv[2] || "full";
|
|
6
5
|
const tasks = checkTasks(mode);
|
|
7
|
-
const npmCli = process.env.npm_execpath;
|
|
8
|
-
if (!npmCli) throw new Error("check runner must run through npm so npm_execpath is available");
|
|
9
|
-
const planStartedAt = performance.now();
|
|
10
6
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
env: process.env,
|
|
18
|
-
stdio: "inherit",
|
|
7
|
+
try {
|
|
8
|
+
await runVerificationPlan({
|
|
9
|
+
mode,
|
|
10
|
+
tasks,
|
|
11
|
+
npmCli: process.env.npm_execpath,
|
|
12
|
+
verbose: process.env.MBM_CHECK_VERBOSE === "1",
|
|
19
13
|
});
|
|
20
|
-
|
|
21
|
-
if (
|
|
22
|
-
|
|
23
|
-
console.error(`verification task failed after ${elapsedSeconds}s: ${task}`);
|
|
24
|
-
process.exit(result.status || 1);
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if (error?.message && !String(error.message).startsWith("verification task failed:")) {
|
|
16
|
+
console.error(error.message);
|
|
25
17
|
}
|
|
26
|
-
|
|
18
|
+
process.exit(Number(error?.exitCode) || 1);
|
|
27
19
|
}
|
|
28
|
-
const totalSeconds = ((performance.now() - planStartedAt) / 1000).toFixed(1);
|
|
29
|
-
console.log(`\n${mode} verification plan passed in ${totalSeconds}s`);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
1
|
+
import { createHash, createHmac, randomBytes } from "node:crypto";
|
|
2
|
+
import { ADMIN_AUTH_SCHEME, adminAuthTranscript } from "../shared/admin-auth.mjs";
|
|
2
3
|
import { ACCOUNT_ROLES, normalizeAccountRole } from "./account-access.mjs";
|
|
3
4
|
import { BridgeError } from "./errors.mjs";
|
|
4
5
|
|
|
@@ -58,13 +59,21 @@ export class AccountAdminClient {
|
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
async request(method, pathname, body) {
|
|
62
|
+
const serializedBody = body === undefined ? "" : JSON.stringify(body);
|
|
63
|
+
const headers = accountAdminRequestHeaders({
|
|
64
|
+
secret: this.adminSecret,
|
|
65
|
+
origin: this.workerUrl,
|
|
66
|
+
method,
|
|
67
|
+
pathname,
|
|
68
|
+
body: serializedBody,
|
|
69
|
+
});
|
|
61
70
|
const response = await this.fetchImpl(`${this.workerUrl}${pathname}`, {
|
|
62
71
|
method,
|
|
63
72
|
headers: {
|
|
64
|
-
|
|
73
|
+
...headers,
|
|
65
74
|
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
66
75
|
},
|
|
67
|
-
body: body === undefined ? undefined :
|
|
76
|
+
body: body === undefined ? undefined : serializedBody,
|
|
68
77
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
69
78
|
cache: "no-store",
|
|
70
79
|
}).catch((error) => {
|
|
@@ -80,6 +89,22 @@ export class AccountAdminClient {
|
|
|
80
89
|
}
|
|
81
90
|
}
|
|
82
91
|
|
|
92
|
+
|
|
93
|
+
export function accountAdminRequestHeaders({ secret, origin, method, pathname, body = "", now = Date.now(), nonce = randomBytes(24).toString("base64url") }) {
|
|
94
|
+
if (typeof secret !== "string" || secret.length < 24) throw new BridgeError("invalid_request", "account administration secret is missing");
|
|
95
|
+
const issuedAt = Math.floor(Number(now) / 1000);
|
|
96
|
+
const bodyHash = createHash("sha256").update(String(body)).digest("hex");
|
|
97
|
+
const transcript = adminAuthTranscript({ origin, method: String(method).toUpperCase(), pathname, bodyHash, issuedAt, nonce });
|
|
98
|
+
const signature = createHmac("sha256", secret).update(transcript).digest("base64url");
|
|
99
|
+
return {
|
|
100
|
+
"X-Bridge-Admin-Scheme": ADMIN_AUTH_SCHEME,
|
|
101
|
+
"X-Bridge-Admin-Time": String(issuedAt),
|
|
102
|
+
"X-Bridge-Admin-Nonce": nonce,
|
|
103
|
+
"X-Bridge-Admin-Body-SHA256": bodyHash,
|
|
104
|
+
"X-Bridge-Admin-Signature": signature,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
83
108
|
export function accountRoleNames() {
|
|
84
109
|
return Object.keys(ACCOUNT_ROLES);
|
|
85
110
|
}
|
|
@@ -39,12 +39,29 @@ export class BoundedOutput {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
get truncatedBytes() {
|
|
42
|
-
|
|
42
|
+
if (this.full) return 0;
|
|
43
|
+
const head = decodeUtf8Boundary(this.head, "head");
|
|
44
|
+
const tail = decodeUtf8Boundary(this.tail, "tail");
|
|
45
|
+
return Math.max(0, this.totalBytes - head.bytes - tail.bytes);
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
text() {
|
|
46
49
|
if (this.full) return this.full.toString("utf8");
|
|
47
|
-
const
|
|
48
|
-
|
|
50
|
+
const head = decodeUtf8Boundary(this.head, "head");
|
|
51
|
+
const tail = decodeUtf8Boundary(this.tail, "tail");
|
|
52
|
+
const omitted = Math.max(0, this.totalBytes - head.bytes - tail.bytes);
|
|
53
|
+
const marker = `\n\n[truncated ${omitted} bytes; preserved beginning and end]\n\n`;
|
|
54
|
+
return `${head.text}${marker}${tail.text}`;
|
|
49
55
|
}
|
|
50
56
|
}
|
|
57
|
+
|
|
58
|
+
function decodeUtf8Boundary(buffer, side) {
|
|
59
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
60
|
+
for (let trim = 0; trim <= Math.min(3, buffer.length); trim += 1) {
|
|
61
|
+
const slice = side === "head"
|
|
62
|
+
? buffer.subarray(0, buffer.length - trim)
|
|
63
|
+
: buffer.subarray(trim);
|
|
64
|
+
try { return { text: decoder.decode(slice), bytes: slice.length }; } catch { /* try the next code-point boundary */ }
|
|
65
|
+
}
|
|
66
|
+
return { text: new TextDecoder("utf-8").decode(buffer), bytes: buffer.length };
|
|
67
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import {
|
|
2
|
+
OPERATION_APPROVAL_SCOPES,
|
|
3
|
+
approvePendingOperation,
|
|
4
|
+
clearOperationLeases,
|
|
5
|
+
grantOperationLease,
|
|
6
|
+
listOperationApprovals,
|
|
7
|
+
revokeOperationLease,
|
|
8
|
+
} from "./operation-authorization.mjs";
|
|
9
|
+
import { loadState } from "./state.mjs";
|
|
10
|
+
|
|
11
|
+
const ACTIONS = new Set(["list", "approve", "grant", "revoke", "clear"]);
|
|
12
|
+
|
|
13
|
+
export function createApprovalCommand({ chooseWorkspace, confirm }) {
|
|
14
|
+
if (typeof chooseWorkspace !== "function" || typeof confirm !== "function") {
|
|
15
|
+
throw new TypeError("approval command requires chooseWorkspace and confirm dependencies");
|
|
16
|
+
}
|
|
17
|
+
return async function approvalCommand(args) {
|
|
18
|
+
const action = String(args._[0] || "list").toLowerCase();
|
|
19
|
+
if (!ACTIONS.has(action)) throw new Error(`Unknown approval action: ${action}`);
|
|
20
|
+
const workspace = await chooseWorkspace({ ...args, _: [] }, { promptOnFirstRun: false, save: false, allowPositional: false });
|
|
21
|
+
const state = loadState(workspace, { stateDir: args.stateDir });
|
|
22
|
+
const root = state.paths.profileDir;
|
|
23
|
+
if (action === "list") return renderList(listOperationApprovals(root), args.json === true);
|
|
24
|
+
if (action === "approve") {
|
|
25
|
+
const id = requiredPositional(args, 1, "approval approve requires APPROVAL_ID");
|
|
26
|
+
return renderLease(await approvePendingOperation(
|
|
27
|
+
root,
|
|
28
|
+
id,
|
|
29
|
+
args.duration || (args.full ? "8h" : "1h"),
|
|
30
|
+
Date.now(),
|
|
31
|
+
args.full ? "full" : "",
|
|
32
|
+
), args.json === true, "Approved");
|
|
33
|
+
}
|
|
34
|
+
if (action === "grant") {
|
|
35
|
+
const scope = requiredPositional(args, 1, "approval grant requires SCOPE");
|
|
36
|
+
if (!OPERATION_APPROVAL_SCOPES.includes(scope)) {
|
|
37
|
+
throw new Error(`approval scope must be one of: ${OPERATION_APPROVAL_SCOPES.join(", ")}`);
|
|
38
|
+
}
|
|
39
|
+
const clientId = String(args.client || "");
|
|
40
|
+
const accountId = String(args.account || "");
|
|
41
|
+
if (!clientId) throw new Error("approval grant requires --client CLIENT_ID; use * only for an intentional all-client lease");
|
|
42
|
+
if (!accountId) throw new Error("approval grant requires --account ACCOUNT_ID; use * only for an intentional all-account lease");
|
|
43
|
+
return renderLease(await grantOperationLease(root, {
|
|
44
|
+
accountId,
|
|
45
|
+
clientId,
|
|
46
|
+
scope,
|
|
47
|
+
duration: args.duration || "1h",
|
|
48
|
+
}), args.json === true, "Granted");
|
|
49
|
+
}
|
|
50
|
+
if (action === "revoke") {
|
|
51
|
+
const id = requiredPositional(args, 1, "approval revoke requires LEASE_ID");
|
|
52
|
+
const removed = await revokeOperationLease(root, id);
|
|
53
|
+
if (args.json) console.log(JSON.stringify({ lease_id: id, revoked: removed }, null, 2));
|
|
54
|
+
else console.log(removed ? `Revoked capability lease: ${id}` : `Capability lease was not active: ${id}`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const approved = await confirm("Revoke all active remote capability leases?", args.yes === true);
|
|
58
|
+
if (!approved) {
|
|
59
|
+
console.log("No capability leases were changed.");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
await clearOperationLeases(root);
|
|
63
|
+
if (args.json) console.log(JSON.stringify({ cleared: true }, null, 2));
|
|
64
|
+
else console.log("Revoked all active remote capability leases.");
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function renderList(result, json) {
|
|
69
|
+
if (json) {
|
|
70
|
+
console.log(JSON.stringify(result, null, 2));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (!result.pending.length && !result.leases.length) {
|
|
74
|
+
console.log("No pending approvals or active capability leases.");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (result.pending.length) {
|
|
78
|
+
console.log("Pending approvals:");
|
|
79
|
+
for (const item of result.pending) {
|
|
80
|
+
console.log(` ${item.id}\t${scopeText(item.scopes)}\t${item.category}\tclient ${shortId(item.client_id)}\texpires ${formatTime(item.expires_at)}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (result.leases.length) {
|
|
84
|
+
console.log("Active capability leases:");
|
|
85
|
+
for (const item of result.leases) {
|
|
86
|
+
console.log(` ${item.id}\t${scopeText(item.scopes)}\tclient ${shortId(item.client_id)}\texpires ${formatTime(item.expires_at)}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function renderLease(lease, json, verb) {
|
|
92
|
+
if (json) {
|
|
93
|
+
console.log(JSON.stringify(lease, null, 2));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
console.log(`${verb} ${scopeText(lease.scopes)} capability lease for client ${shortId(lease.client_id)} until ${formatTime(lease.expires_at)}.`);
|
|
97
|
+
console.log(`Lease ID: ${lease.id}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function requiredPositional(args, index, message) {
|
|
101
|
+
const value = String(args._[index] || "");
|
|
102
|
+
if (!value) throw new Error(message);
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function scopeText(scopes) {
|
|
107
|
+
return Array.isArray(scopes) ? scopes.join("+") : "<invalid-scope>";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function shortId(value) {
|
|
111
|
+
const text = String(value || "");
|
|
112
|
+
return text === "*" ? "*" : `${text.slice(0, 14)}...${text.slice(-6)}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function formatTime(epochSeconds) {
|
|
116
|
+
return new Date(Number(epochSeconds) * 1000).toISOString();
|
|
117
|
+
}
|
|
@@ -3,10 +3,10 @@ import { normalizeLogLevel } from "./log.mjs";
|
|
|
3
3
|
const BOOLEAN_OPTIONS = new Set([
|
|
4
4
|
"help", "version", "quiet", "json", "verbose", "rotateSecrets", "forceWorker",
|
|
5
5
|
"daemonOnly", "noAutostart", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths",
|
|
6
|
-
"yes", "keepWorker", "allowInsecurePermissions", "showPaths",
|
|
6
|
+
"yes", "keepWorker", "allowInsecurePermissions", "showPaths", "full",
|
|
7
7
|
]);
|
|
8
8
|
const VALUE_OPTIONS = new Set([
|
|
9
|
-
"workspace", "stateDir", "workerName", "profile", "execMode", "client", "logLevel", "logFormat",
|
|
9
|
+
"workspace", "stateDir", "workerName", "profile", "execMode", "client", "account", "duration", "logLevel", "logFormat",
|
|
10
10
|
]);
|
|
11
11
|
|
|
12
12
|
const LOG_FORMATS = new Set(["text", "json"]);
|
|
@@ -28,6 +28,7 @@ const COMMAND_OPTIONS = new Map(Object.entries({
|
|
|
28
28
|
autostart: new Set(["workspace", "stateDir", "quiet"]),
|
|
29
29
|
resource: new Set(["workspace", "stateDir", "allowInsecurePermissions", "showPaths", "json"]),
|
|
30
30
|
account: new Set(["workspace", "stateDir", "json", "yes"]),
|
|
31
|
+
approval: new Set(["workspace", "stateDir", "json", "yes", "client", "account", "duration", "full"]),
|
|
31
32
|
browser: new Set(["workspace", "stateDir", "json"]),
|
|
32
33
|
job: new Set(["workspace", "stateDir", "json", "yes"]),
|
|
33
34
|
uninstall: new Set(["stateDir", "keepWorker", "yes"]),
|
|
@@ -41,6 +42,7 @@ const STATIC_POSITIONAL_RULES = new Map([
|
|
|
41
42
|
const RESOURCE_POSITIONAL_LIMITS = new Map(Object.entries({ add: 3, "generate-ssh-key": 3, remove: 2, check: 2 }));
|
|
42
43
|
const JOB_POSITIONAL_LIMITS = new Map(Object.entries({ read: 2, inspect: 2, cancel: 2, approve: 2, submit: 2 }));
|
|
43
44
|
const ACCOUNT_POSITIONAL_LIMITS = new Map(Object.entries({ list: 1, add: 3, role: 3, enable: 2, disable: 2, "rotate-password": 2, remove: 2 }));
|
|
45
|
+
const APPROVAL_POSITIONAL_LIMITS = new Map(Object.entries({ list: 1, approve: 2, grant: 2, revoke: 2, clear: 1 }));
|
|
44
46
|
const ACTION_POSITIONAL_RULES = new Map(Object.entries({
|
|
45
47
|
workspace(args) {
|
|
46
48
|
const action = String(args._[0] || "show");
|
|
@@ -59,6 +61,10 @@ const ACTION_POSITIONAL_RULES = new Map(Object.entries({
|
|
|
59
61
|
const action = String(args._[0] || "list");
|
|
60
62
|
return { max: ACCOUNT_POSITIONAL_LIMITS.get(action) ?? 1, tooMany: `account ${action} received too many positional arguments` };
|
|
61
63
|
},
|
|
64
|
+
approval(args) {
|
|
65
|
+
const action = String(args._[0] || "list");
|
|
66
|
+
return { max: APPROVAL_POSITIONAL_LIMITS.get(action) ?? 1, tooMany: `approval ${action} received too many positional arguments` };
|
|
67
|
+
},
|
|
62
68
|
browser(args) {
|
|
63
69
|
const action = String(args._[0] || "status");
|
|
64
70
|
return { max: 1, tooMany: `browser ${action} received too many positional arguments` };
|
package/src/local/cli.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { assertCanonicalFullPolicy, POLICY_PROFILES, toolsForPolicy } from "./to
|
|
|
10
10
|
import { resolvePolicy } from "./cli-policy.mjs";
|
|
11
11
|
import { effectiveLogFormat, effectiveLogLevel, normalizeCommand, parseArgs, validateCommandOptions, validateLoggingOptions, validatePositionals } from "./cli-options.mjs";
|
|
12
12
|
import { createLocalAdminCommands } from "./cli-local-admin.mjs";
|
|
13
|
+
import { createApprovalCommand } from "./cli-approval.mjs";
|
|
13
14
|
import { createServiceCommand } from "./cli-service.mjs";
|
|
14
15
|
import { generateAccountPassword } from "./account-admin.mjs";
|
|
15
16
|
import { accountAdminClient, createAccountCommand } from "./cli-account-admin.mjs";
|
|
@@ -49,6 +50,7 @@ import {
|
|
|
49
50
|
|
|
50
51
|
const localAdminCommands = createLocalAdminCommands({ chooseWorkspace, confirm });
|
|
51
52
|
const accountCommand = createAccountCommand({ chooseWorkspace, confirm });
|
|
53
|
+
const approvalCommand = createApprovalCommand({ chooseWorkspace, confirm });
|
|
52
54
|
const serviceCommand = createServiceCommand({ chooseWorkspace, stateRootFromArgs, structuredLogger });
|
|
53
55
|
|
|
54
56
|
const COMMAND_HANDLERS = new Map([
|
|
@@ -64,6 +66,7 @@ const COMMAND_HANDLERS = new Map([
|
|
|
64
66
|
["rotate-secrets", rotateSecretsCommand],
|
|
65
67
|
["resource", localAdminCommands.resourceCommand],
|
|
66
68
|
["account", accountCommand],
|
|
69
|
+
["approval", approvalCommand],
|
|
67
70
|
["browser", localAdminCommands.browserCommand],
|
|
68
71
|
["job", localAdminCommands.jobCommand],
|
|
69
72
|
["uninstall", uninstallCommand],
|
|
@@ -300,12 +303,13 @@ async function ensureInitialOwnerAccount(state) {
|
|
|
300
303
|
function createRemoteRuntime({ args, workspace, state, daemonLock }) {
|
|
301
304
|
return new LocalRuntime({
|
|
302
305
|
workerUrl: state.worker.url,
|
|
303
|
-
|
|
306
|
+
deviceIdentity: state.worker.deviceIdentity,
|
|
304
307
|
expectedRelayVersion: currentPackageVersion(),
|
|
305
308
|
workspace,
|
|
306
309
|
policy: state.policy,
|
|
307
310
|
logger: createLogger({ level: args.json ? "error" : effectiveLogLevel(args), format: effectiveLogFormat(args), component: "daemon" }),
|
|
308
311
|
jobRoot: join(state.paths.profileDir, "jobs"),
|
|
312
|
+
approvalRoot: state.paths.profileDir,
|
|
309
313
|
resources: state.resources,
|
|
310
314
|
resourceStatePath: state.paths.statePath,
|
|
311
315
|
browserStateRoot: state.paths.stateRoot,
|
|
@@ -745,9 +749,16 @@ Commands:
|
|
|
745
749
|
status Print redacted local profile state and Worker health
|
|
746
750
|
doctor Check Node, Wrangler, Cloudflare login, Worker health
|
|
747
751
|
full-test Run real local full-profile capability tests in a temporary sandbox
|
|
748
|
-
rotate-secrets Rotate account-admin,
|
|
752
|
+
rotate-secrets Rotate account-admin, device identity, and global token-version secrets
|
|
749
753
|
account list|add|role|enable|disable|rotate-password|remove
|
|
750
754
|
Manage isolated remote accounts and targeted revocation
|
|
755
|
+
approval list Show pending high-impact operations and active capability leases
|
|
756
|
+
approval approve APPROVAL_ID [--duration 1h] [--full]
|
|
757
|
+
Approve its scope, or open an explicit full window (maximum 8h)
|
|
758
|
+
approval grant SCOPE --account ACCOUNT_ID --client CLIENT_ID [--duration 1h]
|
|
759
|
+
Pre-authorize a bounded scope; use * values only intentionally
|
|
760
|
+
approval revoke LEASE_ID | approval clear
|
|
761
|
+
Revoke one or all local capability leases
|
|
751
762
|
resource generate-ssh-key NAME [PATH]
|
|
752
763
|
Generate/reuse an Ed25519 key locally and register its private file by alias
|
|
753
764
|
browser status Show browser-extension bridge and connection status
|