machine-bridge-mcp 2.0.0 → 3.0.0-beta.11
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 +120 -0
- package/CONTRIBUTING.md +20 -25
- package/README.md +21 -14
- package/SECURITY.md +147 -123
- package/browser-extension/manifest.json +3 -2
- package/browser-extension/service-worker.js +31 -12
- package/docs/ARCHITECTURE.md +28 -13
- package/docs/AUDIT.md +101 -2
- package/docs/CLIENTS.md +2 -2
- package/docs/ENGINEERING.md +9 -3
- package/docs/GETTING_STARTED.md +10 -9
- package/docs/LOCAL_AUTHORIZATION.md +115 -72
- package/docs/LOGGING.md +14 -3
- package/docs/MANAGED_JOBS.md +24 -29
- package/docs/MULTI_ACCOUNT.md +98 -37
- package/docs/OPERATIONS.md +34 -23
- package/docs/OVERVIEW.md +7 -7
- package/docs/PROJECT_STANDARDS.md +9 -5
- package/docs/RELEASING.md +171 -64
- package/docs/TESTING.md +21 -9
- package/docs/THREAT_MODEL.md +138 -67
- package/docs/TOOL_REFERENCE.md +2 -2
- package/docs/UPGRADING.md +102 -18
- package/native/macos/MachineBridgeTrustBroker.swift +233 -0
- package/package.json +37 -6
- package/scripts/candidate-runtime-store.mjs +38 -0
- package/scripts/check-plan.mjs +19 -0
- package/scripts/check-runner.mjs +4 -1
- package/scripts/commit-message-check.mjs +5 -2
- package/scripts/coverage-check.mjs +30 -2
- package/scripts/github-push.mjs +11 -0
- package/scripts/github-release.mjs +38 -7
- package/scripts/install-published-prerelease.mjs +167 -0
- package/scripts/local-release-acceptance.mjs +23 -12
- package/scripts/npm-publication-policy.mjs +16 -0
- package/scripts/prerelease-activation.mjs +79 -0
- package/scripts/privacy-check.mjs +7 -5
- package/scripts/promotion-digest.mjs +121 -0
- package/scripts/publish-npm.mjs +51 -0
- package/scripts/published-release.mjs +87 -0
- package/scripts/release-acceptance.mjs +30 -1
- package/scripts/release-candidate-manifest.mjs +40 -0
- package/scripts/release-channel.mjs +106 -0
- package/scripts/release-impact-check.mjs +12 -22
- package/scripts/release-soak.mjs +237 -0
- package/scripts/start-release-candidate.mjs +113 -7
- package/src/local/account-access.mjs +6 -0
- package/src/local/account-admin.mjs +34 -16
- package/src/local/agent-context.mjs +45 -42
- package/src/local/app-automation.mjs +9 -4
- package/src/local/authority-context.mjs +106 -0
- package/src/local/browser-bridge-http.mjs +4 -1
- package/src/local/browser-bridge.mjs +14 -9
- package/src/local/browser-broker-routes.mjs +4 -0
- package/src/local/browser-broker-server.mjs +5 -4
- package/src/local/browser-extension-identity.mjs +50 -0
- package/src/local/browser-extension-protocol.mjs +9 -4
- package/src/local/browser-operation-service.mjs +7 -0
- package/src/local/browser-pairing-http.mjs +36 -0
- package/src/local/browser-pairing-store.mjs +56 -47
- package/src/local/cli-account-admin.mjs +41 -7
- package/src/local/cli-activate.mjs +90 -0
- package/src/local/cli-approval.mjs +14 -58
- package/src/local/cli-local-admin.mjs +5 -15
- package/src/local/cli-options.mjs +11 -7
- package/src/local/cli-service.mjs +35 -5
- package/src/local/cli.mjs +119 -58
- package/src/local/daemon-process.mjs +6 -0
- package/src/local/delegated-process-sandbox.mjs +147 -0
- package/src/local/device-identity.mjs +157 -48
- package/src/local/device-root-provider.mjs +87 -0
- package/src/local/errors.mjs +25 -3
- package/src/local/git-service.mjs +16 -15
- package/src/local/job-runner.mjs +35 -27
- package/src/local/log.mjs +18 -44
- package/src/local/macos-trust-broker.mjs +320 -0
- package/src/local/managed-job-lock.mjs +18 -0
- package/src/local/managed-job-projection.mjs +9 -1
- package/src/local/managed-job-runner.mjs +13 -1
- package/src/local/managed-job-storage.mjs +2 -1
- package/src/local/managed-job-terminal.mjs +138 -0
- package/src/local/managed-jobs.mjs +88 -32
- package/src/local/operation-authorization.mjs +75 -239
- package/src/local/operation-risk.mjs +28 -0
- package/src/local/operation-state-lock.mjs +7 -86
- package/src/local/owner-state-lock.mjs +104 -0
- package/src/local/policy.mjs +19 -0
- package/src/local/process-execution.mjs +21 -9
- package/src/local/process-sessions.mjs +27 -17
- package/src/local/process-tracker.mjs +30 -6
- package/src/local/process-tree-ownership.mjs +59 -0
- package/src/local/process-tree.mjs +22 -18
- package/src/local/relay-connection.mjs +47 -35
- package/src/local/relay-diagnostics.mjs +65 -0
- package/src/local/remote-configuration.mjs +48 -0
- package/src/local/runtime-activation.mjs +141 -0
- package/src/local/runtime-diagnostics.mjs +21 -0
- package/src/local/runtime-relay.mjs +9 -8
- package/src/local/runtime-reporting.mjs +8 -3
- package/src/local/runtime-resource-service.mjs +77 -0
- package/src/local/runtime-tool-handlers.mjs +8 -8
- package/src/local/runtime.mjs +85 -95
- package/src/local/secure-file.mjs +5 -1
- package/src/local/security-audit-log.mjs +204 -0
- package/src/local/service-convergence.mjs +17 -1
- package/src/local/service-ownership.mjs +17 -0
- package/src/local/service-restart-handoff.mjs +50 -0
- package/src/local/service-restart-scheduler.mjs +49 -0
- package/src/local/service-status.mjs +47 -0
- package/src/local/service.mjs +142 -43
- package/src/local/state-inventory.mjs +21 -3
- package/src/local/state.mjs +155 -17
- package/src/local/stdio.mjs +5 -0
- package/src/local/tool-executor.mjs +39 -6
- package/src/local/tool-result-boundary.mjs +51 -0
- package/src/local/tools.mjs +1 -0
- package/src/local/trusted-executable.mjs +53 -0
- package/src/local/trusted-git-executable.mjs +34 -0
- package/src/local/trusted-github-cli.mjs +24 -0
- package/src/local/windows-service.mjs +20 -0
- package/src/local/worker-deployment.mjs +3 -4
- package/src/local/worker-secret-file.mjs +2 -2
- package/src/local/workspace-file-service.mjs +34 -31
- package/src/shared/admin-auth.d.mts +2 -1
- package/src/shared/admin-auth.mjs +3 -3
- package/src/shared/device-session-auth.d.mts +20 -0
- package/src/shared/device-session-auth.mjs +51 -0
- package/src/shared/log-redaction.d.mts +9 -0
- package/src/shared/log-redaction.mjs +51 -0
- package/src/shared/server-metadata.json +1 -1
- package/src/shared/tool-catalog.json +2 -2
- package/src/worker/account-admin.ts +23 -22
- package/src/worker/authority.ts +23 -4
- package/src/worker/daemon-auth.ts +45 -67
- package/src/worker/daemon-sockets.ts +12 -2
- package/src/worker/device-session-verifier.ts +129 -0
- package/src/worker/dpop.ts +151 -0
- package/src/worker/http.ts +47 -16
- package/src/worker/index.ts +53 -16
- package/src/worker/nonce-store.ts +1 -5
- package/src/worker/oauth-client-admin.ts +45 -0
- package/src/worker/oauth-controller.ts +55 -6
- package/src/worker/oauth-refresh-families.ts +21 -7
- package/src/worker/oauth-state.ts +6 -0
- package/src/worker/oauth-tokens.ts +28 -8
- package/src/worker/observability.ts +4 -2
- package/src/worker/websocket-protocol.ts +8 -2
|
@@ -18,6 +18,7 @@ export class BrowserOperationService {
|
|
|
18
18
|
bridgeStatus,
|
|
19
19
|
extensionPath,
|
|
20
20
|
expectedExtensionVersion,
|
|
21
|
+
expectedExtensionId,
|
|
21
22
|
runProcess,
|
|
22
23
|
readResourceText,
|
|
23
24
|
readResourceBinary,
|
|
@@ -28,6 +29,7 @@ export class BrowserOperationService {
|
|
|
28
29
|
this.bridgeStatus = bridgeStatus;
|
|
29
30
|
this.extensionPath = extensionPath;
|
|
30
31
|
this.expectedExtensionVersion = expectedExtensionVersion;
|
|
32
|
+
this.expectedExtensionId = expectedExtensionId;
|
|
31
33
|
this.runProcess = runProcess;
|
|
32
34
|
this.readResourceText = readResourceText;
|
|
33
35
|
this.readResourceBinary = readResourceBinary;
|
|
@@ -42,10 +44,14 @@ export class BrowserOperationService {
|
|
|
42
44
|
available: true,
|
|
43
45
|
connected: bridge.extensionConnected,
|
|
44
46
|
broker_role: bridge.brokerRole,
|
|
47
|
+
runtime_clients: Number(bridge.runtime_clients) || 0,
|
|
48
|
+
routed_requests: Number(bridge.routed_requests) || 0,
|
|
45
49
|
endpoint: `ws://127.0.0.1:${bridge.port}/extension`,
|
|
46
50
|
pairing_url: `http://127.0.0.1:${bridge.port}/pair`,
|
|
47
51
|
extension_path: this.extensionPath,
|
|
48
52
|
expected_extension_version: this.expectedExtensionVersion,
|
|
53
|
+
expected_extension_id: this.expectedExtensionId,
|
|
54
|
+
extension_id: extension?.extension_id || "",
|
|
49
55
|
extension_protocol: extension?.protocol || null,
|
|
50
56
|
extension_version: extension?.version || "",
|
|
51
57
|
extension_capabilities: extension?.capabilities || [],
|
|
@@ -70,6 +76,7 @@ export class BrowserOperationService {
|
|
|
70
76
|
security: {
|
|
71
77
|
loopback_only: true,
|
|
72
78
|
bearer_pairing_token: true,
|
|
79
|
+
pinned_extension_identity: true,
|
|
73
80
|
arbitrary_extension_code_from_mcp: false,
|
|
74
81
|
resource_values_returned_to_model: false,
|
|
75
82
|
},
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { EXPECTED_EXTENSION_ID, normalizeExtensionId } from "./browser-extension-identity.mjs";
|
|
2
|
+
import { EXPECTED_EXTENSION_VERSION } from "./browser-extension-protocol.mjs";
|
|
3
|
+
|
|
4
|
+
export function pairingHtml(port, extensionToken) {
|
|
5
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="machine-bridge-browser-pair" content="1"><meta name="machine-bridge-browser-port" content="${port}"><meta name="machine-bridge-browser-token" content="${extensionToken}"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Machine Bridge browser pairing</title></head><body><h1>Machine Bridge browser pairing</h1><p>Expected extension build: <strong>${EXPECTED_EXTENSION_VERSION}</strong>.</p><p>Expected extension ID: <code>${EXPECTED_EXTENSION_ID}</code>.</p><p>Reload the unpacked extension after every Machine Bridge upgrade.</p><p>The installed extension reads pairing material from this loopback-only page and stores it in browser-local extension storage. It is not sent to any website.</p><p id="status">Waiting for the Machine Bridge extension.</p></body></html>`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function isAllowedExtensionOrigin(origin, expectedExtensionId = EXPECTED_EXTENSION_ID) {
|
|
9
|
+
const expected = normalizeExtensionId(expectedExtensionId);
|
|
10
|
+
if (!expected) return false;
|
|
11
|
+
let parsed;
|
|
12
|
+
try { parsed = new URL(String(origin || "")); } catch { return false; }
|
|
13
|
+
return parsed.protocol === "chrome-extension:" && parsed.hostname === expected
|
|
14
|
+
&& !parsed.username && !parsed.password && !parsed.port
|
|
15
|
+
&& (parsed.pathname === "" || parsed.pathname === "/") && !parsed.search && !parsed.hash;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isAllowedLoopbackHost(host, port) {
|
|
19
|
+
const normalized = String(host || "").toLowerCase();
|
|
20
|
+
return normalized === `127.0.0.1:${port}` || normalized === `localhost:${port}` || normalized === `[::1]:${port}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function securityHeaders(contentType) {
|
|
24
|
+
return {
|
|
25
|
+
"content-type": contentType,
|
|
26
|
+
"cache-control": "no-store",
|
|
27
|
+
"content-security-policy": "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
28
|
+
"x-content-type-options": "nosniff",
|
|
29
|
+
"referrer-policy": "no-referrer",
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function sendJson(response, value) {
|
|
34
|
+
response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
|
|
35
|
+
response.end(`${JSON.stringify(value)}\n`);
|
|
36
|
+
}
|
|
@@ -3,80 +3,89 @@ import { existsSync } from "node:fs";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { createExclusiveFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
|
|
5
5
|
import { readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
6
|
-
import { assertStateMaintenanceAvailable, ensureOwnerOnlyDir, ownerOnlyFile } from "./state.mjs";
|
|
7
|
-
import {
|
|
6
|
+
import { acquireMaintenanceLock, assertStateMaintenanceAvailable, ensureOwnerOnlyDir, ownerOnlyFile } from "./state.mjs";
|
|
7
|
+
import { createMonotonicDeadline } from "./monotonic-deadline.mjs";
|
|
8
8
|
|
|
9
9
|
const DEFAULT_BROWSER_PORT = 39393;
|
|
10
10
|
const PAIRING_FILE = "browser-bridge.json";
|
|
11
|
+
const PAIRING_SCHEMA_VERSION = 2;
|
|
12
|
+
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{32,100}$/;
|
|
11
13
|
|
|
12
14
|
export async function loadOrCreatePairing(stateRoot) {
|
|
13
|
-
if (!stateRoot) return
|
|
14
|
-
assertStateMaintenanceAvailable(stateRoot);
|
|
15
|
+
if (!stateRoot) return newPairing(DEFAULT_BROWSER_PORT);
|
|
15
16
|
ensureOwnerOnlyDir(stateRoot);
|
|
16
17
|
const file = join(stateRoot, PAIRING_FILE);
|
|
18
|
+
if (existsSync(file)) {
|
|
19
|
+
const current = readPairing(file);
|
|
20
|
+
if (!current.legacy) { assertStateMaintenanceAvailable(stateRoot); return current.value; }
|
|
21
|
+
return migrateLegacyPairing(stateRoot, file);
|
|
22
|
+
}
|
|
23
|
+
assertStateMaintenanceAvailable(stateRoot);
|
|
17
24
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
18
|
-
|
|
19
|
-
ownerOnlyFile(file);
|
|
20
|
-
let parsed;
|
|
21
|
-
try { parsed = JSON.parse(readBoundedRegularFileSync(file, 64 * 1024).toString("utf8")); }
|
|
22
|
-
catch { throw new Error("browser pairing state is not valid bounded JSON"); }
|
|
23
|
-
if (!/^[A-Za-z0-9_-]{32,100}$/.test(parsed.token) || !Number.isInteger(parsed.port) || parsed.port < 1024 || parsed.port > 65535) {
|
|
24
|
-
throw new Error("browser pairing state is invalid");
|
|
25
|
-
}
|
|
26
|
-
return parsed;
|
|
27
|
-
}
|
|
28
|
-
const value = { token: randomBytes(32).toString("base64url"), port: DEFAULT_BROWSER_PORT };
|
|
25
|
+
const value = newPairing(DEFAULT_BROWSER_PORT);
|
|
29
26
|
try {
|
|
30
|
-
createExclusiveFileSync(file,
|
|
27
|
+
createExclusiveFileSync(file, pairingJson(value), { mode: 0o600 });
|
|
31
28
|
ownerOnlyFile(file);
|
|
32
29
|
return value;
|
|
33
30
|
} catch (error) {
|
|
34
31
|
if (error?.code !== "EEXIST") throw error;
|
|
32
|
+
const created = readPairing(file);
|
|
33
|
+
if (!created.legacy) return created.value;
|
|
35
34
|
}
|
|
36
35
|
}
|
|
37
|
-
|
|
36
|
+
return migrateLegacyPairing(stateRoot, file);
|
|
38
37
|
}
|
|
39
38
|
|
|
40
39
|
export async function savePairing(stateRoot, value) {
|
|
41
40
|
assertStateMaintenanceAvailable(stateRoot);
|
|
41
|
+
const normalized = normalizePairing(value);
|
|
42
42
|
const file = join(stateRoot, PAIRING_FILE);
|
|
43
|
-
replaceFileAtomicallySync(file,
|
|
43
|
+
replaceFileAtomicallySync(file, pairingJson(normalized), { mode: 0o600 });
|
|
44
44
|
ownerOnlyFile(file);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
&& !parsed.password
|
|
58
|
-
&& !parsed.port
|
|
59
|
-
&& (parsed.pathname === "" || parsed.pathname === "/")
|
|
60
|
-
&& !parsed.search
|
|
61
|
-
&& !parsed.hash;
|
|
47
|
+
async function migrateLegacyPairing(stateRoot, file) {
|
|
48
|
+
const lock = await acquirePairingMigrationLock(stateRoot);
|
|
49
|
+
try {
|
|
50
|
+
const current = readPairing(file);
|
|
51
|
+
if (!current.legacy) return current.value;
|
|
52
|
+
const value = { schemaVersion: PAIRING_SCHEMA_VERSION, extensionToken: current.value.extensionToken, runtimeToken: token(), port: current.value.port };
|
|
53
|
+
replaceFileAtomicallySync(file, pairingJson(value), { mode: 0o600 });
|
|
54
|
+
ownerOnlyFile(file);
|
|
55
|
+
return value;
|
|
56
|
+
} finally { lock.release(); }
|
|
62
57
|
}
|
|
63
58
|
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
59
|
+
async function acquirePairingMigrationLock(stateRoot) {
|
|
60
|
+
const deadline = createMonotonicDeadline(5_000);
|
|
61
|
+
do {
|
|
62
|
+
const lock = acquireMaintenanceLock(stateRoot, { operation: "browser-pairing-migration" });
|
|
63
|
+
if (lock.acquired) return lock;
|
|
64
|
+
await new Promise((resolvePromise) => { setTimeout(resolvePromise, Math.min(50, Math.max(1, deadline.remainingMs()))); });
|
|
65
|
+
} while (!deadline.expired());
|
|
66
|
+
throw new Error("browser pairing migration could not acquire the state maintenance lock");
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
69
|
+
function readPairing(file) {
|
|
70
|
+
ownerOnlyFile(file);
|
|
71
|
+
let parsed;
|
|
72
|
+
try { parsed = JSON.parse(readBoundedRegularFileSync(file, 64 * 1024).toString("utf8")); }
|
|
73
|
+
catch { throw new Error("browser pairing state is not valid bounded JSON"); }
|
|
74
|
+
if (parsed?.schemaVersion === PAIRING_SCHEMA_VERSION) return { legacy: false, value: normalizePairing(parsed) };
|
|
75
|
+
if (TOKEN_PATTERN.test(String(parsed?.token || "")) && validPort(parsed?.port)) {
|
|
76
|
+
return { legacy: true, value: { extensionToken: parsed.token, port: Number(parsed.port) } };
|
|
77
|
+
}
|
|
78
|
+
throw new Error("browser pairing state is invalid");
|
|
77
79
|
}
|
|
78
80
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
81
|
+
function normalizePairing(value) {
|
|
82
|
+
if (value?.schemaVersion !== PAIRING_SCHEMA_VERSION || !TOKEN_PATTERN.test(String(value.extensionToken || ""))
|
|
83
|
+
|| !TOKEN_PATTERN.test(String(value.runtimeToken || "")) || value.extensionToken === value.runtimeToken || !validPort(value.port)) {
|
|
84
|
+
throw new Error("browser pairing state is invalid");
|
|
85
|
+
}
|
|
86
|
+
return { schemaVersion: PAIRING_SCHEMA_VERSION, extensionToken: String(value.extensionToken), runtimeToken: String(value.runtimeToken), port: Number(value.port) };
|
|
82
87
|
}
|
|
88
|
+
function newPairing(port) { return { schemaVersion: PAIRING_SCHEMA_VERSION, extensionToken: token(), runtimeToken: token(), port }; }
|
|
89
|
+
function token() { return randomBytes(32).toString("base64url"); }
|
|
90
|
+
function validPort(value) { return Number.isInteger(Number(value)) && Number(value) >= 1024 && Number(value) <= 65535; }
|
|
91
|
+
function pairingJson(value) { return `${JSON.stringify(value, null, 2)}\n`; }
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
1
3
|
import { AccountAdminClient, accountRoleNames, generateAccountPassword } from "./account-admin.mjs";
|
|
2
|
-
import {
|
|
4
|
+
import { createDeviceSessionForRoot } from "./device-root-provider.mjs";
|
|
5
|
+
import { loadState, packageRoot } from "./state.mjs";
|
|
3
6
|
|
|
4
7
|
export function createAccountCommand({ chooseWorkspace, confirm }) {
|
|
5
8
|
if (typeof chooseWorkspace !== "function" || typeof confirm !== "function") {
|
|
@@ -8,7 +11,7 @@ export function createAccountCommand({ chooseWorkspace, confirm }) {
|
|
|
8
11
|
return async function accountCommand(args) {
|
|
9
12
|
const workspace = await chooseWorkspace({ ...args, _: [] }, { promptOnFirstRun: false, save: false, allowPositional: false });
|
|
10
13
|
const state = loadState(workspace, { stateDir: args.stateDir });
|
|
11
|
-
const client = accountAdminClient(state);
|
|
14
|
+
const client = await accountAdminClient(state);
|
|
12
15
|
const action = String(args._[0] || "list").toLowerCase();
|
|
13
16
|
const result = await performAccountAction({ action, args, client, confirm });
|
|
14
17
|
if (result === null) return;
|
|
@@ -17,15 +20,36 @@ export function createAccountCommand({ chooseWorkspace, confirm }) {
|
|
|
17
20
|
};
|
|
18
21
|
}
|
|
19
22
|
|
|
20
|
-
export function accountAdminClient(state) {
|
|
21
|
-
if (!state.worker?.url || !state.worker?.
|
|
23
|
+
export async function accountAdminClient(state, sessionIdentity = null) {
|
|
24
|
+
if (!state.worker?.url || !state.worker?.deviceIdentity) {
|
|
22
25
|
throw new Error("account administration requires a deployed Worker; run machine-mcp first");
|
|
23
26
|
}
|
|
24
|
-
|
|
27
|
+
const identity = sessionIdentity || await createDeviceSessionForRoot(
|
|
28
|
+
state.worker.deviceIdentity,
|
|
29
|
+
state.worker.url,
|
|
30
|
+
"machine-bridge-mcp",
|
|
31
|
+
currentPackageVersion(),
|
|
32
|
+
{ profileDir: state.paths.profileDir, reason: "Authorize Machine Bridge account administration" },
|
|
33
|
+
);
|
|
34
|
+
return new AccountAdminClient({ workerUrl: state.worker.url, sessionIdentity: identity });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function currentPackageVersion() {
|
|
38
|
+
return String(JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8")).version);
|
|
25
39
|
}
|
|
26
40
|
|
|
27
41
|
async function performAccountAction({ action, args, client, confirm }) {
|
|
28
42
|
if (action === "list") return client.list();
|
|
43
|
+
if (action === "clients") return client.listClients();
|
|
44
|
+
if (action === "revoke-client") {
|
|
45
|
+
const clientId = String(args._[1] || "");
|
|
46
|
+
if (!clientId) throw new Error("account revoke-client requires CLIENT_ID");
|
|
47
|
+
if (!args.yes && !(await confirm(`Revoke OAuth client ${clientId}?`, false))) {
|
|
48
|
+
console.log("Client revocation cancelled.");
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
return client.removeClient({ clientId });
|
|
52
|
+
}
|
|
29
53
|
if (action === "add") {
|
|
30
54
|
const name = args._[1];
|
|
31
55
|
const role = args._[2];
|
|
@@ -56,10 +80,20 @@ async function performAccountAction({ action, args, client, confirm }) {
|
|
|
56
80
|
}
|
|
57
81
|
return client.remove({ accountId: account.account_id });
|
|
58
82
|
}
|
|
59
|
-
throw new Error("Unknown account action. Use list, add, role, enable, disable, rotate-password, or remove.");
|
|
83
|
+
throw new Error("Unknown account action. Use list, clients, revoke-client, add, role, enable, disable, rotate-password, or remove.");
|
|
60
84
|
}
|
|
61
85
|
|
|
62
86
|
function printAccountResult(action, result) {
|
|
87
|
+
if (action === "clients") {
|
|
88
|
+
if (!result.clients.length) {
|
|
89
|
+
console.log("No OAuth clients configured.");
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
for (const client of result.clients) {
|
|
93
|
+
console.log(`${client.client_name}\t${client.trusted_role || "untrusted"}\t${client.client_id}\taccess=${client.active_access_tokens} refresh=${client.active_refresh_tokens}`);
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
63
97
|
if (action === "list") {
|
|
64
98
|
if (!result.accounts.length) {
|
|
65
99
|
console.log("No accounts configured.");
|
|
@@ -75,5 +109,5 @@ function printAccountResult(action, result) {
|
|
|
75
109
|
process.stdout.write(`Password: ${result.password}\n`);
|
|
76
110
|
console.log("Save this password now; it is not stored locally or shown again. Do not share this terminal output.");
|
|
77
111
|
}
|
|
78
|
-
if (result.removed) console.log("Account removed.");
|
|
112
|
+
if (result.removed) console.log(action === "revoke-client" ? "OAuth client revoked." : "Account removed.");
|
|
79
113
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { acquireDaemonLockWithTakeover, inspectWorkspaceDaemon } from "./daemon-process.mjs";
|
|
2
|
+
import { effectiveLogFormat, effectiveLogLevel } from "./cli-options.mjs";
|
|
3
|
+
import { createLogger } from "./log.mjs";
|
|
4
|
+
import { activatePersistentRuntime } from "./runtime-activation.mjs";
|
|
5
|
+
import { installAutostart, startAutostart, stopAutostart } from "./service.mjs";
|
|
6
|
+
import { acquireStartupLockWithWait, loadState } from "./state.mjs";
|
|
7
|
+
import { workerHealth } from "./worker-health.mjs";
|
|
8
|
+
|
|
9
|
+
export function createActivateCommand({
|
|
10
|
+
chooseWorkspace,
|
|
11
|
+
prepareRemoteState,
|
|
12
|
+
createRemoteRuntime,
|
|
13
|
+
currentPackageVersion,
|
|
14
|
+
assertNodeVersion,
|
|
15
|
+
structuredLogger,
|
|
16
|
+
} = {}) {
|
|
17
|
+
for (const [name, value] of Object.entries({
|
|
18
|
+
chooseWorkspace,
|
|
19
|
+
prepareRemoteState,
|
|
20
|
+
createRemoteRuntime,
|
|
21
|
+
currentPackageVersion,
|
|
22
|
+
assertNodeVersion,
|
|
23
|
+
structuredLogger,
|
|
24
|
+
})) {
|
|
25
|
+
if (typeof value !== "function") throw new TypeError(`activate command requires ${name}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return async function activateCommand(args) {
|
|
29
|
+
assertNodeVersion();
|
|
30
|
+
const logger = createLogger({
|
|
31
|
+
level: args.json ? "error" : effectiveLogLevel(args),
|
|
32
|
+
format: effectiveLogFormat(args),
|
|
33
|
+
component: "activate",
|
|
34
|
+
});
|
|
35
|
+
const workspace = await chooseWorkspace(args, { promptOnFirstRun: false, save: true, allowPositional: true });
|
|
36
|
+
const state = loadState(workspace, { stateDir: args.stateDir });
|
|
37
|
+
if (!state.worker?.url) {
|
|
38
|
+
throw new Error("activate requires an existing deployment; run machine-mcp once interactively before persistent activation");
|
|
39
|
+
}
|
|
40
|
+
const activationArgs = { ...args, noAutostart: true, daemonOnly: false };
|
|
41
|
+
const expectedVersion = currentPackageVersion();
|
|
42
|
+
const result = await activatePersistentRuntime({
|
|
43
|
+
expectedVersion,
|
|
44
|
+
acquireStartupLock: () => acquireStartupLockWithWait(state, { operation: "activate", logger }),
|
|
45
|
+
stopAutostart: () => stopAutostart({ logger: structuredLogger(true) }),
|
|
46
|
+
acquireDaemonLock: () => acquireDaemonLockWithTakeover(state, {
|
|
47
|
+
takeOverServiceOwner: true,
|
|
48
|
+
ownerMetadata: { mode: "foreground", version: expectedVersion },
|
|
49
|
+
logger,
|
|
50
|
+
}),
|
|
51
|
+
prepareRemoteState: () => prepareRemoteState({ args: activationArgs, workspace, state, logger }),
|
|
52
|
+
createRuntime: ({ daemonLock, readiness }) => createRemoteRuntime({
|
|
53
|
+
args: activationArgs,
|
|
54
|
+
workspace,
|
|
55
|
+
state,
|
|
56
|
+
daemonLock,
|
|
57
|
+
deviceSessionIdentity: readiness.deviceSessionIdentity,
|
|
58
|
+
exitOnTerminal: false,
|
|
59
|
+
}),
|
|
60
|
+
installAutostart: () => installAutostart({
|
|
61
|
+
workspace,
|
|
62
|
+
stateRoot: state.paths.stateRoot,
|
|
63
|
+
entryScript: process.argv[1],
|
|
64
|
+
logger: structuredLogger(true),
|
|
65
|
+
}),
|
|
66
|
+
startAutostart: () => startAutostart({ logger: structuredLogger(true) }),
|
|
67
|
+
inspectDaemon: async () => inspectWorkspaceDaemon(state),
|
|
68
|
+
checkWorker: async () => workerHealth(state.worker.url, expectedVersion, { expectedWorkerName: state.worker.name }),
|
|
69
|
+
});
|
|
70
|
+
const convergence = result.convergence;
|
|
71
|
+
const payload = {
|
|
72
|
+
ok: true,
|
|
73
|
+
version: expectedVersion,
|
|
74
|
+
workspace: state.workspace.path,
|
|
75
|
+
worker: { name: state.worker.name, url: state.worker.url, health: convergence.worker },
|
|
76
|
+
daemon: convergence.daemon,
|
|
77
|
+
service: result.serviceStart,
|
|
78
|
+
candidate_relay_verified_before_handoff: result.candidateRelayVerified,
|
|
79
|
+
};
|
|
80
|
+
if (args.json) console.log(JSON.stringify(payload, null, 2));
|
|
81
|
+
else {
|
|
82
|
+
logger.success(`Persistent runtime activated: ${expectedVersion}`);
|
|
83
|
+
logger.safePlain(` Worker: ${state.worker.name}`);
|
|
84
|
+
logger.safePlain(` Daemon: pid ${convergence.daemon.pid}`);
|
|
85
|
+
logger.safePlain(" Candidate relay readiness was verified before the login service handoff.");
|
|
86
|
+
logger.safePlain(" The login service now owns the daemon; the activation terminal may close.");
|
|
87
|
+
}
|
|
88
|
+
return payload;
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
|
-
OPERATION_APPROVAL_SCOPES,
|
|
3
|
-
approvePendingOperation,
|
|
4
2
|
clearOperationLeases,
|
|
5
|
-
grantOperationLease,
|
|
6
3
|
listOperationApprovals,
|
|
7
4
|
revokeOperationLease,
|
|
8
5
|
} from "./operation-authorization.mjs";
|
|
9
6
|
import { loadState } from "./state.mjs";
|
|
10
7
|
|
|
11
|
-
const ACTIONS = new Set(["list", "
|
|
8
|
+
const ACTIONS = new Set(["list", "revoke", "clear"]);
|
|
12
9
|
|
|
13
10
|
export function createApprovalCommand({ chooseWorkspace, confirm }) {
|
|
14
11
|
if (typeof chooseWorkspace !== "function" || typeof confirm !== "function") {
|
|
@@ -16,85 +13,44 @@ export function createApprovalCommand({ chooseWorkspace, confirm }) {
|
|
|
16
13
|
}
|
|
17
14
|
return async function approvalCommand(args) {
|
|
18
15
|
const action = String(args._[0] || "list").toLowerCase();
|
|
19
|
-
if (!ACTIONS.has(action))
|
|
16
|
+
if (!ACTIONS.has(action)) {
|
|
17
|
+
throw new Error("Terminal operation approval was removed. Remote calls run automatically within the account role ceiling; use account management to change trust and approval revoke/clear only to remove legacy leases.");
|
|
18
|
+
}
|
|
20
19
|
const workspace = await chooseWorkspace({ ...args, _: [] }, { promptOnFirstRun: false, save: false, allowPositional: false });
|
|
21
20
|
const state = loadState(workspace, { stateDir: args.stateDir });
|
|
22
21
|
const root = state.paths.profileDir;
|
|
23
22
|
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
23
|
if (action === "revoke") {
|
|
51
24
|
const id = requiredPositional(args, 1, "approval revoke requires LEASE_ID");
|
|
52
25
|
const removed = await revokeOperationLease(root, id);
|
|
53
26
|
if (args.json) console.log(JSON.stringify({ lease_id: id, revoked: removed }, null, 2));
|
|
54
|
-
else console.log(removed ? `Revoked capability lease: ${id}` : `
|
|
27
|
+
else console.log(removed ? `Revoked legacy capability lease: ${id}` : `Legacy capability lease was not active: ${id}`);
|
|
55
28
|
return;
|
|
56
29
|
}
|
|
57
|
-
const approved = await confirm("Revoke all
|
|
30
|
+
const approved = await confirm("Revoke all legacy remote capability leases?", args.yes === true);
|
|
58
31
|
if (!approved) {
|
|
59
|
-
console.log("No capability leases were changed.");
|
|
32
|
+
console.log("No legacy capability leases were changed.");
|
|
60
33
|
return;
|
|
61
34
|
}
|
|
62
35
|
await clearOperationLeases(root);
|
|
63
36
|
if (args.json) console.log(JSON.stringify({ cleared: true }, null, 2));
|
|
64
|
-
else console.log("Revoked all
|
|
37
|
+
else console.log("Revoked all legacy remote capability leases.");
|
|
65
38
|
};
|
|
66
39
|
}
|
|
67
40
|
|
|
68
41
|
function renderList(result, json) {
|
|
69
42
|
if (json) {
|
|
70
|
-
console.log(JSON.stringify(result, null, 2));
|
|
43
|
+
console.log(JSON.stringify({ ...result, runtime_authorization: "automatic-within-role-ceiling" }, null, 2));
|
|
71
44
|
return;
|
|
72
45
|
}
|
|
73
|
-
if (!result.
|
|
74
|
-
console.log("No
|
|
46
|
+
if (!result.leases.length) {
|
|
47
|
+
console.log("No active legacy capability leases. Runtime authorization is automatic within each account role ceiling.");
|
|
75
48
|
return;
|
|
76
49
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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;
|
|
50
|
+
console.log("Legacy capability leases (not consumed by the current runtime):");
|
|
51
|
+
for (const item of result.leases) {
|
|
52
|
+
console.log(` ${item.id}\t${scopeText(item.scopes)}\tclient ${shortId(item.client_id)}\texpires ${formatTime(item.expires_at)}`);
|
|
95
53
|
}
|
|
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
54
|
}
|
|
99
55
|
|
|
100
56
|
function requiredPositional(args, index, message) {
|
|
@@ -244,7 +244,10 @@ function readBrowserPairingState(pairingFile) {
|
|
|
244
244
|
throw new Error("browser bridge state is invalid; restart machine-mcp to repair it");
|
|
245
245
|
}
|
|
246
246
|
const port = Number(pairing.port);
|
|
247
|
-
|
|
247
|
+
const extensionToken = String(pairing.extensionToken || pairing.token || "");
|
|
248
|
+
if (!/^[A-Za-z0-9_-]{32,100}$/.test(extensionToken) || (pairing.runtimeToken !== undefined && !/^[A-Za-z0-9_-]{32,100}$/.test(String(pairing.runtimeToken)))) {
|
|
249
|
+
throw new Error("browser bridge state contains invalid bounded tokens");
|
|
250
|
+
}
|
|
248
251
|
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("browser bridge state contains an invalid port");
|
|
249
252
|
return { port };
|
|
250
253
|
}
|
|
@@ -302,7 +305,7 @@ function openExternal(target) {
|
|
|
302
305
|
});
|
|
303
306
|
}
|
|
304
307
|
|
|
305
|
-
async function jobCommand(args, { chooseWorkspace
|
|
308
|
+
async function jobCommand(args, { chooseWorkspace }) {
|
|
306
309
|
const action = String(args._[0] || "list").toLowerCase();
|
|
307
310
|
const workspace = await chooseWorkspace({ ...args, _: [] }, { promptOnFirstRun: false, save: false, allowPositional: false });
|
|
308
311
|
const state = loadState(workspace, { stateDir: args.stateDir });
|
|
@@ -320,19 +323,6 @@ async function jobCommand(args, { chooseWorkspace, confirm }) {
|
|
|
320
323
|
else if (action === "read") result = manager.read({ job_id: args._[1] });
|
|
321
324
|
else if (action === "inspect") result = manager.inspectLocal({ job_id: args._[1] });
|
|
322
325
|
else if (action === "cancel") result = manager.cancel({ job_id: args._[1] });
|
|
323
|
-
else if (action === "approve") {
|
|
324
|
-
if (args.json && !args.yes) throw new Error("job approve --json requires --yes");
|
|
325
|
-
const inspection = manager.inspectLocal({ job_id: args._[1] });
|
|
326
|
-
if (!args.yes) {
|
|
327
|
-
console.log(JSON.stringify(inspection, null, 2));
|
|
328
|
-
const approved = await confirm(`Approve and execute managed job ${args._[1]}?`, false);
|
|
329
|
-
if (!approved) {
|
|
330
|
-
console.log("Managed job approval cancelled. Re-run with --yes after review to skip confirmation.");
|
|
331
|
-
return;
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
result = manager.approve({ job_id: args._[1] }, { localOperator: true });
|
|
335
|
-
}
|
|
336
326
|
else if (action === "submit") {
|
|
337
327
|
const planPath = args._[1];
|
|
338
328
|
if (!planPath) throw new Error("job submit requires a JSON plan file");
|
|
@@ -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",
|
|
7
7
|
]);
|
|
8
8
|
const VALUE_OPTIONS = new Set([
|
|
9
|
-
"workspace", "stateDir", "workerName", "profile", "execMode", "client", "
|
|
9
|
+
"workspace", "stateDir", "workerName", "profile", "execMode", "client", "logLevel", "logFormat",
|
|
10
10
|
]);
|
|
11
11
|
|
|
12
12
|
const LOG_FORMATS = new Set(["text", "json"]);
|
|
@@ -17,6 +17,10 @@ const COMMAND_OPTIONS = new Map(Object.entries({
|
|
|
17
17
|
"daemonOnly", "noAutostart",
|
|
18
18
|
"profile", "execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths",
|
|
19
19
|
]),
|
|
20
|
+
activate: new Set([
|
|
21
|
+
"workspace", "stateDir", "workerName", "quiet", "json", "verbose", "logLevel", "logFormat", "rotateSecrets", "forceWorker",
|
|
22
|
+
"profile", "execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths",
|
|
23
|
+
]),
|
|
20
24
|
stdio: new Set(["workspace", "stateDir", "profile", "execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths", "verbose", "quiet", "logLevel", "logFormat"]),
|
|
21
25
|
"client-config": new Set(["workspace", "stateDir", "profile", "client", "json"]),
|
|
22
26
|
status: new Set(["workspace", "stateDir"]),
|
|
@@ -28,13 +32,13 @@ const COMMAND_OPTIONS = new Map(Object.entries({
|
|
|
28
32
|
autostart: new Set(["workspace", "stateDir", "quiet"]),
|
|
29
33
|
resource: new Set(["workspace", "stateDir", "allowInsecurePermissions", "showPaths", "json"]),
|
|
30
34
|
account: new Set(["workspace", "stateDir", "json", "yes"]),
|
|
31
|
-
approval: new Set(["workspace", "stateDir", "json", "yes"
|
|
35
|
+
approval: new Set(["workspace", "stateDir", "json", "yes"]),
|
|
32
36
|
browser: new Set(["workspace", "stateDir", "json"]),
|
|
33
37
|
job: new Set(["workspace", "stateDir", "json", "yes"]),
|
|
34
38
|
uninstall: new Set(["stateDir", "keepWorker", "yes"]),
|
|
35
39
|
}));
|
|
36
40
|
|
|
37
|
-
const SINGLE_WORKSPACE_POSITIONAL_COMMANDS = new Set(["start", "stdio", "status", "doctor", "full-test", "rotate-secrets"]);
|
|
41
|
+
const SINGLE_WORKSPACE_POSITIONAL_COMMANDS = new Set(["start", "activate", "stdio", "status", "doctor", "full-test", "rotate-secrets"]);
|
|
38
42
|
const STATIC_POSITIONAL_RULES = new Map([
|
|
39
43
|
["client-config", Object.freeze({ max: 1, tooMany: "client-config accepts at most one positional client name" })],
|
|
40
44
|
["uninstall", Object.freeze({ max: 0, tooMany: "uninstall does not accept positional arguments" })],
|
|
@@ -42,7 +46,7 @@ const STATIC_POSITIONAL_RULES = new Map([
|
|
|
42
46
|
const RESOURCE_POSITIONAL_LIMITS = new Map(Object.entries({ add: 3, "generate-ssh-key": 3, remove: 2, check: 2 }));
|
|
43
47
|
const JOB_POSITIONAL_LIMITS = new Map(Object.entries({ read: 2, inspect: 2, cancel: 2, approve: 2, submit: 2 }));
|
|
44
48
|
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,
|
|
49
|
+
const APPROVAL_POSITIONAL_LIMITS = new Map(Object.entries({ list: 1, revoke: 2, clear: 1 }));
|
|
46
50
|
const ACTION_POSITIONAL_RULES = new Map(Object.entries({
|
|
47
51
|
workspace(args) {
|
|
48
52
|
const action = String(args._[0] || "show");
|
|
@@ -50,7 +54,7 @@ const ACTION_POSITIONAL_RULES = new Map(Object.entries({
|
|
|
50
54
|
},
|
|
51
55
|
service(args) {
|
|
52
56
|
const action = String(args._[0] || "status");
|
|
53
|
-
return { max: ["install", "status", "stop"
|
|
57
|
+
return { max: ["install", "status", "stop"].includes(action) ? 2 : 1, tooMany: `service ${action} received too many positional arguments`, workspaceConflictAfter: 1 };
|
|
54
58
|
},
|
|
55
59
|
autostart(args) { return ACTION_POSITIONAL_RULES.get("service")(args); },
|
|
56
60
|
resource(args) {
|
|
@@ -63,7 +67,7 @@ const ACTION_POSITIONAL_RULES = new Map(Object.entries({
|
|
|
63
67
|
},
|
|
64
68
|
approval(args) {
|
|
65
69
|
const action = String(args._[0] || "list");
|
|
66
|
-
return { max: APPROVAL_POSITIONAL_LIMITS.get(action) ??
|
|
70
|
+
return { max: APPROVAL_POSITIONAL_LIMITS.get(action) ?? 2, tooMany: `approval ${action} received too many positional arguments` };
|
|
67
71
|
},
|
|
68
72
|
browser(args) {
|
|
69
73
|
const action = String(args._[0] || "status");
|