machine-bridge-mcp 0.16.0 → 0.16.2
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 +14 -0
- package/browser-extension/manifest.json +2 -2
- package/package.json +2 -2
- package/src/local/app-automation.mjs +0 -2
- package/src/local/cli-policy.mjs +14 -2
- package/src/local/managed-jobs.mjs +11 -4
- package/src/worker/errors.ts +41 -0
- package/src/worker/http.ts +252 -0
- package/src/worker/index.ts +1 -1
- package/src/worker/oauth-state.ts +170 -0
- package/src/worker/observability.ts +111 -0
- package/src/worker/pending-calls.ts +109 -0
- package/src/worker/policy.ts +79 -0
- package/src/worker/worker-configuration.d.ts +14716 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
const MAX_ERROR_CODES = 64;
|
|
2
|
+
const MAX_TOOLS = 128;
|
|
3
|
+
const SENSITIVE_FIELD = /(?:authorization|cookie|credential|password|secret|token|verifier|private[_-]?key)/i;
|
|
4
|
+
|
|
5
|
+
export class WorkerObservability {
|
|
6
|
+
private readonly startedAt = Date.now();
|
|
7
|
+
private readonly requests = { total: 0, successful: 0, client_error: 0, server_error: 0 };
|
|
8
|
+
private readonly calls = { started: 0, completed: 0, failed: 0, cancelled: 0, timed_out: 0 };
|
|
9
|
+
private readonly sockets = { candidates: 0, authenticated: 0, disconnected: 0, protocol_errors: 0 };
|
|
10
|
+
private readonly errors = new Map<string, number>();
|
|
11
|
+
private readonly tools = new Map<string, { started: number; completed: number; failed: number; active: number }>();
|
|
12
|
+
|
|
13
|
+
requestFinished(status: number): void {
|
|
14
|
+
this.requests.total += 1;
|
|
15
|
+
if (status >= 500) this.requests.server_error += 1;
|
|
16
|
+
else if (status >= 400) this.requests.client_error += 1;
|
|
17
|
+
else this.requests.successful += 1;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
callStarted(tool: string): void {
|
|
21
|
+
this.calls.started += 1;
|
|
22
|
+
const metric = this.toolMetric(tool);
|
|
23
|
+
metric.started += 1;
|
|
24
|
+
metric.active += 1;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
callFinished(tool: string, code = ""): void {
|
|
28
|
+
const metric = this.toolMetric(tool);
|
|
29
|
+
metric.active = Math.max(0, metric.active - 1);
|
|
30
|
+
if (!code) {
|
|
31
|
+
this.calls.completed += 1;
|
|
32
|
+
metric.completed += 1;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
this.calls.failed += 1;
|
|
36
|
+
metric.failed += 1;
|
|
37
|
+
if (code === "cancelled") this.calls.cancelled += 1;
|
|
38
|
+
if (code === "timeout") this.calls.timed_out += 1;
|
|
39
|
+
this.incrementError(code);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
socketCandidate(): void { this.sockets.candidates += 1; }
|
|
43
|
+
socketAuthenticated(): void { this.sockets.authenticated += 1; }
|
|
44
|
+
socketDisconnected(): void { this.sockets.disconnected += 1; }
|
|
45
|
+
socketProtocolError(code: string): void {
|
|
46
|
+
this.sockets.protocol_errors += 1;
|
|
47
|
+
this.incrementError(code || "protocol_error");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
snapshot(): Record<string, unknown> {
|
|
51
|
+
return {
|
|
52
|
+
uptime_ms: Math.max(0, Date.now() - this.startedAt),
|
|
53
|
+
requests: { ...this.requests },
|
|
54
|
+
calls: { ...this.calls },
|
|
55
|
+
sockets: { ...this.sockets },
|
|
56
|
+
errors: Object.fromEntries([...this.errors.entries()].sort(([left], [right]) => left.localeCompare(right))),
|
|
57
|
+
tools: Object.fromEntries([...this.tools.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([name, metric]) => [name, { ...metric }])),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
event(level: "info" | "warn" | "error", event: string, fields: Record<string, unknown> = {}): void {
|
|
62
|
+
const entry = {
|
|
63
|
+
timestamp: new Date().toISOString(),
|
|
64
|
+
level,
|
|
65
|
+
component: "worker",
|
|
66
|
+
event: sanitizeName(event),
|
|
67
|
+
...sanitizeFields(fields),
|
|
68
|
+
};
|
|
69
|
+
const text = JSON.stringify(entry);
|
|
70
|
+
if (level === "error") console.error(text);
|
|
71
|
+
else if (level === "warn") console.warn(text);
|
|
72
|
+
else console.log(text);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private toolMetric(tool: string): { started: number; completed: number; failed: number; active: number } {
|
|
76
|
+
const name = sanitizeName(tool) || "unknown";
|
|
77
|
+
if (!this.tools.has(name)) {
|
|
78
|
+
if (this.tools.size >= MAX_TOOLS) return this.toolMetric("other");
|
|
79
|
+
this.tools.set(name, { started: 0, completed: 0, failed: 0, active: 0 });
|
|
80
|
+
}
|
|
81
|
+
return this.tools.get(name)!;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private incrementError(code: string): void {
|
|
85
|
+
const key = sanitizeName(code) || "execution_failed";
|
|
86
|
+
if (!this.errors.has(key) && this.errors.size >= MAX_ERROR_CODES) {
|
|
87
|
+
this.errors.set("other", (this.errors.get("other") ?? 0) + 1);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
this.errors.set(key, (this.errors.get(key) ?? 0) + 1);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function sanitizeName(value: unknown): string {
|
|
95
|
+
return String(value || "").toLowerCase().replace(/[^a-z0-9._-]/g, "_").slice(0, 128);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function sanitizeFields(fields: Record<string, unknown>): Record<string, unknown> {
|
|
99
|
+
const out: Record<string, unknown> = {};
|
|
100
|
+
for (const [key, value] of Object.entries(fields).slice(0, 32)) {
|
|
101
|
+
const safeKey = sanitizeName(key);
|
|
102
|
+
if (!safeKey) continue;
|
|
103
|
+
if (SENSITIVE_FIELD.test(safeKey)) {
|
|
104
|
+
out[safeKey] = "<redacted>";
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (typeof value === "boolean" || typeof value === "number" || value === null) out[safeKey] = value;
|
|
108
|
+
else if (typeof value === "string") out[safeKey] = value.replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, 256);
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export interface PendingCallRecord {
|
|
2
|
+
id: string;
|
|
3
|
+
socket: WebSocket;
|
|
4
|
+
clientRequestKey?: string;
|
|
5
|
+
timeout: ReturnType<typeof setTimeout>;
|
|
6
|
+
resolve: (value: unknown) => void;
|
|
7
|
+
reject: (error: Error) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface RegisterPendingCall {
|
|
11
|
+
id: string;
|
|
12
|
+
socket: WebSocket;
|
|
13
|
+
clientRequestKey?: string;
|
|
14
|
+
timeoutMs: number;
|
|
15
|
+
onTimeout: (record: PendingCallRecord) => Error;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class PendingCallRegistry {
|
|
19
|
+
private readonly maximum: number;
|
|
20
|
+
private readonly byId = new Map<string, PendingCallRecord>();
|
|
21
|
+
private readonly byRequestKey = new Map<string, string>();
|
|
22
|
+
|
|
23
|
+
constructor(maximum: number) {
|
|
24
|
+
this.maximum = maximum;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get size(): number {
|
|
28
|
+
return this.byId.size;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
hasRequestKey(requestKey?: string): boolean {
|
|
32
|
+
return Boolean(requestKey && this.byRequestKey.has(requestKey));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
register(input: RegisterPendingCall): Promise<unknown> {
|
|
36
|
+
if (this.byId.size >= this.maximum) throw new Error("too many concurrent daemon tool calls");
|
|
37
|
+
if (this.byId.has(input.id)) throw new Error("duplicate internal daemon call id");
|
|
38
|
+
if (input.clientRequestKey && this.byRequestKey.has(input.clientRequestKey)) {
|
|
39
|
+
throw new Error("duplicate in-flight JSON-RPC request id for this access token");
|
|
40
|
+
}
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const timeout = setTimeout(() => {
|
|
43
|
+
const record = this.take(input.id);
|
|
44
|
+
if (!record) return;
|
|
45
|
+
let error: unknown;
|
|
46
|
+
try { error = input.onTimeout(record); }
|
|
47
|
+
catch { error = new Error("pending daemon call timed out"); }
|
|
48
|
+
reject(error instanceof Error ? error : new Error("pending daemon call timed out"));
|
|
49
|
+
}, input.timeoutMs);
|
|
50
|
+
const record: PendingCallRecord = {
|
|
51
|
+
id: input.id,
|
|
52
|
+
socket: input.socket,
|
|
53
|
+
clientRequestKey: input.clientRequestKey,
|
|
54
|
+
timeout,
|
|
55
|
+
resolve,
|
|
56
|
+
reject,
|
|
57
|
+
};
|
|
58
|
+
this.byId.set(input.id, record);
|
|
59
|
+
if (input.clientRequestKey) this.byRequestKey.set(input.clientRequestKey, input.id);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
resolve(id: string, socket: WebSocket, value: unknown): boolean {
|
|
64
|
+
const record = this.byId.get(id);
|
|
65
|
+
if (!record || record.socket !== socket) return false;
|
|
66
|
+
this.take(id)?.resolve(value);
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
reject(id: string, error: Error, socket?: WebSocket): boolean {
|
|
71
|
+
const record = this.byId.get(id);
|
|
72
|
+
if (!record || (socket && record.socket !== socket)) return false;
|
|
73
|
+
this.take(id)?.reject(error);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
cancelRequest(requestKey: string, onCancel: (record: PendingCallRecord) => Error): boolean {
|
|
78
|
+
const id = this.byRequestKey.get(requestKey);
|
|
79
|
+
if (!id) return false;
|
|
80
|
+
const record = this.take(id);
|
|
81
|
+
if (!record) return false;
|
|
82
|
+
record.reject(onCancel(record));
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
rejectSocket(socket: WebSocket, createError: (record: PendingCallRecord) => Error): number {
|
|
87
|
+
const ids = [...this.byId.values()].filter((record) => record.socket === socket).map((record) => record.id);
|
|
88
|
+
for (const id of ids) {
|
|
89
|
+
const record = this.take(id);
|
|
90
|
+
if (record) record.reject(createError(record));
|
|
91
|
+
}
|
|
92
|
+
return ids.length;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
snapshot(): { active: number; request_keys: number; maximum: number } {
|
|
96
|
+
return { active: this.byId.size, request_keys: this.byRequestKey.size, maximum: this.maximum };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private take(id: string): PendingCallRecord | undefined {
|
|
100
|
+
const record = this.byId.get(id);
|
|
101
|
+
if (!record) return undefined;
|
|
102
|
+
clearTimeout(record.timeout);
|
|
103
|
+
this.byId.delete(id);
|
|
104
|
+
if (record.clientRequestKey && this.byRequestKey.get(record.clientRequestKey) === id) {
|
|
105
|
+
this.byRequestKey.delete(record.clientRequestKey);
|
|
106
|
+
}
|
|
107
|
+
return record;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import contract from "../shared/policy-contract.json" with { type: "json" };
|
|
2
|
+
import toolCatalog from "../shared/tool-catalog.json" with { type: "json" };
|
|
3
|
+
|
|
4
|
+
export type DaemonPolicy = {
|
|
5
|
+
profile: string;
|
|
6
|
+
origin: string;
|
|
7
|
+
revision: number;
|
|
8
|
+
allowWrite: boolean;
|
|
9
|
+
allowExec: boolean;
|
|
10
|
+
execMode: "off" | "direct" | "shell";
|
|
11
|
+
unrestrictedPaths: boolean;
|
|
12
|
+
minimalEnv: boolean;
|
|
13
|
+
exposeAbsolutePaths: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type ToolDefinition = Record<string, unknown> & { name: string; availability?: string };
|
|
17
|
+
type AvailabilityRequirement = {
|
|
18
|
+
profile?: string;
|
|
19
|
+
allowWrite?: boolean;
|
|
20
|
+
execModes?: string[];
|
|
21
|
+
unrestrictedPaths?: boolean;
|
|
22
|
+
minimalEnv?: boolean;
|
|
23
|
+
exposeAbsolutePaths?: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const tools = toolCatalog as ToolDefinition[];
|
|
27
|
+
const definitions = new Map(tools.map((tool) => [tool.name, tool]));
|
|
28
|
+
const origins = new Set(contract.origins.map(String));
|
|
29
|
+
const availability = contract.availability as Record<string, AvailabilityRequirement>;
|
|
30
|
+
|
|
31
|
+
export function sanitizeDaemonPolicy(value: unknown): DaemonPolicy {
|
|
32
|
+
const policy = asObject(value);
|
|
33
|
+
const execMode: DaemonPolicy["execMode"] = policy.execMode === "shell" || policy.execMode === "direct" ? policy.execMode : "off";
|
|
34
|
+
const origin = sanitizeMetadataText(policy.origin, 32);
|
|
35
|
+
return {
|
|
36
|
+
profile: sanitizeMetadataText(policy.profile, 32) ?? "custom",
|
|
37
|
+
origin: origins.has(origin ?? "") ? origin! : "custom",
|
|
38
|
+
revision: Number.isInteger(policy.revision) && Number(policy.revision) > 0
|
|
39
|
+
? Math.min(Number(policy.revision), 1_000_000)
|
|
40
|
+
: Number(contract.revision),
|
|
41
|
+
allowWrite: policy.allowWrite === true,
|
|
42
|
+
allowExec: execMode !== "off",
|
|
43
|
+
execMode,
|
|
44
|
+
unrestrictedPaths: policy.unrestrictedPaths === true,
|
|
45
|
+
minimalEnv: policy.minimalEnv !== false,
|
|
46
|
+
exposeAbsolutePaths: policy.exposeAbsolutePaths === true,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function policyAllowsAvailability(policy: DaemonPolicy, name: unknown): boolean {
|
|
51
|
+
const requirement = availability[String(name || "")];
|
|
52
|
+
if (!requirement) return false;
|
|
53
|
+
if (requirement.profile && policy.profile !== requirement.profile) return false;
|
|
54
|
+
if (requirement.allowWrite === true && policy.allowWrite !== true) return false;
|
|
55
|
+
if (Array.isArray(requirement.execModes) && !requirement.execModes.includes(policy.execMode)) return false;
|
|
56
|
+
if (typeof requirement.unrestrictedPaths === "boolean" && policy.unrestrictedPaths !== requirement.unrestrictedPaths) return false;
|
|
57
|
+
if (typeof requirement.minimalEnv === "boolean" && policy.minimalEnv !== requirement.minimalEnv) return false;
|
|
58
|
+
if (typeof requirement.exposeAbsolutePaths === "boolean" && policy.exposeAbsolutePaths !== requirement.exposeAbsolutePaths) return false;
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function sanitizeDaemonTools(value: unknown, policy: DaemonPolicy): string[] {
|
|
63
|
+
if (!Array.isArray(value)) return [];
|
|
64
|
+
return [...new Set(value.filter((item): item is string => {
|
|
65
|
+
if (typeof item !== "string" || item === "server_info") return false;
|
|
66
|
+
const definition = definitions.get(item);
|
|
67
|
+
return Boolean(definition && policyAllowsAvailability(policy, definition.availability));
|
|
68
|
+
}))];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function sanitizeMetadataText(value: unknown, maxLength: number): string | undefined {
|
|
72
|
+
if (typeof value !== "string") return undefined;
|
|
73
|
+
const normalized = value.replace(/[\u0000-\u001f\u007f\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g, " ").replace(/\s+/g, " ").trim();
|
|
74
|
+
return normalized ? normalized.slice(0, maxLength) : undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function asObject(value: unknown): Record<string, unknown> {
|
|
78
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
79
|
+
}
|