@roamcode.ai/server 1.0.0 → 1.0.2
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/index.d.ts +2406 -82
- package/dist/index.js +8324 -44
- package/dist/managed-update-helper.js +631 -26
- package/dist/mcp-send.js +76 -90
- package/dist/start.d.ts +591 -9
- package/dist/start.js +7629 -353
- package/package.json +3 -3
- package/dist/auth.d.ts +0 -63
- package/dist/auth.js +0 -133
- package/dist/claude-auth-service.d.ts +0 -76
- package/dist/claude-auth-service.js +0 -217
- package/dist/claude-latest-service.d.ts +0 -34
- package/dist/claude-latest-service.js +0 -61
- package/dist/config.d.ts +0 -78
- package/dist/config.js +0 -59
- package/dist/data-dir.d.ts +0 -42
- package/dist/data-dir.js +0 -70
- package/dist/diag.d.ts +0 -43
- package/dist/diag.js +0 -83
- package/dist/fs-service.d.ts +0 -90
- package/dist/fs-service.js +0 -290
- package/dist/managed-runtime.d.ts +0 -51
- package/dist/managed-runtime.js +0 -411
- package/dist/managed-update-helper.d.ts +0 -2
- package/dist/mcp-send.d.ts +0 -33
- package/dist/origin-check.d.ts +0 -37
- package/dist/origin-check.js +0 -101
- package/dist/pane-status.d.ts +0 -61
- package/dist/pane-status.js +0 -145
- package/dist/providers/claude-metadata-service.d.ts +0 -58
- package/dist/providers/claude-metadata-service.js +0 -352
- package/dist/providers/claude-provider.d.ts +0 -11
- package/dist/providers/claude-provider.js +0 -166
- package/dist/providers/codex-activity.d.ts +0 -21
- package/dist/providers/codex-activity.js +0 -122
- package/dist/providers/codex-app-server-client.d.ts +0 -90
- package/dist/providers/codex-app-server-client.js +0 -485
- package/dist/providers/codex-latest-service.d.ts +0 -50
- package/dist/providers/codex-latest-service.js +0 -174
- package/dist/providers/codex-metadata-service.d.ts +0 -161
- package/dist/providers/codex-metadata-service.js +0 -686
- package/dist/providers/codex-profile-client.d.ts +0 -16
- package/dist/providers/codex-profile-client.js +0 -52
- package/dist/providers/codex-profile-security.d.ts +0 -23
- package/dist/providers/codex-profile-security.js +0 -161
- package/dist/providers/codex-provider.d.ts +0 -15
- package/dist/providers/codex-provider.js +0 -174
- package/dist/providers/codex-thread-coordinator.d.ts +0 -18
- package/dist/providers/codex-thread-coordinator.js +0 -93
- package/dist/providers/codex-thread-persistence.d.ts +0 -9
- package/dist/providers/codex-thread-persistence.js +0 -45
- package/dist/providers/codex-thread-resolver.d.ts +0 -59
- package/dist/providers/codex-thread-resolver.js +0 -322
- package/dist/providers/options.d.ts +0 -7
- package/dist/providers/options.js +0 -155
- package/dist/providers/provider-artifacts.d.ts +0 -3
- package/dist/providers/provider-artifacts.js +0 -30
- package/dist/providers/registry.d.ts +0 -7
- package/dist/providers/registry.js +0 -23
- package/dist/providers/types.d.ts +0 -95
- package/dist/providers/types.js +0 -8
- package/dist/push-dispatch.d.ts +0 -81
- package/dist/push-dispatch.js +0 -100
- package/dist/push-store.d.ts +0 -25
- package/dist/push-store.js +0 -79
- package/dist/rate-limit.d.ts +0 -52
- package/dist/rate-limit.js +0 -72
- package/dist/server-config.d.ts +0 -60
- package/dist/server-config.js +0 -77
- package/dist/service-install.d.ts +0 -60
- package/dist/service-install.js +0 -221
- package/dist/session-defaults.d.ts +0 -26
- package/dist/session-defaults.js +0 -60
- package/dist/session-store.d.ts +0 -81
- package/dist/session-store.js +0 -654
- package/dist/static-routes.d.ts +0 -101
- package/dist/static-routes.js +0 -188
- package/dist/terminal-capability.d.ts +0 -5
- package/dist/terminal-capability.js +0 -27
- package/dist/terminal-manager.d.ts +0 -224
- package/dist/terminal-manager.js +0 -917
- package/dist/terminal-process.d.ts +0 -85
- package/dist/terminal-process.js +0 -238
- package/dist/terminal-shared.d.ts +0 -36
- package/dist/terminal-shared.js +0 -43
- package/dist/tmux-list.d.ts +0 -11
- package/dist/tmux-list.js +0 -39
- package/dist/transport.d.ts +0 -123
- package/dist/transport.js +0 -1559
- package/dist/updater.d.ts +0 -161
- package/dist/updater.js +0 -451
- package/dist/usage-service.d.ts +0 -118
- package/dist/usage-service.js +0 -173
- package/dist/vapid.d.ts +0 -17
- package/dist/vapid.js +0 -31
- package/dist/web-push-send.d.ts +0 -20
- package/dist/web-push-send.js +0 -21
- package/dist/ws-ticket.d.ts +0 -47
- package/dist/ws-ticket.js +0 -62
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@roamcode.ai/server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Host-native server for RoamCode",
|
|
5
5
|
"homepage": "https://roamcode.ai",
|
|
6
6
|
"bugs": "https://github.com/burakgon/roamcode/issues",
|
|
@@ -35,12 +35,12 @@
|
|
|
35
35
|
"@fastify/static": "^9.3.0",
|
|
36
36
|
"@fastify/websocket": "^11.3.0",
|
|
37
37
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
38
|
-
"better-sqlite3": "
|
|
38
|
+
"better-sqlite3": "12.11.1",
|
|
39
39
|
"fastify": "^5.10.0",
|
|
40
40
|
"node-pty": "1.1.0",
|
|
41
41
|
"web-push": "^3.6.7",
|
|
42
42
|
"zod": "^4.4.3",
|
|
43
|
-
"@roamcode.ai/web": "1.0.
|
|
43
|
+
"@roamcode.ai/web": "1.0.2"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@types/better-sqlite3": "^7.6.13",
|
package/dist/auth.d.ts
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
export declare function extractBearerToken(authorizationHeader: string | undefined): string | undefined;
|
|
2
|
-
export interface AuthGateOptions {
|
|
3
|
-
token?: string;
|
|
4
|
-
/** Consecutive failures from one client before it is locked out. Default 10. */
|
|
5
|
-
maxFailures?: number;
|
|
6
|
-
/** Lockout duration in ms. Default 60000. */
|
|
7
|
-
lockoutMs?: number;
|
|
8
|
-
/**
|
|
9
|
-
* Grace window (ms) after {@link AuthGate.rotateToken} during which the PREVIOUS token is still
|
|
10
|
-
* accepted, so a callback already in flight with the old token (e.g. a running mcp-send subprocess that
|
|
11
|
-
* captured RC_TOKEN at spawn) survives the rotation instead of 401-ing mid-conversation. Default 60000.
|
|
12
|
-
*/
|
|
13
|
-
graceMs?: number;
|
|
14
|
-
/** Injectable clock for tests. Default Date.now. */
|
|
15
|
-
now?: () => number;
|
|
16
|
-
}
|
|
17
|
-
export type AuthCheckResult = {
|
|
18
|
-
ok: true;
|
|
19
|
-
} | {
|
|
20
|
-
ok: false;
|
|
21
|
-
reason: "locked" | "invalid" | "missing-token-config";
|
|
22
|
-
};
|
|
23
|
-
/**
|
|
24
|
-
* Verifies a bearer access token in constant time and applies a per-client
|
|
25
|
-
* lockout after repeated failures. Pure / I/O-free so it is unit-testable;
|
|
26
|
-
* Task 8 wires it as a single global Fastify `preHandler` that also gates the
|
|
27
|
-
* WebSocket upgrade.
|
|
28
|
-
*
|
|
29
|
-
* Proxy lockout caveat: the lockout is keyed by `clientKey` (`request.ip`).
|
|
30
|
-
* Behind a reverse proxy (Caddy / Cloudflare) `request.ip` is the proxy's IP
|
|
31
|
-
* for EVERY client, collapsing the per-client lockout to one shared key. To
|
|
32
|
-
* stop that from self-DoS-ing the sole legitimate user, {@link check} validates
|
|
33
|
-
* the token FIRST: a CORRECT token is ALWAYS accepted, even while its key is
|
|
34
|
-
* locked out — the lockout only ever throttles WRONG guesses. Supplying a real
|
|
35
|
-
* per-client key (Fastify `trustProxy` scoped to the proxy) still makes the
|
|
36
|
-
* throttle per-client, but is no longer required to keep the user from being
|
|
37
|
-
* locked out by someone else's bad traffic.
|
|
38
|
-
*/
|
|
39
|
-
export declare class AuthGate {
|
|
40
|
-
private token?;
|
|
41
|
-
private previousToken?;
|
|
42
|
-
private previousValidUntil;
|
|
43
|
-
private readonly maxFailures;
|
|
44
|
-
private readonly lockoutMs;
|
|
45
|
-
private readonly graceMs;
|
|
46
|
-
private readonly now;
|
|
47
|
-
private readonly clients;
|
|
48
|
-
constructor(opts: AuthGateOptions);
|
|
49
|
-
check(presentedToken: string | undefined, clientKey: string): AuthCheckResult;
|
|
50
|
-
/**
|
|
51
|
-
* Atomically swap in a fresh access token (POST /token/rotate). Every subsequent {@link check} compares
|
|
52
|
-
* against the new token; the OLD token is accepted only for a brief grace window (graceMs) so in-flight
|
|
53
|
-
* callbacks holding it (a running mcp-send subprocess) survive the rotation instead of 401-ing mid-turn.
|
|
54
|
-
* A 2nd rotation within the window REPLACES previousToken with the most-recent old token (no list grows).
|
|
55
|
-
* Also clears all per-client lockout state — rotation is an explicit administrative reset, so a fresh
|
|
56
|
-
* slate is correct (and avoids leaving a client locked out against a token that no longer exists).
|
|
57
|
-
*/
|
|
58
|
-
rotateToken(newToken: string): void;
|
|
59
|
-
/** Drop entries whose lockout has expired and whose failure count is 0 — keeps the map bounded. */
|
|
60
|
-
private sweepExpired;
|
|
61
|
-
/** TEST ONLY: number of tracked clients currently locked (lockedUntil in the future). */
|
|
62
|
-
lockedClientCount(): number;
|
|
63
|
-
}
|
package/dist/auth.js
DELETED
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
import { timingSafeEqual } from "node:crypto";
|
|
2
|
-
export function extractBearerToken(authorizationHeader) {
|
|
3
|
-
if (!authorizationHeader)
|
|
4
|
-
return undefined;
|
|
5
|
-
const match = /^Bearer\s+(.+)$/i.exec(authorizationHeader.trim());
|
|
6
|
-
return match ? match[1] : undefined;
|
|
7
|
-
}
|
|
8
|
-
/** Constant-time string compare that does not leak length via early return. */
|
|
9
|
-
function constantTimeEqual(a, b) {
|
|
10
|
-
const bufA = Buffer.from(a, "utf8");
|
|
11
|
-
const bufB = Buffer.from(b, "utf8");
|
|
12
|
-
// timingSafeEqual requires equal lengths; compare against a fixed-length digest-free padding.
|
|
13
|
-
if (bufA.length !== bufB.length) {
|
|
14
|
-
// Still do a compare to keep timing uniform, then return false.
|
|
15
|
-
timingSafeEqual(bufA, bufA);
|
|
16
|
-
return false;
|
|
17
|
-
}
|
|
18
|
-
return timingSafeEqual(bufA, bufB);
|
|
19
|
-
}
|
|
20
|
-
/**
|
|
21
|
-
* Verifies a bearer access token in constant time and applies a per-client
|
|
22
|
-
* lockout after repeated failures. Pure / I/O-free so it is unit-testable;
|
|
23
|
-
* Task 8 wires it as a single global Fastify `preHandler` that also gates the
|
|
24
|
-
* WebSocket upgrade.
|
|
25
|
-
*
|
|
26
|
-
* Proxy lockout caveat: the lockout is keyed by `clientKey` (`request.ip`).
|
|
27
|
-
* Behind a reverse proxy (Caddy / Cloudflare) `request.ip` is the proxy's IP
|
|
28
|
-
* for EVERY client, collapsing the per-client lockout to one shared key. To
|
|
29
|
-
* stop that from self-DoS-ing the sole legitimate user, {@link check} validates
|
|
30
|
-
* the token FIRST: a CORRECT token is ALWAYS accepted, even while its key is
|
|
31
|
-
* locked out — the lockout only ever throttles WRONG guesses. Supplying a real
|
|
32
|
-
* per-client key (Fastify `trustProxy` scoped to the proxy) still makes the
|
|
33
|
-
* throttle per-client, but is no longer required to keep the user from being
|
|
34
|
-
* locked out by someone else's bad traffic.
|
|
35
|
-
*/
|
|
36
|
-
export class AuthGate {
|
|
37
|
-
// NOT readonly: rotateToken() atomically swaps in a fresh secret (POST /token/rotate). Every subsequent
|
|
38
|
-
// check() compares against the new token, so the OLD token is invalid the instant rotation completes —
|
|
39
|
-
// except within the brief grace window below, where the PREVIOUS token is still accepted.
|
|
40
|
-
token;
|
|
41
|
-
// DUAL-TOKEN GRACE: the token rotation REPLACED, plus the wall time it stays acceptable until. Lets a
|
|
42
|
-
// callback already in flight with the old token (a running mcp-send subprocess captured RC_TOKEN at
|
|
43
|
-
// spawn; rewriting its 0600 config can't update the live process env) finish instead of 401-ing
|
|
44
|
-
// mid-conversation. Only ONE previous token is ever retained — a 2nd rotation within the window
|
|
45
|
-
// supersedes it with the most-recent old token (never a growing list).
|
|
46
|
-
previousToken;
|
|
47
|
-
previousValidUntil = 0;
|
|
48
|
-
maxFailures;
|
|
49
|
-
lockoutMs;
|
|
50
|
-
graceMs;
|
|
51
|
-
now;
|
|
52
|
-
clients = new Map();
|
|
53
|
-
constructor(opts) {
|
|
54
|
-
this.token = opts.token;
|
|
55
|
-
this.maxFailures = opts.maxFailures ?? 10;
|
|
56
|
-
this.lockoutMs = opts.lockoutMs ?? 60000;
|
|
57
|
-
this.graceMs = opts.graceMs ?? 60000;
|
|
58
|
-
this.now = opts.now ?? Date.now;
|
|
59
|
-
}
|
|
60
|
-
check(presentedToken, clientKey) {
|
|
61
|
-
if (!this.token)
|
|
62
|
-
return { ok: false, reason: "missing-token-config" };
|
|
63
|
-
this.sweepExpired();
|
|
64
|
-
const state = this.clients.get(clientKey) ?? { failures: 0, lockedUntil: 0 };
|
|
65
|
-
const t = this.now();
|
|
66
|
-
// Validate the token FIRST — BEFORE consulting the lockout. Accept the CURRENT token, OR — within the
|
|
67
|
-
// post-rotation grace window — the PREVIOUS one. Both go through the same length-mismatch-safe
|
|
68
|
-
// constant-time compare (no early return leaks length). A correct token is accepted even while this key
|
|
69
|
-
// is locked out, so bad guesses (from an attacker, or from ANY client when they all share the proxy's IP
|
|
70
|
-
// as clientKey) can never lock out the real user. SECURITY: the grace is a marginal exposure — the old
|
|
71
|
-
// token was already valid up to the instant of rotation; 60s more only lets in-flight MCP callbacks
|
|
72
|
-
// (send_image/send_file) complete, the deliberate tradeoff vs breaking attachments mid-turn.
|
|
73
|
-
const matchesCurrent = presentedToken !== undefined && constantTimeEqual(presentedToken, this.token);
|
|
74
|
-
const matchesPrevious = presentedToken !== undefined &&
|
|
75
|
-
this.previousToken !== undefined &&
|
|
76
|
-
t <= this.previousValidUntil &&
|
|
77
|
-
constantTimeEqual(presentedToken, this.previousToken);
|
|
78
|
-
if (matchesCurrent || matchesPrevious) {
|
|
79
|
-
this.clients.delete(clientKey); // reset on success
|
|
80
|
-
return { ok: true };
|
|
81
|
-
}
|
|
82
|
-
// Wrong token. Only now does the lockout apply — a locked key keeps rejecting WRONG guesses without
|
|
83
|
-
// counting further (the lock governs); the correct token above is unaffected.
|
|
84
|
-
if (state.lockedUntil > t)
|
|
85
|
-
return { ok: false, reason: "locked" };
|
|
86
|
-
state.failures += 1;
|
|
87
|
-
if (state.failures >= this.maxFailures) {
|
|
88
|
-
state.lockedUntil = t + this.lockoutMs;
|
|
89
|
-
state.failures = 0; // reset the counter; the lock now governs
|
|
90
|
-
}
|
|
91
|
-
this.clients.set(clientKey, state);
|
|
92
|
-
return { ok: false, reason: "invalid" };
|
|
93
|
-
}
|
|
94
|
-
/**
|
|
95
|
-
* Atomically swap in a fresh access token (POST /token/rotate). Every subsequent {@link check} compares
|
|
96
|
-
* against the new token; the OLD token is accepted only for a brief grace window (graceMs) so in-flight
|
|
97
|
-
* callbacks holding it (a running mcp-send subprocess) survive the rotation instead of 401-ing mid-turn.
|
|
98
|
-
* A 2nd rotation within the window REPLACES previousToken with the most-recent old token (no list grows).
|
|
99
|
-
* Also clears all per-client lockout state — rotation is an explicit administrative reset, so a fresh
|
|
100
|
-
* slate is correct (and avoids leaving a client locked out against a token that no longer exists).
|
|
101
|
-
*/
|
|
102
|
-
rotateToken(newToken) {
|
|
103
|
-
// Retain the just-replaced token for the grace window — but ONLY when graceMs > 0, so graceMs:0 means
|
|
104
|
-
// an UNAMBIGUOUS instant cutover (no previous token retained, no same-millisecond boundary acceptance).
|
|
105
|
-
if (this.token !== undefined && this.graceMs > 0) {
|
|
106
|
-
this.previousToken = this.token;
|
|
107
|
-
this.previousValidUntil = this.now() + this.graceMs;
|
|
108
|
-
}
|
|
109
|
-
else {
|
|
110
|
-
this.previousToken = undefined;
|
|
111
|
-
this.previousValidUntil = 0;
|
|
112
|
-
}
|
|
113
|
-
this.token = newToken;
|
|
114
|
-
this.clients.clear();
|
|
115
|
-
}
|
|
116
|
-
/** Drop entries whose lockout has expired and whose failure count is 0 — keeps the map bounded. */
|
|
117
|
-
sweepExpired() {
|
|
118
|
-
const t = this.now();
|
|
119
|
-
for (const [key, state] of this.clients) {
|
|
120
|
-
if (state.lockedUntil <= t && state.failures === 0)
|
|
121
|
-
this.clients.delete(key);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
/** TEST ONLY: number of tracked clients currently locked (lockedUntil in the future). */
|
|
125
|
-
lockedClientCount() {
|
|
126
|
-
const t = this.now();
|
|
127
|
-
let n = 0;
|
|
128
|
-
for (const state of this.clients.values())
|
|
129
|
-
if (state.lockedUntil > t)
|
|
130
|
-
n += 1;
|
|
131
|
-
return n;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import { type ChildProcess } from "node:child_process";
|
|
2
|
-
/**
|
|
3
|
-
* In-app re-authentication of the `claude` CLI's Claude subscription, so a user whose server-side login
|
|
4
|
-
* expired (Anthropic returns 401 "Invalid authentication credentials" → every turn fails) can sign in
|
|
5
|
-
* again FROM THE APP instead of SSHing to the server to run `claude auth login`.
|
|
6
|
-
*
|
|
7
|
-
* It wraps `claude`'s OWN manual OAuth flow (no reverse-engineering of Anthropic's OAuth):
|
|
8
|
-
* 1. `claude auth login --claudeai` prints an authorize URL whose redirect is a HOSTED page
|
|
9
|
-
* (platform.claude.com/oauth/code/callback — not localhost), then waits on stdin for a pasted code.
|
|
10
|
-
* 2. We spawn it, capture that URL, and hand it to the UI; the user opens it in ANY browser (their
|
|
11
|
-
* phone), authorizes, and copies the code the callback page shows.
|
|
12
|
-
* 3. The UI posts the code back; we write it to the subprocess stdin; `claude` exchanges it and saves
|
|
13
|
-
* FRESH credentials where every future claude spawn reads them — auth is restored, no restart needed.
|
|
14
|
-
*
|
|
15
|
-
* Single in-flight login at a time (a new start supersedes a stale one); a started login self-expires so
|
|
16
|
-
* an abandoned flow can't hold a subprocess forever. Spawn deps are injectable for unit tests.
|
|
17
|
-
*/
|
|
18
|
-
export interface ClaudeAuthStatus {
|
|
19
|
-
/** Whether `claude` has stored credentials. NOTE: this reflects the LOCAL creds existing, not that they
|
|
20
|
-
* still work — expired creds report loggedIn:true yet 401 on use, so re-auth is offered regardless. */
|
|
21
|
-
loggedIn: boolean;
|
|
22
|
-
email?: string;
|
|
23
|
-
/** "max" | "pro" | … (the Claude subscription tier), when reported. */
|
|
24
|
-
subscriptionType?: string;
|
|
25
|
-
authMethod?: string;
|
|
26
|
-
orgName?: string;
|
|
27
|
-
}
|
|
28
|
-
/** Parse `claude auth status` JSON output into a status. Returns {loggedIn:false} on any unparhseable/empty. */
|
|
29
|
-
export declare function parseAuthStatus(stdout: string): ClaudeAuthStatus;
|
|
30
|
-
/** Pull the authorize URL out of `claude auth login` output ("…visit: https://…/oauth/authorize?…"). */
|
|
31
|
-
export declare function extractLoginUrl(output: string): string | undefined;
|
|
32
|
-
export interface ClaudeAuthDeps {
|
|
33
|
-
/** Spawn a claude subcommand: `claude <args>` with the server env (login-session creds resolve). */
|
|
34
|
-
spawn: (args: string[]) => ChildProcess;
|
|
35
|
-
now: () => number;
|
|
36
|
-
/** How long a started login waits for the URL before giving up (default 20s). */
|
|
37
|
-
urlTimeoutMs?: number;
|
|
38
|
-
/** How long a started login may sit awaiting its code before self-expiring (default 5min). */
|
|
39
|
-
loginTtlMs?: number;
|
|
40
|
-
}
|
|
41
|
-
export declare const LOGIN_URL_TIMEOUT_MS = 20000;
|
|
42
|
-
export declare const LOGIN_TTL_MS: number;
|
|
43
|
-
export declare class ClaudeAuthService {
|
|
44
|
-
private readonly deps;
|
|
45
|
-
private pending?;
|
|
46
|
-
constructor(deps: ClaudeAuthDeps);
|
|
47
|
-
/** Current `claude auth status` (which account is signed in). Never rejects → {loggedIn:false} on error. */
|
|
48
|
-
status(): Promise<ClaudeAuthStatus>;
|
|
49
|
-
/**
|
|
50
|
-
* Start a login: spawn `claude auth login --claudeai`, resolve with the authorize URL once it's printed.
|
|
51
|
-
* Supersedes any prior in-flight login. Rejects if the URL never appears (timeout / immediate exit).
|
|
52
|
-
*/
|
|
53
|
-
startLogin(): Promise<{
|
|
54
|
-
loginId: string;
|
|
55
|
-
url: string;
|
|
56
|
-
}>;
|
|
57
|
-
/**
|
|
58
|
-
* Submit the pasted authorization code for an in-flight login: write it to the subprocess stdin and
|
|
59
|
-
* resolve once `claude` finishes the exchange. {ok:true} on a clean exit (fresh creds saved); {ok:false}
|
|
60
|
-
* + message otherwise. `loginId` must match the in-flight login (else it's stale/unknown).
|
|
61
|
-
*/
|
|
62
|
-
submitCode(loginId: string, code: string): Promise<{
|
|
63
|
-
ok: boolean;
|
|
64
|
-
message?: string;
|
|
65
|
-
}>;
|
|
66
|
-
/** Abandon any in-flight login (e.g. the user cancels). */
|
|
67
|
-
cancel(): void;
|
|
68
|
-
private cancelIf;
|
|
69
|
-
}
|
|
70
|
-
/** Production wiring: spawn `claude <args>` with the server env (ANTHROPIC_API_KEY stripped → subscription
|
|
71
|
-
* auth, mirroring the chat/usage spawns). stdin is piped so the code can be written for the login flow. */
|
|
72
|
-
export declare function createClaudeAuthService(opts: {
|
|
73
|
-
claudeBin: string;
|
|
74
|
-
env?: NodeJS.ProcessEnv;
|
|
75
|
-
now?: () => number;
|
|
76
|
-
}): ClaudeAuthService;
|
|
@@ -1,217 +0,0 @@
|
|
|
1
|
-
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
-
/** Parse `claude auth status` JSON output into a status. Returns {loggedIn:false} on any unparhseable/empty. */
|
|
4
|
-
export function parseAuthStatus(stdout) {
|
|
5
|
-
try {
|
|
6
|
-
const o = JSON.parse(stdout);
|
|
7
|
-
return {
|
|
8
|
-
loggedIn: o.loggedIn === true,
|
|
9
|
-
email: typeof o.email === "string" ? o.email : undefined,
|
|
10
|
-
subscriptionType: typeof o.subscriptionType === "string" ? o.subscriptionType : undefined,
|
|
11
|
-
authMethod: typeof o.authMethod === "string" ? o.authMethod : undefined,
|
|
12
|
-
orgName: typeof o.orgName === "string" ? o.orgName : undefined,
|
|
13
|
-
};
|
|
14
|
-
}
|
|
15
|
-
catch {
|
|
16
|
-
return { loggedIn: false };
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
/** Pull the authorize URL out of `claude auth login` output ("…visit: https://…/oauth/authorize?…"). */
|
|
20
|
-
export function extractLoginUrl(output) {
|
|
21
|
-
const m = /(https:\/\/[^\s]+oauth\/authorize[^\s]*)/i.exec(output);
|
|
22
|
-
return m ? m[1] : undefined;
|
|
23
|
-
}
|
|
24
|
-
export const LOGIN_URL_TIMEOUT_MS = 20_000;
|
|
25
|
-
export const LOGIN_TTL_MS = 5 * 60_000;
|
|
26
|
-
const STATUS_TIMEOUT_MS = 15_000;
|
|
27
|
-
export class ClaudeAuthService {
|
|
28
|
-
deps;
|
|
29
|
-
pending;
|
|
30
|
-
constructor(deps) {
|
|
31
|
-
this.deps = {
|
|
32
|
-
spawn: deps.spawn,
|
|
33
|
-
now: deps.now,
|
|
34
|
-
urlTimeoutMs: deps.urlTimeoutMs ?? LOGIN_URL_TIMEOUT_MS,
|
|
35
|
-
loginTtlMs: deps.loginTtlMs ?? LOGIN_TTL_MS,
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
/** Current `claude auth status` (which account is signed in). Never rejects → {loggedIn:false} on error. */
|
|
39
|
-
async status() {
|
|
40
|
-
return new Promise((resolve) => {
|
|
41
|
-
let child;
|
|
42
|
-
try {
|
|
43
|
-
child = this.deps.spawn(["auth", "status"]);
|
|
44
|
-
}
|
|
45
|
-
catch {
|
|
46
|
-
resolve({ loggedIn: false });
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
let out = "";
|
|
50
|
-
let settled = false;
|
|
51
|
-
const done = (s) => {
|
|
52
|
-
if (settled)
|
|
53
|
-
return;
|
|
54
|
-
settled = true;
|
|
55
|
-
clearTimeout(timer);
|
|
56
|
-
resolve(s);
|
|
57
|
-
};
|
|
58
|
-
const timer = setTimeout(() => {
|
|
59
|
-
try {
|
|
60
|
-
child.kill("SIGKILL");
|
|
61
|
-
}
|
|
62
|
-
catch {
|
|
63
|
-
/* gone */
|
|
64
|
-
}
|
|
65
|
-
done({ loggedIn: false });
|
|
66
|
-
}, STATUS_TIMEOUT_MS);
|
|
67
|
-
child.stdout?.on("data", (d) => (out += d.toString()));
|
|
68
|
-
child.on("error", () => done({ loggedIn: false }));
|
|
69
|
-
child.on("close", () => done(parseAuthStatus(out)));
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
/**
|
|
73
|
-
* Start a login: spawn `claude auth login --claudeai`, resolve with the authorize URL once it's printed.
|
|
74
|
-
* Supersedes any prior in-flight login. Rejects if the URL never appears (timeout / immediate exit).
|
|
75
|
-
*/
|
|
76
|
-
async startLogin() {
|
|
77
|
-
this.cancel(); // one in-flight login at a time
|
|
78
|
-
return new Promise((resolve, reject) => {
|
|
79
|
-
let child;
|
|
80
|
-
try {
|
|
81
|
-
child = this.deps.spawn(["auth", "login", "--claudeai"]);
|
|
82
|
-
}
|
|
83
|
-
catch (err) {
|
|
84
|
-
reject(err instanceof Error ? err : new Error("failed to spawn claude"));
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
const id = randomUUID();
|
|
88
|
-
let settled = false;
|
|
89
|
-
let buf = "";
|
|
90
|
-
const onData = (d) => {
|
|
91
|
-
buf += d.toString();
|
|
92
|
-
const url = extractLoginUrl(buf);
|
|
93
|
-
if (url && !settled) {
|
|
94
|
-
settled = true;
|
|
95
|
-
clearTimeout(timer);
|
|
96
|
-
// Hand off to a PendingLogin that lives until the code arrives (or it self-expires).
|
|
97
|
-
const expiry = setTimeout(() => this.cancelIf(id), this.deps.loginTtlMs);
|
|
98
|
-
this.pending = { id, child, output: buf, startedAt: this.deps.now(), expiry };
|
|
99
|
-
// Keep accumulating output (the success/error after the code is submitted).
|
|
100
|
-
child.stdout?.on("data", (x) => this.pending && (this.pending.output += x.toString()));
|
|
101
|
-
child.stderr?.on("data", (x) => this.pending && (this.pending.output += x.toString()));
|
|
102
|
-
resolve({ loginId: id, url });
|
|
103
|
-
}
|
|
104
|
-
};
|
|
105
|
-
const fail = (msg) => {
|
|
106
|
-
if (settled)
|
|
107
|
-
return;
|
|
108
|
-
settled = true;
|
|
109
|
-
clearTimeout(timer);
|
|
110
|
-
try {
|
|
111
|
-
child.kill("SIGKILL");
|
|
112
|
-
}
|
|
113
|
-
catch {
|
|
114
|
-
/* gone */
|
|
115
|
-
}
|
|
116
|
-
reject(new Error(msg));
|
|
117
|
-
};
|
|
118
|
-
const timer = setTimeout(() => fail("timed out waiting for the sign-in URL"), this.deps.urlTimeoutMs);
|
|
119
|
-
child.stdout?.on("data", onData);
|
|
120
|
-
child.stderr?.on("data", onData);
|
|
121
|
-
child.on("error", (e) => fail(e.message));
|
|
122
|
-
child.on("close", () => fail(buf.trim() || "claude exited before printing a sign-in URL"));
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
/**
|
|
126
|
-
* Submit the pasted authorization code for an in-flight login: write it to the subprocess stdin and
|
|
127
|
-
* resolve once `claude` finishes the exchange. {ok:true} on a clean exit (fresh creds saved); {ok:false}
|
|
128
|
-
* + message otherwise. `loginId` must match the in-flight login (else it's stale/unknown).
|
|
129
|
-
*/
|
|
130
|
-
async submitCode(loginId, code) {
|
|
131
|
-
const p = this.pending;
|
|
132
|
-
if (!p || p.id !== loginId)
|
|
133
|
-
return { ok: false, message: "no sign-in is in progress (it may have expired)" };
|
|
134
|
-
const trimmed = code.trim();
|
|
135
|
-
if (!trimmed)
|
|
136
|
-
return { ok: false, message: "the code is empty" };
|
|
137
|
-
return new Promise((resolve) => {
|
|
138
|
-
let settled = false;
|
|
139
|
-
const done = (r) => {
|
|
140
|
-
if (settled)
|
|
141
|
-
return;
|
|
142
|
-
settled = true;
|
|
143
|
-
clearTimeout(timer);
|
|
144
|
-
if (this.pending === p) {
|
|
145
|
-
clearTimeout(p.expiry);
|
|
146
|
-
this.pending = undefined;
|
|
147
|
-
}
|
|
148
|
-
resolve(r);
|
|
149
|
-
};
|
|
150
|
-
const timer = setTimeout(() => {
|
|
151
|
-
try {
|
|
152
|
-
p.child.kill("SIGKILL");
|
|
153
|
-
}
|
|
154
|
-
catch {
|
|
155
|
-
/* gone */
|
|
156
|
-
}
|
|
157
|
-
done({ ok: false, message: "timed out completing sign-in" });
|
|
158
|
-
}, STATUS_TIMEOUT_MS);
|
|
159
|
-
p.child.on("close", (codeNum) => {
|
|
160
|
-
// A clean exit (0) means the token exchange succeeded. Otherwise surface the tail of the output.
|
|
161
|
-
if (codeNum === 0)
|
|
162
|
-
done({ ok: true });
|
|
163
|
-
else
|
|
164
|
-
done({ ok: false, message: tail(p.output) || `sign-in failed (exit ${codeNum ?? "?"})` });
|
|
165
|
-
});
|
|
166
|
-
p.child.on("error", (e) => done({ ok: false, message: e.message }));
|
|
167
|
-
try {
|
|
168
|
-
p.child.stdin?.write(trimmed + "\n");
|
|
169
|
-
p.child.stdin?.end();
|
|
170
|
-
}
|
|
171
|
-
catch (e) {
|
|
172
|
-
done({ ok: false, message: e instanceof Error ? e.message : "failed to submit the code" });
|
|
173
|
-
}
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
/** Abandon any in-flight login (e.g. the user cancels). */
|
|
177
|
-
cancel() {
|
|
178
|
-
const p = this.pending;
|
|
179
|
-
if (!p)
|
|
180
|
-
return;
|
|
181
|
-
clearTimeout(p.expiry);
|
|
182
|
-
try {
|
|
183
|
-
p.child.kill("SIGKILL");
|
|
184
|
-
}
|
|
185
|
-
catch {
|
|
186
|
-
/* gone */
|
|
187
|
-
}
|
|
188
|
-
this.pending = undefined;
|
|
189
|
-
}
|
|
190
|
-
cancelIf(id) {
|
|
191
|
-
if (this.pending?.id === id)
|
|
192
|
-
this.cancel();
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
/** Last non-empty line of output (the error/success message claude prints), capped. */
|
|
196
|
-
function tail(output) {
|
|
197
|
-
const line = output
|
|
198
|
-
.split("\n")
|
|
199
|
-
.map((l) => l.trim())
|
|
200
|
-
.filter(Boolean)
|
|
201
|
-
.pop();
|
|
202
|
-
if (!line)
|
|
203
|
-
return "";
|
|
204
|
-
return line.length > 200 ? line.slice(0, 197) + "…" : line;
|
|
205
|
-
}
|
|
206
|
-
/** Production wiring: spawn `claude <args>` with the server env (ANTHROPIC_API_KEY stripped → subscription
|
|
207
|
-
* auth, mirroring the chat/usage spawns). stdin is piped so the code can be written for the login flow. */
|
|
208
|
-
export function createClaudeAuthService(opts) {
|
|
209
|
-
return new ClaudeAuthService({
|
|
210
|
-
spawn: (args) => {
|
|
211
|
-
const env = { ...(opts.env ?? process.env) };
|
|
212
|
-
delete env.ANTHROPIC_API_KEY;
|
|
213
|
-
return nodeSpawn(opts.claudeBin, args, { env, stdio: ["pipe", "pipe", "pipe"] });
|
|
214
|
-
},
|
|
215
|
-
now: opts.now ?? (() => Date.now()),
|
|
216
|
-
});
|
|
217
|
-
}
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The LATEST published `claude` CLI version, for update-awareness: the UI compares a session's
|
|
3
|
-
* `claudeVersion` (what it spawned with) against this to show a subtle "update available" hint when a
|
|
4
|
-
* chat is running an older Claude than what's out.
|
|
5
|
-
*
|
|
6
|
-
* Source: the npm registry's dist-tag for `@anthropic-ai/claude-code` (the same release train as the
|
|
7
|
-
* native installer), fetched best-effort and TTL-cached so the rare /claude/version poll is cheap. Any
|
|
8
|
-
* failure (offline, timeout, registry hiccup) degrades to the last good value, or undefined — the UI
|
|
9
|
-
* simply hides the hint, never errors. The fetch is an INJECTABLE seam so the cache logic is unit-testable.
|
|
10
|
-
*/
|
|
11
|
-
export declare const CLAUDE_LATEST_CACHE_MS: number;
|
|
12
|
-
/** Resolve the latest version string (e.g. "2.1.195"). NEVER rejects — resolves undefined on any failure. */
|
|
13
|
-
export type FetchLatest = () => Promise<string | undefined>;
|
|
14
|
-
export interface ClaudeLatestDeps {
|
|
15
|
-
fetchLatest: FetchLatest;
|
|
16
|
-
now: () => number;
|
|
17
|
-
ttlMs?: number;
|
|
18
|
-
}
|
|
19
|
-
/** Pull `version` out of an npm dist-tag document (`{ "version": "2.1.195", … }`). */
|
|
20
|
-
export declare function parseNpmLatest(json: unknown): string | undefined;
|
|
21
|
-
export declare class ClaudeLatestService {
|
|
22
|
-
private readonly fetchLatest;
|
|
23
|
-
private readonly now;
|
|
24
|
-
private readonly ttlMs;
|
|
25
|
-
private cache?;
|
|
26
|
-
constructor(deps: ClaudeLatestDeps);
|
|
27
|
-
/** The latest version, TTL-cached. A failed refresh keeps the last good value (or undefined). */
|
|
28
|
-
getLatest(): Promise<string | undefined>;
|
|
29
|
-
}
|
|
30
|
-
/** The real fetch adapter: GET the npm dist-tag doc with a short timeout. Resolves undefined on any error. */
|
|
31
|
-
export declare function createClaudeLatestService(opts?: {
|
|
32
|
-
now?: () => number;
|
|
33
|
-
ttlMs?: number;
|
|
34
|
-
}): ClaudeLatestService;
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The LATEST published `claude` CLI version, for update-awareness: the UI compares a session's
|
|
3
|
-
* `claudeVersion` (what it spawned with) against this to show a subtle "update available" hint when a
|
|
4
|
-
* chat is running an older Claude than what's out.
|
|
5
|
-
*
|
|
6
|
-
* Source: the npm registry's dist-tag for `@anthropic-ai/claude-code` (the same release train as the
|
|
7
|
-
* native installer), fetched best-effort and TTL-cached so the rare /claude/version poll is cheap. Any
|
|
8
|
-
* failure (offline, timeout, registry hiccup) degrades to the last good value, or undefined — the UI
|
|
9
|
-
* simply hides the hint, never errors. The fetch is an INJECTABLE seam so the cache logic is unit-testable.
|
|
10
|
-
*/
|
|
11
|
-
const NPM_LATEST_URL = "https://registry.npmjs.org/@anthropic-ai/claude-code/latest";
|
|
12
|
-
export const CLAUDE_LATEST_CACHE_MS = 6 * 60 * 60 * 1000;
|
|
13
|
-
const FETCH_TIMEOUT_MS = 8000;
|
|
14
|
-
/** Pull `version` out of an npm dist-tag document (`{ "version": "2.1.195", … }`). */
|
|
15
|
-
export function parseNpmLatest(json) {
|
|
16
|
-
if (json && typeof json === "object" && typeof json.version === "string") {
|
|
17
|
-
return json.version;
|
|
18
|
-
}
|
|
19
|
-
return undefined;
|
|
20
|
-
}
|
|
21
|
-
export class ClaudeLatestService {
|
|
22
|
-
fetchLatest;
|
|
23
|
-
now;
|
|
24
|
-
ttlMs;
|
|
25
|
-
cache;
|
|
26
|
-
constructor(deps) {
|
|
27
|
-
this.fetchLatest = deps.fetchLatest;
|
|
28
|
-
this.now = deps.now;
|
|
29
|
-
this.ttlMs = deps.ttlMs ?? CLAUDE_LATEST_CACHE_MS;
|
|
30
|
-
}
|
|
31
|
-
/** The latest version, TTL-cached. A failed refresh keeps the last good value (or undefined). */
|
|
32
|
-
async getLatest() {
|
|
33
|
-
const now = this.now();
|
|
34
|
-
if (this.cache && now - this.cache.at < this.ttlMs)
|
|
35
|
-
return this.cache.value;
|
|
36
|
-
const fetched = await this.fetchLatest().catch(() => undefined);
|
|
37
|
-
const value = fetched ?? this.cache?.value;
|
|
38
|
-
this.cache = { at: now, value };
|
|
39
|
-
return value;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
/** The real fetch adapter: GET the npm dist-tag doc with a short timeout. Resolves undefined on any error. */
|
|
43
|
-
export function createClaudeLatestService(opts = {}) {
|
|
44
|
-
const fetchLatest = async () => {
|
|
45
|
-
const controller = new AbortController();
|
|
46
|
-
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
47
|
-
try {
|
|
48
|
-
const res = await fetch(NPM_LATEST_URL, { signal: controller.signal });
|
|
49
|
-
if (!res.ok)
|
|
50
|
-
return undefined;
|
|
51
|
-
return parseNpmLatest(await res.json());
|
|
52
|
-
}
|
|
53
|
-
catch {
|
|
54
|
-
return undefined;
|
|
55
|
-
}
|
|
56
|
-
finally {
|
|
57
|
-
clearTimeout(timer);
|
|
58
|
-
}
|
|
59
|
-
};
|
|
60
|
-
return new ClaudeLatestService({ fetchLatest, now: opts.now ?? (() => Date.now()), ttlMs: opts.ttlMs });
|
|
61
|
-
}
|