@aron98/load-lock 1.2.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 CHANGED
@@ -59,7 +59,8 @@ agent A (OpenCode) agent B (Codex) agent C (Claude Code)
59
59
  ```sh
60
60
  npm install -g @aron98/load-lock
61
61
  load-lock install --harness opencode # or claude-code | codex | gemini-cli |
62
- # copilot-cli | cline | roo-code | goose | generic
62
+ # copilot-cli | cline | roo-code | goose |
63
+ # omac (sandbox grants) | generic
63
64
  load-lock doctor
64
65
  ```
65
66
 
@@ -88,7 +89,7 @@ WSL2) share the host's budget instead of getting their own: bridge the
88
89
  daemon's Unix socket into the sandbox, set `LOAD_LOCK_NO_SPAWN=1` so the
89
90
  client never spawns a useless sandbox-local daemon, and optionally tag
90
91
  holds with `LOAD_LOCK_LABEL_PREFIX` (e.g. `bwrap:issue-42 build`).
91
- Ready-to-run per-sandbox recipes — live-verified for bubblewrap,
92
+ Ready-to-run per-sandbox recipes — live-verified for bubblewrap and omac,
92
93
  contract-inspected for the rest, plus the cross-PID-namespace lease
93
94
  lifecycle — live in docs/OPERATIONS.md → "Sandboxes".
94
95
 
@@ -43,11 +43,22 @@
43
43
  * .cursor/rules/load-lock.mdc. Contract-tested against
44
44
  * the documented hooks JSON; not live-verified.
45
45
  * - generic : prints an MCP stdio config snippet (any MCP-capable harness).
46
+ * - omac : merges load-lock grants into omac's user-global sandbox
47
+ * profile (~/.config/omac/sandbox-profiles/default.json):
48
+ * filesystem.allow_unix_dir += the state dir (host-daemon
49
+ * socket bridge), filesystem.read += the installed package
50
+ * tree when load-lock lives outside any granted workdir
51
+ * (global bun/npm installs; gotcha verified live), and
52
+ * environment.allow_vars += "LOAD_LOCK_*". omac parses
53
+ * profiles strictly (unknown fields are errors) and never
54
+ * rewrites a profile it has created, so the installer only
55
+ * writes known fields and touches nothing else.
46
56
  */
47
57
  import fs from "node:fs";
48
58
  import os from "node:os";
49
59
  import path from "node:path";
50
60
  import { POLICY_BRIEF } from "../policy/text.js";
61
+ import { resolveStateDir } from "../shared/transport.js";
51
62
  const HOME = os.homedir();
52
63
  const PKG_NAME = "@aron98/load-lock";
53
64
  export function pkgRoot(binDir) {
@@ -948,6 +959,206 @@ function uninstallCursor(opts) {
948
959
  pruneEmptyDirs(cursorDir, cursorDir);
949
960
  return actions;
950
961
  }
962
+ // --------------------------------------------------------------------- omac
963
+ // omac (verified against 0.11.0) sandboxes agents with bubblewrap +
964
+ // Landlock on Linux and strips the harness environment, allowing through
965
+ // only what the user-global sandbox profile lists. Known profile fields
966
+ // (strict parsing — unknown fields are hard errors): top-level `workdir`,
967
+ // `network`, `filesystem`, `environment`; `workdir.access`,
968
+ // `network.allow_domain`/`deny_domain`/`allow_tcp_connect`,
969
+ // `filesystem.allow`/`read`/`write`/`allow_unix_dir`/`deny`,
970
+ // `environment.allow_vars` — all lists of strings. omac never rewrites a
971
+ // profile it has created, so the profile file is the whole extension
972
+ // point and we only ever write known fields.
973
+ const OMAC_LL_ENV_ENTRY = "LOAD_LOCK_*"; // allow_vars trailing-* prefix (verified: accepted by 0.11.0)
974
+ /** omac's user-global sandbox profile (no project equivalent exists). */
975
+ function omacProfileFile() {
976
+ return path.join(configHome(), "omac", "sandbox-profiles", "default.json");
977
+ }
978
+ /** Read + shape-check the profile; undefined when the file does not exist. */
979
+ function omacReadProfile(file) {
980
+ if (!fs.existsSync(file))
981
+ return undefined;
982
+ let parsed;
983
+ try {
984
+ parsed = JSON.parse(fs.readFileSync(file, "utf8"));
985
+ }
986
+ catch (e) {
987
+ throw new Error(`${file} is not valid JSON (${e instanceof Error ? e.message : String(e)}). omac parses ` +
988
+ `profiles strictly; load-lock refuses to overwrite it — fix or remove the file, then re-run install`);
989
+ }
990
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
991
+ throw new Error(`${file} is not a JSON object; refusing to overwrite`);
992
+ }
993
+ return parsed;
994
+ }
995
+ /** The profile sub-object `key`, created when absent; refuses non-objects. */
996
+ function omacSection(profile, key) {
997
+ const v = profile[key];
998
+ if (v === undefined) {
999
+ const section = {};
1000
+ profile[key] = section;
1001
+ return section;
1002
+ }
1003
+ if (typeof v !== "object" || v === null || Array.isArray(v)) {
1004
+ throw new Error(`omac profile: "${key}" is not an object; refusing to overwrite`);
1005
+ }
1006
+ return v;
1007
+ }
1008
+ /**
1009
+ * Merge string entries into section[key] (created only when something is
1010
+ * added, so we never write empty arrays). Returns the entries actually
1011
+ * added; refuses non-string-array values instead of mangling them.
1012
+ */
1013
+ function omacMergeList(section, key, entries) {
1014
+ const v = section[key];
1015
+ let list;
1016
+ if (v === undefined)
1017
+ list = [];
1018
+ else if (Array.isArray(v) && v.every((x) => typeof x === "string"))
1019
+ list = [...v];
1020
+ else
1021
+ throw new Error(`omac profile: "${key}" is not an array of strings; refusing to overwrite`);
1022
+ const added = entries.filter((e) => !list.includes(e));
1023
+ if (added.length)
1024
+ section[key] = [...list, ...added];
1025
+ return added;
1026
+ }
1027
+ /**
1028
+ * The installed package tree a sandboxed CLI must be able to *read*.
1029
+ * Live-verified gotcha: a global bun/npm install grants the bin dir, but
1030
+ * the launcher resolves the dist tree under `.../node_modules/@aron98/
1031
+ * load-lock` — without a read grant the CLI dies with ERR_MODULE_NOT_FOUND
1032
+ * before ever reaching the socket. Returns the realpath of the package
1033
+ * root, or null when load-lock lives in a repo checkout / worktree (the
1034
+ * nearest `.git`): there the agent's omac workdir grant already covers
1035
+ * it, and a read grant would be noise.
1036
+ */
1037
+ function omacReadGrant(P) {
1038
+ let root = P.root;
1039
+ try {
1040
+ root = fs.realpathSync(root);
1041
+ }
1042
+ catch {
1043
+ /* keep the unresolved path — omac grants are not existence-filtered */
1044
+ }
1045
+ for (let dir = root;;) {
1046
+ if (fs.existsSync(path.join(dir, ".git")))
1047
+ return null; // repo checkout / git worktree
1048
+ const parent = path.dirname(dir);
1049
+ if (parent === dir)
1050
+ break;
1051
+ dir = parent;
1052
+ }
1053
+ return root;
1054
+ }
1055
+ /**
1056
+ * Remove our entries (match-own-entries style: exact current state dir,
1057
+ * the "LOAD_LOCK_*" prefix entry, and read entries pointing at the
1058
+ * current package root or at a global `node_modules/@aron98/load-lock`
1059
+ * tree — the shape the installer writes). Keys whose lists we emptied are
1060
+ * dropped; sections that become empty are dropped; the file is removed
1061
+ * (and dirs pruned) when nothing user-owned remains.
1062
+ */
1063
+ function uninstallOmac() {
1064
+ const actions = [];
1065
+ const file = omacProfileFile();
1066
+ const profile = omacReadProfile(file);
1067
+ if (!profile)
1068
+ return actions; // nothing installed; uninstall never creates anything
1069
+ const stateDir = resolveStateDir(process.platform, process.env);
1070
+ const readTree = omacReadGrant(pkgPaths());
1071
+ const globalPkgSuffix = path.join("node_modules", PKG_NAME);
1072
+ let changed = false;
1073
+ const drop = (sectionKey, key, isOurs) => {
1074
+ const section = profile[sectionKey];
1075
+ if (!section || typeof section !== "object" || Array.isArray(section))
1076
+ return;
1077
+ const list = section[key];
1078
+ if (!Array.isArray(list) || !list.every((x) => typeof x === "string"))
1079
+ return;
1080
+ const kept = list.filter((e) => !isOurs(e));
1081
+ if (kept.length === list.length)
1082
+ return;
1083
+ changed = true;
1084
+ if (kept.length)
1085
+ section[key] = kept;
1086
+ else
1087
+ delete section[key];
1088
+ };
1089
+ drop("filesystem", "allow_unix_dir", (e) => e === stateDir);
1090
+ drop("filesystem", "read", (e) => (readTree !== null && e === readTree) || e.endsWith(globalPkgSuffix));
1091
+ drop("environment", "allow_vars", (e) => e === OMAC_LL_ENV_ENTRY);
1092
+ // prune sections our removals emptied (never anything the user still has)
1093
+ for (const key of ["filesystem", "environment"]) {
1094
+ const section = profile[key];
1095
+ if (section && typeof section === "object" && !Array.isArray(section) && Object.keys(section).length === 0) {
1096
+ delete profile[key];
1097
+ }
1098
+ }
1099
+ if (!changed) {
1100
+ actions.push(`no load-lock grants found in ${file}`);
1101
+ return actions;
1102
+ }
1103
+ if (Object.keys(profile).length === 0) {
1104
+ fs.rmSync(file);
1105
+ actions.push(`removed ${file} (nothing else was in it)`);
1106
+ pruneEmptyDirs(path.dirname(file), path.join(configHome(), "omac"));
1107
+ }
1108
+ else {
1109
+ writeJson(file, profile);
1110
+ actions.push(`removed load-lock grants from ${file}`);
1111
+ }
1112
+ return actions;
1113
+ }
1114
+ function installOmac(opts, P) {
1115
+ const actions = [];
1116
+ const file = omacProfileFile();
1117
+ const existed = fs.existsSync(file);
1118
+ const profile = omacReadProfile(file) ?? {};
1119
+ const stateDir = resolveStateDir(process.platform, process.env);
1120
+ const grants = [];
1121
+ const fsSection = omacSection(profile, "filesystem");
1122
+ grants.push(...omacMergeList(fsSection, "allow_unix_dir", [stateDir]));
1123
+ const readTree = omacReadGrant(P);
1124
+ if (readTree)
1125
+ grants.push(...omacMergeList(fsSection, "read", [readTree]));
1126
+ const envSection = omacSection(profile, "environment");
1127
+ grants.push(...omacMergeList(envSection, "allow_vars", [OMAC_LL_ENV_ENTRY]));
1128
+ // merge-only: never rewrite the file when nothing changed (idempotent
1129
+ // re-install leaves the user's bytes untouched)
1130
+ if (grants.length || !existed)
1131
+ writeJson(file, profile);
1132
+ if (!existed)
1133
+ actions.push(`created ${file} (omac's user-global sandbox profile)`);
1134
+ else if (grants.length)
1135
+ actions.push(`grants merged into ${file}`);
1136
+ else
1137
+ actions.push(`all grants already present in ${file} — nothing to do`);
1138
+ for (const g of grants) {
1139
+ if (g === OMAC_LL_ENV_ENTRY) {
1140
+ actions.push(`environment.allow_vars += "${OMAC_LL_ENV_ENTRY}" (prefix pattern; passes all four LOAD_LOCK_* vars through the sandbox env filter)`);
1141
+ }
1142
+ else if (g === stateDir) {
1143
+ actions.push(`filesystem.allow_unix_dir += ${stateDir} (AF_UNIX connect to the host daemon socket + read/write on the state dir)`);
1144
+ }
1145
+ else {
1146
+ actions.push(`filesystem.read += ${g} (installed package tree — the bin-dir grant alone is not enough; without this the CLI dies with ERR_MODULE_NOT_FOUND before reaching the socket)`);
1147
+ }
1148
+ }
1149
+ if (!readTree) {
1150
+ actions.push(`skipped filesystem.read: load-lock lives in a repo checkout (${P.root}) — the omac workdir grant already covers it`);
1151
+ }
1152
+ actions.push("set the values in your harness env (the grants only allow the names through): " +
1153
+ "LOAD_LOCK_NO_SPAWN=1 (client-only; the daemon must run on the host, e.g. load-lock service start), " +
1154
+ "LOAD_LOCK_LABEL_PREFIX=omac (dashboard shows holders as omac:<label>); " +
1155
+ "LOAD_LOCK_SOCKET only if the default <state dir>/daemon.sock is not what the sandbox sees");
1156
+ actions.push("label prefixes need load-lock >= 1.2.0 on BOTH ends: a daemon started before v1.2.0 " +
1157
+ "silently strips the unknown labelPrefix field — upgrade and restart the daemon (load-lock service stop && load-lock service start)");
1158
+ actions.push("verified live against omac 0.11.0 (bubblewrap + Landlock): sandboxed client -> host daemon " +
1159
+ "round-trip, prefixed labels, LOAD_LOCK_NO_SPAWN error contract");
1160
+ return actions;
1161
+ }
951
1162
  // -------------------------------------------------------------------- public
952
1163
  export async function installCommand(opts) {
953
1164
  const P = pkgPaths(opts.binDir);
@@ -956,7 +1167,7 @@ export async function installCommand(opts) {
956
1167
  }
957
1168
  const harness = opts.harness;
958
1169
  if (!harness)
959
- throw new Error("install: --harness <opencode|claude-code|codex|cursor|gemini-cli|copilot-cli|cline|roo-code|goose|generic> is required");
1170
+ throw new Error("install: --harness <opencode|claude-code|codex|cursor|gemini-cli|copilot-cli|cline|roo-code|goose|omac|generic> is required");
960
1171
  let actions;
961
1172
  switch (harness) {
962
1173
  case "opencode":
@@ -992,6 +1203,9 @@ export async function installCommand(opts) {
992
1203
  case "goose":
993
1204
  actions = installGoose(opts, P);
994
1205
  break;
1206
+ case "omac":
1207
+ actions = installOmac(opts, P);
1208
+ break;
995
1209
  case "generic":
996
1210
  case "all":
997
1211
  default:
@@ -1044,6 +1258,9 @@ export async function uninstallCommand(opts) {
1044
1258
  case "goose":
1045
1259
  actions = uninstallGoose();
1046
1260
  break;
1261
+ case "omac":
1262
+ actions = uninstallOmac();
1263
+ break;
1047
1264
  default:
1048
1265
  throw new Error(`uninstall: unknown harness "${opts.harness}"`);
1049
1266
  }
@@ -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
+ });
@@ -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
@@ -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
@@ -256,6 +291,7 @@ UI** — where no UI surface exists, we say so rather than claim parity.
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.
@@ -607,56 +607,122 @@ Caveats observed (verified):
607
607
  inside the sandbox and split the budget. The tests in
608
608
  `src/test/sandbox.test.ts` pin the loud failure instead.
609
609
 
610
- ### omac — contract-inspected
611
-
612
- **omac is not installed on the verification machine**, so nothing in this
613
- subsection was executed here; it is assembled from omac's documented
614
- behavior (the facts below are from omac's docs, not invented) and the
615
- bubblewrap recipe above, which omac wraps on Linux. Treat it as a
616
- ready-to-run procedure with expected outcomes, and record what you find.
617
-
618
- omac sandboxes agents with bubblewrap on Linux (seatbelt on macOS, WSL2
619
- elsewhere) and **strips the harness environment** before starting the
620
- agent, allowing through only variables listed in the sandbox profile's
621
- `environment.allow_vars` — `~/.config/omac/sandbox-profiles/default.json`
622
- by default. That file is the whole recipe:
623
-
624
- 1. Make sure the load-lock state dir is visible inside omac's bubblewrap
625
- sandbox (the `LOAD_LOCK_SOCKET` path must resolve inside it). How
626
- omac's sandbox mounts map to bwrap `--bind`s is an omac sandbox-config
627
- question — omac's own facade socket under `~/.local/state/omac/run/`
628
- is the project's precedent for a sandbox process connecting to a
629
- host-owned socket through its profile.
630
- 2. Add the load-lock variables to the profile's `environment.allow_vars`:
631
-
632
- ```json
633
- {
634
- "environment": {
635
- "allow_vars": [
636
- "LOAD_LOCK_SOCKET",
637
- "LOAD_LOCK_STATE_DIR",
638
- "LOAD_LOCK_NO_SPAWN",
639
- "LOAD_LOCK_LABEL_PREFIX"
640
- ]
641
- }
642
- }
643
- ```
610
+ ### omac — live-verified
611
+
612
+ Verified 2026-10-20 against **omac 0.11.0** (extracted package binary, no
613
+ root needed) on Linux, driving the load-lock CLI from the v1.2.0 worktree
614
+ build end-to-end from inside `omac sandbox run`. omac wraps bubblewrap +
615
+ Landlock on Linux (seatbelt on macOS with the same env-stripping) and
616
+ **strips the harness environment**, allowing through only variables
617
+ listed in the user-global sandbox profile —
618
+ `~/.config/omac/sandbox-profiles/default.json`. The profile is the whole
619
+ recipe, and `load-lock install --harness omac` writes it for you
620
+ (merge-only; idempotent; `load-lock uninstall --harness omac` removes
621
+ exactly the entries it added):
622
+
623
+ ```json
624
+ {
625
+ "filesystem": {
626
+ "allow_unix_dir": ["<load-lock state dir>"],
627
+ "read": ["<installed package tree>"]
628
+ },
629
+ "environment": { "allow_vars": ["LOAD_LOCK_*"] }
630
+ }
631
+ ```
632
+
633
+ - `filesystem.allow_unix_dir` += the state dir: AF_UNIX connect to the
634
+ host daemon socket plus read/write on the state dir — this is the
635
+ sandbox→host bridge (omac has no separate "bind" step; the grant is the
636
+ bridge).
637
+ - `environment.allow_vars` += `"LOAD_LOCK_*"` — one trailing-`*` prefix
638
+ entry (verified: omac 0.11.0 accepts prefix patterns) covering all four
639
+ client vars (`LOAD_LOCK_SOCKET`, `LOAD_LOCK_STATE_DIR`,
640
+ `LOAD_LOCK_NO_SPAWN`, `LOAD_LOCK_LABEL_PREFIX`).
641
+ - `filesystem.read` += the installed package tree — **only when
642
+ load-lock lives outside an already-granted workdir** (see gotcha 1).
643
+
644
+ The exact procedure that was verified (run it with a throwaway `HOME`
645
+ unless you want the grants in your real profile):
646
+
647
+ ```sh
648
+ # 0. scratch dirs + a current daemon built from the v1.2.0+ tree.
649
+ # GOTCHA 2: the machine's real daemon was still v1.1.0 — a daemon
650
+ # started before v1.2.0 silently strips the unknown labelPrefix field
651
+ # (zod), so prefixed labels disappear without any error. Upgrade and
652
+ # restart: load-lock service stop && load-lock service start. This
653
+ # verification deliberately
654
+ # used its own daemon on a temp state dir.
655
+ LOAD_LOCK_STATE_DIR=/tmp/ll-32-test node dist/cli/main.js service start
656
+
657
+ # 1. install the grants (merge-only; prints exactly what it merged)
658
+ HOME=/tmp/ll-32-home LOAD_LOCK_STATE_DIR=/tmp/ll-32-test \
659
+ node dist/cli/main.js install --harness omac
660
+
661
+ # 2. omac's strict parser accepts the profile (unknown fields are errors)
662
+ HOME=/tmp/ll-32-home omac sandbox run -- true # exit 0, no ERROR lines
663
+
664
+ # 3. sandboxed client -> host daemon round-trip.
665
+ # NOTE: standalone `omac sandbox run` starts at cwd "/" and grants NO
666
+ # workdir by default — `--workdir-access read` binds the invocation
667
+ # dir read-only, modeling what a real omac agent session gets for its
668
+ # project workdir (which is why a repo checkout needs no read grant).
669
+ # The env values are set inside the sandboxed command with `env`
670
+ # (`-- env VAR=... node ...`); with the LOAD_LOCK_* allow_vars entry
671
+ # in place they could equally be inherited from the outer shell.
672
+ HOME=/tmp/ll-32-home omac sandbox run --workdir-access read -- \
673
+ env LOAD_LOCK_SOCKET=/tmp/ll-32-test/daemon.sock LOAD_LOCK_NO_SPAWN=1 \
674
+ LOAD_LOCK_LABEL_PREFIX=omac \
675
+ node dist/cli/main.js acquire --op-key issue-32-live \
676
+ --reason "issue-32 live verification build" --label "issue-32 build" --json
677
+ # -> permit prm_…; host status shows:
678
+ # held: omac:issue-32 build — issue-32 live verification build [claimed, awaiting start]
679
+
680
+ # 4. host-side release (or any later step from inside the sandbox):
681
+ node dist/cli/main.js release --permit prm_… # -> releasedNow: true
682
+ ```
644
683
 
645
- Set the values themselves in your harness env (or the profile's env
646
- mechanism): `LOAD_LOCK_SOCKET` to the host daemon socket as seen inside
647
- the sandbox, `LOAD_LOCK_NO_SPAWN=1`, and — recommended —
648
- `LOAD_LOCK_LABEL_PREFIX=omac` so the dashboard shows holders as
649
- `omac:<label>`.
650
- 3. Watch out: **omac never rewrites a profile it has created**, so if your
651
- `default.json` already exists, edit it by hand (adding the entries
652
- above); and **unknown profile fields are errors**, so don't invent
653
- other keys — `allow_vars` entries are plain variable names.
654
-
655
- Expected outcome (unverified here): with those four variables allowed and
656
- the state dir mounted, an agent inside omac behaves exactly like the
657
- verified bubblewrap case above — host daemon answers, labels carry the
658
- `omac:` prefix, and long holds need `mark_started` + heartbeats (lease
659
- mode) because of the PID namespace.
684
+ Also verified live:
685
+
686
+ - **`LOAD_LOCK_NO_SPAWN=1` error contract**: with the client pointed at a
687
+ missing socket inside the sandbox, the CLI exits **70** with the loud
688
+ "not reachable and auto-spawn is disabled … bridge its socket into this
689
+ sandbox" error instead of silently spawning a sandbox-local daemon.
690
+ - **Lease-mode lifecycle across the PID namespace** (same as the
691
+ bubblewrap case): `acquire` → `mark-started` (held, lease 900s) →
692
+ `release` (`releasedNow: true`), all issued from inside the sandbox;
693
+ long holds need lease mode because the host daemon pid is invisible to
694
+ the sandbox.
695
+ - **Install/uninstall round-trips against the real omac**: a populated
696
+ user profile (workdir/network/environment entries) survives install
697
+ byte-faithfully with our grants appended, omac's strict parser accepts
698
+ the merged file, and uninstall leaves the user entries intact; a
699
+ from-scratch profile is removed entirely on uninstall (with the omac
700
+ config tree pruned) and omac keeps running on its defaults.
701
+
702
+ The two gotchas that shaped the installer:
703
+
704
+ 1. **A global install needs a read grant for the package tree, not just
705
+ the bin dir.** omac's builtin grants cover `~/.bun/bin`, but the
706
+ launcher resolves the dist tree under
707
+ `~/.bun/install/global/node_modules/@aron98/load-lock/dist/…` —
708
+ ungranted, the CLI dies with `Cannot find module …` (exit 1) before
709
+ ever reaching the socket. Proven live both ways: the sandboxed CLI
710
+ from a global-style tree failed with exactly that error before the
711
+ grant, and completed the full acquire→mark-started→release round-trip
712
+ after `filesystem.read` covered the tree. A repo checkout needs no
713
+ read grant (the agent session's workdir already covers it) and the
714
+ installer says so honestly in its output.
715
+ 2. **Label prefixes need a current daemon on the host.** A daemon
716
+ started before v1.2.0 strips the unknown `labelPrefix` field silently
717
+ (zod); the dashboard shows the bare label with no error anywhere.
718
+ `load-lock service stop && load-lock service start` after upgrading.
719
+
720
+ What was NOT verified: omac's macOS seatbelt path (same profile schema,
721
+ per omac's docs, but the verification machine is Linux) and omac's agent
722
+ orchestration commands (`omac start`/`continue`/…) driving a real agent —
723
+ the round-trip was verified with the documented `omac sandbox run`
724
+ primitive those commands build on. The installer's contract tests cover
725
+ the merge/uninstall edge cases (`src/test/install.test.ts`).
660
726
 
661
727
  ### Docker — contract-inspected
662
728
 
@@ -730,8 +796,10 @@ path-literal strict, or re-generate the profile. Expected outcome
730
796
  `LOAD_LOCK_LABEL_PREFIX=…`) passed through to the agent, a seatbelt-sandboxed
731
797
  client talks to the host daemon exactly as in the verified bubblewrap
732
798
  case. omac's macOS sandboxing uses seatbelt under the hood with the same
733
- `environment.allow_vars` env-stripping as on Linux, so the omac recipe
734
- above (profile `allow_vars` + bridged socket path) applies unchanged.
799
+ `environment.allow_vars` env-stripping as on Linux, so the omac installer
800
+ (`load-lock install --harness omac`, live-verified on Linux against omac
801
+ 0.11.0 — see above) applies unchanged; only the socket path and the
802
+ seatbelt file rules differ.
735
803
 
736
804
  ### WSL2 — honest state
737
805
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aron98/load-lock",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "Machine-wide counting semaphore coordinating resource-intensive work across parallel coding agents (OpenCode, Codex CLI, Claude Code, ...)",
5
5
  "type": "module",
6
6
  "license": "MIT",