@aron98/load-lock 1.2.0 → 1.3.0
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/adapters/opencode/tui.js +5 -1
- package/dist/cli/copies.d.ts +28 -0
- package/dist/cli/copies.js +207 -0
- package/dist/cli/install.js +218 -1
- package/dist/cli/main.js +264 -16
- package/dist/core/daemon.d.ts +18 -0
- package/dist/core/daemon.js +84 -3
- package/dist/core/persist.js +5 -3
- package/dist/core/state.d.ts +7 -4
- package/dist/core/state.js +64 -10
- package/dist/dashboard/frame.js +5 -1
- package/dist/dashboard/view.d.ts +4 -0
- package/dist/dashboard/view.js +5 -0
- package/dist/shared/client.js +4 -1
- package/dist/shared/protocol.d.ts +40 -2
- package/dist/shared/protocol.js +5 -0
- package/dist/shared/version.d.ts +10 -0
- package/dist/shared/version.js +57 -0
- package/dist/test/install.test.js +181 -1
- package/dist/test/update.test.d.ts +1 -0
- package/dist/test/update.test.js +455 -0
- package/docs/ARCHITECTURE.md +10 -6
- package/docs/COMPATIBILITY.md +79 -4
- package/docs/OPERATIONS.md +171 -56
- 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
|
|
|
@@ -89,13 +89,17 @@ function dashboardLines(v, width, f) {
|
|
|
89
89
|
const syncAge = v.lastSyncAt ? fmtDuration(Math.max(0, v.now - v.lastSyncAt)) : "never";
|
|
90
90
|
push(`load-lock dashboard ${STATE_LABEL[v.state] ?? v.state} synced ${syncAge} ago`, stateColor(v.state, f));
|
|
91
91
|
if (v.daemon) {
|
|
92
|
-
push(`daemon pid ${v.daemon.pid} · uptime ${fmtDuration(v.daemon.uptimeMs)} · config v${v.daemon.configVersion} · protocol v${v.daemon.protocolVersion}
|
|
92
|
+
push(`daemon pid ${v.daemon.pid} · uptime ${fmtDuration(v.daemon.uptimeMs)} · config v${v.daemon.configVersion} · protocol v${v.daemon.protocolVersion}` +
|
|
93
|
+
(v.daemon.daemonVersion ? ` · daemon v${v.daemon.daemonVersion}` : "") +
|
|
94
|
+
` · events through #${v.latestEventId}`, f.dim);
|
|
93
95
|
}
|
|
94
96
|
else {
|
|
95
97
|
push(`daemon unreachable — it auto-spawns on the next load-lock command`, f.dim);
|
|
96
98
|
}
|
|
97
99
|
if (v.daemonRestartNote)
|
|
98
100
|
push(`note: ${v.daemonRestartNote}`, f.warn);
|
|
101
|
+
if (v.versionSkewNote)
|
|
102
|
+
push(`note: ${v.versionSkewNote}`, f.warn);
|
|
99
103
|
if (v.machine) {
|
|
100
104
|
const m = v.machine;
|
|
101
105
|
const cg = m.cgroupCpuQuota != null ? ` · cgroup quota ${m.cgroupCpuQuota / 100} CPU` : "";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface InstalledCopy {
|
|
2
|
+
/** Where this copy was found. */
|
|
3
|
+
source: "cli" | "path" | "opencode-cache";
|
|
4
|
+
/** Path of the copy's package root (its package.json's directory). */
|
|
5
|
+
path: string;
|
|
6
|
+
/** Version string, or null when the package.json could not be read. */
|
|
7
|
+
version: string | null;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Find the package.json of the @aron98/load-lock copy that contains the
|
|
11
|
+
* given file, by climbing parent directories. Best-effort: returns null if
|
|
12
|
+
* no enclosing package.json matches (or is readable).
|
|
13
|
+
*/
|
|
14
|
+
export declare function enclosingPackage(startDir: string, maxDepth?: number): {
|
|
15
|
+
path: string;
|
|
16
|
+
version: string | null;
|
|
17
|
+
} | null;
|
|
18
|
+
/** The copy of load-lock this process runs from (its own package root). */
|
|
19
|
+
export declare function thisCopy(): InstalledCopy | null;
|
|
20
|
+
/**
|
|
21
|
+
* All installed copies of load-lock detectable on this machine:
|
|
22
|
+
* - "cli": the copy this process runs from.
|
|
23
|
+
* - "path": any `load-lock` binary found on PATH (deduped by real path,
|
|
24
|
+
* so a symlink to the running copy is recognized and not double-listed
|
|
25
|
+
* — a copy is only listed under "path" when it differs from "cli").
|
|
26
|
+
* - "opencode-cache": copies under opencode's private npm cache.
|
|
27
|
+
*/
|
|
28
|
+
export declare function findInstalledCopies(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): InstalledCopy[];
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installed-copy detection (issue #34): on a given machine several copies
|
|
3
|
+
* of load-lock commonly coexist — the globally-installed CLI on PATH, and
|
|
4
|
+
* the private copy opencode maintains under its npm cache (because
|
|
5
|
+
* `install --harness opencode` registers the bare package name, opencode
|
|
6
|
+
* installs and refreshes its own copy on its own schedule).
|
|
7
|
+
*
|
|
8
|
+
* All scanning is best-effort: missing paths, unreadable files, and
|
|
9
|
+
* unexpected layouts yield skipped entries or null versions — never a throw.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { isWindows } from "../shared/transport.js";
|
|
16
|
+
/** Package name of this package (used to find enclosing package roots). */
|
|
17
|
+
const PKG_NAME = "@aron98/load-lock";
|
|
18
|
+
/**
|
|
19
|
+
* Find the package.json of the @aron98/load-lock copy that contains the
|
|
20
|
+
* given file, by climbing parent directories. Best-effort: returns null if
|
|
21
|
+
* no enclosing package.json matches (or is readable).
|
|
22
|
+
*/
|
|
23
|
+
export function enclosingPackage(startDir, maxDepth = 12) {
|
|
24
|
+
let dir = startDir;
|
|
25
|
+
for (let i = 0; i < maxDepth; i++) {
|
|
26
|
+
const pkgPath = join(dir, "package.json");
|
|
27
|
+
try {
|
|
28
|
+
if (existsSync(pkgPath)) {
|
|
29
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
30
|
+
if (pkg?.name === PKG_NAME) {
|
|
31
|
+
return { path: dir, version: typeof pkg.version === "string" ? pkg.version : null };
|
|
32
|
+
}
|
|
33
|
+
// A foreign package.json terminates the climb (e.g. a PATH bin
|
|
34
|
+
// inside some other tool's package).
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const parent = dirname(dir);
|
|
42
|
+
if (parent === dir)
|
|
43
|
+
return null;
|
|
44
|
+
dir = parent;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
/** The copy of load-lock this process runs from (its own package root). */
|
|
49
|
+
export function thisCopy() {
|
|
50
|
+
// dist/cli/copies.js -> dist/cli -> ... -> <pkg>/package.json
|
|
51
|
+
const startDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
52
|
+
const found = enclosingPackage(startDir);
|
|
53
|
+
if (!found)
|
|
54
|
+
return null;
|
|
55
|
+
return { source: "cli", path: found.path, version: found.version };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* All installed copies of load-lock detectable on this machine:
|
|
59
|
+
* - "cli": the copy this process runs from.
|
|
60
|
+
* - "path": any `load-lock` binary found on PATH (deduped by real path,
|
|
61
|
+
* so a symlink to the running copy is recognized and not double-listed
|
|
62
|
+
* — a copy is only listed under "path" when it differs from "cli").
|
|
63
|
+
* - "opencode-cache": copies under opencode's private npm cache.
|
|
64
|
+
*/
|
|
65
|
+
export function findInstalledCopies(platform = process.platform, env = process.env) {
|
|
66
|
+
const copies = [];
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
const add = (c) => {
|
|
69
|
+
if (!c)
|
|
70
|
+
return;
|
|
71
|
+
let real;
|
|
72
|
+
try {
|
|
73
|
+
real = realpathSync(c.path);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
real = c.path;
|
|
77
|
+
}
|
|
78
|
+
if (seen.has(real))
|
|
79
|
+
return;
|
|
80
|
+
seen.add(real);
|
|
81
|
+
copies.push(c);
|
|
82
|
+
};
|
|
83
|
+
add(thisCopy());
|
|
84
|
+
// --- PATH scan ------------------------------------------------------------
|
|
85
|
+
try {
|
|
86
|
+
const binNames = isWindows(platform)
|
|
87
|
+
? ["load-lock.cmd", "load-lock.exe", "load-lock"]
|
|
88
|
+
: ["load-lock"];
|
|
89
|
+
const dirs = (env.PATH ?? "").split(isWindows(platform) ? ";" : ":").filter(Boolean);
|
|
90
|
+
for (const dir of dirs) {
|
|
91
|
+
for (const bin of binNames) {
|
|
92
|
+
try {
|
|
93
|
+
const binPath = join(dir, bin);
|
|
94
|
+
if (!statSync(binPath).isFile())
|
|
95
|
+
continue;
|
|
96
|
+
// A shim/wrapper script is not itself the copy — resolve through
|
|
97
|
+
// its enclosing package (works for npm/bun global layouts where
|
|
98
|
+
// the bin sits in <pkg>/bin or node_modules/.bin via a symlink).
|
|
99
|
+
let realBin;
|
|
100
|
+
try {
|
|
101
|
+
realBin = realpathSync(binPath);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
realBin = binPath;
|
|
105
|
+
}
|
|
106
|
+
const found = enclosingPackage(dirname(realBin)) ?? enclosingPackage(dir);
|
|
107
|
+
if (found)
|
|
108
|
+
add({ source: "path", path: found.path, version: found.version });
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
/* unreadable PATH entry — skip */
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
/* PATH scan is best-effort */
|
|
118
|
+
}
|
|
119
|
+
// --- opencode npm-cache scan ----------------------------------------------
|
|
120
|
+
// Layout (verified live): <cacheRoot>/opencode/npm/@aron98/load-lock@latest/<ts>/
|
|
121
|
+
// where "@aron98" and "load-lock@latest" are two nested directories (a
|
|
122
|
+
// normal npm scoped layout); URL-escaped single-directory variants like
|
|
123
|
+
// "@aron98%2Fload-lock@latest" also occur. Under each <ts>: either the
|
|
124
|
+
// real package at node_modules/@aron98/load-lock, or a top-level
|
|
125
|
+
// package.json (npm-install root).
|
|
126
|
+
try {
|
|
127
|
+
const cacheRoot = opencodeCacheRoot(platform, env);
|
|
128
|
+
if (cacheRoot) {
|
|
129
|
+
const npmDir = join(cacheRoot, "opencode", "npm");
|
|
130
|
+
if (existsSync(npmDir)) {
|
|
131
|
+
const scanTimestampDirs = (pkgBase) => {
|
|
132
|
+
let tsDirs = [];
|
|
133
|
+
try {
|
|
134
|
+
tsDirs = readdirSync(pkgBase).filter((d) => statSync(join(pkgBase, d)).isDirectory());
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
for (const ts of tsDirs) {
|
|
140
|
+
const nested = join(pkgBase, ts, "node_modules", "@aron98", "load-lock");
|
|
141
|
+
const topLevel = join(pkgBase, ts);
|
|
142
|
+
for (const cand of [nested, topLevel]) {
|
|
143
|
+
try {
|
|
144
|
+
const pkgPath = join(cand, "package.json");
|
|
145
|
+
if (!existsSync(pkgPath))
|
|
146
|
+
continue;
|
|
147
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
148
|
+
if (pkg?.name !== PKG_NAME)
|
|
149
|
+
continue;
|
|
150
|
+
add({
|
|
151
|
+
source: "opencode-cache",
|
|
152
|
+
path: cand,
|
|
153
|
+
version: typeof pkg.version === "string" ? pkg.version : null,
|
|
154
|
+
});
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
/* skip unreadable timestamp dir */
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
let scopeDirs = [];
|
|
164
|
+
try {
|
|
165
|
+
scopeDirs = readdirSync(npmDir).filter((d) => statSync(join(npmDir, d)).isDirectory());
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
scopeDirs = [];
|
|
169
|
+
}
|
|
170
|
+
for (const scopeDir of scopeDirs) {
|
|
171
|
+
if (scopeDir.startsWith("@aron98%2Fload-lock")) {
|
|
172
|
+
// URL-escaped single-directory form.
|
|
173
|
+
scanTimestampDirs(join(npmDir, scopeDir));
|
|
174
|
+
}
|
|
175
|
+
else if (scopeDir === "@aron98") {
|
|
176
|
+
// Scoped layout: @aron98/<name>@<version-range>/<ts>/...
|
|
177
|
+
let inner = [];
|
|
178
|
+
try {
|
|
179
|
+
inner = readdirSync(join(npmDir, scopeDir)).filter((d) => statSync(join(npmDir, scopeDir, d)).isDirectory());
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
for (const nameDir of inner) {
|
|
185
|
+
if (nameDir.startsWith("load-lock"))
|
|
186
|
+
scanTimestampDirs(join(npmDir, scopeDir, nameDir));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
/* cache scan is best-effort */
|
|
195
|
+
}
|
|
196
|
+
return copies;
|
|
197
|
+
}
|
|
198
|
+
/** Cache root the way opencode resolves it (its npm cache lives under it). */
|
|
199
|
+
function opencodeCacheRoot(platform, env) {
|
|
200
|
+
if (env.XDG_CACHE_HOME)
|
|
201
|
+
return env.XDG_CACHE_HOME;
|
|
202
|
+
if (platform === "win32")
|
|
203
|
+
return env.LOCALAPPDATA ?? null;
|
|
204
|
+
if (platform === "darwin")
|
|
205
|
+
return join(env.HOME ?? homedir(), "Library", "Caches");
|
|
206
|
+
return join(env.HOME ?? homedir(), ".cache");
|
|
207
|
+
}
|
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
|
}
|