@melra/cli 0.3.0-alpha.14 → 0.3.0-alpha.16

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/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 { approvalScreen, printable } from "./approval-screen.js";
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 parse changes nothing here; `doctor` reports it and the
40
- * server refuses to start on it.
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
- const boolean = new Set(["--http", "--json", "--yes", "--deny"]);
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. Not in unsafe-local, which reaches every file on the machine and
478
- // whose guardrails warning already says so.
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
- const inside = await workspaceHoldsData(env.workspaceRoot, env.dataDirectory);
483
- checks.push(inside
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 enforced mode.
487
- status: env.mode === "enforced" ? "fail" : "warn",
488
- detail: `${env.dataDirectory} is inside the workspace, where a recursive read reaches payload.key without naming it`,
489
- }
490
- : { name: "data-location", status: "pass", detail: "outside the workspace" });
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
- name: "browser",
516
- status: browser === undefined ? "warn" : "pass",
517
- detail: browser ??
518
- "Chrome, Chromium, or Edge not found; non-browser capabilities remain available",
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
- // Only a person at a terminal on both ends is asked. Anything else — a pipe,
633
- // a test, `--json` — gets today's non-interactive generic setup unchanged.
634
- if (argument("--client", args) === undefined && isHuman(args) && process.stdin.isTTY) {
635
- const answer = await setupWizard({ input: process.stdin, output: process.stdout }, detectClients(env.workspaceRoot));
636
- if (answer === undefined) {
637
- process.stdout.write("\nCancelled; nothing was written.\n");
638
- return 130;
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
- process.stdout.write("\n");
641
- // The same arguments the person could have typed, so the wizard cannot
642
- // drift from what `--client X --write` does.
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
- const request = await readTaskRequest(args);
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 !== undefined) {
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
- `The client must be approved again before it can act.\n`);
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. Failure is silent on
824
- * purpose: the URL is already on screen, and a server that refused to start
825
- * because a desktop helper is missing would be worse than one you click into.
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] [--port <port>] [--open]
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), exactly as before.
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. Same as MELRA_SOCKET.
898
- --open Open the console in your browser once it is listening.
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' records as the approver (default: operator)
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 Unix socket path for 'serve' instead of stdio (see --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
- const socketPath = argument("--socket", args) ?? env.socketPath;
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
- (http || port !== undefined || args.includes("--open"))) {
1007
- throw new Error("--socket (or MELRA_SOCKET) replaces --http, --port, and --open; pass one transport");
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
  }