@melra/cli 0.3.0-alpha.10
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/LICENSE +202 -0
- package/README.md +40 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +31 -0
- package/dist/bin.js.map +1 -0
- package/dist/conformance.d.ts +17 -0
- package/dist/conformance.d.ts.map +1 -0
- package/dist/conformance.js +381 -0
- package/dist/conformance.js.map +1 -0
- package/dist/environment.d.ts +22 -0
- package/dist/environment.d.ts.map +1 -0
- package/dist/environment.js +109 -0
- package/dist/environment.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +770 -0
- package/dist/index.js.map +1 -0
- package/package.json +45 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Copyright 2026 XAGI Labs Private Limited
|
|
3
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
5
|
+
import { constants } from "node:fs";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { basename, join, resolve } from "node:path";
|
|
8
|
+
import { createInterface } from "node:readline/promises";
|
|
9
|
+
import { DatabaseSync } from "node:sqlite";
|
|
10
|
+
import { ApprovalResponseSchema, TaskRequestSchema, WorkflowDefinitionSchema, WorkflowInputSchema, PRODUCT_VERSION, principalRef, } from "@melra/protocol";
|
|
11
|
+
import { createMelraRuntime, serveHttp, OAuthProvider, clientPrincipal, serveStdio, unconfinedRoot, } from "@melra/server";
|
|
12
|
+
import { detectBrowserExecutable } from "@melra/browser-runtime";
|
|
13
|
+
import { createSystemComputerAdapter } from "@melra/computer-runtime";
|
|
14
|
+
import { classifyOperation, createDefaultPolicy, evaluatePolicy, loadPolicy, } from "@melra/policy-core";
|
|
15
|
+
import { parseCliEnvironment, serverLaunch, } from "./environment.js";
|
|
16
|
+
import { runConformance } from "./conformance.js";
|
|
17
|
+
async function existingPolicyPath(env) {
|
|
18
|
+
if (env.policyPath !== undefined)
|
|
19
|
+
return env.policyPath;
|
|
20
|
+
const candidate = join(env.dataDirectory, "policy.json");
|
|
21
|
+
try {
|
|
22
|
+
await access(candidate, constants.R_OK);
|
|
23
|
+
return candidate;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The banner unhinged mode prints before it does anything.
|
|
31
|
+
*
|
|
32
|
+
* It goes to stderr, which is where it has to go: in `serve` the stdout stream
|
|
33
|
+
* is the MCP transport and any prose written there corrupts the protocol. stderr
|
|
34
|
+
* is what MCP clients surface in their server logs, so the operator still sees
|
|
35
|
+
* it. Ordinary runs get it too — the mode is per-process, not per-command, and a
|
|
36
|
+
* developer who exported the variable in one shell and forgot deserves the
|
|
37
|
+
* reminder on every invocation rather than only at startup.
|
|
38
|
+
*/
|
|
39
|
+
function unhingedBanner(root) {
|
|
40
|
+
return [
|
|
41
|
+
"",
|
|
42
|
+
" ############################################################",
|
|
43
|
+
" # MELRA IS RUNNING UNHINGED. NO GUARDRAILS ARE APPLIED. #",
|
|
44
|
+
" ############################################################",
|
|
45
|
+
"",
|
|
46
|
+
" Disabled for every task this process runs:",
|
|
47
|
+
" - policy decisions: nothing is denied, no approval is ever asked for",
|
|
48
|
+
" - the command allowlist, including shells and sudo",
|
|
49
|
+
" - the evidence requirement on mutations and destructive operations",
|
|
50
|
+
" - the browser's block on private, loopback, and cloud-metadata hosts",
|
|
51
|
+
` - workspace confinement: files and commands can reach all of ${root}`,
|
|
52
|
+
"",
|
|
53
|
+
" A caller can now delete, overwrite, exfiltrate, or execute anything this",
|
|
54
|
+
" OS user can. Receipts are still written, so you will be able to read what",
|
|
55
|
+
" happened — after it has happened.",
|
|
56
|
+
"",
|
|
57
|
+
" Turn it off by unsetting MELRA_UNHINGED and dropping --unhinged.",
|
|
58
|
+
"",
|
|
59
|
+
].join("\n");
|
|
60
|
+
}
|
|
61
|
+
async function runtime(env) {
|
|
62
|
+
const policyPath = await existingPolicyPath(env);
|
|
63
|
+
return await createMelraRuntime({
|
|
64
|
+
workspaceRoot: env.workspaceRoot,
|
|
65
|
+
dataDirectory: env.dataDirectory,
|
|
66
|
+
unhinged: env.unhinged,
|
|
67
|
+
...(policyPath === undefined ? {} : { policyPath }),
|
|
68
|
+
...(env.browserExecutablePath === undefined
|
|
69
|
+
? {}
|
|
70
|
+
: { browserExecutablePath: env.browserExecutablePath }),
|
|
71
|
+
...(env.browserCdpEndpoint === undefined
|
|
72
|
+
? {}
|
|
73
|
+
: { browserCdpEndpoint: env.browserCdpEndpoint }),
|
|
74
|
+
...(env.browserCdpContextIndex === undefined
|
|
75
|
+
? {}
|
|
76
|
+
: { browserCdpContextIndex: env.browserCdpContextIndex }),
|
|
77
|
+
...(env.browserHarPath === undefined
|
|
78
|
+
? {}
|
|
79
|
+
: { browserHarPath: env.browserHarPath }),
|
|
80
|
+
...(env.browserHarReplayPath === undefined
|
|
81
|
+
? {}
|
|
82
|
+
: { browserHarReplayPath: env.browserHarReplayPath }),
|
|
83
|
+
...(env.browserProfileDir === undefined
|
|
84
|
+
? {}
|
|
85
|
+
: { browserProfileDir: env.browserProfileDir }),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
function output(value) {
|
|
89
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
90
|
+
}
|
|
91
|
+
function argument(name, args) {
|
|
92
|
+
const index = args.indexOf(name);
|
|
93
|
+
return index < 0 ? undefined : args[index + 1];
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* A flag this command does not know is a typo, and a typo must not be silently
|
|
97
|
+
* ignored: `melra run --requst t.json` fell through to reading stdin, which in
|
|
98
|
+
* a terminal prints nothing and in a pipeline that never closes hangs forever.
|
|
99
|
+
*/
|
|
100
|
+
function rejectUnknownFlags(args, known) {
|
|
101
|
+
const allowed = new Set([...known, "--unhinged"]);
|
|
102
|
+
// A flag's own value can look like a flag (`--input node=--x`), so skip the
|
|
103
|
+
// slot after any known flag that takes one.
|
|
104
|
+
const valued = new Set(known.filter((flag) => flag !== "--http"));
|
|
105
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
106
|
+
const argument_ = args[index];
|
|
107
|
+
if (argument_ === undefined || !argument_.startsWith("--"))
|
|
108
|
+
continue;
|
|
109
|
+
if (!allowed.has(argument_)) {
|
|
110
|
+
throw new Error(`unknown flag ${argument_}. Known here: ${[...allowed].sort().join(", ")}`);
|
|
111
|
+
}
|
|
112
|
+
if (valued.has(argument_))
|
|
113
|
+
index += 1;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// `JSON.parse` reports a character offset and nothing about where it was
|
|
117
|
+
// reading, so a typo in a workflow definition surfaced as a bare
|
|
118
|
+
// "Expected property name or '}' in JSON at position 1". Name the source.
|
|
119
|
+
function parseJson(text, source) {
|
|
120
|
+
try {
|
|
121
|
+
return JSON.parse(text);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
125
|
+
throw new Error(`${source} is not valid JSON: ${detail}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async function readTaskRequest(args) {
|
|
129
|
+
const requestPath = argument("--request", args);
|
|
130
|
+
if (requestPath !== undefined) {
|
|
131
|
+
const absolute = resolve(requestPath);
|
|
132
|
+
return TaskRequestSchema.parse(parseJson(await readFile(absolute, "utf8"), absolute));
|
|
133
|
+
}
|
|
134
|
+
if (!process.stdin.isTTY) {
|
|
135
|
+
let input = "";
|
|
136
|
+
for await (const chunk of process.stdin)
|
|
137
|
+
input += String(chunk);
|
|
138
|
+
// Redirected-but-empty stdin is what a script gets wrong, and `JSON.parse`
|
|
139
|
+
// answers it with "Unexpected end of JSON input" — true, and no help at
|
|
140
|
+
// all. Say the same thing an interactive shell would.
|
|
141
|
+
if (input.trim() === "") {
|
|
142
|
+
throw new Error("provide --request <file> or pipe a JSON task request");
|
|
143
|
+
}
|
|
144
|
+
return TaskRequestSchema.parse(parseJson(input, "the piped task request"));
|
|
145
|
+
}
|
|
146
|
+
throw new Error("provide --request <file> or pipe a JSON task request");
|
|
147
|
+
}
|
|
148
|
+
async function readWorkflowDefinition(args) {
|
|
149
|
+
const definitionPath = argument("--definition", args);
|
|
150
|
+
if (definitionPath === undefined) {
|
|
151
|
+
throw new Error("workflow plan requires --definition <file>");
|
|
152
|
+
}
|
|
153
|
+
const absolute = resolve(definitionPath);
|
|
154
|
+
return WorkflowDefinitionSchema.parse(parseJson(await readFile(absolute, "utf8"), absolute));
|
|
155
|
+
}
|
|
156
|
+
function workflowExitCode(status) {
|
|
157
|
+
if (status === "awaiting_approval")
|
|
158
|
+
return 3;
|
|
159
|
+
// A run waiting on a person is not a failure — it is a prompt. Its own exit
|
|
160
|
+
// code lets a script tell "answer me" apart from "approve me" and "broke".
|
|
161
|
+
if (status === "awaiting_input")
|
|
162
|
+
return 4;
|
|
163
|
+
if ([
|
|
164
|
+
"failed",
|
|
165
|
+
"partially_complete",
|
|
166
|
+
"cancelled",
|
|
167
|
+
"recovery_required",
|
|
168
|
+
].includes(status)) {
|
|
169
|
+
return 2;
|
|
170
|
+
}
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
// `--input <node-id>=<value>`. `=` rather than `:` because a node ID never
|
|
174
|
+
// contains one but an answer very often does (URLs, times, ratios).
|
|
175
|
+
function workflowInputs(args) {
|
|
176
|
+
const inputs = [];
|
|
177
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
178
|
+
if (args[index] !== "--input")
|
|
179
|
+
continue;
|
|
180
|
+
const value = args[index + 1];
|
|
181
|
+
if (value === undefined)
|
|
182
|
+
throw new Error("--input requires a value");
|
|
183
|
+
const separator = value.indexOf("=");
|
|
184
|
+
if (separator < 1 || separator === value.length - 1) {
|
|
185
|
+
throw new Error("--input must be <node-id>=<value>");
|
|
186
|
+
}
|
|
187
|
+
inputs.push(WorkflowInputSchema.parse({
|
|
188
|
+
nodeId: value.slice(0, separator),
|
|
189
|
+
value: value.slice(separator + 1),
|
|
190
|
+
}));
|
|
191
|
+
index += 1;
|
|
192
|
+
}
|
|
193
|
+
return inputs;
|
|
194
|
+
}
|
|
195
|
+
function workflowApprovals(args) {
|
|
196
|
+
const approvals = [];
|
|
197
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
198
|
+
if (args[index] !== "--approval")
|
|
199
|
+
continue;
|
|
200
|
+
const value = args[index + 1];
|
|
201
|
+
if (value === undefined)
|
|
202
|
+
throw new Error("--approval requires a value");
|
|
203
|
+
const separator = value.indexOf(":");
|
|
204
|
+
if (separator < 1 || separator === value.length - 1) {
|
|
205
|
+
throw new Error("--approval must be <approval-id>:<exact-phrase>");
|
|
206
|
+
}
|
|
207
|
+
approvals.push(ApprovalResponseSchema.parse({
|
|
208
|
+
approvalId: value.slice(0, separator),
|
|
209
|
+
phrase: value.slice(separator + 1),
|
|
210
|
+
}));
|
|
211
|
+
index += 1;
|
|
212
|
+
}
|
|
213
|
+
return approvals;
|
|
214
|
+
}
|
|
215
|
+
async function workflowCommand(args, env) {
|
|
216
|
+
const [action, ...actionArgs] = args;
|
|
217
|
+
const melra = await runtime(env);
|
|
218
|
+
try {
|
|
219
|
+
switch (action) {
|
|
220
|
+
case "plan": {
|
|
221
|
+
const run = melra.workflows.plan(await readWorkflowDefinition(actionArgs));
|
|
222
|
+
output(run);
|
|
223
|
+
return workflowExitCode(run.status);
|
|
224
|
+
}
|
|
225
|
+
case "advance": {
|
|
226
|
+
const workflowId = actionArgs[0];
|
|
227
|
+
if (workflowId === undefined) {
|
|
228
|
+
throw new Error("workflow advance requires a workflow ID");
|
|
229
|
+
}
|
|
230
|
+
const rest = actionArgs.slice(1);
|
|
231
|
+
const result = await melra.workflows.advance(workflowId, workflowApprovals(rest), workflowInputs(rest));
|
|
232
|
+
output(result);
|
|
233
|
+
return workflowExitCode(result.run.status);
|
|
234
|
+
}
|
|
235
|
+
case "inspect": {
|
|
236
|
+
const workflowId = actionArgs[0];
|
|
237
|
+
if (workflowId === undefined) {
|
|
238
|
+
throw new Error("workflow inspect requires a workflow ID");
|
|
239
|
+
}
|
|
240
|
+
output(melra.workflows.status(workflowId));
|
|
241
|
+
return 0;
|
|
242
|
+
}
|
|
243
|
+
case "cancel": {
|
|
244
|
+
const workflowId = actionArgs[0];
|
|
245
|
+
if (workflowId === undefined) {
|
|
246
|
+
throw new Error("workflow cancel requires a workflow ID");
|
|
247
|
+
}
|
|
248
|
+
const run = melra.workflows.cancel(workflowId);
|
|
249
|
+
output(run);
|
|
250
|
+
return workflowExitCode(run.status);
|
|
251
|
+
}
|
|
252
|
+
case "pause":
|
|
253
|
+
case "resume":
|
|
254
|
+
case "suspend": {
|
|
255
|
+
const workflowId = actionArgs[0];
|
|
256
|
+
if (workflowId === undefined) {
|
|
257
|
+
throw new Error(`workflow ${action} requires a workflow ID`);
|
|
258
|
+
}
|
|
259
|
+
const run = action === "pause"
|
|
260
|
+
? melra.workflows.pause(workflowId)
|
|
261
|
+
: action === "suspend"
|
|
262
|
+
? melra.workflows.suspend(workflowId)
|
|
263
|
+
: melra.workflows.resume(workflowId);
|
|
264
|
+
output(run);
|
|
265
|
+
return workflowExitCode(run.status);
|
|
266
|
+
}
|
|
267
|
+
default:
|
|
268
|
+
throw new Error("workflow supports plan, advance, inspect, cancel, pause, resume, and suspend");
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
finally {
|
|
272
|
+
await melra.close();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
async function durableCoreDemo(env) {
|
|
276
|
+
const examplePath = resolve(import.meta.dirname, "../../../examples/workflows/restart-safe.json");
|
|
277
|
+
const melra = await runtime(env);
|
|
278
|
+
try {
|
|
279
|
+
const definition = WorkflowDefinitionSchema.parse(JSON.parse(await readFile(examplePath, "utf8")));
|
|
280
|
+
const planned = melra.workflows.plan(definition);
|
|
281
|
+
const advanced = await melra.workflows.advance(planned.id);
|
|
282
|
+
output({
|
|
283
|
+
examplePath,
|
|
284
|
+
workflow: advanced.run,
|
|
285
|
+
next: advanced.run.status === "verified_complete"
|
|
286
|
+
? "complete"
|
|
287
|
+
: `melra workflow advance ${planned.id}`,
|
|
288
|
+
});
|
|
289
|
+
return workflowExitCode(advanced.run.status);
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
await melra.close();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
async function doctor(env) {
|
|
296
|
+
const checks = [];
|
|
297
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
298
|
+
checks.push({
|
|
299
|
+
name: "node",
|
|
300
|
+
status: major >= 22 ? "pass" : "fail",
|
|
301
|
+
detail: process.version,
|
|
302
|
+
});
|
|
303
|
+
// A `warn`, not a `fail`: the operator asked for this, so `doctor` must still
|
|
304
|
+
// exit zero. But a machine running with no guardrails should never be able to
|
|
305
|
+
// report a clean bill of health without saying so.
|
|
306
|
+
checks.push(env.unhinged
|
|
307
|
+
? {
|
|
308
|
+
name: "guardrails",
|
|
309
|
+
status: "warn",
|
|
310
|
+
detail: "UNHINGED: no policy, approval, evidence, confinement, or destination check is applied",
|
|
311
|
+
}
|
|
312
|
+
: { name: "guardrails", status: "pass", detail: "enforced" });
|
|
313
|
+
try {
|
|
314
|
+
await access(env.workspaceRoot, constants.R_OK | constants.W_OK);
|
|
315
|
+
checks.push({
|
|
316
|
+
name: "workspace",
|
|
317
|
+
status: "pass",
|
|
318
|
+
detail: env.workspaceRoot,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
checks.push({
|
|
323
|
+
name: "workspace",
|
|
324
|
+
status: "fail",
|
|
325
|
+
detail: `not readable and writable: ${env.workspaceRoot}`,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
await mkdir(env.dataDirectory, { recursive: true });
|
|
330
|
+
await access(env.dataDirectory, constants.R_OK | constants.W_OK);
|
|
331
|
+
checks.push({
|
|
332
|
+
name: "data-directory",
|
|
333
|
+
status: "pass",
|
|
334
|
+
detail: env.dataDirectory,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
checks.push({
|
|
339
|
+
name: "data-directory",
|
|
340
|
+
status: "fail",
|
|
341
|
+
detail: env.dataDirectory,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
try {
|
|
345
|
+
const database = new DatabaseSync(":memory:");
|
|
346
|
+
database.exec("CREATE TABLE readiness(value TEXT)");
|
|
347
|
+
database.close();
|
|
348
|
+
checks.push({ name: "sqlite", status: "pass", detail: "node:sqlite available" });
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
checks.push({
|
|
352
|
+
name: "sqlite",
|
|
353
|
+
status: "fail",
|
|
354
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
const browser = env.browserExecutablePath ?? (await detectBrowserExecutable());
|
|
358
|
+
checks.push({
|
|
359
|
+
name: "browser",
|
|
360
|
+
status: browser === undefined ? "warn" : "pass",
|
|
361
|
+
detail: browser ??
|
|
362
|
+
"Chrome, Chromium, or Edge not found; non-browser capabilities remain available",
|
|
363
|
+
});
|
|
364
|
+
const computer = await createSystemComputerAdapter().capabilities();
|
|
365
|
+
checks.push({
|
|
366
|
+
name: "computer",
|
|
367
|
+
status: computer.available ? "pass" : "warn",
|
|
368
|
+
detail: computer.available
|
|
369
|
+
? `${computer.adapter}: screenshot=${computer.screenshot}, pointer=${computer.pointer}, keyboard=${computer.keyboard}, scroll=${computer.scroll}`
|
|
370
|
+
: computer.limitations.join("; "),
|
|
371
|
+
});
|
|
372
|
+
try {
|
|
373
|
+
const path = await existingPolicyPath(env);
|
|
374
|
+
if (path !== undefined)
|
|
375
|
+
JSON.parse(await readFile(path, "utf8"));
|
|
376
|
+
checks.push({
|
|
377
|
+
name: "policy",
|
|
378
|
+
status: "pass",
|
|
379
|
+
detail: path ?? "using safe built-in defaults",
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
checks.push({
|
|
384
|
+
name: "policy",
|
|
385
|
+
status: "fail",
|
|
386
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
const failed = checks.some((check) => check.status === "fail");
|
|
390
|
+
return {
|
|
391
|
+
report: {
|
|
392
|
+
product: "MELRA",
|
|
393
|
+
version: PRODUCT_VERSION,
|
|
394
|
+
ready: !failed,
|
|
395
|
+
// Alongside the `guardrails` check so a script does not have to read a
|
|
396
|
+
// detail string to find out whether this machine has any.
|
|
397
|
+
unhinged: env.unhinged,
|
|
398
|
+
checks,
|
|
399
|
+
},
|
|
400
|
+
failed,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
async function init(args, env) {
|
|
404
|
+
await mkdir(env.dataDirectory, { recursive: true });
|
|
405
|
+
const policyPath = join(env.dataDirectory, "policy.json");
|
|
406
|
+
try {
|
|
407
|
+
await access(policyPath, constants.F_OK);
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
await writeFile(policyPath, `${JSON.stringify(createDefaultPolicy(env.workspaceRoot), null, 2)}\n`, { flag: "wx" });
|
|
411
|
+
}
|
|
412
|
+
const client = argument("--client", args) ?? "generic";
|
|
413
|
+
const config = {
|
|
414
|
+
mcpServers: {
|
|
415
|
+
melra: {
|
|
416
|
+
...serverLaunch(import.meta.dirname, PRODUCT_VERSION),
|
|
417
|
+
env: {
|
|
418
|
+
MELRA_WORKSPACE: env.workspaceRoot,
|
|
419
|
+
MELRA_HOME: env.dataDirectory,
|
|
420
|
+
MELRA_POLICY: policyPath,
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
return {
|
|
426
|
+
initialized: true,
|
|
427
|
+
client,
|
|
428
|
+
policyPath,
|
|
429
|
+
config,
|
|
430
|
+
note: `Add the mcpServers.melra entry to ${client}'s MCP configuration.`,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
// One command for the whole local setup: write the policy, emit a working
|
|
434
|
+
// client config, and verify the machine can actually run it.
|
|
435
|
+
async function setup(args, env) {
|
|
436
|
+
const initialized = await init(args, env);
|
|
437
|
+
const { report, failed } = await doctor(env);
|
|
438
|
+
output({ ...initialized, ...report });
|
|
439
|
+
return failed ? 1 : 0;
|
|
440
|
+
}
|
|
441
|
+
async function runTask(args, env) {
|
|
442
|
+
const melra = await runtime(env);
|
|
443
|
+
try {
|
|
444
|
+
const task = melra.controller.plan(await readTaskRequest(args));
|
|
445
|
+
if (task.status === "policy_blocked") {
|
|
446
|
+
output({ task });
|
|
447
|
+
return 4;
|
|
448
|
+
}
|
|
449
|
+
if (task.status === "awaiting_approval") {
|
|
450
|
+
if (!process.stdin.isTTY) {
|
|
451
|
+
output({ task, next: "rerun interactively to approve this scoped action" });
|
|
452
|
+
return 3;
|
|
453
|
+
}
|
|
454
|
+
const prompt = createInterface({ input: process.stdin, output: process.stderr });
|
|
455
|
+
const phrase = await prompt.question(`Type the exact approval phrase '${task.approval.phrase}' to continue: `);
|
|
456
|
+
prompt.close();
|
|
457
|
+
const execution = await melra.controller.execute(task.id, {
|
|
458
|
+
approvalId: task.approval.approvalId,
|
|
459
|
+
phrase,
|
|
460
|
+
});
|
|
461
|
+
output(execution);
|
|
462
|
+
return execution.task.status === "verified_success" ? 0 : 2;
|
|
463
|
+
}
|
|
464
|
+
const execution = await melra.controller.execute(task.id);
|
|
465
|
+
output(execution);
|
|
466
|
+
return execution.task.status === "verified_success" ? 0 : 2;
|
|
467
|
+
}
|
|
468
|
+
finally {
|
|
469
|
+
await melra.close();
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
async function inspectTask(args, env) {
|
|
473
|
+
const taskId = args[0];
|
|
474
|
+
if (taskId === undefined)
|
|
475
|
+
throw new Error("inspect requires a task ID");
|
|
476
|
+
const melra = await runtime(env);
|
|
477
|
+
try {
|
|
478
|
+
output({
|
|
479
|
+
task: melra.controller.status(taskId),
|
|
480
|
+
...melra.controller.receipts({ taskId }),
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
finally {
|
|
484
|
+
await melra.close();
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
async function policyTest(args, env) {
|
|
488
|
+
const request = await readTaskRequest(args);
|
|
489
|
+
// Reports what this process would actually decide, unhinged mode included —
|
|
490
|
+
// a dry run that disagreed with the server it is previewing is worse than
|
|
491
|
+
// none. That is also why the operator's own `policy.json` is loaded rather
|
|
492
|
+
// than the defaults: `serve` reads it, so a preview that ignored it would
|
|
493
|
+
// answer for a policy nobody is running.
|
|
494
|
+
const policy = {
|
|
495
|
+
...(await loadPolicy(await existingPolicyPath(env), env.workspaceRoot)),
|
|
496
|
+
unhinged: env.unhinged,
|
|
497
|
+
};
|
|
498
|
+
const taskId = "00000000-0000-4000-8000-000000000000";
|
|
499
|
+
// The decision carries effect, risk, and traits; the classification adds the
|
|
500
|
+
// capability and target it was made against, so a surprising verdict can be
|
|
501
|
+
// traced to what MELRA thought the request was touching.
|
|
502
|
+
const { capability, target } = classifyOperation(request.operation);
|
|
503
|
+
output({
|
|
504
|
+
...evaluatePolicy(taskId, request, policy),
|
|
505
|
+
classified: { capability, target },
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* The clients an operator has approved over OAuth, and the way to take that
|
|
510
|
+
* back. Reads the same file the server writes, so it works while the server is
|
|
511
|
+
* down — which is when someone most wants to withdraw an approval.
|
|
512
|
+
*/
|
|
513
|
+
function clientsCommand(args, env) {
|
|
514
|
+
const oauth = new OAuthProvider(env.dataDirectory);
|
|
515
|
+
const revoke = argument("--revoke", args);
|
|
516
|
+
if (revoke !== undefined) {
|
|
517
|
+
const dropped = oauth.revoke(revoke === "all" ? undefined : revoke);
|
|
518
|
+
process.stdout.write(`Revoked ${dropped} token${dropped === 1 ? "" : "s"}. ` +
|
|
519
|
+
`The client must be approved again before it can act.\n`);
|
|
520
|
+
return 0;
|
|
521
|
+
}
|
|
522
|
+
const clients = oauth.clients();
|
|
523
|
+
if (clients.length === 0) {
|
|
524
|
+
process.stdout.write("No client has registered against this data directory.\n");
|
|
525
|
+
return 0;
|
|
526
|
+
}
|
|
527
|
+
for (const client of clients) {
|
|
528
|
+
process.stdout.write(`${client.approvedAt === undefined ? "pending " : "approved"} ` +
|
|
529
|
+
`${client.id} ${client.name}\n` +
|
|
530
|
+
` ${principalRef(clientPrincipal(client))}\n`);
|
|
531
|
+
}
|
|
532
|
+
process.stdout.write(`\nRevoke one with 'melra clients --revoke <client-id>', or all of them\n` +
|
|
533
|
+
`with 'melra clients --revoke all'.\n`);
|
|
534
|
+
return 0;
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Hands the console URL to whatever opens links here.
|
|
538
|
+
*
|
|
539
|
+
* The URL carries the token, so this is the difference between copying a secret
|
|
540
|
+
* out of a terminal and the console simply being open. Failure is silent on
|
|
541
|
+
* purpose: the URL is already on screen, and a server that refused to start
|
|
542
|
+
* because a desktop helper is missing would be worse than one you click into.
|
|
543
|
+
*/
|
|
544
|
+
function openInBrowser(url) {
|
|
545
|
+
const [command, ...prefix] = process.platform === "darwin"
|
|
546
|
+
? ["open"]
|
|
547
|
+
: process.platform === "win32"
|
|
548
|
+
? ["cmd", "/c", "start", ""]
|
|
549
|
+
: ["xdg-open"];
|
|
550
|
+
const child = spawn(command, [...prefix, url], {
|
|
551
|
+
stdio: "ignore",
|
|
552
|
+
detached: true,
|
|
553
|
+
});
|
|
554
|
+
child.on("error", () => { });
|
|
555
|
+
child.unref();
|
|
556
|
+
}
|
|
557
|
+
function help() {
|
|
558
|
+
process.stdout.write(`MELRA ${PRODUCT_VERSION}
|
|
559
|
+
|
|
560
|
+
Usage:
|
|
561
|
+
melra setup [--client <claude|cursor|vscode|codex|generic>]
|
|
562
|
+
melra doctor
|
|
563
|
+
melra conformance [--url <mcp-url> --token <token>] [--level <1|2|3>]
|
|
564
|
+
melra init --client <claude|cursor|vscode|codex|generic>
|
|
565
|
+
melra serve [--http] [--port <port>] [--open]
|
|
566
|
+
melra run --request <task.json>
|
|
567
|
+
melra inspect <task-id>
|
|
568
|
+
melra workflow plan --definition <workflow.json>
|
|
569
|
+
melra workflow advance <workflow-id> [--approval <id>:<exact-phrase>]
|
|
570
|
+
[--input <node-id>=<value>]
|
|
571
|
+
melra workflow inspect <workflow-id>
|
|
572
|
+
melra workflow cancel <workflow-id>
|
|
573
|
+
melra workflow pause|resume|suspend <workflow-id>
|
|
574
|
+
melra demo durable-core
|
|
575
|
+
melra clients [--revoke <client-id|all>]
|
|
576
|
+
melra policy test --request <task.json>
|
|
577
|
+
melra version
|
|
578
|
+
|
|
579
|
+
Flags:
|
|
580
|
+
--unhinged Run with no policy and no guardrails. Everything the OS user
|
|
581
|
+
can do, any caller can now do. Same as MELRA_UNHINGED=1.
|
|
582
|
+
--http Serve MCP over loopback HTTP instead of stdio, alongside a
|
|
583
|
+
read-only REST API, a workflow event stream, and the console.
|
|
584
|
+
Prints a bearer token; every request must carry it.
|
|
585
|
+
--port <port> HTTP port (default: 7457, or MELRA_HTTP_PORT).
|
|
586
|
+
--open Open the console in your browser once it is listening.
|
|
587
|
+
--url <mcp-url> Endpoint for 'conformance'. Default: spawn 'melra serve'.
|
|
588
|
+
--level <1|2|3> Conformance level being claimed (default: 3). The run exits
|
|
589
|
+
non-zero if the endpoint falls short of it.
|
|
590
|
+
|
|
591
|
+
Environment:
|
|
592
|
+
MELRA_WORKSPACE Workspace boundary (default: current directory)
|
|
593
|
+
MELRA_HOME Local database and artifact directory
|
|
594
|
+
MELRA_POLICY Optional local policy JSON
|
|
595
|
+
MELRA_UNHINGED Set to 1 to disable every guardrail (see --unhinged)
|
|
596
|
+
MELRA_HTTP_PORT Port for 'serve --http' (default: 7457)
|
|
597
|
+
MELRA_HTTP_TOKEN Fixed bearer token for 'serve --http' (default: random)
|
|
598
|
+
MELRA_HTTP_OAUTH Set to 0 so only that token gets in; no client can register
|
|
599
|
+
MELRA_BROWSER Optional Chrome/Chromium/Edge executable
|
|
600
|
+
MELRA_BROWSER_CDP_ENDPOINT Optional HTTP(S) CDP endpoint
|
|
601
|
+
MELRA_BROWSER_CDP_CONTEXT_INDEX External context index (-1 is last)
|
|
602
|
+
MELRA_BROWSER_HAR_PATH Absolute HAR output path
|
|
603
|
+
MELRA_BROWSER_HAR_REPLAY Absolute HAR to replay; nothing hits network
|
|
604
|
+
MELRA_BROWSER_PROFILE Absolute dir keeping cookies between runs
|
|
605
|
+
`);
|
|
606
|
+
}
|
|
607
|
+
async function main() {
|
|
608
|
+
const [command = "help", ...args] = process.argv.slice(2);
|
|
609
|
+
const parsed = parseCliEnvironment(process.env);
|
|
610
|
+
// `--unhinged` is an alias for the variable, not a second setting: one field
|
|
611
|
+
// carries the answer so no code path can consult the weaker of the two.
|
|
612
|
+
const env = args.includes("--unhinged")
|
|
613
|
+
? { ...parsed, unhinged: true }
|
|
614
|
+
: parsed;
|
|
615
|
+
// Before the command runs, and for every command including `help` — the mode
|
|
616
|
+
// is a property of the process, so there is no invocation where staying quiet
|
|
617
|
+
// about it is right.
|
|
618
|
+
if (env.unhinged) {
|
|
619
|
+
process.stderr.write(`${unhingedBanner(unconfinedRoot(env.workspaceRoot))}\n`);
|
|
620
|
+
}
|
|
621
|
+
switch (command) {
|
|
622
|
+
case "doctor": {
|
|
623
|
+
rejectUnknownFlags(args, []);
|
|
624
|
+
const { report, failed } = await doctor(env);
|
|
625
|
+
output(report);
|
|
626
|
+
process.exitCode = failed ? 1 : 0;
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
case "conformance": {
|
|
630
|
+
rejectUnknownFlags(args, ["--url", "--token", "--level"]);
|
|
631
|
+
const level = argument("--level", args);
|
|
632
|
+
if (level !== undefined && !["1", "2", "3"].includes(level)) {
|
|
633
|
+
throw new Error("--level must be 1, 2, or 3");
|
|
634
|
+
}
|
|
635
|
+
const url = argument("--url", args);
|
|
636
|
+
const token = argument("--token", args);
|
|
637
|
+
const { report, failed } = await runConformance({
|
|
638
|
+
...(url === undefined ? {} : { url }),
|
|
639
|
+
...(token === undefined ? {} : { token }),
|
|
640
|
+
...(level === undefined ? {} : { claim: Number(level) }),
|
|
641
|
+
});
|
|
642
|
+
output(report);
|
|
643
|
+
process.exitCode = failed ? 1 : 0;
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
case "init":
|
|
647
|
+
rejectUnknownFlags(args, ["--client"]);
|
|
648
|
+
output(await init(args, env));
|
|
649
|
+
return;
|
|
650
|
+
case "setup":
|
|
651
|
+
rejectUnknownFlags(args, ["--client"]);
|
|
652
|
+
process.exitCode = await setup(args, env);
|
|
653
|
+
return;
|
|
654
|
+
case "serve": {
|
|
655
|
+
rejectUnknownFlags(args, ["--http", "--port", "--open"]);
|
|
656
|
+
const melra = await runtime(env);
|
|
657
|
+
const http = args.includes("--http");
|
|
658
|
+
const port = argument("--port", args);
|
|
659
|
+
const server = http
|
|
660
|
+
? undefined
|
|
661
|
+
: await serveStdio(melra);
|
|
662
|
+
const endpoint = http
|
|
663
|
+
? await serveHttp({
|
|
664
|
+
runtime: melra,
|
|
665
|
+
...(port === undefined ? {} : { port: Number(port) }),
|
|
666
|
+
})
|
|
667
|
+
: undefined;
|
|
668
|
+
if (endpoint !== undefined) {
|
|
669
|
+
// stdout stays clean for the stdio transport's sake even here, so the
|
|
670
|
+
// one command an operator has to copy goes where they can read it.
|
|
671
|
+
process.stderr.write(`MELRA HTTP server listening on http://${endpoint.host}:${endpoint.port}\n` +
|
|
672
|
+
` Console: ${endpoint.url}\n` +
|
|
673
|
+
` MCP: ${endpoint.mcpUrl}\n` +
|
|
674
|
+
` Token: ${endpoint.token}\n` +
|
|
675
|
+
(endpoint.oauth
|
|
676
|
+
? `A client that cannot be given the token can register itself and ask\n` +
|
|
677
|
+
`you to approve it in a browser; approved clients are named on every\n` +
|
|
678
|
+
`receipt. Set MELRA_HTTP_OAUTH=0 to allow only the token above.\n`
|
|
679
|
+
: `OAuth is off, so the token above is the only way in.\n`) +
|
|
680
|
+
`Loopback only. Anyone who can read this token can drive this machine.\n`);
|
|
681
|
+
if (args.includes("--open"))
|
|
682
|
+
openInBrowser(endpoint.url);
|
|
683
|
+
}
|
|
684
|
+
const close = async () => {
|
|
685
|
+
await server?.close();
|
|
686
|
+
await endpoint?.close();
|
|
687
|
+
await melra.close();
|
|
688
|
+
};
|
|
689
|
+
process.once("SIGINT", () => void close().finally(() => process.exit(0)));
|
|
690
|
+
process.once("SIGTERM", () => void close().finally(() => process.exit(0)));
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
case "run":
|
|
694
|
+
rejectUnknownFlags(args, ["--request"]);
|
|
695
|
+
process.exitCode = await runTask(args, env);
|
|
696
|
+
return;
|
|
697
|
+
case "inspect":
|
|
698
|
+
case "export":
|
|
699
|
+
rejectUnknownFlags(args, []);
|
|
700
|
+
await inspectTask(args, env);
|
|
701
|
+
return;
|
|
702
|
+
case "workflow":
|
|
703
|
+
rejectUnknownFlags(args, ["--definition", "--approval", "--input"]);
|
|
704
|
+
process.exitCode = await workflowCommand(args, env);
|
|
705
|
+
return;
|
|
706
|
+
case "demo":
|
|
707
|
+
rejectUnknownFlags(args, []);
|
|
708
|
+
if (args[0] !== "durable-core") {
|
|
709
|
+
throw new Error("demo supports only 'durable-core'");
|
|
710
|
+
}
|
|
711
|
+
process.exitCode = await durableCoreDemo(env);
|
|
712
|
+
return;
|
|
713
|
+
case "clients":
|
|
714
|
+
rejectUnknownFlags(args, ["--revoke"]);
|
|
715
|
+
process.exitCode = clientsCommand(args, env);
|
|
716
|
+
return;
|
|
717
|
+
case "policy":
|
|
718
|
+
if (args[0] !== "test")
|
|
719
|
+
throw new Error("policy supports only 'test'");
|
|
720
|
+
rejectUnknownFlags(args.slice(1), ["--request"]);
|
|
721
|
+
await policyTest(args.slice(1), env);
|
|
722
|
+
return;
|
|
723
|
+
case "version":
|
|
724
|
+
case "--version":
|
|
725
|
+
case "-v":
|
|
726
|
+
process.stdout.write(`${PRODUCT_VERSION}\n`);
|
|
727
|
+
return;
|
|
728
|
+
case "help":
|
|
729
|
+
case "--help":
|
|
730
|
+
case "-h":
|
|
731
|
+
help();
|
|
732
|
+
return;
|
|
733
|
+
default:
|
|
734
|
+
throw new Error(`unknown command: ${basename(command)}`);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* A schema rejection, rendered as one line per problem.
|
|
739
|
+
*
|
|
740
|
+
* Zod's `message` is the JSON dump of its issue array, so an input missing one
|
|
741
|
+
* field used to fill the terminal with brackets and offsets. The useful part of
|
|
742
|
+
* each issue is where it is and what is wrong with it.
|
|
743
|
+
*
|
|
744
|
+
* Detected by shape rather than `instanceof ZodError` so the CLI does not take
|
|
745
|
+
* a direct zod dependency to print an error — every schema it parses comes from
|
|
746
|
+
* `@melra/protocol`, and matching on `issues` also survives a major zod bump.
|
|
747
|
+
*/
|
|
748
|
+
function schemaIssues(error) {
|
|
749
|
+
if (typeof error !== "object" || error === null)
|
|
750
|
+
return undefined;
|
|
751
|
+
const { issues } = error;
|
|
752
|
+
if (!Array.isArray(issues) || issues.length === 0)
|
|
753
|
+
return undefined;
|
|
754
|
+
const lines = issues.map((issue) => {
|
|
755
|
+
const path = (issue.path ?? [])
|
|
756
|
+
.map((segment) => typeof segment === "number" ? `[${segment}]` : `.${segment}`)
|
|
757
|
+
.join("")
|
|
758
|
+
.replace(/^\./, "");
|
|
759
|
+
const message = issue.message ?? "invalid value";
|
|
760
|
+
return ` ${path === "" ? "(root)" : path}: ${message}`;
|
|
761
|
+
});
|
|
762
|
+
return ["input does not match the schema:", ...lines].join("\n");
|
|
763
|
+
}
|
|
764
|
+
main().catch((error) => {
|
|
765
|
+
const message = schemaIssues(error) ??
|
|
766
|
+
(error instanceof Error ? error.message : String(error));
|
|
767
|
+
process.stderr.write(`melra: ${message}\n`);
|
|
768
|
+
process.exitCode = 1;
|
|
769
|
+
});
|
|
770
|
+
//# sourceMappingURL=index.js.map
|