machine-bridge-mcp 0.14.0 → 0.16.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 +39 -0
- package/README.md +18 -8
- package/SECURITY.md +10 -3
- package/browser-extension/browser-operations.js +515 -0
- package/browser-extension/devtools-input.js +17 -2
- package/browser-extension/manifest.json +2 -2
- package/browser-extension/page-automation.js +245 -60
- package/browser-extension/pairing.js +1 -1
- package/browser-extension/service-worker.js +151 -354
- package/docs/ARCHITECTURE.md +6 -4
- package/docs/AUDIT.md +24 -1
- package/docs/LOCAL_AUTOMATION.md +10 -10
- package/docs/LOGGING.md +7 -1
- package/docs/OPERATIONS.md +13 -5
- package/docs/POLICY_REFERENCE.md +88 -0
- package/docs/TESTING.md +9 -2
- package/package.json +16 -3
- package/scripts/coverage-check.mjs +108 -0
- package/scripts/generate-policy-reference.mjs +95 -0
- package/src/local/agent-context.mjs +1 -103
- package/src/local/app-automation.mjs +7 -9
- package/src/local/browser-bridge.mjs +69 -143
- package/src/local/browser-extension-protocol.mjs +75 -0
- package/src/local/browser-pairing-store.mjs +83 -0
- package/src/local/call-registry.mjs +113 -0
- package/src/local/capability-ranking.mjs +103 -0
- package/src/local/cli-local-admin.mjs +308 -0
- package/src/local/cli-options.mjs +151 -0
- package/src/local/cli-policy.mjs +77 -0
- package/src/local/cli.mjs +16 -507
- package/src/local/errors.mjs +122 -0
- package/src/local/git-service.mjs +88 -0
- package/src/local/lifecycle.mjs +64 -0
- package/src/local/log.mjs +50 -19
- package/src/local/managed-job-plan.mjs +235 -0
- package/src/local/managed-jobs.mjs +16 -220
- package/src/local/observability.mjs +83 -0
- package/src/local/policy.mjs +148 -0
- package/src/local/process-execution.mjs +153 -0
- package/src/local/process-sessions.mjs +10 -20
- package/src/local/process-tracker.mjs +55 -0
- package/src/local/runtime.mjs +154 -672
- package/src/local/service.mjs +8 -3
- package/src/local/stdio.mjs +3 -11
- package/src/local/tool-executor.mjs +102 -0
- package/src/local/tools.mjs +21 -104
- package/src/local/workspace-file-service.mjs +451 -0
- package/src/shared/policy-contract.json +54 -0
- package/src/shared/tool-catalog.json +11 -11
- package/src/worker/index.ts +69 -524
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
const ERROR_CODES = new Set([
|
|
2
|
+
"cancelled", "timeout", "authentication_failed", "authorization_denied", "policy_denied",
|
|
3
|
+
"invalid_request", "not_found", "conflict", "limit_exceeded", "permission_denied",
|
|
4
|
+
"path_boundary", "network_error", "protocol_error", "unavailable", "integrity_error",
|
|
5
|
+
"execution_failed", "internal_error",
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
const RETRYABLE_CODES = new Set(["timeout", "network_error", "unavailable"]);
|
|
9
|
+
|
|
10
|
+
export class BridgeError extends Error {
|
|
11
|
+
constructor(code, message, options = {}) {
|
|
12
|
+
const normalizedCode = normalizeErrorCode(code);
|
|
13
|
+
super(String(message || defaultMessage(normalizedCode)), options.cause ? { cause: options.cause } : undefined);
|
|
14
|
+
this.name = "BridgeError";
|
|
15
|
+
this.code = normalizedCode;
|
|
16
|
+
this.retryable = options.retryable === undefined ? RETRYABLE_CODES.has(normalizedCode) : options.retryable === true;
|
|
17
|
+
this.expose = options.expose !== false;
|
|
18
|
+
this.details = isRecord(options.details) ? Object.freeze({ ...options.details }) : undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function bridgeError(code, message, options) {
|
|
23
|
+
return new BridgeError(code, message, options);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function normalizeBridgeError(error, options = {}) {
|
|
27
|
+
if (error instanceof BridgeError) return error;
|
|
28
|
+
const code = errorCode(error, options.defaultCode);
|
|
29
|
+
const message = typeof options.safeMessage === "function"
|
|
30
|
+
? options.safeMessage(error)
|
|
31
|
+
: typeof options.safeMessage === "string"
|
|
32
|
+
? options.safeMessage
|
|
33
|
+
: safeFallbackMessage(error, code);
|
|
34
|
+
return new BridgeError(code, message, {
|
|
35
|
+
cause: error instanceof Error ? error : undefined,
|
|
36
|
+
expose: options.expose !== false,
|
|
37
|
+
retryable: options.retryable,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function errorCode(error, fallback = "execution_failed") {
|
|
42
|
+
if (error instanceof BridgeError) return error.code;
|
|
43
|
+
const direct = normalizeErrorCode(error?.code, "");
|
|
44
|
+
if (direct && ERROR_CODES.has(direct)) return direct;
|
|
45
|
+
const nodeCode = String(error?.code || "").toUpperCase();
|
|
46
|
+
if (["ENOENT", "ENOTDIR"].includes(nodeCode)) return "not_found";
|
|
47
|
+
if (["EACCES", "EPERM", "EROFS"].includes(nodeCode)) return "permission_denied";
|
|
48
|
+
if (["ETIMEDOUT", "ESOCKETTIMEDOUT"].includes(nodeCode)) return "timeout";
|
|
49
|
+
if (["ECONNRESET", "ECONNREFUSED", "EHOSTUNREACH", "ENETUNREACH", "ENOTFOUND", "EAI_AGAIN"].includes(nodeCode)) return "network_error";
|
|
50
|
+
if (["EEXIST", "ENOTEMPTY", "EBUSY"].includes(nodeCode)) return "conflict";
|
|
51
|
+
|
|
52
|
+
// Legacy errors are classified once at the boundary. New code should throw BridgeError directly.
|
|
53
|
+
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
54
|
+
if (/cancel/i.test(message)) return "cancelled";
|
|
55
|
+
if (/timed out/i.test(message)) return "timeout";
|
|
56
|
+
if (/unauthorized|authentication|\b401\b/i.test(message)) return "authentication_failed";
|
|
57
|
+
if (/forbidden|authorization|\b403\b/i.test(message)) return "authorization_denied";
|
|
58
|
+
if (/outside the configured workspace/i.test(message)) return "path_boundary";
|
|
59
|
+
if (/disabled|requires .* mode|requires the canonical .* profile/i.test(message)) return "policy_denied";
|
|
60
|
+
if (/not found|ENOENT/i.test(message)) return "not_found";
|
|
61
|
+
if (/permission|EACCES|EPERM/i.test(message)) return "permission_denied";
|
|
62
|
+
if (/maximum|exceeds|max_bytes|too many|limit reached/i.test(message)) return "limit_exceeded";
|
|
63
|
+
if (/integrity|hash mismatch|changed during/i.test(message)) return "integrity_error";
|
|
64
|
+
if (/protocol|unexpected .* message|invalid .* envelope/i.test(message)) return "protocol_error";
|
|
65
|
+
if (/invalid|must|requires|ambiguous|mismatch/i.test(message)) return "invalid_request";
|
|
66
|
+
return normalizeErrorCode(fallback);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function publicError(error, options = {}) {
|
|
70
|
+
const normalized = normalizeBridgeError(error, options);
|
|
71
|
+
return {
|
|
72
|
+
code: normalized.code,
|
|
73
|
+
message: normalized.expose ? normalized.message : defaultMessage(normalized.code),
|
|
74
|
+
retryable: normalized.retryable,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function remoteBridgeError(value, fallbackMessage = "remote operation failed") {
|
|
79
|
+
if (isRecord(value)) {
|
|
80
|
+
const code = normalizeErrorCode(value.code);
|
|
81
|
+
const message = typeof value.message === "string" && value.message ? value.message : fallbackMessage;
|
|
82
|
+
return new BridgeError(code, message, { retryable: value.retryable === true });
|
|
83
|
+
}
|
|
84
|
+
return new BridgeError("execution_failed", fallbackMessage);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function normalizeErrorCode(value, fallback = "execution_failed") {
|
|
88
|
+
const code = String(value || "").trim().toLowerCase().replace(/[^a-z0-9_-]/g, "_").slice(0, 64);
|
|
89
|
+
return ERROR_CODES.has(code) ? code : fallback;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function safeFallbackMessage(error, code) {
|
|
93
|
+
if (error instanceof Error && error.message) return String(error.message).slice(0, 2000);
|
|
94
|
+
return defaultMessage(code);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function defaultMessage(code) {
|
|
98
|
+
const messages = {
|
|
99
|
+
cancelled: "operation cancelled",
|
|
100
|
+
timeout: "operation timed out",
|
|
101
|
+
authentication_failed: "authentication failed",
|
|
102
|
+
authorization_denied: "authorization denied",
|
|
103
|
+
policy_denied: "operation denied by policy",
|
|
104
|
+
invalid_request: "invalid request",
|
|
105
|
+
not_found: "requested resource was not found",
|
|
106
|
+
conflict: "operation conflicts with current state",
|
|
107
|
+
limit_exceeded: "operation exceeded a configured limit",
|
|
108
|
+
permission_denied: "permission denied",
|
|
109
|
+
path_boundary: "path is outside the configured boundary",
|
|
110
|
+
network_error: "network operation failed",
|
|
111
|
+
protocol_error: "protocol error",
|
|
112
|
+
unavailable: "service is unavailable",
|
|
113
|
+
integrity_error: "integrity check failed",
|
|
114
|
+
internal_error: "internal error",
|
|
115
|
+
execution_failed: "operation failed",
|
|
116
|
+
};
|
|
117
|
+
return messages[code] || messages.execution_failed;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isRecord(value) {
|
|
121
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
122
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, relative, sep } from "node:path";
|
|
3
|
+
import { BridgeError } from "./errors.mjs";
|
|
4
|
+
|
|
5
|
+
export class GitService {
|
|
6
|
+
constructor({ resolveExistingPath, displayPath, runProcess, maximumBytes }) {
|
|
7
|
+
this.resolveExistingPath = resolveExistingPath;
|
|
8
|
+
this.displayPath = displayPath;
|
|
9
|
+
this.runProcess = runProcess;
|
|
10
|
+
this.maximumBytes = maximumBytes;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async status(args = {}, context = {}) {
|
|
14
|
+
const git = await this.context(args.path || ".", context);
|
|
15
|
+
if (!git.ok) return git.result;
|
|
16
|
+
const commandArgs = ["-c", "core.fsmonitor=false", "-C", git.root, "status", "--short", "--branch"];
|
|
17
|
+
if (git.pathspec) commandArgs.push("--", git.pathspec);
|
|
18
|
+
const result = await this.runProcess("git", commandArgs, 30_000, true, 512 * 1024, context);
|
|
19
|
+
return { ...result, path: this.displayPath(git.target), gitRoot: this.displayPath(git.root) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async diff(args = {}, context = {}) {
|
|
23
|
+
const maxBytes = clampInt(args.max_bytes, 1024 * 1024, 1, this.maximumBytes);
|
|
24
|
+
const git = await this.context(args.path || ".", context);
|
|
25
|
+
if (!git.ok) return { ...git.result, path: this.displayPath(git.target) };
|
|
26
|
+
const commandArgs = ["-c", "core.fsmonitor=false", "-c", "diff.external=", "-C", git.root, "diff", "--no-ext-diff", "--no-textconv"];
|
|
27
|
+
if (args.staged) commandArgs.push("--cached");
|
|
28
|
+
if (git.pathspec) commandArgs.push("--", git.pathspec);
|
|
29
|
+
const result = await this.runProcess("git", commandArgs, 60_000, true, maxBytes, context);
|
|
30
|
+
return { ...result, path: this.displayPath(git.target), gitRoot: this.displayPath(git.root), staged: args.staged === true };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async log(args = {}, context = {}) {
|
|
34
|
+
const git = await this.context(args.path || ".", context);
|
|
35
|
+
if (!git.ok) return { ...git.result, path: this.displayPath(git.target) };
|
|
36
|
+
const maxCount = clampInt(args.max_count, 20, 1, 100);
|
|
37
|
+
const format = "%H%x1f%h%x1f%aI%x1f%an%x1f%ae%x1f%s%x1e";
|
|
38
|
+
const commandArgs = ["-c", "core.fsmonitor=false", "-C", git.root, "log", `--max-count=${maxCount}`, `--format=${format}`];
|
|
39
|
+
if (git.pathspec) commandArgs.push("--", git.pathspec);
|
|
40
|
+
const result = await this.runProcess("git", commandArgs, 30_000, true, 1024 * 1024, context);
|
|
41
|
+
const commits = result.stdout.split("\x1e").map((record) => record.trim()).filter(Boolean).map((record) => {
|
|
42
|
+
const [hash, short, authored_at, author_name, author_email, subject] = record.split("\x1f");
|
|
43
|
+
const commit = { hash, short, authored_at, author_name, subject };
|
|
44
|
+
if (args.include_author_email === true) commit.author_email = author_email;
|
|
45
|
+
return commit;
|
|
46
|
+
});
|
|
47
|
+
return { code: result.code, stderr: result.stderr, commits, path: this.displayPath(git.target), gitRoot: this.displayPath(git.root) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async show(args = {}, context = {}) {
|
|
51
|
+
const git = await this.context(args.path || ".", context);
|
|
52
|
+
if (!git.ok) return { ...git.result, path: this.displayPath(git.target) };
|
|
53
|
+
const revision = validateRevision(args.revision || "HEAD");
|
|
54
|
+
const maxBytes = clampInt(args.max_bytes, 1024 * 1024, 1, this.maximumBytes);
|
|
55
|
+
const commandArgs = ["-c", "core.fsmonitor=false", "-c", "diff.external=", "-C", git.root, "show", "--no-ext-diff", "--no-textconv", "--decorate=no", revision];
|
|
56
|
+
if (git.pathspec) commandArgs.push("--", git.pathspec);
|
|
57
|
+
const result = await this.runProcess("git", commandArgs, 60_000, true, maxBytes, context);
|
|
58
|
+
return { ...result, revision, path: this.displayPath(git.target), gitRoot: this.displayPath(git.root) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async context(inputPath, context = {}) {
|
|
62
|
+
const target = await this.resolveExistingPath(inputPath);
|
|
63
|
+
const info = await stat(target);
|
|
64
|
+
const cwd = info.isDirectory() ? target : dirname(target);
|
|
65
|
+
const result = await this.runProcess("git", ["-c", "core.fsmonitor=false", "-C", cwd, "rev-parse", "--show-toplevel"], 10_000, true, 512 * 1024, context);
|
|
66
|
+
if (result.code !== 0) return { ok: false, result, target };
|
|
67
|
+
const root = result.stdout.trim();
|
|
68
|
+
const repoRelative = relative(root, target);
|
|
69
|
+
if (repoRelative.startsWith(`..${sep}`) || repoRelative === ".." || isAbsolute(repoRelative)) {
|
|
70
|
+
return { ok: false, target, result: { code: 128, stdout: "", stderr: "target is outside the detected git repository" } };
|
|
71
|
+
}
|
|
72
|
+
return { ok: true, target, root, pathspec: repoRelative || "" };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function validateRevision(value) {
|
|
77
|
+
const revision = String(value || "HEAD");
|
|
78
|
+
if (!revision || revision.length > 256 || revision.startsWith("-") || revision.includes("\0") || /[\r\n]/.test(revision)) {
|
|
79
|
+
throw new BridgeError("invalid_request", "invalid Git revision");
|
|
80
|
+
}
|
|
81
|
+
return revision;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function clampInt(value, fallback, minimum, maximum) {
|
|
85
|
+
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
86
|
+
const number = Number.isFinite(parsed) ? parsed : fallback;
|
|
87
|
+
return Math.min(Math.max(number, minimum), maximum);
|
|
88
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { BridgeError } from "./errors.mjs";
|
|
2
|
+
|
|
3
|
+
const OPERATIONAL_STATES = new Set(["ready", "starting", "running"]);
|
|
4
|
+
|
|
5
|
+
export class LifecycleController {
|
|
6
|
+
constructor(name = "runtime", now = Date.now) {
|
|
7
|
+
this.name = String(name || "runtime");
|
|
8
|
+
this.now = typeof now === "function" ? now : Date.now;
|
|
9
|
+
this.state = "ready";
|
|
10
|
+
this.changedAt = this.now();
|
|
11
|
+
this.failureCode = "";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
beginStart() {
|
|
15
|
+
if (this.state === "running") return false;
|
|
16
|
+
if (this.state !== "ready" && this.state !== "failed") {
|
|
17
|
+
throw new BridgeError("conflict", `${this.name} cannot start from state ${this.state}`);
|
|
18
|
+
}
|
|
19
|
+
this.transition("starting");
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
markRunning() {
|
|
24
|
+
if (this.state !== "starting") throw new BridgeError("conflict", `${this.name} cannot become running from state ${this.state}`);
|
|
25
|
+
this.failureCode = "";
|
|
26
|
+
this.transition("running");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
markFailed(error) {
|
|
30
|
+
this.failureCode = String(error?.code || error?.name || "execution_failed").slice(0, 64);
|
|
31
|
+
this.transition("failed");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
beginStop() {
|
|
35
|
+
if (this.state === "stopped" || this.state === "stopping") return false;
|
|
36
|
+
this.transition("stopping");
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
markStopped() {
|
|
41
|
+
if (this.state !== "stopping") throw new BridgeError("conflict", `${this.name} cannot become stopped from state ${this.state}`);
|
|
42
|
+
this.transition("stopped");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
assertOperational() {
|
|
46
|
+
if (!OPERATIONAL_STATES.has(this.state)) {
|
|
47
|
+
throw new BridgeError("unavailable", `${this.name} is not operational (${this.state})`, { retryable: this.state !== "stopped" });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
snapshot() {
|
|
52
|
+
return {
|
|
53
|
+
state: this.state,
|
|
54
|
+
operational: OPERATIONAL_STATES.has(this.state),
|
|
55
|
+
changed_at_ms: this.changedAt,
|
|
56
|
+
failure_code: this.failureCode,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
transition(next) {
|
|
61
|
+
this.state = next;
|
|
62
|
+
this.changedAt = this.now();
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/local/log.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import process from "node:process";
|
|
2
2
|
import os from "node:os";
|
|
3
|
+
import { errorCode } from "./errors.mjs";
|
|
3
4
|
|
|
4
5
|
const COLORS = {
|
|
5
6
|
reset: "\x1b[0m",
|
|
@@ -40,7 +41,8 @@ export function createLogger(options = {}) {
|
|
|
40
41
|
const minimumLevel = normalizeLogLevel(options.level || (quiet ? "error" : verbose ? "debug" : "info"));
|
|
41
42
|
const component = sanitizeLogText(options.component ? String(options.component) : "cli", 128);
|
|
42
43
|
const useColor = shouldUseColor(options);
|
|
43
|
-
const
|
|
44
|
+
const stderr = options.stderr || process.stderr;
|
|
45
|
+
const stdout = options.stderrOnly ? stderr : (options.stdout || process.stdout);
|
|
44
46
|
|
|
45
47
|
const write = (stream, level, label, color, message, fields) => {
|
|
46
48
|
if (LEVEL_RANK[level] < LEVEL_RANK[minimumLevel]) return;
|
|
@@ -49,20 +51,50 @@ export function createLogger(options = {}) {
|
|
|
49
51
|
stream.write(`${prefix} ${component}: ${sanitizeLogText(message, MAX_LOG_MESSAGE_CHARS)}${suffix}\n`);
|
|
50
52
|
};
|
|
51
53
|
|
|
54
|
+
const event = (level, name, fields = {}, message = "") => {
|
|
55
|
+
const normalizedLevel = normalizeEventLevel(level);
|
|
56
|
+
const eventName = sanitizeEventName(name);
|
|
57
|
+
const payload = { event: eventName, ...fields };
|
|
58
|
+
const humanMessage = message || eventName;
|
|
59
|
+
if (options.format === "json") {
|
|
60
|
+
if (LEVEL_RANK[normalizedLevel] < LEVEL_RANK[minimumLevel]) return;
|
|
61
|
+
const entry = sanitizeLogValue({
|
|
62
|
+
timestamp: new Date().toISOString(),
|
|
63
|
+
level: normalizedLevel === "success" ? "info" : normalizedLevel,
|
|
64
|
+
component,
|
|
65
|
+
message: sanitizeLogText(humanMessage, MAX_LOG_MESSAGE_CHARS),
|
|
66
|
+
...payload,
|
|
67
|
+
});
|
|
68
|
+
const target = normalizedLevel === "info" || normalizedLevel === "success" ? stdout : stderr;
|
|
69
|
+
target.write(`${JSON.stringify(entry)}\n`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const methods = {
|
|
73
|
+
debug: [stderr, "[debug]", COLORS.gray],
|
|
74
|
+
info: [stdout, "[info]", COLORS.blue],
|
|
75
|
+
success: [stdout, "[ok]", COLORS.green],
|
|
76
|
+
warn: [stderr, "[warn]", COLORS.yellow],
|
|
77
|
+
error: [stderr, "[error]", COLORS.red],
|
|
78
|
+
};
|
|
79
|
+
const [stream, label, color] = methods[normalizedLevel];
|
|
80
|
+
write(stream, normalizedLevel, label, color, humanMessage, payload);
|
|
81
|
+
};
|
|
82
|
+
|
|
52
83
|
return {
|
|
53
84
|
verbose: minimumLevel === "debug",
|
|
54
85
|
level: minimumLevel,
|
|
55
86
|
child(childComponent) {
|
|
56
87
|
return createLogger({ ...options, component: childComponent });
|
|
57
88
|
},
|
|
89
|
+
event,
|
|
58
90
|
info(message, fields) { write(stdout, "info", "[info]", COLORS.blue, message, fields); },
|
|
59
91
|
success(message, fields) { write(stdout, "success", "[ok]", COLORS.green, message, fields); },
|
|
60
|
-
warn(message, fields) { write(
|
|
61
|
-
error(message, fields) { write(
|
|
62
|
-
debug(message, fields) { write(
|
|
63
|
-
plain(message = "") { if (!quiet)
|
|
64
|
-
safePlain(message = "") { if (!quiet)
|
|
65
|
-
json(value) {
|
|
92
|
+
warn(message, fields) { write(stderr, "warn", "[warn]", COLORS.yellow, message, fields); },
|
|
93
|
+
error(message, fields) { write(stderr, "error", "[error]", COLORS.red, message, fields); },
|
|
94
|
+
debug(message, fields) { write(stderr, "debug", "[debug]", COLORS.gray, message, fields); },
|
|
95
|
+
plain(message = "") { if (!quiet) stdout.write(`${String(message)}\n`); },
|
|
96
|
+
safePlain(message = "") { if (!quiet) stdout.write(`${sanitizeLogText(message, MAX_LOG_MESSAGE_CHARS)}\n`); },
|
|
97
|
+
json(value) { stdout.write(`${JSON.stringify(value, null, 2)}\n`); },
|
|
66
98
|
};
|
|
67
99
|
}
|
|
68
100
|
|
|
@@ -76,18 +108,7 @@ export function normalizeLogLevel(value = "info") {
|
|
|
76
108
|
|
|
77
109
|
|
|
78
110
|
export function classifyOperationalError(error) {
|
|
79
|
-
|
|
80
|
-
if (/cancel/i.test(message)) return "cancelled";
|
|
81
|
-
if (/timed out/i.test(message)) return "timeout";
|
|
82
|
-
if (/unauthorized|forbidden|authentication|\b401\b|\b403\b/i.test(message)) return "authentication_failed";
|
|
83
|
-
if (/ECONNRESET|ECONNREFUSED|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|ENOTFOUND|EAI_AGAIN|socket hang up|network|websocket|TLS|SSL/i.test(message)) return "network_error";
|
|
84
|
-
if (/outside the configured workspace/i.test(message)) return "path_boundary";
|
|
85
|
-
if (/disabled|requires .* mode/i.test(message)) return "policy_denied";
|
|
86
|
-
if (/not found|ENOENT/i.test(message)) return "not_found";
|
|
87
|
-
if (/permission|EACCES|EPERM/i.test(message)) return "permission_denied";
|
|
88
|
-
if (/maximum|exceeds|max_bytes|too many/i.test(message)) return "limit_exceeded";
|
|
89
|
-
if (/invalid|must|requires|ambiguous|mismatch/i.test(message)) return "invalid_request";
|
|
90
|
-
return "execution_failed";
|
|
111
|
+
return errorCode(error);
|
|
91
112
|
}
|
|
92
113
|
|
|
93
114
|
export function formatFields(fields) {
|
|
@@ -162,3 +183,13 @@ function shouldUseColor(options) {
|
|
|
162
183
|
if (options.color === true) return true;
|
|
163
184
|
return Boolean(process.stderr.isTTY || process.stdout.isTTY);
|
|
164
185
|
}
|
|
186
|
+
|
|
187
|
+
function normalizeEventLevel(value) {
|
|
188
|
+
const level = String(value || "info").toLowerCase();
|
|
189
|
+
return Object.prototype.hasOwnProperty.call(LEVEL_RANK, level) ? level : "info";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function sanitizeEventName(value) {
|
|
193
|
+
const name = String(value || "event").toLowerCase().replace(/[^a-z0-9._-]/g, "_").slice(0, 128);
|
|
194
|
+
return name || "event";
|
|
195
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstatSync, realpathSync, statSync } from "node:fs";
|
|
3
|
+
import { basename, isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { readBoundedRegularFileWithInfoSync } from "./secure-file.mjs";
|
|
5
|
+
|
|
6
|
+
const RESOURCE_NAME = /^[a-z][a-z0-9._-]{0,63}$/;
|
|
7
|
+
const RESOURCE_TOKEN = /\{\{resource:([a-z][a-z0-9._-]{0,63})\}\}/g;
|
|
8
|
+
const MAX_RESOURCE_BYTES = 1024 * 1024;
|
|
9
|
+
const MAX_JOB_RESOURCE_BYTES = 8 * 1024 * 1024;
|
|
10
|
+
const MAX_RESOURCES = 64;
|
|
11
|
+
const MAX_TEMPORARY_FILE_BYTES = 512 * 1024;
|
|
12
|
+
|
|
13
|
+
export function validateResourceName(value) {
|
|
14
|
+
const name = String(value || "").trim();
|
|
15
|
+
if (!RESOURCE_NAME.test(name)) throw new Error("resource name must match [a-z][a-z0-9._-]{0,63}");
|
|
16
|
+
return name;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function inspectResourceFile(inputPath, { allowInsecurePermissions = false, includeHash = false } = {}) {
|
|
20
|
+
const path = resolve(String(inputPath || ""));
|
|
21
|
+
const canonical = realpathFile(path);
|
|
22
|
+
const { buffer: content, info } = readBoundedRegularFileWithInfoSync(canonical, MAX_RESOURCE_BYTES);
|
|
23
|
+
if (process.platform !== "win32" && !allowInsecurePermissions && (info.mode & 0o077) !== 0) {
|
|
24
|
+
throw new Error("resource file is readable by group or others; restrict permissions or use --allow-insecure-permissions");
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
kind: "file",
|
|
28
|
+
path: canonical,
|
|
29
|
+
pathAliases: normalizeResourcePathAliases([path, canonical]),
|
|
30
|
+
size: info.size,
|
|
31
|
+
mode: process.platform === "win32" ? null : `0${(info.mode & 0o777).toString(8)}`,
|
|
32
|
+
updatedAt: new Date().toISOString(),
|
|
33
|
+
allowInsecurePermissions: allowInsecurePermissions === true,
|
|
34
|
+
...(includeHash ? { sha256: createHash("sha256").update(content).digest("hex") } : {}),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function publicResourceRegistry(resources = {}, { includePaths = false } = {}) {
|
|
39
|
+
const normalized = normalizeResourceRegistry(resources);
|
|
40
|
+
return Object.fromEntries(Object.entries(normalized).map(([name, value]) => [name, {
|
|
41
|
+
kind: value.kind,
|
|
42
|
+
size: value.size ?? null,
|
|
43
|
+
mode: value.mode ?? null,
|
|
44
|
+
updatedAt: value.updatedAt ?? null,
|
|
45
|
+
allowInsecurePermissions: value.allowInsecurePermissions === true,
|
|
46
|
+
paths_exposed: includePaths,
|
|
47
|
+
...(includePaths ? { path: value.path } : {}),
|
|
48
|
+
}]));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function validatePlan(args, context) {
|
|
52
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) throw new Error("job arguments must be an object");
|
|
53
|
+
const allowed = new Set(["name", "steps", "finally_steps", "temporary_files"]);
|
|
54
|
+
for (const key of Object.keys(args)) if (!allowed.has(key)) throw new Error(`job contains unknown field: ${key}`);
|
|
55
|
+
const name = String(args.name || "managed job").trim().slice(0, 128) || "managed job";
|
|
56
|
+
const steps = validateSteps(args.steps, "steps", context);
|
|
57
|
+
const finallySteps = validateSteps(args.finally_steps || [], "finally_steps", context, true);
|
|
58
|
+
const temporaryFiles = validateTemporaryFiles(args.temporary_files || []);
|
|
59
|
+
if (!steps.length) throw new Error("steps must contain at least one step");
|
|
60
|
+
return {
|
|
61
|
+
version: 1,
|
|
62
|
+
name,
|
|
63
|
+
workspace: context.workspace,
|
|
64
|
+
full_env: context.fullEnv,
|
|
65
|
+
resources: referencedResources([...steps, ...finallySteps], context.resources),
|
|
66
|
+
temporary_files: temporaryFiles,
|
|
67
|
+
steps,
|
|
68
|
+
finally_steps: finallySteps,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function validateTemporaryFiles(value) {
|
|
73
|
+
if (!Array.isArray(value) || value.length > 16) throw new Error("temporary_files must contain 0-16 files");
|
|
74
|
+
const seen = new Set();
|
|
75
|
+
let totalBytes = 0;
|
|
76
|
+
return value.map((item, index) => {
|
|
77
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error(`temporary_files[${index}] must be an object`);
|
|
78
|
+
for (const key of Object.keys(item)) if (!["name", "content", "executable"].includes(key)) throw new Error(`temporary_files[${index}] contains unknown field: ${key}`);
|
|
79
|
+
const name = validateResourceName(item.name);
|
|
80
|
+
if (seen.has(name)) throw new Error(`duplicate temporary file name: ${name}`);
|
|
81
|
+
seen.add(name);
|
|
82
|
+
const content = boundedString(item.content, 256 * 1024, `temporary_files[${index}].content`);
|
|
83
|
+
totalBytes += Buffer.byteLength(content);
|
|
84
|
+
if (totalBytes > MAX_TEMPORARY_FILE_BYTES) throw new Error(`temporary file contents exceed ${MAX_TEMPORARY_FILE_BYTES} bytes`);
|
|
85
|
+
return { name, content, executable: item.executable === true };
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function validateSteps(value, label, context, allowEmpty = false) {
|
|
90
|
+
if (!Array.isArray(value) || (!allowEmpty && value.length === 0) || value.length > 16) {
|
|
91
|
+
throw new Error(`${label} must contain ${allowEmpty ? "0-16" : "1-16"} steps`);
|
|
92
|
+
}
|
|
93
|
+
return value.map((input, index) => {
|
|
94
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error(`${label}[${index}] must be an object`);
|
|
95
|
+
const allowed = new Set(["name", "argv", "cwd", "env", "env_resources", "stdin", "stdin_resource", "timeout_seconds", "allow_failure", "capture_output"]);
|
|
96
|
+
for (const key of Object.keys(input)) if (!allowed.has(key)) throw new Error(`${label}[${index}] contains unknown field: ${key}`);
|
|
97
|
+
if (!Array.isArray(input.argv) || !input.argv.length || input.argv.length > 256) throw new Error(`${label}[${index}].argv must contain 1-256 strings`);
|
|
98
|
+
const argv = input.argv.map((item) => boundedString(item, 16 * 1024, `${label}[${index}].argv`));
|
|
99
|
+
if (Buffer.byteLength(JSON.stringify(argv)) > 64 * 1024) throw new Error(`${label}[${index}].argv exceeds 64 KiB`);
|
|
100
|
+
const cwd = input.cwd === undefined ? context.workspace : resolveJobCwd(input.cwd, context.workspace, context.unrestrictedPaths);
|
|
101
|
+
const env = validateEnv(input.env, `${label}[${index}].env`);
|
|
102
|
+
const envResources = validateEnvResources(input.env_resources, `${label}[${index}].env_resources`);
|
|
103
|
+
for (const key of Object.keys(envResources)) if (Object.prototype.hasOwnProperty.call(env, key)) throw new Error(`${label}[${index}] duplicates ${key} in env and env_resources`);
|
|
104
|
+
const stdin = input.stdin === undefined ? null : boundedString(input.stdin, 256 * 1024, `${label}[${index}].stdin`);
|
|
105
|
+
const stdinResource = input.stdin_resource === undefined ? null : validateResourceName(input.stdin_resource);
|
|
106
|
+
if (stdin !== null && stdinResource !== null) throw new Error(`${label}[${index}] cannot combine stdin and stdin_resource`);
|
|
107
|
+
return {
|
|
108
|
+
name: String(input.name || basename(argv[0]) || `step ${index + 1}`).slice(0, 128),
|
|
109
|
+
argv,
|
|
110
|
+
cwd,
|
|
111
|
+
env,
|
|
112
|
+
env_resources: envResources,
|
|
113
|
+
stdin,
|
|
114
|
+
stdin_resource: stdinResource,
|
|
115
|
+
timeout_seconds: clampInt(input.timeout_seconds, 600, 1, 3600),
|
|
116
|
+
allow_failure: input.allow_failure === true,
|
|
117
|
+
capture_output: input.capture_output === "discard" ? "discard" : "redacted",
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function validateEnv(value, label) {
|
|
123
|
+
if (value === undefined) return {};
|
|
124
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
|
125
|
+
const entries = Object.entries(value);
|
|
126
|
+
if (entries.length > 64) throw new Error(`${label} has too many entries`);
|
|
127
|
+
const out = {};
|
|
128
|
+
for (const [key, raw] of entries) {
|
|
129
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(key)) throw new Error(`${label} contains invalid variable name: ${key}`);
|
|
130
|
+
out[key] = boundedString(raw, 16 * 1024, `${label}.${key}`);
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function validateEnvResources(value, label) {
|
|
136
|
+
if (value === undefined) return {};
|
|
137
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
|
138
|
+
const entries = Object.entries(value);
|
|
139
|
+
if (entries.length > 32) throw new Error(`${label} has too many entries`);
|
|
140
|
+
const out = {};
|
|
141
|
+
for (const [key, raw] of entries) {
|
|
142
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(key)) throw new Error(`${label} contains invalid variable name: ${key}`);
|
|
143
|
+
out[key] = validateResourceName(raw);
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function referencedResources(steps, registry) {
|
|
149
|
+
const names = new Set();
|
|
150
|
+
for (const step of steps) {
|
|
151
|
+
if (step.stdin_resource) names.add(step.stdin_resource);
|
|
152
|
+
for (const name of Object.values(step.env_resources || {})) names.add(name);
|
|
153
|
+
for (const value of [...step.argv, ...Object.values(step.env)]) {
|
|
154
|
+
for (const match of String(value).matchAll(RESOURCE_TOKEN)) names.add(match[1]);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (names.size > MAX_RESOURCES) throw new Error(`job references more than ${MAX_RESOURCES} local resources`);
|
|
158
|
+
const out = {};
|
|
159
|
+
let totalBytes = 0;
|
|
160
|
+
for (const name of names) {
|
|
161
|
+
const resource = registry[name];
|
|
162
|
+
if (!resource) throw new Error(`unknown local resource: ${name}`);
|
|
163
|
+
const inspected = inspectResourceFile(resource.path, { allowInsecurePermissions: resource.allowInsecurePermissions === true, includeHash: true });
|
|
164
|
+
totalBytes += inspected.size;
|
|
165
|
+
if (totalBytes > MAX_JOB_RESOURCE_BYTES) throw new Error(`job resources exceed ${MAX_JOB_RESOURCE_BYTES} bytes`);
|
|
166
|
+
out[name] = {
|
|
167
|
+
...inspected,
|
|
168
|
+
pathAliases: normalizeResourcePathAliases([...(resource.pathAliases || []), ...(inspected.pathAliases || [])]),
|
|
169
|
+
allowInsecurePermissions: resource.allowInsecurePermissions === true,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function normalizeResourceRegistry(resources) {
|
|
176
|
+
const out = {};
|
|
177
|
+
if (!resources || typeof resources !== "object" || Array.isArray(resources)) return out;
|
|
178
|
+
for (const [rawName, rawValue] of Object.entries(resources).slice(0, MAX_RESOURCES)) {
|
|
179
|
+
const name = validateResourceName(rawName);
|
|
180
|
+
if (!rawValue || rawValue.kind !== "file" || typeof rawValue.path !== "string") continue;
|
|
181
|
+
out[name] = {
|
|
182
|
+
kind: "file",
|
|
183
|
+
path: resolve(rawValue.path),
|
|
184
|
+
pathAliases: normalizeResourcePathAliases([rawValue.path, ...(Array.isArray(rawValue.pathAliases) ? rawValue.pathAliases : [])]),
|
|
185
|
+
size: Number.isFinite(Number(rawValue.size)) ? Number(rawValue.size) : null,
|
|
186
|
+
mode: rawValue.mode ?? null,
|
|
187
|
+
updatedAt: rawValue.updatedAt ?? null,
|
|
188
|
+
allowInsecurePermissions: rawValue.allowInsecurePermissions === true,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function normalizeResourcePathAliases(values) {
|
|
195
|
+
const aliases = [];
|
|
196
|
+
for (const value of values) {
|
|
197
|
+
if (typeof value !== "string" || !value || value.includes("\0") || value.length > 4096) continue;
|
|
198
|
+
const absolute = resolve(value);
|
|
199
|
+
if (!aliases.includes(absolute)) aliases.push(absolute);
|
|
200
|
+
if (aliases.length >= 8) break;
|
|
201
|
+
}
|
|
202
|
+
return aliases;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function resolveJobCwd(value, workspace, unrestrictedPaths) {
|
|
206
|
+
const raw = boundedString(value, 4096, "cwd");
|
|
207
|
+
const candidate = isAbsolute(raw) ? resolve(raw) : resolve(workspace, raw);
|
|
208
|
+
const canonical = realpathSync.native ? realpathSync.native(candidate) : realpathSync(candidate);
|
|
209
|
+
const info = statSync(canonical);
|
|
210
|
+
if (!info.isDirectory()) throw new Error("managed job cwd is not a directory");
|
|
211
|
+
if (!unrestrictedPaths) {
|
|
212
|
+
const rel = relative(workspace, canonical);
|
|
213
|
+
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) throw new Error("managed job cwd is outside the configured workspace");
|
|
214
|
+
}
|
|
215
|
+
return canonical;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function realpathFile(path) {
|
|
219
|
+
const input = resolve(path);
|
|
220
|
+
const linkInfo = lstatSync(input);
|
|
221
|
+
if (!linkInfo.isFile() && !linkInfo.isSymbolicLink()) throw new Error("resource path is not a regular file");
|
|
222
|
+
return realpathSync.native ? realpathSync.native(input) : realpathSync(input);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function boundedString(value, maxBytes, label) {
|
|
226
|
+
if (typeof value !== "string" || value.includes("\0")) throw new Error(`${label} must be a string without NUL bytes`);
|
|
227
|
+
if (Buffer.byteLength(value) > maxBytes) throw new Error(`${label} exceeds ${maxBytes} bytes`);
|
|
228
|
+
return value;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function clampInt(value, fallback, min, max) {
|
|
232
|
+
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
233
|
+
const number = Number.isFinite(parsed) ? parsed : fallback;
|
|
234
|
+
return Math.min(Math.max(number, min), max);
|
|
235
|
+
}
|