@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.
- package/README.md +14 -2
- package/dist/adapters/opencode/tui.d.ts +3 -2
- package/dist/adapters/opencode/tui.js +25 -8
- package/dist/cli/install.js +218 -1
- package/dist/cli/main.js +3 -1
- package/dist/core/state.js +11 -2
- package/dist/mcp/server.js +2 -0
- package/dist/shared/client.d.ts +13 -0
- package/dist/shared/client.js +48 -0
- package/dist/shared/protocol.d.ts +8 -0
- package/dist/test/cli.test.js +8 -1
- package/dist/test/core.test.js +27 -0
- package/dist/test/install.test.js +181 -1
- package/dist/test/mcp.test.js +2 -1
- package/dist/test/sandbox.test.d.ts +1 -0
- package/dist/test/sandbox.test.js +155 -0
- package/docs/ADAPTERS.md +1 -1
- package/docs/COMPATIBILITY.md +50 -7
- package/docs/OPERATIONS.md +451 -8
- package/package.json +1 -1
|
@@ -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
|
+
});
|
package/dist/test/mcp.test.js
CHANGED
|
@@ -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 |
|
package/docs/COMPATIBILITY.md
CHANGED
|
@@ -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; `
|
|
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`
|
|
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:
|
|
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)
|
|
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.
|