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.
Files changed (73) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/CONTRIBUTING.md +1 -1
  3. package/README.md +11 -6
  4. package/browser-extension/manifest.json +2 -2
  5. package/docs/ARCHITECTURE.md +23 -11
  6. package/docs/AUDIT.md +37 -3
  7. package/docs/CLIENTS.md +2 -2
  8. package/docs/ENGINEERING.md +2 -2
  9. package/docs/GETTING_STARTED.md +1 -1
  10. package/docs/LOCAL_AUTHORIZATION.md +111 -0
  11. package/docs/LOGGING.md +7 -5
  12. package/docs/MULTI_ACCOUNT.md +5 -5
  13. package/docs/OPERATIONS.md +33 -7
  14. package/docs/OVERVIEW.md +12 -8
  15. package/docs/PROJECT_STANDARDS.md +1 -1
  16. package/docs/RELEASING.md +4 -4
  17. package/docs/TESTING.md +9 -2
  18. package/docs/THREAT_MODEL.md +7 -6
  19. package/docs/TOOL_REFERENCE.md +4 -4
  20. package/docs/UPGRADING.md +10 -6
  21. package/package.json +8 -2
  22. package/scripts/check-plan.mjs +5 -0
  23. package/scripts/check-runner.mjs +75 -0
  24. package/scripts/coverage-check.mjs +1 -1
  25. package/scripts/github-backlog.mjs +116 -0
  26. package/scripts/github-push.mjs +4 -0
  27. package/scripts/local-release-acceptance.mjs +2 -2
  28. package/scripts/release-acceptance.mjs +36 -17
  29. package/scripts/run-checks.mjs +11 -21
  30. package/src/local/account-admin.mjs +28 -3
  31. package/src/local/bounded-output.mjs +20 -3
  32. package/src/local/cli-approval.mjs +117 -0
  33. package/src/local/cli-options.mjs +8 -2
  34. package/src/local/cli.mjs +13 -2
  35. package/src/local/device-identity.mjs +108 -0
  36. package/src/local/errors.mjs +5 -1
  37. package/src/local/execution-limits.mjs +6 -1
  38. package/src/local/operation-authorization.mjs +366 -0
  39. package/src/local/operation-risk.mjs +221 -0
  40. package/src/local/operation-state-lock.mjs +92 -0
  41. package/src/local/process-execution.mjs +94 -14
  42. package/src/local/process-output-stream.mjs +82 -0
  43. package/src/local/process-result-projection.mjs +25 -0
  44. package/src/local/process-sessions.mjs +37 -51
  45. package/src/local/relay-connection.mjs +12 -5
  46. package/src/local/runtime-relay.mjs +13 -5
  47. package/src/local/runtime.mjs +14 -7
  48. package/src/local/secure-file.mjs +24 -4
  49. package/src/local/state.mjs +9 -3
  50. package/src/local/tool-executor.mjs +4 -2
  51. package/src/local/tools.mjs +4 -9
  52. package/src/local/worker-deployment.mjs +4 -3
  53. package/src/local/worker-secret-file.mjs +2 -1
  54. package/src/shared/admin-auth.d.mts +10 -0
  55. package/src/shared/admin-auth.mjs +36 -0
  56. package/src/shared/daemon-auth.d.mts +20 -0
  57. package/src/shared/daemon-auth.mjs +55 -0
  58. package/src/shared/result-projection.d.mts +6 -0
  59. package/src/shared/result-projection.json +4 -0
  60. package/src/shared/result-projection.mjs +50 -0
  61. package/src/shared/server-metadata.json +1 -0
  62. package/src/shared/tool-catalog.json +4 -4
  63. package/src/worker/account-admin.ts +73 -4
  64. package/src/worker/daemon-auth.ts +205 -0
  65. package/src/worker/daemon-sockets.ts +15 -2
  66. package/src/worker/errors.ts +18 -4
  67. package/src/worker/index.ts +59 -10
  68. package/src/worker/mcp-jsonrpc.ts +4 -2
  69. package/src/worker/nonce-store.ts +79 -0
  70. package/src/worker/oauth-controller.ts +11 -6
  71. package/src/worker/oauth-refresh-families.ts +172 -0
  72. package/src/worker/oauth-state.ts +48 -3
  73. 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
+ }
@@ -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("The repository owner must start this exact candidate in a local terminal with:");
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("Leave the candidate running while the coding agent verifies connection readiness and representative functionality through Machine Bridge.");
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 ACCEPTANCE_CONFIRMATION = "owner-started-agent-verified-local-candidate";
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
- return compareVersions(parseVersion(version), parseVersion(AGENT_VERIFIED_ACCEPTANCE_VERSION)) >= 0
25
- ? ACCEPTANCE_CONFIRMATION
26
- : LEGACY_ACCEPTANCE_CONFIRMATION;
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
- const stat = lstatSync(path);
80
- if (stat.isSymbolicLink() || !stat.isFile()) {
81
- throw new Error(`release acceptance record must be a regular file: ${path}`);
82
- }
83
- if (stat.size > MAX_ACCEPTANCE_BYTES) {
84
- throw new Error(`release acceptance record exceeds ${MAX_ACCEPTANCE_BYTES} bytes`);
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
- value = JSON.parse(readFileSync(path, "utf8"));
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
- const stat = lstatSync(path);
138
- if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("release candidate tarball is not a regular file");
139
- const bytes = readFileSync(path);
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) {
@@ -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
- console.log(`running ${mode} verification plan (${tasks.length} tasks)`);
12
- for (const [index, task] of tasks.entries()) {
13
- const taskStartedAt = performance.now();
14
- console.log(`\n[${index + 1}/${tasks.length}] npm run ${task}`);
15
- const result = spawnSync(process.execPath, [npmCli, "run", task], {
16
- cwd: process.cwd(),
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
- const elapsedSeconds = ((performance.now() - taskStartedAt) / 1000).toFixed(1);
21
- if (result.error) throw result.error;
22
- if (result.status !== 0) {
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
- console.log(`completed ${task} in ${elapsedSeconds}s`);
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
- authorization: `Bearer ${this.adminSecret}`,
73
+ ...headers,
65
74
  ...(body === undefined ? {} : { "content-type": "application/json" }),
66
75
  },
67
- body: body === undefined ? undefined : JSON.stringify(body),
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
- return Math.max(0, this.totalBytes - this.maximum);
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 marker = `\n\n[truncated ${this.truncatedBytes} bytes; preserved beginning and end]\n\n`;
48
- return `${this.head.toString("utf8")}${marker}${this.tail.toString("utf8")}`;
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
- secret: state.worker.daemonSecret,
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, daemon, and global token-version secrets
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