@aron98/load-lock 1.1.0 → 1.2.1

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.
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { test } from "node:test";
7
7
  import assert from "node:assert/strict";
8
- import { mkdtempSync, rmSync, mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
8
+ import { mkdtempSync, rmSync, mkdirSync, readFileSync, writeFileSync, existsSync, cpSync } from "node:fs";
9
9
  import { tmpdir } from "node:os";
10
10
  import { join, dirname, resolve } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
@@ -656,3 +656,183 @@ test("cursor: uninstall with nothing installed is a harmless no-op", async () =>
656
656
  assert.ok(!existsSync(join(proj, ".cursor")), "no files created by uninstall");
657
657
  rmSync(home, { recursive: true, force: true });
658
658
  });
659
+ // --------------------------------------------------------------------- omac
660
+ // The omac profile the installer must merge into without damaging it:
661
+ // entries in every known section plus an unknown field (a newer omac
662
+ // would accept it; we must preserve it untouched).
663
+ function omacUserProfile(home) {
664
+ return JSON.stringify({
665
+ workdir: { access: "readwrite" },
666
+ network: { allow_domain: ["example.com"], allow_tcp_connect: [22] },
667
+ filesystem: {
668
+ allow: ["/data"],
669
+ read: ["/docs"],
670
+ write: ["/out"],
671
+ allow_unix_dir: ["/tmp/other-socks"],
672
+ deny: [".env"],
673
+ },
674
+ environment: { allow_vars: ["ANTHROPIC_API_KEY", "MY_*"] },
675
+ someFutureField: { nested: true },
676
+ }, null, 2) + "\n";
677
+ }
678
+ function omacProfilePath(home) {
679
+ return join(home, ".config", "omac", "sandbox-profiles", "default.json");
680
+ }
681
+ test("omac: install/uninstall round-trip on a populated profile; re-install is byte-identical", async () => {
682
+ const home = tempHome();
683
+ const xdg = join(home, ".config");
684
+ const profileFile = omacProfilePath(home);
685
+ mkdirSync(dirname(profileFile), { recursive: true });
686
+ const before = omacUserProfile(home);
687
+ writeFileSync(profileFile, before);
688
+ const stateDir = join(home, "ll-state");
689
+ const env = { XDG_CONFIG_HOME: xdg, LOAD_LOCK_STATE_DIR: stateDir, XDG_STATE_HOME: join(home, ".local", "state") };
690
+ // -- install
691
+ let r = await runCli(home, ["install", "--harness", "omac"], env);
692
+ assert.equal(r.code, 0, `install failed: ${r.stderr}`);
693
+ // honesty: live-verified claim, the env prefix entry, and the state-dir grant
694
+ assert.match(r.stdout, /live-verified.*omac 0\.11\.0|verified live against omac 0\.11\.0/i, "installer output must state the verification status");
695
+ assert.match(r.stdout, /LOAD_LOCK_\*/, "installer output must mention the LOAD_LOCK_* allow_vars entry");
696
+ assert.match(r.stdout, /allow_unix_dir \+=/, "installer output must mention the state-dir grant");
697
+ assert.match(r.stdout, /skipped filesystem\.read.*repo checkout/, "installer output must honestly note the skipped read grant (CLI runs from a repo checkout)");
698
+ const p = JSON.parse(readFileSync(profileFile, "utf8"));
699
+ // user entries preserved everywhere
700
+ assert.deepEqual(p.workdir, { access: "readwrite" }, "user workdir section preserved");
701
+ assert.deepEqual(p.network, { allow_domain: ["example.com"], allow_tcp_connect: [22] }, "user network section preserved");
702
+ assert.deepEqual(p.filesystem.allow, ["/data"], "user filesystem.allow preserved");
703
+ assert.deepEqual(p.filesystem.write, ["/out"], "user filesystem.write preserved");
704
+ assert.deepEqual(p.filesystem.deny, [".env"], "user filesystem.deny preserved");
705
+ assert.deepEqual(p.filesystem.read, ["/docs"], "read untouched — repo checkout needs no package-tree grant");
706
+ assert.deepEqual(p.someFutureField, { nested: true }, "unknown (newer-omac) field preserved");
707
+ // our grants, exactly once, alongside the user's
708
+ assert.deepEqual(p.filesystem.allow_unix_dir, ["/tmp/other-socks", stateDir], "state dir appended to allow_unix_dir");
709
+ assert.deepEqual(p.environment.allow_vars, ["ANTHROPIC_API_KEY", "MY_*", "LOAD_LOCK_*"], "LOAD_LOCK_* appended to allow_vars");
710
+ assert.equal(p.filesystem.allow_unix_dir.filter((e) => e === stateDir).length, 1, "state dir granted exactly once");
711
+ // -- idempotent re-install: the file is not rewritten at all
712
+ const afterInstall = readFileSync(profileFile, "utf8");
713
+ r = await runCli(home, ["install", "--harness", "omac"], env);
714
+ assert.equal(r.code, 0, `re-install failed: ${r.stderr}`);
715
+ assert.equal(readFileSync(profileFile, "utf8"), afterInstall, "re-install must be byte-identical");
716
+ assert.match(r.stdout, /already present.*nothing to do/, "re-install must say nothing was needed");
717
+ // -- uninstall removes exactly our entries
718
+ r = await runCli(home, ["uninstall", "--harness", "omac"], env);
719
+ assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
720
+ const u = JSON.parse(readFileSync(profileFile, "utf8"));
721
+ assert.deepEqual(u.filesystem.allow_unix_dir, ["/tmp/other-socks"], "our state dir removed, user entry kept");
722
+ assert.ok(!u.filesystem.read.includes(stateDir), "no stray read grant");
723
+ assert.deepEqual(u.filesystem.read, ["/docs"], "user read entry kept");
724
+ assert.deepEqual(u.environment.allow_vars, ["ANTHROPIC_API_KEY", "MY_*"], "our env entry removed, user entries kept");
725
+ assert.deepEqual(u.workdir, { access: "readwrite" }, "user sections untouched by uninstall");
726
+ assert.deepEqual(u.someFutureField, { nested: true }, "unknown field untouched by uninstall");
727
+ rmSync(home, { recursive: true, force: true });
728
+ });
729
+ test("omac: install on a missing profile creates a minimal valid one (XDG_STATE_HOME honored); uninstall cleans it", async () => {
730
+ const home = tempHome();
731
+ const xdg = join(home, ".config");
732
+ const profileFile = omacProfilePath(home);
733
+ const xdgState = join(home, ".local", "state");
734
+ // LOAD_LOCK_STATE_DIR: "" clears any inherited override so XDG_STATE_HOME is honored
735
+ const env = { XDG_CONFIG_HOME: xdg, LOAD_LOCK_STATE_DIR: "", XDG_STATE_HOME: xdgState };
736
+ let r = await runCli(home, ["install", "--harness", "omac"], env);
737
+ assert.equal(r.code, 0, `install failed: ${r.stderr}`);
738
+ assert.ok(existsSync(profileFile), "profile created");
739
+ const p = JSON.parse(readFileSync(profileFile, "utf8"));
740
+ // minimal valid profile: only known fields, no empty arrays, no read grant (repo checkout)
741
+ assert.deepEqual(p, {
742
+ environment: { allow_vars: ["LOAD_LOCK_*"] },
743
+ filesystem: { allow_unix_dir: [join(xdgState, "load-lock")] },
744
+ }, "created profile holds exactly our grants");
745
+ // XDG_STATE_HOME honored: default state dir, not ~/.local/state off the real HOME
746
+ assert.ok(p.filesystem.allow_unix_dir[0].startsWith(xdgState), "state dir derived from XDG_STATE_HOME");
747
+ // idempotent re-install: byte-identical
748
+ const bytes = readFileSync(profileFile, "utf8");
749
+ r = await runCli(home, ["install", "--harness", "omac"], env);
750
+ assert.equal(r.code, 0, `re-install failed: ${r.stderr}`);
751
+ assert.equal(readFileSync(profileFile, "utf8"), bytes, "re-install is byte-identical");
752
+ // uninstall removes the file (nothing user-owned was ever in it) and prunes dirs
753
+ r = await runCli(home, ["uninstall", "--harness", "omac"], env);
754
+ assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
755
+ assert.ok(!existsSync(profileFile), "our file removed");
756
+ assert.ok(!existsSync(join(xdg, "omac")), "empty omac config tree pruned");
757
+ rmSync(home, { recursive: true, force: true });
758
+ });
759
+ test("omac: a global-style install (package outside any repo) adds the package-tree read grant; uninstall removes it by shape", async () => {
760
+ const home = tempHome();
761
+ const xdg = join(home, ".config");
762
+ const stateDir = join(home, "ll-state");
763
+ const env = { XDG_CONFIG_HOME: xdg, LOAD_LOCK_STATE_DIR: stateDir, XDG_STATE_HOME: join(home, ".local", "state") };
764
+ // simulate a global install: package tree under node_modules, outside any .git
765
+ const pkgMock = join(home, "bun-mock", "install", "global", "node_modules", "@aron98", "load-lock");
766
+ mkdirSync(dirname(pkgMock), { recursive: true });
767
+ cpSync(join(HERE, ".."), join(pkgMock, "dist"), { recursive: true }); // dist/ from the worktree build
768
+ const mockCli = join(pkgMock, "dist", "cli", "main.js");
769
+ const profileFile = omacProfilePath(home);
770
+ const runMock = (args) => new Promise((resolveP, reject) => {
771
+ const child = spawn(process.execPath, [mockCli, ...args], { env: { ...process.env, HOME: home, ...env } });
772
+ let stdout = "";
773
+ let stderr = "";
774
+ child.stdout.on("data", (b) => (stdout += b));
775
+ child.stderr.on("data", (b) => (stderr += b));
776
+ child.stdin.end();
777
+ child.once("exit", (code) => resolveP({ code, stdout, stderr }));
778
+ child.once("error", reject);
779
+ });
780
+ let r = await runMock(["install", "--harness", "omac"]);
781
+ assert.equal(r.code, 0, `install failed: ${r.stderr}`);
782
+ assert.match(r.stdout, /filesystem\.read \+=/, "installer output must mention the package-tree read grant");
783
+ const p = JSON.parse(readFileSync(profileFile, "utf8"));
784
+ const pkgReal = resolve(pkgMock);
785
+ assert.deepEqual(p.filesystem.read, [pkgReal], "read grant is the realpath of the package root");
786
+ assert.ok(p.filesystem.allow_unix_dir.includes(stateDir), "state dir granted too");
787
+ // idempotent re-install from the global copy: byte-identical
788
+ const bytes = readFileSync(profileFile, "utf8");
789
+ r = await runMock(["install", "--harness", "omac"]);
790
+ assert.equal(r.code, 0, `re-install failed: ${r.stderr}`);
791
+ assert.equal(readFileSync(profileFile, "utf8"), bytes, "re-install is byte-identical");
792
+ // uninstall from the WORKTREE CLI (a different location, readTree is null
793
+ // there): the read entry still matches by its node_modules/@aron98/load-lock shape
794
+ r = await runCli(home, ["uninstall", "--harness", "omac"], env);
795
+ assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
796
+ assert.ok(!existsSync(profileFile), "profile held only our grants — removed");
797
+ rmSync(home, { recursive: true, force: true });
798
+ });
799
+ test("omac: uninstall with nothing installed is a harmless no-op", async () => {
800
+ const home = tempHome();
801
+ const r = await runCli(home, ["uninstall", "--harness", "omac"], { XDG_CONFIG_HOME: join(home, ".config"), LOAD_LOCK_STATE_DIR: join(home, "ll-state") });
802
+ assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
803
+ assert.ok(!existsSync(join(home, ".config", "omac")), "no files created by uninstall");
804
+ rmSync(home, { recursive: true, force: true });
805
+ });
806
+ test("omac: install refuses to touch a broken or misshapen profile", async () => {
807
+ const home = tempHome();
808
+ const xdg = join(home, ".config");
809
+ const profileFile = omacProfilePath(home);
810
+ mkdirSync(dirname(profileFile), { recursive: true });
811
+ const env = { XDG_CONFIG_HOME: xdg, LOAD_LOCK_STATE_DIR: join(home, "ll-state") };
812
+ // (a) invalid JSON
813
+ const broken = "{ not json";
814
+ writeFileSync(profileFile, broken);
815
+ let r = await runCli(home, ["install", "--harness", "omac"], env);
816
+ assert.notEqual(r.code, 0, "install must refuse invalid JSON");
817
+ assert.match(r.stderr + r.stdout, /not valid JSON|refus/i, "error must explain the refusal");
818
+ assert.equal(readFileSync(profileFile, "utf8"), broken, "file untouched on refusal");
819
+ // (b) allow_vars present but not an array of strings
820
+ const misshapen = JSON.stringify({ environment: { allow_vars: "PATH" } }, null, 2) + "\n";
821
+ writeFileSync(profileFile, misshapen);
822
+ r = await runCli(home, ["install", "--harness", "omac"], env);
823
+ assert.notEqual(r.code, 0, "install must refuse a non-array allow_vars");
824
+ assert.match(r.stderr + r.stdout, /allow_vars.*not an array/i, "error must name the offending field");
825
+ assert.equal(readFileSync(profileFile, "utf8"), misshapen, "file untouched on refusal");
826
+ // uninstall also refuses (never "fixes" the file by overwriting it) on invalid JSON
827
+ writeFileSync(profileFile, broken);
828
+ r = await runCli(home, ["uninstall", "--harness", "omac"], env);
829
+ assert.notEqual(r.code, 0, "uninstall must refuse invalid JSON too");
830
+ assert.equal(readFileSync(profileFile, "utf8"), broken, "file untouched on refusal");
831
+ // on a merely misshapen value (valid JSON, allow_vars not an array) uninstall
832
+ // is a graceful no-op: it never wrote that value, so it leaves it alone
833
+ writeFileSync(profileFile, misshapen);
834
+ r = await runCli(home, ["uninstall", "--harness", "omac"], env);
835
+ assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
836
+ assert.equal(readFileSync(profileFile, "utf8"), misshapen, "file untouched by uninstall");
837
+ rmSync(home, { recursive: true, force: true });
838
+ });
@@ -32,10 +32,11 @@ test("mcp: tool listing + acquire/release/configure round trip", async () => {
32
32
  for (const expected of ["acquire", "claim", "release", "cancel", "rejoin", "pending", "wait_for_turn", "status", "inspect_machine", "get_config", "configure", "run_with_permit", "job_status", "job_stop", "attach_pid", "mark_started"]) {
33
33
  assert.ok(names.includes(expected), `tool ${expected} registered`);
34
34
  }
35
- const acq = await callJson(c, "acquire", { reason: "build", op_key: "mcp-rt", agent_id: "mcpAgentA" });
35
+ const acq = await callJson(c, "acquire", { reason: "build", op_key: "mcp-rt", agent_id: "mcpAgentA", label_prefix: "omac" });
36
36
  assert.equal(acq.state, "granted");
37
37
  assert.ok(acq.permit_token, "acquire result carries permit_token");
38
38
  assert.ok(acq.next_action, "structured next_action present");
39
+ assert.equal(acq.entry.agentLabel, "omac:mcpAgentA", "label_prefix composes the holder label");
39
40
  // idempotent acquire with the same op_key
40
41
  const acq2 = await callJson(c, "acquire", { reason: "build", op_key: "mcp-rt", agent_id: "mcpAgentA" });
41
42
  assert.equal(acq2.request_id, acq.request_id, "same op_key -> same request");
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Issue #29: sandboxed thin clients.
3
+ *
4
+ * - Client-only mode (LOAD_LOCK_NO_SPAWN): with the env set and the socket
5
+ * unreachable, the client must NEVER auto-spawn a daemon and must fail
6
+ * loudly, pointing at the Sandboxes section of docs/OPERATIONS.md.
7
+ * - Holder-label prefixing (labelPrefix / LOAD_LOCK_LABEL_PREFIX) so
8
+ * sandboxed clients are identifiable in the dashboard.
9
+ */
10
+ import test from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { spawn } from "node:child_process";
15
+ import { LLClient, ensureDaemon } from "../shared/client.js";
16
+ import { ProtocolError } from "../shared/protocol.js";
17
+ import { startDaemon, delay, probe, cliMainPath } from "./testutil.js";
18
+ const ACQ = (agentId, opKey, extra = {}) => ({
19
+ agentId,
20
+ opKey,
21
+ reason: "sandbox test",
22
+ ...extra,
23
+ });
24
+ /** Fresh empty state dir + socket path; the dir is NOT created. */
25
+ function freshDir(prefix) {
26
+ const dir = mkdtempSync(join(process.env.LL_TEST_TMP ?? "/tmp", prefix));
27
+ rmSync(dir, { recursive: true, force: true }); // we want it absent
28
+ const sock = join(dir, "daemon.sock");
29
+ return { dir, sock, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
30
+ }
31
+ /** Stop a daemon spawned via auto-spawn (pid recorded in daemon.json). */
32
+ async function killSpawnedDaemon(dir) {
33
+ const info = JSON.parse(readFileSync(join(dir, "daemon.json"), "utf8"));
34
+ try {
35
+ process.kill(info.pid, "SIGTERM");
36
+ }
37
+ catch {
38
+ /* already gone */
39
+ }
40
+ await delay(300);
41
+ }
42
+ test("LOAD_LOCK_NO_SPAWN: no daemon spawned, loud error pointing at the docs", async () => {
43
+ const { dir, sock, cleanup } = freshDir("ll-nospawn-");
44
+ process.env.LOAD_LOCK_NO_SPAWN = "1";
45
+ try {
46
+ const c = new LLClient({ socket: sock, stateDir: dir });
47
+ await assert.rejects(() => c.connect(), (e) => {
48
+ assert.ok(e instanceof ProtocolError);
49
+ assert.equal(e.code, "unavailable");
50
+ assert.ok(e.message.includes("LOAD_LOCK_NO_SPAWN"), "error names the env var");
51
+ assert.ok(e.message.includes("docs/OPERATIONS.md"), "error points at the docs");
52
+ assert.ok(e.message.includes("Sandboxes"), "error points at the Sandboxes section");
53
+ assert.ok(e.message.includes(sock), "error names the unreachable socket");
54
+ return true;
55
+ });
56
+ // No daemon process was spawned: the state dir was never even created
57
+ // (ensureDaemon creates it only when spawning) and nothing is listening.
58
+ assert.equal(existsSync(dir), false, "no state dir created");
59
+ assert.equal(await probe(sock), false, "nothing listening on the socket");
60
+ await delay(1_000); // would-be spawner waits up to 10s; give it a beat
61
+ assert.equal(existsSync(dir), false, "still no state dir after a wait");
62
+ assert.equal(await probe(sock), false, "still no daemon after a wait");
63
+ }
64
+ finally {
65
+ delete process.env.LOAD_LOCK_NO_SPAWN;
66
+ cleanup();
67
+ }
68
+ });
69
+ test("LOAD_LOCK_NO_SPAWN: ensureDaemon refuses directly too", async () => {
70
+ const { dir, sock, cleanup } = freshDir("ll-nospawn2-");
71
+ process.env.LOAD_LOCK_NO_SPAWN = "true";
72
+ try {
73
+ await assert.rejects(() => ensureDaemon(dir, sock), (e) => e instanceof ProtocolError && e.message.includes("docs/OPERATIONS.md"));
74
+ assert.equal(existsSync(dir), false, "ensureDaemon created nothing");
75
+ }
76
+ finally {
77
+ delete process.env.LOAD_LOCK_NO_SPAWN;
78
+ cleanup();
79
+ }
80
+ });
81
+ test("LOAD_LOCK_NO_SPAWN=0 disables client-only mode (daemon auto-spawns)", async () => {
82
+ const { dir, sock, cleanup } = freshDir("ll-nospawn0-");
83
+ process.env.LOAD_LOCK_NO_SPAWN = "0";
84
+ try {
85
+ const c = new LLClient({ socket: sock, stateDir: dir });
86
+ await c.connect();
87
+ const hello = (await c.request("hello", {}));
88
+ assert.ok(hello.pid, "daemon spawned and reachable");
89
+ c.close();
90
+ await killSpawnedDaemon(dir);
91
+ }
92
+ finally {
93
+ delete process.env.LOAD_LOCK_NO_SPAWN;
94
+ cleanup();
95
+ }
96
+ });
97
+ test("CLI under LOAD_LOCK_NO_SPAWN fails loudly with a docs pointer", async () => {
98
+ const { dir, sock, cleanup } = freshDir("ll-nospawncli-");
99
+ const res = await new Promise((resolve) => {
100
+ const child = spawn(process.execPath, [cliMainPath, "status"], {
101
+ env: {
102
+ ...process.env,
103
+ LOAD_LOCK_NO_SPAWN: "1",
104
+ LOAD_LOCK_STATE_DIR: dir,
105
+ LOAD_LOCK_SOCKET: sock,
106
+ },
107
+ });
108
+ let stderr = "";
109
+ child.stderr.on("data", (b) => (stderr += b));
110
+ child.once("exit", (code) => resolve({ code, stderr }));
111
+ });
112
+ try {
113
+ assert.equal(res.code, 70, "unavailable -> exit 70");
114
+ assert.ok(res.stderr.includes("docs/OPERATIONS.md"), "stderr points at the docs");
115
+ assert.ok(res.stderr.includes("Sandboxes"), "stderr points at the Sandboxes section");
116
+ assert.equal(existsSync(dir), false, "CLI spawned no daemon");
117
+ }
118
+ finally {
119
+ cleanup();
120
+ }
121
+ });
122
+ test("daemon-side labelPrefix end-to-end: holder shows prefixed label", async () => {
123
+ const d = await startDaemon();
124
+ try {
125
+ const c = d.client();
126
+ const acq = (await c.request("acquire", ACQ("agentSbx", "sbx-1", { agentLabel: "issue-42 build", labelPrefix: "omac" })));
127
+ assert.equal(acq.state, "granted");
128
+ assert.equal(acq.entry.agentLabel, "omac:issue-42 build");
129
+ const st = (await c.request("status", {}));
130
+ assert.equal(st.pools[0].holders[0].agentLabel, "omac:issue-42 build");
131
+ }
132
+ finally {
133
+ await d.stop();
134
+ }
135
+ });
136
+ test("LOAD_LOCK_LABEL_PREFIX env is injected by the shared client; explicit param wins", async () => {
137
+ const d = await startDaemon();
138
+ process.env.LOAD_LOCK_LABEL_PREFIX = "bwrap";
139
+ try {
140
+ const c = d.client();
141
+ // injected from env when the acquire does not pass labelPrefix itself
142
+ const a = (await c.request("acquire", ACQ("agentEnv", "env-1", { agentLabel: "cargo test" })));
143
+ assert.equal(a.entry.agentLabel, "bwrap:cargo test");
144
+ // an explicit labelPrefix overrides the env-injected one
145
+ const b = (await c.request("acquire", ACQ("agentEnv", "env-2", { agentLabel: "x", labelPrefix: "omac" })));
146
+ assert.equal(b.entry.agentLabel, "omac:x");
147
+ // unprefixable clients (no label) still show the prefix via agentId fallback
148
+ const e2 = (await c.request("acquire", ACQ("agentEnv2", "env-3", { pool: "heavy", weight: 1 })));
149
+ assert.ok(String(e2.entry.agentLabel).startsWith("bwrap:"), `fallback label was ${e2.entry.agentLabel}`);
150
+ }
151
+ finally {
152
+ delete process.env.LOAD_LOCK_LABEL_PREFIX;
153
+ await d.stop();
154
+ }
155
+ });
package/docs/ADAPTERS.md CHANGED
@@ -84,7 +84,7 @@ normally touches:
84
84
 
85
85
  | Method | Purpose |
86
86
  | --- | --- |
87
- | `acquire {pool?, agentId, agentLabel?, cwd?, opKey, reason, weight?, waitMs?, subscribeIfBusy?}` | grant / queue (idempotent per agent+opKey) |
87
+ | `acquire {pool?, agentId, agentLabel?, labelPrefix?, cwd?, opKey, reason, weight?, waitMs?, subscribeIfBusy?}` | grant / queue (idempotent per agent+opKey); labelPrefix composes `prefix:label` holder labels for sandboxed clients (env `LOAD_LOCK_LABEL_PREFIX` is injected by the shared client) |
88
88
  | `claim {offerToken}` | offer → permit (before starting heavy work) |
89
89
  | `release {permitToken, outcome?}` | idempotent |
90
90
  | `cancel {requestId}` / `rejoin {requestId}` / `decline {offerToken}` | queue hygiene |
@@ -43,6 +43,7 @@ Status labels mean exactly this:
43
43
  | **Cline** | Yes (`cline_mcp_settings.json`, merge-only installer) | `~/.cline/rules/load-lock.md` (dedicated rule file) | Rules always loaded | No hooks — `wait_for_turn` bounded sleep is the primary idle mechanism | No | Via MCP `run_with_permit` only | **Implemented (fallback tier, config-tested)** |
44
44
  | **Roo Code** | Yes (project `.roo/mcp.json` + existing global `mcp_settings.json`, merge-only installer) | `~/.roo/rules/load-lock.md` (dedicated rule file) | Rules always loaded | No hooks — `wait_for_turn` bounded sleep is the primary idle mechanism | No | Via MCP `run_with_permit` only | **Implemented (fallback tier, config-tested)** |
45
45
  | **Goose** | Yes (`~/.config/goose/config.yaml` `extensions` block, merge-only installer) | `.goosehints` (global, marked section) | Hints always loaded | No hooks — `wait_for_turn` bounded sleep is the primary idle mechanism | No | Via MCP `run_with_permit` only | **Implemented (fallback tier, config-tested)** |
46
+ | **omac** (agent sandbox; verified with 0.11.0) | N/A — omac is a sandbox runtime, not a harness: the agent inside it uses whichever harness adapter applies (OpenCode, Claude Code, …) | via the inner harness | via the inner harness | via the inner harness | via the inner harness | Full for the sandbox grants: `load-lock uninstall --harness omac` removes exactly the entries install added (match-own-entries, empty sections pruned) | **Sandbox-profile grants installed + live-verified** (`install --harness omac`: `filesystem.allow_unix_dir` += state dir, `filesystem.read` += package tree when needed, `environment.allow_vars` += `LOAD_LOCK_*`) |
46
47
  | **Aider** | **No MCP support** | CONVENTIONS.md / `.aider.conf.yml` | Read each run | N/A | N/A | CLI-only: same-shell `load-lock acquire ... && cmd; load-lock release ...` (manual) | Proposed (manual CLI tier) |
47
48
 
48
49
  ## How each integration tier works
@@ -62,8 +63,8 @@ Global plugin (`load-lock install --harness opencode`) registers:
62
63
  `load-lock.dashboard` session panel in-window (issue #24: side panel,
63
64
  not full-screen) with pools, holder cards, offer countdowns, queue,
64
65
  paused, event feed, `inspect_machine`; a clickable sidebar widget
65
- (`load-lock 0/3`, health-colored; `gl` as the mouse-free fallback) and
66
- a home-footer `lock 1/1·Q2` indicator; and an `x` force-release flow
66
+ (`load-lock 0/3`, health-colored; `/load-lock` as the mouse-free
67
+ fallback) and a home-footer `lock 1/1·Q2` indicator; and an `x` force-release flow
67
68
  gated behind an explicit select + confirm (the panel itself is read-only
68
69
  and never acquires a permit).
69
70
 
@@ -236,6 +237,40 @@ push channel, so a woken agent relies on tool results (which carry
236
237
  pending offers) rather than harness messages. We do not pretend these
237
238
  harnesses got push notifications.
238
239
 
240
+ ### omac — sandbox-profile grants (live-verified, issue #32)
241
+
242
+ omac runs agent harnesses inside a sandbox (bubblewrap + Landlock on
243
+ Linux, seatbelt on macOS) and strips the harness environment, allowing
244
+ through only what the user-global profile lists — so a load-lock client
245
+ inside omac cannot reach the host daemon without explicit grants.
246
+ `load-lock install --harness omac` merges three grants into
247
+ `~/.config/omac/sandbox-profiles/default.json` (merge-only, idempotent,
248
+ reversible by uninstall; omac parses profiles strictly so the installer
249
+ writes only known fields and never rewrites the user's bytes when
250
+ nothing changed):
251
+
252
+ - `filesystem.allow_unix_dir` += the load-lock state dir (AF_UNIX connect
253
+ to the host daemon socket + read/write on the state dir);
254
+ - `filesystem.read` += the installed package tree — **only when load-lock
255
+ lives outside an already-granted workdir** (a repo checkout needs
256
+ nothing: the sandbox workdir grant covers it; a global bun/npm install
257
+ does need it, see OPERATIONS.md gotcha #1);
258
+ - `environment.allow_vars` += `"LOAD_LOCK_*"` — one trailing-`*` prefix
259
+ entry that passes all four client vars (`LOAD_LOCK_SOCKET`,
260
+ `LOAD_LOCK_STATE_DIR`, `LOAD_LOCK_NO_SPAWN`, `LOAD_LOCK_LABEL_PREFIX`)
261
+ through omac's env filter.
262
+
263
+ Verified live against omac 0.11.0 (see OPERATIONS.md for the exact
264
+ procedure and the two gotchas): profile accepted by omac's strict parser,
265
+ sandboxed CLI → host daemon round-trip, prefixed holder labels
266
+ (`omac:<label>`), client-only (`LOAD_LOCK_NO_SPAWN=1`) error contract, and
267
+ the global-install read grant proven necessary and sufficient
268
+ (ERR_MODULE_NOT_FOUND without it, working round-trip with it).
269
+ Install/uninstall round-trips are contract-tested in
270
+ `src/test/install.test.ts` (temp HOME: populated profile preserved,
271
+ idempotent re-install byte-identical, missing-profile creation, global
272
+ read grant, refusal on broken profiles).
273
+
239
274
  ### Tier 4 — Aider (manual CLI)
240
275
 
241
276
  No MCP/hooks; nothing to install. Agents (or humans) use the CLI directly in
@@ -251,11 +286,12 @@ UI** — where no UI surface exists, we say so rather than claim parity.
251
286
 
252
287
  | Harness | In-harness dashboard | Also available |
253
288
  | --- | --- | --- |
254
- | **OpenCode** | Native TUI panel: `/load-lock` opens `load-lock.dashboard` **in-window** as a side panel (also opened by the `gl` binding or a click on the sidebar widget) with pools, holder cards, offer claim-window countdowns, queue, paused, event feed, `inspect_machine`; home-footer `lock 1/1` indicator on the home screen plus a clickable, health-colored `load-lock 0/3` widget in the session sidebar; `x` force-release behind select + confirm. Implemented as the package's `./tui` companion plugin (main plugin unchanged). Live-verified: opencode 2.0.29 (panel render, palette/slash command, confirm-gated force-release) and opencode 2.0.32 in a real pty (sidebar widget render, click-to-open, `gl`, in-window presentation). | `load-lock dashboard` (alias `top`) |
289
+ | **OpenCode** | Native TUI panel: `/load-lock` opens `load-lock.dashboard` **in-window** as a side panel (also opened by clicking the sidebar widget; deliberately **not** bound to any key — issue #28: the old `gl` sequence swallowed plain `g` typing, and the candidate `ctrl+g` is the host's `session.first` default) with pools, holder cards, offer claim-window countdowns, queue, paused, event feed, `inspect_machine`; home-footer `lock 1/1` indicator on the home screen plus a clickable, health-colored `load-lock 0/3` widget in the session sidebar; `x` force-release behind select + confirm. Implemented as the package's `./tui` companion plugin (main plugin unchanged). Live-verified: opencode 2.0.29 (panel render, palette/slash command, confirm-gated force-release) and opencode 2.0.32 in a real pty (sidebar widget render, click-to-open, in-window presentation, plain `g` reaches the composer, `ctrl+g` not intercepted by load-lock). | `load-lock dashboard` (alias `top`) |
255
290
  | **Claude Code** | None in-app — Claude Code has no plugin UI surface. The model can report status via the `load_lock_status` MCP tool at tool boundaries, and each `SessionStart` hook injects a one-line machine summary. | `load-lock dashboard` (any terminal) |
256
291
  | **Codex CLI** | None in-app — same reason. `load_lock_status` MCP tool; `SessionStart`/`SubagentStart` machine summary. | `load-lock dashboard` (alias `top`) |
257
292
  | **Gemini CLI** | None in-app (no hooks, no UI plugin surface). `load_lock_status` MCP tool at tool boundaries. | `load-lock dashboard` (alias `top`) |
258
293
  | **Copilot CLI, Cursor, Cline, Roo Code, Goose** | None in-app. MCP `status` tool where the MCP server is configured. | `load-lock dashboard` (alias `top`) |
294
+ | **omac** | None added by the omac installer — it only edits the sandbox profile; the inner harness keeps whatever surface its own adapter provides. | `load-lock dashboard` (alias `top`) |
259
295
  | **Aider** | None (no MCP either). | `load-lock dashboard` (alias `top`) |
260
296
 
261
297
  All dashboard surfaces are **read-only viewers that never acquire a
@@ -272,6 +308,7 @@ reconnect, view-model shaping, frame rendering) is covered by
272
308
  | Claude Code | hooks get `session_id`; MCP tools take explicit `agent_id` | policy instructs agents to pass it |
273
309
  | Codex | hooks get `session_id`; MCP tools take explicit `agent_id` | same |
274
310
  | Cursor | hooks get `conversation_id` (no `cwd` — `workspace_roots[0]` identifies the request); MCP tools take explicit `agent_id` | same |
311
+ | omac (sandbox) | inner harness identity + `LOAD_LOCK_LABEL_PREFIX=omac` | dashboard shows holders as `omac:<label>` |
275
312
  | Tier 3 | explicit `agent_id` argument | falls back to per-server-process id |
276
313
 
277
314
  ## Platform matrix
@@ -289,19 +326,25 @@ reconnect, view-model shaping, frame rendering) is covered by
289
326
  asyncRewake wakeup paths, subagents, background-job cleanup); Codex CLI
290
327
  (Docker, interactive TUI: policy injection, MCP round-trip, FIFO queueing
291
328
  behind a CLI holder, Stop-hook decision-block wakeup; exec-mode MCP
292
- injection blocked upstream — see Tier 2 notes).
329
+ injection blocked upstream — see Tier 2 notes); omac 0.11.0 (sandbox
330
+ grants installer: profile accepted by omac's strict parser, sandboxed
331
+ client → host daemon round-trip, `omac:`-prefixed labels,
332
+ `LOAD_LOCK_NO_SPAWN=1` error contract, global-install read grant proven
333
+ necessary and sufficient — see the omac section above and OPERATIONS.md).
293
334
  - Contract-tested: Cursor (hooks-v1 stop-hook adapter + installer, tested
294
335
  against the officially documented hooks JSON contract; no Cursor install
295
336
  on the verification machine — see the Cursor section under Tier 2).
296
337
  - Config-tested: Gemini CLI, Copilot CLI, Cline, Roo Code, Goose (installer
297
338
  round-trips, temp HOME; real harnesses not installed on the verification
298
339
  machine).
299
- - Core daemon/protocol/CLI: 68 automated tests + scripted 3-worktree demo
340
+ - Core daemon/protocol/CLI + adapters: 125 automated tests + scripted 3-worktree demo
300
341
  (three independent agent processes, real git worktrees, one pool), including
301
342
  9 dashboard tests (view-model shaping, event-cursor resume across a daemon
302
- restart, reconnect, and a `load-lock dashboard --once` render smoke test) and
343
+ restart, reconnect, and a `load-lock dashboard --once` render smoke test),
303
344
  9 bounded-wait tests (multi-process `wait_for_turn`: FIFO wake with
304
345
  auto-claim, timeout/pause/cancel/restart/abort paths, `maxTurnWaitMs` cap,
305
- MCP snake_case round trip).
346
+ MCP snake_case round trip) and 5 omac installer contract tests
347
+ (populated-profile preservation, idempotent re-install, missing-profile
348
+ creation, global-install read grant, broken-profile refusal).
306
349
  - Proposed (researched capabilities, generic adapter only):
307
350
  Aider.