@stage-labs/metro 0.1.0-beta.310 → 0.1.0-beta.312
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/claude-env.js +1 -1
- package/dist/route.js +1 -1
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/core/package.json +1 -0
- package/runtime/node_modules/@metro-labs/core/src/claude-token.ts +3 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +1 -3
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +1 -36
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +4 -13
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/delivered.ts +16 -9
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/effective.ts +4 -15
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/fallback.ts +5 -4
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +6 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +66 -67
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +18 -32
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +15 -46
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-leftovers.ts +24 -3
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/routeway.ts +19 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/subscription.ts +1 -61
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage-refresh.ts +11 -55
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +1 -66
- package/runtime/node_modules/@metro-labs/http/src/api-http.ts +1 -1
- package/runtime/node_modules/@metro-labs/http/src/connector-tools.ts +7 -1
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +1 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/claude-token.ts +3 -0
- package/runtime/sdk-runner/runner.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +0 -243
- package/runtime/node_modules/@metro-labs/daemon/src/claude/onboarding.ts +0 -38
- package/runtime/node_modules/@metro-labs/daemon/src/claude/usage-probe.ts +0 -128
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-logins.ts +0 -192
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-store.ts +0 -59
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-lists.ts +0 -40
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/provider-models.ts +0 -77
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import { spawnSync } from 'node:child_process';
|
|
3
|
-
import { markOnboardingDone } from './onboarding.js';
|
|
4
|
-
import { ApiError } from '@metro-labs/http/api-error';
|
|
5
|
-
import { isRecord } from '@metro-labs/core/is-record';
|
|
6
|
-
import { errMsg, log } from '@metro-labs/core/log';
|
|
7
|
-
import { agentCommand, asAgent, CLAUDE_ENV, claudeBin } from '../agent-user/user.js';
|
|
8
|
-
|
|
9
|
-
const LOGIN_ARGS = ['auth', 'login', '--claudeai'];
|
|
10
|
-
const STATUS_COMMAND = ['auth', 'status', '--json'];
|
|
11
|
-
const TTL_MS = 10 * 60_000;
|
|
12
|
-
const OUTPUT_MAX = 8_000;
|
|
13
|
-
const COLS = 100;
|
|
14
|
-
const ROWS = 40;
|
|
15
|
-
const ESC = String.fromCharCode(27);
|
|
16
|
-
const CSI = /^\[[0-9;?]*[a-zA-Z]/;
|
|
17
|
-
const URL_RE = /https:\/\/[^\s"'`]+/g;
|
|
18
|
-
|
|
19
|
-
type LoginState = 'pending' | 'done' | 'failed';
|
|
20
|
-
|
|
21
|
-
export interface LoginView {
|
|
22
|
-
id: string;
|
|
23
|
-
state: LoginState;
|
|
24
|
-
url: string | null;
|
|
25
|
-
output: string;
|
|
26
|
-
error: string | null;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface LoginTarget {
|
|
30
|
-
key: string;
|
|
31
|
-
env: Record<string, string>;
|
|
32
|
-
settle: (ok: boolean) => Promise<string | null>;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
interface Live {
|
|
36
|
-
id: string;
|
|
37
|
-
startedAt: number;
|
|
38
|
-
state: LoginState;
|
|
39
|
-
output: string;
|
|
40
|
-
error: string | null;
|
|
41
|
-
target: LoginTarget | null;
|
|
42
|
-
write: (text: string) => void;
|
|
43
|
-
kill: () => void;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface LoginDeps {
|
|
47
|
-
command?: string[];
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
let live: Live | null = null;
|
|
51
|
-
|
|
52
|
-
export function plainText(raw: string): string {
|
|
53
|
-
return raw
|
|
54
|
-
.split(ESC)
|
|
55
|
-
.map((part, index) => (index === 0 ? part : part.replace(CSI, '')))
|
|
56
|
-
.join('');
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function loginUrlIn(output: string): string | null {
|
|
60
|
-
const found = output.match(URL_RE) ?? [];
|
|
61
|
-
const trimmed = found.map((url) => url.replace(/[.,)]+$/, ''));
|
|
62
|
-
return trimmed.find((url) => url.includes('claude.ai') || url.includes('anthropic.com')) ?? trimmed[0] ?? null;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const view = (session: Live): LoginView => ({
|
|
66
|
-
id: session.id,
|
|
67
|
-
state: session.state,
|
|
68
|
-
url: loginUrlIn(session.output),
|
|
69
|
-
output: session.output,
|
|
70
|
-
error: session.error,
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
function sweep(now: number): void {
|
|
74
|
-
if (live !== null && live.state !== 'pending') return;
|
|
75
|
-
if (live !== null && now - live.startedAt > TTL_MS) {
|
|
76
|
-
live.kill();
|
|
77
|
-
live = null;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const CHECK_MS = 30_000;
|
|
82
|
-
|
|
83
|
-
interface Checked<T> {
|
|
84
|
-
at: number;
|
|
85
|
-
value: T;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const checks: { installed?: Checked<boolean>; account?: Checked<ClaudeAccount> } = {};
|
|
89
|
-
|
|
90
|
-
function remembered<T>(slot: Checked<T> | undefined, read: () => T, keep: (c: Checked<T>) => void): T {
|
|
91
|
-
const now = Date.now();
|
|
92
|
-
if (slot !== undefined && now - slot.at < CHECK_MS) return slot.value;
|
|
93
|
-
const value = read();
|
|
94
|
-
keep({ at: now, value });
|
|
95
|
-
return value;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export function forgetClaudeChecks(): void {
|
|
99
|
-
delete checks.installed;
|
|
100
|
-
delete checks.account;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export function claudeInstalled(): boolean {
|
|
104
|
-
return remembered(checks.installed, () => {
|
|
105
|
-
const run = spawnSync(...asAgent(claudeBin(), ['--version'], CLAUDE_ENV), { stdio: 'ignore', env: { ...process.env, ...CLAUDE_ENV } });
|
|
106
|
-
return run.error === undefined && run.status === 0;
|
|
107
|
-
}, (c) => {
|
|
108
|
-
checks.installed = c;
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
export interface ClaudeAccount {
|
|
113
|
-
signedIn: boolean;
|
|
114
|
-
account: string | null;
|
|
115
|
-
plan: string | null;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const SIGNED_OUT: ClaudeAccount = { signedIn: false, account: null, plan: null };
|
|
119
|
-
|
|
120
|
-
export function claudeAccount(): ClaudeAccount {
|
|
121
|
-
return remembered(checks.account, () => claudeAccountIn({}), (c) => {
|
|
122
|
-
checks.account = c;
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const textOf = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null);
|
|
127
|
-
|
|
128
|
-
export function claudeAccountIn(extra: Record<string, string>): ClaudeAccount {
|
|
129
|
-
const env = { ...CLAUDE_ENV, ...extra };
|
|
130
|
-
const run = spawnSync(...asAgent(claudeBin(), STATUS_COMMAND, env), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: { ...process.env, ...env } });
|
|
131
|
-
if (run.error !== undefined || typeof run.stdout !== 'string') return SIGNED_OUT;
|
|
132
|
-
try {
|
|
133
|
-
const parsed: unknown = JSON.parse(run.stdout);
|
|
134
|
-
if (!isRecord(parsed)) return SIGNED_OUT;
|
|
135
|
-
const account = textOf(parsed.email) ?? textOf(parsed.organization) ?? textOf(parsed.orgName);
|
|
136
|
-
return { signedIn: parsed.authenticated === true || parsed.loggedIn === true || account !== null, account, plan: textOf(parsed.subscriptionType) };
|
|
137
|
-
} catch {
|
|
138
|
-
return SIGNED_OUT;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function note(session: Live, chunk: string): void {
|
|
143
|
-
const next = session.output + plainText(chunk);
|
|
144
|
-
session.output = next.length > OUTPUT_MAX ? next.slice(next.length - OUTPUT_MAX) : next;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
async function verdict(session: Live, code: number): Promise<string | null> {
|
|
148
|
-
const problem = code === 0 ? null : `the login ended with status ${String(code)}`;
|
|
149
|
-
if (session.target === null) return problem;
|
|
150
|
-
try {
|
|
151
|
-
return (await session.target.settle(problem === null)) ?? problem;
|
|
152
|
-
} catch (err) {
|
|
153
|
-
return errMsg(err);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function runningFor(target: LoginTarget | null): Live | null {
|
|
158
|
-
if (live === null || live.state !== 'pending') return null;
|
|
159
|
-
if (live.target?.key === target?.key) return live;
|
|
160
|
-
live.kill();
|
|
161
|
-
live = null;
|
|
162
|
-
return null;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
export function startClaudeLogin(deps: LoginDeps = {}, now = Date.now(), target: LoginTarget | null = null): LoginView {
|
|
166
|
-
sweep(now);
|
|
167
|
-
const running = runningFor(target);
|
|
168
|
-
if (running !== null) return view(running);
|
|
169
|
-
const env = { TERM: 'xterm-256color', ...CLAUDE_ENV, ...target?.env };
|
|
170
|
-
const command = deps.command ?? agentCommand([claudeBin(), ...LOGIN_ARGS], env);
|
|
171
|
-
const session: Live = {
|
|
172
|
-
id: randomBytes(9).toString('base64url'),
|
|
173
|
-
startedAt: now,
|
|
174
|
-
state: 'pending',
|
|
175
|
-
output: '',
|
|
176
|
-
error: null,
|
|
177
|
-
target,
|
|
178
|
-
write: () => undefined,
|
|
179
|
-
kill: () => undefined,
|
|
180
|
-
};
|
|
181
|
-
const terminal = new Bun.Terminal({
|
|
182
|
-
cols: COLS,
|
|
183
|
-
rows: ROWS,
|
|
184
|
-
data: (_term, chunk: Uint8Array) => {
|
|
185
|
-
note(session, Buffer.from(chunk).toString('utf8'));
|
|
186
|
-
},
|
|
187
|
-
});
|
|
188
|
-
const proc = Bun.spawn(command, { terminal, env: { ...process.env, ...env } });
|
|
189
|
-
session.write = (text: string) => {
|
|
190
|
-
terminal.write(Buffer.from(`${text}\r`, 'utf8'));
|
|
191
|
-
};
|
|
192
|
-
session.kill = () => {
|
|
193
|
-
proc.kill();
|
|
194
|
-
terminal.close();
|
|
195
|
-
};
|
|
196
|
-
proc.exited
|
|
197
|
-
.then(async (code) => {
|
|
198
|
-
session.write = () => undefined;
|
|
199
|
-
terminal.close();
|
|
200
|
-
forgetClaudeChecks();
|
|
201
|
-
session.error = await verdict(session, code);
|
|
202
|
-
session.state = session.error === null ? 'done' : 'failed';
|
|
203
|
-
const onboarding = session.error === null ? markOnboardingDone() : 'skipped';
|
|
204
|
-
log.info({ state: session.state, onboarding, own: session.target !== null }, 'claude-login: the official login finished');
|
|
205
|
-
})
|
|
206
|
-
.catch((err: unknown) => {
|
|
207
|
-
session.state = 'failed';
|
|
208
|
-
session.error = errMsg(err);
|
|
209
|
-
});
|
|
210
|
-
live = session;
|
|
211
|
-
log.info({ command: command[0] }, 'claude-login: started');
|
|
212
|
-
return view(session);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function current(id: string): Live {
|
|
216
|
-
const session = live;
|
|
217
|
-
if (session?.id !== id) throw new ApiError('that sign-in is over; start another one', 404);
|
|
218
|
-
return session;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
export function claudeLoginView(id: string, now = Date.now()): LoginView {
|
|
222
|
-
sweep(now);
|
|
223
|
-
return view(current(id));
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
export function answerClaudeLogin(id: string, text: string): LoginView {
|
|
227
|
-
const session = current(id);
|
|
228
|
-
if (session.state !== 'pending') throw new ApiError('that sign-in has already finished', 409);
|
|
229
|
-
session.write(text);
|
|
230
|
-
return view(session);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
export function endClaudeLogin(id: string): { ended: true } {
|
|
234
|
-
const session = current(id);
|
|
235
|
-
session.kill();
|
|
236
|
-
live = null;
|
|
237
|
-
return { ended: true };
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
export const forgetClaudeLogin = (): void => {
|
|
241
|
-
live?.kill();
|
|
242
|
-
live = null;
|
|
243
|
-
};
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from '../agent-user/agent-fs.js';
|
|
2
|
-
import { homedir } from 'node:os';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { isRecord } from '@metro-labs/core/is-record';
|
|
5
|
-
import { writeHomeText } from '../agent-user/home-fs.js';
|
|
6
|
-
import { agentUser } from '../agent-user/user.js';
|
|
7
|
-
|
|
8
|
-
const FILE = '.claude.json';
|
|
9
|
-
const FLAG = 'hasCompletedOnboarding';
|
|
10
|
-
|
|
11
|
-
export type OnboardingMark = 'marked' | 'already' | 'unreadable';
|
|
12
|
-
|
|
13
|
-
const given = (value: string | undefined): string | undefined =>
|
|
14
|
-
value !== undefined && value.trim() !== '' ? value.trim() : undefined;
|
|
15
|
-
|
|
16
|
-
export function claudeConfigPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
17
|
-
const user = agentUser();
|
|
18
|
-
if (user !== null) return join(user.home, FILE);
|
|
19
|
-
return join(given(env.CLAUDE_CONFIG_DIR) ?? given(env.HOME) ?? homedir(), FILE);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function readConfig(path: string): Record<string, unknown> | null {
|
|
23
|
-
if (!existsSync(path)) return {};
|
|
24
|
-
try {
|
|
25
|
-
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'));
|
|
26
|
-
return isRecord(parsed) ? parsed : null;
|
|
27
|
-
} catch {
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function markOnboardingDone(path = claudeConfigPath()): OnboardingMark {
|
|
33
|
-
const config = readConfig(path);
|
|
34
|
-
if (config === null) return 'unreadable';
|
|
35
|
-
if (config[FLAG] === true) return 'already';
|
|
36
|
-
writeHomeText(path, `${JSON.stringify({ ...config, [FLAG]: true }, null, 2)}\n`, 0o600);
|
|
37
|
-
return 'marked';
|
|
38
|
-
}
|
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
|
-
import { errMsg, log } from '@metro-labs/core/log';
|
|
4
|
-
import { agentCommand, agentUser, agentUserExpected, CLAUDE_ENV, claudeBin } from '../agent-user/user.js';
|
|
5
|
-
import { runningAsRoot } from '../metro-user/privilege.js';
|
|
6
|
-
import { PRIVACY_ENV } from './setup.js';
|
|
7
|
-
|
|
8
|
-
const REQUEST_ID = 'metro-usage';
|
|
9
|
-
const REQUEST = `${JSON.stringify({ type: 'control_request', request_id: REQUEST_ID, request: { subtype: 'get_usage', skip_behaviors: true } })}\n`;
|
|
10
|
-
const ARGS = [
|
|
11
|
-
'-p',
|
|
12
|
-
'--setting-sources',
|
|
13
|
-
'project',
|
|
14
|
-
'--strict-mcp-config',
|
|
15
|
-
'--mcp-config',
|
|
16
|
-
'{"mcpServers":{}}',
|
|
17
|
-
'--no-session-persistence',
|
|
18
|
-
'--input-format',
|
|
19
|
-
'stream-json',
|
|
20
|
-
'--output-format',
|
|
21
|
-
'stream-json',
|
|
22
|
-
'--verbose',
|
|
23
|
-
];
|
|
24
|
-
const BLOCKS_USAGE = 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC';
|
|
25
|
-
const PROBE_ENV: Record<string, string> = {
|
|
26
|
-
...Object.fromEntries(Object.entries(PRIVACY_ENV).filter(([name]) => name !== BLOCKS_USAGE)),
|
|
27
|
-
...CLAUDE_ENV,
|
|
28
|
-
DISABLE_BUG_COMMAND: '1',
|
|
29
|
-
ENABLE_CLAUDEAI_MCP_SERVERS: 'false',
|
|
30
|
-
};
|
|
31
|
-
const TIMEOUT_MS = 20_000;
|
|
32
|
-
const KILL_GRACE_MS = 2_000;
|
|
33
|
-
const LINE_MAX = 1024 * 1024;
|
|
34
|
-
|
|
35
|
-
export interface UsageProbeDeps {
|
|
36
|
-
command?: string[];
|
|
37
|
-
timeoutMs?: number;
|
|
38
|
-
env?: Record<string, string>;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function usageProbeCommand(env: Record<string, string> = {}): string[] | null {
|
|
42
|
-
if (runningAsRoot() || (agentUserExpected() && agentUser() === null)) return null;
|
|
43
|
-
return agentCommand([claudeBin(), ...ARGS], { ...PROBE_ENV, ...env });
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function answerOf(line: string): Record<string, unknown> | null {
|
|
47
|
-
try {
|
|
48
|
-
const parsed: unknown = JSON.parse(line);
|
|
49
|
-
if (!isRecord(parsed) || parsed.type !== 'control_response' || !isRecord(parsed.response)) return null;
|
|
50
|
-
return parsed.response.request_id === REQUEST_ID ? parsed.response : null;
|
|
51
|
-
} catch {
|
|
52
|
-
return null;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function limitsOf(answer: Record<string, unknown>): unknown {
|
|
57
|
-
if (answer.subtype !== 'success') throw new Error(`Claude Code refused the usage request: ${typeof answer.error === 'string' ? answer.error : 'no reason given'}`);
|
|
58
|
-
return isRecord(answer.response) ? (answer.response.rate_limits ?? null) : null;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function usageIn(output: string): unknown {
|
|
62
|
-
const answer = output
|
|
63
|
-
.split('\n')
|
|
64
|
-
.map(answerOf)
|
|
65
|
-
.find((found) => found !== null);
|
|
66
|
-
if (answer === undefined || answer === null) throw new Error('Claude Code gave no usage answer');
|
|
67
|
-
return limitsOf(answer);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function readClaudeUsage(deps: UsageProbeDeps = {}): Promise<unknown> {
|
|
71
|
-
const command = deps.command ?? usageProbeCommand(deps.env);
|
|
72
|
-
if (command === null) return Promise.reject(new Error('Claude Code runs only as the agent user, and this box has none'));
|
|
73
|
-
const [file = '', ...args] = command;
|
|
74
|
-
return new Promise((resolve, reject) => {
|
|
75
|
-
const child = spawn(file, args, { cwd: '/', stdio: ['pipe', 'pipe', 'ignore'], env: { ...process.env, ...PROBE_ENV, ...deps.env } });
|
|
76
|
-
let carry = '';
|
|
77
|
-
let settled = false;
|
|
78
|
-
let hardKill: ReturnType<typeof setTimeout> | undefined;
|
|
79
|
-
const settle = (run: () => void): void => {
|
|
80
|
-
if (settled) return;
|
|
81
|
-
settled = true;
|
|
82
|
-
run();
|
|
83
|
-
};
|
|
84
|
-
const overdue = setTimeout(() => {
|
|
85
|
-
settle(() => {
|
|
86
|
-
reject(new Error('Claude Code did not report the usage in time'));
|
|
87
|
-
});
|
|
88
|
-
child.stdout.destroy();
|
|
89
|
-
child.kill('SIGTERM');
|
|
90
|
-
hardKill = setTimeout(() => child.kill('SIGKILL'), KILL_GRACE_MS);
|
|
91
|
-
}, deps.timeoutMs ?? TIMEOUT_MS);
|
|
92
|
-
const take = (line: string): void => {
|
|
93
|
-
const answer = answerOf(line);
|
|
94
|
-
if (answer === null) return;
|
|
95
|
-
settle(() => {
|
|
96
|
-
try {
|
|
97
|
-
resolve(limitsOf(answer));
|
|
98
|
-
} catch (err) {
|
|
99
|
-
reject(err instanceof Error ? err : new Error(errMsg(err)));
|
|
100
|
-
}
|
|
101
|
-
});
|
|
102
|
-
};
|
|
103
|
-
child.stdout.on('data', (chunk: Buffer) => {
|
|
104
|
-
const lines = (carry + chunk.toString('utf8')).split('\n');
|
|
105
|
-
carry = lines.pop() ?? '';
|
|
106
|
-
if (carry.length > LINE_MAX) carry = '';
|
|
107
|
-
lines.forEach(take);
|
|
108
|
-
});
|
|
109
|
-
child.stdin.on('error', (err) => {
|
|
110
|
-
log.debug({ err: errMsg(err) }, 'claude-usage: Claude Code closed its input early');
|
|
111
|
-
});
|
|
112
|
-
child.on('error', (err) => {
|
|
113
|
-
clearTimeout(overdue);
|
|
114
|
-
settle(() => {
|
|
115
|
-
reject(err);
|
|
116
|
-
});
|
|
117
|
-
});
|
|
118
|
-
child.on('close', () => {
|
|
119
|
-
clearTimeout(overdue);
|
|
120
|
-
clearTimeout(hardKill);
|
|
121
|
-
take(carry);
|
|
122
|
-
settle(() => {
|
|
123
|
-
reject(new Error('Claude Code gave no usage answer'));
|
|
124
|
-
});
|
|
125
|
-
});
|
|
126
|
-
child.stdin.end(REQUEST);
|
|
127
|
-
});
|
|
128
|
-
}
|
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
import { homedir } from 'node:os';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { ID_RE, newId } from '@metro-labs/core/ids';
|
|
4
|
-
import { isRecord } from '@metro-labs/core/is-record';
|
|
5
|
-
import { errMsg, log } from '@metro-labs/core/log';
|
|
6
|
-
import { ApiError } from '@metro-labs/http/api-error';
|
|
7
|
-
import { readFileSync } from '../agent-user/agent-fs.js';
|
|
8
|
-
import { moveHome, removeHome } from '../agent-user/home-fs.js';
|
|
9
|
-
import { claudeHome } from '../agent-user/user.js';
|
|
10
|
-
import { claudeAccountIn, type ClaudeAccount, type LoginTarget } from '../claude/login.js';
|
|
11
|
-
import { readClaudeUsage } from '../claude/usage-probe.js';
|
|
12
|
-
import { currentOf, reach, refreshed, type TokenSource, type TokenState } from './subscription.js';
|
|
13
|
-
import { forgetOne, forgetReported } from './usage.js';
|
|
14
|
-
import { readClaudeLogins, writeClaudeLogins, type ClaudeLogin } from './claude-store.js';
|
|
15
|
-
import { loginRef } from './model-config.js';
|
|
16
|
-
|
|
17
|
-
const CREDENTIALS = '.credentials.json';
|
|
18
|
-
const RENEW_MARGIN_MS = 60_000;
|
|
19
|
-
const NEW = 'new';
|
|
20
|
-
const MISSING = 'this Claude login is no longer on the box: sign in again on the Model page';
|
|
21
|
-
|
|
22
|
-
export interface ClaudeLoginDeps {
|
|
23
|
-
root?: string;
|
|
24
|
-
probe?: (env: Record<string, string>) => Promise<unknown>;
|
|
25
|
-
account?: (env: Record<string, string>) => ClaudeAccount;
|
|
26
|
-
dir?: string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
interface ClaudeTokens {
|
|
30
|
-
accessToken: string;
|
|
31
|
-
expiresAt: number | null;
|
|
32
|
-
savedAt: string;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const state: TokenState<ClaudeTokens> = new Map();
|
|
36
|
-
const seen = new Map<string, ClaudeTokens>();
|
|
37
|
-
|
|
38
|
-
const claudeLoginsRoot = (): string => join(claudeHome() ?? homedir(), '.metro', 'claude-logins');
|
|
39
|
-
|
|
40
|
-
const rootOf = (deps: ClaudeLoginDeps): string => deps.root ?? claudeLoginsRoot();
|
|
41
|
-
|
|
42
|
-
function claudeLoginEnv(login: string, deps: ClaudeLoginDeps = {}): Record<string, string> {
|
|
43
|
-
if (!ID_RE.test(login)) throw new Error('not a Claude login id');
|
|
44
|
-
return { CLAUDE_CONFIG_DIR: join(rootOf(deps), login) };
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export const probeClaudeLogin = (login: string, deps: ClaudeLoginDeps = {}): Promise<unknown> =>
|
|
48
|
-
(deps.probe ?? ((env) => readClaudeUsage({ env })))(claudeLoginEnv(login, deps));
|
|
49
|
-
|
|
50
|
-
const stale = (tokens: ClaudeTokens, margin = RENEW_MARGIN_MS): boolean => tokens.expiresAt !== null && tokens.expiresAt - margin <= Date.now();
|
|
51
|
-
|
|
52
|
-
function onDisk(login: string, deps: ClaudeLoginDeps): ClaudeTokens | null {
|
|
53
|
-
let raw: unknown;
|
|
54
|
-
try {
|
|
55
|
-
raw = JSON.parse(readFileSync(join(claudeLoginEnv(login, deps).CLAUDE_CONFIG_DIR ?? '', CREDENTIALS), 'utf8'));
|
|
56
|
-
} catch {
|
|
57
|
-
return null;
|
|
58
|
-
}
|
|
59
|
-
const oauth = isRecord(raw) && isRecord(raw.claudeAiOauth) ? raw.claudeAiOauth : {};
|
|
60
|
-
const accessToken = typeof oauth.accessToken === 'string' ? oauth.accessToken : '';
|
|
61
|
-
const expiresAt = typeof oauth.expiresAt === 'number' && Number.isFinite(oauth.expiresAt) ? oauth.expiresAt : null;
|
|
62
|
-
return accessToken === '' ? null : { accessToken, expiresAt, savedAt: new Date(expiresAt ?? 0).toISOString() };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function remembered(login: string, tokens: ClaudeTokens | null): ClaudeTokens | null {
|
|
66
|
-
if (tokens === null) seen.delete(login);
|
|
67
|
-
else seen.set(login, tokens);
|
|
68
|
-
return tokens;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function known(login: string, deps: ClaudeLoginDeps): ClaudeTokens | null {
|
|
72
|
-
const kept = seen.get(login);
|
|
73
|
-
if (kept !== undefined && !stale(kept)) return kept;
|
|
74
|
-
const read = onDisk(login, deps);
|
|
75
|
-
return read === null && kept !== undefined ? kept : remembered(login, read);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function sourceFor(login: string, deps: ClaudeLoginDeps): TokenSource<ClaudeTokens> {
|
|
79
|
-
return {
|
|
80
|
-
label: 'Claude',
|
|
81
|
-
stale: (tokens) => stale(tokens),
|
|
82
|
-
refresh: async () => {
|
|
83
|
-
try {
|
|
84
|
-
await probeClaudeLogin(login, deps);
|
|
85
|
-
} catch (err) {
|
|
86
|
-
log.warn({ err: errMsg(err) }, 'claude-logins: Claude Code stumbled while renewing a login, so its folder is read as it is');
|
|
87
|
-
}
|
|
88
|
-
const fresh = onDisk(login, deps);
|
|
89
|
-
if (fresh === null || stale(fresh, 0)) throw new Error('Claude Code could not renew this login');
|
|
90
|
-
return fresh;
|
|
91
|
-
},
|
|
92
|
-
save: (id, tokens) => {
|
|
93
|
-
remembered(id, tokens);
|
|
94
|
-
},
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function again(login: string, failed: ClaudeTokens, deps: ClaudeLoginDeps, source: TokenSource<ClaudeTokens>): Promise<ClaudeTokens> {
|
|
99
|
-
const disk = onDisk(login, deps);
|
|
100
|
-
if (disk !== null && disk.accessToken !== failed.accessToken && !stale(disk)) return Promise.resolve(remembered(login, disk) ?? disk);
|
|
101
|
-
return refreshed(state, login, failed, source);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export function withClaudeLogin(login: string, deps: ClaudeLoginDeps, send: (token: string) => Promise<Response>): Promise<Response> {
|
|
105
|
-
const source = sourceFor(login, deps);
|
|
106
|
-
return reach(
|
|
107
|
-
() => currentOf(state, login, known(login, deps), source, MISSING),
|
|
108
|
-
(failed) => again(login, failed, deps, source),
|
|
109
|
-
(tokens) => send(tokens.accessToken),
|
|
110
|
-
);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const folderOf = (login: string, deps: ClaudeLoginDeps): string => claudeLoginEnv(login, deps).CLAUDE_CONFIG_DIR ?? '';
|
|
114
|
-
|
|
115
|
-
function forgetTokens(login: string): void {
|
|
116
|
-
seen.delete(login);
|
|
117
|
-
state.delete(login);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function dropFolder(login: string, deps: ClaudeLoginDeps): void {
|
|
121
|
-
forgetTokens(login);
|
|
122
|
-
try {
|
|
123
|
-
removeHome(folderOf(login, deps), true);
|
|
124
|
-
} catch (err) {
|
|
125
|
-
log.warn({ err: errMsg(err) }, 'claude-logins: could not remove the folder of a Claude login that is no longer used');
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
export function removeClaudeLogin(login: string, deps: ClaudeLoginDeps = {}): void {
|
|
130
|
-
const logins = readClaudeLogins(deps.dir);
|
|
131
|
-
if (!logins.some((l) => l.id === login)) throw new ApiError('no such Claude login on this box', 404);
|
|
132
|
-
writeClaudeLogins(logins.filter((l) => l.id !== login), deps.dir);
|
|
133
|
-
dropFolder(login, deps);
|
|
134
|
-
forgetOne(loginRef(login));
|
|
135
|
-
log.info({ login }, 'claude-logins: a Claude login was removed from this box');
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function takeOver(fresh: string, replaced: string, deps: ClaudeLoginDeps): void {
|
|
139
|
-
const aside = `${folderOf(replaced, deps)}.old-${newId()}`;
|
|
140
|
-
moveHome(folderOf(replaced, deps), aside);
|
|
141
|
-
try {
|
|
142
|
-
moveHome(folderOf(fresh, deps), folderOf(replaced, deps));
|
|
143
|
-
} catch (err) {
|
|
144
|
-
moveHome(aside, folderOf(replaced, deps));
|
|
145
|
-
throw err;
|
|
146
|
-
}
|
|
147
|
-
forgetTokens(replaced);
|
|
148
|
-
try {
|
|
149
|
-
removeHome(aside, true);
|
|
150
|
-
} catch (err) {
|
|
151
|
-
log.warn({ err: errMsg(err) }, 'claude-logins: could not remove the folder of the login that was signed in again');
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function keep(login: string, replacing: string | null, account: ClaudeAccount, deps: ClaudeLoginDeps): string | null {
|
|
156
|
-
const logins = readClaudeLogins(deps.dir);
|
|
157
|
-
if (replacing !== null && !logins.some((l) => l.id === replacing)) return 'that Claude login was removed during the sign-in';
|
|
158
|
-
if (replacing !== null) takeOver(login, replacing, deps);
|
|
159
|
-
const entry: ClaudeLogin = { id: replacing ?? login, email: account.account, plan: account.plan, savedAt: new Date().toISOString() };
|
|
160
|
-
writeClaudeLogins(replacing === null ? [...logins, entry] : logins.map((l) => (l.id === replacing ? entry : l)), deps.dir);
|
|
161
|
-
forgetReported(loginRef(entry.id));
|
|
162
|
-
log.info({ login: entry.id, plan: account.plan, added: replacing === null }, 'claude-logins: the Claude login was kept on this box');
|
|
163
|
-
return null;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function settle(ok: boolean, login: string, replacing: string | null, deps: ClaudeLoginDeps): Promise<string | null> {
|
|
167
|
-
if (!ok) {
|
|
168
|
-
dropFolder(login, deps);
|
|
169
|
-
return Promise.resolve(null);
|
|
170
|
-
}
|
|
171
|
-
let problem: string | null;
|
|
172
|
-
try {
|
|
173
|
-
const account = (deps.account ?? claudeAccountIn)(claudeLoginEnv(login, deps));
|
|
174
|
-
problem = account.signedIn ? keep(login, replacing, account, deps) : 'the sign-in finished but Claude Code holds no login; try again';
|
|
175
|
-
} catch (err) {
|
|
176
|
-
problem = errMsg(err);
|
|
177
|
-
}
|
|
178
|
-
if (problem !== null) dropFolder(login, deps);
|
|
179
|
-
return Promise.resolve(problem);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
export function claudeLoginTarget(asked: string, deps: ClaudeLoginDeps = {}): LoginTarget {
|
|
183
|
-
const replacing = asked === NEW ? null : asked;
|
|
184
|
-
if (replacing !== null && !readClaudeLogins(deps.dir).some((l) => l.id === replacing)) throw new ApiError('no such Claude login on this box', 404);
|
|
185
|
-
const login = newId();
|
|
186
|
-
return { key: asked, env: claudeLoginEnv(login, deps), settle: (ok) => settle(ok, login, replacing, deps) };
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
export function forgetClaudeTokens(): void {
|
|
190
|
-
seen.clear();
|
|
191
|
-
state.clear();
|
|
192
|
-
}
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import { join } from 'node:path';
|
|
2
|
-
import { ID_RE } from '@metro-labs/core/ids';
|
|
3
|
-
import { isRecord } from '@metro-labs/core/is-record';
|
|
4
|
-
import { errMsg, log } from '@metro-labs/core/log';
|
|
5
|
-
import { readJson, writeSecure } from '@metro-labs/core/secure-fs';
|
|
6
|
-
import { agentsDir } from '../agents/files.js';
|
|
7
|
-
|
|
8
|
-
export interface ClaudeLogin {
|
|
9
|
-
id: string;
|
|
10
|
-
email: string | null;
|
|
11
|
-
plan: string | null;
|
|
12
|
-
savedAt: string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const MODEL_FILE = 'model.json';
|
|
16
|
-
|
|
17
|
-
const text = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
|
|
18
|
-
const maybe = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null);
|
|
19
|
-
|
|
20
|
-
function claudeLoginOf(raw: unknown): ClaudeLogin | null {
|
|
21
|
-
if (!isRecord(raw) || !ID_RE.test(text(raw.id))) return null;
|
|
22
|
-
return { id: text(raw.id), email: maybe(raw.email), plan: maybe(raw.plan), savedAt: text(raw.savedAt) };
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const modelPath = (dir: string): string => join(dir, MODEL_FILE);
|
|
26
|
-
|
|
27
|
-
const readModelFile = (dir: string): unknown =>
|
|
28
|
-
readJson<unknown>(modelPath(dir), null, { warn: 'model.json is unreadable, so the Claude logins of this box are not seen until it is fixed' });
|
|
29
|
-
|
|
30
|
-
const isV2 = (raw: unknown): raw is Record<string, unknown> & { connections: unknown[] } =>
|
|
31
|
-
isRecord(raw) && raw.version !== 3 && Array.isArray(raw.connections);
|
|
32
|
-
|
|
33
|
-
const unique = (logins: ClaudeLogin[]): ClaudeLogin[] => logins.filter((l, at) => logins.findIndex((o) => o.id === l.id) === at);
|
|
34
|
-
|
|
35
|
-
function claudeLoginsIn(raw: unknown): ClaudeLogin[] {
|
|
36
|
-
if (isV2(raw)) return unique(raw.connections.flatMap((c: unknown) => (isRecord(c) && c.provider === 'anthropic' ? (claudeLoginOf(c.claude) ?? []) : [])));
|
|
37
|
-
if (!isRecord(raw) || !Array.isArray(raw.claude)) return [];
|
|
38
|
-
return unique(raw.claude.flatMap((c: unknown) => claudeLoginOf(c) ?? []));
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export const readClaudeLogins = (dir = agentsDir()): ClaudeLogin[] => claudeLoginsIn(readModelFile(dir));
|
|
42
|
-
|
|
43
|
-
export function writeClaudeLogins(logins: ClaudeLogin[], dir = agentsDir()): void {
|
|
44
|
-
writeSecure(modelPath(dir), JSON.stringify({ version: 3, claude: logins }, null, 2));
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function retireV2ModelFile(dir = agentsDir()): boolean {
|
|
48
|
-
try {
|
|
49
|
-
const raw = readModelFile(dir);
|
|
50
|
-
if (!isV2(raw)) return false;
|
|
51
|
-
const logins = claudeLoginsIn(raw);
|
|
52
|
-
writeClaudeLogins(logins, dir);
|
|
53
|
-
log.warn({ claudeLogins: logins.length, connections: raw.connections.length }, 'org-models: model.json was still the old version 2; it keeps only its Claude logins now, and its connections are never served');
|
|
54
|
-
return true;
|
|
55
|
-
} catch (err) {
|
|
56
|
-
log.warn({ err: errMsg(err) }, 'org-models: the old version 2 model.json could not be rewritten; its connections are never served');
|
|
57
|
-
return false;
|
|
58
|
-
}
|
|
59
|
-
}
|