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.
@@ -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)
@@ -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
- [[ -n "$EFFORT" && "$EFFORT" != "-" ]] && LIVE_ARGS+=(--effort "$EFFORT")
46
- if [[ "$MODE" == "advise" ]]; then
47
- LIVE_ARGS+=(--tools Read Glob Grep)
48
- else
49
- LIVE_ARGS+=(--permission-mode acceptEdits)
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
- LIVE_ARGS+=(-p --verbose --input-format stream-json --output-format stream-json)
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
- OMNILANE_DEPTH=1 \
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
- if [[ "$MODE" == "advise" ]]; then
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
- OMNILANE_DEPTH=1 \
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
- OMNILANE_DEPTH=1 \
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