machine-bridge-mcp 2.0.0 → 3.0.0-beta.9
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 +101 -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 +27 -13
- package/docs/AUDIT.md +85 -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 +12 -3
- package/docs/MANAGED_JOBS.md +24 -29
- package/docs/MULTI_ACCOUNT.md +98 -37
- package/docs/OPERATIONS.md +33 -22
- package/docs/OVERVIEW.md +7 -7
- package/docs/PROJECT_STANDARDS.md +9 -5
- package/docs/RELEASING.md +171 -64
- package/docs/TESTING.md +19 -7
- 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 +36 -5
- 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 +80 -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 +112 -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 +14 -1
- 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-tool-handlers.mjs +8 -8
- package/src/local/runtime.mjs +73 -39
- 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/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 +9 -2
- 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/websocket-protocol.ts +8 -2
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { BridgeError } from "./errors.mjs";
|
|
2
|
+
import { intersectPolicies, normalizePolicy, policyProfile } from "./policy.mjs";
|
|
3
|
+
|
|
4
|
+
const ACCOUNT_ID = /^acct_[A-Za-z0-9_-]{20,96}$/;
|
|
5
|
+
const CLIENT_ID = /^mcp_client_[A-Za-z0-9_-]{43}$/;
|
|
6
|
+
const FAMILY_ID = /^mcp_family_[A-Za-z0-9_-]{43}$/;
|
|
7
|
+
|
|
8
|
+
export function buildAuthorityContext({ authorization = {}, daemonPolicy, origin = "local" } = {}) {
|
|
9
|
+
if (origin !== "relay") {
|
|
10
|
+
const policy = normalizePolicy(daemonPolicy || {});
|
|
11
|
+
return Object.freeze({
|
|
12
|
+
origin: "local",
|
|
13
|
+
principal: Object.freeze({ kind: "local", role: "owner" }),
|
|
14
|
+
daemonPolicy: policy,
|
|
15
|
+
accountPolicy: policy,
|
|
16
|
+
effectivePolicy: policy,
|
|
17
|
+
owner: true,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const accountId = String(authorization.account_id || "");
|
|
22
|
+
const clientId = String(authorization.client_id || "");
|
|
23
|
+
const familyId = String(authorization.family_id || "");
|
|
24
|
+
const role = String(authorization.role || "").trim().toLowerCase();
|
|
25
|
+
const accountVersion = Number(authorization.account_version);
|
|
26
|
+
if (!ACCOUNT_ID.test(accountId) || !CLIENT_ID.test(clientId) || !FAMILY_ID.test(familyId) || !Number.isSafeInteger(accountVersion) || accountVersion <= 0) {
|
|
27
|
+
throw new BridgeError("authorization_denied", "relay operation is missing authenticated principal identity");
|
|
28
|
+
}
|
|
29
|
+
const accountPolicy = policyProfile(roleProfile(role), "explicit");
|
|
30
|
+
const normalizedDaemonPolicy = normalizePolicy(daemonPolicy || {});
|
|
31
|
+
const effectivePolicy = intersectPolicies(normalizedDaemonPolicy, accountPolicy, { origin: "effective" });
|
|
32
|
+
const principal = Object.freeze({
|
|
33
|
+
kind: "account",
|
|
34
|
+
accountId,
|
|
35
|
+
accountVersion,
|
|
36
|
+
clientId,
|
|
37
|
+
familyId,
|
|
38
|
+
role,
|
|
39
|
+
});
|
|
40
|
+
return Object.freeze({
|
|
41
|
+
origin: "relay",
|
|
42
|
+
principal,
|
|
43
|
+
daemonPolicy: normalizedDaemonPolicy,
|
|
44
|
+
accountPolicy,
|
|
45
|
+
effectivePolicy,
|
|
46
|
+
owner: role === "owner",
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function policyForContext(context, fallbackPolicy) {
|
|
51
|
+
return normalizePolicy(context?.authority?.effectivePolicy || fallbackPolicy || {});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function principalForContext(context) {
|
|
55
|
+
const principal = context?.authority?.principal;
|
|
56
|
+
if (principal?.kind === "account") return principal;
|
|
57
|
+
return Object.freeze({ kind: "local", role: "owner" });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function principalBinding(context) {
|
|
61
|
+
const principal = principalForContext(context);
|
|
62
|
+
if (principal.kind !== "account") return { owner_kind: "local" };
|
|
63
|
+
return {
|
|
64
|
+
owner_kind: "account",
|
|
65
|
+
owner_account_id: principal.accountId,
|
|
66
|
+
owner_account_version: principal.accountVersion,
|
|
67
|
+
owner_client_id: principal.clientId,
|
|
68
|
+
owner_family_id: principal.familyId,
|
|
69
|
+
owner_role: principal.role,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function assertOwnedByContext(record, context, label = "object") {
|
|
74
|
+
const principal = principalForContext(context);
|
|
75
|
+
if (principal.kind !== "account" || principal.role === "owner") return;
|
|
76
|
+
if (
|
|
77
|
+
record?.owner_kind !== "account"
|
|
78
|
+
|| record.owner_account_id !== principal.accountId
|
|
79
|
+
|| record.owner_account_version !== principal.accountVersion
|
|
80
|
+
|| record.owner_client_id !== principal.clientId
|
|
81
|
+
|| record.owner_family_id !== principal.familyId
|
|
82
|
+
) {
|
|
83
|
+
throw new BridgeError("authorization_denied", `${label} belongs to another account or an obsolete account session`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function visibleToContext(record, context) {
|
|
88
|
+
try {
|
|
89
|
+
assertOwnedByContext(record, context);
|
|
90
|
+
return true;
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function roleProfile(role) {
|
|
97
|
+
const mapping = {
|
|
98
|
+
reviewer: "review",
|
|
99
|
+
editor: "edit",
|
|
100
|
+
operator: "agent",
|
|
101
|
+
owner: "full",
|
|
102
|
+
};
|
|
103
|
+
const profile = mapping[role];
|
|
104
|
+
if (!profile) throw new BridgeError("authorization_denied", `unknown account role: ${role}`);
|
|
105
|
+
return profile;
|
|
106
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { EXPECTED_EXTENSION_VERSION } from "./browser-extension-protocol.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { EXPECTED_EXTENSION_ID } from "./browser-extension-identity.mjs";
|
|
3
|
+
import { isAllowedLoopbackHost, pairingHtml, securityHeaders, sendJson } from "./browser-pairing-http.mjs";
|
|
3
4
|
|
|
4
5
|
export function handleBrowserBridgeHttp(request, response, {
|
|
5
6
|
port,
|
|
@@ -26,6 +27,8 @@ export function handleBrowserBridgeHttp(request, response, {
|
|
|
26
27
|
connected: extensionConnected(),
|
|
27
28
|
broker: "machine-bridge-browser",
|
|
28
29
|
expected_extension_version: EXPECTED_EXTENSION_VERSION,
|
|
30
|
+
expected_extension_id: EXPECTED_EXTENSION_ID,
|
|
31
|
+
extension_id: extension?.extension_id || "",
|
|
29
32
|
extension_protocol: extension?.protocol || null,
|
|
30
33
|
extension_version: extension?.version || "",
|
|
31
34
|
extension_capabilities: extension?.capabilities || [],
|
|
@@ -13,6 +13,7 @@ import { BrowserRequestRegistry } from "./browser-request-registry.mjs";
|
|
|
13
13
|
import { handleBrowserBridgeHttp } from "./browser-bridge-http.mjs";
|
|
14
14
|
import { BrowserBrokerRoutes } from "./browser-broker-routes.mjs";
|
|
15
15
|
import { startBrowserBrokerServer } from "./browser-broker-server.mjs";
|
|
16
|
+
import { EXPECTED_EXTENSION_ID } from "./browser-extension-identity.mjs";
|
|
16
17
|
|
|
17
18
|
const MAX_PORT_ATTEMPTS = 10;
|
|
18
19
|
const MAX_PENDING = 32;
|
|
@@ -49,13 +50,11 @@ export class BrowserBridgeManager {
|
|
|
49
50
|
extensionStatusInfo: () => this.extensionStatusInfo(),
|
|
50
51
|
extensionReloadRequired: () => this.extensionReloadRequired(),
|
|
51
52
|
});
|
|
52
|
-
// Public aliases remain for diagnostics and compatibility with existing behavioral tests.
|
|
53
|
-
this.runtimeClients = this.brokerRoutes.clients;
|
|
54
|
-
this.proxyRoutes = this.brokerRoutes.routes;
|
|
55
53
|
this.startPromise = null;
|
|
56
54
|
this.startGeneration = 0;
|
|
57
55
|
this.port = 0;
|
|
58
|
-
this.
|
|
56
|
+
this.extensionToken = "";
|
|
57
|
+
this.runtimeToken = "";
|
|
59
58
|
this.extensionPath = resolve(packageRoot, "browser-extension");
|
|
60
59
|
this.operationService = new BrowserOperationService({
|
|
61
60
|
authorizeTool: (tool) => this.authorizeTool(tool),
|
|
@@ -67,15 +66,19 @@ export class BrowserBridgeManager {
|
|
|
67
66
|
extensionConnected: this.extensionConnected(),
|
|
68
67
|
extensionInfo: this.extensionStatusInfo(),
|
|
69
68
|
extensionReloadRequired: this.extensionReloadRequired(),
|
|
69
|
+
...this.brokerRoutes.snapshot(),
|
|
70
70
|
}),
|
|
71
71
|
extensionPath: this.extensionPath,
|
|
72
72
|
expectedExtensionVersion: EXPECTED_EXTENSION_VERSION,
|
|
73
|
+
expectedExtensionId: EXPECTED_EXTENSION_ID,
|
|
73
74
|
runProcess: (...args) => this.runProcess(...args),
|
|
74
75
|
readResourceText: (name) => this.readResourceText(name),
|
|
75
76
|
readResourceBinary: (name) => this.readResourceBinary(name),
|
|
76
77
|
});
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
brokerDiagnostics() { return this.brokerRoutes.snapshot(); }
|
|
81
|
+
|
|
79
82
|
status(context = {}) { return this.operationService.status(context); }
|
|
80
83
|
|
|
81
84
|
pair(args = {}, context = {}) { return this.operationService.pair(args, context); }
|
|
@@ -133,14 +136,15 @@ export class BrowserBridgeManager {
|
|
|
133
136
|
try {
|
|
134
137
|
const pairing = await loadOrCreatePairing(this.stateRoot);
|
|
135
138
|
this.assertStartCurrent(generation);
|
|
136
|
-
this.
|
|
139
|
+
this.extensionToken = pairing.extensionToken;
|
|
140
|
+
this.runtimeToken = pairing.runtimeToken;
|
|
137
141
|
for (let offset = 0; offset < MAX_PORT_ATTEMPTS; offset += 1) {
|
|
138
142
|
const port = pairing.port + offset;
|
|
139
143
|
try {
|
|
140
144
|
await this.listen(port);
|
|
141
145
|
this.assertStartCurrent(generation);
|
|
142
146
|
if (port !== pairing.port && this.stateRoot) {
|
|
143
|
-
await savePairing(this.stateRoot, {
|
|
147
|
+
await savePairing(this.stateRoot, { schemaVersion: pairing.schemaVersion, extensionToken: this.extensionToken, runtimeToken: this.runtimeToken, port });
|
|
144
148
|
this.assertStartCurrent(generation);
|
|
145
149
|
}
|
|
146
150
|
return;
|
|
@@ -176,7 +180,8 @@ export class BrowserBridgeManager {
|
|
|
176
180
|
this.port = port;
|
|
177
181
|
const { server, wss } = await startBrowserBrokerServer({
|
|
178
182
|
port,
|
|
179
|
-
|
|
183
|
+
extensionToken: this.extensionToken,
|
|
184
|
+
runtimeToken: this.runtimeToken,
|
|
180
185
|
maxPayload: MAX_BROWSER_MESSAGE_BYTES,
|
|
181
186
|
onHttp: (request, response) => this.handleHttp(request, response),
|
|
182
187
|
onSocket: (socket, role) => this.acceptSocket(socket, role),
|
|
@@ -189,7 +194,7 @@ export class BrowserBridgeManager {
|
|
|
189
194
|
const url = `ws://127.0.0.1:${port}/runtime`;
|
|
190
195
|
return new Promise((resolvePromise) => {
|
|
191
196
|
let settled = false;
|
|
192
|
-
const ws = new WebSocket(url, [`mbm-runtime.${this.
|
|
197
|
+
const ws = new WebSocket(url, [`mbm-runtime.${this.runtimeToken}`], { maxPayload: MAX_BROWSER_MESSAGE_BYTES });
|
|
193
198
|
const timer = setTimeout(() => finish(false), 1500);
|
|
194
199
|
timer.unref?.();
|
|
195
200
|
const finish = (ok) => {
|
|
@@ -372,7 +377,7 @@ export class BrowserBridgeManager {
|
|
|
372
377
|
handleHttp(request, response) {
|
|
373
378
|
return handleBrowserBridgeHttp(request, response, {
|
|
374
379
|
port: this.port,
|
|
375
|
-
token: this.
|
|
380
|
+
token: this.extensionToken,
|
|
376
381
|
extensionConnected: () => this.extensionConnected(),
|
|
377
382
|
extensionStatusInfo: () => this.extensionStatusInfo(),
|
|
378
383
|
extensionReloadRequired: () => this.extensionReloadRequired(),
|
|
@@ -13,6 +13,10 @@ export class BrowserBrokerRoutes {
|
|
|
13
13
|
this.routes = new Map();
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
snapshot() {
|
|
17
|
+
return Object.freeze({ runtime_clients: this.clients.size, routed_requests: this.routes.size });
|
|
18
|
+
}
|
|
19
|
+
|
|
16
20
|
acceptClient(socket) {
|
|
17
21
|
this.clients.add(socket);
|
|
18
22
|
socket.on("message", (data) => this.handleClientMessage(socket, data));
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import { WebSocketServer } from "ws";
|
|
3
|
-
import { isAllowedExtensionOrigin, isAllowedLoopbackHost } from "./browser-pairing-
|
|
3
|
+
import { isAllowedExtensionOrigin, isAllowedLoopbackHost } from "./browser-pairing-http.mjs";
|
|
4
|
+
import { EXPECTED_EXTENSION_ID } from "./browser-extension-identity.mjs";
|
|
4
5
|
|
|
5
|
-
export async function startBrowserBrokerServer({ port,
|
|
6
|
+
export async function startBrowserBrokerServer({ port, extensionToken, runtimeToken, maxPayload, onHttp, onSocket }) {
|
|
6
7
|
const server = createServer(onHttp);
|
|
7
8
|
const wss = new WebSocketServer({ noServer: true, maxPayload });
|
|
8
9
|
server.on("upgrade", (request, socket, head) => {
|
|
@@ -16,8 +17,8 @@ export async function startBrowserBrokerServer({ port, token, maxPayload, onHttp
|
|
|
16
17
|
const protocol = String(request.headers["sec-websocket-protocol"] || "");
|
|
17
18
|
const origin = String(request.headers.origin || "");
|
|
18
19
|
let role = "";
|
|
19
|
-
if (url.pathname === "/extension" && protocol === `mbm.${
|
|
20
|
-
if (url.pathname === "/runtime" && protocol === `mbm-runtime.${
|
|
20
|
+
if (url.pathname === "/extension" && protocol === `mbm.${extensionToken}` && isAllowedExtensionOrigin(origin, EXPECTED_EXTENSION_ID)) role = "extension";
|
|
21
|
+
if (url.pathname === "/runtime" && protocol === `mbm-runtime.${runtimeToken}` && !origin) role = "runtime";
|
|
21
22
|
if (!role) {
|
|
22
23
|
rejectUpgrade(socket, "401 Unauthorized");
|
|
23
24
|
return;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { packageRoot } from "./state.mjs";
|
|
5
|
+
|
|
6
|
+
const EXTENSION_ID_PATTERN = /^[a-p]{32}$/;
|
|
7
|
+
const PUBLIC_KEY_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
8
|
+
|
|
9
|
+
const manifest = readExtensionManifest();
|
|
10
|
+
export const EXPECTED_EXTENSION_PUBLIC_KEY = normalizeManifestPublicKey(manifest.key);
|
|
11
|
+
export const EXPECTED_EXTENSION_ID = extensionIdFromPublicKey(EXPECTED_EXTENSION_PUBLIC_KEY);
|
|
12
|
+
|
|
13
|
+
export function extensionIdFromPublicKey(value) {
|
|
14
|
+
const publicKey = normalizeManifestPublicKey(value);
|
|
15
|
+
const bytes = Buffer.from(publicKey, "base64");
|
|
16
|
+
const hexadecimal = createHash("sha256").update(bytes).digest("hex").slice(0, 32);
|
|
17
|
+
const extensionId = [...hexadecimal]
|
|
18
|
+
.map((character) => String.fromCharCode(97 + Number.parseInt(character, 16)))
|
|
19
|
+
.join("");
|
|
20
|
+
if (!EXTENSION_ID_PATTERN.test(extensionId)) throw new Error("browser extension public key produced an invalid extension id");
|
|
21
|
+
return extensionId;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function normalizeExtensionId(value) {
|
|
25
|
+
const extensionId = String(value || "").trim().toLowerCase();
|
|
26
|
+
return EXTENSION_ID_PATTERN.test(extensionId) ? extensionId : "";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeManifestPublicKey(value) {
|
|
30
|
+
const publicKey = String(value || "").trim();
|
|
31
|
+
if (!PUBLIC_KEY_PATTERN.test(publicKey) || publicKey.length < 128 || publicKey.length > 8192) {
|
|
32
|
+
throw new Error("browser extension manifest must contain a bounded base64 public key");
|
|
33
|
+
}
|
|
34
|
+
let bytes;
|
|
35
|
+
try { bytes = Buffer.from(publicKey, "base64"); }
|
|
36
|
+
catch { throw new Error("browser extension manifest public key is invalid base64"); }
|
|
37
|
+
if (bytes.length < 96 || bytes.length > 6144 || bytes.toString("base64") !== publicKey) {
|
|
38
|
+
throw new Error("browser extension manifest public key is not canonical DER base64");
|
|
39
|
+
}
|
|
40
|
+
return publicKey;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readExtensionManifest() {
|
|
44
|
+
const path = resolve(packageRoot, "browser-extension", "manifest.json");
|
|
45
|
+
const manifestValue = JSON.parse(readFileSync(path, "utf8"));
|
|
46
|
+
if (!manifestValue || typeof manifestValue !== "object" || Array.isArray(manifestValue)) {
|
|
47
|
+
throw new Error("browser extension manifest is invalid");
|
|
48
|
+
}
|
|
49
|
+
return manifestValue;
|
|
50
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { resolve } from "node:path";
|
|
5
5
|
import { packageRoot } from "./state.mjs";
|
|
6
|
+
import { EXPECTED_EXTENSION_ID, normalizeExtensionId } from "./browser-extension-identity.mjs";
|
|
6
7
|
|
|
7
8
|
export const BROWSER_EXTENSION_PROTOCOL = 3;
|
|
8
9
|
export const EXPECTED_EXTENSION_VERSION = extensionVersion();
|
|
@@ -11,13 +12,13 @@ const REQUIRED_EXTENSION_CAPABILITIES = Object.freeze([
|
|
|
11
12
|
]);
|
|
12
13
|
export const MAX_BROWSER_MESSAGE_BYTES = 8 * 1024 * 1024;
|
|
13
14
|
|
|
14
|
-
/** @typedef {{protocol: number, version: string, capabilities: string[]}} ExtensionInfo */
|
|
15
|
+
/** @typedef {{protocol: number, version: string, extension_id: string, capabilities: string[]}} ExtensionInfo */
|
|
15
16
|
/** @typedef {{readyState: number, close: (code?: number, reason?: string) => unknown, send: (value: string) => unknown}} ProtocolSocket */
|
|
16
17
|
|
|
17
18
|
/** @param {unknown} value @returns {ExtensionInfo | null} */
|
|
18
19
|
export function normalizeCompatibleExtensionInfo(value) {
|
|
19
20
|
const info = normalizeExtensionInfo(value);
|
|
20
|
-
if (!info || info.protocol !== BROWSER_EXTENSION_PROTOCOL || info.version !== EXPECTED_EXTENSION_VERSION) return null;
|
|
21
|
+
if (!info || info.protocol !== BROWSER_EXTENSION_PROTOCOL || info.version !== EXPECTED_EXTENSION_VERSION || info.extension_id !== EXPECTED_EXTENSION_ID) return null;
|
|
21
22
|
if (REQUIRED_EXTENSION_CAPABILITIES.some((capability) => !info.capabilities.includes(capability))) return null;
|
|
22
23
|
return info;
|
|
23
24
|
}
|
|
@@ -32,6 +33,9 @@ export function parseExtensionHello(message) {
|
|
|
32
33
|
if (info.version !== EXPECTED_EXTENSION_VERSION) {
|
|
33
34
|
throw new Error(`extension version mismatch; expected ${EXPECTED_EXTENSION_VERSION}; reload the extension`);
|
|
34
35
|
}
|
|
36
|
+
if (info.extension_id !== EXPECTED_EXTENSION_ID) {
|
|
37
|
+
throw new Error(`extension identity mismatch; expected ${EXPECTED_EXTENSION_ID}; reload the packaged extension`);
|
|
38
|
+
}
|
|
35
39
|
const missing = REQUIRED_EXTENSION_CAPABILITIES.filter((capability) => !info.capabilities.includes(capability));
|
|
36
40
|
if (missing.length) throw new Error(`extension capability mismatch; reload the extension (${missing.join(",")})`);
|
|
37
41
|
return info;
|
|
@@ -77,11 +81,12 @@ function normalizeExtensionInfo(value) {
|
|
|
77
81
|
const record = /** @type {Record<string, unknown>} */ (value);
|
|
78
82
|
const protocol = Number(record.protocol);
|
|
79
83
|
const version = typeof record.version === "string" && record.version.length <= 100 ? record.version : "";
|
|
84
|
+
const extension_id = normalizeExtensionId(record.extension_id);
|
|
80
85
|
const capabilities = Array.isArray(record.capabilities)
|
|
81
86
|
? [...new Set(record.capabilities.filter((entry) => typeof entry === "string" && /^[a-z][a-z0-9_]{0,63}$/.test(entry)))].slice(0, 32)
|
|
82
87
|
: [];
|
|
83
|
-
if (!Number.isInteger(protocol) || protocol < 1 || !version) return null;
|
|
84
|
-
return { protocol, version, capabilities };
|
|
88
|
+
if (!Number.isInteger(protocol) || protocol < 1 || !version || !extension_id) return null;
|
|
89
|
+
return { protocol, version, extension_id, capabilities };
|
|
85
90
|
}
|
|
86
91
|
|
|
87
92
|
function extensionVersion() {
|
|
@@ -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
|
}
|