omega-code 0.4.0__py3-none-any.whl

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.
Files changed (73) hide show
  1. omega/__init__.py +0 -0
  2. omega/__main__.py +589 -0
  3. omega/artifacts.py +151 -0
  4. omega/checkpoint.py +246 -0
  5. omega/compact.py +106 -0
  6. omega/config.py +285 -0
  7. omega/eval/__init__.py +3 -0
  8. omega/eval/cli.py +127 -0
  9. omega/eval/examples/plan-version-flag.yaml +11 -0
  10. omega/eval/examples/relative-age-negative-delta.yaml +14 -0
  11. omega/eval/examples/version-flag.yaml +10 -0
  12. omega/eval/manifest.py +129 -0
  13. omega/eval/prices.py +29 -0
  14. omega/eval/report.py +135 -0
  15. omega/eval/runner.py +199 -0
  16. omega/eval/tasks.py +97 -0
  17. omega/events.py +145 -0
  18. omega/export.py +80 -0
  19. omega/gitlog.py +229 -0
  20. omega/hooks.py +63 -0
  21. omega/instructions.py +103 -0
  22. omega/integrations.py +284 -0
  23. omega/keys.py +173 -0
  24. omega/llm.py +442 -0
  25. omega/loop.py +510 -0
  26. omega/mcp.py +490 -0
  27. omega/memory/__init__.py +5 -0
  28. omega/memory/consolidate.py +103 -0
  29. omega/memory/curate.py +69 -0
  30. omega/memory/store.py +321 -0
  31. omega/memory/tools.py +175 -0
  32. omega/migrate.py +40 -0
  33. omega/onboarding.py +242 -0
  34. omega/permissions.py +137 -0
  35. omega/secrets.py +173 -0
  36. omega/server/__init__.py +7 -0
  37. omega/server/__main__.py +18 -0
  38. omega/server/app.py +71 -0
  39. omega/server/auth.py +73 -0
  40. omega/server/manager.py +287 -0
  41. omega/server/models.py +123 -0
  42. omega/server/tasks_api.py +311 -0
  43. omega/server/terminals.py +245 -0
  44. omega/server/worker.py +186 -0
  45. omega/session.py +209 -0
  46. omega/setup.html +281 -0
  47. omega/setup_server.py +452 -0
  48. omega/skills.py +158 -0
  49. omega/subagent.py +98 -0
  50. omega/tasks.py +195 -0
  51. omega/tools.py +590 -0
  52. omega/trace.py +156 -0
  53. omega/trajectory.py +146 -0
  54. omega/ui/__init__.py +0 -0
  55. omega/ui/composer.py +140 -0
  56. omega/ui/format.py +708 -0
  57. omega/ui/plain.py +141 -0
  58. omega/ui/tui/__init__.py +9 -0
  59. omega/ui/tui/app.py +958 -0
  60. omega/ui/tui/history.py +50 -0
  61. omega/ui/tui/modals.py +292 -0
  62. omega/ui/tui/onboarding.py +367 -0
  63. omega/ui/tui/prefs.py +25 -0
  64. omega/ui/tui/sidebar.py +510 -0
  65. omega/ui/tui/status.py +115 -0
  66. omega/ui/tui/theme.py +91 -0
  67. omega/ui/tui/transcript.py +783 -0
  68. omega/verify.py +133 -0
  69. omega_code-0.4.0.dist-info/METADATA +479 -0
  70. omega_code-0.4.0.dist-info/RECORD +73 -0
  71. omega_code-0.4.0.dist-info/WHEEL +4 -0
  72. omega_code-0.4.0.dist-info/entry_points.txt +2 -0
  73. omega_code-0.4.0.dist-info/licenses/LICENSE +21 -0
omega/onboarding.py ADDED
@@ -0,0 +1,242 @@
1
+ """First-run setup: get `~/.omega/config.json` into a working state without
2
+ the full browser flow (`omega setup`). `run()` picks the Textual wizard on a
3
+ real terminal and falls back to `run_plain()` (the original `input()` flow)
4
+ otherwise; both share the provider presets, catalog, and config-merge logic
5
+ in this module. No Textual imports here -- `omega/ui/tui/onboarding.py` is the
6
+ only place that knows about widgets."""
7
+ import asyncio
8
+ import getpass
9
+ import sys
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ import httpx
14
+
15
+ from . import config, loop
16
+ from .setup_server import PROBE_PROMPT as PROBE_PROMPT
17
+ from .setup_server import _save
18
+
19
+ Body = dict[str, Any]
20
+
21
+
22
+ @dataclass
23
+ class _Choice:
24
+ provider_key: str
25
+ provider: Body
26
+ catalog: dict[str, Body] # alias -> model entry, this provider only
27
+ default_alias: str
28
+ cheap_alias: str
29
+
30
+
31
+ _ANTHROPIC = _Choice(
32
+ provider_key="anthropic",
33
+ provider={"type": "anthropic", "apiKey": ""},
34
+ catalog={
35
+ "fable": {"model": "claude-fable-5-1", "provider": "anthropic", "context": 1048576, "effort": "xhigh"},
36
+ "opus": {"model": "claude-opus-5", "provider": "anthropic", "context": 1048576, "effort": "high"},
37
+ "sonnet": {"model": "claude-sonnet-5", "provider": "anthropic", "context": 1048576, "effort": "high"},
38
+ "haiku": {"model": "claude-haiku-4-5", "provider": "anthropic", "context": 200000},
39
+ },
40
+ default_alias="opus", cheap_alias="haiku",
41
+ )
42
+ _OPENROUTER = _Choice(
43
+ provider_key="openrouter",
44
+ provider={"type": "openai", "baseUrl": "https://openrouter.ai/api/v1", "apiKey": ""},
45
+ catalog={
46
+ "spark": {"model": "meta/muse-spark-1.3", "provider": "openrouter", "context": 1048576},
47
+ "kimi": {"model": "moonshotai/kimi-k3", "provider": "openrouter", "context": 1048576},
48
+ "glm": {"model": "z-ai/glm-5.3-flash", "provider": "openrouter", "context": 128000},
49
+ "sol": {"model": "openai/gpt-5.6-sol", "provider": "openrouter", "context": 1050000},
50
+ "terra": {"model": "openai/gpt-5.6-terra", "provider": "openrouter", "context": 1050000},
51
+ "luna": {"model": "openai/gpt-5.6-luna", "provider": "openrouter", "context": 1050000},
52
+ "codex": {"model": "openai/gpt-5.3-codex", "provider": "openrouter", "context": 400000},
53
+ "grok": {"model": "x-ai/grok-4.6", "provider": "openrouter", "context": 500000},
54
+ "grok-build": {"model": "x-ai/grok-build-0.1", "provider": "openrouter", "context": 256000},
55
+ },
56
+ default_alias="spark", cheap_alias="glm",
57
+ )
58
+ _OPENAI = _Choice(
59
+ provider_key="openai",
60
+ provider={"type": "openai", "baseUrl": "https://api.openai.com/v1", "apiKey": ""},
61
+ catalog={
62
+ "astra": {"model": "gpt-6-astra", "provider": "openai", "context": 1050000},
63
+ "sol": {"model": "gpt-5.6-sol", "provider": "openai", "context": 1050000},
64
+ "terra": {"model": "gpt-5.6-terra", "provider": "openai", "context": 1050000},
65
+ "luna": {"model": "gpt-5.6-luna", "provider": "openai", "context": 1050000},
66
+ "codex": {"model": "gpt-5.3-codex", "provider": "openai", "context": 400000},
67
+ },
68
+ default_alias="sol", cheap_alias="luna",
69
+ )
70
+
71
+ # USD per million tokens, (input, output) -- shown in the model picker.
72
+ PRICES: dict[str, tuple[float, float]] = {
73
+ "fable": (10, 50), "opus": (5, 25), "sonnet": (2, 10), "haiku": (1, 5),
74
+ "spark": (1.25, 4.25), "kimi": (3, 15), "glm": (0.6, 2.2),
75
+ "astra": (10, 50), "sol": (2, 10), "terra": (2, 12), "luna": (0.2, 1.2), "codex": (1.75, 14),
76
+ "grok": (2, 6), "grok-build": (1, 2),
77
+ }
78
+
79
+ PURPOSES: dict[str, str] = {
80
+ "fable": "hardest problems, deepest reasoning",
81
+ "opus": "best for coding (recommended)",
82
+ "sonnet": "fast and strong",
83
+ "haiku": "cheapest, quick tasks",
84
+ "spark": "Meta's frontier — strong at agentic coding, cheap",
85
+ "kimi": "strong open-weights, 1M context",
86
+ "glm": "cheap and fast",
87
+ "astra": "OpenAI's flagship GPT-6 — limited rollout, priciest",
88
+ "sol": "GPT-5.6 top tier — strong reasoning and coding",
89
+ "terra": "GPT-5.6 mid tier — balanced cost and capability",
90
+ "luna": "GPT-5.6 fast tier — high volume, cheap",
91
+ "codex": "OpenAI's agentic coding model",
92
+ "grok": "SpaceX's Grok 4.6 — frontier agentic coding, 500k context",
93
+ "grok-build": "Grok's fast coding model — cheap",
94
+ }
95
+
96
+ # provider_key -> (label, one-liner, env var checked for "key found in environment")
97
+ PROVIDER_INFO: list[tuple[str, str, str, str]] = [
98
+ ("anthropic", "Anthropic — Claude (recommended)",
99
+ "Native Anthropic API access -- fable, opus, sonnet, haiku.", "ANTHROPIC_API_KEY"),
100
+ ("openrouter", "OpenRouter — Claude, GPT, Grok, Kimi, GLM, Gemini and 200+ more",
101
+ "One key, hundreds of models, pay-as-you-go.", "OPENROUTER_API_KEY"),
102
+ ("openai", "OpenAI — GPT-6 Astra, GPT-5.6, Codex",
103
+ "Native OpenAI API access -- the only route to GPT-6 Astra for now.", "OPENAI_API_KEY"),
104
+ ("other", "Other OpenAI-compatible — any /chat/completions endpoint",
105
+ "Bring your own base URL: local models, other clouds, self-hosted.", ""),
106
+ ]
107
+
108
+
109
+ def env_var_for(provider_key: str) -> str:
110
+ for key, _label, _desc, env in PROVIDER_INFO:
111
+ if key == provider_key or (key == "other" and provider_key == "custom"):
112
+ return env
113
+ return ""
114
+
115
+
116
+ def choice_for(provider_key: str, base_url: str = "") -> _Choice:
117
+ if provider_key == "anthropic":
118
+ return _ANTHROPIC
119
+ if provider_key == "openrouter":
120
+ return _OPENROUTER
121
+ if provider_key == "openai":
122
+ return _OPENAI
123
+ return _Choice(provider_key="custom", provider={"type": "openai", "baseUrl": base_url, "apiKey": ""},
124
+ catalog={}, default_alias="", cheap_alias="")
125
+
126
+
127
+ def build_config(choice: _Choice, main_alias: str, cheap_alias: str, models: dict[str, Body]) -> Body:
128
+ """Merge this choice into whatever config already exists on disk --
129
+ onboarding's own six roles win, everything else in the file is untouched."""
130
+ raw = config._json_or_default()
131
+ raw.setdefault("providers", {})[choice.provider_key] = choice.provider
132
+ raw.setdefault("models", {}).update(models)
133
+ roles = raw.setdefault("roles", {})
134
+ roles["main"] = {"alias": main_alias}
135
+ roles["plan"] = {"alias": main_alias}
136
+ roles["subagent_mid"] = {"alias": main_alias}
137
+ roles["subagent_fast"] = {"alias": cheap_alias}
138
+ roles["compact"] = {"alias": cheap_alias}
139
+ roles["memory"] = {"alias": cheap_alias}
140
+ return raw
141
+
142
+
143
+ def save_config(raw: Body) -> None:
144
+ _save(raw)
145
+
146
+
147
+ async def validate_key(choice: _Choice, key: str) -> tuple[bool, str]:
148
+ """Probe that `key` actually works for `choice`'s provider. Returns
149
+ (ok, message) -- message is "connected" on success or a short error."""
150
+ if choice.provider.get("type") == "anthropic":
151
+ from anthropic import AsyncAnthropic
152
+ try:
153
+ client = AsyncAnthropic(api_key=key)
154
+ await client.models.list(limit=1)
155
+ return True, "connected"
156
+ except Exception as e:
157
+ return False, f"{type(e).__name__}: {e}"[:120]
158
+
159
+ base = (choice.provider.get("baseUrl") or "").rstrip("/")
160
+ try:
161
+ async with httpx.AsyncClient(timeout=15) as http_client:
162
+ r = await http_client.get(f"{base}/models", headers={"Authorization": f"Bearer {key}"})
163
+ if r.status_code != 200:
164
+ return False, f"HTTP {r.status_code}: {r.text[:100]}"
165
+ return True, "connected"
166
+ except Exception as e:
167
+ return False, f"{type(e).__name__}: {e}"[:120]
168
+
169
+
170
+ async def run() -> bool:
171
+ """Entry point for both `omega onboard` and the first-run gate: the
172
+ Textual wizard on a real terminal, the original `input()` flow otherwise
173
+ (piped/scripted invocations, or a dumb terminal)."""
174
+ if sys.stdin.isatty() and sys.stdout.isatty():
175
+ from .ui.tui.onboarding import OnboardingApp
176
+ return bool(await OnboardingApp().run_async())
177
+ await run_plain()
178
+ return config.CONFIG_PATH.exists()
179
+
180
+
181
+ async def _ask(prompt: str) -> str:
182
+ return (await asyncio.to_thread(input, prompt)).strip()
183
+
184
+
185
+ async def _ask_choice(prompt: str, options: list[str], default: int = 1) -> int:
186
+ print(prompt)
187
+ for i, opt in enumerate(options, 1):
188
+ print(f" {i}. {opt}")
189
+ raw = await _ask(f"choice [{default}]: ")
190
+ if not raw:
191
+ return default
192
+ if raw.isdigit() and 1 <= int(raw) <= len(options):
193
+ return int(raw)
194
+ return default
195
+
196
+
197
+ async def run_plain() -> None:
198
+ print("omega needs a model to drive it -- let's set one up (`omega setup` opens the full browser flow).")
199
+ pick = await _ask_choice("Pick a provider:",
200
+ ["Anthropic (native)", "OpenRouter", "OpenAI (native)", "Other OpenAI-compatible"])
201
+
202
+ if pick == 1:
203
+ choice = _ANTHROPIC
204
+ elif pick == 2:
205
+ choice = _OPENROUTER
206
+ elif pick == 3:
207
+ choice = _OPENAI
208
+ else:
209
+ base_url = await _ask("Base URL (e.g. https://api.example.com/v1): ")
210
+ choice = choice_for("other", base_url)
211
+
212
+ key = await asyncio.to_thread(getpass.getpass, "API key (hidden): ")
213
+ choice.provider["apiKey"] = key
214
+
215
+ if choice.catalog:
216
+ aliases = sorted(choice.catalog)
217
+ default_idx = aliases.index(choice.default_alias) + 1
218
+ labels = [f"{a} {choice.catalog[a]['model']}" for a in aliases]
219
+ picked = await _ask_choice("Default model for `main`:", labels, default=default_idx)
220
+ main_alias = aliases[picked - 1]
221
+ cheap_alias = choice.cheap_alias
222
+ models = dict(choice.catalog)
223
+ else:
224
+ model_id = await _ask("Model id served by this endpoint: ")
225
+ main_alias = cheap_alias = "main-model"
226
+ models = {main_alias: {"model": model_id, "provider": choice.provider_key, "context": 128000}}
227
+
228
+ save_config(build_config(choice, main_alias, cheap_alias, models))
229
+ print(f"wrote {config.CONFIG_PATH}")
230
+
231
+ print("\nrunning a test turn...")
232
+ try:
233
+ cfg = config.load()
234
+ text = await loop.run_agent(cfg, "main", loop.BUILD_SYSTEM,
235
+ [{"role": "user", "content": PROBE_PROMPT}])
236
+ print(text)
237
+ except Exception as e:
238
+ print(f"test turn failed ({type(e).__name__}: {e}) -- config is saved; check the key and retry.")
239
+
240
+ print("\nomega interactive TUI\n"
241
+ "omega \"do the thing\" one-shot\n"
242
+ "omega setup full browser setup, more providers/roles/MCP")
omega/permissions.py ADDED
@@ -0,0 +1,137 @@
1
+ import json
2
+ import os
3
+ import re
4
+ import shlex
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ STORE = Path.home() / ".omega" / "permissions.json"
9
+
10
+ ALLOW, ASK, DENY = "allow", "ask", "deny"
11
+
12
+ # Never askable: no confirmation prompt makes these safe, and a model that has
13
+ # ingested untrusted content is exactly what would propose them. This floor
14
+ # stays in place even with everything below relaxed to ALLOW -- it guards
15
+ # against catastrophic, irreversible harm (credential theft, wiping the
16
+ # filesystem, running as root), not routine workflow friction, so "drop all
17
+ # permissions" doesn't extend to it without a separate, explicit ask.
18
+ # Both `.omega/config.json` and the pre-rename `.rig/config.json` stay
19
+ # protected -- the old file may still exist (with a key in it) after migration.
20
+ FORBIDDEN_PATHS = ("/.ssh", "/.aws", "/.gnupg", "/.omega/config.json", "/.rig/config.json",
21
+ "/.claude.json", "/.claude/", "/.mcp-auth", "/.netrc",
22
+ "/.config/gh", "/.kube")
23
+ FORBIDDEN_PATTERNS = [
24
+ (re.compile(r"\b(curl|wget)\b[^|;&]*\|\s*(sudo\s+)?(ba)?sh"), "pipes a download into a shell"),
25
+ (re.compile(r"\brm\s+(-[a-zA-Z]*\s+)*-?[a-zA-Z]*[rf][a-zA-Z]*\s+(/|~|\$HOME)\s*$"), "recursive delete of / or ~"),
26
+ (re.compile(r"\bsudo\b"), "runs as root"),
27
+ (re.compile(r"\bgit\s+push\b.*--force(?!-with-lease)"), "force-push"),
28
+ (re.compile(r":\(\)\s*\{.*\};\s*:"), "fork bomb"),
29
+ (re.compile(r"\b(shutdown|reboot|mkfs|diskutil\s+erase)\b"), "destructive system command"),
30
+ ]
31
+
32
+
33
+ def _load() -> dict[str, list[str]]:
34
+ if STORE.exists():
35
+ try:
36
+ return dict(json.loads(STORE.read_text()))
37
+ except json.JSONDecodeError:
38
+ pass
39
+ return {"allow": [], "deny": []}
40
+
41
+
42
+ def remember(rule: str, verdict: str) -> None:
43
+ data = _load()
44
+ key = "allow" if verdict == ALLOW else "deny"
45
+ if rule not in data[key]:
46
+ data[key].append(rule)
47
+ STORE.parent.mkdir(parents=True, exist_ok=True)
48
+ STORE.write_text(json.dumps(data, indent=1))
49
+ STORE.chmod(0o600)
50
+
51
+
52
+ def rule_for(name: str, args: dict[str, Any]) -> str:
53
+ if name == "bash":
54
+ return f"bash:{_first_token(args.get('command', ''))}"
55
+ if name == "call_tool":
56
+ tool = str(args.get("name", ""))
57
+ # One "always" per connected server, not per tool: the user authorised
58
+ # the integration, and a server with 40 tools would otherwise prompt
59
+ # 40 times.
60
+ m = re.match(r"(mcp__[^_]+(?:_[^_]+)*?)__", tool)
61
+ return f"call_tool:{m.group(1)}__*" if m else f"call_tool:{tool}"
62
+ return name
63
+
64
+
65
+ def _first_token(command: str) -> str:
66
+ try:
67
+ parts = shlex.split(command)
68
+ except ValueError:
69
+ return command.strip().split(" ")[0] if command else ""
70
+ if not parts:
71
+ return ""
72
+ # `cd <dir> && real-command …`: the rule should name the real command,
73
+ # otherwise every command run from a worktree is classified as "cd".
74
+ if parts[0] == "cd" and "&&" in parts:
75
+ parts = parts[parts.index("&&") + 1:] or parts
76
+ if parts[0] == "git" and len(parts) > 1:
77
+ return f"git {parts[1]}"
78
+ return parts[0]
79
+
80
+
81
+ def _touches_forbidden(text: str) -> str | None:
82
+ expanded = os.path.expanduser(text)
83
+ home = str(Path.home())
84
+ for frag in FORBIDDEN_PATHS:
85
+ if frag in expanded or (home + frag) in expanded:
86
+ return f"touches protected path {frag}"
87
+ return None
88
+
89
+
90
+ def decide(name: str, args: dict[str, Any], cwd: str | None = None,
91
+ tainted: bool = False) -> tuple[str, str]:
92
+ """Returns (verdict, reason). Pure -- no I/O beyond the rules file.
93
+
94
+ Everything the model can do to the local filesystem/shell -- write, edit,
95
+ bash, the git subcommand it runs, whether a path is inside `cwd` -- is
96
+ ALLOW by default now; only the FORBIDDEN_PATHS/FORBIDDEN_PATTERNS floor
97
+ above and prompt-injection taint still gate. `call_tool` (any external
98
+ MCP server, database writes included) is deliberately left unclassified
99
+ below so it still falls through to ASK -- that's the one category this
100
+ relaxation does not cover."""
101
+ stored = _load()
102
+ rule = rule_for(name, args)
103
+
104
+ if name in ("write", "edit", "bash", "call_tool", "remember", "supersede", "link"):
105
+ blob = " ".join(str(v) for v in args.values())
106
+ why = _touches_forbidden(blob)
107
+ if why:
108
+ return DENY, why
109
+
110
+ if name == "bash":
111
+ command = args.get("command", "")
112
+ for pattern, why in FORBIDDEN_PATTERNS:
113
+ if pattern.search(command):
114
+ return DENY, why
115
+
116
+ if rule in stored["deny"]:
117
+ return DENY, "denied by a saved rule"
118
+ if rule in stored["allow"]:
119
+ return ALLOW, "allowed by a saved rule"
120
+
121
+ if name in ("read", "grep", "glob", "recall", "find_tools", "subagent",
122
+ "fetch_result", "list_artifacts", "ask_user", "skill", "bash_status"):
123
+ return ALLOW, "read-only"
124
+
125
+ if name in ("save_artifact", "update_artifact", "remember", "supersede", "link"):
126
+ return ALLOW, "writes to omega's own store (~/.omega or .omega/), not project files"
127
+
128
+ if name in ("write", "edit"):
129
+ return ALLOW, "writes always allowed (permissions relaxed to the filesystem/shell)"
130
+
131
+ if name == "bash":
132
+ # The user chose zero prompts over the prompt-injection guard: the
133
+ # FORBIDDEN floor above is the only thing between untrusted content
134
+ # and the shell.
135
+ return ALLOW, "bash always allowed (permissions relaxed to the filesystem/shell)"
136
+
137
+ return ASK, "not classified"
omega/secrets.py ADDED
@@ -0,0 +1,173 @@
1
+ """Where provider API keys actually live.
2
+
3
+ A key written into `config.json` is a key that leaks: into a screenshot, into
4
+ `cat` output pasted in a bug report, into every backup that copies the file,
5
+ into the `.bak` written beside it. The file being mode 0600 stops another
6
+ *user* reading it and nothing else -- most of the ways a key escapes a laptop
7
+ run as you.
8
+
9
+ So the config holds a *reference* to a secret rather than the secret. Three
10
+ kinds, in the order `config.Provider` tries them:
11
+
12
+ apiKeyEnv "OPENROUTER_API_KEY" -- an environment variable
13
+ apiKeyCmd "op read op://dev/or/key" -- anything that prints it to stdout
14
+ (keychain) looked up automatically -- the OS store, service "omega"
15
+
16
+ The keychain is the default target rather than an encrypted file of our own
17
+ making. It is already encrypted at rest, already unlocked by your login, and
18
+ already access-controlled per application; a passphrase-encrypted file would
19
+ either prompt on every single invocation or cache its derived key on disk,
20
+ which just moves the plaintext somewhere less examined. `apiKeyCmd` is the
21
+ escape hatch for anyone who keeps secrets in 1Password, pass, or a cloud
22
+ secret manager and wants omega to never store one at all.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import re
28
+ import shutil
29
+ import subprocess
30
+ import sys
31
+
32
+ SERVICE = "omega"
33
+
34
+ # Resolving a secret costs a keychain round-trip or a whole subprocess, and
35
+ # `Provider.api_key` is read on every request. Cached for the life of the
36
+ # process; a rotated key takes effect on the next run, same as a config edit.
37
+ _cache: dict[str, str] = {}
38
+
39
+
40
+ def clear_cache() -> None:
41
+ _cache.clear()
42
+
43
+
44
+ # ---- the OS keychain --------------------------------------------------------
45
+
46
+ def keychain_available() -> bool:
47
+ """macOS only for now. Linux's Secret Service and Windows' Credential
48
+ Locker have no equivalent single binary to shell out to, so those
49
+ platforms get `apiKeyEnv`/`apiKeyCmd`, which reach every secret manager
50
+ worth using anyway."""
51
+ return sys.platform == "darwin" and shutil.which("security") is not None
52
+
53
+
54
+ def keychain_get(account: str) -> str | None:
55
+ if not keychain_available():
56
+ return None
57
+ key = f"keychain:{account}"
58
+ if key in _cache:
59
+ return _cache[key]
60
+ proc = subprocess.run(
61
+ ["security", "find-generic-password", "-s", SERVICE, "-a", account, "-w"],
62
+ capture_output=True, text=True)
63
+ if proc.returncode != 0:
64
+ return None
65
+ secret = proc.stdout.strip()
66
+ if secret:
67
+ _cache[key] = secret
68
+ return secret or None
69
+
70
+
71
+ def keychain_set(account: str, secret: str) -> None:
72
+ """Store (or replace) a key.
73
+
74
+ The secret goes through `security`'s argv, which is visible to `ps` for
75
+ the instant the process lives -- the CLI's own `-w` prompt cannot be
76
+ piped (it demands an interactive retype), and the alternative is a ctypes
77
+ binding to the Security framework whose memory handling is easy to get
78
+ subtly wrong. This runs at most once per key, on `omega keys set`, versus
79
+ reads that happen constantly and expose nothing. Anyone who considers
80
+ even that moment too much should use `apiKeyCmd` and keep the secret in a
81
+ manager omega never writes to."""
82
+ if not keychain_available():
83
+ raise RuntimeError("no OS keychain available on this platform")
84
+ proc = subprocess.run(
85
+ ["security", "add-generic-password", "-U", "-s", SERVICE, "-a", account,
86
+ "-w", secret, "-D", "omega provider key",
87
+ "-j", f"API key for omega provider {account!r}"],
88
+ capture_output=True, text=True)
89
+ if proc.returncode != 0:
90
+ raise RuntimeError(proc.stderr.strip() or "keychain write failed")
91
+ _cache.pop(f"keychain:{account}", None)
92
+
93
+
94
+ def keychain_delete(account: str) -> bool:
95
+ if not keychain_available():
96
+ return False
97
+ proc = subprocess.run(
98
+ ["security", "delete-generic-password", "-s", SERVICE, "-a", account],
99
+ capture_output=True, text=True)
100
+ _cache.pop(f"keychain:{account}", None)
101
+ return proc.returncode == 0
102
+
103
+
104
+ def keychain_accounts(names: list[str]) -> list[str]:
105
+ """Which of `names` the keychain holds. Probed one at a time rather than
106
+ by dumping the service: `security dump-keychain` prompts for permission
107
+ per item and is the kind of thing that trains people to click Allow."""
108
+ return [n for n in names if keychain_get(n) is not None]
109
+
110
+
111
+ # ---- an external command ----------------------------------------------------
112
+
113
+ def from_command(cmd: str, *, timeout: int = 20) -> str:
114
+ """Run `cmd` and take its stdout as the secret.
115
+
116
+ Deliberately a shell string, not an argv list: the point is to paste in
117
+ whatever incantation a secret manager already documents (`op read ...`,
118
+ `pass show ...`, `gcloud secrets versions access ...`) without translating
119
+ it. It only ever runs commands the config's owner wrote there themselves.
120
+ """
121
+ key = f"cmd:{cmd}"
122
+ if key in _cache:
123
+ return _cache[key]
124
+ try:
125
+ proc = subprocess.run(cmd, shell=True, capture_output=True, text=True,
126
+ timeout=timeout)
127
+ except subprocess.TimeoutExpired:
128
+ raise RuntimeError(f"apiKeyCmd timed out after {timeout}s: {cmd}") from None
129
+ if proc.returncode != 0:
130
+ detail = proc.stderr.strip().splitlines()
131
+ raise RuntimeError(f"apiKeyCmd failed (exit {proc.returncode}): "
132
+ f"{detail[0] if detail else cmd}")
133
+ secret = proc.stdout.strip()
134
+ if not secret:
135
+ raise RuntimeError(f"apiKeyCmd printed nothing: {cmd}")
136
+ _cache[key] = secret
137
+ return secret
138
+
139
+
140
+ # ---- redaction --------------------------------------------------------------
141
+
142
+ # Provider key shapes, longest-prefix first so `sk-ant-` is not eaten by the
143
+ # shorter `sk-` alternative before it can match.
144
+ _KEY_RE = re.compile(r"\b(sk-ant-[A-Za-z0-9_\-]{8,}|sk-or-[A-Za-z0-9_\-]{8,}"
145
+ r"|sk-proj-[A-Za-z0-9_\-]{8,}|sk-[A-Za-z0-9_\-]{16,}"
146
+ r"|ghp_[A-Za-z0-9]{16,}|xox[baprs]-[A-Za-z0-9\-]{10,})")
147
+
148
+
149
+ def redact(text: str) -> str:
150
+ """Mask anything key-shaped, keeping enough of the head to tell two keys
151
+ apart. For error messages and any diagnostic a user might paste back."""
152
+ def mask(m: re.Match[str]) -> str:
153
+ raw = m.group(0)
154
+ return f"{raw[:11]}…{raw[-4:]} [redacted]" if len(raw) > 20 else "[redacted]"
155
+ return _KEY_RE.sub(mask, text)
156
+
157
+
158
+ def looks_like_key(value: str) -> bool:
159
+ return bool(_KEY_RE.fullmatch(value.strip()))
160
+
161
+
162
+ def describe_source(env: str, cmd: str, literal: str, account: str) -> str:
163
+ """Where a provider's key is coming from, for `omega keys` and `doctor`.
164
+ Never the value."""
165
+ if literal:
166
+ return "config.json (plaintext)"
167
+ if cmd:
168
+ return f"command: {cmd}"
169
+ if env and os.environ.get(env):
170
+ return f"env: {env}"
171
+ if keychain_get(account) is not None:
172
+ return "keychain"
173
+ return "missing"
@@ -0,0 +1,7 @@
1
+ """D1 daemon: a FastAPI + uvicorn server exposing omega's agent loop over
2
+ REST/WebSocket for the Omega.app SwiftUI client -- see Phase 9 of the plan.
3
+ `omega serve` (wired up separately in omega/__main__.py) and
4
+ `python -m omega.server` both call `main()`."""
5
+ from .app import create_app, main
6
+
7
+ __all__ = ["create_app", "main"]
@@ -0,0 +1,18 @@
1
+ """`python -m omega.server --port 7777` -- a manual entrypoint for the D1
2
+ daemon, ahead of the `omega serve` subcommand (wired up separately)."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from .app import main
8
+
9
+
10
+ def _cli() -> None:
11
+ parser = argparse.ArgumentParser(prog="python -m omega.server")
12
+ parser.add_argument("--port", type=int, default=7777)
13
+ args = parser.parse_args()
14
+ main(port=args.port)
15
+
16
+
17
+ if __name__ == "__main__":
18
+ _cli()
omega/server/app.py ADDED
@@ -0,0 +1,71 @@
1
+ """FastAPI app factory and process entrypoint for the D1 daemon: binds
2
+ 127.0.0.1 only, no CORS, bearer-token auth (see auth.py) -- `omega serve`
3
+ (wired up separately in omega/__main__.py) and `python -m omega.server` both
4
+ end up calling `main()`."""
5
+ from __future__ import annotations
6
+
7
+ import contextlib
8
+ import importlib.metadata
9
+ import logging
10
+ import os
11
+ from collections.abc import AsyncIterator
12
+
13
+ from fastapi import FastAPI
14
+
15
+ from . import auth, tasks_api, terminals
16
+ from .manager import TaskManager, WorkerArgv, default_worker_argv
17
+
18
+ LOG_PATH = auth.SERVE_PATH.parent / "serve.log"
19
+
20
+
21
+ def _version() -> str:
22
+ try:
23
+ return importlib.metadata.version("omega-code")
24
+ except importlib.metadata.PackageNotFoundError:
25
+ return "0.0.0"
26
+
27
+
28
+ def create_app(port: int, worker_argv: WorkerArgv | None = None) -> FastAPI:
29
+ """`worker_argv` overrides how a task's child worker process is launched
30
+ -- the real daemon never passes it (defaults to spawning
31
+ `python -m omega.server.worker`); tests point it at a scripted stub."""
32
+ token = auth.generate_token()
33
+
34
+ @contextlib.asynccontextmanager
35
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
36
+ auth.write_serve_info(auth.ServeInfo(port=port, token=token, pid=os.getpid()))
37
+ try:
38
+ yield
39
+ finally:
40
+ await app.state.tasks_manager.shutdown_all()
41
+ app.state.terminals.kill_all()
42
+ auth.remove_serve_info()
43
+
44
+ app = FastAPI(title="omega D1 daemon", lifespan=lifespan)
45
+ app.state.token = token
46
+ app.state.version = _version()
47
+ app.state.tasks_manager = TaskManager(worker_argv or default_worker_argv)
48
+ app.state.terminals = terminals.TerminalManager()
49
+ app.include_router(tasks_api.router)
50
+ app.include_router(terminals.router)
51
+ return app
52
+
53
+
54
+ def _configure_logging() -> None:
55
+ LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
56
+ handler = logging.FileHandler(LOG_PATH)
57
+ handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
58
+ # "uvicorn.error"/"uvicorn.access" propagate up to "uvicorn" by default --
59
+ # attaching the handler to all three would log every line twice.
60
+ for name in ("uvicorn", "omega.server"):
61
+ logger = logging.getLogger(name)
62
+ logger.addHandler(handler)
63
+ logger.setLevel(logging.INFO)
64
+
65
+
66
+ def main(port: int = 7777) -> None:
67
+ import uvicorn
68
+
69
+ _configure_logging()
70
+ app = create_app(port)
71
+ uvicorn.run(app, host="127.0.0.1", port=port, log_config=None)