feat(models): sign in to the Claude sidecar from Settings, with account switching

The sidecar keeps one 'claude auth login' between two calls (address, then pasted code); the web app proxies /api/model-auth.
No new dependencies.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
homelab-updaterandClaude Sonnet 5.5 committed 2026-10-23 12:02:05 +02:00
1 parent 765a1e0eee
commit 8ea9f884f1
16 files changed
+588 -18

No files matched your search

+3
View File
@@ -24,6 +24,8 @@ services:
environment:
TZ: Europe/Warsaw
FUNCTION_RUNTIME_URL: http://function-runtime:8085
# Settings → Anthropic account signs the Claude sidecar in (only /auth/* is open to this service).
MODEL_CLAUDE_URL: http://model-claude:8083
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8080/api/health').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
interval: 30s
@@ -195,6 +197,7 @@ services:
MODEL_PORT: "8083"
HL_URL: http://executor:8081
DRIVER_HOST: driver
AUTH_HOST: server
volumes:
- /srv/appdata/homelab-upgrade/claude:/claude
networks: [default, executor]
+37
View File
@@ -0,0 +1,37 @@
import { z } from 'zod';
// The owner's Anthropic account behind the Claude sidecar: who is signed in, and the browser sign-in that replaces it.
export const ModelAuthStatus = z.object({
loggedIn: z.boolean(),
email: z.string().nullable(),
organization: z.string().nullable(),
/** How the CLI is signed in (`claude.ai` subscription, `console` API billing, …); null when not signed in. */
method: z.string().nullable(),
});
export type ModelAuthStatus = z.infer<typeof ModelAuthStatus>;
/** The sign-in page the owner opens in a browser; it ends in a code to paste back. */
export const ModelAuthLoginStart = z.object({ url: z.string().url() });
export type ModelAuthLoginStart = z.infer<typeof ModelAuthLoginStart>;
export const ModelAuthCodeRequest = z.strictObject({ code: z.string().trim().min(8).max(2048) });
export type ModelAuthCodeRequest = z.infer<typeof ModelAuthCodeRequest>;
/** The sidecar's side: all failures are exceptions with a message the owner can read. */
export interface ModelAuth {
status(): Promise<ModelAuthStatus>;
/** Starts a sign-in (dropping one in progress) and returns the page to open. */
startLogin(): Promise<ModelAuthLoginStart>;
/** Finishes the sign-in with the code the page showed; returns the new status. */
submitCode(code: string): Promise<ModelAuthStatus>;
cancelLogin(): Promise<void>;
logout(): Promise<ModelAuthStatus>;
}
export class ModelAuthError extends Error {
constructor(message: string) {
super(message);
this.name = 'ModelAuthError';
}
}
+2
View File
@@ -301,6 +301,8 @@ export const WorkflowModelCallResponse = z.object({
});
export type WorkflowModelCallResponse = z.infer<typeof WorkflowModelCallResponse>;
export { ModelAuthLoginStart, ModelAuthStatus } from '../domain/model-auth.ts';
/** GET /api/model-calls: the newest model runs of workflow runs, and what each model cost over them. */
export const ModelCallsResponse = z.object({
calls: z.array(WorkflowModelCallResponse),
+41
View File
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it } from 'vitest';
import type { ClaudeRunRequest } from '../app/model/claude.ts';
import { createHttpModelClient } from '../infra/http-model-client.ts';
import { ModelAuthError } from '../domain/model-auth.ts';
import { ModelError } from '../domain/ports.ts';
import type { AddressInfo } from 'node:net';
import { buildModelServer } from './model-server.ts';
@@ -24,6 +25,46 @@ async function live(run: Run, heartbeatMs?: number) {
const RUN_BODY = JSON.stringify({ executor: 'sonnet', profile: 'tools', prompt: 'x', output_schema: { type: 'object' } });
describe('model sidecar sign-in routes', () => {
const auth = {
status: () => Promise.resolve({ loggedIn: true, email: 'me@example.com', organization: null, method: 'claude.ai' }),
startLogin: () => Promise.resolve({ url: 'https://claude.com/x' }),
submitCode: () => Promise.reject(new ModelAuthError('the code was not accepted')),
cancelLogin: () => Promise.resolve(),
logout: () => Promise.resolve({ loggedIn: false, email: null, organization: null, method: null }),
};
const run: Run = () => Promise.resolve({ ok: true, output: {}, costUsd: 0, turns: 1 });
it('is 404 without a sign-in', async () => {
const app = await buildModelServer({ timeoutMs: 60_000, run });
closers.push(() => app.close());
expect((await app.inject('/auth/status')).statusCode).toBe(404);
});
it('lets the web app use /auth/* but not /run, and the driver both', async () => {
const app = await buildModelServer({ timeoutMs: 60_000, run, auth, isDriver: () => Promise.resolve(false), mayManageLogin: () => Promise.resolve(true) });
closers.push(() => app.close());
expect((await app.inject('/auth/status')).json()).toMatchObject({ email: 'me@example.com' });
expect((await app.inject({ method: 'POST', url: '/auth/login' })).json()).toEqual({ url: 'https://claude.com/x' });
expect((await app.inject({ method: 'POST', url: '/run', payload: {} })).statusCode).toBe(403);
});
it('keeps /auth/* from callers that may not manage the login', async () => {
const app = await buildModelServer({ timeoutMs: 60_000, run, auth, isDriver: () => Promise.resolve(false), mayManageLogin: () => Promise.resolve(false) });
closers.push(() => app.close());
expect((await app.inject('/auth/status')).statusCode).toBe(403);
});
it('answers a refused code with 409 and its message, and a missing code with 400', async () => {
const app = await buildModelServer({ timeoutMs: 60_000, run, auth });
closers.push(() => app.close());
const refused = await app.inject({ method: 'POST', url: '/auth/code', payload: { code: 'abcdefgh' } });
expect(refused.statusCode).toBe(409);
expect(refused.json()).toMatchObject({ message: 'the code was not accepted' });
expect((await app.inject({ method: 'POST', url: '/auth/code', payload: {} })).statusCode).toBe(400);
});
});
describe('model sidecar over HTTP', () => {
it('answers /run only to the driver when an address guard is configured', async () => {
const app = await buildModelServer({ timeoutMs: 60_000, run: () => Promise.resolve({ ok: true, output: {}, costUsd: 0, turns: 1 }), isDriver: () => Promise.resolve(false) });
+32 -3
View File
@@ -1,7 +1,9 @@
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
import type { FastifyInstance, FastifyReply } from 'fastify';
import type { ClaudeRunRequest } from '../app/model/claude.ts';
import { ModelRunRequest } from '../domain/model-api.ts';
import { ModelAuthCodeRequest, ModelAuthError } from '../domain/model-auth.ts';
import type { ModelAuth } from '../domain/model-auth.ts';
import { answerWhenDone } from './keep-alive.ts';
import type { ModelRunResponse, ModelSidecarRunResult } from '../domain/model-api.ts';
@@ -19,6 +21,10 @@ export interface ModelServerDeps {
readonly heartbeatMs?: number;
readonly logger?: boolean;
readonly isDriver?: (address: string) => Promise<boolean>;
/** Who may use /auth/* (the driver and the web app); defaults to `isDriver`. */
readonly mayManageLogin?: (address: string) => Promise<boolean>;
/** Sign-in of the owner's account; without it /auth/* is 404. */
readonly auth?: ModelAuth;
}
export async function buildModelServer(deps: ModelServerDeps): Promise<FastifyInstance> {
@@ -27,12 +33,35 @@ export async function buildModelServer(deps: ModelServerDeps): Promise<FastifyIn
let busy = false;
app.addHook('onRequest', async (request, reply) => {
if (deps.isDriver === undefined || request.url === '/health') return;
if (!(await deps.isDriver(request.socket.remoteAddress ?? ''))) {
if (request.url === '/health') return;
const allowed = request.url.startsWith('/auth/') ? (deps.mayManageLogin ?? deps.isDriver) : deps.isDriver;
if (allowed === undefined) return;
if (!(await allowed(request.socket.remoteAddress ?? ''))) {
await reply.code(403).send({ error: 'forbidden', message: 'only the driver may call this' });
}
});
const { auth } = deps;
if (auth !== undefined) {
const guarded = async (reply: FastifyReply, work: () => Promise<unknown>) => {
try {
return await work();
} catch (cause) {
if (!(cause instanceof ModelAuthError)) throw cause;
return reply.code(409).send({ error: 'login_failed', message: cause.message });
}
};
app.get('/auth/status', (_request, reply) => guarded(reply, () => auth.status()));
app.post('/auth/login', (_request, reply) => guarded(reply, () => auth.startLogin()));
app.post('/auth/code', (request, reply) => {
const parsed = ModelAuthCodeRequest.safeParse(request.body);
if (!parsed.success) return reply.code(400).send({ error: 'bad_request', message: 'paste the code the sign-in page showed' });
return guarded(reply, () => auth.submitCode(parsed.data.code));
});
app.post('/auth/cancel', (_request, reply) => guarded(reply, async () => (await auth.cancelLogin(), { ok: true })));
app.post('/auth/logout', (_request, reply) => guarded(reply, () => auth.logout()));
}
app.post('/run', async (request, reply) => {
const parsed = ModelRunRequest.safeParse(request.body);
if (!parsed.success) return reply.code(400).send({ error: 'bad_request', message: parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; ') });
+36 -1
View File
@@ -4,13 +4,15 @@ import { join } from 'node:path';
import type { FastifyInstance, InjectOptions } from 'fastify';
import { afterEach, describe, expect, it } from 'vitest';
import type { z } from 'zod';
import { ModelAuthError } from '../domain/model-auth.ts';
import type { ModelAuth, ModelAuthStatus } from '../domain/model-auth.ts';
import type { AppConfigStore } from '../domain/ports.ts';
import { RegistryError } from '../domain/ports.ts';
import { seedTestApps } from '../testing/test-apps.ts';
import { sqliteServerDeps } from '../testing/server-deps.ts';
import { FakeRegistryClient } from '../testing/fakes.ts';
import type { ChangeHub } from './change-hub.ts';
import { AppConfigResponse, AppsResponse, BuildResponse, ErrorResponse, TagPreviewResponse } from './contract.ts';
import { AppConfigResponse, AppsResponse, BuildResponse, ErrorResponse, ModelAuthLoginStart, ModelAuthStatus as ModelAuthStatusResponse, TagPreviewResponse } from './contract.ts';
import { buildServer } from './server.ts';
import type { ServerDeps } from './server.ts';
@@ -109,6 +111,39 @@ describe('apps', () => {
});
});
describe('Anthropic account', () => {
const SIGNED_IN: ModelAuthStatus = { loggedIn: true, email: 'me@example.com', organization: null, method: 'claude.ai' };
const fakeAuth = (overrides: Partial<ModelAuth> = {}): ModelAuth => ({
status: () => Promise.resolve(SIGNED_IN),
startLogin: () => Promise.resolve({ url: 'https://claude.com/oauth/authorize?x=1' }),
submitCode: () => Promise.resolve(SIGNED_IN),
cancelLogin: () => Promise.resolve(),
logout: () => Promise.resolve({ ...SIGNED_IN, loggedIn: false, email: null, method: null }),
...overrides,
});
it('is 503 without a Claude sidecar', async () => {
const { app } = await fixture();
expect(await json(app, ErrorResponse, '/api/model-auth', 503)).toMatchObject({ error: 'unavailable' });
});
it('shows the account, starts a sign-in, finishes it with the code and signs out', async () => {
const seen: string[] = [];
const { app } = await fixture({ claudeAuth: fakeAuth({ submitCode: (code) => (seen.push(code), Promise.resolve(SIGNED_IN)) }) });
expect(await json(app, ModelAuthStatusResponse, '/api/model-auth')).toMatchObject({ loggedIn: true, email: 'me@example.com' });
expect(await json(app, ModelAuthLoginStart, { method: 'POST', url: '/api/model-auth/login', payload: {} })).toEqual({ url: 'https://claude.com/oauth/authorize?x=1' });
await json(app, ModelAuthStatusResponse, { method: 'POST', url: '/api/model-auth/code', payload: { code: ' abcdefgh#xyz ' } });
expect(seen).toEqual(['abcdefgh#xyz']);
expect(await json(app, ModelAuthStatusResponse, { method: 'POST', url: '/api/model-auth/logout', payload: {} })).toMatchObject({ loggedIn: false });
});
it('is 400 for a missing code and 502 with the sidecar\'s message when the sign-in fails', async () => {
const { app } = await fixture({ claudeAuth: fakeAuth({ submitCode: () => Promise.reject(new ModelAuthError('the code was not accepted: start again')) }) });
expect(await json(app, ErrorResponse, { method: 'POST', url: '/api/model-auth/code', payload: { code: '' } }, 400)).toMatchObject({ error: 'invalid_request' });
expect(await json(app, ErrorResponse, { method: 'POST', url: '/api/model-auth/code', payload: { code: 'abcdefgh' } }, 502)).toMatchObject({ message: 'the code was not accepted: start again' });
});
});
describe('tag preview', () => {
const preview = (app: string, pattern: string) => ({ method: 'POST' as const, url: `/api/apps/${app}/tag-preview`, payload: { pattern } });
+27
View File
@@ -55,6 +55,8 @@ import {
} from '../domain/workflow-graph.ts';
import { OwnerDecisionV1, OWNER_DECISION_SIGNAL, OwnerDecisionAction } from '../domain/owner-decision.ts';
import { MODEL_PROFILE_ID_REGEX } from '../domain/model-profile.ts';
import { ModelAuthCodeRequest, ModelAuthError } from '../domain/model-auth.ts';
import type { ModelAuth } from '../domain/model-auth.ts';
import { ReleaseNotesSources } from '../domain/release-notes.ts';
import {
FunctionOutcome,
@@ -116,6 +118,8 @@ export interface ServerDeps {
readonly startSystemRun?: (workflowId: string) => StartRunResult;
/** Read-only registry access for the tag pattern preview; without it that endpoint is 503. */
readonly registry?: RegistryClient;
/** The Claude sidecar's sign-in of the owner's account; without it /api/model-auth is 503. */
readonly claudeAuth?: ModelAuth;
}
const UNKNOWN_BUILD: BuildInfo = { branch: null, number: null, revision: null };
@@ -730,6 +734,29 @@ export async function buildServer(deps: ServerDeps): Promise<FastifyInstance> {
return reply.send({ ...timeline, modelCalls, costUsd: totalCostUsd(modelCalls), modelSessions });
});
// The owner's Anthropic account behind the Claude sidecar: status, a browser sign-in finished with a pasted code, sign-out.
{
const withAuth = async (reply: FastifyReply, work: (auth: ModelAuth) => Promise<unknown>) => {
const auth = deps.claudeAuth;
if (!auth) return fail(reply, 503, 'unavailable', 'the Claude sidecar is not configured');
try {
return await work(auth);
} catch (cause) {
if (!(cause instanceof ModelAuthError)) throw cause;
return fail(reply, 502, 'model_auth_failed', cause.message);
}
};
app.get('/api/model-auth', (_request, reply) => withAuth(reply, (auth) => auth.status()));
app.post('/api/model-auth/login', (_request, reply) => withAuth(reply, (auth) => auth.startLogin()));
app.post('/api/model-auth/code', (request, reply) => {
const body = ModelAuthCodeRequest.safeParse(request.body);
if (!body.success) return fail(reply, 400, 'invalid_request', 'paste the code the sign-in page showed');
return withAuth(reply, (auth) => auth.submitCode(body.data.code));
});
app.post('/api/model-auth/cancel', (_request, reply) => withAuth(reply, async (auth) => (await auth.cancelLogin(), { ok: true })));
app.post('/api/model-auth/logout', (_request, reply) => withAuth(reply, (auth) => auth.logout()));
}
const ModelCallsQuery = z.object({ limit: z.coerce.number().int().min(1).max(500).default(50) });
// The model runs of the latest workflow runs, newest first, and per model how many and what they cost.
+38
View File
@@ -0,0 +1,38 @@
import { ModelAuthError, ModelAuthLoginStart, ModelAuthStatus } from '../domain/model-auth.ts';
import type { ModelAuth } from '../domain/model-auth.ts';
import { describeError } from '../domain/mask.ts';
// The web app's side of the sidecar's /auth/*: the owner's sign-in. Failures are ModelAuthError with a readable message.
export function createHttpModelAuthClient(options: { readonly baseUrl: string; readonly fetch?: typeof fetch }): ModelAuth {
const doFetch = options.fetch ?? fetch;
async function request<T>(schema: { parse(value: unknown): T }, path: string, method: 'GET' | 'POST', body?: unknown): Promise<T> {
let response: Response;
try {
response = await doFetch(new URL(path, options.baseUrl), {
method,
...(body === undefined ? {} : { headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }),
signal: AbortSignal.timeout(90_000),
});
} catch (cause) {
throw new ModelAuthError(`model sidecar unreachable: ${describeError(cause)}`);
}
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
const message = (payload as { message?: unknown } | null)?.message;
throw new ModelAuthError(typeof message === 'string' ? message : `model sidecar answered HTTP ${String(response.status)}`);
}
try {
return schema.parse(payload);
} catch {
throw new ModelAuthError('model sidecar answered in an unexpected shape');
}
}
return {
status: () => request(ModelAuthStatus, '/auth/status', 'GET'),
startLogin: () => request(ModelAuthLoginStart, '/auth/login', 'POST', {}),
submitCode: (code) => request(ModelAuthStatus, '/auth/code', 'POST', { code }),
cancelLogin: async () => void (await request({ parse: (value) => value }, '/auth/cancel', 'POST', {})),
logout: () => request(ModelAuthStatus, '/auth/logout', 'POST', {}),
};
}
+86
View File
@@ -0,0 +1,86 @@
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { ModelAuthError } from '../../domain/model-auth.ts';
import { createClaudeAuth, parseAuthStatus } from './claude-auth.ts';
// A stand-in for the CLI: `auth status` reads state.json, `auth login` prints the sign-in address in a terminal hyperlink
// (as the real one does) and waits for a code on stdin, `auth logout` forgets the account.
const FAKE_CLAUDE = `#!/usr/bin/env node
const fs = require('node:fs');
const state = process.env.CLAUDE_CONFIG_DIR + '/state.json';
const [, , group, action] = process.argv;
if (group === 'auth' && action === 'status') {
process.stdout.write(fs.existsSync(state) ? fs.readFileSync(state, 'utf8') : JSON.stringify({ loggedIn: false, authMethod: 'none' }));
} else if (group === 'auth' && action === 'logout') {
fs.rmSync(state, { force: true });
} else if (group === 'auth' && action === 'login') {
const url = 'https://claude.com/cai/oauth/authorize?state=abc';
process.stdout.write('Opening browser to sign in…\\nIf the browser did not open, visit: \\u001b]8;;' + url + '\\u0007' + url + '\\u001b]8;;\\u0007\\nPaste code here if prompted > ');
process.stdin.once('data', (chunk) => {
if (chunk.toString().trim() === 'good-code') fs.writeFileSync(state, JSON.stringify({ loggedIn: true, authMethod: 'claude.ai', email: 'new@example.com', orgName: 'New Org' }));
process.exit(0);
});
}
`;
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'claude-auth-'));
await writeFile(join(dir, 'claude'), FAKE_CLAUDE);
await chmod(join(dir, 'claude'), 0o755);
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
const make = () => createClaudeAuth({ claudeBin: join(dir, 'claude'), configDir: dir, path: process.env['PATH'] ?? '', urlTimeoutMs: 5000, codeTimeoutMs: 5000 });
describe('parseAuthStatus', () => {
it('reads the signed-in account', () => {
expect(parseAuthStatus('{"loggedIn":true,"authMethod":"claude.ai","email":"a@b.c","orgName":"Org"}')).toEqual({ loggedIn: true, email: 'a@b.c', organization: 'Org', method: 'claude.ai' });
});
it('treats anything else as signed out', () => {
for (const out of ['', 'oops', '[]', '{"loggedIn":false,"authMethod":"none"}']) expect(parseAuthStatus(out).loggedIn).toBe(false);
});
});
describe('Claude sign-in', () => {
it('starts as signed out', async () => {
expect(await make().status()).toEqual({ loggedIn: false, email: null, organization: null, method: null });
});
it('returns the sign-in address, then signs in with the pasted code', async () => {
const auth = make();
expect(await auth.startLogin()).toEqual({ url: 'https://claude.com/cai/oauth/authorize?state=abc' });
expect(await auth.submitCode('good-code')).toEqual({ loggedIn: true, email: 'new@example.com', organization: 'New Org', method: 'claude.ai' });
});
it('rejects a wrong code and needs a new start', async () => {
const auth = make();
await auth.startLogin();
await expect(auth.submitCode('wrong-code')).rejects.toThrow(/not accepted/);
await expect(auth.submitCode('good-code')).rejects.toThrow(ModelAuthError);
});
it('refuses a code when no sign-in was started or it was cancelled', async () => {
const auth = make();
await expect(auth.submitCode('good-code')).rejects.toThrow(/no sign-in in progress/);
await auth.startLogin();
await auth.cancelLogin();
await expect(auth.submitCode('good-code')).rejects.toThrow(/no sign-in in progress/);
});
it('signs out', async () => {
const auth = make();
await auth.startLogin();
await auth.submitCode('good-code');
expect((await auth.logout()).loggedIn).toBe(false);
});
it('says so when the CLI is missing', async () => {
const auth = createClaudeAuth({ claudeBin: join(dir, 'nope'), configDir: dir, path: '' });
await expect(auth.status()).rejects.toThrow(/cannot run claude/);
});
});
+126
View File
@@ -0,0 +1,126 @@
import { spawn } from 'node:child_process';
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
import { ModelAuthError } from '../../domain/model-auth.ts';
import type { ModelAuth, ModelAuthStatus } from '../../domain/model-auth.ts';
// `claude auth login` without a terminal prints the sign-in URL and waits for the code the page shows on stdin
// ("Paste code here"). This keeps that one process between the two HTTP calls.
export interface ClaudeAuthOptions {
readonly claudeBin: string;
/** CLAUDE_CONFIG_DIR, also HOME: where the login lives. */
readonly configDir: string;
readonly path: string;
/** How long to wait for the URL / for the CLI to accept the code. */
readonly urlTimeoutMs?: number;
readonly codeTimeoutMs?: number;
}
// Printable ASCII only: the address ends at the first space or terminal control character (the CLI wraps it in a hyperlink escape).
const URL_PATTERN = /https:\/\/[\x21-\x7e]+/;
const SIGNED_OUT: ModelAuthStatus = { loggedIn: false, email: null, organization: null, method: null };
function text(value: unknown): string | null {
return typeof value === 'string' && value !== '' ? value : null;
}
export function parseAuthStatus(stdout: string): ModelAuthStatus {
let raw: unknown;
try {
raw = JSON.parse(stdout);
} catch {
return SIGNED_OUT;
}
if (typeof raw !== 'object' || raw === null) return SIGNED_OUT;
const fields = raw as Record<string, unknown>;
if (fields['loggedIn'] !== true) return SIGNED_OUT;
return { loggedIn: true, email: text(fields['email']), organization: text(fields['orgName']) ?? text(fields['organization']), method: text(fields['authMethod']) };
}
export function createClaudeAuth(options: ClaudeAuthOptions): ModelAuth {
const urlTimeoutMs = options.urlTimeoutMs ?? 20_000;
const codeTimeoutMs = options.codeTimeoutMs ?? 45_000;
const env = { PATH: options.path, HOME: options.configDir, CLAUDE_CONFIG_DIR: options.configDir, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', BROWSER: 'true' };
let pending: { readonly child: ChildProcessWithoutNullStreams; readonly exited: Promise<number> } | null = null;
const launch = (args: readonly string[]) => spawn(options.claudeBin, [...args], { cwd: options.configDir, env, stdio: ['pipe', 'pipe', 'pipe'] });
async function oneShot(args: readonly string[]): Promise<string> {
const child = launch(args);
let out = '';
child.stdout.on('data', (chunk: Buffer) => (out += chunk.toString('utf8')));
child.stdin.end();
await new Promise<void>((resolve, reject) => {
child.once('error', reject);
child.once('close', () => resolve());
}).catch((cause: unknown) => {
throw new ModelAuthError(`cannot run claude: ${cause instanceof Error ? cause.message : String(cause)}`);
});
return out;
}
const status = async (): Promise<ModelAuthStatus> => parseAuthStatus(await oneShot(['auth', 'status', '--json']));
function drop(): void {
pending?.child.kill('SIGTERM');
pending = null;
}
return {
status,
async startLogin() {
drop();
const child = launch(['auth', 'login', '--claudeai']);
child.stdin.on('error', () => undefined);
const exited = new Promise<number>((resolve) => child.once('close', (code) => resolve(code ?? -1)));
child.once('error', () => undefined);
pending = { child, exited };
let output = '';
const url = await new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => reject(new ModelAuthError('claude printed no sign-in address in time')), urlTimeoutMs);
const look = (chunk: Buffer) => {
output += chunk.toString('utf8');
const found = URL_PATTERN.exec(output);
if (found !== null && output.length > found.index + found[0].length) {
clearTimeout(timer);
resolve(found[0]);
}
};
child.stdout.on('data', look);
child.stderr.on('data', look);
void exited.then(() => {
clearTimeout(timer);
reject(new ModelAuthError('claude stopped before it printed a sign-in address'));
});
}).catch((cause: unknown) => {
drop();
throw cause;
});
return { url };
},
async submitCode(code) {
const current = pending;
if (current === null) throw new ModelAuthError('no sign-in in progress: start again');
pending = null;
current.child.stdin.write(`${code.trim()}\n`);
const timeout = new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), codeTimeoutMs).unref());
const result = await Promise.race([current.exited, timeout]);
if (result === 'timeout') {
current.child.kill('SIGTERM');
throw new ModelAuthError('claude did not accept the code in time: start again');
}
const now = await status();
if (!now.loggedIn) throw new ModelAuthError('the code was not accepted: start again and paste the new code');
return now;
},
cancelLogin() {
drop();
return Promise.resolve();
},
async logout() {
drop();
await oneShot(['auth', 'logout']);
return status();
},
};
}
+4
View File
@@ -159,6 +159,8 @@ const ModelEnv = z.object({
MODEL_TIMEOUT_MS: z.coerce.number().int().min(60_000).default(30 * 60_000),
/** Only this host may call /run. Empty permits local test/development callers. */
DRIVER_HOST: z.string().default('driver'),
/** The web app may also manage the sign-in (/auth/*), nothing else. Empty = only the driver. */
AUTH_HOST: z.string().default('server'),
});
export interface ModelConfig {
@@ -170,6 +172,7 @@ export interface ModelConfig {
readonly hlUrl: string;
readonly timeoutMs: number;
readonly driverHost: string | null;
readonly authHost: string | null;
}
export function loadModelConfig(env: Readonly<Record<string, string | undefined>>): ModelConfig {
@@ -185,5 +188,6 @@ export function loadModelConfig(env: Readonly<Record<string, string | undefined>
hlUrl: values.HL_URL,
timeoutMs: values.MODEL_TIMEOUT_MS,
driverHost: values.DRIVER_HOST === '' ? null : values.DRIVER_HOST,
authHost: values.AUTH_HOST === '' ? null : values.AUTH_HOST,
};
}
+19 -12
View File
@@ -1,10 +1,11 @@
// Process 4: the model sidecar. Runs the unmodified `claude` CLI logged in with the owner's account (CLAUDE_CONFIG_DIR, a
// private volume; the owner logs in once: `docker compose exec -it model claude` → /login). No Docker socket, no project
// private volume; the owner signs in from the app: Settings → Anthropic account, /auth/*). No Docker socket, no project
// files: in a repair session the model acts only through `hl` on the executor. The driver is its only caller.
import { runClaude } from '../app/model/claude.ts';
import { lookup } from 'node:dns/promises';
import { buildModelServer } from '../http/model-server.ts';
import { createConsoleLogger } from '../infra/console-logger.ts';
import { createClaudeAuth } from '../infra/model/claude-auth.ts';
import { createProcessRunner } from '../infra/process-runner.ts';
import { loadModelConfig } from './config.ts';
@@ -28,17 +29,21 @@ const deps = {
guardCommand: 'node /app/src/main/model-guard.ts',
};
let driverAddresses: { readonly at: number; readonly addresses: ReadonlySet<string> } | null = null;
const isDriver =
config.driverHost === null
? undefined
: async (address: string): Promise<boolean> => {
if (driverAddresses === null || Date.now() - driverAddresses.at > 30_000) {
const found = await lookup(config.driverHost ?? '', { all: true }).catch(() => []);
driverAddresses = { at: Date.now(), addresses: new Set(found.map((entry) => entry.address)) };
}
return driverAddresses.addresses.has(address.replace(/^::ffff:/, ''));
};
// Callers are told apart by the address their host name resolves to (looked up at most every 30 s).
function allowHosts(hosts: readonly (string | null)[]): ((address: string) => Promise<boolean>) | undefined {
const names = hosts.filter((host): host is string => host !== null);
if (names.length === 0) return undefined;
let known: { readonly at: number; readonly addresses: ReadonlySet<string> } | null = null;
return async (address) => {
if (known === null || Date.now() - known.at > 30_000) {
const found = await Promise.all(names.map((name) => lookup(name, { all: true }).catch(() => [])));
known = { at: Date.now(), addresses: new Set(found.flat().map((entry) => entry.address)) };
}
return known.addresses.has(address.replace(/^::ffff:/, ''));
};
}
const isDriver = config.driverHost === null ? undefined : allowHosts([config.driverHost]);
const mayManageLogin = config.driverHost === null ? undefined : allowHosts([config.driverHost, config.authHost]);
const app = await buildModelServer({
timeoutMs: config.timeoutMs,
@@ -50,6 +55,8 @@ const app = await buildModelServer({
return result;
},
...(isDriver === undefined ? {} : { isDriver }),
...(mayManageLogin === undefined ? {} : { mayManageLogin }),
auth: createClaudeAuth({ claudeBin: config.claudeBin, configDir: config.configDir, path: deps.path }),
});
for (const signal of ['SIGINT', 'SIGTERM'] as const) process.once(signal, () => void app.close());
await app.listen({ host: config.host, port: config.port });
+2
View File
@@ -18,6 +18,7 @@ import { createSqliteModelProfileStore } from '../infra/sqlite/model-profile-sto
import { createSqliteWorkflowRunInputStore } from '../infra/sqlite/workflow-run-input-store.ts';
import { createSqliteModelActivityStore } from '../infra/sqlite/model-activity-store.ts';
import { createFunctionRuntimeClient } from '../infra/runtime/function-runtime-client.ts';
import { createHttpModelAuthClient } from '../infra/http-model-auth-client.ts';
import { createHttpRegistryClient } from '../infra/http-registry.ts';
import { systemClock } from '../infra/system-clock.ts';
import { ensureAppWorkflows, ensureDefaultModelProfiles, ensureUpdatePolicy } from '../app/workflow-seed.ts';
@@ -86,6 +87,7 @@ const app = await buildServer({
startSystemRun: (workflowId) => startSystemRun(runDeps, workflowId),
schedules: { store: scheduleStore, timeZone: config.scheduleTimeZone },
registry: createHttpRegistryClient(),
...(config.modelUrls.claude === null ? {} : { claudeAuth: createHttpModelAuthClient({ baseUrl: config.modelUrls.claude }) }),
clock: systemClock,
hub: createChangeHub(),
dataVersion: () => dataVersion(db),
+7
View File
@@ -15,6 +15,8 @@ import {
WorkflowInstanceListResponse,
WorkflowListResponse,
WorkflowTimelineResponse,
ModelAuthLoginStart,
ModelAuthStatus,
ModelCallsResponse,
ModelProfileListResponse,
ReleaseNotesSourcesResponse,
@@ -116,6 +118,11 @@ export const api = {
call(z.object({ ok: z.boolean(), instance: z.unknown() }), `/api/workflow-instances/${encodeURIComponent(id)}/resume`, { expectedLifecycleSeq }),
cancelWorkflowInstance: (id: string, expectedLifecycleSeq: number) =>
call(z.object({ ok: z.boolean(), instance: z.unknown() }), `/api/workflow-instances/${encodeURIComponent(id)}/cancel`, { expectedLifecycleSeq }),
modelAuth: () => call(ModelAuthStatus, '/api/model-auth'),
startModelLogin: () => call(ModelAuthLoginStart, '/api/model-auth/login', {}),
submitModelLoginCode: (code: string) => call(ModelAuthStatus, '/api/model-auth/code', { code }),
cancelModelLogin: () => call(z.object({ ok: z.boolean() }), '/api/model-auth/cancel', {}),
modelLogout: () => call(ModelAuthStatus, '/api/model-auth/logout', {}),
modelCalls: (limit = 50) => call(ModelCallsResponse, `/api/model-calls?limit=${String(limit)}`),
modelProfiles: () => call(ModelProfileListResponse, '/api/model-profiles'),
releaseNotesSources: () => call(ReleaseNotesSourcesResponse, '/api/release-notes-sources'),
+30
View File
@@ -377,6 +377,36 @@ describe('other views', () => {
expect(text()).toContain('No real update, nothing failed, nothing waits for you.');
});
it('switches the Anthropic account: sign-in page, pasted code, new account shown', async () => {
let account: string | null = 'old@example.com';
const status = () => ({ loggedIn: account !== null, email: account, organization: null, method: account === null ? null : 'claude.ai' });
const codes: string[] = [];
server = await buildServer({
...sqliteServerDeps(),
claudeAuth: {
status: () => Promise.resolve(status()),
startLogin: () => Promise.resolve({ url: 'https://claude.com/oauth/authorize?x=1' }),
submitCode: (code) => (codes.push(code), account = 'new@example.com', Promise.resolve(status())),
cancelLogin: () => Promise.resolve(),
logout: () => (account = null, Promise.resolve(status())),
},
});
window.location.hash = '#/settings';
await eventually(() => expect(text()).toContain('old@example.com'));
button(container, 'Sign in with another account').click();
const link = await eventually(() => {
const found = container.querySelector<HTMLAnchorElement>('a[href^="https://claude.com/oauth/authorize"]');
if (found === null) throw new Error('no sign-in link');
return found;
});
expect(link.target).toBe('_blank');
await type(container.querySelector<HTMLInputElement>('input[autocomplete="off"]') as HTMLInputElement, 'abcdefgh#state');
button(container, 'Finish sign-in').click();
await eventually(() => expect(text()).toContain('new@example.com'));
expect(codes).toEqual(['abcdefgh#state']);
expect(text()).not.toContain('Finish sign-in');
});
it('jumps to an app from the palette opened with Ctrl+K', async () => {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true }));
const input = await eventually(() => {
+98 -2
View File
@@ -1,10 +1,10 @@
import { useState } from 'preact/hooks';
import { useContext, useState } from 'preact/hooks';
import { api } from '../api.ts';
import { ErrorNote, Loading } from '../components.tsx';
import { Card } from '../components/ui.tsx';
import { useToast } from '../components/dialogs.tsx';
import { formatInstant } from '../format.ts';
import { useResource } from '../hooks.ts';
import { LiveContext, useResource } from '../hooks.ts';
type Settings = Awaited<ReturnType<typeof api.notificationSettings>>['settings'];
@@ -69,6 +69,101 @@ function NotificationsForm({ initial, timeZone, lastDigestAt }: { readonly initi
);
}
type AuthStatus = Awaited<ReturnType<typeof api.modelAuth>>;
/** Who the Claude sidecar is signed in as, and the sign-in that replaces it: open the page, sign in, paste the code it shows. */
function AnthropicAccount() {
const { data, error } = useResource(() => api.modelAuth(), []);
const { refresh } = useContext(LiveContext);
const [loginUrl, setLoginUrl] = useState<string | null>(null);
const [code, setCode] = useState('');
const [busy, setBusy] = useState(false);
const [problem, setProblem] = useState<string | null>(null);
const toast = useToast();
const attempt = async (work: () => Promise<void>) => {
setBusy(true);
setProblem(null);
try {
await work();
} catch (cause) {
setProblem(cause instanceof Error ? cause.message : 'Something went wrong');
} finally {
setBusy(false);
}
};
const start = () => attempt(async () => setLoginUrl((await api.startModelLogin()).url));
const cancel = () => attempt(async () => {
await api.cancelModelLogin();
setLoginUrl(null);
setCode('');
});
const finish = (event: Event) => {
event.preventDefault();
return attempt(async () => {
const status = await api.submitModelLoginCode(code);
setLoginUrl(null);
setCode('');
toast(`Signed in as ${status.email ?? 'the new account'}.`);
refresh();
});
};
const signOut = () => attempt(async () => {
await api.modelLogout();
toast('Signed out.');
refresh();
});
const status: AuthStatus | null = data;
return (
<Card title="Anthropic account" icon="models">
{error === null ? null : <ErrorNote message={error} />}
{status === null ? (error === null ? <Loading /> : null) : (
<p>
{status.loggedIn ? (
<>
Signed in as <strong>{status.email ?? 'an unknown account'}</strong>
{status.organization === null ? '' : ` (${status.organization})`}
{status.method === null ? '' : ` · ${status.method}`}
</>
) : (
<strong>Not signed in: model runs with Claude fail until you sign in.</strong>
)}
</p>
)}
{loginUrl === null ? (
<div class="actions">
<button type="button" class="primary" disabled={busy || status === null} onClick={() => void start()}>
{status?.loggedIn === true ? 'Sign in with another account' : 'Sign in'}
</button>
{status?.loggedIn === true && (
<button type="button" disabled={busy} onClick={() => void signOut()}>
Sign out
</button>
)}
</div>
) : (
<form class="settings-form" onSubmit={(event) => void finish(event)}>
<p class="muted">
1. <a href={loginUrl} target="_blank" rel="noreferrer noopener">Open the Anthropic sign-in page</a> and sign in with the account you want. 2. Paste the code it shows here.
</p>
<label>
Code
<input type="text" value={code} autocomplete="off" spellcheck={false} required onInput={(event) => setCode((event.target as HTMLInputElement).value)} />
</label>
<div class="actions">
<button type="submit" class="primary" disabled={busy || code.trim().length < 8}>
{busy ? 'Checking…' : 'Finish sign-in'}
</button>
<button type="button" disabled={busy} onClick={() => void cancel()}>
Cancel
</button>
</div>
</form>
)}
{problem === null ? null : <ErrorNote message={problem} />}
</Card>
);
}
export function SettingsView() {
const { data, error } = useResource(() => api.notificationSettings(), []);
return (
@@ -83,6 +178,7 @@ export function SettingsView() {
{error === null ? null : <ErrorNote message={error} />}
{data === null ? (error === null ? <Loading /> : null) : <NotificationsForm key={JSON.stringify(data.settings)} initial={data.settings} timeZone={data.timeZone} lastDigestAt={data.lastDigestAt} />}
</Card>
<AnthropicAccount />
</section>
);
}