machine-bridge-mcp 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/README.md +21 -12
- package/SECURITY.md +17 -9
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +8 -6
- package/docs/AUDIT.md +3 -3
- package/docs/CLIENTS.md +3 -1
- package/docs/GETTING_STARTED.md +404 -0
- package/docs/LOGGING.md +2 -2
- package/docs/MANAGED_JOBS.md +1 -1
- package/docs/MULTI_ACCOUNT.md +123 -0
- package/docs/OPERATIONS.md +4 -4
- package/docs/POLICY_REFERENCE.md +1 -1
- package/docs/PRIVACY.md +1 -1
- package/docs/PROJECT_STANDARDS.md +1 -1
- package/docs/TESTING.md +4 -4
- package/package.json +4 -3
- package/src/local/account-access.mjs +37 -0
- package/src/local/account-admin.mjs +105 -0
- package/src/local/bounded-output.mjs +50 -0
- package/src/local/call-registry.mjs +10 -0
- package/src/local/cli-account-admin.mjs +79 -0
- package/src/local/cli-options.mjs +9 -4
- package/src/local/cli-policy.mjs +7 -28
- package/src/local/cli.mjs +48 -78
- package/src/local/daemon-process.mjs +3 -2
- package/src/local/errors.mjs +0 -14
- package/src/local/log.mjs +1 -1
- package/src/local/managed-jobs.mjs +1 -3
- package/src/local/process-execution.mjs +90 -60
- package/src/local/relay-connection.mjs +11 -3
- package/src/local/runtime.mjs +27 -2
- package/src/local/service.mjs +7 -26
- package/src/local/shell.mjs +17 -29
- package/src/local/state-inventory.mjs +2 -2
- package/src/local/state.mjs +30 -85
- package/src/local/tool-executor.mjs +8 -2
- package/src/shared/access-contract.json +23 -0
- package/src/shared/policy-contract.json +30 -7
- package/src/worker/access.ts +46 -0
- package/src/worker/account-admin.ts +103 -0
- package/src/worker/index.ts +75 -48
- package/src/worker/oauth-state.ts +184 -2
- package/src/worker/tool-catalog.ts +13 -0
package/docs/TESTING.md
CHANGED
|
@@ -21,7 +21,7 @@ The suite includes:
|
|
|
21
21
|
- shared tool-catalog schema, annotation, and profile-inventory checks;
|
|
22
22
|
- default working-agreement injection without user files, bounded automatic project metadata, script-body non-disclosure, user-global opt-out, repository opt-out rejection, global `model_instructions_file` injection in stdio/remote initialization, hierarchical precedence, `.agents/skills` and `.codex/skills` compatibility discovery, live project/skill rescanning and fingerprints, automatic task ranking/loading with English inflection normalization, bounded Chinese/English intent aliases, capability-name weighting, and selected-skill instruction loading; automatic `package.*` commands with English/Chinese workflow-intent matching and Windows command-shim execution, explicit command override/removal, direct argv handling, timeout ceilings, runtime-keyed routing-telemetry privacy, and execution-profile denial;
|
|
23
23
|
- concurrent complete-before-visible lock claims, atomic replacement under active readers, malformed-lock grace, snapshot/token-safe reclamation, absolute-age expiry, PID-reuse detection, and bounded startup-lock waiting;
|
|
24
|
-
- foreground takeover of active and orphaned background daemons
|
|
24
|
+
- foreground takeover of active and orphaned background daemons with current service-lock metadata, foreground-process protection, bounded final lock-handoff retry, actual-PID exit waiting, POSIX non-escalating timeout behavior, Windows verified-daemon stop semantics, daemon lock mode/version/process-start metadata, launchd service-target semantics, and silent idempotent duplicate service starts; daemon fixture subprocesses intentionally do not inherit V8 coverage because their purpose is ownership timing rather than code measurement;
|
|
25
25
|
- fail-closed service lifecycle ordering for provider-stop, all-workspace daemon-stop, and definition removal, including platform/daemon/removal failure injection and normalized macOS/systemd/Windows results;
|
|
26
26
|
- machine-level browser-broker ownership/client proxying, authenticated extension origin/subprotocol, non-cacheable local pairing, pairing-token non-disclosure, resource-backed upload routing, broker result redaction, pre-registered runtime-handshake listeners, bounded socket/HTTP waits, installed-application discovery caching, and name-based task matching;
|
|
27
27
|
- relay environment-proxy direct/bypass/agent selection, unsupported proxy rejection, fail-fast invalid configuration, and route observability without endpoint or credential disclosure;
|
|
@@ -41,11 +41,11 @@ The suite includes:
|
|
|
41
41
|
- real-machine canonical-full sandbox acceptance for outside-workspace I/O, direct/shell execution, full environment inheritance, SSH prerequisites, temporary authorized-key writing, and detached cleanup without external state changes;
|
|
42
42
|
- deterministic injected atomic-replace failures, sustained transient Windows sharing contention beyond the old retry budget, bounded exponential delay selection, and repeated Windows full-sandbox runs;
|
|
43
43
|
- canonical named-profile repair and full-only tool exposure parity between local and Worker policy filters;
|
|
44
|
-
- managed-job staging/local approval/cancel-before-start, detachment, job-scoped temporary files, resource hash verification/redaction, discard capture, finally execution, descendant-tree escalation, token/snapshot-safe transition locks, runner process identity, plan scrubbing, PID-reuse-safe dead-runner recovery, and
|
|
44
|
+
- managed-job staging/local approval/cancel-before-start, detachment, job-scoped temporary files, resource hash verification/redaction, discard capture, finally execution, descendant-tree escalation, token/snapshot-safe transition locks, runner process identity, plan scrubbing, PID-reuse-safe dead-runner recovery, and rejection of numeric-only runner records;
|
|
45
45
|
- daemon/startup locking, successfully-read corrupt-JSON recovery, and explicit propagation/preservation of oversized, symbolic-link, permission, and I/O failures;
|
|
46
|
-
- guarded state-root removal, unsafe state-root/workspace overlap rejection before creation, all-profile lock/daemon scanning, schema
|
|
46
|
+
- guarded state-root removal, unsafe state-root/workspace overlap rejection before creation, all-profile lock/daemon scanning, strict current-schema validation, corrupt-JSON isolation, and policy-origin persistence;
|
|
47
47
|
- no filename-based sensitive-file denial under unrestricted policy;
|
|
48
|
-
- log redaction, control-character handling, message/field bounds, suppression of both successful and failed per-tool events outside debug, service warning-level configuration, and
|
|
48
|
+
- log redaction, control-character handling, message/field bounds, suppression of both successful and failed per-tool events outside debug, service warning-level configuration, current-schema reset, and bounded tail trimming;
|
|
49
49
|
- deterministic relay connection lifecycle coverage for transport construction/error/deadline, pre-handshake `welcome` validation, authenticated `hello_ack` readiness, identity/version mismatch, retryable Worker handshake errors, fatal protocol errors, autonomous outage-reminder backoff, handshake and heartbeat timeout, brief-outage suppression, sustained-outage escalation, recovery summaries, and supersession;
|
|
50
50
|
- shared no-follow bounded-file reads for normal files, over-limit data, directories, and symbolic links;
|
|
51
51
|
- CLI parsing, policy profiles, and client configuration boundaries;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "machine-bridge-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Cross-client MCP bridge for local agent context, structured browser and application automation, files, Git, processes, resources, and durable jobs over stdio or OAuth relay.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"worker:types": "node scripts/generate-worker-types.mjs",
|
|
44
44
|
"typecheck": "npm run worker:types && tsc -p tsconfig.json --noEmit",
|
|
45
45
|
"syntax": "node scripts/syntax-check.mjs",
|
|
46
|
-
"check": "npm run privacy:check && npm run privacy:test && npm run release-impact:check && npm run release-impact:test && npm run release-state:test && npm run release-ci:test && npm run network-retry:test && npm run relay:test && npm run secure-file:test && npm run architecture:test && npm run markdown:test && npm run project-metadata:test && npm run numbers:test && npm run records:test && npm run state-inventory:test && npm run commit-message:test && npm run policy:test && npm run policy-docs:check && npm run tool-docs:check && npm run runtime-infrastructure:test && npm run runtime-handlers:test && npm run cli-entrypoint:test && npm run lifecycle:test && npm run logging-structure:test && npm run coverage:test && npm run worker-runtime-infrastructure:test && npm run typecheck && npm run syntax && npm run process-lock:test && npm run service-lifecycle:test && npm run service-platform:test && npm run catalog:test && npm run agent-context:test && npm run capability-ranking:test && npm run browser-command:test && npm run browser-devtools-input:test && npm run browser-service-worker:test && npm run browser-page-automation:test && npm run browser-bridge:test && npm run app-automation:test && npm run self-test && npm run atomic-fs:test && npm run package:test && npm run ssh-key:test && npm run full-access:test && npm run managed-jobs:test && npm run stdio:integration-test && npm run worker:integration-test",
|
|
46
|
+
"check": "npm run privacy:check && npm run privacy:test && npm run release-impact:check && npm run release-impact:test && npm run release-state:test && npm run release-ci:test && npm run network-retry:test && npm run relay:test && npm run secure-file:test && npm run architecture:test && npm run markdown:test && npm run project-metadata:test && npm run numbers:test && npm run records:test && npm run state-inventory:test && npm run commit-message:test && npm run policy:test && npm run account:test && npm run policy-docs:check && npm run tool-docs:check && npm run runtime-infrastructure:test && npm run runtime-handlers:test && npm run cli-entrypoint:test && npm run lifecycle:test && npm run logging-structure:test && npm run coverage:test && npm run worker-runtime-infrastructure:test && npm run typecheck && npm run syntax && npm run process-lock:test && npm run service-lifecycle:test && npm run service-platform:test && npm run catalog:test && npm run agent-context:test && npm run capability-ranking:test && npm run browser-command:test && npm run browser-devtools-input:test && npm run browser-service-worker:test && npm run browser-page-automation:test && npm run browser-bridge:test && npm run app-automation:test && npm run self-test && npm run atomic-fs:test && npm run package:test && npm run ssh-key:test && npm run full-access:test && npm run managed-jobs:test && npm run stdio:integration-test && npm run worker:integration-test",
|
|
47
47
|
"worker:dry-run": "wrangler deploy --dry-run",
|
|
48
48
|
"worker:integration-test": "node tests/worker-integration-test.mjs",
|
|
49
49
|
"release:check": "node scripts/github-release.mjs --check",
|
|
@@ -98,7 +98,8 @@
|
|
|
98
98
|
"commit-message:check": "node scripts/commit-message-check.mjs",
|
|
99
99
|
"commit-message:test": "node tests/commit-message-test.mjs",
|
|
100
100
|
"tool-docs:generate": "node scripts/generate-tool-reference.mjs",
|
|
101
|
-
"tool-docs:check": "node scripts/generate-tool-reference.mjs --check"
|
|
101
|
+
"tool-docs:check": "node scripts/generate-tool-reference.mjs --check",
|
|
102
|
+
"account:test": "node tests/account-admin-test.mjs"
|
|
102
103
|
},
|
|
103
104
|
"dependencies": {
|
|
104
105
|
"https-proxy-agent": "9.1.0",
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import accessContract from "../shared/access-contract.json" with { type: "json" };
|
|
2
|
+
import { BridgeError } from "./errors.mjs";
|
|
3
|
+
import { policyProfile, toolNamesForPolicy, assertToolAllowed } from "./policy.mjs";
|
|
4
|
+
|
|
5
|
+
export const ACCOUNT_ACCESS_REVISION = Number(accessContract.revision);
|
|
6
|
+
export const ACCOUNT_ROLES = Object.freeze(Object.fromEntries(
|
|
7
|
+
Object.entries(accessContract.roles).map(([name, value]) => [name, Object.freeze({ ...value })]),
|
|
8
|
+
));
|
|
9
|
+
export const DEFAULT_ACCOUNT_ROLE = String(accessContract.defaultRole);
|
|
10
|
+
export const OWNER_ACCOUNT_ROLE = String(accessContract.ownerRole);
|
|
11
|
+
|
|
12
|
+
export function normalizeAccountRole(value) {
|
|
13
|
+
const role = String(value || "").trim().toLowerCase();
|
|
14
|
+
if (!ACCOUNT_ROLES[role]) throw new BridgeError("invalid_request", `unknown account role: ${role}`);
|
|
15
|
+
return role;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function accountRolePolicy(role) {
|
|
19
|
+
const normalized = normalizeAccountRole(role);
|
|
20
|
+
return policyProfile(ACCOUNT_ROLES[normalized].profile, "explicit");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function accountRoleToolNames(role) {
|
|
24
|
+
return toolNamesForPolicy(accountRolePolicy(role));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class AccountAccessGate {
|
|
28
|
+
assert(role, tool) {
|
|
29
|
+
const normalized = normalizeAccountRole(role);
|
|
30
|
+
assertToolAllowed(accountRolePolicy(normalized), tool);
|
|
31
|
+
return normalized;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
names(role) {
|
|
35
|
+
return accountRoleToolNames(role);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { ACCOUNT_ROLES, normalizeAccountRole } from "./account-access.mjs";
|
|
3
|
+
import { BridgeError } from "./errors.mjs";
|
|
4
|
+
|
|
5
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
6
|
+
|
|
7
|
+
export function generateAccountPassword() {
|
|
8
|
+
return `account_password_${randomBytes(32).toString("base64url")}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class AccountAdminClient {
|
|
12
|
+
constructor({ workerUrl, adminSecret, fetchImpl = fetch }) {
|
|
13
|
+
this.workerUrl = normalizeWorkerUrl(workerUrl);
|
|
14
|
+
if (typeof adminSecret !== "string" || adminSecret.length < 24) throw new BridgeError("invalid_request", "account administration secret is missing");
|
|
15
|
+
this.adminSecret = adminSecret;
|
|
16
|
+
this.fetchImpl = fetchImpl;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
list() {
|
|
20
|
+
return this.request("GET", "/admin/accounts");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
create({ name, role, password, displayName = "" }) {
|
|
24
|
+
return this.request("POST", "/admin/accounts", {
|
|
25
|
+
name: normalizeAccountName(name),
|
|
26
|
+
role: normalizeAccountRole(role),
|
|
27
|
+
password: String(password || ""),
|
|
28
|
+
...(displayName ? { display_name: String(displayName) } : {}),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
update({ accountId, role, active, displayName }) {
|
|
33
|
+
return this.request("PATCH", "/admin/accounts", {
|
|
34
|
+
account_id: requiredAccountId(accountId),
|
|
35
|
+
...(role === undefined ? {} : { role: normalizeAccountRole(role) }),
|
|
36
|
+
...(active === undefined ? {} : { active: Boolean(active) }),
|
|
37
|
+
...(displayName === undefined ? {} : { display_name: String(displayName) }),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
rotatePassword({ accountId, password }) {
|
|
42
|
+
return this.request("POST", "/admin/accounts/rotate-password", {
|
|
43
|
+
account_id: requiredAccountId(accountId),
|
|
44
|
+
password: String(password || ""),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
remove({ accountId }) {
|
|
49
|
+
return this.request("DELETE", "/admin/accounts", { account_id: requiredAccountId(accountId) });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async find(target) {
|
|
53
|
+
const result = await this.list();
|
|
54
|
+
const value = String(target || "").trim().toLowerCase();
|
|
55
|
+
const matches = result.accounts.filter((account) => account.account_id === target || account.name === value);
|
|
56
|
+
if (matches.length !== 1) throw new BridgeError(matches.length ? "conflict" : "not_found", matches.length ? "account target is ambiguous" : "account was not found");
|
|
57
|
+
return matches[0];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async request(method, pathname, body) {
|
|
61
|
+
const response = await this.fetchImpl(`${this.workerUrl}${pathname}`, {
|
|
62
|
+
method,
|
|
63
|
+
headers: {
|
|
64
|
+
authorization: `Bearer ${this.adminSecret}`,
|
|
65
|
+
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
66
|
+
},
|
|
67
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
68
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
69
|
+
cache: "no-store",
|
|
70
|
+
}).catch((error) => {
|
|
71
|
+
throw new BridgeError("network_error", "account administration request failed", { cause: error, retryable: true });
|
|
72
|
+
});
|
|
73
|
+
if (response.status === 204) return { removed: true };
|
|
74
|
+
const payload = await response.json().catch(() => ({}));
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
const message = typeof payload.message === "string" ? payload.message : typeof payload.error === "string" ? payload.error : `account administration failed (${response.status})`;
|
|
77
|
+
throw new BridgeError(response.status === 404 ? "not_found" : response.status === 409 ? "conflict" : response.status === 401 ? "authentication_failed" : "invalid_request", message);
|
|
78
|
+
}
|
|
79
|
+
return payload;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function accountRoleNames() {
|
|
84
|
+
return Object.keys(ACCOUNT_ROLES);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function normalizeWorkerUrl(value) {
|
|
88
|
+
const url = new URL(String(value || ""));
|
|
89
|
+
if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
|
|
90
|
+
throw new BridgeError("invalid_request", "Worker URL must be a credential-free HTTPS origin");
|
|
91
|
+
}
|
|
92
|
+
return url.origin;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function normalizeAccountName(value) {
|
|
96
|
+
const name = String(value || "").trim().toLowerCase();
|
|
97
|
+
if (!/^[a-z0-9](?:[a-z0-9._-]{1,62}[a-z0-9])?$/.test(name)) throw new BridgeError("invalid_request", "account name must contain 3-64 lowercase letters, digits, dots, underscores, or hyphens");
|
|
98
|
+
return name;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function requiredAccountId(value) {
|
|
102
|
+
const id = String(value || "");
|
|
103
|
+
if (!/^acct_[A-Za-z0-9_-]{20,96}$/.test(id)) throw new BridgeError("invalid_request", "account id is invalid");
|
|
104
|
+
return id;
|
|
105
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export class BoundedOutput {
|
|
2
|
+
constructor(maximumBytes, options = {}) {
|
|
3
|
+
const maximum = Number(maximumBytes);
|
|
4
|
+
if (!Number.isFinite(maximum) || maximum < 1) throw new Error("maximum output bytes must be positive");
|
|
5
|
+
this.maximum = Math.floor(maximum);
|
|
6
|
+
this.headLimit = Math.max(1, Math.min(this.maximum, Math.floor(Number(options.headBytes) || this.maximum / 3)));
|
|
7
|
+
this.tailLimit = Math.max(0, this.maximum - this.headLimit);
|
|
8
|
+
this.totalBytes = 0;
|
|
9
|
+
this.full = Buffer.alloc(0);
|
|
10
|
+
this.head = Buffer.alloc(0);
|
|
11
|
+
this.tail = Buffer.alloc(0);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
append(chunk) {
|
|
15
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk ?? ""));
|
|
16
|
+
if (!bytes.length) return;
|
|
17
|
+
this.totalBytes += bytes.length;
|
|
18
|
+
|
|
19
|
+
if (this.full) {
|
|
20
|
+
const combined = Buffer.concat([this.full, bytes]);
|
|
21
|
+
if (combined.length <= this.maximum) {
|
|
22
|
+
this.full = combined;
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
this.full = null;
|
|
26
|
+
this.head = combined.subarray(0, this.headLimit);
|
|
27
|
+
this.tail = this.tailLimit ? combined.subarray(Math.max(this.headLimit, combined.length - this.tailLimit)) : Buffer.alloc(0);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (this.head.length < this.headLimit) {
|
|
32
|
+
const missing = this.headLimit - this.head.length;
|
|
33
|
+
this.head = Buffer.concat([this.head, bytes.subarray(0, missing)]);
|
|
34
|
+
}
|
|
35
|
+
if (this.tailLimit) {
|
|
36
|
+
const combinedTail = Buffer.concat([this.tail, bytes]);
|
|
37
|
+
this.tail = combinedTail.subarray(Math.max(0, combinedTail.length - this.tailLimit));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get truncatedBytes() {
|
|
42
|
+
return Math.max(0, this.totalBytes - this.maximum);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
text() {
|
|
46
|
+
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")}`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -62,6 +62,16 @@ export class CallRegistry {
|
|
|
62
62
|
return true;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
cancelOrigin(origin, reason = "transport disconnected") {
|
|
66
|
+
const expected = String(origin || "");
|
|
67
|
+
let cancelled = 0;
|
|
68
|
+
for (const [id, record] of this.calls) {
|
|
69
|
+
if (record.origin !== expected) continue;
|
|
70
|
+
if (this.cancel(id, reason)) cancelled += 1;
|
|
71
|
+
}
|
|
72
|
+
return cancelled;
|
|
73
|
+
}
|
|
74
|
+
|
|
65
75
|
cancelAll(reason = "runtime stopped") {
|
|
66
76
|
for (const id of [...this.calls.keys()]) {
|
|
67
77
|
this.cancel(id, reason);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { AccountAdminClient, accountRoleNames, generateAccountPassword } from "./account-admin.mjs";
|
|
2
|
+
import { loadState } from "./state.mjs";
|
|
3
|
+
|
|
4
|
+
export function createAccountCommand({ chooseWorkspace, confirm }) {
|
|
5
|
+
if (typeof chooseWorkspace !== "function" || typeof confirm !== "function") {
|
|
6
|
+
throw new TypeError("account command requires chooseWorkspace and confirm dependencies");
|
|
7
|
+
}
|
|
8
|
+
return async function accountCommand(args) {
|
|
9
|
+
const workspace = await chooseWorkspace({ ...args, _: [] }, { promptOnFirstRun: false, save: false, allowPositional: false });
|
|
10
|
+
const state = loadState(workspace, { stateDir: args.stateDir });
|
|
11
|
+
const client = accountAdminClient(state);
|
|
12
|
+
const action = String(args._[0] || "list").toLowerCase();
|
|
13
|
+
const result = await performAccountAction({ action, args, client, confirm });
|
|
14
|
+
if (result === null) return;
|
|
15
|
+
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
16
|
+
else printAccountResult(action, result);
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function accountAdminClient(state) {
|
|
21
|
+
if (!state.worker?.url || !state.worker?.accountAdminSecret) {
|
|
22
|
+
throw new Error("account administration requires a deployed Worker; run machine-mcp first");
|
|
23
|
+
}
|
|
24
|
+
return new AccountAdminClient({ workerUrl: state.worker.url, adminSecret: state.worker.accountAdminSecret });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function performAccountAction({ action, args, client, confirm }) {
|
|
28
|
+
if (action === "list") return client.list();
|
|
29
|
+
if (action === "add") {
|
|
30
|
+
const name = args._[1];
|
|
31
|
+
const role = args._[2];
|
|
32
|
+
if (!name || !role) throw new Error(`account add requires NAME and ROLE (${accountRoleNames().join(", ")})`);
|
|
33
|
+
const password = generateAccountPassword();
|
|
34
|
+
return { ...(await client.create({ name, role, password })), password };
|
|
35
|
+
}
|
|
36
|
+
if (action === "role") {
|
|
37
|
+
const account = await client.find(args._[1]);
|
|
38
|
+
const role = args._[2];
|
|
39
|
+
if (!role) throw new Error(`account role requires NAME_OR_ID and ROLE (${accountRoleNames().join(", ")})`);
|
|
40
|
+
return client.update({ accountId: account.account_id, role });
|
|
41
|
+
}
|
|
42
|
+
if (action === "enable" || action === "disable") {
|
|
43
|
+
const account = await client.find(args._[1]);
|
|
44
|
+
return client.update({ accountId: account.account_id, active: action === "enable" });
|
|
45
|
+
}
|
|
46
|
+
if (action === "rotate-password") {
|
|
47
|
+
const account = await client.find(args._[1]);
|
|
48
|
+
const password = generateAccountPassword();
|
|
49
|
+
return { ...(await client.rotatePassword({ accountId: account.account_id, password })), password };
|
|
50
|
+
}
|
|
51
|
+
if (action === "remove") {
|
|
52
|
+
const account = await client.find(args._[1]);
|
|
53
|
+
if (!args.yes && !(await confirm(`Remove account ${account.name}?`, false))) {
|
|
54
|
+
console.log("Account removal cancelled.");
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return client.remove({ accountId: account.account_id });
|
|
58
|
+
}
|
|
59
|
+
throw new Error("Unknown account action. Use list, add, role, enable, disable, rotate-password, or remove.");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function printAccountResult(action, result) {
|
|
63
|
+
if (action === "list") {
|
|
64
|
+
if (!result.accounts.length) {
|
|
65
|
+
console.log("No accounts configured.");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
for (const account of result.accounts) {
|
|
69
|
+
console.log(`${account.name}\t${account.role}\t${account.active ? "active" : "disabled"}\t${account.account_id}`);
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (result.account) console.log(`${result.account.name}: ${result.account.role}; ${result.account.active ? "active" : "disabled"}`);
|
|
74
|
+
if (result.password) {
|
|
75
|
+
process.stdout.write(`Password: ${result.password}\n`);
|
|
76
|
+
console.log("Save this password now; it is not stored locally or shown again.");
|
|
77
|
+
}
|
|
78
|
+
if (result.removed) console.log("Account removed.");
|
|
79
|
+
}
|
|
@@ -2,8 +2,7 @@ import { normalizeLogLevel } from "./log.mjs";
|
|
|
2
2
|
|
|
3
3
|
const BOOLEAN_OPTIONS = new Set([
|
|
4
4
|
"help", "version", "quiet", "json", "verbose", "rotateSecrets", "forceWorker",
|
|
5
|
-
"daemonOnly", "noAutostart", "
|
|
6
|
-
"printCredentials", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths",
|
|
5
|
+
"daemonOnly", "noAutostart", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths",
|
|
7
6
|
"yes", "keepWorker", "allowInsecurePermissions", "showPaths",
|
|
8
7
|
]);
|
|
9
8
|
const VALUE_OPTIONS = new Set([
|
|
@@ -15,7 +14,7 @@ const LOG_FORMATS = new Set(["text", "json"]);
|
|
|
15
14
|
const COMMAND_OPTIONS = {
|
|
16
15
|
start: new Set([
|
|
17
16
|
"workspace", "stateDir", "workerName", "quiet", "json", "verbose", "logLevel", "logFormat", "rotateSecrets", "forceWorker",
|
|
18
|
-
"daemonOnly", "noAutostart",
|
|
17
|
+
"daemonOnly", "noAutostart",
|
|
19
18
|
"profile", "execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths",
|
|
20
19
|
]),
|
|
21
20
|
stdio: new Set(["workspace", "stateDir", "profile", "execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths", "verbose", "quiet", "logLevel", "logFormat"]),
|
|
@@ -23,11 +22,12 @@ const COMMAND_OPTIONS = {
|
|
|
23
22
|
status: new Set(["workspace", "stateDir"]),
|
|
24
23
|
doctor: new Set(["workspace", "stateDir"]),
|
|
25
24
|
"full-test": new Set(["workspace", "stateDir", "json"]),
|
|
26
|
-
"rotate-secrets": new Set(["workspace", "stateDir", "workerName", "
|
|
25
|
+
"rotate-secrets": new Set(["workspace", "stateDir", "workerName", "quiet"]),
|
|
27
26
|
workspace: new Set(["workspace", "stateDir"]),
|
|
28
27
|
service: new Set(["workspace", "stateDir", "quiet"]),
|
|
29
28
|
autostart: new Set(["workspace", "stateDir", "quiet"]),
|
|
30
29
|
resource: new Set(["workspace", "stateDir", "allowInsecurePermissions", "showPaths", "json"]),
|
|
30
|
+
account: new Set(["workspace", "stateDir", "json", "yes"]),
|
|
31
31
|
browser: new Set(["workspace", "stateDir", "json"]),
|
|
32
32
|
job: new Set(["workspace", "stateDir", "json", "yes"]),
|
|
33
33
|
uninstall: new Set(["stateDir", "keepWorker", "yes"]),
|
|
@@ -40,6 +40,7 @@ const STATIC_POSITIONAL_RULES = Object.freeze({
|
|
|
40
40
|
});
|
|
41
41
|
const RESOURCE_POSITIONAL_LIMITS = Object.freeze({ add: 3, "generate-ssh-key": 3, remove: 2, check: 2 });
|
|
42
42
|
const JOB_POSITIONAL_LIMITS = Object.freeze({ read: 2, inspect: 2, cancel: 2, approve: 2, submit: 2 });
|
|
43
|
+
const ACCOUNT_POSITIONAL_LIMITS = Object.freeze({ list: 1, add: 3, role: 3, enable: 2, disable: 2, "rotate-password": 2, remove: 2 });
|
|
43
44
|
const ACTION_POSITIONAL_RULES = Object.freeze({
|
|
44
45
|
workspace(args) {
|
|
45
46
|
const action = String(args._[0] || "show");
|
|
@@ -54,6 +55,10 @@ const ACTION_POSITIONAL_RULES = Object.freeze({
|
|
|
54
55
|
const action = String(args._[0] || "list");
|
|
55
56
|
return { max: RESOURCE_POSITIONAL_LIMITS[action] ?? 1, tooMany: `resource ${action} received too many positional arguments` };
|
|
56
57
|
},
|
|
58
|
+
account(args) {
|
|
59
|
+
const action = String(args._[0] || "list");
|
|
60
|
+
return { max: ACCOUNT_POSITIONAL_LIMITS[action] ?? 1, tooMany: `account ${action} received too many positional arguments` };
|
|
61
|
+
},
|
|
57
62
|
browser(args) {
|
|
58
63
|
const action = String(args._[0] || "status");
|
|
59
64
|
return { max: 1, tooMany: `browser ${action} received too many positional arguments` };
|
package/src/local/cli-policy.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
DEFAULT_POLICY_PROFILE,
|
|
3
3
|
DEFAULT_POLICY_REVISION,
|
|
4
|
+
POLICY_ORIGINS,
|
|
4
5
|
POLICY_PROFILES,
|
|
5
6
|
normalizePolicy,
|
|
6
7
|
policyProfile,
|
|
@@ -9,9 +10,7 @@ import {
|
|
|
9
10
|
const POLICY_OVERRIDE_KEYS = Object.freeze(["execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths"]);
|
|
10
11
|
|
|
11
12
|
export function resolvePolicy(args = {}, stored = {}) {
|
|
12
|
-
const hasStored = stored && typeof stored === "object" &&
|
|
13
|
-
typeof stored.allowWrite === "boolean" || typeof stored.allowExec === "boolean" || typeof stored.execMode === "string"
|
|
14
|
-
);
|
|
13
|
+
const hasStored = stored && typeof stored === "object" && typeof stored.profile === "string";
|
|
15
14
|
const explicitKeys = ["profile", ...POLICY_OVERRIDE_KEYS];
|
|
16
15
|
const hasExplicit = explicitKeys.some((key) => Object.prototype.hasOwnProperty.call(args, key));
|
|
17
16
|
const base = { ...selectPolicyBase(args, stored, hasStored) };
|
|
@@ -26,8 +25,6 @@ export function resolvePolicy(args = {}, stored = {}) {
|
|
|
26
25
|
}
|
|
27
26
|
|
|
28
27
|
function policyState(policy) {
|
|
29
|
-
// Capability fields retain the canonical immutable contract. The CLI owns a
|
|
30
|
-
// sealed persistence record with exactly one writable metadata field.
|
|
31
28
|
const normalized = normalizePolicy(policy);
|
|
32
29
|
const state = {};
|
|
33
30
|
for (const [key, value] of Object.entries(normalized)) {
|
|
@@ -43,8 +40,11 @@ function selectPolicyBase(args, stored, hasStored) {
|
|
|
43
40
|
if (!POLICY_PROFILES[profile]) throw new Error(`--profile must be one of: ${Object.keys(POLICY_PROFILES).join(", ")}`);
|
|
44
41
|
return policyProfile(profile, "explicit");
|
|
45
42
|
}
|
|
46
|
-
if (hasStored) return
|
|
47
|
-
|
|
43
|
+
if (!hasStored) return policyProfile(DEFAULT_POLICY_PROFILE, "default");
|
|
44
|
+
if (stored.revision !== DEFAULT_POLICY_REVISION || !POLICY_ORIGINS.has(stored.origin)) {
|
|
45
|
+
throw new Error("stored policy schema is obsolete; remove the state root and initialize the current version");
|
|
46
|
+
}
|
|
47
|
+
return normalizePolicy(stored);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
function applyPolicyOverrides(policy, args) {
|
|
@@ -66,24 +66,3 @@ function applyPolicyOverrides(policy, args) {
|
|
|
66
66
|
function applyBooleanOverride(args, key, apply) {
|
|
67
67
|
if (typeof args[key] === "boolean") apply(args[key]);
|
|
68
68
|
}
|
|
69
|
-
|
|
70
|
-
function migrateLegacyPolicy(stored = {}) {
|
|
71
|
-
if (stored.origin === "default" && Number(stored.revision || 0) < DEFAULT_POLICY_REVISION) {
|
|
72
|
-
return policyProfile(DEFAULT_POLICY_PROFILE, "default");
|
|
73
|
-
}
|
|
74
|
-
if (stored.origin === "migrated" && Number(stored.revision || 0) < DEFAULT_POLICY_REVISION) {
|
|
75
|
-
return policyProfile(DEFAULT_POLICY_PROFILE, "migrated");
|
|
76
|
-
}
|
|
77
|
-
if (stored.origin) return normalizePolicy(stored);
|
|
78
|
-
const normalized = normalizePolicy(stored);
|
|
79
|
-
const looksLikeLegacyImplicitDefault = (
|
|
80
|
-
normalized.profile === "custom"
|
|
81
|
-
&& normalized.allowWrite === true
|
|
82
|
-
&& normalized.execMode === "shell"
|
|
83
|
-
&& normalized.unrestrictedPaths === false
|
|
84
|
-
&& normalized.minimalEnv === true
|
|
85
|
-
&& normalized.exposeAbsolutePaths === false
|
|
86
|
-
);
|
|
87
|
-
if (looksLikeLegacyImplicitDefault) return policyProfile("full", "migrated");
|
|
88
|
-
return normalizePolicy({ ...normalized, origin: "legacy-preserved", revision: DEFAULT_POLICY_REVISION });
|
|
89
|
-
}
|