Compare commits

..

7 Commits

Author SHA1 Message Date
Koshkoshinsk a6a46621dd fix(codex): deliver harness file events + add file to ProviderEvent
Codex's built-in image generation yields { type: 'file', path } that the
ProviderEvent union didn't declare (breaks tsc once codex.ts lands on
trunk) and the poll-loop never consumed (the image was dropped and never
reached chat). Adding the type alone clears the build but leaves delivery
broken — this fixes both.

- add { type: 'file'; path: string } to ProviderEvent
- extract enqueueFileOut() owning the outbox-staging + messages_out
  {files:[]} contract so send_file and the poll-loop can't drift apart
- poll-loop delivers file events to the batch's reply destination,
  best-effort (missing dest / unreadable file logs, never fails the turn)
- tests for enqueueFileOut

Refs CDX-001.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 01:50:32 +03:00
gavrielc ac0a799cbf refactor(add-codex): install Codex CLI via cli-tools.json, not the Dockerfile
adfae67 moved the agent's global Node CLIs into container/cli-tools.json so a
skill adds one with a json-merge instead of editing the Dockerfile. The Codex
provider install was left behind — add-codex.sh still awk'd an ARG + RUN into
the Dockerfile and its test guarded that shape.

Migrate add-codex to the seam:
- add-codex.sh appends { name: "@openai/codex", version } to cli-tools.json
  (idempotent json-merge); install/idempotency gates read the manifest.
- SKILL.md / REMOVE.md document the manifest append/removal, not Dockerfile edits.
- codex-dockerfile.test.ts -> codex-cli-tools.test.ts, asserting the manifest
  entry (skips when the manifest is absent, e.g. the bare providers branch).

Pairs with the providers-branch commit that drops the codex Dockerfile lines,
renames the payload test, and points the setup install-check at the manifest.

Verified end-to-end: full add-codex install into a clean worktree leaves the
Dockerfile codex-free, the manifest correctly appended and idempotent; vitest
cli-tools.test.ts (6) and bun codex-cli-tools.test.ts (2) green; host tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 21:40:44 +03:00
github-actions[bot] e3986eb58c chore: bump version to 2.1.16 2026-06-14 18:29:28 +00:00
github-actions[bot] 6d0d48d585 docs: update token count to 195k tokens · 98% of context window 2026-06-14 18:29:25 +00:00
gavrielc a142c496f7 Merge pull request #2756 from nanocoai/provider-selection
feat(providers): operator-driven provider selection, switching, and memory migration
2026-06-14 21:29:12 +03:00
Daniel M ed8b4149e7 Merge pull request #2764 from glifocat/docs/fix-claude-md-relocated-paths
docs(CLAUDE.md): fix two relocated Key Files paths
2026-06-14 18:13:31 +03:00
glifocat d5ce02d1b8 docs(CLAUDE.md): fix two relocated Key Files paths
The Key Files table and the Secrets/OneCLI section referenced
src/onecli-approvals.ts and src/user-dm.ts, but both files were moved
under src/modules/ (src/modules/approvals/onecli-approvals.ts and
src/modules/permissions/user-dm.ts). onecli-approvals.ts is already
cited at its correct new path elsewhere in the same doc, so this was a
partial-rename miss. Docs only — no code changes.

Closes #2763

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:01:40 +02:00
13 changed files with 311 additions and 88 deletions
+13 -3
View File
@@ -37,7 +37,7 @@ rm -f src/providers/codex.ts \
container/agent-runner/src/providers/codex.factory.test.ts \
container/agent-runner/src/providers/codex.turns.test.ts \
container/agent-runner/src/providers/codex-app-server.test.ts \
container/agent-runner/src/providers/codex-dockerfile.test.ts \
container/agent-runner/src/providers/codex-cli-tools.test.ts \
setup/providers/codex.ts \
setup/providers/codex.test.ts \
setup/providers/codex-registration.test.ts
@@ -47,9 +47,19 @@ This skill itself (`.claude/skills/add-codex/`) stays — it ships with trunk so
`container/AGENTS.md` stays only if another installed provider uses agent surfaces; otherwise remove it too.
## 4. Revert the Dockerfile
## 4. Remove the CLI manifest entry
Delete the `ARG CODEX_VERSION=...` line and the `RUN pnpm install -g "@openai/codex@${CODEX_VERSION}"` line from `container/Dockerfile`.
Delete the `@openai/codex` entry from `container/cli-tools.json`:
```bash
node -e '
const fs = require("fs");
const file = "container/cli-tools.json";
const tools = JSON.parse(fs.readFileSync(file, "utf8")).filter((t) => t.name !== "@openai/codex");
const fmt = (t) => " { " + Object.entries(t).map(([k, v]) => JSON.stringify(k) + ": " + JSON.stringify(v)).join(", ") + " }";
fs.writeFileSync(file, "[\n" + tools.map(fmt).join(",\n") + "\n]\n");
'
```
## 5. Vault secret (optional)
+18 -9
View File
@@ -5,9 +5,9 @@ description: Use Codex (OpenAI's codex app-server) as a full agent provider —
# Codex agent provider
> Shortcut: `pnpm exec tsx setup/index.ts --step provider-auth codex` performs this whole install (manifest-driven from the providers branch: files, barrels, Dockerfile pin, image rebuild) plus auth in one command. The steps below are the same operations, for agent-driven or manual application.
> Shortcut: `pnpm exec tsx setup/index.ts --step provider-auth codex` performs this whole install (manifest-driven from the providers branch: files, barrels, CLI manifest entry, image rebuild) plus auth in one command. The steps below are the same operations, for agent-driven or manual application.
NanoClaw selects each group's agent backend from `container_configs.provider` (default `claude`). This skill installs the Codex provider: copy the payload from the `providers` branch, append one import to each of the three provider barrels, add the pinned Codex CLI to the Dockerfile, rebuild, then run the vault auth walk-through.
NanoClaw selects each group's agent backend from `container_configs.provider` (default `claude`). This skill installs the Codex provider: copy the payload from the `providers` branch, append one import to each of the three provider barrels, add the pinned Codex CLI to the container manifest (`container/cli-tools.json`), rebuild, then run the vault auth walk-through.
The provider runs `codex app-server` as a child process speaking JSON-RPC over stdio: native streaming, MCP tools, server-side conversation history (the continuation is a thread id, no on-disk transcript). Credentials are **vault-only**: OneCLI serves a sentinel `auth.json` stub into the container and swaps the real ChatGPT token or API key on the wire — no key in `.env`, nothing readable in the container.
@@ -21,7 +21,7 @@ Check whether the payload is already wired (a prior apply, or a trunk that still
- `container/agent-runner/src/providers/codex.ts` and `codex-app-server.ts`
- `setup/providers/codex.ts`
- `import './codex.js';` in `src/providers/index.ts`, `container/agent-runner/src/providers/index.ts`, and `setup/providers/index.ts`
- `ARG CODEX_VERSION=` in `container/Dockerfile`
- an `@openai/codex` entry in `container/cli-tools.json`
### Fetch and copy
@@ -45,7 +45,7 @@ Container (`container/agent-runner/src/providers/`):
- `exchange-archive.test.ts` — writer behavior
- `codex-registration.test.ts` — barrel-driven container registration guard
- `codex.factory.test.ts`, `codex.turns.test.ts`, `codex-app-server.test.ts` — provider behavior
- `codex-dockerfile.test.ts` — structural guard for the Dockerfile install
- `codex-cli-tools.test.ts` — structural guard for the Codex entry in `container/cli-tools.json`
Setup (`setup/providers/`):
- `codex.ts` — picker entry self-registration + the vault auth walk-through + install check
@@ -62,15 +62,24 @@ Append `import './codex.js';` to each of:
- `container/agent-runner/src/providers/index.ts`
- `setup/providers/index.ts`
### Dockerfile
### CLI manifest
Copy the two Codex lines verbatim from the branch (the branch's Dockerfile is the canonical pin — do not hand-type a version):
The agent's global Node CLIs install from `container/cli-tools.json` (a json-merge seam), not hand-edited Dockerfile layers. Add Codex by appending one entry — `@openai/codex` has no native postinstall, so no `onlyBuilt`:
```bash
git show origin/providers:container/Dockerfile | grep -A1 'ARG CODEX_VERSION'
node -e '
const fs = require("fs");
const file = "container/cli-tools.json";
const tools = JSON.parse(fs.readFileSync(file, "utf8"));
if (!tools.some((t) => t.name === "@openai/codex")) {
tools.push({ name: "@openai/codex", version: "0.138.0" });
const fmt = (t) => " { " + Object.entries(t).map(([k, v]) => JSON.stringify(k) + ": " + JSON.stringify(v)).join(", ") + " }";
fs.writeFileSync(file, "[\n" + tools.map(fmt).join(",\n") + "\n]\n");
}
'
```
Add the `ARG CODEX_VERSION=<pinned>` line to the version-args block and the `RUN pnpm install -g "@openai/codex@${CODEX_VERSION}"` line to the global-install block (its own layer).
The version (`0.138.0`) is the canonical pin — keep it in sync with `setup/add-codex.sh`. The Dockerfile already installs every manifest entry via pinned `pnpm install -g`; no Dockerfile edit is needed.
### Build
@@ -113,5 +122,5 @@ There is no install-wide default provider. Setup's provider picker sets codex on
## Troubleshooting
- **Container dies at boot, channel silent:** `grep 'Container exited non-zero' logs/nanoclaw.error.log` — the `stderrTail` carries the reason (e.g. `Unknown provider: codex. Registered: claude` means the barrels aren't wired in the running build).
- **In-channel `Error: spawn codex ENOENT` on every message:** the image predates the Dockerfile edit — re-run `./container/build.sh`.
- **In-channel `Error: spawn codex ENOENT` on every message:** the image predates the manifest entry — re-run `./container/build.sh`.
- **Auth errors mid-conversation:** the vault secret is missing or stale — re-run `pnpm exec tsx setup/index.ts --step provider-auth codex` (subscription re-login updates the vault copy).
@@ -0,0 +1,39 @@
// Structural guard for the Codex CLI install in container/cli-tools.json.
//
// @openai/codex is a CLI *binary* installed from the global-CLI manifest (a
// json-merge seam), not an importable package, so the barrel-driven
// registration tests cannot see it. This test reads the real cli-tools.json
// and asserts the @openai/codex entry is present and pinned to an exact
// version. It goes red if the manifest entry is dropped or unpins.
//
// Runs under bun (same suite as the container registration test):
// cd container/agent-runner && bun test src/providers/codex-cli-tools.test.ts
import { existsSync, readFileSync } from 'fs';
import path from 'path';
import { describe, it, expect } from 'bun:test';
// container/agent-runner/src/providers/ -> container/cli-tools.json
const MANIFEST = path.join(import.meta.dir, '..', '..', '..', 'cli-tools.json');
const manifestPresent = existsSync(MANIFEST);
// Read lazily — `describe.skipIf` still runs the body to register tests, so the
// read has to be guarded for the bare-branch (no manifest) case.
const tools: Array<{ name: string; version: string }> = manifestPresent
? JSON.parse(readFileSync(MANIFEST, 'utf8'))
: [];
const codex = tools.find((t) => t.name === '@openai/codex');
// cli-tools.json is a trunk file; on the bare providers branch it isn't present,
// so skip there. In an installed tree (trunk + this payload) it must carry the
// pinned @openai/codex entry.
describe.skipIf(!manifestPresent)('container/cli-tools.json codex CLI install', () => {
it('includes the @openai/codex entry', () => {
expect(codex).toBeDefined();
});
it('pins it to an exact semver (no latest, no ranges)', () => {
expect(codex?.version).toMatch(/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/);
});
});
@@ -1,30 +0,0 @@
// Structural guard for the Codex CLI install in container/Dockerfile.
//
// @openai/codex is a CLI *binary* installed via the Dockerfile, not an
// importable package, so the barrel-driven registration tests cannot see it.
// This test reads the real Dockerfile and asserts the version ARG and the
// `pnpm install -g` line for @openai/codex are both present. It goes red if
// either Dockerfile edit is dropped or drifts.
//
// Runs under bun (same suite as the container registration test):
// cd container/agent-runner && bun test src/providers/codex-dockerfile.test.ts
import { readFileSync } from 'fs';
import path from 'path';
import { describe, it, expect } from 'bun:test';
// container/agent-runner/src/providers/ -> container/Dockerfile
const DOCKERFILE = path.join(import.meta.dir, '..', '..', '..', 'Dockerfile');
describe('container/Dockerfile codex CLI install', () => {
const dockerfile = readFileSync(DOCKERFILE, 'utf8');
it('declares the CODEX_VERSION ARG', () => {
expect(dockerfile).toMatch(/ARG\s+CODEX_VERSION=/);
});
it('installs the @openai/codex CLI pinned to that ARG', () => {
expect(dockerfile).toMatch(/pnpm install -g\s+"@openai\/codex@\$\{CODEX_VERSION\}"/);
});
});
+3 -3
View File
@@ -69,8 +69,8 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
| `src/modules/permissions/access.ts` | `canAccessAgentGroup` — owner / global admin / scoped admin / member resolution against `user_roles` + `agent_group_members` |
| `src/modules/approvals/primitive.ts` | `pickApprover`, `pickApprovalDelivery`, `requestApproval`, approval-handler registry |
| `src/command-gate.ts` | Router-side admin command gate — queries `user_roles` directly (no env var, no container-side check) |
| `src/onecli-approvals.ts` | OneCLI credentialed-action approval bridge |
| `src/user-dm.ts` | Cold-DM resolution + `user_dms` cache |
| `src/modules/approvals/onecli-approvals.ts` | OneCLI credentialed-action approval bridge |
| `src/modules/permissions/user-dm.ts` | Cold-DM resolution + `user_dms` cache |
| `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills, agent-runner-src overlay) |
| `src/db/container-configs.ts` | CRUD for `container_configs` table (per-group container runtime config) |
| `src/backfill-container-configs.ts` | Migrates legacy `container.json` files into the DB on startup |
@@ -152,7 +152,7 @@ Key files: `src/container-restart.ts`, `src/container-runner.ts` (`killContainer
## Secrets / Credentials / OneCLI
API keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway. Secrets are injected into per-agent containers at request time — none are passed in env vars or through chat context. The container agent sees this via the `onecli-gateway` container skill (`container/skills/onecli-gateway/SKILL.md`), which teaches it how the proxy works, how to handle auth errors, and to never ask for raw credentials. Host-side wiring: `src/onecli-approvals.ts`, `ensureAgent()` in `container-runner.ts`. Run `onecli --help`.
API keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway. Secrets are injected into per-agent containers at request time — none are passed in env vars or through chat context. The container agent sees this via the `onecli-gateway` container skill (`container/skills/onecli-gateway/SKILL.md`), which teaches it how the proxy works, how to handle auth errors, and to never ask for raw credentials. Host-side wiring: `src/modules/approvals/onecli-approvals.ts`, `ensureAgent()` in `container-runner.ts`. Run `onecli --help`.
### Secret modes
+11 -15
View File
@@ -13,6 +13,7 @@ import { getCurrentInReplyTo } from '../current-batch.js';
import { findByName, getAllDestinations } from '../destinations.js';
import { getMessageIdBySeq, getRoutingBySeq, writeMessageOut } from '../db/messages-out.js';
import { getSessionRouting } from '../db/session-routing.js';
import { enqueueFileOut } from '../outbox.js';
import { registerTools } from './server.js';
import type { McpToolDefinition } from './types.js';
@@ -156,21 +157,16 @@ export const sendFile: McpToolDefinition = {
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve('/workspace/agent', filePath);
if (!fs.existsSync(resolvedPath)) return err(`File not found: ${filePath}`);
const id = generateId();
const filename = (args.filename as string) || path.basename(resolvedPath);
const outboxDir = path.join('/workspace/outbox', id);
fs.mkdirSync(outboxDir, { recursive: true });
fs.copyFileSync(resolvedPath, path.join(outboxDir, filename));
writeMessageOut({
id,
in_reply_to: getCurrentInReplyTo(),
kind: 'chat',
platform_id: routing.platform_id,
channel_type: routing.channel_type,
thread_id: routing.thread_id,
content: JSON.stringify({ text: (args.text as string) || '', files: [filename] }),
const { id, filename } = enqueueFileOut({
srcPath: resolvedPath,
routing: {
platform_id: routing.platform_id,
channel_type: routing.channel_type,
thread_id: routing.thread_id,
in_reply_to: getCurrentInReplyTo(),
},
text: (args.text as string) || '',
filename: (args.filename as string) || undefined,
});
log(`send_file: ${id}${routing.resolvedName} (${filename})`);
+87
View File
@@ -0,0 +1,87 @@
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { initTestSessionDb, closeSessionDb } from './db/connection.js';
import { getUndeliveredMessages } from './db/messages-out.js';
import { enqueueFileOut } from './outbox.js';
let outboxDir: string;
let srcDir: string;
beforeEach(() => {
initTestSessionDb();
outboxDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-outbox-'));
srcDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-src-'));
process.env.NANOCLAW_OUTBOX_DIR = outboxDir;
});
afterEach(() => {
closeSessionDb();
delete process.env.NANOCLAW_OUTBOX_DIR;
fs.rmSync(outboxDir, { recursive: true, force: true });
fs.rmSync(srcDir, { recursive: true, force: true });
});
function writeSrc(name: string, bytes: string): string {
const p = path.join(srcDir, name);
fs.writeFileSync(p, bytes);
return p;
}
describe('enqueueFileOut', () => {
it('stages the file under the outbox and enqueues a messages_out row with files[]', () => {
const src = writeSrc('ig_abc.png', 'PNGDATA');
const { id, filename } = enqueueFileOut({
srcPath: src,
routing: { platform_id: 'chan-1', channel_type: 'discord', thread_id: 'thr-9', in_reply_to: 'm1' },
text: 'here you go',
});
// Bytes staged at <outbox>/<id>/<filename> for the host to read.
const staged = path.join(outboxDir, id, filename);
expect(fs.existsSync(staged)).toBe(true);
expect(fs.readFileSync(staged, 'utf8')).toBe('PNGDATA');
// Exactly one outbound row, carrying the file reference + routing.
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
const row = out[0];
expect(row.platform_id).toBe('chan-1');
expect(row.channel_type).toBe('discord');
expect(row.thread_id).toBe('thr-9');
expect(row.in_reply_to).toBe('m1');
const content = JSON.parse(row.content);
expect(content.files).toEqual(['ig_abc.png']);
expect(content.text).toBe('here you go');
});
it('defaults filename to the basename and text to empty', () => {
const src = writeSrc('chart.png', 'X');
const { filename } = enqueueFileOut({
srcPath: src,
routing: { platform_id: 'C-1', channel_type: 'slack', thread_id: null },
});
expect(filename).toBe('chart.png');
const row = getUndeliveredMessages()[0];
expect(row.in_reply_to).toBeNull();
const content = JSON.parse(row.content);
expect(content.text).toBe('');
expect(content.files).toEqual(['chart.png']);
});
it('throws when the source file is missing — callers decide how to surface it', () => {
expect(() =>
enqueueFileOut({
srcPath: path.join(srcDir, 'does-not-exist.png'),
routing: { platform_id: 'C-1', channel_type: 'slack', thread_id: null },
}),
).toThrow();
// Nothing enqueued on failure.
expect(getUndeliveredMessages()).toHaveLength(0);
});
});
+68
View File
@@ -0,0 +1,68 @@
/**
* File delivery via the outbox.
*
* A file is delivered in two parts that must stay in lockstep: the bytes are
* staged under `/workspace/outbox/<id>/<filename>` (the host reads them from
* there after polling), and a `messages_out` row carries `{ files: [name] }`
* so the host knows to attach them. This helper owns that contract so the two
* callers — the `send_file` MCP tool (model-driven) and the poll-loop's `file`
* event consumer (harness-generated images) — can't drift apart.
*/
import fs from 'fs';
import path from 'path';
import { writeMessageOut } from './db/messages-out.js';
/** Where staged files live. Overridable for tests; production is always the mount. */
function outboxBase(): string {
return process.env.NANOCLAW_OUTBOX_DIR ?? '/workspace/outbox';
}
function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
export interface FileOutRouting {
platform_id: string;
channel_type: string;
thread_id: string | null;
in_reply_to?: string | null;
}
export interface EnqueueFileOut {
/** Absolute or already-resolved path to the file to deliver. Must exist. */
srcPath: string;
routing: FileOutRouting;
/** Optional accompanying message text. */
text?: string;
/** Display name; defaults to the basename of `srcPath`. */
filename?: string;
}
/**
* Stage a file into the outbox and enqueue its `messages_out` row.
*
* Throws if `srcPath` cannot be read/copied — callers decide whether that
* should surface to the user (the MCP tool validates existence first; the
* poll-loop consumer logs and moves on so one bad image can't fail the turn).
*/
export function enqueueFileOut(opts: EnqueueFileOut): { id: string; filename: string; seq: number } {
const id = generateId();
const filename = opts.filename ?? path.basename(opts.srcPath);
const outboxDir = path.join(outboxBase(), id);
fs.mkdirSync(outboxDir, { recursive: true });
fs.copyFileSync(opts.srcPath, path.join(outboxDir, filename));
const seq = writeMessageOut({
id,
in_reply_to: opts.routing.in_reply_to ?? null,
kind: 'chat',
platform_id: opts.routing.platform_id,
channel_type: opts.routing.channel_type,
thread_id: opts.routing.thread_id,
content: JSON.stringify({ text: opts.text ?? '', files: [filename] }),
});
return { id, filename, seq };
}
+31
View File
@@ -14,6 +14,7 @@ import {
type RoutingContext,
} from './formatter.js';
import { isUploadTraceCommand, uploadTrace } from './upload-trace.js';
import { enqueueFileOut } from './outbox.js';
import type { AgentProvider, AgentQuery, ProviderEvent, ProviderExchange } from './providers/types.js';
const POLL_INTERVAL_MS = 1000;
@@ -507,6 +508,8 @@ async function processQuery(
} else {
archivePrompts.shift();
}
} else if (event.type === 'file') {
deliverHarnessFile(event.path, routing);
}
}
} catch (err) {
@@ -557,6 +560,34 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
}
}
/**
* Deliver a harness-generated file (e.g. a Codex-rendered image) to the
* batch's reply destination. The model never sends these itself — its native
* client already rendered them — so the loop delivers them via the same outbox
* path send_file uses. Best-effort: a missing reply destination or an
* unreadable file logs and is skipped rather than failing the whole turn.
*/
function deliverHarnessFile(filePath: string, routing: RoutingContext): void {
if (!routing.platformId || !routing.channelType) {
log(`Dropping harness file ${filePath}: batch has no reply destination`);
return;
}
try {
const { filename, seq } = enqueueFileOut({
srcPath: filePath,
routing: {
platform_id: routing.platformId,
channel_type: routing.channelType,
thread_id: routing.threadId,
in_reply_to: routing.inReplyTo,
},
});
log(`Delivered harness file #${seq}${routing.channelType}:${routing.platformId} (${filename})`);
} catch (err) {
log(`Failed to deliver harness file ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
}
}
/**
* Parse the agent's final text for <message to="name">...</message> blocks
* and dispatch each one to its resolved destination. Text outside of blocks
@@ -128,6 +128,13 @@ export type ProviderEvent =
| { type: 'result'; text: string | null }
| { type: 'error'; message: string; retryable: boolean; classification?: string }
| { type: 'progress'; message: string }
/**
* A file the harness produced that the model won't deliver itself (e.g.
* Codex's built-in image generation renders to its native client, so the
* model believes delivery already happened). The poll-loop delivers it to
* the batch's reply destination. `path` is absolute inside the container.
*/
| { type: 'file'; path: string }
/**
* Liveness signal. Providers MUST yield this on every underlying SDK
* event (tool call, thinking, partial message, anything) so the
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.1.15",
"version": "2.1.16",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="194k tokens, 97% of context window">
<title>194k tokens, 97% of context window</title>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="195k tokens, 98% of context window">
<title>195k tokens, 98% of context window</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,8 +15,8 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
<text x="26" y="14">tokens</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">194k</text>
<text x="71" y="14">194k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">195k</text>
<text x="71" y="14">195k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+29 -23
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env bash
#
# Install the Codex agent provider non-interactively: copy the payload from the
# `providers` branch, wire the three provider barrels, and pin the Codex CLI in
# the Dockerfile. The image rebuild is the caller's job (the setup container
# step / `./container/build.sh`).
# `providers` branch, wire the three provider barrels, and add the Codex CLI to
# the container manifest (container/cli-tools.json). The image rebuild is the
# caller's job (the setup container step / `./container/build.sh`).
#
# Emits exactly one status block on stdout (ADD_CODEX); all chatty progress
# goes to stderr. Keep in sync with .claude/skills/add-codex/SKILL.md.
@@ -12,7 +12,8 @@ set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Keep in sync with the providers-branch Dockerfile and add-codex SKILL.md.
# Keep in sync with add-codex SKILL.md. This is the canonical Codex CLI pin —
# it lands in container/cli-tools.json (the global-CLI manifest), not the Dockerfile.
CODEX_VERSION="0.138.0"
# Resolve the remote carrying the providers branch (same nanoclaw remote that
@@ -38,7 +39,7 @@ PAYLOAD_FILES=(
container/agent-runner/src/providers/codex.factory.test.ts
container/agent-runner/src/providers/codex.turns.test.ts
container/agent-runner/src/providers/codex-app-server.test.ts
container/agent-runner/src/providers/codex-dockerfile.test.ts
container/agent-runner/src/providers/codex-cli-tools.test.ts
setup/providers/codex.ts
setup/providers/codex.test.ts
setup/providers/codex-registration.test.ts
@@ -63,11 +64,11 @@ emit_status() {
log() { echo "[add-codex] $*" >&2; }
# Idempotent: a complete install has the host provider file, the host barrel
# import, and the Dockerfile pin. Any missing → (re)install.
# import, and the Codex CLI in the container manifest. Any missing → (re)install.
need_install() {
[ ! -f src/providers/codex.ts ] && return 0
! grep -q "^import './codex.js';" src/providers/index.ts 2>/dev/null && return 0
! grep -q '@openai/codex@' container/Dockerfile 2>/dev/null && return 0
! grep -q '@openai/codex' container/cli-tools.json 2>/dev/null && return 0
return 1
}
@@ -94,22 +95,27 @@ if need_install; then
grep -q "^import './codex.js';" "$b" || printf "import './codex.js';\n" >> "$b"
done
log "Pinning Codex CLI in the Dockerfile…"
DF=container/Dockerfile
if ! grep -q "^ARG CODEX_VERSION=" "$DF"; then
# Version ARG ahead of the first ARG in the version-args block.
awk -v ins="ARG CODEX_VERSION=${CODEX_VERSION}" \
'add!=1 && /^ARG /{print ins; add=1} {print}' "$DF" > "$DF.tmp" && mv "$DF.tmp" "$DF"
fi
if ! grep -q '@openai/codex@' "$DF"; then
# Install RUN block (its own cache layer) before the ncl CLI wrapper anchor.
awk 'add!=1 && /# ---- ncl CLI wrapper/ {
print "RUN --mount=type=cache,target=/root/.cache/pnpm \\"
print " pnpm install -g \"@openai/codex@${CODEX_VERSION}\""
print ""
add=1
} {print}' "$DF" > "$DF.tmp" && mv "$DF.tmp" "$DF"
fi
log "Adding the Codex CLI to the container manifest (cli-tools.json)…"
# A json-merge: append { name, version } if absent. The Dockerfile installs
# every manifest entry via pinned `pnpm install -g` — no Dockerfile edit, no
# awk surgery. @openai/codex has no native postinstall, so no "onlyBuilt".
MANIFEST=container/cli-tools.json
node -e '
const fs = require("fs");
const [file, name, version] = process.argv.slice(1);
const tools = JSON.parse(fs.readFileSync(file, "utf8"));
if (!tools.some((t) => t.name === name)) {
tools.push({ name, version });
const fmt = (t) =>
" { " +
Object.entries(t).map(([k, v]) => JSON.stringify(k) + ": " + JSON.stringify(v)).join(", ") +
" }";
fs.writeFileSync(file, "[\n" + tools.map(fmt).join(",\n") + "\n]\n");
}
' "$MANIFEST" "@openai/codex" "${CODEX_VERSION}" || {
emit_status failed "failed to add @openai/codex to ${MANIFEST}"
exit 1
}
fi
emit_status ok