Compare commits

..

6 Commits

Author SHA1 Message Date
gavrielc 8c84dec8e9 Merge remote-tracking branch 'origin/main' into feat/upgrade-tripwire
# Conflicts:
#	.claude/skills/migrate-nanoclaw/SKILL.md
2026-06-07 17:05:24 +03:00
gavrielc 092487d7ad chore: release 2.1.0; guard auto-bump against deliberate version changes
Set package.json to 2.1.0 to match the CHANGELOG entry for the upgrade
tripwire (a [BREAKING] change warrants a minor bump). The startup
tripwire reads package.json as the source of truth, so this is the
version the gate will enforce.

bump-version.yml previously ran `pnpm version patch` on every push to
main, which would patch a deliberate 2.1.0 up to 2.1.1. It now skips the
auto-bump when the pushed commits already changed package.json
themselves. fetch-depth: 0 so the before/after diff has both tips.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:03:02 +03:00
gavrielc 87850aa7f8 docs(changelog): release the upgrade-tripwire entry as 2.1.0
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:59:30 +03:00
gavrielc 526170fd47 feat(upgrade): add human-addressed guidance to tripwire banner
The startup tripwire message was written for a coding agent and gave a
human no direction — only the bare `set` override (which skips the
migrations the gate guards). Add one human-addressed stanza pointing to
/update-nanoclaw as the correct fix. The tested CODING AGENT block is
left byte-for-byte unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:57:13 +03:00
gavrielc 2d9375531b Merge pull request #2698 from nanocoai/feat/skill-exemplars
Skills conformance: exemplars + fleet retrofit (upgrade-maintainable skills)
2026-06-06 20:16:24 +03:00
gavrielc e734e5cddd feat(upgrade): startup tripwire + upgrade marker
Refuse to start unless this install reached the current version through a
sanctioned path (setup / update / migrate). A raw `git pull` that skips
migrations now fails loudly with a self-healing message instead of
silently breaking.

- src/upgrade-state.ts: marker at data/upgrade-state.json, getCodeVersion,
  isUpgradeCurrent, enforceUpgradeTripwire (fails closed on missing /
  corrupt / mismatched marker)
- src/index.ts: gate wired in at startup step 0.5, before DB init
- scripts/upgrade-state.ts: get/set CLI (also the override / recovery cmd)
- setup/service.ts, /update-nanoclaw, /migrate-nanoclaw: stamp on success;
  update/migrate also self-update their own skill first
- CHANGELOG [BREAKING] entry bridges existing installs via the skills'
  breaking-change check
- docs/upgrade-recovery.md: clearing the tripwire

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:02:12 +03:00
11 changed files with 350 additions and 1 deletions
+14
View File
@@ -28,6 +28,15 @@ Two phases: **Extract** (build the migration guide) and **Upgrade** (use it). If
---
# Phase 0: Refresh this skill first
The migration process itself evolves, so run its newest version before doing anything else:
- Ensure the `upstream` remote exists (default `https://github.com/nanocoai/nanoclaw.git`) and fetch: `git fetch upstream --prune`. Detect the upstream branch (`main` or `master`).
- Refresh this skill from upstream: `git checkout upstream/<branch> -- .claude/skills/migrate-nanoclaw/`
- Re-read `.claude/skills/migrate-nanoclaw/SKILL.md`. If it changed, **follow the updated version from the top** instead of this one.
This is the only working-tree change expected before the preflight check below; changes limited to `.claude/skills/migrate-nanoclaw/` are this self-refresh — ignore them in the 1.0 clean-tree check and proceed.
# Phase 1: Extract
## 1.0 Preflight
@@ -464,6 +473,11 @@ Point the branch at the upgraded state with `git reset --hard <upgrade-commit>`
Run `pnpm install && pnpm run build` in the main tree to confirm.
Stamp the upgrade marker (required — without it the startup tripwire stops the host on next start). Only do this after the build above succeeds:
```bash
pnpm exec tsx scripts/upgrade-state.ts set "" migrate-nanoclaw
```
Restart the service. Service labels are per-install — derive them from `setup/lib/install-slug.sh`:
```bash
source setup/lib/install-slug.sh
+19
View File
@@ -60,11 +60,20 @@ Help a user with a customized NanoClaw install safely incorporate upstream chang
- Default to MERGE (one-pass conflict resolution). Offer REBASE as an explicit option.
- Keep token usage low: rely on `git status`, `git log`, `git diff`, and open only conflicted files.
# Step 0a: Refresh this skill first
The update process itself evolves, so run its newest version before doing anything else:
- Ensure the `upstream` remote exists (default `https://github.com/nanocoai/nanoclaw.git`) and fetch: `git fetch upstream --prune`. Detect the upstream branch (`main` or `master`).
- Refresh this skill from upstream: `git checkout upstream/<branch> -- .claude/skills/update-nanoclaw/`
- Re-read `.claude/skills/update-nanoclaw/SKILL.md`. If it changed, **follow the updated version from the top** instead of this one.
This is the only working-tree change expected before the preflight check; the full update commits it along with everything else.
# Step 0: Preflight (stop early if unsafe)
Run:
- `git status --porcelain`
If output is non-empty:
- Tell the user to commit or stash first, then stop.
- Exception: changes limited to `.claude/skills/update-nanoclaw/` are the Step 0a self-refresh — ignore those and proceed.
Confirm remotes:
- `git remote -v`
@@ -256,6 +265,16 @@ If any channels/providers are installed AND `upstream/channels` or `upstream/pro
If no channels/providers are installed, skip silently.
Proceed to Step 7.9.
# Step 7.9: Stamp the upgrade marker (required)
After validation has **succeeded**, record that this install reached the new version through the supported path. Without this, the startup tripwire stops the host on its next start.
- `pnpm exec tsx scripts/upgrade-state.ts set "" update-nanoclaw`
- The empty version argument stamps the current `package.json` version.
If validation did NOT succeed, do not stamp — leave the tripwire to catch the broken state.
Proceed to Step 8.
# Step 8: Summary + rollback instructions
+8
View File
@@ -18,12 +18,20 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
- uses: pnpm/action-setup@v4
- name: Bump patch version
run: |
# Skip the auto-bump when the pushed commits already changed the
# version themselves (e.g. a release PR that set a minor/major).
# Otherwise the bot would patch a deliberate 2.1.0 up to 2.1.1.
if git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" | grep -qx 'package.json'; then
echo "package.json already changed in this push; skipping auto-bump."
exit 0
fi
pnpm version patch --no-git-tag-version
git add package.json
git diff --cached --quiet && exit 0
+4
View File
@@ -2,6 +2,10 @@
All notable changes to NanoClaw will be documented in this file.
## [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).
## [2.0.64] - 2026-05-18
- **`ncl destinations add` and `remove` through the approval flow now reach the receiver immediately.** Approved destinations weren't being projected into the receiving agent's local session state, so a freshly-added destination silently failed at `send_message` with `unknown destination`, and a removed destination stayed resolvable until the next container restart. Both now take effect the moment the approval executes. Direct (non-approval) calls were unaffected.
+51
View File
@@ -0,0 +1,51 @@
# Recovering from the upgrade tripwire
If NanoClaw refuses to start with a message like *"update did not go through the supported path"*, this page explains what happened and how to clear it.
## What happened
NanoClaw records the version it reached each time you upgrade through a supported path — `/setup`, `/update-nanoclaw`, or `/migrate-nanoclaw`. That record lives in `data/upgrade-state.json`.
At startup the host checks that record against the running code. If it's **missing** or its version **doesn't match** the code, the host stops. This almost always means the code was updated by a raw `git pull` instead of the supported flow — so migrations, dependency installs, or container rebuilds that the flow would have run may not have happened.
## If you just ran a supported upgrade
If you reached this point by running `/update-nanoclaw`, `/migrate-nanoclaw`, or `/setup` and it **completed successfully**, this is expected the first time an existing install meets the tripwire (your previous version predated it). Clear it by stamping the current version:
```bash
pnpm exec tsx scripts/upgrade-state.ts set
```
Then restart NanoClaw. It won't trip again.
**Coding agents:** if the upgrade you just ran reported success, run the command above automatically and restart. Only stop to ask a human if the upgrade itself failed or you can't confirm it finished.
## If you ran `git pull` yourself
Don't just clear the tripwire — that skips the work the supported flow does. Instead, run the supported upgrade so backups, migrations, and rebuilds happen:
```
/update-nanoclaw
```
Once it finishes it stamps the marker for you, and the next start is clean.
## If you have your own upgrade flow
If you've built your own way to upgrade — a custom skill, a deploy script, a CI job, a service that pulls and restarts — it won't stamp the marker, so the host will trip on the next start. Add the stamp as the **last step** of that flow, after the upgrade succeeds and before the restart:
```bash
pnpm exec tsx scripts/upgrade-state.ts set
```
That's the same thing `/setup`, `/update-nanoclaw`, and `/migrate-nanoclaw` do at the end. Do it only when the upgrade actually completed — the marker is your assertion that this install reached the current version through a path you trust.
## The override
`pnpm exec tsx scripts/upgrade-state.ts set` is the override: it declares "this install is good at the current version." Use it when you know the install is actually in a good state (e.g. you completed the steps manually). It's safe to re-run.
To inspect the current marker:
```bash
pnpm exec tsx scripts/upgrade-state.ts get
```
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.0.76",
"version": "2.1.0",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
+26
View File
@@ -0,0 +1,26 @@
/**
* scripts/upgrade-state.ts — read or stamp the upgrade marker.
*
* Usage:
* pnpm exec tsx scripts/upgrade-state.ts get
* pnpm exec tsx scripts/upgrade-state.ts set [version] [via]
*
* `set` with no version stamps the current package.json version. The
* sanctioned upgrade paths (setup / update / migrate) call `set` on
* success; running it by hand is also the documented way to clear the
* startup tripwire — see docs/upgrade-recovery.md.
*/
import { getCodeVersion, markerPath, readUpgradeState, writeUpgradeState } from '../src/upgrade-state.js';
const [, , cmd, versionArg, viaArg] = process.argv;
if (cmd === 'get') {
const state = readUpgradeState();
console.log(state ? JSON.stringify(state) : 'none');
} else if (cmd === 'set') {
const state = writeUpgradeState({ version: versionArg || getCodeVersion(), via: viaArg || 'manual' });
console.log(`Stamped ${markerPath()}: ${JSON.stringify(state)}`);
} else {
console.error('Usage: pnpm exec tsx scripts/upgrade-state.ts get | set [version] [via]');
process.exit(2);
}
+6
View File
@@ -11,6 +11,7 @@ import path from 'path';
import { log } from '../src/log.js';
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
import { writeUpgradeState } from '../src/upgrade-state.js';
import { cleanupUnhealthyPeers } from './peer-cleanup.js';
import {
commandExists,
@@ -54,6 +55,11 @@ export async function run(_args: string[]): Promise<void> {
fs.mkdirSync(path.join(projectRoot, 'logs'), { recursive: true });
// Stamp the upgrade marker before the host first starts, so the startup
// tripwire (enforceUpgradeTripwire) sees this as a sanctioned install.
const stamped = writeUpgradeState({ via: 'setup' });
log.info('Stamped upgrade marker', { version: stamped.version });
// Peer preflight — a crash-looping peer install (most often the legacy v1
// `com.nanoclaw` plist) will keep trashing this install's containers on
// every respawn via its own cleanupOrphans. Detect and unload any peer
+5
View File
@@ -17,6 +17,7 @@ import { startActiveDeliveryPoll, startSweepDeliveryPoll, setDeliveryAdapter, st
import { startHostSweep, stopHostSweep } from './host-sweep.js';
import { routeInbound } from './router.js';
import { log } from './log.js';
import { enforceUpgradeTripwire } from './upgrade-state.js';
// Response + shutdown registries live in response-registry.ts to break the
// circular import cycle: src/index.ts imports src/modules/index.js for side
@@ -69,6 +70,10 @@ async function main(): Promise<void> {
// 0. Circuit breaker — backoff on rapid restarts
await enforceStartupBackoff();
// 0.5 Upgrade tripwire — refuse to start if this install was updated
// outside the sanctioned path (raw `git pull` instead of /update-nanoclaw).
enforceUpgradeTripwire();
// 1. Init central DB
const dbPath = path.join(DATA_DIR, 'v2.db');
const db = initDb(dbPath);
+90
View File
@@ -0,0 +1,90 @@
import fs from 'fs';
import path from 'path';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
vi.mock('./config.js', async () => {
const actual = await vi.importActual<typeof import('./config.js')>('./config.js');
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-upgrade-state' };
});
const TEST_DIR = '/tmp/nanoclaw-test-upgrade-state';
import {
enforceUpgradeTripwire,
getCodeVersion,
isUpgradeCurrent,
markerPath,
readUpgradeState,
writeUpgradeState,
} from './upgrade-state.js';
beforeEach(() => {
fs.rmSync(TEST_DIR, { recursive: true, force: true });
});
afterEach(() => {
fs.rmSync(TEST_DIR, { recursive: true, force: true });
});
describe('upgrade-state', () => {
it('getCodeVersion reads the package.json version', () => {
const pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'));
expect(getCodeVersion()).toBe(pkg.version);
});
it('readUpgradeState returns null when the marker is absent', () => {
expect(readUpgradeState()).toBeNull();
});
it('write then read round-trips, with version/via/updatedAt', () => {
const written = writeUpgradeState({ version: '9.9.9', via: 'test' });
expect(written).toMatchObject({ version: '9.9.9', via: 'test' });
expect(written.updatedAt).toBeTruthy();
expect(readUpgradeState()).toEqual(written);
});
it('write defaults the version to the code version', () => {
expect(writeUpgradeState({ via: 'test' }).version).toBe(getCodeVersion());
});
it('isUpgradeCurrent: false when absent, false on mismatch, true on match', () => {
expect(isUpgradeCurrent()).toBe(false);
writeUpgradeState({ version: '0.0.0-nope', via: 'test' });
expect(isUpgradeCurrent()).toBe(false);
writeUpgradeState({ version: getCodeVersion(), via: 'test' });
expect(isUpgradeCurrent()).toBe(true);
});
it('treats a corrupt marker as absent (fails closed, never throws)', () => {
fs.mkdirSync(TEST_DIR, { recursive: true });
fs.writeFileSync(path.join(TEST_DIR, 'upgrade-state.json'), '{ this is not json');
expect(() => readUpgradeState()).not.toThrow();
expect(readUpgradeState()).toBeNull();
expect(isUpgradeCurrent()).toBe(false);
});
it('markerPath is upgrade-state.json under the data dir', () => {
expect(markerPath()).toBe(path.join(TEST_DIR, 'upgrade-state.json'));
});
it('enforceUpgradeTripwire exits when not current and passes when current', () => {
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit:${code}`);
}) as never);
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
// No marker → trips.
expect(() => enforceUpgradeTripwire()).toThrow('exit:1');
// Stale marker → trips.
writeUpgradeState({ version: '0.0.0-nope', via: 'test' });
expect(() => enforceUpgradeTripwire()).toThrow('exit:1');
// Matching marker → passes.
writeUpgradeState({ version: getCodeVersion(), via: 'test' });
expect(() => enforceUpgradeTripwire()).not.toThrow();
exitSpy.mockRestore();
errSpy.mockRestore();
});
});
+126
View File
@@ -0,0 +1,126 @@
/**
* Upgrade marker the record that an install reached its current version
* through a sanctioned path (setup / `/update-nanoclaw` / `/migrate-nanoclaw`).
*
* The startup tripwire (enforceUpgradeTripwire) refuses to run if the marker
* is missing or its version doesn't match the running code i.e. if the
* install was updated by a raw `git pull` instead of the supported flow.
*
* The marker lives in `data/` (gitignored), so a `git pull` can't touch it.
* Only the sanctioned paths call writeUpgradeState(); clearing the tripwire
* by hand is the same `set` see docs/upgrade-recovery.md.
*/
import fs from 'fs';
import path from 'path';
import { DATA_DIR } from './config.js';
import { log } from './log.js';
export interface UpgradeState {
version: string;
updatedAt: string;
via: string;
}
const MARKER_PATH = path.join(DATA_DIR, 'upgrade-state.json');
const FIX_COMMAND = 'pnpm exec tsx scripts/upgrade-state.ts set';
/** Version the running code declares, read from package.json. */
export function getCodeVersion(): string {
const pkgPath = path.join(process.cwd(), 'package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { version?: string };
if (!pkg.version) throw new Error(`No version field in ${pkgPath}`);
return pkg.version;
}
/**
* Read the upgrade marker, or null if it's absent, unreadable, or corrupt.
* Never throws a boot gate must fail closed (treat anything it can't trust
* as "no valid marker" trip), not crash with a stack trace.
*/
export function readUpgradeState(): UpgradeState | null {
let raw: string;
try {
raw = fs.readFileSync(MARKER_PATH, 'utf8');
} catch (e: unknown) {
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null;
log.warn('Could not read upgrade marker; treating as absent', { path: MARKER_PATH, err: String(e) });
return null;
}
try {
return JSON.parse(raw) as UpgradeState;
} catch {
log.warn('Upgrade marker is corrupt; treating as absent', { path: MARKER_PATH });
return null;
}
}
/**
* Stamp the marker. Only the sanctioned paths (setup / update / migrate)
* call this on success; `version` defaults to the current code version.
*/
export function writeUpgradeState(opts: { version?: string; via: string }): UpgradeState {
const state: UpgradeState = {
version: opts.version ?? getCodeVersion(),
updatedAt: new Date().toISOString(),
via: opts.via,
};
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(MARKER_PATH, JSON.stringify(state, null, 2) + '\n');
return state;
}
/** True when the marker exists and matches the running code version. */
export function isUpgradeCurrent(): boolean {
const state = readUpgradeState();
return state !== null && state.version === getCodeVersion();
}
/** Absolute path to the marker file. */
export function markerPath(): string {
return MARKER_PATH;
}
/**
* Startup gate. If the install didn't reach the current version through a
* sanctioned path, stop with a message written for the coding agent that
* just ran the upgrade to act on automatically.
*/
export function enforceUpgradeTripwire(): void {
if (isUpgradeCurrent()) return;
const code = getCodeVersion();
const recorded = readUpgradeState()?.version ?? 'none';
console.error(
[
'',
'='.repeat(64),
'NanoClaw stopped: update did not go through the supported path',
'='.repeat(64),
'This install was updated outside the sanctioned upgrade flow',
'(no upgrade marker, or the marker version does not match the code).',
'',
` code version: ${code}`,
` recorded version: ${recorded}`,
'',
"IF YOU'RE A HUMAN: you most likely ran `git pull` directly. Don't",
'fix this by hand — open Claude Code in this folder and run',
'/update-nanoclaw, which repairs the install and clears this stop.',
'',
'CODING AGENT: if you just ran /update-nanoclaw, /migrate-nanoclaw, or',
'setup and it completed successfully, clear this automatically by running:',
'',
` ${FIX_COMMAND}`,
'',
'then restart NanoClaw. If the upgrade did NOT complete, stop and',
'investigate instead of clearing.',
'',
'Details: docs/upgrade-recovery.md',
'='.repeat(64),
'',
].join('\n'),
);
log.error('Upgrade tripwire: install not on the sanctioned path', { code, recorded });
process.exit(1);
}