Skip to content

Commit 27a8d02

Browse files
noah-databricksIsaac
andcommitted
feat(appkit): offer Codex in the omnigent plugin, sandboxed with bubblewrap (2/4)
Codex has its own shell, so it runs through a bubblewrap wrapper: own PID namespace, the plugin's state directory masked, only the user's home and work directory bound in, and /proc of the app's processes hidden. The wrapper is named codex and put first on PATH, because Omnigent resolves harness CLIs by name. Without bubblewrap, Codex is not offered. Codex only trusts public TLS roots, so the model gateway also listens on plain http for it. Co-authored-by: Isaac <no-reply@databricks.com> Signed-off-by: Noah Solomon <noah@databricks.com>
1 parent 0bc2968 commit 27a8d02

14 files changed

Lines changed: 335 additions & 66 deletions

File tree

‎docs/docs/plugins/omnigent.md‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ This plugin is currently **beta**. APIs may change between minor releases. Impor
66
:::
77
<!-- AUTO-GENERATED: stability-banner-end -->
88

9-
The `omnigent` plugin embeds [Omnigent](https://github.com/omnigent-ai/omnigent), the open-source agent meta-harness, in a Databricks AppKit app. Your users get Claude Agent SDK, Pi and OpenAI Agents SDK sessions inside the app, with model calls going through Unity AI Gateway as the app's service principal.
9+
The `omnigent` plugin embeds [Omnigent](https://github.com/omnigent-ai/omnigent), the open-source agent meta-harness, in a Databricks AppKit app. Your users get Claude Agent SDK, Codex, Pi and OpenAI Agents SDK sessions inside the app, with model calls going through Unity AI Gateway as the app's service principal.
1010

1111
Omnigent owns the agent loop: sessions, history, approvals. The plugin runs it safely inside a Databricks App. Use the [`agents`](./agents.md) plugin when you want AppKit to own the loop against one serving endpoint. Use `omnigent` when you want the harnesses themselves.
1212

@@ -27,21 +27,21 @@ await createApp({
2727
The app also needs:
2828

2929
- **Python.** Add a `pyproject.toml` with `requires-python = ">=3.12,<3.13"` and `omnigent[databricks]`, plus a `uv.lock`. Databricks Apps then builds a Python 3.12 venv next to `npm install`. A plain `requirements.txt` gets Python 3.11, which Omnigent does not support. The lockfile must reference public PyPI.
30-
- **Harness CLIs.** Add them to your `package.json`, so you pin their versions: `@anthropic-ai/claude-code` and `@earendil-works/pi-coding-agent`. The plugin only offers harnesses whose CLI is installed. The Claude Agent SDK's bundled CLI is too old for current Claude models, so add `@anthropic-ai/claude-code` even though the SDK ships one.
30+
- **Harness CLIs.** Add them to your `package.json`, so you pin their versions: `@anthropic-ai/claude-code`, `@openai/codex` and `@earendil-works/pi-coding-agent`. The plugin only offers harnesses whose CLI is installed. The Claude Agent SDK's bundled CLI is too old for current Claude models, so add `@anthropic-ai/claude-code` even though the SDK ships one.
3131
- **Start with `node`.** In `app.yaml`, use `command: ["node", "server.js"]`, not `npm run start`. npm does not pass SIGTERM on, so the plugin never gets to stop its child processes. As a backstop, each child also exits when Node does, so a redeploy still succeeds.
3232

3333
## Configuration
3434

3535
| Option | Default | |
3636
|---|---|---|
3737
| `instructions` | a generic assistant prompt | System instructions for every session |
38-
| `harnesses` | `claude-sdk`, `pi`, `openai-agents` | Harnesses users may pick |
38+
| `harnesses` | `claude-sdk`, `codex`, `pi`, `openai-agents` | Harnesses users may pick |
3939
| `defaultHarness`, `defaultModel` | first available | |
4040
| `runtime.python` | `./.venv/bin/python` | |
4141
| `runtime.home` | `$TMPDIR/appkit-omnigent` | The plugin's state directory |
4242
| `runtime.hostIdleMs` | 30 min | Stop a user's Omnigent host after this long idle |
4343

44-
Each harness is offered the Unity AI Gateway `system.ai` models whose API it speaks: Claude Agent SDK the Anthropic Messages API, Pi the Codex Responses API, OpenAI Agents SDK the OpenAI Responses API.
44+
Each harness is offered the Unity AI Gateway `system.ai` models whose API it speaks: Claude Agent SDK the Anthropic Messages API, Codex and Pi the Codex Responses API, OpenAI Agents SDK the OpenAI Responses API.
4545

4646
## Routes
4747

@@ -55,15 +55,22 @@ All routes are under `/api/omnigent` and scoped to the signed-in user.
5555
| `POST /sessions/:id/messages` | `{ "text": "..." }` |
5656
| `GET /sessions/:id/stream` | Server-sent events (Omnigent's session stream, which replays from `Last-Event-ID`) |
5757
| `POST /sessions/:id/interrupt` | Stop the current turn |
58-
| `GET /status` | Runtime health: hosts, gateway counters |
58+
| `GET /status` | Runtime health: sandbox, hosts, gateway counters |
5959

6060
## How it works
6161

6262
- **One Omnigent server, one host per user.** The server listens on loopback in Omnigent's header-auth mode, under a random header name only the plugin knows. Each active user gets their own host process, registered as that user; Omnigent only runs a session on its owner's host. An idle host stops after `runtime.hostIdleMs`.
63-
- **No credential in any child process.** The harnesses' Databricks profile points at a model gateway inside the plugin, on loopback, with a placeholder token. The gateway forwards only model-serving paths to the workspace and adds the service principal's token. Every other path returns 403.
63+
- **No credential in any child process.** The harnesses' Databricks profile points at a model gateway inside the plugin, on loopback, with a placeholder token. The gateway forwards only model-serving paths to the workspace and adds the service principal's token. Every other path returns 403. Codex only trusts public TLS roots, so it reaches the gateway over plain http on loopback; the others use https with a certificate the plugin generates.
64+
- **Codex runs sandboxed.** Codex has its own shell. Its binary runs through a bubblewrap wrapper that gives it:
65+
- its own PID namespace
66+
- the plugin's state directory masked
67+
- only the user's home and work directory bound in
68+
- the `/proc` entries of the app's own processes hidden
69+
70+
Without bubblewrap on `PATH` (or bundled with Codex), Codex is not offered.
6471

6572
## Limits
6673

6774
- **Sessions live in the container.** Omnigent keeps them in SQLite under the plugin's state directory, so they survive a process restart but not a redeploy.
68-
- **Only gateway harnesses.** Harnesses that sign in with their vendor (Cursor, Copilot, Gemini and others) bypass Unity AI Gateway, so they are not offered. Native terminal harnesses (`claude-native`, `pi-native`) are not offered yet: they need tmux and a terminal client.
75+
- **Only gateway harnesses.** Harnesses that sign in with their vendor (Cursor, Copilot, Gemini and others) bypass Unity AI Gateway, so they are not offered. Native terminal harnesses (`claude-native`, `codex-native`, `pi-native`) are not offered yet: they need tmux and a terminal client.
6976
- **Shared gateway limits.** All users' model traffic uses the service principal's gateway limits.

‎packages/appkit/src/plugins/omnigent/harnesses.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import type { OmnigentHarness } from "./types";
22

33
interface HarnessInfo {
44
label: string;
5+
/** Runs its own shell (so it needs the sandbox). */
6+
shell: boolean;
57
/**
68
* Unity AI Gateway API types it calls (a UC model service's
79
* `supported_api_types`); a model must support one of them.
@@ -20,18 +22,27 @@ interface HarnessInfo {
2022
const GATEWAY_HARNESSES: Readonly<Record<string, HarnessInfo>> = {
2123
"claude-sdk": {
2224
label: "Claude Agent SDK",
25+
shell: false,
2326
apis: ["anthropic/v1/messages"],
2427
preferred: ["system.ai.claude-sonnet-5", "system.ai.claude-opus-5-5"],
2528
},
29+
codex: {
30+
label: "Codex",
31+
shell: true,
32+
apis: ["codex/v1/responses"],
33+
preferred: ["system.ai.gpt-5-5"],
34+
},
2635
pi: {
2736
label: "Pi",
37+
shell: false,
2838
// Pi reaches Databricks models through the Codex responses API; on the
2939
// chat-completions path Gemini fails with an empty 400 (tested).
3040
apis: ["codex/v1/responses"],
3141
preferred: ["system.ai.gpt-5-5"],
3242
},
3343
"openai-agents": {
3444
label: "OpenAI Agents SDK",
45+
shell: false,
3546
apis: ["openai/v1/responses"],
3647
preferred: ["system.ai.gpt-5-5"],
3748
},
@@ -53,11 +64,13 @@ export interface HarnessOffer extends OmnigentHarness {
5364

5465
/**
5566
* What a user can pick: the allowed gateway harnesses their host reports as
56-
* usable, each with the models whose API it speaks.
67+
* usable, minus shell harnesses when they cannot be sandboxed, each with the
68+
* models whose API it speaks.
5769
*/
5870
export function offerHarnesses(opts: {
5971
allowed: string[];
6072
configured: Record<string, boolean | string>;
73+
shellAllowed: boolean;
6174
models: GatewayModel[];
6275
}): HarnessOffer[] {
6376
const out: HarnessOffer[] = [];
@@ -68,12 +81,14 @@ export function offerHarnesses(opts: {
6881
// "needs-auth" is advisory for gateway harnesses: their auth comes from the profile.
6982
if (ready === undefined || ready === false || ready === "binary-missing")
7083
continue;
84+
if (info.shell && !opts.shellAllowed) continue;
7185
const models = opts.models
7286
.filter((m) => m.apiTypes.some((t) => info.apis.includes(t)))
7387
.map((m) => m.name);
7488
out.push({
7589
id,
7690
label: info.label,
91+
shell: info.shell,
7792
ready,
7893
models,
7994
defaultModel: info.preferred.find((m) => models.includes(m)) ?? models[0],

‎packages/appkit/src/plugins/omnigent/manifest.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"$schema": "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json",
33
"name": "omnigent",
44
"displayName": "Omnigent Plugin",
5-
"description": "Embedded Omnigent agent runtime: Claude Agent SDK, Pi and OpenAI Agents SDK harnesses through Unity AI Gateway",
5+
"description": "Embedded Omnigent agent runtime: Claude Agent SDK, Codex, Pi and OpenAI Agents SDK harnesses through Unity AI Gateway",
66
"stability": "beta",
77
"resources": {
88
"required": [],
@@ -12,7 +12,7 @@
1212
"rules": {
1313
"must": [
1414
"Add pyproject.toml (python >=3.12,<3.13) with omnigent[databricks] and a uv.lock that uses public PyPI",
15-
"Add harness CLIs to package.json: @anthropic-ai/claude-code, @earendil-works/pi-coding-agent",
15+
"Add harness CLIs to package.json: @anthropic-ai/claude-code, @openai/codex, @earendil-works/pi-coding-agent",
1616
"Start the app with node, not npm run, so SIGTERM reaches the plugin and it can stop its child processes"
1717
]
1818
}

‎packages/appkit/src/plugins/omnigent/omnigent.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
type HarnessOffer,
1818
} from "./harnesses";
1919
import manifest from "./manifest.json";
20-
import { GATEWAY_PROFILE } from "./runtime/env";
20+
import { profileFor } from "./runtime/env";
2121
import { OmnigentRuntime } from "./runtime/runtime";
2222
import type { CreateSessionRequest, IOmnigentConfig } from "./types";
2323

@@ -37,7 +37,7 @@ export class OmnigentPlugin extends Plugin {
3737
static manifest = defineManifest<"omnigent">(manifest);
3838

3939
protected static description =
40-
"Embedded Omnigent: Claude Agent SDK, Pi and OpenAI Agents SDK, through Unity AI Gateway";
40+
"Embedded Omnigent: Claude Agent SDK, Codex, Pi and more, through Unity AI Gateway";
4141
declare protected config: IOmnigentConfig;
4242

4343
private runtime?: OmnigentRuntime;
@@ -163,6 +163,7 @@ export class OmnigentPlugin extends Plugin {
163163
return offerHarnesses({
164164
allowed: this.allowedHarnesses(),
165165
configured: mine?.configured_harnesses ?? {},
166+
shellAllowed: rt.shellHarnessesAllowed,
166167
models: await this.servedModels(),
167168
});
168169
}
@@ -184,7 +185,7 @@ export class OmnigentPlugin extends Plugin {
184185
description: "AppKit app agent",
185186
harness,
186187
model,
187-
profile: GATEWAY_PROFILE,
188+
profile: profileFor(harness),
188189
instructions: this.instructions(email),
189190
});
190191
}
@@ -521,7 +522,7 @@ export class OmnigentPlugin extends Plugin {
521522

522523
exports() {
523524
return {
524-
/** Runtime health: server, hosts, gateway counters. */
525+
/** Runtime health: server, sandbox, hosts, gateway counters. */
525526
status: () => this.rt().status(),
526527
};
527528
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
4+
interface CodexTools {
5+
/** The Codex binary from the app's `@openai/codex` package. */
6+
bin?: string;
7+
/** A usable `bwrap`, to sandbox it. */
8+
bwrap?: string;
9+
}
10+
11+
function readdir(dir: string): string[] {
12+
try {
13+
return fs.readdirSync(dir);
14+
} catch {
15+
return [];
16+
}
17+
}
18+
19+
function onPath(name: string): string | undefined {
20+
for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
21+
const p = path.join(dir, name);
22+
try {
23+
fs.accessSync(p, fs.constants.X_OK);
24+
if (dir) return p;
25+
} catch {}
26+
}
27+
return undefined;
28+
}
29+
30+
/** Finds the Codex binary (to wrap it) and bubblewrap (PATH, else the one Codex's package ships). */
31+
export function findCodex(appDir: string): CodexTools {
32+
const nm = path.join(appDir, "node_modules", "@openai");
33+
let bin: string | undefined;
34+
// vendor/<triple>/bin/codex (0.15x+) or vendor/<triple>/codex/codex (older).
35+
for (const pkg of readdir(nm).filter((d) => d.startsWith("codex-"))) {
36+
for (const triple of readdir(path.join(nm, pkg, "vendor"))) {
37+
const base = path.join(nm, pkg, "vendor", triple);
38+
bin =
39+
[
40+
path.join(base, "bin", "codex"),
41+
path.join(base, "codex", "codex"),
42+
].find((p) => fs.existsSync(p)) ?? bin;
43+
}
44+
}
45+
let bwrap = onPath("bwrap");
46+
if (!bwrap && bin) {
47+
const shipped = path.resolve(
48+
path.dirname(bin),
49+
"..",
50+
"codex-resources",
51+
"bwrap",
52+
);
53+
if (fs.existsSync(shipped)) {
54+
fs.chmodSync(shipped, 0o755);
55+
bwrap = shipped;
56+
}
57+
}
58+
return { bin, bwrap };
59+
}

‎packages/appkit/src/plugins/omnigent/runtime/env.ts‎

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,24 @@ import { createHash } from "node:crypto";
22
import fs from "node:fs";
33
import path from "node:path";
44

5-
/** The Databricks profile the children use; it points at the model gateway. */
6-
export const GATEWAY_PROFILE = "appkit-omnigent";
5+
/** Databricks profile names the children use; both point at the model gateway. */
6+
const PROFILE_HTTPS = "appkit-omnigent";
7+
const PROFILE_HTTP = "appkit-omnigent-http";
8+
9+
/** Harnesses whose TLS stack only trusts public roots use the plain-http listener. */
10+
export function profileFor(harness: string): string {
11+
return harness.startsWith("codex") ? PROFILE_HTTP : PROFILE_HTTPS;
12+
}
713

814
/**
915
* Directory layout under the runtime root:
1016
*
11-
* server/ Omnigent server data
17+
* server/ Omnigent server data (masked from harnesses)
1218
* users/<id>/home one user's $HOME: Databricks profile, Omnigent config
1319
* users/<id>/data that user's host data
1420
* work/<id> that user's workspace (sessions' cwd)
15-
* bin/ public: CA bundle
16-
* tls/ gateway key
21+
* bin/ public: sandbox wrappers, CA bundle
22+
* tls/ gateway key (masked)
1723
* logs/
1824
*/
1925
export class RuntimeLayout {
@@ -57,28 +63,34 @@ export class RuntimeLayout {
5763

5864
interface GatewayEndpoints {
5965
httpsUrl: string;
66+
httpUrl: string;
6067
placeholder: string;
6168
}
6269

6370
/**
64-
* Writes a user's `$HOME` config: a `.databrickscfg` whose profile points at
65-
* the gateway with the placeholder token (no real credential), and an
66-
* Omnigent `config.yaml` that authenticates through that profile.
71+
* Writes a user's `$HOME` config: a `.databrickscfg` whose two profiles point
72+
* at the gateway with the placeholder token (no real credential), and an
73+
* Omnigent `config.yaml` that authenticates through the https one.
6774
*/
6875
export function writeUserConfig(home: string, gw: GatewayEndpoints): void {
6976
fs.mkdirSync(path.join(home, ".config", "omnigent"), { recursive: true });
7077
const cfg = [
71-
`[${GATEWAY_PROFILE}]`,
78+
`[${PROFILE_HTTPS}]`,
7279
`host = ${gw.httpsUrl}`,
7380
`token = ${gw.placeholder}`,
7481
"auth_type = pat",
7582
"",
83+
`[${PROFILE_HTTP}]`,
84+
`host = ${gw.httpUrl}`,
85+
`token = ${gw.placeholder}`,
86+
"auth_type = pat",
87+
"",
7688
].join("\n");
7789
fs.writeFileSync(path.join(home, ".databrickscfg"), cfg, { mode: 0o600 });
7890
const omnigentConfig = [
7991
"auth:",
8092
" type: databricks",
81-
` profile: ${GATEWAY_PROFILE}`,
93+
` profile: ${PROFILE_HTTPS}`,
8294
"",
8395
].join("\n");
8496
fs.writeFileSync(

0 commit comments

Comments
 (0)