Compare commits

..

7 Commits

Author SHA1 Message Date
Koshkoshinsk 77f3be57fa Merge upstream/main (templates) into feat/global-provider-default
Resolution: main replaced the generic `groups create` with a custom
handler (--template branch), removing the generic-create path our
afterCreate hook attached to. Re-seat the instance-default stamp as a
direct ensureContainerConfig(group.id) call in the bare-row handler,
and drop the now-userless afterCreate hook from crud.ts. Template
creation already calls ensureContainerConfig and inherits the default
through the chokepoint unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:05:02 +03:00
Koshkoshinsk 35248f1bfa fix: persist DEFAULT_AGENT_PROVIDER only after provider install + auth succeed
A failed codex setup previously left DEFAULT_AGENT_PROVIDER=codex in .env,
defaulting every future group to an unauthenticated runtime. Also drop the
overstated "single writer" claim on upsertEnvVar — legacy setup steps still
write .env directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:47:27 +03:00
Daniel M 551660a2bf Merge branch 'main' into feat/global-provider-default 2026-07-01 17:03:42 +03:00
Koshkoshinsk a13bb24300 docs: align changelog + update-nanoclaw with instance-default provider
CHANGELOG: reword the unreleased provider entry to describe the
instance-wide default (DEFAULT_AGENT_PROVIDER) rather than stating no
install-wide default exists.

update-nanoclaw: drop Step 6.5 (the codex-default-offer during updates);
the default is set in /setup and by /add-codex, not on upgrade.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:55:00 +03:00
Koshkoshinsk ec35d9c3f7 docs: document the instance-wide default provider
- add-codex: offer to set DEFAULT_AGENT_PROVIDER=codex on install.
- update-nanoclaw: nudge to default new groups to codex when codex is installed
  but no default is set (detected via src/providers/codex.ts).
- manage-channels / init-first-agent: correct the stale 'no install-wide default'
  and dead --provider guidance.
- picked-provider: rewrite the header to reflect the persisted default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 01:19:16 +03:00
Koshkoshinsk 96f924f2ac feat: instance-wide default agent provider for new groups
Add DEFAULT_AGENT_PROVIDER (.env-backed, default 'claude'): newly created agent
groups are created on this provider; existing groups are never touched. Applied
at a single chokepoint — ensureContainerConfig stamps a fresh config row with it
(INSERT OR IGNORE, so existing rows stay frozen). Resolution is unchanged
(session -> container_configs.provider -> 'claude'); per-group
`ncl groups config update --provider` still overrides.

- config: DEFAULT_AGENT_PROVIDER constant.
- chokepoint: ensureContainerConfig defaults an absent provider to it,
  normalizing claude/casing to NULL/lowercase; every creation path
  (channel-approval, register, init scripts, ncl groups create afterCreate)
  inherits it without each having to remember.
- subagents (create_agent) inherit the parent's effective provider, not the
  global, so a child never spawns on a runtime the parent can't reach.
- setup persists the operator's pick to .env and pre-selects the current default
  in the picker so a re-run does not silently reset it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 01:19:16 +03:00
Koshkoshinsk 5945a19655 refactor: extract upsertEnvVar and add generic afterCreate CRUD hook
Structural prep for the instance-wide default provider, no behavior change:
- Extract the .env upsert from set-env's run() into a reusable upsertEnvVar()
  so setup code can persist a key without reinventing the grep/sed pipeline.
- Add an optional afterCreate hook to the CRUD ResourceDef + genericCreate so a
  resource can seed a dependent row the single-table INSERT can't cover.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 01:19:16 +03:00
20 changed files with 260 additions and 102 deletions
+16 -1
View File
@@ -135,7 +135,22 @@ ncl groups restart --id <group-id>
Switching is an operator action — run it from the host. Memory does NOT carry over automatically — each provider keeps its own store; run `/migrate-memory` to carry it across. See [docs/provider-migration.md](../../docs/provider-migration.md) for the carry-over table and rollback.
There is no install-wide default provider. Setup's provider picker sets codex on the first agent it creates; creation itself is provider-agnostic (no `--provider` flag — provider is a DB property). Any group switches afterward via `ncl groups config update --provider` as above.
### Default new groups to codex (optional)
New groups are created on the **instance default** (`DEFAULT_AGENT_PROVIDER` in `.env`, or `claude` when unset). Installing this skill wires codex in but does NOT change that default — "installed" is not "authenticated", so the default stays claude until you opt in explicitly.
After install, ask the operator before flipping it:
> "Codex is installed. Default new agent groups to codex? Existing groups keep their current provider."
On yes — set it, then restart the host so it takes effect:
```bash
pnpm exec tsx setup/index.ts --step set-env -- --key DEFAULT_AGENT_PROVIDER --value codex
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS; Linux: systemctl --user restart nanoclaw
```
This affects only groups created afterward. Per-group `ncl groups config update --provider` still overrides the default in either direction. Creation itself stays provider-agnostic (no `--provider` flag — provider is a DB property stamped from the instance default at creation).
## Troubleshooting
+1 -1
View File
@@ -82,7 +82,7 @@ npx tsx scripts/init-first-agent.ts \
--agent-name "${AGENT_NAME}"
```
Add `--provider <name>` when the user picked a non-default provider (there is no install-wide default — the choice is explicit per group). Add `--welcome "System instruction: ..."` to override the default welcome prompt.
The new group is created on the instance default provider (`DEFAULT_AGENT_PROVIDER` in `.env`, or `claude` when unset). To put it on a different provider, switch after creation with `ncl groups config update --id <group-id> --provider <name>`. Add `--welcome "System instruction: ..."` to override the default welcome prompt.
The script:
1. Upserts the `users` row and grants `owner` role if no owner exists.
+1 -1
View File
@@ -67,7 +67,7 @@ pnpm exec tsx setup/index.ts --step register -- \
The `register` step creates the agent group (reusing it if the folder already exists), the messaging group, and the wiring row. `createMessagingGroupAgent` auto-creates the companion `agent_destinations` row so the agent can address the channel by name.
When creating a NEW agent group on a non-default provider, append `--provider <name>` (e.g. `--provider codex`) — there is no install-wide default; existing groups switch via `ncl groups config update --provider` instead.
New agent groups are created on the instance default provider (`DEFAULT_AGENT_PROVIDER` in `.env`, or `claude` when unset). To run a group on a different provider, switch it after creation with `ncl groups config update --provider <name>` (e.g. `codex`).
For separate agents, also ask for a folder name and optionally a different assistant name.
+2 -2
View File
@@ -233,7 +233,7 @@ Parse the diff output for lines that contain `[BREAKING]` anywhere in the line.
```
If no `[BREAKING]` lines are found:
- Skip this step silently. Proceed to Step 7 (skill updates check).
- Skip this step silently. Proceed to Step 7.
If one or more `[BREAKING]` lines are found:
- Display a warning header to the user: "This update includes breaking changes that may require action:"
@@ -244,7 +244,7 @@ If one or more `[BREAKING]` lines are found:
- "Skip — I'll handle these manually"
- Set `multiSelect: true` so the user can pick multiple skills 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).
- After all selected skills complete (or if user chose Skip), proceed to Step 7.
# Step 7: Skill updates (part of updating NanoClaw)
+1 -1
View File
@@ -10,7 +10,7 @@ All notable changes to NanoClaw will be documented in this file.
- [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 gateway is a separate component — updating NanoClaw does not upgrade it for you:** `/update-nanoclaw` upgrades it when the pin moves, otherwise upgrade manually. **Migration:** [docs/onecli-upgrades.md](docs/onecli-upgrades.md).
- **New agent provider: Codex (OpenAI) — run `/add-codex`.** Full runtime via `codex app-server` (planning, MCP tools, server-side history, resume). Trunk ships the seams and the skill; the payload installs from the `providers` branch (the skill, the setup picker, or `--step provider-auth codex`). Auth is vault-only — no credential ever enters a container.
- **Setup can now select, install, and authenticate a non-default agent provider.** A provider registry feeds the setup picker, an installer pulls the provider's payload from its branch, a vault auth walkthrough runs (`--step provider-auth`), and the picked provider is set on the first agent (a DB property) before its first spawn. Default (Claude) installs are unaffected — picking Claude changes nothing.
- **Provider choice is explicit per group — no install-wide default.** Provider is a DB property set via `ncl groups config update --provider` + restart; creation is provider-agnostic.
- **New groups inherit an instance-wide default provider.** `DEFAULT_AGENT_PROVIDER` in `.env` (default `claude`) sets which provider newly created agent groups get at creation; provider stays a per-group DB property, overridable via `ncl groups config update --provider` + restart. Existing groups are untouched — no migration, no retroactive flips.
- **Memory migrates via `/migrate-memory`, never at runtime.** Each provider keeps its own store; fresh groups on a surfaces-owning provider see no stale `CLAUDE.*` files. See [docs/provider-migration.md](docs/provider-migration.md).
- **Per-exchange archiving is provider-owned** — the `onExchangeComplete` hook; the markdown writer ships with the codex payload.
- **Container boot failures now say why** — the last stderr lines are logged at `warn` on a non-zero exit instead of a silent crash loop.
+4 -5
View File
@@ -21,7 +21,6 @@ import path from 'path';
import { DATA_DIR } from '../src/config.js';
import { createAgentGroup, getAgentGroupByFolder } from '../src/db/agent-groups.js';
import { updateContainerConfigScalars } from '../src/db/container-configs.js';
import { initDb } from '../src/db/connection.js';
import {
createMessagingGroup,
@@ -124,11 +123,11 @@ async function main(): Promise<void> {
`# ${args.agentName}\n\n` +
`You are ${args.agentName}, a personal NanoClaw agent for ${args.displayName}. ` +
'When the user first reaches out, introduce yourself briefly and invite them to chat. Keep replies concise.',
// The operator's setup pick (NANOCLAW_PICKED_PROVIDER) when set; otherwise
// undefined, so initGroupFilesystem falls back to the instance default and
// stamps it onto the fresh config row.
provider: pickedProvider,
});
// Runtime provider lives on the config row, not the deprecated agent_provider.
if (pickedProvider && pickedProvider !== 'claude') {
updateContainerConfigScalars(ag.id, { provider: pickedProvider });
}
// 3. CLI messaging group + wiring.
let cliMg: MessagingGroup | undefined = getMessagingGroupByPlatform(CLI_CHANNEL, CLI_PLATFORM_ID);
+7 -9
View File
@@ -205,15 +205,13 @@ async function main(): Promise<void> {
} else {
console.log(`Reusing agent group: ${ag.id} (${folder})`);
}
// Ensure the config row exists; defer workspace scaffolding to the first
// spawn (group-init), where the DB-resolved provider decides the surface
// (Claude: CLAUDE.local.md; a surfaces-owning provider: the memory scaffold)
// — so a non-Claude group never gets stale CLAUDE.* files written here.
ensureContainerConfig(ag.id);
// Runtime provider lives on the config row, not the deprecated agent_provider.
if (pickedProvider && pickedProvider !== 'claude') {
updateContainerConfigScalars(ag.id, { provider: pickedProvider });
}
// Seed the config row, stamped with the effective provider: the operator's
// setup pick (NANOCLAW_PICKED_PROVIDER) when this runs inside a setup run,
// otherwise the persisted instance default. Workspace scaffolding is deferred
// to the first spawn (group-init), where the DB-resolved provider decides the
// surface (Claude: CLAUDE.local.md; a surfaces-owning provider: the memory
// scaffold). A reused group keeps its provider (INSERT OR IGNORE).
ensureContainerConfig(ag.id, pickedProvider);
const groupDir = path.resolve(GROUPS_DIR, folder);
fs.mkdirSync(groupDir, { recursive: true });
fs.writeFileSync(
+16 -4
View File
@@ -46,6 +46,7 @@ import './providers/index.js';
import { brightSelect } from './lib/bright-select.js';
import { offerClaudeOnFailure } from './lib/claude-handoff.js';
import { setPickedProvider } from './lib/picked-provider.js';
import { upsertEnvVar } from './set-env.js';
import {
applyToEnv,
parseFlags,
@@ -65,6 +66,7 @@ import { ensureAnswer, fail, runQuietChild, runQuietStep, spawnQuiet } from './l
import { emit as phEmit } from './lib/diagnostics.js';
import { accentGreen, brandBody, brandBold, brandChip, dimWrap, fitToWidth, fmtDuration, note, wrapForGutter } from './lib/theme.js';
import { isValidTimezone } from '../src/timezone.js';
import { DEFAULT_AGENT_PROVIDER } from '../src/config.js';
const CLI_AGENT_NAME = 'Terminal Agent';
const RUN_START = Date.now();
@@ -375,6 +377,12 @@ async function main(): Promise<void> {
} else {
await runAuthStep();
}
// Persist the pick as the instance-wide default so every future group
// (channel-approved, ncl-created) is created on this provider. Read from
// .env at host start; per-group `ncl groups config update --provider` wins.
// Only after install + auth succeeded — a failed setup must not leave new
// groups defaulting to an unauthenticated runtime.
upsertEnvVar('DEFAULT_AGENT_PROVIDER', agentProvider);
}
if (!skip.has('mounts')) {
@@ -827,14 +835,18 @@ async function askAgentProviderChoice(): Promise<string> {
phEmit('agent_provider_chosen', { provider: preset, preset: true });
return preset;
}
// The pick installs and authenticates a runtime — it is not an
// install-wide default, so re-runs safely Enter-through on claude (its
// auth flow short-circuits when the secret already exists).
// The pick is persisted as the instance default (DEFAULT_AGENT_PROVIDER), so
// pre-select the current default — a re-run Enter-through then preserves it
// instead of silently resetting it to claude. Fall back to claude if the
// persisted default isn't an offered option (e.g. its provider was removed).
const currentDefault = options.some((o) => o.value === DEFAULT_AGENT_PROVIDER)
? DEFAULT_AGENT_PROVIDER
: 'claude';
const choice = ensureAnswer(
await brightSelect<string>({
message: 'Which agent runtime should power your assistant?',
options,
initialValue: 'claude',
initialValue: currentDefault,
}),
) as string;
setupLog.userInput('agent_provider', choice);
+12 -11
View File
@@ -1,16 +1,17 @@
/**
* The agent runtime the operator picked in THIS setup run.
* The agent runtime the operator picked in THIS setup run, carried to the
* group-creation child processes over the process boundary.
*
* There is no install-wide default provider and no `--provider` in the
* creation contract provider is a DB property of a group. Setup is the one
* orchestrator that knows the operator's pick, so it stashes it here (set once
* at the auth step). The group-creation scripts (`init-first-agent`,
* `init-cli-agent`) run as **child processes**, so the pick is carried over the
* process boundary via an environment variable they inherit; they apply it to
* the group at creation, before the welcome wakes the container. This is the
* only place the value lives a setup-run-scoped global, NOT a persisted
* install default. `undefined` / `'claude'` means the built-in default and no
* provider write at all.
* There is no `--provider` flag in the creation contract provider is a DB
* property of a group. Setup persists the pick two ways: as the install-wide
* default (`DEFAULT_AGENT_PROVIDER` in `.env`, see src/config.ts), which every
* future group inherits at creation via the `ensureContainerConfig` chokepoint;
* and here, in a setup-run-scoped env var, so the FIRST agent created in the
* same run (by `init-first-agent` / `init-cli-agent`, which run as child
* processes) is stamped with the pick before the welcome wakes the container
* without waiting for the host to restart and reload `.env`. `undefined` /
* `'claude'` means no run-scoped pick; the creation scripts then fall back to
* the install-wide default.
*/
const ENV_KEY = 'NANOCLAW_PICKED_PROVIDER';
+10 -3
View File
@@ -55,12 +55,19 @@ describe('setup carries the picked provider to creation via a setup-run env var'
// The creation scripts run as child processes, inherit the env var, and apply
// it to the group's runtime config — container_configs.provider, the source of
// truth materialized into container.json (agent_provider is deprecated) — before
// the welcome wakes the container. No `--provider` flag in the contract (above).
for (const file of ['scripts/init-first-agent.ts', 'scripts/init-cli-agent.ts']) {
// the welcome wakes the container, falling back to the instance default
// (DEFAULT_AGENT_PROVIDER) when the env var is unset. No `--provider` flag in
// the contract (above). init-first-agent stamps directly via
// ensureContainerConfig; init-cli-agent threads it through initGroupFilesystem.
const applyPattern: Record<string, RegExp> = {
'scripts/init-first-agent.ts': /ensureContainerConfig\([^)]*pickedProvider/,
'scripts/init-cli-agent.ts': /provider:\s*pickedProvider/,
};
for (const [file, pattern] of Object.entries(applyPattern)) {
it(`${file} applies the env-carried provider to container_configs.provider`, () => {
const src = read(file);
expect(src).toContain('NANOCLAW_PICKED_PROVIDER');
expect(src).toMatch(/updateContainerConfigScalars\([^)]*provider:\s*pickedProvider/);
expect(src).toMatch(pattern);
});
}
});
+6 -5
View File
@@ -126,11 +126,12 @@ export async function run(args: string[]): Promise<void> {
const db = initDb(dbPath);
runMigrations(db);
// 1. Create or find agent group. Provider-agnostic: provider is a DB
// property set via `ncl groups config update --provider`, not a creation
// flag. The workspace is scaffolded at the first spawn (group-init), where
// the DB-resolved provider is known; here we only ensure the config row
// exists so that update has a row to write.
// 1. Create or find agent group. The workspace is scaffolded at the first
// spawn (group-init), where the DB-resolved provider is known; here we only
// seed the config row — stamped with the instance default so a newly wired
// channel group is created on the operator's chosen provider (per-group
// `ncl groups config update --provider` still overrides). A reused group
// keeps its existing provider (INSERT OR IGNORE).
let agentGroup = getAgentGroupByFolder(parsed.folder);
if (!agentGroup) {
const agId = generateId('ag');
+31 -25
View File
@@ -18,6 +18,34 @@ import path from 'path';
import { log } from '../src/log.js';
import { emitStatus } from './status.js';
/**
* Upsert a `KEY=VALUE` line into the project's `.env`, returning whether the
* key already existed. The canonical writer for new `.env` edits (legacy setup
* steps still write directly) so flows don't invent grep/sed pipelines (which
* can't be allowlisted tightly).
*/
export function upsertEnvVar(key: string, value: string): { existed: boolean } {
if (!/^[A-Z][A-Z0-9_]*$/.test(key)) {
throw new Error(`Invalid env key: ${key} (must be UPPER_SNAKE_CASE)`);
}
const envFile = path.join(process.cwd(), '.env');
let content = '';
if (fs.existsSync(envFile)) {
content = fs.readFileSync(envFile, 'utf-8');
}
const lineRegex = new RegExp(`^${key}=.*$`, 'm');
const existed = lineRegex.test(content);
const newLine = `${key}=${value}`;
if (existed) {
content = content.replace(lineRegex, newLine);
} else {
const sep = content && !content.endsWith('\n') ? '\n' : '';
content = content + sep + newLine + '\n';
}
fs.writeFileSync(envFile, content);
return { existed };
}
export async function run(args: string[]): Promise<void> {
const keyIdx = args.indexOf('--key');
const valueIdx = args.indexOf('--value');
@@ -33,37 +61,15 @@ export async function run(args: string[]): Promise<void> {
const key = args[keyIdx + 1];
const value = args[valueIdx + 1];
if (!/^[A-Z][A-Z0-9_]*$/.test(key)) {
throw new Error(`Invalid env key: ${key} (must be UPPER_SNAKE_CASE)`);
}
const projectRoot = process.cwd();
const envFile = path.join(projectRoot, '.env');
let content = '';
if (fs.existsSync(envFile)) {
content = fs.readFileSync(envFile, 'utf-8');
}
const lineRegex = new RegExp(`^${key}=.*$`, 'm');
const newLine = `${key}=${value}`;
const existed = lineRegex.test(content);
if (existed) {
content = content.replace(lineRegex, newLine);
} else {
const sep = content && !content.endsWith('\n') ? '\n' : '';
content = content + sep + newLine + '\n';
}
fs.writeFileSync(envFile, content);
const { existed } = upsertEnvVar(key, value);
log.info('Updated .env', { key, existed });
let synced = false;
if (syncContainer) {
const projectRoot = process.cwd();
const dataEnvDir = path.join(projectRoot, 'data', 'env');
fs.mkdirSync(dataEnvDir, { recursive: true });
fs.copyFileSync(envFile, path.join(dataEnvDir, 'env'));
fs.copyFileSync(path.join(projectRoot, '.env'), path.join(dataEnvDir, 'env'));
synced = true;
log.info('Synced .env to container mount', { path: 'data/env/env' });
}
+6
View File
@@ -8,6 +8,7 @@ import { getDb, hasTable } from '../../db/connection.js';
import { getSession } from '../../db/sessions.js';
import { writeSessionMessage } from '../../session-manager.js';
import {
ensureContainerConfig,
getContainerConfig,
updateContainerConfigScalars,
updateContainerConfigJson,
@@ -90,6 +91,11 @@ registerResource({
created_at: new Date().toISOString(),
};
createAgentGroup(group);
// Seed the config row now so the group is created on the instance
// default (ensureContainerConfig stamps it) and is spawnable without
// waiting for the startup backfill. Per-group overrides via
// `groups config update --provider` still win.
ensureContainerConfig(group.id);
return group;
},
},
+19 -1
View File
@@ -6,9 +6,27 @@ import { getContainerImageBase, getDefaultContainerImage, getInstallSlug } from
import { isValidTimezone } from './timezone.js';
// Read config values from .env (falls back to process.env).
const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER', 'ONECLI_URL', 'ONECLI_API_KEY', 'TZ']);
const envConfig = readEnvFile([
'ASSISTANT_NAME',
'ASSISTANT_HAS_OWN_NUMBER',
'ONECLI_URL',
'ONECLI_API_KEY',
'TZ',
'DEFAULT_AGENT_PROVIDER',
]);
export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
// Instance-wide default agent provider for newly created groups. `claude` (the
// built-in provider) when unset, so existing installs are unaffected on upgrade.
// Applied only at group-creation time (stamped onto the config row) — never in
// provider resolution — so existing groups are never retroactively flipped.
// Per-group `ncl groups config update --provider` still overrides it.
export const DEFAULT_AGENT_PROVIDER = (
process.env.DEFAULT_AGENT_PROVIDER ||
envConfig.DEFAULT_AGENT_PROVIDER ||
'claude'
).toLowerCase();
export const ASSISTANT_HAS_OWN_NUMBER =
(process.env.ASSISTANT_HAS_OWN_NUMBER || envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true';
+59
View File
@@ -0,0 +1,59 @@
/**
* ensureContainerConfig provider stamping (global-default-provider feature).
*
* Two load-bearing guarantees:
* 1. A fresh row is stamped with the given provider (claude NULL), so a new
* group is created on the instance default.
* 2. An existing row is never overwritten (INSERT OR IGNORE), so enabling a
* non-claude default never retroactively flips existing groups.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { initTestDb, closeDb } from './connection.js';
import { runMigrations } from './migrations/index.js';
import { createAgentGroup } from './agent-groups.js';
import { ensureContainerConfig, getContainerConfig } from './container-configs.js';
function makeGroup(id: string): void {
createAgentGroup({ id, name: id, folder: id, agent_provider: null, created_at: new Date().toISOString() });
}
describe('ensureContainerConfig provider stamping', () => {
beforeEach(() => {
const db = initTestDb();
runMigrations(db);
});
afterEach(() => {
closeDb();
});
it('stamps a non-default provider on a fresh row; claude is stored as NULL', () => {
makeGroup('ag-codex');
ensureContainerConfig('ag-codex', 'codex');
expect(getContainerConfig('ag-codex')?.provider).toBe('codex');
makeGroup('ag-claude');
ensureContainerConfig('ag-claude', 'claude');
expect(getContainerConfig('ag-claude')?.provider).toBeNull();
// Casing is normalized to match what resolution lowercases to.
makeGroup('ag-cased');
ensureContainerConfig('ag-cased', 'Codex');
expect(getContainerConfig('ag-cased')?.provider).toBe('codex');
makeGroup('ag-cased-claude');
ensureContainerConfig('ag-cased-claude', 'Claude');
expect(getContainerConfig('ag-cased-claude')?.provider).toBeNull();
});
it('never overwrites an existing row — existing groups are not flipped', () => {
makeGroup('ag-existing');
ensureContainerConfig('ag-existing', 'codex'); // existing group already on codex
expect(getContainerConfig('ag-existing')?.provider).toBe('codex');
// A later bare ensure (defensive re-init, or a changed instance default)
// must NOT change it — INSERT OR IGNORE keeps the row frozen.
ensureContainerConfig('ag-existing');
expect(getContainerConfig('ag-existing')?.provider).toBe('codex');
});
});
+28 -5
View File
@@ -1,3 +1,4 @@
import { DEFAULT_AGENT_PROVIDER } from '../config.js';
import type { ContainerConfigRow } from '../types.js';
import { getDb } from './connection.js';
@@ -39,14 +40,36 @@ export function createContainerConfig(config: ContainerConfigRow): void {
.run(config);
}
/** Create an empty config row with sensible defaults. Idempotent — no-ops if row exists. */
export function ensureContainerConfig(agentGroupId: string): void {
/**
* Create a config row if one doesn't exist, stamping the provider. Idempotent
* no-ops if the row already exists, so an existing group's provider is never
* overwritten (load-bearing: this is how the global default stays "new groups
* only" for groups that already have a row).
*
* An absent `provider` takes the instance default (`DEFAULT_AGENT_PROVIDER`);
* `claude` and an absent value that resolves to claude are stored as NULL the
* column means "follows the built-in default", matching pre-feature rows.
*/
export function ensureContainerConfig(agentGroupId: string, provider?: string | null): void {
// Single chokepoint for the instance default: a fresh row with no explicit
// provider is stamped with DEFAULT_AGENT_PROVIDER, so every new-group creation
// path inherits it without each having to remember. INSERT OR IGNORE keeps an
// EXISTING row untouched — so this stays "new groups only" for any group that
// already has a config row (backfillContainerConfigs seeds one for every group
// at host startup; a non-claude default would only reach a row-less *legacy*
// group if a creation script reused it before that first backfill ran). Callers
// that know the provider (subagent → parent's, spawn → resolved) pass it
// explicitly and override the default.
// `claude` (the built-in default) and casing normalize to NULL/lowercase so the
// column matches what resolution lowercases to.
const normalized = (provider ?? DEFAULT_AGENT_PROVIDER).toLowerCase();
const stamped = normalized && normalized !== 'claude' ? normalized : null;
getDb()
.prepare(
`INSERT OR IGNORE INTO container_configs (agent_group_id, updated_at)
VALUES (?, ?)`,
`INSERT OR IGNORE INTO container_configs (agent_group_id, provider, updated_at)
VALUES (?, ?, ?)`,
)
.run(agentGroupId, new Date().toISOString());
.run(agentGroupId, stamped, new Date().toISOString());
}
/** Update scalar fields on a config row. Only touches fields present in `updates`. */
+17 -8
View File
@@ -1,7 +1,7 @@
import fs from 'fs';
import path from 'path';
import { DATA_DIR, GROUPS_DIR } from './config.js';
import { DATA_DIR, DEFAULT_AGENT_PROVIDER, 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';
@@ -53,11 +53,18 @@ export function initGroupFilesystem(
): 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);
// `opts.provider` absent means "caller has no provider opinion" — for a
// brand-new group that resolves to the instance default, so the scaffold and
// the stamped config row both match it. A caller that knows the provider
// (subagent → parent's, spawn → resolved, setup → operator's pick) passes it
// explicitly — including `claude` — which pins the group and skips the
// default. ensureContainerConfig is INSERT OR IGNORE, so this only stamps a
// genuinely new group; existing rows are never touched.
const providerHint = (opts?.provider ?? DEFAULT_AGENT_PROVIDER).toLowerCase();
// Default agent surfaces apply unless the provider declares (at registration)
// that it provides its own.
const defaultSurfaces = !providerProvidesAgentSurfaces(providerHint);
// 1. groups/<folder>/ — group memory + working dir
const groupDir = path.resolve(GROUPS_DIR, group.folder);
@@ -106,8 +113,10 @@ export function initGroupFilesystem(
}
// Ensure container_configs row exists in the DB. Idempotent — no-op if
// the row already exists (e.g. created by backfill or group creation).
ensureContainerConfig(group.id);
// the row already exists (e.g. created by backfill or group creation). On a
// fresh row, stamp the resolved provider hint so a new group is created on
// the instance default (or the caller's explicit pick).
ensureContainerConfig(group.id, providerHint);
initialized.push('container_configs');
// 2. data/v2-sessions/<id>/.claude-shared/ — Claude state + per-group skills
@@ -16,7 +16,6 @@ const mockRequestApproval = vi.fn().mockResolvedValue(undefined);
const mockGetContainerConfig = vi.fn();
const mockCreateAgentGroup = vi.fn();
const mockInitGroupFilesystem = vi.fn();
const mockUpdateScalars = vi.fn();
const mockWriteDestinations = vi.fn();
const mockNotifyWrite = vi.fn();
@@ -26,7 +25,6 @@ vi.mock('../approvals/index.js', () => ({
vi.mock('../../db/container-configs.js', () => ({
getContainerConfig: (...a: unknown[]) => mockGetContainerConfig(...a),
ensureContainerConfig: () => {},
updateContainerConfigScalars: (...a: unknown[]) => mockUpdateScalars(...a),
}));
vi.mock('../../db/agent-groups.js', () => ({
getAgentGroup: (id: string) => ({ id, name: id.toUpperCase(), folder: id, agent_provider: null, created_at: '' }),
@@ -80,8 +78,10 @@ describe('handleCreateAgent — scope-based authorization', () => {
it('child inherits the creator provider (codex parent → codex child)', async () => {
// A subagent must run on the same authenticated runtime as its creator —
// on a codex-only install a claude default would 401. Red-on-delete:
// dropping the inheritance leaves the child provider-less (→ claude).
// on a codex-only install a claude default would 401. The provider is
// passed to initGroupFilesystem, which stamps the child's config row.
// Red-on-delete: dropping the inheritance lets the child fall through to the
// instance default instead of codex.
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global', provider: 'codex' });
await handleCreateAgent({ name: 'Scout', instructions: 'help' }, SESSION);
@@ -90,15 +90,19 @@ describe('handleCreateAgent — scope-based authorization', () => {
expect.anything(),
expect.objectContaining({ provider: 'codex' }),
);
expect(mockUpdateScalars).toHaveBeenCalledWith(expect.any(String), { provider: 'codex' });
});
it('claude creator leaves the child provider unset (built-in default)', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' }); // no provider
it('claude creator pins the child to claude, not the instance default', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' }); // parent has no explicit provider
await handleCreateAgent({ name: 'Scout', instructions: 'help' }, SESSION);
expect(mockUpdateScalars).not.toHaveBeenCalled();
// The child inherits the parent's EFFECTIVE provider (claude), passed
// explicitly so it never falls through to a non-claude instance default.
expect(mockInitGroupFilesystem).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ provider: 'claude' }),
);
});
it('group scope (default): requires approval, does NOT create directly', async () => {
+8 -10
View File
@@ -16,7 +16,7 @@ import path from 'path';
import { GROUPS_DIR } from '../../config.js';
import { createAgentGroup, getAgentGroup, getAgentGroupByFolder } from '../../db/agent-groups.js';
import { getContainerConfig, updateContainerConfigScalars } from '../../db/container-configs.js';
import { getContainerConfig } from '../../db/container-configs.js';
import { getSession } from '../../db/sessions.js';
import { wakeContainer } from '../../container-runner.js';
import { initGroupFilesystem } from '../../group-init.js';
@@ -163,17 +163,15 @@ async function performCreateAgent(
created_at: now,
};
createAgentGroup(newGroup);
// A subagent inherits its creator's provider. Provider is a DB property; the
// child is created provider-agnostic, then stamped with the parent's runtime
// so a single-provider install (e.g. codex-only, where claude isn't
// authenticated) doesn't spawn a child on a runtime it can't reach. The
// Subagent path: a child inherits its creator's EFFECTIVE provider, NOT the
// instance-wide default — so a child is never spawned on a runtime the parent
// can't reach (e.g. a codex-only install where claude isn't authenticated).
// Passing it explicitly to initGroupFilesystem pins the child's scaffold and
// stamps its config row in one step (a NULL parent resolves to claude). The
// operator can still flip a child later with `ncl groups config update
// --provider`. claude (the built-in default) leaves the column unset.
const parentProvider = getContainerConfig(sourceGroup.id)?.provider ?? undefined;
// --provider`.
const parentProvider = getContainerConfig(sourceGroup.id)?.provider ?? 'claude';
initGroupFilesystem(newGroup, { instructions: instructions ?? undefined, provider: parentProvider });
if (parentProvider) {
updateContainerConfigScalars(newGroup.id, { provider: parentProvider });
}
// Insert bidirectional destination rows (= ACL grants).
// Creator refers to child by the name it chose; child refers to creator as "parent".
+4 -2
View File
@@ -292,8 +292,10 @@ export function createNewAgentGroup(name: string): AgentGroup {
});
const ag = getAgentGroup(agId)!;
// Channel-approved groups get the built-in default provider (claude); the
// operator flips a group with `ncl groups config update --provider`.
// Channel-approved groups are created on the instance default provider
// (DEFAULT_AGENT_PROVIDER, or claude when unset) — initGroupFilesystem stamps
// it onto the fresh config row. The operator flips a group afterward with
// `ncl groups config update --provider`.
initGroupFilesystem(ag);
return ag;
}