@melra/cli 0.3.0-alpha.14 → 0.3.0-alpha.15
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/README.md +15 -8
- package/dist/activity.d.ts +6 -0
- package/dist/activity.d.ts.map +1 -1
- package/dist/activity.js +86 -14
- package/dist/activity.js.map +1 -1
- package/dist/approval-console.d.ts +42 -0
- package/dist/approval-console.d.ts.map +1 -0
- package/dist/approval-console.js +130 -0
- package/dist/approval-console.js.map +1 -0
- package/dist/bin.js +14 -2
- package/dist/bin.js.map +1 -1
- package/dist/conformance.d.ts +6 -0
- package/dist/conformance.d.ts.map +1 -1
- package/dist/conformance.js +27 -3
- package/dist/conformance.js.map +1 -1
- package/dist/environment.d.ts +6 -3
- package/dist/environment.d.ts.map +1 -1
- package/dist/environment.js +7 -5
- package/dist/environment.js.map +1 -1
- package/dist/human.d.ts +30 -5
- package/dist/human.d.ts.map +1 -1
- package/dist/human.js +101 -67
- package/dist/human.js.map +1 -1
- package/dist/index.js +295 -83
- package/dist/index.js.map +1 -1
- package/dist/isolation.d.ts.map +1 -1
- package/dist/isolation.js +3 -1
- package/dist/isolation.js.map +1 -1
- package/dist/node-support.d.ts +5 -0
- package/dist/node-support.d.ts.map +1 -0
- package/dist/node-support.js +16 -0
- package/dist/node-support.js.map +1 -0
- package/dist/output.js +1 -1
- package/dist/output.js.map +1 -1
- package/package.json +24 -10
- package/dist/approval-screen.d.ts +0 -35
- package/dist/approval-screen.d.ts.map +0 -1
- package/dist/approval-screen.js +0 -130
- package/dist/approval-screen.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -5,23 +5,22 @@ import { access, lstat, mkdir, readFile, stat, writeFile } from "node:fs/promise
|
|
|
5
5
|
import { constants } from "node:fs";
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
7
7
|
import { basename, dirname, join, resolve } from "node:path";
|
|
8
|
-
import { createInterface } from "node:readline/promises";
|
|
9
8
|
import { DatabaseSync } from "node:sqlite";
|
|
10
9
|
import { ApprovalResponseSchema, TaskRequestSchema, TaskStatusSchema, WorkflowDefinitionSchema, WorkflowInputSchema, PRODUCT_VERSION, deploymentMode, principalRef, } from "@melra/protocol";
|
|
11
|
-
import { assertEnforceable, confinedRoot, createMelraRuntime, serveHttp, OAuthProvider, clientPrincipal, serveStdio, unconfinedRoot, workspaceHoldsData, } from "@melra/server";
|
|
10
|
+
import { assertEnforceable, broadWorkspace, confinedRoot, createMelraRuntime, dataLocationAdvice, narrowWorkspaceAdvice, secretFiles, secretInsideWorkspace, serveHttp, OAuthProvider, clientPrincipal, printable, serveStdio, unconfinedRoot, workspaceHoldsData, workspaceInsideData, } from "@melra/server";
|
|
12
11
|
import { detectBrowserExecutable } from "@melra/browser-runtime";
|
|
13
12
|
import { createSystemComputerAdapter } from "@melra/computer-runtime";
|
|
14
|
-
import { capabilityUsageReader } from "@melra/runtime-core";
|
|
13
|
+
import { capabilityUsageReader, withDefaultEvidence } from "@melra/runtime-core";
|
|
15
14
|
import { SqliteStore } from "@melra/storage-sqlite";
|
|
16
15
|
import { classifyOperation, createDefaultPolicy, evaluatePolicy, loadPolicy, } from "@melra/policy-core";
|
|
17
16
|
import { approverLabel, parseCliEnvironment, serverLaunch, } from "./environment.js";
|
|
18
|
-
import {
|
|
17
|
+
import { approvalConsole, assertConsoleTerminal, confirmInTerminal } from "./approval-console.js";
|
|
19
18
|
import { runConformance } from "./conformance.js";
|
|
20
19
|
import { statusCommand, tasksCommand, watchCommand } from "./activity.js";
|
|
21
20
|
import { clientStatuses, clientsCheck, detectClients } from "./client-status.js";
|
|
22
21
|
import { RESTART_SAFE_WORKFLOW } from "./demo-workflow.js";
|
|
23
22
|
import { detectIsolation, isolationCheck } from "./isolation.js";
|
|
24
|
-
import { renderConformance, renderDoctor, renderInit, renderSetup, setupWizard, } from "./human.js";
|
|
23
|
+
import { broadWorkspaceWarning, confirmBroadWorkspace, prompter, renderConformance, renderDoctor, renderInit, renderSetup, setupWizard, } from "./human.js";
|
|
25
24
|
import { isHuman, paint, printJsonOrHuman } from "./output.js";
|
|
26
25
|
import { CLIENTS, clientConfig, clientConfigPath, resolveClient, writeClientConfig, } from "./clients.js";
|
|
27
26
|
/**
|
|
@@ -36,8 +35,9 @@ import { CLIENTS, clientConfig, clientConfigPath, resolveClient, writeClientConf
|
|
|
36
35
|
* runs wrote their own workspace into the shared ~/.melra/policy.json, and a
|
|
37
36
|
* server for any later project was then confined to the first one instead.
|
|
38
37
|
*
|
|
39
|
-
* A file that does not
|
|
40
|
-
*
|
|
38
|
+
* A file that does not load changes nothing here, the mode included: nothing in
|
|
39
|
+
* an invalid file is trusted to mean what it seems to. `doctor`, `status` and
|
|
40
|
+
* `tasks` report it, and `serve`, `run` and `approve` refuse to start on it.
|
|
41
41
|
*/
|
|
42
42
|
async function settle(env) {
|
|
43
43
|
const path = await existingPolicyPath(env);
|
|
@@ -160,7 +160,9 @@ function rejectUnknownFlags(args, known) {
|
|
|
160
160
|
]);
|
|
161
161
|
// A flag's own value can look like a flag (`--input node=--x`), so skip the
|
|
162
162
|
// slot after any known flag that takes one.
|
|
163
|
-
|
|
163
|
+
// Every known flag that takes no value. One missing here had its next
|
|
164
|
+
// token skipped unchecked, so `--write --dry-run` wrote the config.
|
|
165
|
+
const boolean = new Set(["--http", "--json", "--yes", "--deny", "--write", "--open"]);
|
|
164
166
|
const valued = new Set(known.filter((flag) => !boolean.has(flag)));
|
|
165
167
|
for (let index = 0; index < args.length; index += 1) {
|
|
166
168
|
const argument_ = args[index];
|
|
@@ -352,7 +354,7 @@ async function durableCoreDemo(env) {
|
|
|
352
354
|
}
|
|
353
355
|
}
|
|
354
356
|
/**
|
|
355
|
-
* Whether `MELRA_SOCKET` names somewhere `serve` can listen, and whether a
|
|
357
|
+
* Whether `MELRA_SOCKET` names somewhere `serve --socket` can listen, and whether a
|
|
356
358
|
* socket already there is one only this user can open. The mode is the whole
|
|
357
359
|
* point of the transport, so a wider one is a `fail`, not a `warn`.
|
|
358
360
|
*/
|
|
@@ -458,12 +460,15 @@ async function doctor(env) {
|
|
|
458
460
|
});
|
|
459
461
|
}
|
|
460
462
|
try {
|
|
461
|
-
await mkdir(env.dataDirectory, { recursive: true });
|
|
463
|
+
await mkdir(env.dataDirectory, { recursive: true, mode: 0o700 });
|
|
462
464
|
await access(env.dataDirectory, constants.R_OK | constants.W_OK);
|
|
465
|
+
// Created private above. One that already existed is left as it is, but
|
|
466
|
+
// said to be open: the task database inside is private, its directory not.
|
|
467
|
+
const open = process.platform !== "win32" && ((await stat(env.dataDirectory)).mode & 0o077) !== 0;
|
|
463
468
|
checks.push({
|
|
464
469
|
name: "data-directory",
|
|
465
|
-
status: "pass",
|
|
466
|
-
detail: env.dataDirectory,
|
|
470
|
+
status: open ? "warn" : "pass",
|
|
471
|
+
detail: open ? `${env.dataDirectory} can be read by other users; run chmod 700 on it` : env.dataDirectory,
|
|
467
472
|
});
|
|
468
473
|
}
|
|
469
474
|
catch {
|
|
@@ -474,20 +479,33 @@ async function doctor(env) {
|
|
|
474
479
|
});
|
|
475
480
|
}
|
|
476
481
|
// Only once both exist as directories: the check would create a missing
|
|
477
|
-
// workspace.
|
|
478
|
-
|
|
479
|
-
if (!env.unhinged &&
|
|
480
|
-
checks.every((check) => !["workspace", "data-directory"].includes(check.name) || check.status === "pass")) {
|
|
482
|
+
// workspace.
|
|
483
|
+
if (checks.every((check) => !["workspace", "data-directory"].includes(check.name) || check.status !== "fail")) {
|
|
481
484
|
try {
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
? {
|
|
485
|
+
if (await workspaceInsideData(env.workspaceRoot, env.dataDirectory)) {
|
|
486
|
+
checks.push({
|
|
485
487
|
name: "data-location",
|
|
486
|
-
// The server refuses to start this way in
|
|
487
|
-
status:
|
|
488
|
-
detail:
|
|
489
|
-
|
|
490
|
-
|
|
488
|
+
// The server refuses to start this way in every mode.
|
|
489
|
+
status: "fail",
|
|
490
|
+
detail: `workspace_inside_data_directory: ${env.workspaceRoot} is inside ${env.dataDirectory}, ` +
|
|
491
|
+
"where every path is MELRA's own; point MELRA_WORKSPACE at the project",
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
else if (!env.unhinged) {
|
|
495
|
+
// Not in unsafe-local, which reaches every file on the machine and
|
|
496
|
+
// whose guardrails warning already says so.
|
|
497
|
+
const inside = await workspaceHoldsData(env.workspaceRoot, env.dataDirectory);
|
|
498
|
+
checks.push(inside
|
|
499
|
+
? {
|
|
500
|
+
name: "data-location",
|
|
501
|
+
// The server refuses to start this way in enforced mode.
|
|
502
|
+
status: env.mode === "enforced" ? "fail" : "warn",
|
|
503
|
+
detail: `${env.dataDirectory} is inside the workspace, where a recursive read reaches payload.key without naming it`,
|
|
504
|
+
// As the server words its refusal.
|
|
505
|
+
next: await dataLocationAdvice(env.workspaceRoot, env.dataDirectory, env.policyPath),
|
|
506
|
+
}
|
|
507
|
+
: { name: "data-location", status: "pass", detail: "outside the workspace" });
|
|
508
|
+
}
|
|
491
509
|
}
|
|
492
510
|
catch (error) {
|
|
493
511
|
checks.push({
|
|
@@ -511,12 +529,21 @@ async function doctor(env) {
|
|
|
511
529
|
});
|
|
512
530
|
}
|
|
513
531
|
const browser = env.browserExecutablePath ?? (await detectBrowserExecutable());
|
|
514
|
-
checks.push(
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
"
|
|
519
|
-
|
|
532
|
+
checks.push(
|
|
533
|
+
// An attached browser may be the one the operator browses and approves in.
|
|
534
|
+
env.browserCdpEndpoint !== undefined
|
|
535
|
+
? {
|
|
536
|
+
name: "browser",
|
|
537
|
+
status: "warn",
|
|
538
|
+
detail: `attached over CDP to ${env.browserCdpEndpoint}: any tab open there, signed-in sites included, ` +
|
|
539
|
+
"is a tab_switch away from the agent; attach to a browser you do not use yourself",
|
|
540
|
+
}
|
|
541
|
+
: {
|
|
542
|
+
name: "browser",
|
|
543
|
+
status: browser === undefined ? "warn" : "pass",
|
|
544
|
+
detail: browser ??
|
|
545
|
+
"Chrome, Chromium, or Edge not found; non-browser capabilities remain available",
|
|
546
|
+
});
|
|
520
547
|
const computer = await createSystemComputerAdapter().capabilities();
|
|
521
548
|
checks.push({
|
|
522
549
|
name: "computer",
|
|
@@ -525,16 +552,28 @@ async function doctor(env) {
|
|
|
525
552
|
? `${computer.adapter}: screenshot=${computer.screenshot}, pointer=${computer.pointer}, keyboard=${computer.keyboard}, scroll=${computer.scroll}`
|
|
526
553
|
: computer.limitations.join("; "),
|
|
527
554
|
});
|
|
555
|
+
let credentials = {};
|
|
528
556
|
try {
|
|
529
557
|
const path = await existingPolicyPath(env);
|
|
558
|
+
let allowLocalhost;
|
|
530
559
|
// The server's own parser, so a bad `mode` or grant fails here too.
|
|
531
560
|
if (path !== undefined)
|
|
532
|
-
await loadPolicy(path, env.workspaceRoot);
|
|
561
|
+
({ credentials, allowLocalhost } = await loadPolicy(path, env.workspaceRoot));
|
|
533
562
|
checks.push({
|
|
534
563
|
name: "policy",
|
|
535
564
|
status: "pass",
|
|
536
565
|
detail: path ?? "using safe built-in defaults",
|
|
537
566
|
});
|
|
567
|
+
// Enforced mode keeps loopback closed unless the file opens it, so say so
|
|
568
|
+
// when it does: the services this machine runs for itself are behind it.
|
|
569
|
+
if (env.mode === "enforced" && allowLocalhost === true) {
|
|
570
|
+
checks.push({
|
|
571
|
+
name: "loopback",
|
|
572
|
+
status: "warn",
|
|
573
|
+
detail: `${path} sets allowLocalhost, so HTTP and browser effects can reach services on this machine's loopback`,
|
|
574
|
+
next: `remove allowLocalhost from ${path} unless a task needs a service on this machine`,
|
|
575
|
+
});
|
|
576
|
+
}
|
|
538
577
|
}
|
|
539
578
|
catch (error) {
|
|
540
579
|
checks.push({
|
|
@@ -543,6 +582,44 @@ async function doctor(env) {
|
|
|
543
582
|
detail: error instanceof Error ? error.message : String(error),
|
|
544
583
|
});
|
|
545
584
|
}
|
|
585
|
+
// Where the browser's and the credential broker's secret files lie, judged as
|
|
586
|
+
// the server judges them before it starts. Only once the workspace exists:
|
|
587
|
+
// the check would create it.
|
|
588
|
+
const secrets = secretFiles(env, credentials);
|
|
589
|
+
if (secrets.size > 0 && checks.some((check) => check.name === "workspace" && check.status === "pass")) {
|
|
590
|
+
try {
|
|
591
|
+
const profile = secrets.get("MELRA_BROWSER_PROFILE");
|
|
592
|
+
if (profile !== undefined && (await workspaceInsideData(env.workspaceRoot, profile))) {
|
|
593
|
+
checks.push({
|
|
594
|
+
name: "secret-location",
|
|
595
|
+
// The server refuses to start this way in every mode.
|
|
596
|
+
status: "fail",
|
|
597
|
+
detail: `workspace_inside_browser_profile: ${env.workspaceRoot} is inside ${profile}`,
|
|
598
|
+
next: "point MELRA_WORKSPACE at the project, outside MELRA_BROWSER_PROFILE",
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
else if (!env.unhinged) {
|
|
602
|
+
// Not in unsafe-local, as for the data directory.
|
|
603
|
+
const held = await secretInsideWorkspace(env.workspaceRoot, secrets);
|
|
604
|
+
checks.push(held === undefined
|
|
605
|
+
? { name: "secret-location", status: "pass", detail: "outside the workspace" }
|
|
606
|
+
: {
|
|
607
|
+
name: "secret-location",
|
|
608
|
+
// The server refuses to start this way in enforced mode.
|
|
609
|
+
status: env.mode === "enforced" ? "fail" : "warn",
|
|
610
|
+
detail: `${held} names ${secrets.get(held)}, inside the workspace, where a recursive read reaches it without naming it`,
|
|
611
|
+
next: `move ${secrets.get(held)} out of the workspace`,
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
catch (error) {
|
|
616
|
+
checks.push({
|
|
617
|
+
name: "secret-location",
|
|
618
|
+
status: "fail",
|
|
619
|
+
detail: `could not resolve the workspace: ${error instanceof Error ? error.message : String(error)}`,
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
}
|
|
546
623
|
if (env.socketPath !== undefined)
|
|
547
624
|
checks.push(await socketCheck(env.socketPath));
|
|
548
625
|
checks.push(isolationCheck(await detectIsolation(), env.mode));
|
|
@@ -566,7 +643,7 @@ async function doctor(env) {
|
|
|
566
643
|
};
|
|
567
644
|
}
|
|
568
645
|
async function init(args, env) {
|
|
569
|
-
await mkdir(env.dataDirectory, { recursive: true });
|
|
646
|
+
await mkdir(env.dataDirectory, { recursive: true, mode: 0o700 });
|
|
570
647
|
// The operator's MELRA_POLICY when there is one: writing the default path
|
|
571
648
|
// into the client config instead quietly swapped their policy for ours.
|
|
572
649
|
const policyPath = env.policyPath ?? join(env.dataDirectory, "policy.json");
|
|
@@ -579,8 +656,10 @@ async function init(args, env) {
|
|
|
579
656
|
}
|
|
580
657
|
// No `workspaceRoot`: this file is shared by every project's server, and
|
|
581
658
|
// each config `setup` writes names its own workspace in MELRA_WORKSPACE.
|
|
659
|
+
// In the mode asked for, so `approve` and `console` run from a shell agree
|
|
660
|
+
// with the server on it; a policy that already exists is the operator's.
|
|
582
661
|
const { workspaceRoot: _shared, ...policy } = createDefaultPolicy(env.workspaceRoot);
|
|
583
|
-
await writeFile(policyPath, `${JSON.stringify(policy, null, 2)}\n`, { flag: "wx" });
|
|
662
|
+
await writeFile(policyPath, `${JSON.stringify({ ...policy, mode: env.mode }, null, 2)}\n`, { flag: "wx" });
|
|
584
663
|
}
|
|
585
664
|
const client = resolveClient(argument("--client", args) ?? "generic");
|
|
586
665
|
const label = CLIENTS[client]?.label ?? client;
|
|
@@ -590,6 +669,9 @@ async function init(args, env) {
|
|
|
590
669
|
MELRA_WORKSPACE: env.workspaceRoot,
|
|
591
670
|
MELRA_HOME: env.dataDirectory,
|
|
592
671
|
MELRA_POLICY: policyPath,
|
|
672
|
+
// The client starts the server with this env, not with our flags: without
|
|
673
|
+
// it `--mode enforced` would be reported here and a developer server run.
|
|
674
|
+
...(env.mode === "enforced" ? { MELRA_MODE: "enforced" } : {}),
|
|
593
675
|
},
|
|
594
676
|
});
|
|
595
677
|
const configPath = clientConfigPath(client, env.workspaceRoot);
|
|
@@ -602,6 +684,7 @@ async function init(args, env) {
|
|
|
602
684
|
return {
|
|
603
685
|
initialized: true,
|
|
604
686
|
client,
|
|
687
|
+
workspace: env.workspaceRoot,
|
|
605
688
|
policyPath,
|
|
606
689
|
configPath,
|
|
607
690
|
written: true,
|
|
@@ -616,6 +699,7 @@ async function init(args, env) {
|
|
|
616
699
|
return {
|
|
617
700
|
initialized: true,
|
|
618
701
|
client,
|
|
702
|
+
workspace: env.workspaceRoot,
|
|
619
703
|
policyPath,
|
|
620
704
|
...(configPath === undefined ? {} : { configPath }),
|
|
621
705
|
config,
|
|
@@ -625,22 +709,53 @@ async function init(args, env) {
|
|
|
625
709
|
: `Add this to ${configPath}, or rerun with --write to merge it there for you.`,
|
|
626
710
|
};
|
|
627
711
|
}
|
|
712
|
+
// A broad workspace (home, a directory holding it, or a filesystem root) hands
|
|
713
|
+
// the agent every project at once, and a fresh terminal starts in home.
|
|
714
|
+
// MELRA_WORKSPACE naming it is a choice already made; otherwise a person at a
|
|
715
|
+
// terminal is asked, defaulting to no, and anything else is refused before a
|
|
716
|
+
// file is written.
|
|
717
|
+
async function workspaceAgreed(env, reader) {
|
|
718
|
+
const broad = env.workspaceSet === true ? undefined : await broadWorkspace(env.workspaceRoot);
|
|
719
|
+
if (broad === undefined)
|
|
720
|
+
return true;
|
|
721
|
+
const warning = broadWorkspaceWarning(env.workspaceRoot, broad, narrowWorkspaceAdvice(env.dataDirectory, env.policyPath));
|
|
722
|
+
if (reader === undefined) {
|
|
723
|
+
throw new Error(`workspace_too_broad: ${warning} (MELRA_WORKSPACE=${env.workspaceRoot} to use it anyway)`);
|
|
724
|
+
}
|
|
725
|
+
if (await confirmBroadWorkspace(reader, env.workspaceRoot, warning))
|
|
726
|
+
return true;
|
|
727
|
+
process.stdout.write("Nothing was written.\n");
|
|
728
|
+
return false;
|
|
729
|
+
}
|
|
730
|
+
// Only a person at a terminal on both ends is asked anything. Anything else —
|
|
731
|
+
// a pipe, a test, `--json` — gets the non-interactive behaviour.
|
|
732
|
+
function questions(args) {
|
|
733
|
+
return isHuman(args) && process.stdin.isTTY ? prompter({ input: process.stdin, output: process.stdout }) : undefined;
|
|
734
|
+
}
|
|
628
735
|
// One command for the whole local setup: write the policy, emit a working
|
|
629
736
|
// client config, and verify the machine can actually run it.
|
|
630
737
|
async function setup(args, env) {
|
|
631
738
|
let chosen = args;
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
if (
|
|
637
|
-
|
|
638
|
-
|
|
739
|
+
// One reader for the workspace question and the wizard, so answers typed
|
|
740
|
+
// ahead of the second are not dropped with the first.
|
|
741
|
+
const reader = questions(args);
|
|
742
|
+
try {
|
|
743
|
+
if (!(await workspaceAgreed(env, reader)))
|
|
744
|
+
return 1;
|
|
745
|
+
if (argument("--client", args) === undefined && reader !== undefined) {
|
|
746
|
+
const answer = await setupWizard(reader, detectClients(env.workspaceRoot));
|
|
747
|
+
if (answer === undefined) {
|
|
748
|
+
process.stdout.write("\nCancelled; nothing was written.\n");
|
|
749
|
+
return 130;
|
|
750
|
+
}
|
|
751
|
+
process.stdout.write("\n");
|
|
752
|
+
// The same arguments the person could have typed, so the wizard cannot
|
|
753
|
+
// drift from what `--client X --write` does.
|
|
754
|
+
chosen = ["--client", answer.client, ...(answer.write ? ["--write"] : [])];
|
|
639
755
|
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
chosen = ["--client", answer.client, ...(answer.write ? ["--write"] : [])];
|
|
756
|
+
}
|
|
757
|
+
finally {
|
|
758
|
+
reader?.close();
|
|
644
759
|
}
|
|
645
760
|
const initialized = await init(chosen, env);
|
|
646
761
|
const { report, failed } = await doctor(env);
|
|
@@ -679,14 +794,6 @@ async function runTask(args, env) {
|
|
|
679
794
|
await melra.close();
|
|
680
795
|
}
|
|
681
796
|
}
|
|
682
|
-
/** The effect as an approver needs to see it, on stderr, then y/N. */
|
|
683
|
-
async function confirmInTerminal(view) {
|
|
684
|
-
process.stderr.write(approvalScreen(view));
|
|
685
|
-
const prompt = createInterface({ input: process.stdin, output: process.stderr });
|
|
686
|
-
const answer = (await prompt.question("Approve this effect? [y/N] ")).trim().toLowerCase();
|
|
687
|
-
prompt.close();
|
|
688
|
-
return answer === "y" || answer === "yes";
|
|
689
|
-
}
|
|
690
797
|
/**
|
|
691
798
|
* Records an approval for a task another process planned, without running it:
|
|
692
799
|
* the agent that planned it runs it, and the decision lets it.
|
|
@@ -725,10 +832,8 @@ async function approveCommand(args, env) {
|
|
|
725
832
|
});
|
|
726
833
|
return 3;
|
|
727
834
|
}
|
|
728
|
-
if (!(await confirmInTerminal(view)))
|
|
729
|
-
process.stderr.write("Not approved.\n");
|
|
835
|
+
if (!(await confirmInTerminal(view)))
|
|
730
836
|
return 1;
|
|
731
|
-
}
|
|
732
837
|
}
|
|
733
838
|
const decision = melra.controller.decide(taskId, view.renderDigest, approver);
|
|
734
839
|
if (decision.channel === "in_band") {
|
|
@@ -757,7 +862,10 @@ async function inspectTask(args, env) {
|
|
|
757
862
|
}
|
|
758
863
|
}
|
|
759
864
|
async function policyTest(args, env) {
|
|
760
|
-
|
|
865
|
+
// With the evidence the server derives for a mutation that declares none, as
|
|
866
|
+
// plan does: without it, a file write the server would ask approval for was
|
|
867
|
+
// previewed as denied for want of evidence.
|
|
868
|
+
const request = withDefaultEvidence(await readTaskRequest(args));
|
|
761
869
|
// Reports what this process would actually decide, unhinged mode included —
|
|
762
870
|
// a dry run that disagreed with the server it is previewing is worse than
|
|
763
871
|
// none. That is also why the operator's own `policy.json` is loaded rather
|
|
@@ -782,6 +890,7 @@ async function policyTest(args, env) {
|
|
|
782
890
|
output({
|
|
783
891
|
...evaluatePolicy(taskId, request, policy, capabilityUsageReader(store)),
|
|
784
892
|
classified: { capability, target },
|
|
893
|
+
requiredEvidence: request.requiredEvidence,
|
|
785
894
|
});
|
|
786
895
|
}
|
|
787
896
|
finally {
|
|
@@ -796,10 +905,18 @@ async function policyTest(args, env) {
|
|
|
796
905
|
function clientsCommand(args, env) {
|
|
797
906
|
const oauth = new OAuthProvider(env.dataDirectory);
|
|
798
907
|
const revoke = argument("--revoke", args);
|
|
799
|
-
if (revoke
|
|
908
|
+
if (args.includes("--revoke")) {
|
|
909
|
+
// A bare --revoke listed the clients and exited 0, and a mistyped id
|
|
910
|
+
// reported a revocation that touched nothing.
|
|
911
|
+
if (revoke === undefined || revoke.trim() === "") {
|
|
912
|
+
throw new Error("--revoke requires a client id, or all");
|
|
913
|
+
}
|
|
914
|
+
if (revoke !== "all" && !oauth.clients().some((client) => client.id === revoke)) {
|
|
915
|
+
throw new Error(`no such client: ${printable(revoke)}; 'melra clients' lists them`);
|
|
916
|
+
}
|
|
800
917
|
const dropped = oauth.revoke(revoke === "all" ? undefined : revoke);
|
|
801
918
|
process.stdout.write(`Revoked ${dropped} token${dropped === 1 ? "" : "s"}. ` +
|
|
802
|
-
|
|
919
|
+
`${revoke === "all" ? "Every client" : "The client"} must be approved again before it can act.\n`);
|
|
803
920
|
return 0;
|
|
804
921
|
}
|
|
805
922
|
const clients = oauth.clients();
|
|
@@ -817,12 +934,14 @@ function clientsCommand(args, env) {
|
|
|
817
934
|
return 0;
|
|
818
935
|
}
|
|
819
936
|
/**
|
|
820
|
-
* Hands the console URL to whatever opens links here
|
|
937
|
+
* Hands the console URL to whatever opens links here, and says whether anything
|
|
938
|
+
* took it: the helper ran and, if it finished within a few seconds, succeeded.
|
|
821
939
|
*
|
|
822
940
|
* The URL carries the token, so this is the difference between copying a secret
|
|
823
|
-
* out of a terminal and the console simply being open.
|
|
824
|
-
* purpose: the URL is already on screen, and a server that refused
|
|
825
|
-
* because a desktop helper is missing would be worse than one you
|
|
941
|
+
* out of a terminal and the console simply being open. `serve --open` ignores a
|
|
942
|
+
* failure on purpose: the URL is already on screen, and a server that refused
|
|
943
|
+
* to start because a desktop helper is missing would be worse than one you
|
|
944
|
+
* click into. `melra console` prints no URL, so it has to know.
|
|
826
945
|
*/
|
|
827
946
|
function openInBrowser(url) {
|
|
828
947
|
const [command, ...prefix] = process.platform === "darwin"
|
|
@@ -834,8 +953,18 @@ function openInBrowser(url) {
|
|
|
834
953
|
stdio: "ignore",
|
|
835
954
|
detached: true,
|
|
836
955
|
});
|
|
837
|
-
child.on("error", () => { });
|
|
838
956
|
child.unref();
|
|
957
|
+
return new Promise((resolveOpened) => {
|
|
958
|
+
// ponytail: a helper still running after 3s is taken to have handed the URL
|
|
959
|
+
// on; some xdg-open fallbacks only return when the browser closes.
|
|
960
|
+
const handedOn = setTimeout(() => resolveOpened(true), 3_000);
|
|
961
|
+
handedOn.unref();
|
|
962
|
+
child.once("error", () => resolveOpened(false));
|
|
963
|
+
child.once("exit", (code) => {
|
|
964
|
+
clearTimeout(handedOn);
|
|
965
|
+
resolveOpened(code === 0);
|
|
966
|
+
});
|
|
967
|
+
});
|
|
839
968
|
}
|
|
840
969
|
function help() {
|
|
841
970
|
process.stdout.write(`MELRA ${PRODUCT_VERSION}
|
|
@@ -845,10 +974,11 @@ Usage:
|
|
|
845
974
|
melra doctor [--json]
|
|
846
975
|
melra conformance [--url <mcp-url> --token <token>] [--level <1|2|3>]
|
|
847
976
|
melra init [--client <name>] [--write] [--json]
|
|
848
|
-
melra serve [--http
|
|
977
|
+
melra serve [--http [--port <port>] [--open]]
|
|
849
978
|
melra serve --socket <path>
|
|
850
979
|
melra run --request <task.json>
|
|
851
980
|
melra approve <task-id> [--yes] [--deny] [--json]
|
|
981
|
+
melra console
|
|
852
982
|
melra inspect <task-id>
|
|
853
983
|
melra tasks [--limit <n>] [--status <status>] [--json]
|
|
854
984
|
melra watch [--json]
|
|
@@ -866,9 +996,23 @@ Usage:
|
|
|
866
996
|
melra policy test --request <task.json>
|
|
867
997
|
melra version [--json]
|
|
868
998
|
|
|
999
|
+
'melra console', run by you in a terminal, opens an approval console in your
|
|
1000
|
+
browser, on a server of its own with no MCP endpoint, that approves or denies
|
|
1001
|
+
as 'melra approve' does. It pairs with the first page to use its code, normally
|
|
1002
|
+
the tab it opens. Its one-time pairing code is never printed: it reaches the
|
|
1003
|
+
browser on its opener's command line, and reloading or leaving the page ends
|
|
1004
|
+
the pairing. Run it with the MELRA_HOME, MELRA_WORKSPACE and mode your agent's
|
|
1005
|
+
server uses. Without a terminal or a browser to open, it refuses to start.
|
|
1006
|
+
|
|
869
1007
|
In a terminal, doctor, setup, init, and version print a readable report, and
|
|
870
1008
|
'melra setup' with no --client asks which detected client to set up. Piped or
|
|
871
|
-
captured, they print JSON (version: the bare number)
|
|
1009
|
+
captured, they print JSON (version: the bare number).
|
|
1010
|
+
|
|
1011
|
+
The workspace setup and init write into the config is the current directory,
|
|
1012
|
+
unless MELRA_WORKSPACE (or a MELRA_POLICY file's workspaceRoot) names one. If
|
|
1013
|
+
that is your home directory, a directory holding it, or a filesystem root,
|
|
1014
|
+
they ask first in a terminal, and refuse with workspace_too_broad otherwise;
|
|
1015
|
+
setting MELRA_WORKSPACE to it skips the question.
|
|
872
1016
|
|
|
873
1017
|
Flags:
|
|
874
1018
|
--json Print JSON even in a terminal, and never ask a question.
|
|
@@ -891,11 +1035,13 @@ Flags:
|
|
|
891
1035
|
--http Serve MCP over loopback HTTP instead of stdio, alongside a
|
|
892
1036
|
read-only REST API, a workflow event stream, and the console.
|
|
893
1037
|
Prints a bearer token; every request must carry it.
|
|
894
|
-
--port <port> HTTP port (default: 7457, or MELRA_HTTP_PORT).
|
|
1038
|
+
--port <port> HTTP port, with --http (default: 7457, or MELRA_HTTP_PORT).
|
|
895
1039
|
--socket <path> Serve what --http serves on a Unix socket created mode 0600,
|
|
896
1040
|
so only this OS user can connect; the token is still
|
|
897
|
-
required. Not on Windows.
|
|
898
|
-
|
|
1041
|
+
required. Not on Windows. This flag, not MELRA_SOCKET,
|
|
1042
|
+
moves 'serve' off stdio.
|
|
1043
|
+
--open With --http, open the console in your browser once it is
|
|
1044
|
+
listening.
|
|
899
1045
|
--url <mcp-url> Endpoint for 'conformance'. Default: spawn 'melra serve'.
|
|
900
1046
|
--level <1|2|3> Conformance level being claimed (default: 3). The run exits
|
|
901
1047
|
non-zero if the endpoint falls short of it.
|
|
@@ -911,11 +1057,12 @@ Environment:
|
|
|
911
1057
|
MELRA_POLICY Optional local policy JSON
|
|
912
1058
|
MELRA_UNHINGED Set to 1 to disable every guardrail (see --unsafe-local)
|
|
913
1059
|
MELRA_MODE developer (default) or enforced (see --mode)
|
|
914
|
-
MELRA_APPROVER Name 'melra approve'
|
|
1060
|
+
MELRA_APPROVER Name 'melra approve' and 'melra console' record as the
|
|
1061
|
+
approver (default: operator)
|
|
915
1062
|
MELRA_HTTP_PORT Port for 'serve --http' (default: 7457)
|
|
916
1063
|
MELRA_HTTP_TOKEN Fixed bearer token for 'serve --http' (default: random)
|
|
917
1064
|
MELRA_HTTP_OAUTH Set to 0 so only that token gets in; no client can register
|
|
918
|
-
MELRA_SOCKET
|
|
1065
|
+
MELRA_SOCKET Socket path 'doctor' and 'status' check (serve needs --socket)
|
|
919
1066
|
MELRA_BROWSER Optional Chrome/Chromium/Edge executable
|
|
920
1067
|
MELRA_BROWSER_CDP_ENDPOINT Optional HTTP(S) CDP endpoint
|
|
921
1068
|
MELRA_BROWSER_CDP_CONTEXT_INDEX External context index (-1 is last)
|
|
@@ -978,15 +1125,26 @@ async function main() {
|
|
|
978
1125
|
...(url === undefined ? {} : { url }),
|
|
979
1126
|
...(token === undefined ? {} : { token }),
|
|
980
1127
|
...(level === undefined ? {} : { claim: Number(level) }),
|
|
1128
|
+
// This process's flags, which a server it spawns gets only as env.
|
|
1129
|
+
serverEnv: {
|
|
1130
|
+
...(env.mode === "enforced" ? { MELRA_MODE: "enforced" } : {}),
|
|
1131
|
+
...(env.unhinged ? { MELRA_UNHINGED: "1" } : {}),
|
|
1132
|
+
},
|
|
981
1133
|
});
|
|
982
1134
|
printJsonOrHuman(report, renderConformance, args);
|
|
983
1135
|
process.exitCode = failed ? 1 : 0;
|
|
984
1136
|
return;
|
|
985
1137
|
}
|
|
986
|
-
case "init":
|
|
1138
|
+
case "init": {
|
|
987
1139
|
rejectUnknownFlags(args, ["--client", "--write", "--json"]);
|
|
1140
|
+
const reader = questions(args);
|
|
1141
|
+
if (!(await workspaceAgreed(env, reader).finally(() => reader?.close()))) {
|
|
1142
|
+
process.exitCode = 1;
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
988
1145
|
printJsonOrHuman(await init(args, env), renderInit, args);
|
|
989
1146
|
return;
|
|
1147
|
+
}
|
|
990
1148
|
case "setup":
|
|
991
1149
|
rejectUnknownFlags(args, ["--client", "--write", "--json"]);
|
|
992
1150
|
process.exitCode = await setup(args, env);
|
|
@@ -996,15 +1154,28 @@ async function main() {
|
|
|
996
1154
|
if (args.includes("--socket") && argument("--socket", args) === undefined) {
|
|
997
1155
|
throw new Error("--socket requires a path");
|
|
998
1156
|
}
|
|
999
|
-
|
|
1157
|
+
// Only the flag: an exported MELRA_SOCKET would otherwise move every
|
|
1158
|
+
// stdio client launched from that shell off stdio, to wait forever.
|
|
1159
|
+
const socketPath = argument("--socket", args);
|
|
1000
1160
|
const http = args.includes("--http");
|
|
1001
1161
|
const port = argument("--port", args);
|
|
1002
1162
|
// One transport per process. Quietly preferring one would leave an
|
|
1003
1163
|
// operator believing they had the other — and the difference between a
|
|
1004
1164
|
// socket only they can open and a port anyone local can is the point.
|
|
1005
1165
|
if (socketPath !== undefined &&
|
|
1006
|
-
|
|
1007
|
-
|
|
1166
|
+
// The flag, not its value: a --port with nothing after it is still one.
|
|
1167
|
+
(http || args.includes("--port") || args.includes("--open"))) {
|
|
1168
|
+
throw new Error("--socket replaces --http, --port, and --open; pass one transport");
|
|
1169
|
+
}
|
|
1170
|
+
// The same mistake the other way round: without --http, serve speaks
|
|
1171
|
+
// stdio, and a port nobody listens on looks like a server that hangs.
|
|
1172
|
+
if (socketPath === undefined && !http && (args.includes("--port") || args.includes("--open"))) {
|
|
1173
|
+
throw new Error("--port and --open need --http; without it, serve speaks MCP over stdio");
|
|
1174
|
+
}
|
|
1175
|
+
// `--port "$PORT"` with PORT unset is Number(""), which is 0: a random
|
|
1176
|
+
// port on every start, the bug a blank MELRA_HTTP_PORT had.
|
|
1177
|
+
if (args.includes("--port") && (port === undefined || port.trim() === "")) {
|
|
1178
|
+
throw new Error("--port requires a port; leave it out for the default (7457, or MELRA_HTTP_PORT), or pass 0 for any free port");
|
|
1008
1179
|
}
|
|
1009
1180
|
const melra = await runtime(env);
|
|
1010
1181
|
const server = http || socketPath !== undefined ? undefined : await serveStdio(melra);
|
|
@@ -1017,14 +1188,6 @@ async function main() {
|
|
|
1017
1188
|
})
|
|
1018
1189
|
: undefined;
|
|
1019
1190
|
if (endpoint?.socketPath !== undefined) {
|
|
1020
|
-
// An exported MELRA_SOCKET also reaches every stdio client spawned from
|
|
1021
|
-
// that shell, which then waits forever for JSON-RPC on stdout. Name the
|
|
1022
|
-
// variable where those clients log stderr, so the hang has a cause.
|
|
1023
|
-
if (argument("--socket", args) === undefined) {
|
|
1024
|
-
process.stderr.write(`melra: MELRA_SOCKET is set, so 'serve' is on the socket below, not stdio.\n` +
|
|
1025
|
-
` An MCP client that started this process will get no reply; unset\n` +
|
|
1026
|
-
` MELRA_SOCKET for it, or pass --socket explicitly when you mean it.\n`);
|
|
1027
|
-
}
|
|
1028
1191
|
process.stderr.write(`MELRA server listening on Unix socket ${endpoint.socketPath} (mode 0600)\n` +
|
|
1029
1192
|
` MCP: ${endpoint.mcpUrl}, connecting to that socket\n` +
|
|
1030
1193
|
` Token: ${endpoint.token}\n` +
|
|
@@ -1053,7 +1216,7 @@ async function main() {
|
|
|
1053
1216
|
: "") +
|
|
1054
1217
|
`Loopback only. Anyone who can read this token can drive this machine.\n`);
|
|
1055
1218
|
if (args.includes("--open"))
|
|
1056
|
-
openInBrowser(endpoint.url);
|
|
1219
|
+
void openInBrowser(endpoint.url);
|
|
1057
1220
|
}
|
|
1058
1221
|
const close = async () => {
|
|
1059
1222
|
await server?.close();
|
|
@@ -1068,6 +1231,28 @@ async function main() {
|
|
|
1068
1231
|
rejectUnknownFlags(args, ["--request"]);
|
|
1069
1232
|
process.exitCode = await runTask(args, env);
|
|
1070
1233
|
return;
|
|
1234
|
+
case "console": {
|
|
1235
|
+
rejectUnknownFlags(args, []);
|
|
1236
|
+
const interactive = process.stdin.isTTY === true && process.stderr.isTTY === true;
|
|
1237
|
+
// Before the runtime, which would open, and could create, the database.
|
|
1238
|
+
assertConsoleTerminal(interactive);
|
|
1239
|
+
const melra = await runtime(env);
|
|
1240
|
+
const endpoint = await approvalConsole(env, melra, {
|
|
1241
|
+
interactive,
|
|
1242
|
+
open: openInBrowser,
|
|
1243
|
+
say: (text) => process.stderr.write(text),
|
|
1244
|
+
}).catch(async (error) => {
|
|
1245
|
+
await melra.close();
|
|
1246
|
+
throw error;
|
|
1247
|
+
});
|
|
1248
|
+
const close = async () => {
|
|
1249
|
+
await endpoint.close();
|
|
1250
|
+
await melra.close();
|
|
1251
|
+
};
|
|
1252
|
+
process.once("SIGINT", () => void close().finally(() => process.exit(0)));
|
|
1253
|
+
process.once("SIGTERM", () => void close().finally(() => process.exit(0)));
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1071
1256
|
case "approve":
|
|
1072
1257
|
rejectUnknownFlags(args, ["--yes", "--deny", "--json"]);
|
|
1073
1258
|
process.exitCode = await approveCommand(args, env);
|
|
@@ -1079,15 +1264,42 @@ async function main() {
|
|
|
1079
1264
|
return;
|
|
1080
1265
|
case "tasks": {
|
|
1081
1266
|
rejectUnknownFlags(args, ["--limit", "--status", "--json"]);
|
|
1267
|
+
// A trailing --status, as an unquoted `--status $S` sends with S unset,
|
|
1268
|
+
// listed every status: an unfiltered list that looked filtered.
|
|
1269
|
+
for (const flag of ["--limit", "--status"]) {
|
|
1270
|
+
const value = argument(flag, args);
|
|
1271
|
+
if (args.includes(flag) && (value === undefined || value.startsWith("--"))) {
|
|
1272
|
+
throw new Error(`${flag} requires a value`);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1082
1275
|
const limit = Number(argument("--limit", args) ?? "20");
|
|
1083
1276
|
if (!Number.isInteger(limit) || limit < 1 || limit > 500) {
|
|
1084
1277
|
throw new Error("--limit must be a whole number from 1 to 500");
|
|
1085
1278
|
}
|
|
1086
1279
|
const status = argument("--status", args);
|
|
1280
|
+
// Approvals are read by the window and mode a server started here would
|
|
1281
|
+
// use; a policy file that does not load leaves the defaults, and says so,
|
|
1282
|
+
// as status does: a server would refuse to start on it.
|
|
1283
|
+
const policyPath = await existingPolicyPath(env);
|
|
1284
|
+
let policy;
|
|
1285
|
+
let policyError;
|
|
1286
|
+
if (policyPath !== undefined) {
|
|
1287
|
+
try {
|
|
1288
|
+
policy = await loadPolicy(policyPath, env.workspaceRoot);
|
|
1289
|
+
}
|
|
1290
|
+
catch (error) {
|
|
1291
|
+
policyError = error instanceof Error ? error.message : String(error);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1087
1294
|
process.exitCode = tasksCommand(env, {
|
|
1088
1295
|
limit,
|
|
1089
1296
|
json: args.includes("--json"),
|
|
1090
1297
|
...(status === undefined ? {} : { status: TaskStatusSchema.parse(status) }),
|
|
1298
|
+
...(policyError === undefined ? {} : { policyError }),
|
|
1299
|
+
window: {
|
|
1300
|
+
mode: env.mode === "enforced" || policy?.mode === "enforced" ? "enforced" : "developer",
|
|
1301
|
+
...(policy?.approvalTtlMs === undefined ? {} : { approvalTtlMs: policy.approvalTtlMs }),
|
|
1302
|
+
},
|
|
1091
1303
|
});
|
|
1092
1304
|
return;
|
|
1093
1305
|
}
|