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:
1 parent
765a1e0eee
commit
8ea9f884f1
16 files changed
+588
-18
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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
@@ -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 } });
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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', {}),
|
||||
};
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user