setup: drop redundant agent ping; harden auth detection and OAuth paste

- verify: remove the CLI ping; cli-agent step earlier in setup already
  proved the round-trip works, and the test agent gets cleaned up before
  verify runs — so the ping was guaranteed to fail on installs that wired
  a messaging app instead of staying CLI-only. Status now collapses to
  service-running ∧ credentials ∧ ≥1 wired group.
- agent-ping: catch Claude Code's "Please run /login" / "Not logged in" /
  "Invalid API key" banners so a successfully-spawned agent that has no
  credentials no longer reports as 'ok'.
- auth paste: validate the full sk-ant-oat…AA shape; when the cleaned
  input is under 90 chars, surface a truncation-specific hint pointing at
  terminal wrap as the likely cause. Strip internal whitespace at both
  validate and assignment so multi-line pastes that survive clack also
  go through cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
exe.dev user
2026-05-01 17:03:02 +00:00
parent 897b770296
commit 1b08b58fcd
5 changed files with 54 additions and 74 deletions
+13 -12
View File
@@ -491,14 +491,6 @@ async function main(): Promise<void> {
6, 6,
), ),
); );
} else {
const agentPing = res.terminal?.fields.AGENT_PING;
if (agentPing && agentPing !== 'ok' && agentPing !== 'skipped') {
notes.push(
"• Your assistant didn't reply to a test message. " +
'Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.',
);
}
} }
if (!res.terminal?.fields.CONFIGURED_CHANNELS) { if (!res.terminal?.fields.CONFIGURED_CHANNELS) {
notes.push( notes.push(
@@ -518,7 +510,6 @@ async function main(): Promise<void> {
unresolved_count: notes.length, unresolved_count: notes.length,
service_running: res.terminal?.fields.SERVICE === 'running', service_running: res.terminal?.fields.SERVICE === 'running',
has_credentials: res.terminal?.fields.CREDENTIALS === 'configured', has_credentials: res.terminal?.fields.CREDENTIALS === 'configured',
agent_responds: res.terminal?.fields.AGENT_PING === 'ok',
}); });
await offerClaudeAssist({ await offerClaudeAssist({
stepName: 'verify', stepName: 'verify',
@@ -777,15 +768,25 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
message: `Paste your ${label}`, message: `Paste your ${label}`,
clearOnError: true, clearOnError: true,
validate: (v) => { validate: (v) => {
if (!v || !v.trim()) return 'Required'; // Strip any internal whitespace so a line-wrapped paste that did
if (!v.trim().startsWith(prefix)) { // survive into clack can still validate. The mid-token-newline
// case where clack only sees the first line is caught by the
// shape check below.
const cleaned = (v ?? '').replace(/\s+/g, '');
if (!cleaned) return 'Required';
if (!cleaned.startsWith(prefix)) {
return `Should start with ${prefix}`; return `Should start with ${prefix}`;
} }
if (method === 'oauth' && !/^sk-ant-oat[A-Za-z0-9_-]{80,500}AA$/.test(cleaned)) {
return cleaned.length < 90
? 'Token looks truncated — line breaks in the paste can cut it off. Widen your terminal so the token fits on one line, then paste again.'
: "Token shape doesn't look right (expected sk-ant-oat…AA).";
}
return undefined; return undefined;
}, },
}), }),
); );
const token = (answer as string).trim(); const token = (answer as string).replace(/\s+/g, '');
const res = await runQuietChild( const res = await runQuietChild(
'auth', 'auth',
+9
View File
@@ -20,6 +20,15 @@ describe('classifyPingResult', () => {
expect(classifyPingResult(1, '', 'Authentication error')).toBe('auth_error'); expect(classifyPingResult(1, '', 'Authentication error')).toBe('auth_error');
}); });
it('detects Claude Code login banners printed as a chat reply', () => {
expect(
classifyPingResult(0, 'Invalid API key · Please run /login'),
).toBe('auth_error');
expect(
classifyPingResult(0, 'Not logged in · Please run /login'),
).toBe('auth_error');
});
it('preserves socket errors', () => { it('preserves socket errors', () => {
expect(classifyPingResult(2, '')).toBe('socket_error'); expect(classifyPingResult(2, '')).toBe('socket_error');
}); });
+4 -1
View File
@@ -20,7 +20,10 @@ export function classifyPingResult(exitCode: number | null, stdout: string, stde
if ( if (
/Invalid bearer token/i.test(output) || /Invalid bearer token/i.test(output) ||
/authentication[_ ]error/i.test(output) || /authentication[_ ]error/i.test(output) ||
/Failed to authenticate/i.test(output) /Failed to authenticate/i.test(output) ||
/Please run \/login/i.test(output) ||
/Not logged in/i.test(output) ||
/Invalid API key/i.test(output)
) { ) {
return 'auth_error'; return 'auth_error';
} }
+19 -32
View File
@@ -5,45 +5,14 @@ import { determineVerifyStatus } from './verify.js';
const healthyBase = { const healthyBase = {
service: 'running' as const, service: 'running' as const,
credentials: 'configured', credentials: 'configured',
anyChannelConfigured: false,
registeredGroups: 1, registeredGroups: 1,
agentPing: 'ok' as const,
}; };
describe('determineVerifyStatus', () => { describe('determineVerifyStatus', () => {
it('accepts a working CLI-only install', () => { it('accepts a healthy install with at least one wired agent group', () => {
expect(determineVerifyStatus(healthyBase)).toBe('success'); expect(determineVerifyStatus(healthyBase)).toBe('success');
}); });
it('accepts a messaging-channel install when CLI ping is skipped', () => {
expect(
determineVerifyStatus({
...healthyBase,
anyChannelConfigured: true,
agentPing: 'skipped',
}),
).toBe('success');
});
it('fails when neither CLI nor messaging channels are usable', () => {
expect(
determineVerifyStatus({
...healthyBase,
agentPing: 'skipped',
}),
).toBe('failed');
});
it('fails when the CLI agent does not respond', () => {
expect(
determineVerifyStatus({
...healthyBase,
anyChannelConfigured: true,
agentPing: 'no_reply',
}),
).toBe('failed');
});
it('fails when no agent groups are registered', () => { it('fails when no agent groups are registered', () => {
expect( expect(
determineVerifyStatus({ determineVerifyStatus({
@@ -52,4 +21,22 @@ describe('determineVerifyStatus', () => {
}), }),
).toBe('failed'); ).toBe('failed');
}); });
it('fails when the service is not running', () => {
expect(
determineVerifyStatus({
...healthyBase,
service: 'stopped',
}),
).toBe('failed');
});
it('fails when credentials are missing', () => {
expect(
determineVerifyStatus({
...healthyBase,
credentials: 'missing',
}),
).toBe('failed');
});
}); });
+9 -29
View File
@@ -14,7 +14,6 @@ import Database from 'better-sqlite3';
import { DATA_DIR } from '../src/config.js'; import { DATA_DIR } from '../src/config.js';
import { readEnvFile } from '../src/env.js'; import { readEnvFile } from '../src/env.js';
import { log } from '../src/log.js'; import { log } from '../src/log.js';
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
import { import {
getPlatform, getPlatform,
@@ -33,11 +32,12 @@ export async function run(_args: string[]): Promise<void> {
// 1. Check service status + detect checkout mismatch. // 1. Check service status + detect checkout mismatch.
// //
// Why the mismatch matters: the host binds `<DATA_DIR>/cli.sock` relative // Why the mismatch matters: the host reads `<projectRoot>/data/v2.db` and
// to the project root it was started from. If the running service is from // binds `<DATA_DIR>/cli.sock` relative to the project root it was started
// a sibling checkout (common for developers with multiple clones), this // from. If the running service is from a sibling checkout (common for
// repo's `data/cli.sock` won't exist — AGENT_PING would return a // developers with multiple clones), nothing in this checkout is actually
// misleading `socket_error`. Surface the mismatch directly instead. // wired up. Surface the mismatch directly so the user knows to point the
// service at the right folder.
let service: let service:
| 'not_found' | 'not_found'
| 'stopped' | 'stopped'
@@ -186,7 +186,6 @@ export async function run(_args: string[]): Promise<void> {
if (has('IMESSAGE_ENABLED')) channelAuth.imessage = 'configured'; if (has('IMESSAGE_ENABLED')) channelAuth.imessage = 'configured';
const configuredChannels = Object.keys(channelAuth); const configuredChannels = Object.keys(channelAuth);
const anyChannelConfigured = configuredChannels.length > 0;
// 5. Check registered groups in v2 central DB (agent_groups + messaging_group_agents) // 5. Check registered groups in v2 central DB (agent_groups + messaging_group_agents)
let registeredGroups = 0; let registeredGroups = 0;
@@ -218,23 +217,12 @@ export async function run(_args: string[]): Promise<void> {
mountAllowlist = 'configured'; mountAllowlist = 'configured';
} }
// 7. End-to-end: ping the CLI agent and confirm it replies. Only run if // Determine overall status. The cli-agent step earlier in setup already
// everything upstream looks healthy, since a broken socket would just hang. // proved the agent round-trip works; verify is a static health check.
let agentPing: 'ok' | 'no_reply' | 'socket_error' | 'auth_error' | 'skipped' = 'skipped';
if (service === 'running' && registeredGroups > 0) {
log.info('Pinging CLI agent');
agentPing = await pingCliAgent();
log.info('Agent ping result', { agentPing });
}
// Determine overall status. A CLI-only install is valid when the local
// agent round-trip succeeds; messaging app credentials are optional.
const status = determineVerifyStatus({ const status = determineVerifyStatus({
service, service,
credentials, credentials,
anyChannelConfigured,
registeredGroups, registeredGroups,
agentPing,
}); });
log.info('Verification complete', { status, channelAuth }); log.info('Verification complete', { status, channelAuth });
@@ -247,7 +235,6 @@ export async function run(_args: string[]): Promise<void> {
CHANNEL_AUTH: JSON.stringify(channelAuth), CHANNEL_AUTH: JSON.stringify(channelAuth),
REGISTERED_GROUPS: registeredGroups, REGISTERED_GROUPS: registeredGroups,
MOUNT_ALLOWLIST: mountAllowlist, MOUNT_ALLOWLIST: mountAllowlist,
AGENT_PING: agentPing,
STATUS: status, STATUS: status,
LOG: 'logs/setup.log', LOG: 'logs/setup.log',
}); });
@@ -258,18 +245,11 @@ export async function run(_args: string[]): Promise<void> {
export function determineVerifyStatus(input: { export function determineVerifyStatus(input: {
service: 'not_found' | 'stopped' | 'running' | 'running_other_checkout'; service: 'not_found' | 'stopped' | 'running' | 'running_other_checkout';
credentials: string; credentials: string;
anyChannelConfigured: boolean;
registeredGroups: number; registeredGroups: number;
agentPing: PingResult | 'skipped';
}): 'success' | 'failed' { }): 'success' | 'failed' {
const cliAgentResponds = input.agentPing === 'ok';
const hasUsableChannel = input.anyChannelConfigured || cliAgentResponds;
return input.service === 'running' && return input.service === 'running' &&
input.credentials !== 'missing' && input.credentials !== 'missing' &&
hasUsableChannel && input.registeredGroups > 0
input.registeredGroups > 0 &&
(cliAgentResponds || input.agentPing === 'skipped')
? 'success' ? 'success'
: 'failed'; : 'failed';
} }