mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-12 18:11:51 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f9e89d345 | |||
| 36cbf17e10 | |||
| 4459ab2e54 | |||
| 9e6238d28f | |||
| f69af07c57 |
@@ -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
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
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).
|
||||
|
||||
## [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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.1.10",
|
||||
"version": "2.1.11",
|
||||
"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",
|
||||
|
||||
Generated
+5
-5
@@ -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': {}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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,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);
|
||||
});
|
||||
});
|
||||
|
||||
+18
-14
@@ -419,20 +419,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 +445,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');
|
||||
|
||||
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"onecli-gateway": "1.36.0",
|
||||
"onecli-cli": "2.2.5"
|
||||
}
|
||||
Reference in New Issue
Block a user