@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 +3 -2
- package/dist/cli/install.js +218 -1
- package/dist/test/install.test.js +181 -1
- package/docs/COMPATIBILITY.md +47 -4
- package/docs/OPERATIONS.md +119 -51
- package/package.json +1 -1
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 |
|
|
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
|
|
package/dist/cli/install.js
CHANGED
|
@@ -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
|
+
});
|
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
|
|
@@ -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:
|
|
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.
|
package/docs/OPERATIONS.md
CHANGED
|
@@ -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 —
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
omac
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
|
734
|
-
|
|
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.
|
|
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",
|