@aron98/load-lock 1.1.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -2
- package/dist/adapters/opencode/tui.d.ts +3 -2
- package/dist/adapters/opencode/tui.js +25 -8
- package/dist/cli/install.js +218 -1
- package/dist/cli/main.js +3 -1
- package/dist/core/state.js +11 -2
- package/dist/mcp/server.js +2 -0
- package/dist/shared/client.d.ts +13 -0
- package/dist/shared/client.js +48 -0
- package/dist/shared/protocol.d.ts +8 -0
- package/dist/test/cli.test.js +8 -1
- package/dist/test/core.test.js +27 -0
- package/dist/test/install.test.js +181 -1
- package/dist/test/mcp.test.js +2 -1
- package/dist/test/sandbox.test.d.ts +1 -0
- package/dist/test/sandbox.test.js +155 -0
- package/docs/ADAPTERS.md +1 -1
- package/docs/COMPATIBILITY.md +50 -7
- package/docs/OPERATIONS.md +451 -8
- package/package.json +1 -1
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
|
|
|
@@ -81,6 +82,17 @@ opens it in-window, every session screen shows a clickable at-a-glance
|
|
|
81
82
|
widget in the right sidebar, and the home screen has a footer indicator —
|
|
82
83
|
same data, themed. See docs/OPERATIONS.md → "Dashboard".
|
|
83
84
|
|
|
85
|
+
## Sandboxed agents
|
|
86
|
+
|
|
87
|
+
Agents running inside a sandbox (bubblewrap/omac, Docker, macOS seatbelt,
|
|
88
|
+
WSL2) share the host's budget instead of getting their own: bridge the
|
|
89
|
+
daemon's Unix socket into the sandbox, set `LOAD_LOCK_NO_SPAWN=1` so the
|
|
90
|
+
client never spawns a useless sandbox-local daemon, and optionally tag
|
|
91
|
+
holds with `LOAD_LOCK_LABEL_PREFIX` (e.g. `bwrap:issue-42 build`).
|
|
92
|
+
Ready-to-run per-sandbox recipes — live-verified for bubblewrap and omac,
|
|
93
|
+
contract-inspected for the rest, plus the cross-PID-namespace lease
|
|
94
|
+
lifecycle — live in docs/OPERATIONS.md → "Sandboxes".
|
|
95
|
+
|
|
84
96
|
## Layout
|
|
85
97
|
|
|
86
98
|
```
|
|
@@ -103,7 +115,7 @@ skills/ packaged skill
|
|
|
103
115
|
- [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md) — per-harness capability
|
|
104
116
|
matrix with implemented / contract-tested / proposed labels.
|
|
105
117
|
- [docs/OPERATIONS.md](docs/OPERATIONS.md) — install, config,
|
|
106
|
-
troubleshooting, recovery, daemon lifecycle.
|
|
118
|
+
troubleshooting, recovery, daemon lifecycle, sandbox bridging.
|
|
107
119
|
- [docs/AGENT_GUIDE.md](docs/AGENT_GUIDE.md) — the playbook for agents.
|
|
108
120
|
- [docs/ADAPTERS.md](docs/ADAPTERS.md) — how to write an adapter for a new
|
|
109
121
|
harness, using the published library surface.
|
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
* - a clickable sidebar.footer widget with the same summary (session
|
|
16
16
|
* screens) — onMouseDown opens the dashboard; the two slots render on
|
|
17
17
|
* disjoint surfaces, so this is not a double-render
|
|
18
|
-
* - /load-lock (palette + slash command
|
|
19
|
-
*
|
|
18
|
+
* - /load-lock (palette + slash command) opens the panel IN-WINDOW via
|
|
19
|
+
* presentation: "panel" instead of fullscreen (deliberately NOT bound to
|
|
20
|
+
* any key — see the "no global key binding" comment in the keymap layer)
|
|
20
21
|
* - 'r' resync, 'x' force-release, and 'Esc' close while the panel is open;
|
|
21
22
|
* force-release is an admin action gated behind an explicit select +
|
|
22
23
|
* confirm dialog
|
|
@@ -16,8 +16,9 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "@opentui/soli
|
|
|
16
16
|
* - a clickable sidebar.footer widget with the same summary (session
|
|
17
17
|
* screens) — onMouseDown opens the dashboard; the two slots render on
|
|
18
18
|
* disjoint surfaces, so this is not a double-render
|
|
19
|
-
* - /load-lock (palette + slash command
|
|
20
|
-
*
|
|
19
|
+
* - /load-lock (palette + slash command) opens the panel IN-WINDOW via
|
|
20
|
+
* presentation: "panel" instead of fullscreen (deliberately NOT bound to
|
|
21
|
+
* any key — see the "no global key binding" comment in the keymap layer)
|
|
21
22
|
* - 'r' resync, 'x' force-release, and 'Esc' close while the panel is open;
|
|
22
23
|
* force-release is an admin action gated behind an explicit select +
|
|
23
24
|
* confirm dialog
|
|
@@ -181,8 +182,8 @@ function MiniIndicator(v, ctx) {
|
|
|
181
182
|
* Right-sidebar widget: the same glance summary plus a "click to open"
|
|
182
183
|
* affordance. Click support is contract-level (opentui enables mouse and
|
|
183
184
|
* bubbles mouse-down events to slot renderables); terminals that don't
|
|
184
|
-
* deliver mouse events can still open the dashboard
|
|
185
|
-
*
|
|
185
|
+
* deliver mouse events can still open the dashboard via the /load-lock
|
|
186
|
+
* slash command (or the command palette) named on the hint line.
|
|
186
187
|
*/
|
|
187
188
|
function SidebarWidget(v, ctx) {
|
|
188
189
|
const f = themeFg(ctx.theme);
|
|
@@ -196,7 +197,7 @@ function SidebarWidget(v, ctx) {
|
|
|
196
197
|
return (_jsx("box", { onMouseDown: openDashboard, children: _jsx("text", { fg: f.dim, children: "load-lock: no pools configured" }) }));
|
|
197
198
|
}
|
|
198
199
|
const shortcut = ctx.keymap.shortcuts("load-lock.dashboard.open")[0];
|
|
199
|
-
const hint = shortcut ? `${shortcut} / click opens dashboard` : "click opens dashboard";
|
|
200
|
+
const hint = shortcut ? `${shortcut} / click opens dashboard` : "/load-lock or click opens dashboard";
|
|
200
201
|
return (_jsxs("box", { onMouseDown: openDashboard, children: [_jsx("text", { fg: healthColor(g.health, f), children: `load-lock ${g.bits.join("·")}` }), _jsx("text", { fg: f.dim, children: hint })] }));
|
|
201
202
|
}
|
|
202
203
|
// ---------------------------------------------------------------- plugin
|
|
@@ -268,9 +269,21 @@ export default Plugin.define({
|
|
|
268
269
|
id: "load-lock.dashboard.open",
|
|
269
270
|
title: "load-lock: open the lock-status dashboard",
|
|
270
271
|
group: "load-lock",
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
bind
|
|
272
|
+
// NO global key binding, on purpose (issue #28):
|
|
273
|
+
//
|
|
274
|
+
// 1. Never bind unmodified single letters or letter sequences
|
|
275
|
+
// (the old "gl") at global scope: while the keymap engine waits
|
|
276
|
+
// for the next key of a sequence it swallows the prefix, so
|
|
277
|
+
// plain "g" never reached the composer. Modifier chords don't
|
|
278
|
+
// have this problem.
|
|
279
|
+
// 2. The obvious chord, ctrl+g, is already the host's documented
|
|
280
|
+
// default binding for session.first ("Navigate to first
|
|
281
|
+
// message", bound to ctrl+g,home,alt+home in opencode >=
|
|
282
|
+
// 2.0.32); our layer is mode "global" + priority 5 and would
|
|
283
|
+
// shadow it. Rather than shadow the host or pick another
|
|
284
|
+
// unmodified key, the command ships unbound: the sidebar
|
|
285
|
+
// widget (click), the command palette, and the /load-lock
|
|
286
|
+
// slash command remain as entry points.
|
|
274
287
|
palette: true,
|
|
275
288
|
slash: { name: "load-lock", aliases: ["lock", "ll"] },
|
|
276
289
|
run: () => {
|
|
@@ -291,6 +304,10 @@ export default Plugin.define({
|
|
|
291
304
|
id: "load-lock.dashboard.resync",
|
|
292
305
|
title: "load-lock: resync the dashboard",
|
|
293
306
|
group: "load-lock",
|
|
307
|
+
// Unmodified letters are fine here (unlike the global-scope rule
|
|
308
|
+
// above): `enabled` gates these to moments when OUR panel is the
|
|
309
|
+
// fullscreen current one and the composer is suspended, so there is
|
|
310
|
+
// no typing to swallow.
|
|
294
311
|
bind: "r",
|
|
295
312
|
enabled: panelOpen,
|
|
296
313
|
run: () => {
|
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
|
}
|
package/dist/cli/main.js
CHANGED
|
@@ -65,7 +65,7 @@ const HELP_GROUPS = [
|
|
|
65
65
|
{
|
|
66
66
|
title: "permit operations",
|
|
67
67
|
items: [
|
|
68
|
-
{ names: ["acquire"], desc: "request a permit: --pool --op-key --reason [--wait-ms] [--no-queue]" },
|
|
68
|
+
{ names: ["acquire"], desc: "request a permit: --pool --op-key --reason [--label] [--label-prefix] [--wait-ms] [--no-queue]" },
|
|
69
69
|
{ names: ["claim"], desc: "claim an offered permit before its window expires: --offer <token>" },
|
|
70
70
|
{ names: ["release"], desc: "release a held permit: --permit <token> [--outcome success|failure]" },
|
|
71
71
|
{ names: ["cancel"], desc: "cancel a queued/offered/paused request: --id <request-id> [--admin]" },
|
|
@@ -253,6 +253,7 @@ async function main() {
|
|
|
253
253
|
pool: S("pool", "heavy"),
|
|
254
254
|
agentId: S("agent", defaultAgent()),
|
|
255
255
|
agentLabel: S("label") || undefined,
|
|
256
|
+
labelPrefix: S("label-prefix") || undefined,
|
|
256
257
|
cwd: S("cwd", process.cwd()),
|
|
257
258
|
opKey: S("op-key") || fail("acquire: --op-key is required"),
|
|
258
259
|
reason: S("reason") || fail("acquire: --reason is required"),
|
|
@@ -461,6 +462,7 @@ async function runWithPermit(jobArgv) {
|
|
|
461
462
|
pool: S("pool", "heavy"),
|
|
462
463
|
agentId: S("agent", defaultAgent()),
|
|
463
464
|
agentLabel: S("label") || "load-lock run",
|
|
465
|
+
labelPrefix: S("label-prefix") || undefined,
|
|
464
466
|
cwd: process.cwd(),
|
|
465
467
|
opKey: S("op-key") || `run/${command}/${Date.now()}`,
|
|
466
468
|
reason: S("reason") || command,
|
package/dist/core/state.js
CHANGED
|
@@ -239,6 +239,15 @@ export class Machine {
|
|
|
239
239
|
const weight = p.weight ?? 1;
|
|
240
240
|
if (!Number.isInteger(weight) || weight < 1)
|
|
241
241
|
throw new ProtocolError("invalid", "weight must be a positive integer");
|
|
242
|
+
// Holder-label prefix (sandbox tagging): composed here so every adapter
|
|
243
|
+
// gets identical behavior; falls back to agentId when no label is given.
|
|
244
|
+
let agentLabel = p.agentLabel;
|
|
245
|
+
if (p.labelPrefix !== undefined) {
|
|
246
|
+
const prefix = p.labelPrefix.trim();
|
|
247
|
+
if (!prefix)
|
|
248
|
+
throw new ProtocolError("invalid", "labelPrefix must be a non-empty string");
|
|
249
|
+
agentLabel = `${prefix}:${p.agentLabel ?? p.agentId}`;
|
|
250
|
+
}
|
|
242
251
|
// Idempotency: a live request for the same (agent, op) returns its state
|
|
243
252
|
// (including its offer/permit token for the owner).
|
|
244
253
|
const existing = this.liveEntryByOpKey(p.agentId, p.opKey);
|
|
@@ -259,7 +268,7 @@ export class Machine {
|
|
|
259
268
|
id: rid(),
|
|
260
269
|
pool,
|
|
261
270
|
agentId: p.agentId,
|
|
262
|
-
agentLabel
|
|
271
|
+
agentLabel,
|
|
263
272
|
cwd: p.cwd,
|
|
264
273
|
opKey: p.opKey,
|
|
265
274
|
reason: p.reason,
|
|
@@ -281,7 +290,7 @@ export class Machine {
|
|
|
281
290
|
id: rid(),
|
|
282
291
|
pool,
|
|
283
292
|
agentId: p.agentId,
|
|
284
|
-
agentLabel
|
|
293
|
+
agentLabel,
|
|
285
294
|
cwd: p.cwd,
|
|
286
295
|
opKey: p.opKey,
|
|
287
296
|
reason: p.reason,
|
package/dist/mcp/server.js
CHANGED
|
@@ -42,6 +42,7 @@ export function buildServer(opts) {
|
|
|
42
42
|
reason: z.string().describe("Why you need the machine (shown to other agents, e.g. 'cargo test --all in worktree feat-x')"),
|
|
43
43
|
op_key: z.string().describe("Stable key for the pending operation (idempotency; e.g. 'wt-feat-x/cargo-test')"),
|
|
44
44
|
agent_id: z.string().optional().describe("Override agent identity (rarely needed)"),
|
|
45
|
+
label_prefix: z.string().optional().describe("Prefix prepended to your holder label so sandboxed clients are identifiable, e.g. 'omac' -> 'omac:<label>' (or set env LOAD_LOCK_LABEL_PREFIX)"),
|
|
45
46
|
wait_ms: z.number().int().optional().describe("Bounded blocking wait for capacity, clamped by config (default 0)"),
|
|
46
47
|
subscribe_if_busy: z.boolean().optional().describe("false = fail fast instead of queueing (default true)"),
|
|
47
48
|
pool: z.string().optional().describe("Resource pool (default 'heavy')"),
|
|
@@ -50,6 +51,7 @@ export function buildServer(opts) {
|
|
|
50
51
|
const res = await client.request("acquire", {
|
|
51
52
|
pool: input.pool,
|
|
52
53
|
agentId: agentIdOf(input),
|
|
54
|
+
labelPrefix: input.label_prefix,
|
|
53
55
|
cwd: process.cwd(),
|
|
54
56
|
opKey: input.op_key,
|
|
55
57
|
reason: input.reason,
|
package/dist/shared/client.d.ts
CHANGED
|
@@ -34,6 +34,19 @@ export declare class LLClient {
|
|
|
34
34
|
onDisconnect(cb: () => void): () => void;
|
|
35
35
|
close(): void;
|
|
36
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Client-only mode: LOAD_LOCK_NO_SPAWN set to any non-empty value except
|
|
39
|
+
* "0" forbids auto-spawning a daemon. Set this inside OS-level sandboxes
|
|
40
|
+
* (bubblewrap/omac, Docker, ...) so a sandbox-local daemon is never
|
|
41
|
+
* silently spawned and an unreachable host socket fails loudly instead.
|
|
42
|
+
*/
|
|
43
|
+
export declare function noSpawnRequested(env?: NodeJS.ProcessEnv): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Env-supplied holder-label prefix (LOAD_LOCK_LABEL_PREFIX), applied to
|
|
46
|
+
* every acquire so sandboxed clients are identifiable in the dashboard
|
|
47
|
+
* (e.g. "omac:issue-42 build"). An explicit labelPrefix param wins.
|
|
48
|
+
*/
|
|
49
|
+
export declare function envLabelPrefix(env?: NodeJS.ProcessEnv): string | undefined;
|
|
37
50
|
/** Idempotently ensure a daemon is running and listening on `sockPath`. */
|
|
38
51
|
export declare function ensureDaemon(stateDir: string, sockPath: string): Promise<void>;
|
|
39
52
|
/** One-shot helper: connect (auto-spawning), run fn, close. */
|
package/dist/shared/client.js
CHANGED
|
@@ -12,6 +12,13 @@
|
|
|
12
12
|
* NTFS) and starts a detached daemon; losers wait for the endpoint to
|
|
13
13
|
* appear. The freshly spawned daemon itself re-checks the endpoint before
|
|
14
14
|
* binding, so a double-spawn is harmless (the loser exits).
|
|
15
|
+
*
|
|
16
|
+
* Client-only mode (LOAD_LOCK_NO_SPAWN): sandboxed clients must be thin
|
|
17
|
+
* clients of the HOST daemon. A sandbox-local daemon is useless (it cannot
|
|
18
|
+
* see host processes or host-wide load) and hides the failure — so when the
|
|
19
|
+
* env var is set (any non-empty value except "0"), no daemon is ever
|
|
20
|
+
* auto-spawned here or in ensureDaemon(); an unreachable endpoint fails
|
|
21
|
+
* loudly with a pointer to the Sandboxes section of docs/OPERATIONS.md.
|
|
15
22
|
*/
|
|
16
23
|
import { connect } from "node:net";
|
|
17
24
|
import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
|
@@ -48,6 +55,9 @@ export class LLClient {
|
|
|
48
55
|
catch {
|
|
49
56
|
/* fall through to spawn */
|
|
50
57
|
}
|
|
58
|
+
if (noSpawnRequested()) {
|
|
59
|
+
throw new ProtocolError("unavailable", noSpawnError(this.socketPath()));
|
|
60
|
+
}
|
|
51
61
|
if (!this.opts.spawnDaemon) {
|
|
52
62
|
throw new ProtocolError("unavailable", `load-lock daemon is not running on ${this.socketPath()}`);
|
|
53
63
|
}
|
|
@@ -134,6 +144,13 @@ export class LLClient {
|
|
|
134
144
|
}
|
|
135
145
|
/** Issue one RPC. Reconnects once if the connection was lost. */
|
|
136
146
|
async request(method, params = {}, timeoutMs = 20_000) {
|
|
147
|
+
if (method === "acquire" && params.labelPrefix === undefined) {
|
|
148
|
+
// Sandbox wrapper tagging (LOAD_LOCK_LABEL_PREFIX): applied by the
|
|
149
|
+
// shared client so every adapter (CLI, MCP, plugin, hooks) gets it.
|
|
150
|
+
const prefix = envLabelPrefix();
|
|
151
|
+
if (prefix)
|
|
152
|
+
params = { ...params, labelPrefix: prefix };
|
|
153
|
+
}
|
|
137
154
|
if (!this.sock || this.sock.destroyed)
|
|
138
155
|
await this.connect();
|
|
139
156
|
const id = this.nextId++;
|
|
@@ -179,6 +196,33 @@ export class LLClient {
|
|
|
179
196
|
}
|
|
180
197
|
}
|
|
181
198
|
// ------------------------------------------------------------------- spawning
|
|
199
|
+
/**
|
|
200
|
+
* Client-only mode: LOAD_LOCK_NO_SPAWN set to any non-empty value except
|
|
201
|
+
* "0" forbids auto-spawning a daemon. Set this inside OS-level sandboxes
|
|
202
|
+
* (bubblewrap/omac, Docker, ...) so a sandbox-local daemon is never
|
|
203
|
+
* silently spawned and an unreachable host socket fails loudly instead.
|
|
204
|
+
*/
|
|
205
|
+
export function noSpawnRequested(env = process.env) {
|
|
206
|
+
const v = env.LOAD_LOCK_NO_SPAWN;
|
|
207
|
+
return v !== undefined && v !== "" && v !== "0";
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Env-supplied holder-label prefix (LOAD_LOCK_LABEL_PREFIX), applied to
|
|
211
|
+
* every acquire so sandboxed clients are identifiable in the dashboard
|
|
212
|
+
* (e.g. "omac:issue-42 build"). An explicit labelPrefix param wins.
|
|
213
|
+
*/
|
|
214
|
+
export function envLabelPrefix(env = process.env) {
|
|
215
|
+
const v = env.LOAD_LOCK_LABEL_PREFIX?.trim();
|
|
216
|
+
return v || undefined;
|
|
217
|
+
}
|
|
218
|
+
function noSpawnError(sockPath) {
|
|
219
|
+
return (`load-lock daemon is not reachable at ${sockPath} and auto-spawn is disabled (LOAD_LOCK_NO_SPAWN is set): ` +
|
|
220
|
+
`this client is client-only and started no daemon. Run the daemon on the host machine ` +
|
|
221
|
+
`(e.g. load-lock service start) and bridge its socket into this sandbox, ` +
|
|
222
|
+
`then point LOAD_LOCK_SOCKET at the bridged path — see the "Sandboxes" section of ` +
|
|
223
|
+
`docs/OPERATIONS.md for per-sandbox recipes (bubblewrap/omac, Docker, seatbelt, WSL2). ` +
|
|
224
|
+
`To allow spawning a daemon here instead, unset LOAD_LOCK_NO_SPAWN.`);
|
|
225
|
+
}
|
|
182
226
|
function daemonMainPath() {
|
|
183
227
|
if (process.env.LOAD_LOCK_DAEMON_BIN)
|
|
184
228
|
return process.env.LOAD_LOCK_DAEMON_BIN;
|
|
@@ -187,6 +231,10 @@ function daemonMainPath() {
|
|
|
187
231
|
}
|
|
188
232
|
/** Idempotently ensure a daemon is running and listening on `sockPath`. */
|
|
189
233
|
export async function ensureDaemon(stateDir, sockPath) {
|
|
234
|
+
if (noSpawnRequested()) {
|
|
235
|
+
// Client-only mode: never spawn, even for direct callers.
|
|
236
|
+
throw new ProtocolError("unavailable", noSpawnError(sockPath));
|
|
237
|
+
}
|
|
190
238
|
if (await probe(sockPath))
|
|
191
239
|
return;
|
|
192
240
|
const lockDir = join(stateDir, "spawn.lock");
|
|
@@ -226,6 +226,14 @@ export interface AcquireParams {
|
|
|
226
226
|
pool?: string;
|
|
227
227
|
agentId: string;
|
|
228
228
|
agentLabel?: string;
|
|
229
|
+
/**
|
|
230
|
+
* Optional prefix prepended to the holder label (composed daemon-side) so
|
|
231
|
+
* sandboxed/thin clients are identifiable in the dashboard, e.g.
|
|
232
|
+
* labelPrefix "omac" + agentLabel "issue-42 build" -> "omac:issue-42
|
|
233
|
+
* build". Falls back to agentId when no agentLabel is given. Adapters may
|
|
234
|
+
* source it from LOAD_LOCK_LABEL_PREFIX (the shared client injects it).
|
|
235
|
+
*/
|
|
236
|
+
labelPrefix?: string;
|
|
229
237
|
cwd?: string;
|
|
230
238
|
opKey: string;
|
|
231
239
|
reason: string;
|
package/dist/test/cli.test.js
CHANGED
|
@@ -94,6 +94,13 @@ test("top stays a working compat alias for dashboard", async () => {
|
|
|
94
94
|
assert.equal(alias.code, 0);
|
|
95
95
|
assert.equal(primary.code, 0);
|
|
96
96
|
// "synced N ago" and daemon uptime tick between runs; normalize before comparing.
|
|
97
|
-
|
|
97
|
+
// The MACHINE block is live host telemetry — load averages, available memory
|
|
98
|
+
// and the used-percentage in the pressure note move (and round across
|
|
99
|
+
// boundaries) between the two back-to-back runs — so collapse it wholesale;
|
|
100
|
+
// the alias check is that everything *else* renders identically.
|
|
101
|
+
const norm = (s) => s
|
|
102
|
+
.replace(/synced \S+ ago/g, "synced N ago")
|
|
103
|
+
.replace(/uptime \S+/g, "uptime N")
|
|
104
|
+
.replace(/^MACHINE.*\n\s*pressure:.*\n/m, "MACHINE <live telemetry>\n");
|
|
98
105
|
assert.equal(norm(alias.stdout), norm(primary.stdout), "identical rendering");
|
|
99
106
|
});
|
package/dist/test/core.test.js
CHANGED
|
@@ -279,3 +279,30 @@ test("diagnose reports holder info and flags long holds without revoking", () =>
|
|
|
279
279
|
assert.equal(stateOf(m, a.requestId), "held", "long hold alone never revokes a running job");
|
|
280
280
|
assert.ok(diags[0].diagnostics.note.includes("pid 333"));
|
|
281
281
|
});
|
|
282
|
+
// ------------------------------------------------------- labelPrefix (issue #29)
|
|
283
|
+
test("labelPrefix composes the holder label; agentId fallback when unlabeled", () => {
|
|
284
|
+
const { m } = mk();
|
|
285
|
+
const a = grant(m, A, "op1", { agentLabel: "issue-42 build", labelPrefix: "omac" });
|
|
286
|
+
assert.equal(a.agentLabel, "omac:issue-42 build");
|
|
287
|
+
// queued entry composes too, falling back to agentId without a label
|
|
288
|
+
const b = m.acquire({ agentId: B, opKey: "op1", reason: "b", labelPrefix: "omac" });
|
|
289
|
+
assert.equal(b.state, "queued");
|
|
290
|
+
assert.equal(b.entry.agentLabel, "omac:" + B);
|
|
291
|
+
// status (dashboard data source) shows the composed labels
|
|
292
|
+
const st = m.status();
|
|
293
|
+
assert.equal(st.pools[0].holders[0].agentLabel, "omac:issue-42 build");
|
|
294
|
+
assert.equal(st.pools[0].queue[0].agentLabel, "omac:" + B);
|
|
295
|
+
// events and journal carry the composed label (restart reconciliation)
|
|
296
|
+
assert.equal(m.eventsSince(0)[1].entry.agentLabel, "omac:issue-42 build");
|
|
297
|
+
assert.equal(m.journalEntries()[0].agentLabel, "omac:issue-42 build");
|
|
298
|
+
// no prefix: unchanged behavior
|
|
299
|
+
const c = m.acquire({ agentId: C, opKey: "op1", reason: "c", agentLabel: "plain" });
|
|
300
|
+
assert.equal(c.entry.agentLabel, "plain");
|
|
301
|
+
});
|
|
302
|
+
test("labelPrefix is trimmed and must be non-empty when present", () => {
|
|
303
|
+
const { m } = mk();
|
|
304
|
+
const a = grant(m, A, "op1", { agentLabel: "build", labelPrefix: " omac " });
|
|
305
|
+
assert.equal(a.agentLabel, "omac:build");
|
|
306
|
+
assert.throws(() => m.acquire({ agentId: A, opKey: "op2", reason: "r", labelPrefix: " " }), (e) => e instanceof ProtocolError && e.code === "invalid");
|
|
307
|
+
assert.throws(() => m.acquire({ agentId: A, opKey: "op3", reason: "r", labelPrefix: "" }), (e) => e instanceof ProtocolError && e.code === "invalid");
|
|
308
|
+
});
|