omnilane 0.34.0 → 0.41.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +47 -1
- package/README.ja.md +44 -33
- package/README.ko.md +44 -32
- package/README.md +71 -77
- package/README.zh-CN.md +42 -30
- package/README.zh-TW.md +65 -68
- package/VERSION +1 -1
- package/docs/aa-model-coverage-2026-09-05.json +29204 -0
- package/docs/model-capabilities-2026-09.md +380 -0
- package/package.json +3 -1
- package/plugin.json +1 -1
- package/routing.local.yaml.example +8 -3
- package/routing.yaml +16 -16
- package/scripts/configure.sh +4 -4
- package/scripts/dispatch.sh +83 -14
- package/scripts/doctor.sh +55 -1
- package/scripts/jobs.sh +6 -2
- package/scripts/lib/common.sh +47 -1
- package/scripts/lib/job-worker.sh +312 -20
- package/scripts/lib/live-protocol.sh +147 -2
- package/scripts/lib/normalize-claude-stream.py +72 -0
- package/scripts/lib/prepare-agy-mode.py +374 -0
- package/scripts/release-audit.sh +103 -0
- package/scripts/runners/run-claude.sh +81 -47
- package/scripts/runners/run-codex-live.py +462 -0
- package/scripts/runners/run-codex.sh +62 -3
- package/scripts/runners/run-gemini.sh +85 -10
- package/scripts/runners/run-grok-live.py +426 -0
- package/scripts/runners/run-grok.sh +113 -6
- package/scripts/runners/run-vote.sh +3 -3
- package/skills/omnilane/SKILL.md +106 -59
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Create one private per-session Agy settings root and print its CLI path."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
import tempfile
|
|
12
|
+
|
|
13
|
+
POLICY_MARKER = ".omnilane-policy-root-v1.json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def policy(mode: str, workdir: str, app_root: str | None = None) -> dict[str, object]:
|
|
17
|
+
read = f"read_file({workdir})"
|
|
18
|
+
write = f"write_file({workdir})"
|
|
19
|
+
if mode == "advise":
|
|
20
|
+
allow = [read, "read_url(*)"]
|
|
21
|
+
deny = ["write_file(*)", "command(*)", "execute_url(*)", "mcp(*)", "unsandboxed(*)"]
|
|
22
|
+
permission = "proceed-in-sandbox"
|
|
23
|
+
terminal_sandbox = True
|
|
24
|
+
outside = False
|
|
25
|
+
elif mode == "work":
|
|
26
|
+
allow = [read, write, "command(*)"]
|
|
27
|
+
# Agy's command(*) denial matching also covers unsandboxed(*), so a
|
|
28
|
+
# blanket unsandboxed deny blocks ordinary sandboxed commands. Native
|
|
29
|
+
# proceed-in-sandbox asks for bypass separately and headless denies it.
|
|
30
|
+
deny = ["read_url(*)", "execute_url(*)", "mcp(*)"]
|
|
31
|
+
roots = [
|
|
32
|
+
"/tmp", "/private/tmp", "/var/tmp", "/private/var/tmp",
|
|
33
|
+
"/var/folders", "/private/var/folders", "/Library/Caches",
|
|
34
|
+
str(Path.home() / "Library/Caches"), str(Path.home() / ".cache"),
|
|
35
|
+
str(Path.home() / ".npm"), str(Path(workdir) / ".agents"),
|
|
36
|
+
]
|
|
37
|
+
if app_root is not None:
|
|
38
|
+
roots.append(app_root)
|
|
39
|
+
# read_file denial is required for native default cache mounts;
|
|
40
|
+
# write_file denial alone did not remove their shell write access.
|
|
41
|
+
deny += [f"{action}({root})" for root in dict.fromkeys(roots)
|
|
42
|
+
for action in ("read_file", "write_file")]
|
|
43
|
+
permission = "proceed-in-sandbox"
|
|
44
|
+
terminal_sandbox = True
|
|
45
|
+
outside = False
|
|
46
|
+
elif mode == "sysops":
|
|
47
|
+
allow = [
|
|
48
|
+
"read_file(*)", "write_file(*)", "command(*)", "read_url(*)",
|
|
49
|
+
"execute_url(*)", "mcp(*)", "unsandboxed(*)",
|
|
50
|
+
]
|
|
51
|
+
deny = []
|
|
52
|
+
permission = "always-proceed"
|
|
53
|
+
terminal_sandbox = False
|
|
54
|
+
outside = True
|
|
55
|
+
else:
|
|
56
|
+
raise ValueError(f"unsupported agy mode: {mode}")
|
|
57
|
+
return {
|
|
58
|
+
"toolPermission": permission,
|
|
59
|
+
"enableTerminalSandbox": terminal_sandbox,
|
|
60
|
+
"allowNonWorkspaceAccess": outside,
|
|
61
|
+
"permissions": {"allow": allow, "deny": deny, "ask": []},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_json(path: Path, source: str) -> dict[str, object]:
|
|
66
|
+
if not path.exists():
|
|
67
|
+
return {}
|
|
68
|
+
if not path.is_file():
|
|
69
|
+
raise ValueError(f"{source} permissions source is not a regular file")
|
|
70
|
+
try:
|
|
71
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
72
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
73
|
+
raise ValueError(f"{source} permissions source is unreadable or invalid JSON") from exc
|
|
74
|
+
if not isinstance(value, dict):
|
|
75
|
+
raise ValueError(f"{source} permissions source has an unknown schema")
|
|
76
|
+
return value
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def permission_allow_list(value: object, source: str) -> list[str]:
|
|
80
|
+
if value is None:
|
|
81
|
+
return []
|
|
82
|
+
if not isinstance(value, dict):
|
|
83
|
+
raise ValueError(f"{source} permissions have an unknown schema")
|
|
84
|
+
allow = value.get("allow", [])
|
|
85
|
+
deny = value.get("deny", [])
|
|
86
|
+
ask = value.get("ask", [])
|
|
87
|
+
if not all(isinstance(items, list) and all(isinstance(item, str) for item in items)
|
|
88
|
+
for items in (allow, deny, ask)):
|
|
89
|
+
raise ValueError(f"{source} permissions have an unknown schema")
|
|
90
|
+
return allow
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def rule_broadens_work(rule: str, workdir: Path) -> str | None:
|
|
94
|
+
match = re.fullmatch(r"([a-z_]+)\((.*)\)", rule)
|
|
95
|
+
if match is None:
|
|
96
|
+
return "unknown allow-rule schema"
|
|
97
|
+
action, target = match.groups()
|
|
98
|
+
if action in {"read_url", "execute_url", "mcp", "unsandboxed"}:
|
|
99
|
+
return f"{action} bypasses work network or sandbox policy"
|
|
100
|
+
if action == "write_file":
|
|
101
|
+
if target == "*" or not target:
|
|
102
|
+
return "write_file grant extends beyond workdir"
|
|
103
|
+
candidate = Path(target).expanduser()
|
|
104
|
+
if not candidate.is_absolute():
|
|
105
|
+
candidate = workdir / candidate
|
|
106
|
+
try:
|
|
107
|
+
candidate.resolve(strict=False).relative_to(workdir)
|
|
108
|
+
except ValueError:
|
|
109
|
+
return "write_file grant extends beyond workdir"
|
|
110
|
+
return None
|
|
111
|
+
if action in {"read_file", "command"}:
|
|
112
|
+
return None
|
|
113
|
+
return "unknown allow-rule action may bypass work policy"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def audit_work_permissions(gemini_dir: Path, app_root: Path, workdir: Path) -> None:
|
|
117
|
+
sources: list[tuple[str, list[str]]] = []
|
|
118
|
+
|
|
119
|
+
shared = load_json(gemini_dir / "config" / "config.json", "Shared")
|
|
120
|
+
if shared.get("allowNonWorkspaceAccess") is True:
|
|
121
|
+
raise ValueError("Shared permissions allow non-workspace access")
|
|
122
|
+
sources.append(("Shared", permission_allow_list(shared.get("permissions"), "Shared")))
|
|
123
|
+
|
|
124
|
+
existing = load_json(app_root / "settings.json", "private app state")
|
|
125
|
+
sources.append(("private app state", permission_allow_list(existing.get("permissions"), "private app state")))
|
|
126
|
+
|
|
127
|
+
project_id_path = app_root / "cache" / "default_project_id.txt"
|
|
128
|
+
if project_id_path.exists():
|
|
129
|
+
if not project_id_path.is_file():
|
|
130
|
+
raise ValueError("Project permission selector has an unknown schema")
|
|
131
|
+
project_id = project_id_path.read_text(encoding="utf-8").strip()
|
|
132
|
+
if not re.fullmatch(r"[A-Za-z0-9._:-]{1,256}", project_id):
|
|
133
|
+
raise ValueError("Project permission selector has an unknown schema")
|
|
134
|
+
project = load_json(gemini_dir / "config" / "projects" / f"{project_id}.json", "Project")
|
|
135
|
+
grants = project.get("permissionGrants", {})
|
|
136
|
+
if grants and not isinstance(grants, dict):
|
|
137
|
+
raise ValueError("Project permissions have an unknown schema")
|
|
138
|
+
nested = grants.get("permissionGrants") if isinstance(grants, dict) else None
|
|
139
|
+
sources.append(("Project", permission_allow_list(nested, "Project")))
|
|
140
|
+
|
|
141
|
+
for source, rules in sources:
|
|
142
|
+
for rule in rules:
|
|
143
|
+
reason = rule_broadens_work(rule, workdir)
|
|
144
|
+
if reason is not None:
|
|
145
|
+
raise ValueError(f"{source} permission grant conflicts with work mode: {reason}")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def ensure_private_root(path: Path, mode: str, workdir: Path) -> None:
|
|
149
|
+
if path.is_symlink():
|
|
150
|
+
raise ValueError(f"unsafe symlinked agy app root: {path}")
|
|
151
|
+
if path.exists() and not path.is_dir():
|
|
152
|
+
raise ValueError(f"unsafe non-directory agy app root: {path}")
|
|
153
|
+
path.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
154
|
+
os.chmod(path, 0o700)
|
|
155
|
+
marker = path / POLICY_MARKER
|
|
156
|
+
expected = {"schema_version": 1, "mode": mode, "workdir": str(workdir)}
|
|
157
|
+
if marker.exists():
|
|
158
|
+
if marker.is_symlink() or load_json(marker, "private app marker") != expected:
|
|
159
|
+
raise ValueError("private app state does not match the requested mode and workdir")
|
|
160
|
+
else:
|
|
161
|
+
if any(path.iterdir()):
|
|
162
|
+
raise ValueError("private app state is not an Omnilane-owned empty root")
|
|
163
|
+
marker.write_text(json.dumps(expected, sort_keys=True) + "\n", encoding="utf-8")
|
|
164
|
+
os.chmod(marker, 0o600)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def atomic_write_json(path: Path, value: dict[str, object]) -> None:
|
|
168
|
+
if path.is_symlink() or (path.exists() and not path.is_file()):
|
|
169
|
+
raise ValueError("unsafe agy settings path")
|
|
170
|
+
fd, temporary = tempfile.mkstemp(prefix=".settings.", dir=path.parent)
|
|
171
|
+
try:
|
|
172
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
173
|
+
json.dump(value, handle, separators=(",", ":"))
|
|
174
|
+
handle.write("\n")
|
|
175
|
+
handle.flush()
|
|
176
|
+
os.fsync(handle.fileno())
|
|
177
|
+
os.chmod(temporary, 0o600)
|
|
178
|
+
os.replace(temporary, path)
|
|
179
|
+
finally:
|
|
180
|
+
try:
|
|
181
|
+
os.unlink(temporary)
|
|
182
|
+
except FileNotFoundError:
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
WORK_TOOLS = (
|
|
187
|
+
"view_file", "write_to_file", "run_command", "finish",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def ensure_work_agent(app_root: Path, name: str = "omnilane-work") -> Path:
|
|
192
|
+
"""Own and verify a session policy file; filesystem mode is not immutability."""
|
|
193
|
+
directory = app_root / "policy"
|
|
194
|
+
if directory.is_symlink() or directory.resolve() != app_root.resolve() / "policy":
|
|
195
|
+
raise ValueError("unsafe agy agent policy directory")
|
|
196
|
+
if directory.exists() and not directory.is_dir():
|
|
197
|
+
raise ValueError("unsafe agy agent policy directory")
|
|
198
|
+
directory.mkdir(mode=0o700, exist_ok=True)
|
|
199
|
+
os.chmod(directory, 0o700)
|
|
200
|
+
path = directory / "agent.md"
|
|
201
|
+
content = (
|
|
202
|
+
f"---\nname: {name}\ndescription: Omnilane local work mode\n"
|
|
203
|
+
"mainAgent: true\nsubagent: false\ninheritCustomizations: false\n"
|
|
204
|
+
"inheritMcp: false\ncommandExecutionPolicy: sandbox\ntools:\n"
|
|
205
|
+
+ "".join(f" - {tool}\n" for tool in WORK_TOOLS)
|
|
206
|
+
+ "---\nComplete the requested local workspace task using the listed tools.\n"
|
|
207
|
+
"Use view_file for reading and write_to_file for creating files. For precise "
|
|
208
|
+
"edits, searches, builds and tests use run_command, preserving unrelated "
|
|
209
|
+
"file content. Wait for terminal results and report nonzero exit status.\n"
|
|
210
|
+
)
|
|
211
|
+
if path.is_symlink() or (path.exists() and not path.is_file()):
|
|
212
|
+
raise ValueError("unsafe agy primary agent path")
|
|
213
|
+
if path.exists():
|
|
214
|
+
if path.read_text(encoding="utf-8") != content:
|
|
215
|
+
raise ValueError("existing agy primary agent conflicts with work mode")
|
|
216
|
+
os.chmod(path, 0o600)
|
|
217
|
+
return path.resolve()
|
|
218
|
+
fd, temporary = tempfile.mkstemp(prefix=".agent.", dir=directory)
|
|
219
|
+
try:
|
|
220
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
221
|
+
handle.write(content)
|
|
222
|
+
handle.flush()
|
|
223
|
+
os.fsync(handle.fileno())
|
|
224
|
+
os.chmod(temporary, 0o600)
|
|
225
|
+
# link is atomic and fails if another process created the target.
|
|
226
|
+
try:
|
|
227
|
+
os.link(temporary, path)
|
|
228
|
+
except FileExistsError:
|
|
229
|
+
if path.is_symlink() or path.read_text(encoding="utf-8") != content:
|
|
230
|
+
raise ValueError("existing agy primary agent conflicts with work mode")
|
|
231
|
+
finally:
|
|
232
|
+
os.unlink(temporary)
|
|
233
|
+
return path.resolve()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def checked_directory(path: Path) -> None:
|
|
237
|
+
if path.is_symlink() or (path.exists() and not path.is_dir()):
|
|
238
|
+
raise ValueError(f"unsafe agy workspace directory: {path}")
|
|
239
|
+
path.mkdir(mode=0o700, exist_ok=True)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def stage_work_agent(app_root: Path, workdir: Path) -> None:
|
|
243
|
+
name = "omnilane-work-" + hashlib.sha256(str(app_root.resolve()).encode()).hexdigest()[:16]
|
|
244
|
+
agent = ensure_work_agent(app_root, name)
|
|
245
|
+
cache = workdir / ".omnilane-cache"
|
|
246
|
+
checked_directory(cache)
|
|
247
|
+
cache = cache / name
|
|
248
|
+
checked_directory(cache)
|
|
249
|
+
for part in ("tmp", "cache", "clang", "swift"):
|
|
250
|
+
checked_directory(cache / part)
|
|
251
|
+
for parent in (workdir / ".agents", workdir / ".agents/agents"):
|
|
252
|
+
checked_directory(parent)
|
|
253
|
+
leaf = workdir / ".agents/agents" / name
|
|
254
|
+
# This exclusive owned leaf is also the per-session concurrency guard.
|
|
255
|
+
# Never adopt or overwrite a user profile, an active run or stale state.
|
|
256
|
+
try:
|
|
257
|
+
leaf.mkdir(mode=0o700)
|
|
258
|
+
initial_stat = leaf.lstat()
|
|
259
|
+
expected_identity = (initial_stat.st_dev, initial_stat.st_ino)
|
|
260
|
+
except FileExistsError as exc:
|
|
261
|
+
cleaned = load_json(app_root / "workspace-agent-cleaned.json", "cleaned workspace policy")
|
|
262
|
+
if leaf.is_symlink() or not leaf.is_dir():
|
|
263
|
+
raise ValueError("unsafe existing agy workspace policy leaf") from exc
|
|
264
|
+
st = leaf.lstat()
|
|
265
|
+
if (cleaned.get("leaf") != str(leaf) or cleaned.get("app_root") != str(app_root.resolve())
|
|
266
|
+
or cleaned.get("workdir") != str(workdir) or cleaned.get("agent") != name
|
|
267
|
+
or (st.st_dev, st.st_ino) != (cleaned.get("device"), cleaned.get("inode"))
|
|
268
|
+
or any(leaf.iterdir())):
|
|
269
|
+
raise ValueError("agy workspace policy leaf is active or stale; inspect it before reuse") from exc
|
|
270
|
+
expected_identity = (cleaned["device"], cleaned["inode"])
|
|
271
|
+
marker = {"schema_version": 1, "app_root": str(app_root.resolve()),
|
|
272
|
+
"workdir": str(workdir), "agent": name}
|
|
273
|
+
fd = os.open(leaf, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
274
|
+
try:
|
|
275
|
+
st = os.fstat(fd)
|
|
276
|
+
if (st.st_dev, st.st_ino) != expected_identity:
|
|
277
|
+
raise ValueError("agy workspace policy leaf changed before staging")
|
|
278
|
+
marker_fd = os.open(".omnilane-owned.json", os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
|
279
|
+
0o600, dir_fd=fd)
|
|
280
|
+
with os.fdopen(marker_fd, "w", encoding="utf-8") as handle:
|
|
281
|
+
json.dump(marker, handle)
|
|
282
|
+
handle.write("\n")
|
|
283
|
+
os.symlink(agent, "agent.md", dir_fd=fd)
|
|
284
|
+
current = leaf.lstat()
|
|
285
|
+
if (current.st_dev, current.st_ino) != (st.st_dev, st.st_ino):
|
|
286
|
+
raise ValueError("agy workspace policy leaf changed during staging")
|
|
287
|
+
finally:
|
|
288
|
+
os.close(fd)
|
|
289
|
+
atomic_write_json(app_root / "workspace-agent.json", {
|
|
290
|
+
**marker, "leaf": str(leaf), "device": st.st_dev, "inode": st.st_ino,
|
|
291
|
+
"policy": str(agent), "policy_sha256": hashlib.sha256(agent.read_bytes()).hexdigest(),
|
|
292
|
+
"cache": str(cache),
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def cleanup_work_agent(app_root: Path, workdir: Path) -> None:
|
|
297
|
+
state_path = app_root / "workspace-agent.json"
|
|
298
|
+
if state_path.is_symlink():
|
|
299
|
+
raise ValueError("unsafe agy workspace policy state")
|
|
300
|
+
state = load_json(state_path, "workspace policy")
|
|
301
|
+
if not state:
|
|
302
|
+
return
|
|
303
|
+
name = state.get("agent", "")
|
|
304
|
+
if not isinstance(name, str) or not re.fullmatch(r"omnilane-work-[a-f0-9]{16}", name):
|
|
305
|
+
raise ValueError("invalid agy workspace policy identity")
|
|
306
|
+
leaf = workdir / ".agents/agents" / name
|
|
307
|
+
expected = {"schema_version": 1, "app_root": str(app_root.resolve()),
|
|
308
|
+
"workdir": str(workdir), "agent": name}
|
|
309
|
+
if any(state.get(k) != v for k, v in expected.items()) or state.get("leaf") != str(leaf):
|
|
310
|
+
raise ValueError("agy workspace policy ownership changed")
|
|
311
|
+
for path in (workdir / ".agents", workdir / ".agents/agents", leaf):
|
|
312
|
+
if path.is_symlink() or not path.is_dir():
|
|
313
|
+
raise ValueError("agy workspace policy directory changed")
|
|
314
|
+
# Pin the owned leaf before unlinking. All removal is relative to this FD.
|
|
315
|
+
fd = os.open(leaf, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
316
|
+
try:
|
|
317
|
+
st = os.fstat(fd)
|
|
318
|
+
if (st.st_dev, st.st_ino) != (state.get("device"), state.get("inode")):
|
|
319
|
+
raise ValueError("agy workspace policy inode changed")
|
|
320
|
+
if set(os.listdir(fd)) != {"agent.md", ".omnilane-owned.json"}:
|
|
321
|
+
raise ValueError("agy workspace policy leaf contents changed")
|
|
322
|
+
marker = leaf / ".omnilane-owned.json"
|
|
323
|
+
agent = leaf / "agent.md"
|
|
324
|
+
policy_path = app_root / "policy/agent.md"
|
|
325
|
+
if (marker.is_symlink() or load_json(marker, "workspace marker") != expected
|
|
326
|
+
or not agent.is_symlink() or os.readlink("agent.md", dir_fd=fd) != str(policy_path.resolve())
|
|
327
|
+
or state.get("policy") != str(policy_path.resolve())
|
|
328
|
+
or hashlib.sha256(policy_path.read_bytes()).hexdigest() != state.get("policy_sha256")):
|
|
329
|
+
raise ValueError("agy workspace policy content changed")
|
|
330
|
+
os.unlink("agent.md", dir_fd=fd)
|
|
331
|
+
os.unlink(".omnilane-owned.json", dir_fd=fd)
|
|
332
|
+
# No path-based rmdir: another process could replace the name after
|
|
333
|
+
# validation. Keep this empty owned directory and reuse its exact inode.
|
|
334
|
+
current = leaf.lstat()
|
|
335
|
+
if (current.st_dev, current.st_ino) != (st.st_dev, st.st_ino):
|
|
336
|
+
raise ValueError("agy workspace policy leaf changed during cleanup; replacement preserved")
|
|
337
|
+
atomic_write_json(app_root / "workspace-agent-cleaned.json", state)
|
|
338
|
+
state_path.unlink()
|
|
339
|
+
finally:
|
|
340
|
+
os.close(fd)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def main() -> int:
|
|
344
|
+
parser = argparse.ArgumentParser()
|
|
345
|
+
parser.add_argument("--mode", required=True, choices=("advise", "work", "sysops"))
|
|
346
|
+
parser.add_argument("--workdir", required=True)
|
|
347
|
+
parser.add_argument("--app-root", required=True)
|
|
348
|
+
parser.add_argument("--gemini-dir", required=True)
|
|
349
|
+
parser.add_argument("--cleanup", action="store_true")
|
|
350
|
+
args = parser.parse_args()
|
|
351
|
+
|
|
352
|
+
workdir = Path(args.workdir).resolve(strict=True)
|
|
353
|
+
app_root = Path(args.app_root).expanduser()
|
|
354
|
+
gemini_dir = Path(args.gemini_dir).expanduser().resolve(strict=True)
|
|
355
|
+
if args.cleanup:
|
|
356
|
+
cleanup_work_agent(app_root, workdir)
|
|
357
|
+
return 0
|
|
358
|
+
ensure_private_root(app_root, args.mode, workdir)
|
|
359
|
+
if args.mode == "work":
|
|
360
|
+
audit_work_permissions(gemini_dir, app_root, workdir)
|
|
361
|
+
atomic_write_json(app_root / "settings.json", policy(args.mode, str(workdir), str(app_root.resolve())))
|
|
362
|
+
if args.mode == "work":
|
|
363
|
+
stage_work_agent(app_root, workdir)
|
|
364
|
+
print(os.path.relpath(app_root.resolve(), gemini_dir))
|
|
365
|
+
return 0
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
if __name__ == "__main__":
|
|
369
|
+
try:
|
|
370
|
+
raise SystemExit(main())
|
|
371
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
372
|
+
import sys
|
|
373
|
+
print(f"omnilane: {exc}", file=sys.stderr)
|
|
374
|
+
raise SystemExit(2)
|
package/scripts/release-audit.sh
CHANGED
|
@@ -140,6 +140,28 @@ sha256_stdin() {
|
|
|
140
140
|
fi
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
extract_long_flags() {
|
|
144
|
+
awk '
|
|
145
|
+
{
|
|
146
|
+
text = $0
|
|
147
|
+
while (match(text, /--[a-z][a-z-]*/)) {
|
|
148
|
+
token = substr(text, RSTART, RLENGTH)
|
|
149
|
+
if (token != "--help") print token
|
|
150
|
+
text = substr(text, RSTART + RLENGTH)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
' | LC_ALL=C sort -u
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
extract_help_subcommands() {
|
|
157
|
+
awk '$1 == "omnilane" && $2 ~ /^[a-z][a-z-]*$/ { print $2 }' |
|
|
158
|
+
LC_ALL=C sort -u
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
join_token_lines() {
|
|
162
|
+
awk 'NF { printf "%s%s", separator, $0; separator = "," } END { print "" }'
|
|
163
|
+
}
|
|
164
|
+
|
|
143
165
|
command -v git >/dev/null 2>&1 || {
|
|
144
166
|
fail git-unavailable
|
|
145
167
|
if [[ "$json_output" -eq 1 ]]; then render_json FAIL;
|
|
@@ -167,6 +189,87 @@ else
|
|
|
167
189
|
pass clean-worktree
|
|
168
190
|
fi
|
|
169
191
|
|
|
192
|
+
usage_doc="${OMNILANE_USAGE_DOC:-}"
|
|
193
|
+
if [[ -z "$usage_doc" ]]; then
|
|
194
|
+
[[ "$json_output" -eq 1 ]] ||
|
|
195
|
+
printf 'SKIP usage-doc-drift OMNILANE_USAGE_DOC is not configured\n'
|
|
196
|
+
elif [[ ! -f "$usage_doc" || ! -r "$usage_doc" ]]; then
|
|
197
|
+
fail "usage-doc-unreadable:$usage_doc"
|
|
198
|
+
else
|
|
199
|
+
usage_help_failed=0
|
|
200
|
+
dispatch_usage_help=""
|
|
201
|
+
jobs_usage_help=""
|
|
202
|
+
omnilane_usage_help=""
|
|
203
|
+
if ! dispatch_usage_help="$(/bin/bash "$ROOT/scripts/dispatch.sh" --help 2>&1)"; then
|
|
204
|
+
fail usage-doc-dispatch-help-unavailable
|
|
205
|
+
usage_help_failed=1
|
|
206
|
+
fi
|
|
207
|
+
if ! jobs_usage_help="$(/bin/bash "$ROOT/scripts/jobs.sh" --help 2>&1)"; then
|
|
208
|
+
fail usage-doc-jobs-help-unavailable
|
|
209
|
+
usage_help_failed=1
|
|
210
|
+
fi
|
|
211
|
+
if ! omnilane_usage_help="$(/bin/bash "$ROOT/bin/omnilane" help 2>&1)"; then
|
|
212
|
+
fail usage-doc-omnilane-help-unavailable
|
|
213
|
+
usage_help_failed=1
|
|
214
|
+
fi
|
|
215
|
+
|
|
216
|
+
if [[ "$usage_help_failed" -eq 0 ]]; then
|
|
217
|
+
expected_usage_flags="$(
|
|
218
|
+
printf '%s\n%s\n' "$dispatch_usage_help" "$jobs_usage_help" |
|
|
219
|
+
extract_long_flags
|
|
220
|
+
)"
|
|
221
|
+
expected_usage_subcommands="$(
|
|
222
|
+
printf '%s\n' "$omnilane_usage_help" | extract_help_subcommands
|
|
223
|
+
)"
|
|
224
|
+
expected_usage_tokens="$(
|
|
225
|
+
printf '%s\n%s\n' "$expected_usage_flags" "$expected_usage_subcommands" |
|
|
226
|
+
awk 'NF' | LC_ALL=C sort -u
|
|
227
|
+
)"
|
|
228
|
+
|
|
229
|
+
documented_usage_flags="$(extract_long_flags < "$usage_doc")"
|
|
230
|
+
documented_usage_subcommands="$(
|
|
231
|
+
{
|
|
232
|
+
while IFS= read -r token; do
|
|
233
|
+
[[ -n "$token" ]] || continue
|
|
234
|
+
if LC_ALL=C grep -E -q \
|
|
235
|
+
"(^|[^a-z-])${token}([^a-z-]|$)" "$usage_doc"; then
|
|
236
|
+
printf '%s\n' "$token"
|
|
237
|
+
fi
|
|
238
|
+
done <<< "$expected_usage_subcommands"
|
|
239
|
+
{
|
|
240
|
+
LC_ALL=C grep -Eo 'omnilane[[:space:]]+[a-z][a-z-]*' "$usage_doc" 2>/dev/null ||
|
|
241
|
+
true
|
|
242
|
+
} | awk '{ print $2 }'
|
|
243
|
+
} | awk 'NF' | LC_ALL=C sort -u
|
|
244
|
+
)"
|
|
245
|
+
documented_usage_tokens="$(
|
|
246
|
+
printf '%s\n%s\n' "$documented_usage_flags" "$documented_usage_subcommands" |
|
|
247
|
+
awk 'NF' | LC_ALL=C sort -u
|
|
248
|
+
)"
|
|
249
|
+
|
|
250
|
+
missing_usage_tokens="$(
|
|
251
|
+
comm -23 \
|
|
252
|
+
<(printf '%s\n' "$expected_usage_tokens" | awk 'NF') \
|
|
253
|
+
<(printf '%s\n' "$documented_usage_tokens" | awk 'NF')
|
|
254
|
+
)"
|
|
255
|
+
stale_usage_tokens="$(
|
|
256
|
+
comm -13 \
|
|
257
|
+
<(printf '%s\n' "$expected_usage_tokens" | awk 'NF') \
|
|
258
|
+
<(printf '%s\n' "$documented_usage_tokens" | awk 'NF')
|
|
259
|
+
)"
|
|
260
|
+
|
|
261
|
+
expected_usage_count="$(printf '%s\n' "$expected_usage_tokens" | awk 'NF { count++ } END { print count + 0 }')"
|
|
262
|
+
documented_usage_count="$(printf '%s\n' "$documented_usage_tokens" | awk 'NF { count++ } END { print count + 0 }')"
|
|
263
|
+
if [[ -z "$missing_usage_tokens" && -z "$stale_usage_tokens" ]]; then
|
|
264
|
+
pass "usage-doc-drift:expected=$expected_usage_count documented=$documented_usage_count"
|
|
265
|
+
else
|
|
266
|
+
missing_usage_list="$(printf '%s\n' "$missing_usage_tokens" | join_token_lines)"
|
|
267
|
+
stale_usage_list="$(printf '%s\n' "$stale_usage_tokens" | join_token_lines)"
|
|
268
|
+
fail "usage-doc-drift:path=$usage_doc missing=${missing_usage_list:-none} stale=${stale_usage_list:-none}"
|
|
269
|
+
fi
|
|
270
|
+
fi
|
|
271
|
+
fi
|
|
272
|
+
|
|
170
273
|
version=""
|
|
171
274
|
if [[ -f "$ROOT/VERSION" && ! -L "$ROOT/VERSION" ]]; then
|
|
172
275
|
version="$(<"$ROOT/VERSION")"
|
|
@@ -13,6 +13,60 @@ CLAUDE_BIN="${CLAUDE_BIN:-claude}"
|
|
|
13
13
|
RUN_TIMEOUT="${OMNILANE_TIMEOUT:-600}"
|
|
14
14
|
|
|
15
15
|
truncate_payload "$PROMPT_FILE" 102400
|
|
16
|
+
WORKDIR="$(cd -- "$WORKDIR" && pwd -P)" || {
|
|
17
|
+
echo "omnilane: Claude workdir is not accessible" >&2
|
|
18
|
+
exit 2
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
MODE_ENV=(OMNILANE_DEPTH=1)
|
|
22
|
+
if [[ "$MODE" == "work" ]]; then
|
|
23
|
+
CLAUDE_TMP_BASE="$WORKDIR/.omnilane-claude-tmp"
|
|
24
|
+
if [[ -L "$CLAUDE_TMP_BASE" || ( -e "$CLAUDE_TMP_BASE" && ! -d "$CLAUDE_TMP_BASE" ) ]]; then
|
|
25
|
+
echo "omnilane: unsafe Claude work temp path" >&2
|
|
26
|
+
exit 125
|
|
27
|
+
fi
|
|
28
|
+
mkdir -p "$CLAUDE_TMP_BASE"
|
|
29
|
+
chmod 700 "$CLAUDE_TMP_BASE"
|
|
30
|
+
CLAUDE_TMP_BASE="$(cd -- "$CLAUDE_TMP_BASE" && pwd -P)"
|
|
31
|
+
case "$CLAUDE_TMP_BASE/" in
|
|
32
|
+
"$WORKDIR/"*) ;;
|
|
33
|
+
*) echo "omnilane: Claude work temp escaped workdir" >&2; exit 125 ;;
|
|
34
|
+
esac
|
|
35
|
+
MODE_ENV+=("CLAUDE_CODE_TMPDIR=$CLAUDE_TMP_BASE")
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
RESTRICTED_SETTINGS='{"sandbox":{"enabled":true,"failIfUnavailable":true,"allowUnsandboxedCommands":false,"autoAllowBashIfSandboxed":true,"excludedCommands":[],"filesystem":{"disabled":false,"allowRead":[],"allowWrite":[]},"network":{"allowedDomains":[]}}}'
|
|
39
|
+
MODE_ARGS=()
|
|
40
|
+
case "$MODE" in
|
|
41
|
+
advise)
|
|
42
|
+
MODE_ARGS=(
|
|
43
|
+
--safe-mode --restricted --setting-sources ""
|
|
44
|
+
--strict-mcp-config --mcp-config '{"mcpServers":{}}'
|
|
45
|
+
--settings "$RESTRICTED_SETTINGS"
|
|
46
|
+
--permission-prompts none --permission-mode plan
|
|
47
|
+
--tools 'Bash,Read,Glob,Grep,WebSearch,WebFetch'
|
|
48
|
+
)
|
|
49
|
+
;;
|
|
50
|
+
work)
|
|
51
|
+
MODE_ARGS=(
|
|
52
|
+
--safe-mode --restricted --setting-sources ""
|
|
53
|
+
--strict-mcp-config --mcp-config '{"mcpServers":{}}'
|
|
54
|
+
--settings "$RESTRICTED_SETTINGS"
|
|
55
|
+
--permission-prompts none --permission-mode acceptEdits
|
|
56
|
+
--tools 'Bash,Read,Glob,Grep,Edit,Write,NotebookEdit'
|
|
57
|
+
)
|
|
58
|
+
;;
|
|
59
|
+
sysops)
|
|
60
|
+
MODE_ARGS=(
|
|
61
|
+
--settings '{"sandbox":{"enabled":false}}'
|
|
62
|
+
--permission-mode bypassPermissions --dangerously-skip-permissions
|
|
63
|
+
)
|
|
64
|
+
;;
|
|
65
|
+
*)
|
|
66
|
+
echo "omnilane: invalid Claude mode '$MODE'" >&2
|
|
67
|
+
exit 2
|
|
68
|
+
;;
|
|
69
|
+
esac
|
|
16
70
|
|
|
17
71
|
THREAD_MODE="${OMNILANE_THREAD_MODE:-}"
|
|
18
72
|
THREAD_ID="${OMNILANE_THREAD_ID:-}"
|
|
@@ -42,48 +96,25 @@ if [[ -n "$LIVE_INBOX" && -p "$LIVE_INBOX" ]]; then
|
|
|
42
96
|
(umask 077; : > "$EVENTS_FILE"; : > "$STDERR_FILE")
|
|
43
97
|
|
|
44
98
|
LIVE_ARGS=(--disable-slash-commands --model "$MODEL")
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
99
|
+
[[ -n "$EFFORT" && "$EFFORT" != "-" ]] && LIVE_ARGS+=(--effort "$EFFORT")
|
|
100
|
+
LIVE_ARGS+=("${MODE_ARGS[@]}")
|
|
101
|
+
LIVE_ARGS+=(-p --verbose --input-format stream-json --output-format stream-json)
|
|
102
|
+
|
|
103
|
+
finalize_live_output() {
|
|
104
|
+
local tmp="${OUTPUT_FILE}.tmp"
|
|
105
|
+
if command -v python3 >/dev/null 2>&1; then
|
|
106
|
+
if python3 "$OMNILANE_REPO/scripts/lib/normalize-claude-stream.py" \
|
|
107
|
+
"$EVENTS_FILE" "$tmp"; then
|
|
108
|
+
mv "$tmp" "$OUTPUT_FILE"
|
|
109
|
+
return 0
|
|
110
|
+
fi
|
|
111
|
+
echo "omnilane: Claude live stream ended without readable successful result or top-level assistant text" >> "$STDERR_FILE"
|
|
112
|
+
return 1
|
|
50
113
|
fi
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
finalize_live_output() {
|
|
54
|
-
local tmp="${OUTPUT_FILE}.tmp"
|
|
55
|
-
if ! command -v python3 >/dev/null 2>&1; then
|
|
114
|
+
if ! command -v python3 >/dev/null 2>&1; then
|
|
56
115
|
echo "omnilane: cannot extract Claude live result: python3 not found" >> "$STDERR_FILE"
|
|
57
116
|
return 1
|
|
58
117
|
fi
|
|
59
|
-
if ! python3 - "$EVENTS_FILE" "$tmp" <<'PY'
|
|
60
|
-
import json
|
|
61
|
-
import pathlib
|
|
62
|
-
import sys
|
|
63
|
-
|
|
64
|
-
events_path = pathlib.Path(sys.argv[1])
|
|
65
|
-
output_path = pathlib.Path(sys.argv[2])
|
|
66
|
-
last_result = None
|
|
67
|
-
|
|
68
|
-
with events_path.open(encoding="utf-8") as events:
|
|
69
|
-
for raw_line in events:
|
|
70
|
-
try:
|
|
71
|
-
event = json.loads(raw_line)
|
|
72
|
-
except json.JSONDecodeError:
|
|
73
|
-
continue
|
|
74
|
-
if event.get("type") == "result" and isinstance(event.get("result"), str):
|
|
75
|
-
last_result = event["result"]
|
|
76
|
-
|
|
77
|
-
if last_result is None:
|
|
78
|
-
raise SystemExit(1)
|
|
79
|
-
|
|
80
|
-
output_path.write_text(last_result.rstrip("\n") + "\n", encoding="utf-8")
|
|
81
|
-
PY
|
|
82
|
-
then
|
|
83
|
-
echo "omnilane: Claude live stream ended without a readable result event" >> "$STDERR_FILE"
|
|
84
|
-
return 1
|
|
85
|
-
fi
|
|
86
|
-
mv "$tmp" "$OUTPUT_FILE"
|
|
87
118
|
}
|
|
88
119
|
|
|
89
120
|
# Invoked by signal traps below.
|
|
@@ -103,6 +134,9 @@ PY
|
|
|
103
134
|
fi
|
|
104
135
|
wait "$LIVE_CHILD_PID" 2>/dev/null
|
|
105
136
|
fi
|
|
137
|
+
# Recover the transcript unconditionally so an aborted turn still leaves
|
|
138
|
+
# its work in out.txt; whether that recovery counts as success is decided
|
|
139
|
+
# by the job worker, which alone knows why the session was closed.
|
|
106
140
|
finalize_live_output || true
|
|
107
141
|
[[ -s "$STDERR_FILE" ]] || rm "$STDERR_FILE" 2>/dev/null || true
|
|
108
142
|
exit "$signal_rc"
|
|
@@ -112,7 +146,7 @@ PY
|
|
|
112
146
|
(
|
|
113
147
|
cd "$WORKDIR" || exit 127
|
|
114
148
|
run_with_timeout "$RUN_TIMEOUT" env \
|
|
115
|
-
|
|
149
|
+
"${MODE_ENV[@]}" \
|
|
116
150
|
"$CLAUDE_BIN" "${LIVE_ARGS[@]}" < "$LIVE_INBOX" > "$EVENTS_FILE" 2> "$STDERR_FILE"
|
|
117
151
|
) &
|
|
118
152
|
LIVE_CHILD_PID=$!
|
|
@@ -135,12 +169,7 @@ fi
|
|
|
135
169
|
|
|
136
170
|
ARGS=(--disable-slash-commands --model "$MODEL")
|
|
137
171
|
[[ -n "$EFFORT" && "$EFFORT" != "-" ]] && ARGS+=(--effort "$EFFORT")
|
|
138
|
-
|
|
139
|
-
# Read-only surface: the worker can inspect the repo but not change or run anything.
|
|
140
|
-
ARGS+=(--tools Read Glob Grep)
|
|
141
|
-
else
|
|
142
|
-
ARGS+=(--permission-mode acceptEdits)
|
|
143
|
-
fi
|
|
172
|
+
ARGS+=("${MODE_ARGS[@]}")
|
|
144
173
|
if [[ -n "$THREAD_MODE" ]]; then
|
|
145
174
|
ARGS+=(--verbose --output-format stream-json)
|
|
146
175
|
ARGS+=("${THREAD_ARGS[@]}")
|
|
@@ -154,11 +183,11 @@ set +e
|
|
|
154
183
|
cd "$WORKDIR" || exit 127
|
|
155
184
|
if [[ -n "$THREAD_MODE" ]]; then
|
|
156
185
|
run_with_timeout "$RUN_TIMEOUT" env \
|
|
157
|
-
|
|
186
|
+
"${MODE_ENV[@]}" \
|
|
158
187
|
"$CLAUDE_BIN" "${ARGS[@]}" > "${OUTPUT_FILE}.events.jsonl" 2> "${OUTPUT_FILE}.stderr.log"
|
|
159
188
|
else
|
|
160
189
|
run_with_timeout "$RUN_TIMEOUT" env \
|
|
161
|
-
|
|
190
|
+
"${MODE_ENV[@]}" \
|
|
162
191
|
"$CLAUDE_BIN" "${ARGS[@]}" > "${OUTPUT_FILE}.tmp" 2> "${OUTPUT_FILE}.stderr.log"
|
|
163
192
|
fi
|
|
164
193
|
)
|
|
@@ -166,6 +195,11 @@ RC=$?
|
|
|
166
195
|
set -e
|
|
167
196
|
|
|
168
197
|
if [[ -n "$THREAD_MODE" ]]; then
|
|
198
|
+
if [[ "$RC" -eq 0 ]] && ! python3 "$OMNILANE_REPO/scripts/lib/normalize-claude-stream.py" \
|
|
199
|
+
"$OUTPUT_FILE.events.jsonl" "${OUTPUT_FILE}.tmp"; then
|
|
200
|
+
echo "omnilane: Claude thread stream without successful result or top-level assistant text" >> "${OUTPUT_FILE}.stderr.log"
|
|
201
|
+
RC=1
|
|
202
|
+
fi
|
|
169
203
|
if [[ "$RC" -eq 0 ]]; then
|
|
170
204
|
if ! python3 - "$OUTPUT_FILE.events.jsonl" "${OUTPUT_FILE}.tmp" <<'PY'
|
|
171
205
|
import json
|