Compare commits

..

16 Commits

Author SHA1 Message Date
gavrielc a619fc1aa2 Apply suggestion from @gavrielc 2026-06-13 16:03:02 +03:00
Omri Maya 3d2f3e58ca feat(runner): onExchangeComplete provider hook + slash-command interruption
Inverts conversation archiving into an optional onExchangeComplete provider
hook: the runner never archives on a provider's behalf, and the markdown
writer ships with the provider that needs it. Dormant for the default
provider.

Slash commands now interrupt an in-flight turn — a runner-handled command
(/clear, /compact, /cost, …) arriving mid-turn aborts the active stream and
runs immediately instead of waiting out the turn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 15:56:43 +03:00
gavrielc 11afc64ba4 Merge pull request #2747 from nanocoai/oss/onecli-sdk-v2
feat(onecli): SDK 2.2.1 — credential-stub mounts + machine-checkable pins
2026-06-13 15:49:40 +03:00
github-actions[bot] 0ee75d393c chore: bump version to 2.1.13 2026-06-13 12:27:29 +00:00
github-actions[bot] 72b9cc7ed0 docs: update token count to 192k tokens · 96% of context window 2026-06-13 12:27:24 +00:00
gavrielc 5fcf234165 Merge pull request #2746 from nanocoai/oss/agent-surfaces
feat(providers): agent-surfaces capability seam
2026-06-13 15:27:12 +03:00
github-actions[bot] 9b1236505f chore: bump version to 2.1.12 2026-06-13 12:25:58 +00:00
github-actions[bot] 878cd68c1b docs: update token count to 191k tokens · 96% of context window 2026-06-13 12:25:52 +00:00
gavrielc fab1ebf2d6 Merge pull request #2745 from nanocoai/oss/memory-scaffold
feat(memory): opt-in persistent memory scaffold for providers
2026-06-13 15:25:39 +03:00
Omri Maya 3f9e89d345 feat(onecli): SDK 2.2.1 — credential-stub mounts + machine-checkable pins
Injects credentials as request-time stubs so no credential is ever written
into a container or to disk. Gateway and CLI versions move to versions.json
(machine-checkable pins); breaking upgrades are documented in
docs/onecli-upgrades.md as an agent-executable runbook (detect / why / fix /
verify / rollback), and the update flow follows linked docs and diffs the
pins.

BREAKING: requires a gateway upgrade; the doc carries the steps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 11:30:11 +03:00
Omri Maya 14810a5090 feat(providers): agent-surfaces capability seam
Host-side registry where a provider can declare, by capability rather than
by name, that it owns its agent surfaces (project doc, skills). Default
providers keep the standard surfaces; a surfaces-owning provider suppresses
them. Dormant until a provider registers — no change for existing installs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 11:30:10 +03:00
Omri Maya 2cfa86e570 feat(memory): opt-in persistent memory scaffold for providers
Adds a provider capability (usesMemoryScaffold) and a container-side boot
scaffold that materializes a persistent memory/ tree for providers that opt
in. Dormant for the default provider — the scaffold is only built when a
provider declares the capability, so existing installs are byte-identical
(asserted by a boot-gate wiring test).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 11:30:09 +03:00
github-actions[bot] 36cbf17e10 chore: bump version to 2.1.11 2026-06-11 17:16:51 +00:00
gavrielc 4459ab2e54 Merge pull request #2739 from nanocoai/feat/raw-webhook-registry
feat(webhook-server): raw-route registry — non-Chat-SDK webhooks become an append
2026-06-11 20:16:33 +03:00
gavrielc 9e6238d28f Merge main (channel instances): keep both webhook suites as separate files
The instance route-split suite (from #2733) keeps src/webhook-server.test.ts;
this branch's raw-route suite moves to src/webhook-server-raw.test.ts —
incompatible lifecycle setups (fixed port + afterEach vs random port +
afterAll) make a single merged file wrong. webhook-server.ts auto-merge
verified: raw routes take dispatch priority, stop clears both maps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:07:30 +03:00
gavrielc f69af07c57 feat(webhook-server): raw-route registry — non-Chat-SDK webhooks become an append
Add a RawWebhookHandler registry alongside the Chat SDK adapter routes
so modules can mount plain Node handlers at /webhook/{path} on the
shared server instead of editing webhook-server.ts or standing up a
second HTTP server on another port. Raw routes dispatch ahead of
adapter routes, handler throws surface as a 500, and stopWebhookServer
clears the registry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:58:51 +03:00
40 changed files with 1142 additions and 547 deletions
+16 -9
View File
@@ -33,7 +33,7 @@ Run `/update-nanoclaw` in Claude Code.
**Validation**: runs `pnpm run build` and `pnpm test`. If container files changed, also runs the container typecheck and `./container/build.sh`.
**Breaking changes check**: after validation, reads CHANGELOG.md for any `[BREAKING]` entries introduced by the update. If found, shows each breaking change and offers to run the recommended skill to migrate.
**Breaking changes check**: after validation, reads CHANGELOG.md for any `[BREAKING]` entries introduced by the update and diffs `versions.json` for moved component pins. Each entry carries its migration path — a skill to run or a `docs/` page to follow (per CONTRIBUTING.md, "Breaking Changes") — and the skill walks you through them.
## Rollback
@@ -221,24 +221,31 @@ After validation succeeds, check if the update introduced any breaking changes.
Determine which CHANGELOG entries are new by diffing against the backup tag:
- `git diff <backup-tag-from-step-1>..HEAD -- CHANGELOG.md`
Parse the diff output for lines that contain `[BREAKING]` anywhere in the line. Each such line is one breaking change entry. The format is:
Parse the diff output for lines that contain `[BREAKING]` anywhere in the line. Each such line is one breaking change entry, and per CONTRIBUTING.md ("Breaking Changes") it references its migration path in one of two forms:
```
[BREAKING] <description>. Run `/<skill-name>` to <action>.
[BREAKING] <description>. **Migration:** follow [docs/<page>.md](docs/<page>.md) ...
```
If no `[BREAKING]` lines are found:
Also diff the component version pins:
- `git diff <backup-tag-from-step-1>..HEAD -- versions.json`
Each changed pin is a breaking component update (e.g. `onecli-gateway` moving means the OneCLI gateway must be upgraded). Its migration path is the `[BREAKING]` CHANGELOG entry covering it; if no new entry mentions it, search `docs/` for the pin name (convention: `docs/<component>-upgrades.md`) and treat that doc as the migration path.
If no `[BREAKING]` lines are found and `versions.json` did not change:
- Skip this step silently. Proceed to Step 7 (skill updates check).
If one or more `[BREAKING]` lines are found:
Otherwise:
- Display a warning header to the user: "This update includes breaking changes that may require action:"
- For each breaking change, display the full description.
- Collect all skill names referenced in the breaking change entries (the `/<skill-name>` part).
- Use AskUserQuestion to ask the user which migration skills they want to run now. Options:
- For each breaking change, display the full description (for a moved pin without its own entry: the component name, old → new version, and the doc that covers it).
- Use AskUserQuestion to ask the user which migrations to run now. Options:
- One option per referenced skill (e.g., "Run /add-whatsapp to re-add WhatsApp channel")
- One option per referenced doc (e.g., "Upgrade the OneCLI gateway (docs/onecli-upgrades.md)")
- "Skip — I'll handle these manually"
- Set `multiSelect: true` so the user can pick multiple skills if there are several breaking changes.
- Set `multiSelect: true` so the user can pick multiple migrations if there are several breaking changes.
- For each skill the user selects, invoke it using the Skill tool.
- After all selected skills complete (or if user chose Skip), proceed to Step 7 (skill updates check).
- For each doc the user selects, read the doc and execute it top to bottom — these docs are written to be executed verbatim by a coding agent (detect → fix → verify → rollback). Stop and report if a verify step fails.
- After all selected migrations complete (or if user chose Skip), proceed to Step 7 (skill updates check).
# Step 7: Check for skill and channel/provider updates
+5
View File
@@ -2,6 +2,11 @@
All notable changes to NanoClaw will be documented in this file.
## [Unreleased]
- [BREAKING] **`@onecli-sh/sdk` 0.5.0 -> 2.2.1 — requires a OneCLI server with the `/v1` API** (older servers 404 every SDK call). The sanctioned gateway and CLI versions are pinned in `versions.json`; the `onecli` setup step enforces them. **Migration:** [docs/onecli-upgrades.md](docs/onecli-upgrades.md).
- **Slash commands now interrupt an in-flight turn.** A runner-handled command (`/clear`, `/compact`, `/cost`, …) arriving mid-turn aborts the active stream and runs immediately instead of waiting out the turn.
## [2.1.0] - 2026-06-07
- [BREAKING] **Startup now requires an upgrade marker.** The host refuses to boot unless `data/upgrade-state.json` records that this install reached the current version through a sanctioned path (`/setup`, `/update-nanoclaw`, `/migrate-nanoclaw`). After this update completes — and before restarting the service — stamp the marker by running `pnpm exec tsx scripts/upgrade-state.ts set`. If the host has already tripped on restart with "update did not go through the supported path", that same command clears it. See [docs/upgrade-recovery.md](docs/upgrade-recovery.md).
+7
View File
@@ -19,6 +19,13 @@
**Not accepted:** Features, capabilities, compatibility, enhancements. These should be skills.
## Breaking Changes
Breaking changes are allowed; **silent** ones are not. NanoClaw does not migrate user installs at runtime — the user's coding agent is the migrator, so every breaking change must ship a migration path that agent can execute without a human reverse-engineering the diff:
1. **Every `[BREAKING]` CHANGELOG entry must reference its migration path** — either a skill to run (`Run /<skill-name> to <action>`) or a `docs/` page covering **detect / why / fix / verify / rollback** (see [docs/onecli-upgrades.md](docs/onecli-upgrades.md) for the shape). `/update-nanoclaw` surfaces these entries after every update and walks the user through them.
2. **If the change moves an external component's sanctioned version** (gateway, pinned CLI binary, …), update its pin in [`versions.json`](versions.json). The changelog stays human-narrative; `versions.json` is the machine-checkable signal — `/update-nanoclaw` diffs it across the update and routes the user to the linked doc for any pin that moved.
## Skills
NanoClaw uses [Claude Code skills](https://code.claude.com/docs/en/skills) — markdown files with optional supporting files that teach Claude how to do something. There are four types of skills in NanoClaw, each serving a different purpose.
-3
View File
@@ -15,8 +15,6 @@ export interface RunnerConfig {
groupName: string;
agentGroupId: string;
maxMessagesPerPrompt: number;
/** Idle window in ms after which the poll loop exits cleanly. 0 = disabled. */
idleTimeoutMs: number;
mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }>;
model?: string;
effort?: string;
@@ -46,7 +44,6 @@ export function loadConfig(): RunnerConfig {
groupName: (raw.groupName as string) || '',
agentGroupId: (raw.agentGroupId as string) || '',
maxMessagesPerPrompt: (raw.maxMessagesPerPrompt as number) || DEFAULT_MAX_MESSAGES,
idleTimeoutMs: (raw.idleTimeoutMs as number) || 0,
mcpServers: (raw.mcpServers as RunnerConfig['mcpServers']) || {},
model: (raw.model as string) || undefined,
effort: (raw.effort as string) || undefined,
@@ -1,193 +0,0 @@
/**
* Idle-timeout guard — the machinery that lets ephemeral sessions exit
* cleanly instead of riding until host-sweep's absolute ceiling.
*
* Behavior leg: the idle tracker with an injected clock (markActivity /
* shouldExit semantics, including the hasProcessedAtLeastOne gate and the
* idleTimeoutMs <= 0 disable).
*
* AST legs (runPollLoop is an infinite loop — not invocable in a test):
* - runPollLoop destructures idleTimeoutMs from loadConfig() (the
* destructure may carry other keys; this only pins idleTimeoutMs);
* - the empty-poll branch exits via process.exit(0) gated on
* idle.shouldExit();
* - idle.markActivity() runs after the batch-completion
* markCompleted(processingIds) so the idle window restarts per batch;
* - the processQuery call site threads idleTimeoutMs as the 5th argument;
* - the 'result' event arm calls query.end() gated on
* `idleTimeoutMs > 0 && !hasUnwrapped` — never unconditionally, or the
* unwrapped-output re-send nudge would be cut off mid-stream;
* - loadConfig()'s returned literal carries the idleTimeoutMs field
* (RunnerConfig's type is covered by the typecheck leg).
*/
import fs from 'fs';
import path from 'path';
import { describe, expect, it } from 'bun:test';
import ts from 'typescript';
import { createIdleTracker } from './idle-tracker.js';
describe('idle tracker behavior', () => {
it('never exits before the first processed batch, regardless of elapsed time', () => {
let clock = 0;
const tracker = createIdleTracker(1000, () => clock);
clock = 1_000_000;
expect(tracker.shouldExit()).toBe(false);
});
it('exits only after the idle window elapses past the last activity', () => {
let clock = 0;
const tracker = createIdleTracker(1000, () => clock);
tracker.markActivity(); // first batch completes at t=0
clock = 900;
expect(tracker.shouldExit()).toBe(false);
clock = 1001;
expect(tracker.shouldExit()).toBe(true);
// New activity re-arms the window.
tracker.markActivity();
clock = 1900;
expect(tracker.shouldExit()).toBe(false);
clock = 2002;
expect(tracker.shouldExit()).toBe(true);
});
it('idleTimeoutMs <= 0 disables idle exit entirely', () => {
let clock = 0;
const tracker = createIdleTracker(0, () => clock);
tracker.markActivity();
clock = 10_000_000;
expect(tracker.shouldExit()).toBe(false);
});
});
// ── AST legs ──
function parse(file: string): ts.SourceFile {
const source = fs.readFileSync(path.join(import.meta.dir, file), 'utf8');
return ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true);
}
function findAll<T extends ts.Node>(root: ts.Node, pred: (n: ts.Node) => n is T): T[] {
const out: T[] = [];
const visit = (n: ts.Node): void => {
if (pred(n)) out.push(n);
n.forEachChild(visit);
};
visit(root);
return out;
}
function hasAncestor(node: ts.Node, pred: (n: ts.Node) => boolean): boolean {
let cur: ts.Node | undefined = node.parent;
while (cur) {
if (pred(cur)) return true;
cur = cur.parent;
}
return false;
}
describe('poll-loop.ts idle wiring', () => {
const sf = parse('poll-loop.ts');
const runPollLoop = findAll(sf, ts.isFunctionDeclaration).find((f) => f.name?.text === 'runPollLoop');
it('destructures idleTimeoutMs from loadConfig()', () => {
const decls = findAll(runPollLoop!, ts.isVariableDeclaration).filter(
(d) =>
d.initializer !== undefined &&
ts.isCallExpression(d.initializer) &&
ts.isIdentifier(d.initializer.expression) &&
d.initializer.expression.text === 'loadConfig' &&
ts.isObjectBindingPattern(d.name),
);
expect(decls.length).toBeGreaterThanOrEqual(1);
const hasKey = decls.some((d) =>
(d.name as ts.ObjectBindingPattern).elements.some(
(e) => ts.isIdentifier(e.name) && e.name.text === 'idleTimeoutMs',
),
);
expect(hasKey).toBe(true);
});
it('the empty-poll branch exits 0 gated on idle.shouldExit()', () => {
const exits = findAll(runPollLoop!, ts.isCallExpression).filter(
(c) =>
ts.isPropertyAccessExpression(c.expression) &&
c.expression.getText(sf) === 'process.exit' &&
c.arguments[0]?.getText(sf) === '0',
);
const gated = exits.filter((c) =>
hasAncestor(
c,
(n) => ts.isIfStatement(n) && n.expression.getText(sf).replace(/\s+/g, '') === 'idle.shouldExit()',
),
);
expect(gated.length).toBe(1);
// And the gate itself sits inside the messages.length === 0 branch.
expect(
hasAncestor(gated[0], (n) => ts.isIfStatement(n) && n.expression.getText(sf).includes('messages.length === 0')),
).toBe(true);
});
it('marks activity after markCompleted so the idle window restarts per batch', () => {
const marks = findAll(runPollLoop!, ts.isCallExpression).filter(
(c) => c.expression.getText(sf).replace(/\s+/g, '') === 'idle.markActivity',
);
expect(marks.length).toBe(1);
// The batch-completion call is markCompleted(processingIds) — the others
// handle command/skip bookkeeping and must not arm the idle window.
const completed = findAll(runPollLoop!, ts.isCallExpression).filter(
(c) =>
ts.isIdentifier(c.expression) &&
c.expression.text === 'markCompleted' &&
c.arguments[0]?.getText(sf) === 'processingIds',
);
expect(completed.length).toBe(1);
expect(marks[0].getStart(sf)).toBeGreaterThan(completed[0].getStart(sf));
});
it("threads idleTimeoutMs as processQuery's 5th argument", () => {
const calls = findAll(runPollLoop!, ts.isCallExpression).filter(
(c) => ts.isIdentifier(c.expression) && c.expression.text === 'processQuery',
);
expect(calls.length).toBe(1);
expect(calls[0].arguments.length).toBe(5);
expect(calls[0].arguments[4].getText(sf)).toBe('idleTimeoutMs');
});
it("the 'result' event arm ends the stream gated on idleTimeoutMs > 0 && !hasUnwrapped", () => {
const processQuery = findAll(sf, ts.isFunctionDeclaration).find((f) => f.name?.text === 'processQuery');
expect(processQuery).toBeDefined();
const ends = findAll(processQuery!, ts.isCallExpression).filter(
(c) => c.expression.getText(sf).replace(/\s+/g, '') === 'query.end',
);
// The !hasUnwrapped half of the gate is load-bearing: an unconditional
// (or idleTimeoutMs-only) end would close the stream right after the
// unwrapped-output nudge was pushed, stranding the re-sent response.
const gated = ends.filter((c) =>
hasAncestor(
c,
(n) =>
ts.isIfStatement(n) && n.expression.getText(sf).replace(/\s+/g, '') === 'idleTimeoutMs>0&&!hasUnwrapped',
),
);
expect(gated.length).toBe(1);
});
});
describe('config.ts idle wiring', () => {
const sf = parse('config.ts');
it('loadConfig returns an idleTimeoutMs field', () => {
const loadConfig = findAll(sf, ts.isFunctionDeclaration).find((f) => f.name?.text === 'loadConfig');
expect(loadConfig).toBeDefined();
const props = findAll(loadConfig!, ts.isPropertyAssignment).filter(
(p) => ts.isIdentifier(p.name) && p.name.text === 'idleTimeoutMs',
);
expect(props.length).toBe(1);
// Reads the raw container.json key with a 0 default (0 = disabled).
expect(props[0].initializer.getText(sf).replace(/\s+/g, '')).toContain('raw.idleTimeoutMs');
});
});
@@ -1,40 +0,0 @@
/**
* Idle-exit tracker for ephemeral sessions.
*
* The poll loop creates one tracker per run and makes two one-line calls:
*
* - `markActivity()` after a batch completes records the last time the
* agent did real work and arms the tracker (an agent that never processed
* anything must not idle-exit before its first trigger arrives).
* - `shouldExit()` in the empty-poll branch true once idleTimeoutMs > 0,
* at least one batch has been processed, and the idle window has elapsed.
*
* `idleTimeoutMs` comes from the group's container.json (RunnerConfig),
* materialized from the `container_configs.idle_timeout_ms` column. A value
* of 0 (the default) disables idle exit entirely the container then rides
* until host-sweep's absolute ceiling, exactly as before this tracker existed.
*/
export interface IdleTracker {
/** Record activity: arms the tracker and resets the idle window. */
markActivity(): void;
/** True when the session has been idle past the timeout and may exit 0. */
shouldExit(): boolean;
}
export function createIdleTracker(idleTimeoutMs: number, now: () => number = Date.now): IdleTracker {
let lastActivityAt = now();
let hasProcessedAtLeastOne = false;
return {
markActivity(): void {
lastActivityAt = now();
hasProcessedAtLeastOne = true;
},
shouldExit(): boolean {
if (idleTimeoutMs <= 0) return false;
if (!hasProcessedAtLeastOne) return false;
return now() - lastActivityAt > idleTimeoutMs;
},
};
}
+7
View File
@@ -27,6 +27,7 @@ import { fileURLToPath } from 'url';
import { loadConfig } from './config.js';
import { buildSystemPromptAddendum } from './destinations.js';
import { ensureMemoryScaffold } from './memory-scaffold.js';
// Providers barrel — each enabled provider self-registers on import.
// Provider skills append imports to providers/index.ts.
import './providers/index.js';
@@ -95,6 +96,12 @@ async function main(): Promise<void> {
effort: config.effort,
});
// Providers that lack native memory opt in via `usesMemoryScaffold`; for them
// the runner creates a persistent memory/ tree in its host-backed workspace at
// boot (idempotent). Default off — the trunk default (Claude) omits the flag
// and keeps its native memory untouched.
if (provider.usesMemoryScaffold) ensureMemoryScaffold();
await runPollLoop({
provider,
providerName,
@@ -5,6 +5,7 @@ import { getUndeliveredMessages } from './db/messages-out.js';
import { getPendingMessages } from './db/messages-in.js';
import { getContinuation, setContinuation } from './db/session-state.js';
import { MockProvider } from './providers/mock.js';
import type { ProviderExchange } from './providers/types.js';
import { runPollLoop } from './poll-loop.js';
beforeEach(() => {
@@ -304,6 +305,7 @@ async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSigna
provider,
providerName: 'mock',
cwd: '/tmp',
signal,
}),
new Promise<void>((_, reject) => {
signal.addEventListener('abort', () => reject(new Error('aborted')));
@@ -324,6 +326,86 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
describe('poll loop — exchange hook (onExchangeComplete)', () => {
// A provider that declares the per-exchange hook. The hook call is the
// wiring under test — these tests go red if the poll-loop seam is severed.
// What the provider DOES with an exchange (e.g. write markdown into
// conversations/) ships with the provider, not the runner.
class HookedMockProvider extends MockProvider {
readonly exchanges: ProviderExchange[] = [];
onExchangeComplete(exchange: ProviderExchange): void {
this.exchanges.push(exchange);
}
}
it('reports each exchange to a provider that declares the hook', async () => {
insertMessage('m1', { sender: 'Alice', text: 'please archive this' }, { platformId: 'chan-1', channelType: 'discord' });
const provider = new HookedMockProvider({}, () => '<message to="discord-test">archived answer</message>');
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
await waitFor(() => provider.exchanges.length > 0, 2000);
controller.abort();
expect(provider.exchanges.length).toBe(1);
const exchange = provider.exchanges[0];
expect(exchange.prompt).toContain('please archive this');
expect(exchange.result).toContain('archived answer');
expect(exchange.continuation).toStartWith('mock-session-');
expect(exchange.status).toBe('completed');
await loopPromise.catch(() => {});
});
it('does not report the internal wrapping-retry nudge as a user prompt', async () => {
insertMessage('m1', { sender: 'Alice', text: 'wrap this later' }, { platformId: 'chan-1', channelType: 'discord' });
let calls = 0;
const provider = new HookedMockProvider({}, () => {
calls += 1;
// First result is unwrapped (triggers the retry nudge), second is wrapped.
return calls === 1 ? 'unwrapped text' : '<message to="discord-test">wrapped now</message>';
});
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 3000);
await waitFor(() => provider.exchanges.length >= 2, 3000);
controller.abort();
// Both exchanges attribute themselves to the real user prompt, never the nudge.
for (const exchange of provider.exchanges) {
expect(exchange.prompt).not.toContain('Your response was not delivered');
expect(exchange.prompt).toContain('wrap this later');
}
expect(provider.exchanges.map((e) => e.status)).toEqual(['undelivered', 'completed']);
await loopPromise.catch(() => {});
});
it('a throwing hook never breaks delivery', async () => {
insertMessage('m1', { sender: 'Alice', text: 'still deliver this' }, { platformId: 'chan-1', channelType: 'discord' });
class ThrowingHookProvider extends MockProvider {
onExchangeComplete(): void {
throw new Error('hook exploded');
}
}
const provider = new ThrowingHookProvider({}, () => '<message to="discord-test">delivered anyway</message>');
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
controller.abort();
const out = getUndeliveredMessages();
expect(out.length).toBe(1);
expect(out[0].content).toContain('delivered anyway');
await loopPromise.catch(() => {});
});
});
describe('poll loop — provider error recovery', () => {
it('writes error to outbound and continues loop on provider throw', async () => {
insertMessage('m1', { sender: 'Alice', text: 'trigger error' }, { platformId: 'chan-1', channelType: 'discord' });
@@ -462,3 +544,76 @@ class InvalidSessionProvider {
};
}
}
describe('poll loop — slash command during active query', () => {
it('aborts the active query when /clear arrives as a follow-up', async () => {
insertMessage('m-active', { sender: 'Alice', text: 'long running request' }, { platformId: 'chan-1', channelType: 'discord' });
const provider = new BlockingProvider();
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 3000);
await waitFor(() => provider.queries === 1, 2000);
insertMessage('m-clear-active', { sender: 'Alice', text: '/clear' }, { platformId: 'chan-1', channelType: 'discord' });
await waitFor(() => provider.aborts === 1, 2000);
await waitFor(
() => getUndeliveredMessages().some((msg) => JSON.parse(msg.content).text === 'Session cleared.'),
2000,
);
controller.abort();
expect(provider.ends).toBe(0);
expect(getContinuation('mock')).toBeUndefined();
expect(getPendingMessages()).toHaveLength(0);
await loopPromise.catch(() => {});
});
});
/**
* Provider whose query never completes until ended/aborted for testing how
* the loop interrupts an active stream.
*/
class BlockingProvider {
readonly supportsNativeSlashCommands = false;
queries = 0;
aborts = 0;
ends = 0;
isSessionInvalid(): boolean {
return false;
}
query() {
const owner = this;
this.queries += 1;
let wake: (() => void) | null = null;
let ended = false;
let aborted = false;
return {
push() {},
end: () => {
owner.ends += 1;
ended = true;
wake?.();
},
abort: () => {
owner.aborts += 1;
aborted = true;
wake?.();
},
events: (async function* () {
yield { type: 'activity' as const };
yield { type: 'init' as const, continuation: 'blocking-session' };
while (!ended && !aborted) {
await new Promise<void>((resolve) => {
wake = resolve;
});
wake = null;
}
})(),
};
}
}
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'bun:test';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { ensureMemoryScaffold } from './memory-scaffold.js';
describe('ensureMemoryScaffold', () => {
it('deterministically creates the memory tree', () => {
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-mem-'));
try {
ensureMemoryScaffold(base);
expect(fs.existsSync(path.join(base, 'memory', 'index.md'))).toBe(true);
expect(fs.existsSync(path.join(base, 'memory', 'system', 'definition.md'))).toBe(true);
expect(fs.existsSync(path.join(base, 'memory', 'memories'))).toBe(true);
expect(fs.existsSync(path.join(base, 'memory', 'data'))).toBe(true);
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
});
it('is idempotent and never clobbers the agent edits', () => {
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-mem-'));
try {
ensureMemoryScaffold(base);
const indexFile = path.join(base, 'memory', 'index.md');
fs.writeFileSync(indexFile, '# my own index\n');
ensureMemoryScaffold(base);
expect(fs.readFileSync(indexFile, 'utf-8')).toBe('# my own index\n');
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,39 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
/**
* Create the agent's persistent memory scaffold, container-side, at boot.
*
* The runner owns its own workspace: it writes the memory tree straight into
* `/workspace/agent` (the host-backed, RW group dir, so it persists across the
* ephemeral container). No host-side step, nothing mounted in.
*
* The default `definition.md` / `index.md` live as real markdown templates next
* to this module (under `memory-templates/`) not as strings in code so the
* doctrine is editable as markdown and the agent receives an unescaped copy.
* They ship in the mounted `/app/src` tree, so no image change is needed.
*
* Idempotent only writes what's missing, so the agent's own edits and
* accumulated memory are never clobbered on a later wake. Provider-agnostic:
* the runner makes no assumption about which harness is running a provider
* opts in via `usesMemoryScaffold`.
*/
const TEMPLATES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'memory-templates');
export function ensureMemoryScaffold(baseDir = '/workspace/agent'): void {
const memoryDir = path.join(baseDir, 'memory');
const systemDir = path.join(memoryDir, 'system');
for (const dir of [systemDir, path.join(memoryDir, 'memories'), path.join(memoryDir, 'data')]) {
fs.mkdirSync(dir, { recursive: true });
}
copyTemplateIfMissing('definition.md', path.join(systemDir, 'definition.md'));
copyTemplateIfMissing('index.md', path.join(memoryDir, 'index.md'));
}
function copyTemplateIfMissing(template: string, dest: string): void {
if (fs.existsSync(dest)) return;
fs.copyFileSync(path.join(TEMPLATES_DIR, template), dest);
}
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'bun:test';
import fs from 'fs';
import path from 'path';
// Wiring guard for the memory-scaffold seam: the boot gate in index.ts
// (`if (provider.usesMemoryScaffold) ensureMemoryScaffold()`) is the seam's
// single functional reach-in. The unit tests in memory-scaffold.test.ts drive
// ensureMemoryScaffold directly and stay green if the gate is deleted — this
// test goes red. main() can't be driven in-process (it reads
// /workspace/agent/container.json and enters the poll loop), so the guard is
// structural: gate + import must both be present in the real entry point.
describe('memory scaffold boot wiring', () => {
const indexSrc = fs.readFileSync(path.join(import.meta.dir, 'index.ts'), 'utf-8');
it('gates the scaffold on the provider capability in main()', () => {
expect(indexSrc).toContain('if (provider.usesMemoryScaffold) ensureMemoryScaffold()');
});
it('imports ensureMemoryScaffold from the seam module', () => {
expect(indexSrc).toContain("import { ensureMemoryScaffold } from './memory-scaffold.js'");
});
});
@@ -0,0 +1,23 @@
# Agent Memory System
This editable file defines how your persistent memory works. It is a starting
point, not a contract — reorganize it as the work demands. If the user or another
memory system replaces this definition, follow the replacement.
Start every memory task at `memory/index.md`, then follow the narrowest relevant index.
Treat indexes as core data: keep them accurate and concise.
Every folder of durable memory has its own `index.md` describing its contents.
When an index grows past roughly 20 entries, group related items into subfolders,
and give each new subfolder its own `index.md` linked from the parent.
Use `memory/memories/` for durable facts, project context, people, decisions, and entity notes.
Use `memory/data/` for structured reference data, datasets, tables, and reusable records.
Use entity folders for things that matter: projects, people, places, organizations, decisions.
When the user shares something that should survive future turns, store it in the
smallest useful file; prefer updating an existing file over creating duplicates.
Write concise, source-aware notes; include dates when timing matters.
If a fact is corrected, update the memory and keep only useful history.
When you add, move, or remove memory, update the nearest index.
Before answering from memory, read the relevant index or file instead of guessing;
if memory is missing or uncertain, say so and verify when it matters.
@@ -0,0 +1,5 @@
# Memory Index
- [Memory system definition](system/definition.md)
- [Memories](memories/) - durable facts, people, projects, decisions
- [Data](data/) - structured reference data
+69 -31
View File
@@ -4,8 +4,6 @@ import { writeMessageOut } from './db/messages-out.js';
import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
import { clearContinuation, migrateLegacyContinuation, setContinuation } from './db/session-state.js';
import { clearCurrentInReplyTo, setCurrentInReplyTo } from './current-batch.js';
import { loadConfig } from './config.js';
import { createIdleTracker } from './idle-tracker.js';
import {
formatMessages,
extractRouting,
@@ -16,7 +14,7 @@ import {
type RoutingContext,
} from './formatter.js';
import { isUploadTraceCommand, uploadTrace } from './upload-trace.js';
import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js';
import type { AgentProvider, AgentQuery, ProviderEvent, ProviderExchange } from './providers/types.js';
const POLL_INTERVAL_MS = 1000;
const ACTIVE_POLL_INTERVAL_MS = 500;
@@ -65,6 +63,12 @@ export interface PollLoopConfig {
systemContext?: {
instructions?: string;
};
/**
* Optional stop signal. In production the loop runs until the container
* dies; tests pass a signal so an abandoned loop actually exits instead of
* polling forever and stealing messages from the next test's DB.
*/
signal?: AbortSignal;
}
/**
@@ -106,15 +110,10 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
// This lets the new container re-process those messages.
clearStaleProcessingAcks();
// Idle exit: when the group's container config sets idle_timeout_ms, an
// idle container exits 0 after the window elapses instead of riding until
// host-sweep's absolute ceiling kills it. Unset/0 = disabled (default).
const { idleTimeoutMs } = loadConfig();
const idle = createIdleTracker(idleTimeoutMs);
let pollCount = 0;
let isFirstPoll = true;
while (true) {
if (config.signal?.aborted) return;
// Skip system messages — they're responses for MCP tools (e.g., ask_user_question)
const messages = getPendingMessages(isFirstPoll).filter((m) => m.kind !== 'system');
isFirstPoll = false;
@@ -126,10 +125,6 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
}
if (messages.length === 0) {
if (idle.shouldExit()) {
log(`Idle timeout (${idleTimeoutMs}ms) — exiting`);
process.exit(0);
}
await sleep(POLL_INTERVAL_MS);
continue;
}
@@ -244,7 +239,15 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
// can stamp it on outbound rows — needed for a2a return-path routing.
setCurrentInReplyTo(routing.inReplyTo);
try {
const result = await processQuery(query, routing, processingIds, config.providerName, idleTimeoutMs);
const result = await processQuery(
query,
routing,
processingIds,
config.providerName,
config.provider.onExchangeComplete?.bind(config.provider),
prompt,
continuation,
);
if (result.continuation && result.continuation !== continuation) {
continuation = result.continuation;
setContinuation(config.providerName, continuation);
@@ -278,7 +281,6 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
// Ensure completed even if processQuery ended without a result event
// (e.g. stream closed unexpectedly).
markCompleted(processingIds);
idle.markActivity();
log(`Completed ${ids.length} message(s)`);
}
}
@@ -326,11 +328,18 @@ async function processQuery(
routing: RoutingContext,
initialBatchIds: string[],
providerName: string,
idleTimeoutMs: number = 0,
onExchangeComplete: ((exchange: ProviderExchange) => void) | undefined,
initialPrompt: string,
initialContinuation: string | undefined,
): Promise<QueryResult> {
let queryContinuation: string | undefined;
let done = false;
let unwrappedNudged = false;
// Prompt queue for the exchange hook — each result event consumes the
// oldest unanswered prompt, except a wrapping-retry result, which answers
// the same prompt again. Unused (and unmaintained) when the provider
// doesn't implement `onExchangeComplete`.
const archivePrompts: string[] = [initialPrompt];
// Concurrent polling: push follow-ups into the active query as they arrive.
// We do NOT force-end the stream on silence — keeping the query open avoids
@@ -356,13 +365,16 @@ async function processQuery(
// resume id (fixed at sdkQuery() time); admin/passthrough commands
// (/compact, /cost, …) only dispatch when they're the first input
// of a query — pushed mid-stream they arrive as plain text and
// the SDK never runs them. End the stream and leave the rows
// pending; the outer loop handles them on next iteration via the
// canonical command path + formatMessagesWithCommands.
// the SDK never runs them. Abort the active stream and leave the
// rows pending; the outer loop handles them on next iteration via
// the canonical command path + formatMessagesWithCommands. Abort,
// not end: end() lets an in-flight turn run to completion, which
// can block the command (e.g. /clear during a long task) for as
// long as the turn takes.
if (pending.some((m) => isRunnerCommand(m))) {
log('Pending slash command — ending stream so outer loop can process');
log('Pending slash command — aborting active stream so outer loop can process');
endedForCommand = true;
query.end();
query.abort();
return;
}
@@ -407,6 +419,7 @@ async function processQuery(
log(`Pushing ${keep.length} follow-up message(s) into active query`);
unwrappedNudged = false;
query.push(prompt);
archivePrompts.push(prompt);
markCompleted(keptIds);
} catch (err) {
// Without this catch the rejection escapes the void IIFE and Node
@@ -468,10 +481,16 @@ async function processQuery(
// (send_message) mid-turn, or the message may not need a response
// at all — either way the turn is finished.
markCompleted(initialBatchIds);
let hasUnwrapped = false;
if (event.text) {
({ hasUnwrapped } = dispatchResultText(event.text, routing));
if (hasUnwrapped && !unwrappedNudged) {
const { hasUnwrapped } = dispatchResultText(event.text, routing);
const willRetryWrapping = hasUnwrapped && !unwrappedNudged;
notifyExchangeComplete(onExchangeComplete, {
prompt: archivePrompts[0] ?? initialPrompt,
result: event.text,
continuation: queryContinuation ?? initialContinuation,
status: hasUnwrapped ? 'undelivered' : 'completed',
});
if (willRetryWrapping) {
unwrappedNudged = true;
const destinations = getAllDestinations();
const names = destinations.map((d) => d.name).join(', ');
@@ -482,16 +501,23 @@ async function processQuery(
`Please re-send your response with the correct wrapping.</system>`,
);
}
}
// When idleTimeoutMs is set, end the stream once the turn completes
// so the outer loop can evaluate the idle window. Skipped while the
// turn's output was unwrapped — the re-send nudge pushed above needs
// the stream to stay open for the corrected response.
if (idleTimeoutMs > 0 && !hasUnwrapped) {
query.end();
// The wrapping-retry result answers the SAME user prompt — keep it
// queued so the retry archives against it, not the nudge text.
if (!willRetryWrapping) archivePrompts.shift();
} else {
archivePrompts.shift();
}
}
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
notifyExchangeComplete(onExchangeComplete, {
prompt: archivePrompts[0] ?? initialPrompt,
result: `Error: ${errMsg}`,
continuation: queryContinuation ?? initialContinuation,
status: 'error',
});
throw err;
} finally {
done = true;
clearInterval(pollHandle);
@@ -500,6 +526,18 @@ async function processQuery(
return { continuation: queryContinuation };
}
function notifyExchangeComplete(
hook: ((exchange: ProviderExchange) => void) | undefined,
exchange: ProviderExchange,
): void {
if (!hook) return;
try {
hook(exchange);
} catch (err) {
log(`onExchangeComplete failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
switch (event.type) {
case 'init':
@@ -6,6 +6,25 @@ export interface AgentProvider {
*/
readonly supportsNativeSlashCommands: boolean;
/**
* Optional. When true, the runner scaffolds a persistent `memory/` tree in the
* agent's workspace at boot. Providers with their own native memory (e.g.
* Claude's `CLAUDE.local.md`) omit this and get nothing memory is opt-in per
* provider, never gated on a provider name.
*/
readonly usesMemoryScaffold?: boolean;
/**
* Optional. Called by the poll-loop after each completed exchange (a
* result, a wrapping retry, or an error). Providers whose harness keeps no
* on-disk transcript implement this to persist exchanges themselves (e.g.
* markdown into the agent's `conversations/` dir); providers that persist
* and archive their own transcript (e.g. the Claude Agent SDK's `.jsonl`)
* omit it. Best-effort: the loop catches and logs anything it throws. The
* implementation lives with the provider, never in the runner.
*/
onExchangeComplete?(exchange: ProviderExchange): void;
/** Start a new query. Returns a handle for streaming input and output. */
query(input: QueryInput): AgentQuery;
@@ -31,6 +50,16 @@ export interface AgentProvider {
maybeRotateContinuation?(continuation: string, cwd: string): string | null;
}
/** One prompt/result round-trip, as reported to `onExchangeComplete`. */
export interface ProviderExchange {
/** The user prompt this exchange answers (never an internal retry nudge). */
prompt: string;
result: string | null;
/** Continuation/thread id in effect for the exchange, if any. */
continuation?: string;
status: 'completed' | 'undelivered' | 'error';
}
/**
* Options passed to provider constructors. Fields are common to most
* providers; individual providers may ignore any they don't need.
-2
View File
@@ -310,7 +310,6 @@ CREATE TABLE container_configs (
image_tag TEXT,
assistant_name TEXT,
max_messages_per_prompt INTEGER,
idle_timeout_ms INTEGER, -- idle-exit window (ms); NULL/0 = disabled
skills TEXT NOT NULL DEFAULT '"all"',
mcp_servers TEXT NOT NULL DEFAULT '{}',
packages_apt TEXT NOT NULL DEFAULT '[]',
@@ -345,7 +344,6 @@ Migrations live in `src/db/migrations/`, one file per migration. Runner: `runMig
| 009 | `009-drop-pending-credentials.ts` | Drop the defunct `pending_credentials` table |
| 014 | `014-container-configs.ts` | `container_configs` — per-agent-group container runtime config |
| 015 | `015-cli-scope.ts` | `ALTER TABLE container_configs ADD COLUMN cli_scope` |
| 016 | `016-container-idle-timeout.ts` | `ALTER TABLE container_configs ADD COLUMN idle_timeout_ms` |
Numbers 005 and 006 are intentionally absent — migrations were renumbered during early development.
+83
View File
@@ -0,0 +1,83 @@
# Upgrading the OneCLI gateway
NanoClaw talks to the OneCLI gateway (credential vault + egress proxy) through `@onecli-sh/sdk`. The gateway is an external component with its own release line, so NanoClaw pins the **sanctioned gateway version** in [`versions.json`](../versions.json) under `onecli-gateway`. When an update moves that pin, the gateway must be upgraded — this doc is the migration path. It is written to be handed to a coding agent verbatim: detect → upgrade → verify → rollback.
There is deliberately **no runtime version check, and setup does not migrate the gateway for you**: the gateway is a separate out-of-band component, and the migrator is your coding agent running `/update-nanoclaw` — it diffs `versions.json` across the update and routes you here when the `onecli-gateway` pin moved. (Setup detects a pre-`/v1` gateway and points at this doc, but never upgrades it.) Run the steps below verbatim.
## 1. Detect
Find out what is running and what is required:
```bash
cat versions.json # the sanctioned pin
curl -s http://127.0.0.1:10254/api/health # reports the running gateway version
curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:10254/v1/health
```
If the last command prints `404`, the server predates the `/v1` API that `@onecli-sh/sdk` 2.x requires — every SDK call will fail with 404s that look transient but are permanent. If your gateway is remote, substitute its host for `127.0.0.1` (it's in `.env` as `ONECLI_URL` / `NANOCLAW_ONECLI_API_HOST`).
Why gateways fall behind: the OneCLI installer's docker-compose tracks the `latest` image tag, but Docker never re-pulls a tag — the server freezes at whatever `latest` meant on install day.
## 2. Upgrade
The gateway runs as a Docker service in `~/.onecli`. Upgrade just that container to the pinned `onecli-gateway` version — vault data lives in named Docker volumes and survives. This upgrades only the gateway; the CLI binary is pinned separately (see below).
**Local gateway (the common case):**
```bash
cd ~/.onecli && ONECLI_VERSION=<onecli-gateway pin from versions.json> docker compose pull onecli && docker compose up -d
```
**Remote gateway** — run the same command on the gateway's host (NanoClaw can't reach it over SSH).
## 3. Verify
Host-side health is necessary but **not sufficient**:
```bash
curl -s http://127.0.0.1:10254/v1/health # must return {"status":"ok",...}
```
**Verify the bind interface (container reachability).** Agent containers reach the gateway over the docker bridge (`host.docker.internal` → e.g. `172.17.0.1`), so a server bound only to `127.0.0.1` boots clean host-side while every credentialed call from containers dies at the proxy:
```bash
docker run --rm --add-host=host.docker.internal:host-gateway \
curlimages/curl -s -o /dev/null -w '%{http_code}' http://host.docker.internal:10254/v1/health
```
This must print `200`. If it can't connect while the host-side check passed, set the bind address in `~/.onecli/.env` to the docker-bridge IP (or `0.0.0.0` on a host with a closed firewall) and `cd ~/.onecli && docker compose up -d`. Symptom if skipped: host log clean, agents fail all API calls.
Finally, restart the NanoClaw service (per-install names — derive with `setup/lib/install-slug.sh`):
```bash
# macOS
source setup/lib/install-slug.sh && launchctl kickstart -k gui/$(id -u)/$(launchd_label)
# Linux
source setup/lib/install-slug.sh && systemctl --user restart $(systemd_unit)
```
## 4. Rollback
```bash
cd ~/.onecli && ONECLI_VERSION=<old-version> docker compose up -d
```
If the NanoClaw update itself is being rolled back, also pin `@onecli-sh/sdk` back to its previous version in `package.json` and run `pnpm install`. Vault data is unaffected in both directions.
## The CLI binary (`onecli-cli` pin)
The `onecli` host CLI is pinned the same way, under `onecli-cli` in `versions.json`. Setup installs exactly that version by direct release download — it never resolves "latest". When an update moves this pin, replace the binary with the pinned release:
```bash
onecli --version # detect: what is installed
V=<onecli-cli pin from versions.json>
OS=$(uname -s | tr '[:upper:]' '[:lower:]') # darwin | linux
ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') # amd64 | arm64
curl -fsSL -o /tmp/onecli.tgz \
"https://github.com/onecli/onecli-cli/releases/download/v${V}/onecli_${V}_${OS}_${ARCH}.tar.gz"
tar -xzf /tmp/onecli.tgz -C /tmp
install -m 0755 /tmp/onecli "$(command -v onecli || echo ~/.local/bin/onecli)"
onecli --version # verify: must match versions.json
```
To roll back, run the same block after reverting `versions.json` (or checking out the previous NanoClaw version). The CLI is stateless — vault data lives in the gateway, so swapping the binary in either direction loses nothing.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.1.10",
"version": "2.1.13",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
@@ -30,7 +30,7 @@
"dependencies": {
"@clack/core": "^1.2.0",
"@clack/prompts": "^1.2.0",
"@onecli-sh/sdk": "^0.5.0",
"@onecli-sh/sdk": "2.2.1",
"better-sqlite3": "11.10.0",
"chat": "^4.24.0",
"cron-parser": "5.5.0",
+5 -5
View File
@@ -15,8 +15,8 @@ importers:
specifier: ^1.2.0
version: 1.2.0
'@onecli-sh/sdk':
specifier: ^0.5.0
version: 0.5.0
specifier: 2.2.1
version: 2.2.1
better-sqlite3:
specifier: 11.10.0
version: 11.10.0
@@ -303,8 +303,8 @@ packages:
'@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1
'@onecli-sh/sdk@0.5.0':
resolution: {integrity: sha512-oe5Yx9o98v6N1PgzcCR7nULHHqcqKWNJIDOHGOSNX+l20mLlZpFUqfKPeFmsojBNRQMoqbvZQKUlFMp6gVuYBA==}
'@onecli-sh/sdk@2.2.1':
resolution: {integrity: sha512-q2mCW4ZsARlLEoTxz/P0NQ4MiCh7Z2n28pxkSc7srS+tozyw40PdTnWYW7NI8hfSYplZTx5856Adq1iPi4KN3Q==}
engines: {node: '>=20'}
'@oxc-project/types@0.124.0':
@@ -1665,7 +1665,7 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
'@onecli-sh/sdk@0.5.0': {}
'@onecli-sh/sdk@2.2.1': {}
'@oxc-project/types@0.124.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="190k tokens, 95% of context window">
<title>190k tokens, 95% 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="192k tokens, 96% of context window">
<title>192k tokens, 96% 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">190k</text>
<text x="71" y="14">190k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">192k</text>
<text x="71" y="14">192k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+48
View File
@@ -0,0 +1,48 @@
/**
* versions.json is the machine-checkable source for sanctioned component
* versions: setup steps read it, /update-nanoclaw diffs it across updates.
* These tests go red if the file, the pin, or the onecli-step wiring is
* deleted the pin moving back to a hardcoded constant is the regression
* this guards against.
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { describe, expect, it } from 'vitest';
import { readVersionPin } from './version-pins.js';
const here = path.dirname(fileURLToPath(import.meta.url));
describe('readVersionPin', () => {
it('resolves the onecli-gateway pin from the real versions.json', () => {
expect(readVersionPin('onecli-gateway')).toMatch(/^\d+\.\d+\.\d+$/);
});
it('resolves the onecli-cli pin from the real versions.json', () => {
expect(readVersionPin('onecli-cli')).toMatch(/^\d+\.\d+\.\d+$/);
});
it('throws for a component with no pin', () => {
expect(() => readVersionPin('no-such-component')).toThrow(/no pin/);
});
});
describe('onecli step wiring', () => {
it('reads its gateway pin from versions.json, not a hardcoded constant', () => {
const source = fs.readFileSync(path.join(here, '..', 'onecli.ts'), 'utf-8');
expect(source).toContain("readVersionPin('onecli-gateway')");
expect(source).not.toMatch(/ONECLI_GATEWAY_VERSION = '\d/);
});
it('reads its CLI pin from versions.json and never resolves "latest"', () => {
const source = fs.readFileSync(path.join(here, '..', 'onecli.ts'), 'utf-8');
expect(source).toContain("readVersionPin('onecli-cli')");
expect(source).not.toMatch(/ONECLI_CLI(?:_FALLBACK)?_VERSION = '\d/);
// The upstream installer and the /releases/latest redirect probe both
// chase "latest" — reintroducing either bypasses the sanctioned pin.
expect(source).not.toContain('onecli.sh/cli/install');
expect(source).not.toContain('/releases/latest');
});
});
+31
View File
@@ -0,0 +1,31 @@
/**
* Sanctioned version pins for external components (`versions.json` at the
* repo root) the single machine-checkable source. Setup steps read their
* pin here; `/update-nanoclaw` diffs the file across an update and routes
* the user to the migration doc for any pin that moved (see CONTRIBUTING.md,
* "Breaking changes").
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const VERSIONS_FILE = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'..',
'versions.json',
);
/**
* Returns the pinned version for a component, e.g.
* `readVersionPin('onecli-gateway')`. Throws when the file or the pin is
* missing a missing pin is an install-tree defect, not a runtime condition.
*/
export function readVersionPin(component: string): string {
const pins: unknown = JSON.parse(fs.readFileSync(VERSIONS_FILE, 'utf-8'));
const value = (pins as Record<string, unknown>)[component];
if (typeof value !== 'string' || value.length === 0) {
throw new Error(`versions.json has no pin for "${component}"`);
}
return value;
}
+29
View File
@@ -0,0 +1,29 @@
/**
* The step DETECTS gateway /v1 compatibility and warns (pointing at
* docs/onecli-upgrades.md) it does not migrate the gateway; that's the
* agent's job via /update-nanoclaw. The verify helper must distinguish
* incompatible (pre-/v1 server: warn) from unreachable (transient: nothing to
* say) so the warning only fires on a real pre-/v1 server.
*/
import { describe, expect, it } from 'vitest';
import { verifyGatewayV1 } from './onecli.js';
function fakeFetch(behavior: 'ok' | '404' | 'down'): typeof fetch {
return (async () => {
if (behavior === 'down') throw new Error('ECONNREFUSED');
return { ok: behavior === 'ok' } as Response;
}) as unknown as typeof fetch;
}
describe('verifyGatewayV1', () => {
it('ok when /v1/health answers', async () => {
expect(await verifyGatewayV1('http://x', fakeFetch('ok'))).toBe('ok');
});
it('incompatible when the server answers HTTP without /v1', async () => {
expect(await verifyGatewayV1('http://x', fakeFetch('404'))).toBe('incompatible');
});
it('unreachable on connection failure', async () => {
expect(await verifyGatewayV1('http://x', fakeFetch('down'))).toBe('unreachable');
});
});
+61 -54
View File
@@ -17,6 +17,7 @@ import os from 'os';
import path from 'path';
import { log } from '../src/log.js';
import { readVersionPin } from './lib/version-pins.js';
import { emitStatus } from './status.js';
const LOCAL_BIN = path.join(os.homedir(), '.local', 'bin');
@@ -102,20 +103,18 @@ function writeEnvOnecliUrl(url: string): void {
writeEnvVar('ONECLI_URL', url);
}
// Last-known-good CLI release. Used only if BOTH the upstream installer
// and the redirect-based version probe fail. Bump deliberately when a
// new CLI release ships.
const ONECLI_GATEWAY_VERSION = '1.23.0';
const ONECLI_CLI_FALLBACK_VERSION = '1.3.0';
// The SANCTIONED gateway version: fresh installs pin to it. Upgrading an
// existing gateway is NOT done here — the gateway is a separate out-of-band
// component, and the migrator is the user's coding agent following
// docs/onecli-upgrades.md during /update-nanoclaw. The pin lives in
// versions.json ("onecli-gateway") so that flow can diff it across updates and
// route the agent to the doc; bump it there deliberately on a new release.
const ONECLI_GATEWAY_VERSION = readVersionPin('onecli-gateway');
// The CLI binary follows the same convention: installed at its pin
// ("onecli-cli" in versions.json), never at whatever "latest" means today.
const ONECLI_CLI_VERSION = readVersionPin('onecli-cli');
const ONECLI_CLI_REPO = 'onecli/onecli-cli';
function installOnecliCliOnly(): { stdout: string; ok: boolean } {
const upstream = runInstall('curl -fsSL onecli.sh/cli/install | sh');
if (upstream.ok) return { stdout: upstream.stdout, ok: true };
const fallback = installOnecliCliDirect();
return { stdout: upstream.stdout + (upstream.stderr ?? '') + '\n' + fallback.stdout, ok: fallback.ok };
}
// Remove containers in the "onecli" compose project whose service name isn't
// in the v2 set. Pre-v2 OneCLI used service "app" (container onecli-app-1);
// v2 uses "onecli". Compose flags the old container as an orphan but won't
@@ -161,24 +160,10 @@ function installOnecli(): { stdout: string; ok: boolean } {
return { stdout: stdout + (gw.stderr ?? ''), ok: false };
}
// CLI install. The upstream script calls the GitHub releases API
// (api.github.com) to resolve the latest tag — which 403s anonymous
// callers after 60 requests/hour per IP. Try upstream first; on failure
// resolve the version ourselves (via HTTP redirect, which isn't
// API-throttled) and download the release archive directly.
const upstream = runInstall('curl -fsSL onecli.sh/cli/install | sh');
stdout += upstream.stdout;
if (upstream.ok) return { stdout, ok: true };
log.warn('Upstream CLI installer failed — falling back to direct download', {
stderr: upstream.stderr,
});
stdout += (upstream.stderr ?? '') + '\n';
const fallback = installOnecliCliDirect();
stdout += fallback.stdout;
if (!fallback.ok) {
log.error('OneCLI CLI install failed (both upstream and direct fallback)');
const cli = installOnecliCliDirect();
stdout += cli.stdout;
if (!cli.ok) {
log.error('OneCLI CLI install failed');
return { stdout, ok: false };
}
return { stdout, ok: true };
@@ -198,11 +183,11 @@ function runInstall(cmd: string): { stdout: string; stderr?: string; ok: boolean
}
/**
* Reinstate the OneCLI CLI install without hitting GitHub's rate-limited
* releases API. Resolves the version via the HTTP redirect from
* /releases/latest /releases/tag/vX.Y.Z, then downloads the archive
* directly. Falls back to ONECLI_CLI_FALLBACK_VERSION if the redirect
* probe also fails.
* Install the OneCLI CLI at the sanctioned pin by downloading the release
* archive straight from GitHub. Deliberately no "latest" resolution the
* upstream installer script always chases the newest release, which would
* drift from the pin. PATH setup is not lost by skipping it:
* ensureShellProfilePath() in run() covers it.
*/
function installOnecliCliDirect(): { stdout: string; ok: boolean } {
const lines: string[] = [];
@@ -221,24 +206,7 @@ function installOnecliCliDirect(): { stdout: string; ok: boolean } {
return { stdout: lines.join('\n'), ok: false };
}
let version: string | null = null;
try {
const redirect = execSync(
`curl -fsSL -o /dev/null -w '%{url_effective}' https://github.com/${ONECLI_CLI_REPO}/releases/latest`,
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },
).trim();
const m = redirect.match(/\/tag\/v?([^/]+)$/);
if (m) version = m[1];
} catch {
// redirect probe failed — we'll pin the fallback
}
if (!version) {
version = ONECLI_CLI_FALLBACK_VERSION;
append(`Version probe failed; installing pinned fallback ${version}.`);
} else {
append(`Resolved onecli CLI ${version} via release redirect.`);
}
const version = ONECLI_CLI_VERSION;
const archive = `onecli_${version}_${osName}_${arch}.tar.gz`;
const url = `https://github.com/${ONECLI_CLI_REPO}/releases/download/v${version}/${archive}`;
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'onecli-'));
@@ -275,6 +243,39 @@ function installOnecliCliDirect(): { stdout: string; ok: boolean } {
}
}
/**
* /v1 API compatibility check. @onecli-sh/sdk 2.x requires the server's /v1
* API; servers older than the cutover answer 404 on every SDK call (permanent,
* but presents as transient per-spawn failures). This is detect-only setup
* does not migrate the gateway. The upgrade is an out-of-band action on a
* separate component that the agent runs via docs/onecli-upgrades.md during
* /update-nanoclaw, so this step only surfaces the condition and points there.
*/
export async function verifyGatewayV1(
url: string,
fetchImpl: typeof fetch = fetch,
): Promise<'ok' | 'incompatible' | 'unreachable'> {
try {
const res = await fetchImpl(`${url}/v1/health`, { signal: AbortSignal.timeout(5000) });
return res.ok ? 'ok' : 'incompatible';
} catch {
return 'unreachable';
}
}
/**
* Detect-and-warn helper: returns a status HINT (and logs) when the gateway is
* pre-/v1, else null. Never fails the step or auto-upgrades the agent owns
* the upgrade via docs/onecli-upgrades.md.
*/
function gatewayV1Hint(result: 'ok' | 'incompatible' | 'unreachable'): string | null {
if (result !== 'incompatible') return null;
log.warn('OneCLI gateway lacks the /v1 API @onecli-sh/sdk 2.x requires', {
pin: ONECLI_GATEWAY_VERSION,
});
return 'OneCLI gateway lacks the /v1 API @onecli-sh/sdk 2.x requires — upgrade it: docs/onecli-upgrades.md';
}
export async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
// `/api/health` matches the path probe.sh uses — keep them aligned.
const deadline = Date.now() + timeoutMs;
@@ -300,7 +301,7 @@ export async function run(args: string[]): Promise<void> {
// Remote-mode: install only the CLI, point it at the remote gateway, and
// record the URL in .env. No local gateway is started.
log.info('Installing OneCLI CLI for remote gateway', { remoteUrl });
const res = installOnecliCliOnly();
const res = installOnecliCliDirect();
if (!res.ok || !onecliVersion()) {
emitStatus('ONECLI', {
INSTALLED: false,
@@ -339,12 +340,14 @@ export async function run(args: string[]): Promise<void> {
log.info('Wrote ONECLI_API_KEY to .env');
}
const healthy = await pollHealth(remoteUrl, 5000);
const v1Hint = healthy ? gatewayV1Hint(await verifyGatewayV1(remoteUrl)) : null;
emitStatus('ONECLI', {
INSTALLED: true,
REMOTE: true,
ONECLI_URL: remoteUrl,
HEALTHY: healthy,
STATUS: 'success',
...(v1Hint ? { GATEWAY_HINT: v1Hint } : {}),
LOG: 'logs/setup.log',
});
return;
@@ -378,12 +381,14 @@ export async function run(args: string[]): Promise<void> {
writeEnvOnecliUrl(url);
log.info('Reusing existing OneCLI', { url });
const healthy = await pollHealth(url, 5000);
const v1Hint = healthy ? gatewayV1Hint(await verifyGatewayV1(url)) : null;
emitStatus('ONECLI', {
INSTALLED: true,
REUSED: true,
ONECLI_URL: url,
HEALTHY: healthy,
STATUS: 'success',
...(v1Hint ? { GATEWAY_HINT: v1Hint } : {}),
LOG: 'logs/setup.log',
});
return;
@@ -436,6 +441,7 @@ export async function run(args: string[]): Promise<void> {
log.info('Wrote ONECLI_URL to .env', { url });
const healthy = await pollHealth(url, 15000);
const v1Hint = healthy ? gatewayV1Hint(await verifyGatewayV1(url)) : null;
emitStatus('ONECLI', {
INSTALLED: true,
@@ -446,6 +452,7 @@ export async function run(args: string[]): Promise<void> {
// The next step (auth) will surface a genuinely broken gateway via
// `onecli secrets list`, so don't trigger rescue attempts from here.
STATUS: 'success',
...(v1Hint ? { GATEWAY_HINT: v1Hint } : {}),
...(healthy
? {}
: {
-1
View File
@@ -59,7 +59,6 @@ export function backfillContainerConfigs(): void {
image_tag: legacy.imageTag ?? null,
assistant_name: legacy.assistantName ?? null,
max_messages_per_prompt: legacy.maxMessagesPerPrompt ?? null,
idle_timeout_ms: null,
skills: JSON.stringify(legacy.skills ?? 'all'),
mcp_servers: JSON.stringify(legacy.mcpServers ?? {}),
packages_apt: JSON.stringify(legacy.packages?.apt ?? []),
+3 -12
View File
@@ -22,7 +22,6 @@ function presentConfig(row: ContainerConfigRow): Record<string, unknown> {
image_tag: row.image_tag,
assistant_name: row.assistant_name,
max_messages_per_prompt: row.max_messages_per_prompt,
idle_timeout_ms: row.idle_timeout_ms,
skills: JSON.parse(row.skills),
mcp_servers: JSON.parse(row.mcp_servers),
packages_apt: JSON.parse(row.packages_apt),
@@ -214,7 +213,7 @@ registerResource({
access: 'approval',
description:
'Update container config scalar fields. Changes are saved but do NOT take effect until you run `ncl groups restart`. ' +
'Use --id <group-id> and any of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --idle-timeout-ms, --cli-scope.',
'Use --id <group-id> and any of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope.',
handler: async (args) => {
const id = args.id as string;
if (!id) throw new Error('--id is required');
@@ -224,14 +223,7 @@ registerResource({
const updates: Partial<
Pick<
ContainerConfigRow,
| 'provider'
| 'model'
| 'effort'
| 'image_tag'
| 'assistant_name'
| 'max_messages_per_prompt'
| 'idle_timeout_ms'
| 'cli_scope'
'provider' | 'model' | 'effort' | 'image_tag' | 'assistant_name' | 'max_messages_per_prompt' | 'cli_scope'
>
> = {};
if (args.provider !== undefined) updates.provider = args.provider as string;
@@ -241,7 +233,6 @@ registerResource({
if (args.assistant_name !== undefined) updates.assistant_name = args.assistant_name as string;
if (args.max_messages_per_prompt !== undefined)
updates.max_messages_per_prompt = Number(args.max_messages_per_prompt);
if (args.idle_timeout_ms !== undefined) updates.idle_timeout_ms = Number(args.idle_timeout_ms);
if (args['cli-scope'] !== undefined || args.cli_scope !== undefined) {
const scope = (args['cli-scope'] ?? args.cli_scope) as string;
if (!['disabled', 'group', 'global'].includes(scope)) {
@@ -252,7 +243,7 @@ registerResource({
if (Object.keys(updates).length === 0) {
throw new Error(
'Nothing to update — provide at least one of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --idle-timeout-ms, --cli-scope',
'Nothing to update — provide at least one of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope',
);
}
-82
View File
@@ -1,82 +0,0 @@
/**
* idle_timeout_ms threading migration 016 column `ContainerConfigRow`
* `configFromDb()` `materializeContainerJson()` `container.json`.
*
* The default leg is load-bearing: a NULL column must keep `idleTimeoutMs`
* out of container.json entirely, so groups that never set the value get
* today's behavior byte-identical (the container-side loadConfig then
* defaults to 0 = idle exit disabled).
*/
import fs from 'fs';
import path from 'path';
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
vi.mock('./config.js', async () => {
const actual = await vi.importActual('./config.js');
return { ...actual, GROUPS_DIR: '/tmp/nanoclaw-test-container-config/groups' };
});
const TEST_DIR = '/tmp/nanoclaw-test-container-config';
const GROUPS_DIR = path.join(TEST_DIR, 'groups');
import { initTestDb, closeDb, runMigrations } from './db/index.js';
import { createAgentGroup, getAgentGroup } from './db/agent-groups.js';
import { ensureContainerConfig, getContainerConfig, updateContainerConfigScalars } from './db/container-configs.js';
import { configFromDb, materializeContainerJson } from './container-config.js';
const GID = 'ag-idle';
function now(): string {
return new Date().toISOString();
}
describe('container config idle_timeout_ms threading', () => {
beforeEach(() => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
fs.mkdirSync(GROUPS_DIR, { recursive: true });
const db = initTestDb();
runMigrations(db);
createAgentGroup({ id: GID, name: 'idle-group', folder: 'idle-group', agent_provider: null, created_at: now() });
ensureContainerConfig(GID);
});
afterEach(() => {
closeDb();
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
});
it('updateContainerConfigScalars persists idle_timeout_ms and configFromDb threads it', () => {
updateContainerConfigScalars(GID, { idle_timeout_ms: 300000 });
const row = getContainerConfig(GID)!;
expect(row.idle_timeout_ms).toBe(300000);
const config = configFromDb(row, getAgentGroup(GID)!);
expect(config.idleTimeoutMs).toBe(300000);
});
it('materializeContainerJson writes idleTimeoutMs into container.json', () => {
updateContainerConfigScalars(GID, { idle_timeout_ms: 300000 });
const config = materializeContainerJson(GID);
expect(config.idleTimeoutMs).toBe(300000);
const written = JSON.parse(fs.readFileSync(path.join(GROUPS_DIR, 'idle-group', 'container.json'), 'utf8'));
expect(written.idleTimeoutMs).toBe(300000);
});
it('NULL column (the default) keeps idleTimeoutMs out of container.json — feature off', () => {
const row = getContainerConfig(GID)!;
expect(row.idle_timeout_ms).toBeNull();
const config = configFromDb(row, getAgentGroup(GID)!);
expect(config.idleTimeoutMs).toBeUndefined();
materializeContainerJson(GID);
const written = JSON.parse(fs.readFileSync(path.join(GROUPS_DIR, 'idle-group', 'container.json'), 'utf8'));
// JSON.stringify drops undefined — the key must be absent, not null/0.
expect('idleTimeoutMs' in written).toBe(false);
});
});
-3
View File
@@ -41,8 +41,6 @@ export interface ContainerConfig {
assistantName?: string;
agentGroupId?: string;
maxMessagesPerPrompt?: number;
/** Idle window in ms after which an idle container exits cleanly. Unset/0 = disabled. */
idleTimeoutMs?: number;
model?: string;
effort?: string;
}
@@ -63,7 +61,6 @@ export function configFromDb(row: ContainerConfigRow, group: AgentGroup): Contai
assistantName: row.assistant_name ?? group.name,
agentGroupId: group.id,
maxMessagesPerPrompt: row.max_messages_per_prompt ?? undefined,
idleTimeoutMs: row.idle_timeout_ms ?? undefined,
model: row.model ?? undefined,
effort: row.effort ?? undefined,
};
+21
View File
@@ -1,3 +1,5 @@
import fs from 'fs';
import path from 'path';
import { describe, expect, it } from 'vitest';
import { resolveProviderName } from './container-runner.js';
@@ -25,3 +27,22 @@ describe('resolveProviderName', () => {
expect(resolveProviderName(null, '')).toBe('claude');
});
});
describe('buildContainerArgs ordering invariant (structural)', () => {
// The OneCLI gateway apply (SDK applyContainerConfig) appends credential-stub
// mounts — e.g. the codex auth.json sentinel nested INSIDE our RW
// /home/node/.codex mount. Docker applies binds in argument order, so the
// stub must land AFTER its parent mount or the parent shadows it and the
// agent silently degrades to loginless auth. Driving the real
// buildContainerArgs needs a live gateway + container runtime, so this
// guards the invariant structurally: the gateway apply must appear after
// the volume-mounts loop in the source.
it('applies the OneCLI gateway after the volume mounts', () => {
const src = fs.readFileSync(path.join(process.cwd(), 'src', 'container-runner.ts'), 'utf-8');
const mountsLoop = src.indexOf('for (const mount of mounts)');
const gatewayApply = src.indexOf('onecli.applyContainerConfig');
expect(mountsLoop).toBeGreaterThan(-1);
expect(gatewayApply).toBeGreaterThan(-1);
expect(gatewayApply).toBeGreaterThan(mountsLoop);
});
});
+67 -48
View File
@@ -36,6 +36,7 @@ import { validateAdditionalMounts } from './modules/mount-security/index.js';
import './providers/index.js';
import {
getProviderContainerConfig,
providerProvidesAgentSurfaces,
type ProviderContainerContribution,
type VolumeMount,
} from './providers/provider-container-registry.js';
@@ -127,12 +128,19 @@ async function spawnContainer(session: Session): Promise<void> {
// and buildContainerArgs so we don't re-read.
const containerConfig = materializeContainerJson(agentGroup.id);
// Per-group filesystem state lives forever after first creation. Init is
// idempotent: it only writes paths that don't already exist, so this call
// is a no-op for groups that have spawned before. Runs before the provider
// contribution so a surfaces-providing provider finds the group dir ready.
const providerName = resolveProviderName(session.agent_provider, containerConfig.provider);
initGroupFilesystem(agentGroup, { provider: providerName });
// Resolve the effective provider + any host-side contribution it declares
// (extra mounts, env passthrough). Computed once and threaded through both
// buildMounts and buildContainerArgs so side effects (mkdir, etc.) fire once.
const { provider, contribution } = resolveProviderContribution(session, agentGroup, containerConfig);
const mounts = buildMounts(agentGroup, session, containerConfig, contribution);
const mounts = buildMounts(agentGroup, session, containerConfig, provider, contribution);
const containerName = `nanoclaw-v2-${agentGroup.folder}-${Date.now()}`;
// OneCLI agent identifier is always the agent group id — stable across
// sessions and reversible via getAgentGroup() for approval routing.
@@ -234,32 +242,37 @@ function resolveProviderContribution(
? fn({
sessionDir: sessionDir(agentGroup.id, session.id),
agentGroupId: agentGroup.id,
groupDir: path.resolve(GROUPS_DIR, agentGroup.folder),
selectedSkills: selectedSkillNames(containerConfig),
hostEnv: process.env,
})
: {};
return { provider, contribution };
}
function buildMounts(
export function buildMounts(
agentGroup: AgentGroup,
session: Session,
containerConfig: import('./container-config.js').ContainerConfig,
provider: string,
providerContribution: ProviderContainerContribution,
): VolumeMount[] {
const projectRoot = process.cwd();
// Per-group filesystem state lives forever after first creation. Init is
// idempotent: it only writes paths that don't already exist, so this call
// is a no-op for groups that have spawned before.
initGroupFilesystem(agentGroup);
// Default agent surfaces (composed project doc, skill links, provider state
// dir) apply unless the provider's registration declares it provides its
// own — a capability, never a provider name. See provider-container-registry.
const defaultSurfaces = !providerProvidesAgentSurfaces(provider);
// Sync skill symlinks based on container.json selection before mounting.
const claudeDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, '.claude-shared');
syncSkillSymlinks(claudeDir, containerConfig);
if (defaultSurfaces) {
// Sync skill symlinks based on container.json selection before mounting.
syncSkillSymlinks(claudeDir, containerConfig);
// Compose CLAUDE.md fresh every spawn from the shared base, enabled skill
// fragments, and MCP server instructions. See `claude-md-compose.ts`.
composeGroupClaudeMd(agentGroup);
// Compose CLAUDE.md fresh every spawn from the shared base, enabled skill
// fragments, and MCP server instructions. See `claude-md-compose.ts`.
composeGroupClaudeMd(agentGroup);
}
const mounts: VolumeMount[] = [];
const sessDir = sessionDir(agentGroup.id, session.id);
@@ -286,11 +299,11 @@ function buildMounts(
// already RO-mounted, so writes through it fail regardless — no need for
// a nested mount there.
const composedClaudeMd = path.join(groupDir, 'CLAUDE.md');
if (fs.existsSync(composedClaudeMd)) {
if (defaultSurfaces && fs.existsSync(composedClaudeMd)) {
mounts.push({ hostPath: composedClaudeMd, containerPath: '/workspace/agent/CLAUDE.md', readonly: true });
}
const fragmentsDir = path.join(groupDir, '.claude-fragments');
if (fs.existsSync(fragmentsDir)) {
if (defaultSurfaces && fs.existsSync(fragmentsDir)) {
mounts.push({ hostPath: fragmentsDir, containerPath: '/workspace/agent/.claude-fragments', readonly: true });
}
@@ -303,13 +316,15 @@ function buildMounts(
// Shared CLAUDE.md — read-only, imported by the composed entry point via
// the `.claude-shared.md` symlink inside the group dir.
const sharedClaudeMd = path.join(process.cwd(), 'container', 'CLAUDE.md');
if (fs.existsSync(sharedClaudeMd)) {
if (defaultSurfaces && fs.existsSync(sharedClaudeMd)) {
mounts.push({ hostPath: sharedClaudeMd, containerPath: '/app/CLAUDE.md', readonly: true });
}
// Per-group .claude-shared at /home/node/.claude (Claude state, settings,
// skill symlinks)
mounts.push({ hostPath: claudeDir, containerPath: '/home/node/.claude', readonly: false });
if (defaultSurfaces) {
mounts.push({ hostPath: claudeDir, containerPath: '/home/node/.claude', readonly: false });
}
// Shared agent-runner source — read-only, same code for all groups.
const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src');
@@ -346,25 +361,7 @@ function syncSkillSymlinks(claudeDir: string, containerConfig: import('./contain
fs.mkdirSync(skillsDir, { recursive: true });
}
// Determine desired skill set
const projectRoot = process.cwd();
const sharedSkillsDir = path.join(projectRoot, 'container', 'skills');
let desired: string[];
if (containerConfig.skills === 'all') {
// Recompute from shared dir — newly-added upstream skills appear automatically
desired = fs.existsSync(sharedSkillsDir)
? fs.readdirSync(sharedSkillsDir).filter((e) => {
try {
return fs.statSync(path.join(sharedSkillsDir, e)).isDirectory();
} catch {
return false;
}
})
: [];
} else {
desired = containerConfig.skills;
}
const desired = selectedSkillNames(containerConfig);
const desiredSet = new Set(desired);
// Remove symlinks not in the desired set
@@ -397,6 +394,24 @@ function syncSkillSymlinks(claudeDir: string, containerConfig: import('./contain
}
}
/**
* Resolve the group's skill selection to concrete names — `'all'` recomputes
* from `container/skills/` so newly-added upstream skills appear automatically.
*/
function selectedSkillNames(containerConfig: import('./container-config.js').ContainerConfig): string[] {
if (containerConfig.skills !== 'all') return containerConfig.skills;
const sharedSkillsDir = path.join(process.cwd(), 'container', 'skills');
return fs.existsSync(sharedSkillsDir)
? fs.readdirSync(sharedSkillsDir).filter((e) => {
try {
return fs.statSync(path.join(sharedSkillsDir, e)).isDirectory();
} catch {
return false;
}
})
: [];
}
async function buildContainerArgs(
mounts: VolumeMount[],
containerName: string,
@@ -419,20 +434,6 @@ async function buildContainerArgs(
}
}
// OneCLI gateway — injects HTTPS_PROXY + certs so container API calls
// are routed through the agent vault for credential injection. Treated as
// a transient hard failure: if we can't wire the gateway, we don't spawn.
// The caller (router or host-sweep) catches the throw, leaves the inbound
// message pending, and the next sweep tick retries.
if (agentIdentifier) {
await onecli.ensureAgent({ name: agentGroup.name, identifier: agentIdentifier });
}
const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier });
if (!onecliApplied) {
throw new Error('OneCLI gateway not applied — refusing to spawn container without credentials');
}
log.info('OneCLI gateway applied', { containerName });
// Egress lockdown when enabled — throws if it can't be established, aborting
// the spawn rather than running with open egress. Otherwise the host gateway.
if (ensureEgressNetwork()) {
@@ -459,6 +460,24 @@ async function buildContainerArgs(
}
}
// OneCLI gateway — injects HTTPS_PROXY + certs so container API calls
// are routed through the agent vault for credential injection, and mounts
// any credential stubs the gateway serves (e.g. a sentinel auth file).
// Runs AFTER the volume mounts so a stub nested inside one of our mounts
// (a parent dir mounted RW above it) lands later in the args and isn't
// shadowed by it. Treated as a transient hard failure: if we can't wire
// the gateway, we don't spawn. The caller (router or host-sweep) catches
// the throw, leaves the inbound message pending, and the next sweep tick
// retries.
if (agentIdentifier) {
await onecli.ensureAgent({ name: agentGroup.name, identifier: agentIdentifier });
}
const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier });
if (!onecliApplied) {
throw new Error('OneCLI gateway not applied — refusing to spawn container without credentials');
}
log.info('OneCLI gateway applied', { containerName });
// Override entrypoint: run v2 entry point directly via Bun (no tsc, no stdin).
args.push('--entrypoint', 'bash');
+1 -9
View File
@@ -8,7 +8,6 @@ const SCALAR_COLUMNS = new Set([
'image_tag',
'assistant_name',
'max_messages_per_prompt',
'idle_timeout_ms',
'cli_scope',
]);
const JSON_COLUMNS = new Set(['skills', 'mcp_servers', 'packages_apt', 'packages_npm', 'additional_mounts']);
@@ -56,14 +55,7 @@ export function updateContainerConfigScalars(
updates: Partial<
Pick<
ContainerConfigRow,
| 'provider'
| 'model'
| 'effort'
| 'image_tag'
| 'assistant_name'
| 'max_messages_per_prompt'
| 'idle_timeout_ms'
| 'cli_scope'
'provider' | 'model' | 'effort' | 'image_tag' | 'assistant_name' | 'max_messages_per_prompt' | 'cli_scope'
>
>,
): void {
@@ -1,13 +0,0 @@
import type Database from 'better-sqlite3';
import type { Migration } from './index.js';
export const migration017: Migration = {
version: 17,
name: 'container-idle-timeout',
up(db: Database.Database) {
// Idle-exit window in ms for the agent container. NULL (the default) or 0
// disables idle exit — existing groups keep today's behavior, where an
// idle container rides until host-sweep's absolute ceiling kills it.
db.prepare('ALTER TABLE container_configs ADD COLUMN idle_timeout_ms INTEGER').run();
},
};
-2
View File
@@ -13,7 +13,6 @@ import { migration013 } from './013-approval-render-metadata.js';
import { migration014 } from './014-container-configs.js';
import { migration015 } from './015-cli-scope.js';
import { migration016 } from './016-messaging-group-instance.js';
import { migration017 } from './017-container-idle-timeout.js';
import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js';
import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js';
@@ -47,7 +46,6 @@ export const migrations: Migration[] = [
migration014,
migration015,
migration016,
migration017,
];
/** Row shape of PRAGMA foreign_key_check. Child rowids are stable across a
+32 -20
View File
@@ -4,6 +4,7 @@ import path from 'path';
import { DATA_DIR, GROUPS_DIR } from './config.js';
import { ensureContainerConfig } from './db/container-configs.js';
import { log } from './log.js';
import { providerProvidesAgentSurfaces } from './providers/provider-container-registry.js';
import type { AgentGroup } from './types.js';
const DEFAULT_SETTINGS_JSON =
@@ -46,9 +47,18 @@ const DEFAULT_SETTINGS_JSON =
* spawn by `composeGroupClaudeMd()` (see `claude-md-compose.ts`). Initial
* per-group instructions (if provided) seed `CLAUDE.local.md`.
*/
export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: string }): void {
export function initGroupFilesystem(
group: AgentGroup,
opts?: { instructions?: string; provider?: string | null },
): void {
const initialized: string[] = [];
// Default agent surfaces apply unless the group's provider declares (at
// registration) that it provides its own. Callers that don't know the
// provider omit it — unregistered/unknown names report no capabilities,
// so the default surfaces are written, exactly as before this seam.
const defaultSurfaces = !providerProvidesAgentSurfaces(opts?.provider);
// 1. groups/<folder>/ — group memory + working dir
const groupDir = path.resolve(GROUPS_DIR, group.folder);
if (!fs.existsSync(groupDir)) {
@@ -59,7 +69,7 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s
// groups/<folder>/CLAUDE.local.md — per-group agent memory, auto-loaded by
// Claude Code. Seeded with caller-provided instructions on first creation.
const claudeLocalFile = path.join(groupDir, 'CLAUDE.local.md');
if (!fs.existsSync(claudeLocalFile)) {
if (defaultSurfaces && !fs.existsSync(claudeLocalFile)) {
const body = opts?.instructions ? opts.instructions + '\n' : '';
fs.writeFileSync(claudeLocalFile, body);
initialized.push('CLAUDE.local.md');
@@ -71,26 +81,28 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s
initialized.push('container_configs');
// 2. data/v2-sessions/<id>/.claude-shared/ — Claude state + per-group skills
const claudeDir = path.join(DATA_DIR, 'v2-sessions', group.id, '.claude-shared');
if (!fs.existsSync(claudeDir)) {
fs.mkdirSync(claudeDir, { recursive: true });
initialized.push('.claude-shared');
}
if (defaultSurfaces) {
const claudeDir = path.join(DATA_DIR, 'v2-sessions', group.id, '.claude-shared');
if (!fs.existsSync(claudeDir)) {
fs.mkdirSync(claudeDir, { recursive: true });
initialized.push('.claude-shared');
}
const settingsFile = path.join(claudeDir, 'settings.json');
if (!fs.existsSync(settingsFile)) {
fs.writeFileSync(settingsFile, DEFAULT_SETTINGS_JSON);
initialized.push('settings.json');
} else {
ensurePreCompactHook(settingsFile, initialized);
}
const settingsFile = path.join(claudeDir, 'settings.json');
if (!fs.existsSync(settingsFile)) {
fs.writeFileSync(settingsFile, DEFAULT_SETTINGS_JSON);
initialized.push('settings.json');
} else {
ensurePreCompactHook(settingsFile, initialized);
}
// Skills directory — created empty here; symlinks are synced at spawn
// time by container-runner.ts based on container.json skills selection.
const skillsDst = path.join(claudeDir, 'skills');
if (!fs.existsSync(skillsDst)) {
fs.mkdirSync(skillsDst, { recursive: true });
initialized.push('skills/');
// Skills directory — created empty here; symlinks are synced at spawn
// time by container-runner.ts based on container.json skills selection.
const skillsDst = path.join(claudeDir, 'skills');
if (!fs.existsSync(skillsDst)) {
fs.mkdirSync(skillsDst, { recursive: true });
initialized.push('skills/');
}
}
if (initialized.length > 0) {
+143
View File
@@ -0,0 +1,143 @@
import fs from 'fs';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const TEST_ROOT = '/tmp/nanoclaw-provider-surfaces-test';
const GROUPS_DIR = path.join(TEST_ROOT, 'groups');
const DATA_DIR = path.join(TEST_ROOT, 'data');
vi.mock('./config.js', async (importOriginal) => ({
...(await importOriginal<typeof import('./config.js')>()),
DATA_DIR: '/tmp/nanoclaw-provider-surfaces-test/data',
GROUPS_DIR: '/tmp/nanoclaw-provider-surfaces-test/groups',
}));
vi.mock('./log.js', () => ({
log: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
fatal: vi.fn(),
},
}));
import { buildMounts } from './container-runner.js';
import { closeDb, createAgentGroup, initTestDb, runMigrations } from './db/index.js';
import { ensureContainerConfig } from './db/container-configs.js';
import { initGroupFilesystem } from './group-init.js';
import { registerProviderContainerConfig } from './providers/provider-container-registry.js';
import type { ContainerConfig } from './container-config.js';
import type { AgentGroup, Session } from './types.js';
// A provider that declares (at registration) that it owns its agent surfaces.
// Registered once — the registry is module-global and rejects duplicates.
registerProviderContainerConfig('surfaces-test-provider', () => ({}), { providesAgentSurfaces: true });
function group(id: string, folder: string): AgentGroup {
return { id, name: folder, folder, agent_provider: null, created_at: new Date().toISOString() } as AgentGroup;
}
function session(id: string, agentGroupId: string): Session {
return { id, agent_group_id: agentGroupId } as Session;
}
function containerConfig(): ContainerConfig {
return { mcpServers: {}, packages: { apt: [], npm: [] }, additionalMounts: [], skills: [] };
}
beforeEach(() => {
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
fs.mkdirSync(TEST_ROOT, { recursive: true });
runMigrations(initTestDb());
});
afterEach(() => {
closeDb();
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
});
describe('initGroupFilesystem agent surfaces', () => {
it('writes the default surfaces when no provider is given (todays behavior)', () => {
const ag = group('ag-default', 'default-group');
createAgentGroup(ag);
initGroupFilesystem(ag, { instructions: 'hello' });
const groupDir = path.join(GROUPS_DIR, ag.folder);
const claudeDir = path.join(DATA_DIR, 'v2-sessions', ag.id, '.claude-shared');
expect(fs.readFileSync(path.join(groupDir, 'CLAUDE.local.md'), 'utf-8')).toBe('hello\n');
expect(fs.existsSync(path.join(claudeDir, 'settings.json'))).toBe(true);
expect(fs.existsSync(path.join(claudeDir, 'skills'))).toBe(true);
});
it('skips the default surfaces for a provider that provides its own', () => {
const ag = group('ag-surfy', 'surfy-group');
createAgentGroup(ag);
initGroupFilesystem(ag, { instructions: 'hello', provider: 'surfaces-test-provider' });
const groupDir = path.join(GROUPS_DIR, ag.folder);
const sessionRoot = path.join(DATA_DIR, 'v2-sessions', ag.id);
expect(fs.existsSync(groupDir)).toBe(true);
expect(fs.existsSync(path.join(groupDir, 'CLAUDE.local.md'))).toBe(false);
expect(fs.existsSync(path.join(sessionRoot, '.claude-shared'))).toBe(false);
});
it('treats an unregistered provider name as default surfaces', () => {
const ag = group('ag-unknown', 'unknown-group');
createAgentGroup(ag);
initGroupFilesystem(ag, { provider: 'not-registered' });
expect(fs.existsSync(path.join(GROUPS_DIR, ag.folder, 'CLAUDE.local.md'))).toBe(true);
});
});
describe('buildMounts agent surfaces', () => {
it('mounts the default surfaces for an unregistered provider (todays behavior)', () => {
const ag = group('ag-mounts-default', 'mounts-default');
createAgentGroup(ag);
ensureContainerConfig(ag.id);
initGroupFilesystem(ag, {});
const mounts = buildMounts(ag, session('s1', ag.id), containerConfig(), 'claude', {});
const byContainerPath = new Map(mounts.map((m) => [m.containerPath, m]));
expect(byContainerPath.has('/home/node/.claude')).toBe(true);
expect(byContainerPath.has('/app/CLAUDE.md')).toBe(true);
expect(byContainerPath.has('/workspace/agent/CLAUDE.md')).toBe(true);
// Composer ran: the generated project doc exists on disk.
expect(fs.existsSync(path.join(GROUPS_DIR, ag.folder, 'CLAUDE.md'))).toBe(true);
});
it('suppresses the default surfaces and keeps contributed mounts for a surfaces-providing provider', () => {
const ag = group('ag-mounts-surfy', 'mounts-surfy');
createAgentGroup(ag);
ensureContainerConfig(ag.id);
initGroupFilesystem(ag, { provider: 'surfaces-test-provider' });
const contributed = {
mounts: [
{
hostPath: path.join(GROUPS_DIR, ag.folder),
containerPath: '/workspace/agent/OWN-DOC.md',
readonly: true,
},
],
};
const mounts = buildMounts(ag, session('s2', ag.id), containerConfig(), 'surfaces-test-provider', contributed);
const containerPaths = mounts.map((m) => m.containerPath);
expect(containerPaths).not.toContain('/home/node/.claude');
expect(containerPaths).not.toContain('/app/CLAUDE.md');
expect(containerPaths).not.toContain('/workspace/agent/CLAUDE.md');
// Composer did NOT run for this group.
expect(fs.existsSync(path.join(GROUPS_DIR, ag.folder, 'CLAUDE.md'))).toBe(false);
// Core mounts and the provider's own contribution are intact.
expect(containerPaths).toContain('/workspace');
expect(containerPaths).toContain('/workspace/agent');
expect(containerPaths).toContain('/app/src');
expect(containerPaths).toContain('/workspace/agent/OWN-DOC.md');
});
});
+54 -4
View File
@@ -27,6 +27,19 @@ export interface ProviderContainerContext {
sessionDir: string;
/** Agent group ID, for any per-group logic. */
agentGroupId: string;
/**
* Per-group host directory: `<GROUPS_DIR>/<folder>` (mounted RW at
* `/workspace/agent`). Exists by the time the config fn runs group
* filesystem init happens first. Surfaces-providing providers compose
* their project doc and skill links here.
*/
groupDir: string;
/**
* Skill names selected by the group's container config, with `'all'`
* already resolved against `container/skills/`. Surfaces-providing
* providers use this to sync their own skill-discovery links.
*/
selectedSkills: string[];
/** `process.env` at spawn time — pull passthrough values from here. */
hostEnv: NodeJS.ProcessEnv;
}
@@ -38,19 +51,56 @@ export interface ProviderContainerContribution {
env?: Record<string, string>;
}
/**
* Static capabilities a provider declares at registration time knowable
* without a spawn context, so any host path (group init, spawn, creation
* flows) can consult them by name.
*/
export interface ProviderHostCapabilities {
/**
* Optional. When true, this provider owns its agent-facing surfaces the
* composed project doc, skill-discovery links, and provider state dir
* and the host must NOT compose or mount the default ones (composed
* CLAUDE.md, `.claude-fragments`, `/app/CLAUDE.md`, `/home/node/.claude`,
* `CLAUDE.local.md` seeding). The provider's config fn does its own
* composing and returns its own mounts. Default off providers that omit
* this get the default surfaces, which is today's behavior.
*/
readonly providesAgentSurfaces?: boolean;
}
export type ProviderContainerConfigFn = (ctx: ProviderContainerContext) => ProviderContainerContribution;
const registry = new Map<string, ProviderContainerConfigFn>();
interface RegistryEntry {
fn: ProviderContainerConfigFn;
capabilities: ProviderHostCapabilities;
}
export function registerProviderContainerConfig(name: string, fn: ProviderContainerConfigFn): void {
const registry = new Map<string, RegistryEntry>();
export function registerProviderContainerConfig(
name: string,
fn: ProviderContainerConfigFn,
capabilities: ProviderHostCapabilities = {},
): void {
if (registry.has(name)) {
throw new Error(`Provider container config already registered: ${name}`);
}
registry.set(name, fn);
registry.set(name, { fn, capabilities });
}
export function getProviderContainerConfig(name: string): ProviderContainerConfigFn | undefined {
return registry.get(name);
return registry.get(name)?.fn;
}
/**
* Capability lookup by provider name. Unregistered providers (including the
* baked-in default) report no capabilities the host applies its default
* surfaces, exactly as before this seam existed.
*/
export function providerProvidesAgentSurfaces(name: string | null | undefined): boolean {
if (!name) return false;
return registry.get(name)?.capabilities.providesAgentSurfaces === true;
}
export function listProviderContainerConfigNames(): string[] {
-1
View File
@@ -19,7 +19,6 @@ export interface ContainerConfigRow {
image_tag: string | null;
assistant_name: string | null;
max_messages_per_prompt: number | null;
idle_timeout_ms: number | null; // idle-exit window (ms); NULL/0 = disabled
skills: string; // JSON: '"all"' | '["skill1","skill2"]'
mcp_servers: string; // JSON: Record<string, McpServerConfig>
packages_apt: string; // JSON: string[]
+97
View File
@@ -0,0 +1,97 @@
/**
* Guard for the raw-route half of src/webhook-server.ts
* registerWebhookHandler + the rawRoutes dispatch branch.
*
* Drives the REAL shared HTTP server on an ephemeral WEBHOOK_PORT (no
* mocking of the routing layer): a registered raw route must dispatch,
* unknown paths must 404, a throwing handler must surface as 500,
* raw routes must coexist with Chat SDK adapter routes on the same
* server, and stopWebhookServer must clear them.
*/
import { afterAll, describe, expect, it, vi } from 'vitest';
import type { Chat } from 'chat';
import { registerWebhookAdapter, registerWebhookHandler, stopWebhookServer } from './webhook-server.js';
const PORT = 21000 + Math.floor(Math.random() * 20000);
async function post(path: string, body = '{}'): Promise<globalThis.Response> {
for (let attempt = 0; ; attempt++) {
try {
return await fetch(`http://127.0.0.1:${PORT}/webhook/${path}`, { method: 'POST', body });
} catch (err) {
if (attempt >= 40) throw err;
await new Promise((r) => setTimeout(r, 50));
}
}
}
afterAll(async () => {
await stopWebhookServer();
delete process.env.WEBHOOK_PORT;
});
describe('webhook server raw routes', () => {
it('dispatches a registered raw route to its handler', async () => {
process.env.WEBHOOK_PORT = String(PORT);
const methods: string[] = [];
registerWebhookHandler('ping', (req, res) => {
methods.push(req.method || '');
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('pong');
});
const res = await post('ping');
expect(res.status).toBe(200);
expect(await res.text()).toBe('pong');
expect(methods).toEqual(['POST']);
});
it('returns 404 for paths with no registered route', async () => {
const res = await post('nope');
expect(res.status).toBe(404);
});
it('turns a throwing handler into a 500 response', async () => {
registerWebhookHandler('boom', () => {
throw new Error('handler exploded');
});
const res = await post('boom');
expect(res.status).toBe(500);
expect(await res.text()).toBe('Internal Server Error');
});
it('coexists with Chat SDK adapter routes on the same server', async () => {
const handler = vi.fn(async () => new Response('ok-chat', { status: 200 }));
const chat = { webhooks: { fake: handler } } as unknown as Chat;
registerWebhookAdapter(chat, 'fake');
const chatRes = await post('fake');
expect(chatRes.status).toBe(200);
expect(await chatRes.text()).toBe('ok-chat');
expect(handler).toHaveBeenCalledTimes(1);
// The raw route registered earlier is still live alongside it.
const rawRes = await post('ping');
expect(rawRes.status).toBe(200);
});
it('clears raw routes on stopWebhookServer', async () => {
await stopWebhookServer();
// Restart the server with a fresh route; the old raw routes must be gone.
registerWebhookHandler('fresh', (_req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('fresh');
});
const stale = await post('ping');
expect(stale.status).toBe(404);
const fresh = await post('fresh');
expect(fresh.status).toBe(200);
expect(await fresh.text()).toBe('fresh');
});
});
+43 -9
View File
@@ -3,9 +3,12 @@
*
* Starts lazily on first adapter registration. Routes requests by path:
* /webhook/{adapterName} chat.webhooks[adapterName](request)
* /webhook/{path} raw handler from registerWebhookHandler(path, ...)
*
* Multiple Chat instances can register adapters each adapter name maps
* to its owning Chat instance.
* to its owning Chat instance. Raw routes let modules receive non-Chat-SDK
* webhooks (GitHub, payment providers, health checks) on the same server
* without editing this file or opening a second port.
*/
import http from 'http';
@@ -20,7 +23,11 @@ interface WebhookEntry {
adapterName: string;
}
/** Node-style handler for raw (non-Chat-SDK) webhook routes. */
export type RawWebhookHandler = (req: http.IncomingMessage, res: http.ServerResponse) => void | Promise<void>;
const routes = new Map<string, WebhookEntry>();
const rawRoutes = new Map<string, RawWebhookHandler>();
let server: http.Server | null = null;
/** Convert Node.js IncomingMessage to a Web API Request. */
@@ -84,6 +91,22 @@ export function registerWebhookAdapter(chat: Chat, adapterName: string, routingP
log.info('Webhook adapter registered', { adapter: adapterName, path: `/webhook/${routingPath}` });
}
/**
* Register a raw Node-style handler at /webhook/{path} on the shared server.
*
* For webhooks that don't flow through a Chat SDK adapter (GitHub, payment
* providers, health checks): modules register their endpoint here instead of
* editing this file or standing up a second HTTP server on another port.
* The handler owns the request/response directly.
*
* Starts the server lazily on first call.
*/
export function registerWebhookHandler(path: string, handler: RawWebhookHandler): void {
rawRoutes.set(path, handler);
ensureServer();
log.info('Webhook handler registered', { path: `/webhook/${path}` });
}
function ensureServer(): void {
if (server) return;
@@ -101,14 +124,22 @@ function ensureServer(): void {
}
const adapterName = match[1];
const entry = routes.get(adapterName);
if (!entry) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end(`Unknown adapter: ${adapterName}`);
return;
}
try {
// Raw routes take priority — the handler writes the response itself.
const rawHandler = rawRoutes.get(adapterName);
if (rawHandler) {
await rawHandler(req, res);
return;
}
const entry = routes.get(adapterName);
if (!entry) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end(`Unknown adapter: ${adapterName}`);
return;
}
const webReq = await toWebRequest(req);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const webhooks = entry.chat.webhooks as Record<string, (r: Request, opts?: any) => Promise<Response>>;
@@ -121,8 +152,10 @@ function ensureServer(): void {
await fromWebResponse(webRes, res);
} catch (err) {
log.error('Webhook handler error', { adapter: adapterName, url: req.url, err });
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Internal Server Error');
if (!res.headersSent) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Internal Server Error');
}
}
});
@@ -137,6 +170,7 @@ export async function stopWebhookServer(): Promise<void> {
await new Promise<void>((resolve) => server!.close(() => resolve()));
server = null;
routes.clear();
rawRoutes.clear();
log.info('Webhook server stopped');
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"onecli-gateway": "1.36.0",
"onecli-cli": "2.2.5"
}