@sailresearch/code 0.1.0

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/src/login.js ADDED
@@ -0,0 +1,453 @@
1
+ /**
2
+ * Browser device-flow login against the Sail dashboard's `/cli-auth` page.
3
+ *
4
+ * We open `{app}/cli-auth?name=…&redirect_uri=http://127.0.0.1:{port}/callback&state=…`
5
+ * in the user's browser. After the user authenticates, the page mints an API
6
+ * key and form-POSTs `api_key`, `state`, `mode`, and `api_url` back to our
7
+ * loopback listener. The key is validated against the returned API URL with a
8
+ * cheap authenticated call (`GET /v1/models`) before being stored.
9
+ */
10
+
11
+ import crypto from "node:crypto";
12
+ import http from "node:http";
13
+ import { spawn } from "node:child_process";
14
+
15
+ import { DEFAULT_APP_URL, MODE_API_URLS, MODE_VALUES } from "./constants.js";
16
+ import {
17
+ authPath,
18
+ maskSecret,
19
+ saveAuthKey,
20
+ saveLoginSettings,
21
+ } from "./credentials.js";
22
+
23
+ const LOGIN_TIMEOUT_MS = 10 * 60 * 1000;
24
+ const MAX_CALLBACK_BODY_BYTES = 64 * 1024;
25
+
26
+ /**
27
+ * Reject a login callback that landed on a different environment than the
28
+ * one the invocation explicitly requested (via --mode, SAIL_MODE,
29
+ * SAIL_API_URL, or a stored config target). Without this, `SAIL_API_URL=…
30
+ * sail-code` with no stored key would let the default (prod) browser login
31
+ * silently retarget the session and store a key tagged for the wrong
32
+ * environment. The implicit prod default is not enforced, so SAIL_APP_URL
33
+ * driven logins against other environments stay possible. Exported for tests.
34
+ */
35
+ export function assertLoginTargetMatches({
36
+ expectedTarget,
37
+ expectedSource,
38
+ apiUrl,
39
+ }) {
40
+ if (!expectedTarget || expectedSource === "default") return;
41
+ const expected = expectedTarget.trim().replace(/\/+$/, "");
42
+ if (expected !== "" && expected !== apiUrl) {
43
+ throw new Error(
44
+ `this login is for ${apiUrl}, but ${expectedSource} requests ${expected}; ` +
45
+ `set SAIL_APP_URL to that environment's web app and try again`,
46
+ );
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Fail *before* opening the browser when the dashboard we'd open cannot mint a
52
+ * key for the requested environment. The only unambiguous case is the default
53
+ * (prod) web app paired with an explicit non-prod/custom target (`--mode dev`,
54
+ * `SAIL_MODE=staging`, a non-prod `SAIL_API_URL`, or a stored non-prod target):
55
+ * that app can only mint a production key, which `assertLoginTargetMatches`
56
+ * would later reject — but only after the dashboard already minted an unused
57
+ * prod credential. Preflighting here avoids that orphan key. A custom
58
+ * `SAIL_APP_URL` is left alone: it may legitimately mint the requested
59
+ * environment's key, and the loopback callback + `assertLoginTargetMatches`
60
+ * adjudicate it. Exported for tests. `appUrl` is the normalized dashboard URL.
61
+ */
62
+ export function assertLoginAppCanMintTarget({
63
+ expectedTarget,
64
+ expectedSource,
65
+ appUrl,
66
+ }) {
67
+ if (!expectedTarget || expectedSource === "default") return;
68
+ const expected = expectedTarget.trim().replace(/\/+$/, "");
69
+ const defaultApp = DEFAULT_APP_URL.replace(/\/+$/, "");
70
+ const prod = MODE_API_URLS.prod.replace(/\/+$/, "");
71
+ if (appUrl === defaultApp && expected !== "" && expected !== prod) {
72
+ throw new Error(
73
+ `logging in at ${defaultApp} can only mint a production key, but ` +
74
+ `${expectedSource} requests ${expected}. Set SAIL_APP_URL to that ` +
75
+ `environment's web app, or set SAIL_API_KEY to a key for it.`,
76
+ );
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Run the browser login flow and persist the credential. Returns
82
+ * `{ apiKey, apiUrl }`. `expectedTarget`/`expectedSource` (from
83
+ * `resolveTarget`) pin the environment this invocation asked for: a callback
84
+ * for a different one fails loudly rather than storing a key tagged for the
85
+ * wrong target.
86
+ */
87
+ export async function browserLogin({
88
+ expectedTarget,
89
+ expectedSource,
90
+ _readPastedKey = readPastedKeyFromStdin,
91
+ } = {}) {
92
+ const appUrl = (process.env.SAIL_APP_URL?.trim() || DEFAULT_APP_URL).replace(
93
+ /\/+$/,
94
+ "",
95
+ );
96
+ // Bail before minting an unusable key if the dashboard can't serve the
97
+ // requested environment (e.g. `--mode dev` with the default prod app).
98
+ assertLoginAppCanMintTarget({ expectedTarget, expectedSource, appUrl });
99
+ const state = crypto.randomBytes(32).toString("hex");
100
+
101
+ const { server, port, callback } = await startCallbackServer(state);
102
+ const loginUrl =
103
+ `${appUrl}/cli-auth?name=sail-code` +
104
+ `&redirect_uri=${encodeURIComponent(`http://127.0.0.1:${port}/callback`)}` +
105
+ `&state=${state}`;
106
+ // Same page without redirect_uri/state renders the key as copyable text
107
+ // instead of auto-POSTing it — the manual path for when the browser can't
108
+ // reach this machine's loopback (SSH, WSL, a container).
109
+ const manualUrl = `${appUrl}/cli-auth?name=sail-code`;
110
+
111
+ console.error(`Opening browser to log in to Sail…\n ${loginUrl}\n`);
112
+ openBrowser(loginUrl);
113
+ console.error(
114
+ "If your browser opened on a different machine (SSH/WSL/container) and " +
115
+ "the login doesn't complete on its own, open this URL instead, then " +
116
+ "paste the key here and press Enter:\n" +
117
+ ` ${manualUrl}\n`,
118
+ );
119
+
120
+ // The manual paste yields only the key; its environment is this invocation's
121
+ // resolved target (or prod by default). validateKey below rejects a paste
122
+ // for the wrong environment, so a mismatch fails loudly rather than storing
123
+ // a mis-tagged key.
124
+ const manualApiUrl = manualPasteApiUrl(expectedTarget);
125
+ // Only SAIL_APP_URL customized the dashboard (no --mode / SAIL_API_URL): the
126
+ // loopback callback would learn that app's API URL from the POST, but the
127
+ // paste path has no such signal and manualApiUrl falls back to prod. Storing
128
+ // a key minted by a custom dashboard as prod is wrong, so the paste path
129
+ // fails closed here and asks for an explicit SAIL_API_URL. (The loopback
130
+ // path is unaffected and still works for a local browser.)
131
+ const pasteTargetAmbiguous = pasteTargetIsAmbiguous({
132
+ appUrl,
133
+ expectedSource,
134
+ });
135
+ const paste = _readPastedKey();
136
+
137
+ let result;
138
+ try {
139
+ result = await Promise.race([
140
+ callback,
141
+ paste.promise.then((apiKey) => {
142
+ if (pasteTargetAmbiguous) {
143
+ throw new Error(
144
+ `SAIL_APP_URL points at ${appUrl}, but no SAIL_API_URL was set, so ` +
145
+ "a pasted key's environment can't be determined (it would be " +
146
+ "stored as production). Re-run with SAIL_API_URL set to that " +
147
+ "environment's API endpoint, or complete the login in a local " +
148
+ "browser.",
149
+ );
150
+ }
151
+ return {
152
+ api_key: apiKey,
153
+ // Recover the named mode from the resolved target so a pasted login
154
+ // for `--mode dev`/SAIL_MODE=staging persists the mode (not just the
155
+ // api_url override) to ~/.sail — otherwise the Sail CLI/SDK keep prod
156
+ // defaults for sibling endpoints (sailbox/imagebuilder). The loopback
157
+ // path gets this from the page; the paste path has only the URL.
158
+ mode: modeForApiUrl(manualApiUrl),
159
+ api_url: manualApiUrl,
160
+ };
161
+ }),
162
+ ]);
163
+ } finally {
164
+ server.close();
165
+ // Node 18's close() leaves idle keep-alive sockets (e.g. the browser's)
166
+ // open for their timeout, which would keep the process alive post-login.
167
+ server.closeAllConnections?.();
168
+ paste.cancel();
169
+ }
170
+
171
+ const apiUrl = result.api_url?.trim().replace(/\/+$/, "");
172
+ if (!apiUrl) {
173
+ throw new Error("login callback did not include an API URL — try again");
174
+ }
175
+ const mode = MODE_VALUES.includes(result.mode) ? result.mode : null;
176
+
177
+ assertLoginTargetMatches({ expectedTarget, expectedSource, apiUrl });
178
+
179
+ await validateKey(result.api_key, apiUrl);
180
+
181
+ saveAuthKey(result.api_key);
182
+ saveLoginSettings({ target: apiUrl, mode });
183
+ console.error(
184
+ `Logged in. Credential ${maskSecret(result.api_key)} saved to ${authPath()}.`,
185
+ );
186
+ return { apiKey: result.api_key, apiUrl };
187
+ }
188
+
189
+ /** A cheap authenticated call proves the key works before we store it. */
190
+ async function validateKey(apiKey, apiUrl) {
191
+ let res;
192
+ try {
193
+ res = await fetch(`${apiUrl}/v1/models`, {
194
+ headers: { Authorization: `Bearer ${apiKey}` },
195
+ signal: AbortSignal.timeout(30_000),
196
+ });
197
+ } catch (err) {
198
+ throw new Error(
199
+ `could not validate the API key against ${apiUrl} (${err.message})`,
200
+ );
201
+ }
202
+ if (res.status === 401 || res.status === 403) {
203
+ throw new Error(`API key rejected by ${apiUrl} (HTTP ${res.status})`);
204
+ }
205
+ if (!res.ok) {
206
+ throw new Error(
207
+ `could not validate the API key against ${apiUrl} (HTTP ${res.status})`,
208
+ );
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Loopback listener for the `/cli-auth` form POST. Requests with a wrong
214
+ * state or shape get an error response and the listener keeps waiting, so a
215
+ * stray request cannot hijack or kill the login.
216
+ */
217
+ function startCallbackServer(expectedState) {
218
+ return new Promise((resolveServer, rejectServer) => {
219
+ let settle;
220
+ const callback = new Promise((resolve, reject) => {
221
+ settle = { resolve, reject };
222
+ setTimeout(
223
+ () =>
224
+ reject(
225
+ new Error(
226
+ "timed out waiting for the browser login — run `sail-code login` to retry, " +
227
+ "or create a key at the Sail dashboard and export SAIL_API_KEY",
228
+ ),
229
+ ),
230
+ LOGIN_TIMEOUT_MS,
231
+ ).unref();
232
+ });
233
+
234
+ // Any throw in here must answer the request, never kill the process: a
235
+ // stray or hostile local request may not end the login flow.
236
+ const reject400 = (res) => {
237
+ try {
238
+ res
239
+ .writeHead(400, { "Content-Type": "text/plain" })
240
+ .end("invalid login callback");
241
+ } catch {
242
+ // Socket already gone; nothing to answer.
243
+ }
244
+ };
245
+ const server = http.createServer((req, res) => {
246
+ try {
247
+ handleCallbackRequest(req, res);
248
+ } catch {
249
+ reject400(res);
250
+ }
251
+ });
252
+
253
+ function handleCallbackRequest(req, res) {
254
+ if (
255
+ req.method !== "POST" ||
256
+ new URL(req.url, "http://127.0.0.1").pathname !== "/callback"
257
+ ) {
258
+ res.writeHead(404, { "Content-Type": "text/plain" }).end("not found");
259
+ return;
260
+ }
261
+ let body = "";
262
+ let overflow = false;
263
+ req.on("data", (chunk) => {
264
+ body += chunk;
265
+ if (body.length > MAX_CALLBACK_BODY_BYTES) {
266
+ overflow = true;
267
+ req.destroy();
268
+ }
269
+ });
270
+ req.on("end", () => {
271
+ if (overflow) return;
272
+ try {
273
+ const params = new URLSearchParams(body);
274
+ const state = params.get("state") ?? "";
275
+ const apiKey = params.get("api_key") ?? "";
276
+ // Compare byte buffers: timingSafeEqual throws on unequal lengths,
277
+ // and multibyte characters make string length ≠ byte length.
278
+ const stateBuf = Buffer.from(state);
279
+ const expectedBuf = Buffer.from(expectedState);
280
+ const stateOk =
281
+ stateBuf.length === expectedBuf.length &&
282
+ crypto.timingSafeEqual(stateBuf, expectedBuf);
283
+ if (!stateOk || apiKey === "") {
284
+ reject400(res);
285
+ return;
286
+ }
287
+ res
288
+ .writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
289
+ .end(
290
+ "<!doctype html><title>Sail login complete</title>" +
291
+ '<body style="font-family:system-ui;display:grid;place-items:center;height:90vh">' +
292
+ "<p>Login complete — return to your terminal.</p></body>",
293
+ );
294
+ settle.resolve({
295
+ api_key: apiKey,
296
+ mode: params.get("mode") ?? "",
297
+ api_url: params.get("api_url") ?? "",
298
+ });
299
+ } catch {
300
+ reject400(res);
301
+ }
302
+ });
303
+ }
304
+
305
+ server.on("error", (err) => {
306
+ rejectServer(
307
+ new Error(`could not start the login listener: ${err.message}`),
308
+ );
309
+ settle.reject(err);
310
+ });
311
+ server.listen(0, "127.0.0.1", () => {
312
+ resolveServer({ server, port: server.address().port, callback });
313
+ });
314
+ });
315
+ }
316
+
317
+ /** Open a URL in the default browser, best effort (the URL is also printed). */
318
+ function openBrowser(url) {
319
+ const platform = process.platform;
320
+ let cmd;
321
+ let args;
322
+ if (platform === "darwin") {
323
+ cmd = "open";
324
+ args = [url];
325
+ } else if (platform === "win32") {
326
+ // Not `cmd /c start`: cmd.exe applies %VAR% expansion and &-parsing to the
327
+ // command string, which can corrupt the login URL's percent-encoded
328
+ // redirect_uri / query before `start` ever sees it. explorer.exe hands the
329
+ // URL straight to ShellExecute (the default browser) with no shell
330
+ // parsing, so % and & survive intact. (It exits 1 even on success, but we
331
+ // ignore the exit code — this is best effort and the URL is also printed.)
332
+ cmd = "explorer.exe";
333
+ args = [url];
334
+ } else {
335
+ cmd = "xdg-open";
336
+ args = [url];
337
+ }
338
+ try {
339
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
340
+ child.on("error", () => {});
341
+ child.unref();
342
+ } catch {
343
+ // The URL is printed; the user can open it by hand.
344
+ }
345
+ }
346
+
347
+ /**
348
+ * The environment a manually pasted key is validated/stored against. The
349
+ * manual `/cli-auth` page only hands back the key, not its API URL, so we use
350
+ * the invocation's resolved target (trailing slash normalized), falling back
351
+ * to prod when none was requested. Exported for tests.
352
+ */
353
+ export function manualPasteApiUrl(expectedTarget) {
354
+ const target = expectedTarget?.trim().replace(/\/+$/, "");
355
+ return target || MODE_API_URLS.prod;
356
+ }
357
+
358
+ /**
359
+ * Whether a manually pasted key's environment can't be determined: only
360
+ * `SAIL_APP_URL` pointed the browser at a non-default dashboard, with no
361
+ * `--mode`/`SAIL_API_URL` to say which API endpoint minted the key. The
362
+ * loopback path learns that from the callback POST; the paste path can't, so
363
+ * it must fail closed rather than store the key against prod. Exported for
364
+ * tests. `appUrl` is the already-normalized dashboard URL browserLogin opens.
365
+ */
366
+ export function pasteTargetIsAmbiguous({ appUrl, expectedSource }) {
367
+ const defaultApp = DEFAULT_APP_URL.replace(/\/+$/, "");
368
+ return appUrl !== defaultApp && expectedSource === "default";
369
+ }
370
+
371
+ /**
372
+ * The mode name whose API URL equals `apiUrl`, or "" for a custom/self-hosted
373
+ * URL with no named mode. Lets the paste path persist the mode the same way
374
+ * the loopback callback does. Exported for tests.
375
+ */
376
+ export function modeForApiUrl(apiUrl) {
377
+ for (const [mode, url] of Object.entries(MODE_API_URLS)) {
378
+ if (url === apiUrl) return mode;
379
+ }
380
+ return "";
381
+ }
382
+
383
+ /**
384
+ * Read a pasted API key from stdin, concurrently with the loopback callback,
385
+ * so remote sessions whose browser can't reach 127.0.0.1 still finish login.
386
+ * Returns `{ promise, cancel }`: `promise` resolves with the first non-empty
387
+ * line; `cancel` stops reading (called once the race settles). Non-interactive
388
+ * stdin has no paste path, so the promise never resolves and the loopback
389
+ * timeout governs.
390
+ */
391
+ export function readPastedKeyFromStdin(input = process.stdin) {
392
+ if (!input.isTTY) {
393
+ return { promise: new Promise(() => {}), cancel: () => {} };
394
+ }
395
+ // Read with echo disabled: the pasted value is a secret API key, and a
396
+ // cooked-mode TTY (what readline uses) echoes it into the terminal
397
+ // scrollback and any session log. Raw mode stops the TTY driver from
398
+ // echoing; we consume the bytes ourselves and never write them back.
399
+ let settled = false;
400
+ let buf = "";
401
+ let onData;
402
+ const restore = () => {
403
+ if (onData) input.removeListener("data", onData);
404
+ try {
405
+ input.setRawMode?.(false);
406
+ } catch {
407
+ // Not a real TTY (e.g. tests); nothing to restore.
408
+ }
409
+ };
410
+ const promise = new Promise((resolve, reject) => {
411
+ try {
412
+ input.setRawMode?.(true);
413
+ } catch {
414
+ // Best effort — fall back to the stream's current mode.
415
+ }
416
+ input.resume?.();
417
+ onData = (chunk) => {
418
+ if (settled) return;
419
+ buf += chunk.toString("utf8");
420
+ // Ctrl-C / Ctrl-D: raw mode swallows the terminal's own SIGINT/EOF, so
421
+ // we must act on them ourselves — restore the terminal and reject so the
422
+ // login aborts now instead of hanging until the loopback timeout (the
423
+ // race would otherwise keep waiting for the callback for 10 minutes).
424
+ if (buf.includes("\x03") || buf.includes("\x04")) {
425
+ const eof = !buf.includes("\x03");
426
+ settled = true;
427
+ restore();
428
+ reject(new Error(eof ? "login canceled (EOF)" : "login canceled"));
429
+ return;
430
+ }
431
+ let nl;
432
+ while ((nl = buf.search(/[\r\n]/)) !== -1) {
433
+ const line = buf.slice(0, nl).trim();
434
+ buf = buf.slice(nl + 1);
435
+ if (line && !settled) {
436
+ settled = true;
437
+ restore();
438
+ resolve(line);
439
+ return;
440
+ }
441
+ }
442
+ };
443
+ input.on("data", onData);
444
+ });
445
+ const cancel = () => {
446
+ settled = true;
447
+ restore();
448
+ // Release the stream so it can't keep the process alive or steal input
449
+ // from a subsequently spawned `claude`.
450
+ input.pause?.();
451
+ };
452
+ return { promise, cancel };
453
+ }