runline 0.29.0 → 0.30.1

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.
Files changed (69) hide show
  1. package/dist/auth/errors.d.ts +22 -0
  2. package/dist/auth/errors.js +25 -0
  3. package/dist/auth/oauth2.d.ts +21 -0
  4. package/dist/auth/oauth2.js +154 -0
  5. package/dist/auth/token.d.ts +11 -0
  6. package/dist/auth/token.js +231 -0
  7. package/dist/auth/types.d.ts +93 -0
  8. package/dist/auth/types.js +1 -0
  9. package/dist/commands/auth.js +26 -13
  10. package/dist/commands/connection.js +3 -6
  11. package/dist/commands/exec.js +7 -2
  12. package/dist/commands/init.js +9 -2
  13. package/dist/config/loader.d.ts +7 -13
  14. package/dist/config/loader.js +58 -88
  15. package/dist/config/store.d.ts +17 -0
  16. package/dist/config/store.js +85 -0
  17. package/dist/connections/file.d.ts +13 -0
  18. package/dist/connections/file.js +53 -0
  19. package/dist/connections/memory.d.ts +15 -0
  20. package/dist/connections/memory.js +70 -0
  21. package/dist/connections/types.d.ts +47 -0
  22. package/dist/connections/types.js +10 -0
  23. package/dist/core/engine.d.ts +5 -2
  24. package/dist/core/engine.js +48 -55
  25. package/dist/core/oauth.d.ts +20 -15
  26. package/dist/core/oauth.js +195 -98
  27. package/dist/credentials/http.d.ts +19 -0
  28. package/dist/credentials/http.js +109 -0
  29. package/dist/credentials/policy.d.ts +5 -0
  30. package/dist/credentials/policy.js +92 -0
  31. package/dist/credentials/registry.d.ts +31 -0
  32. package/dist/credentials/registry.js +171 -0
  33. package/dist/credentials/transport.d.ts +38 -0
  34. package/dist/credentials/transport.js +283 -0
  35. package/dist/credentials/types.d.ts +80 -0
  36. package/dist/credentials/types.js +1 -0
  37. package/dist/index.d.ts +13 -1
  38. package/dist/index.js +9 -1
  39. package/dist/plugin/types.d.ts +30 -17
  40. package/dist/plugins/_shared/credentialAdapter.js +88 -0
  41. package/dist/plugins/_shared/googleAuth.js +89 -103
  42. package/dist/plugins/_shared/googleCredentials.js +160 -0
  43. package/dist/plugins/_shared/microsoftAuth.js +81 -74
  44. package/dist/plugins/_shared/microsoftCredentials.js +90 -0
  45. package/dist/plugins/_shared/tokenRefresh.js +39 -0
  46. package/dist/plugins/bitwarden/src/index.js +13 -31
  47. package/dist/plugins/gmail/src/index.js +8 -44
  48. package/dist/plugins/googleAppsScript/src/index.js +3 -25
  49. package/dist/plugins/googleCalendar/src/index.js +3 -41
  50. package/dist/plugins/googleContacts/src/index.js +3 -36
  51. package/dist/plugins/googleDocs/src/index.js +5 -0
  52. package/dist/plugins/googleDocs/src/shared.js +2 -30
  53. package/dist/plugins/googleDrive/src/index.js +52 -98
  54. package/dist/plugins/googleSheets/src/index.js +3 -40
  55. package/dist/plugins/googleSlides/src/index.js +5 -39
  56. package/dist/plugins/googleTasks/src/index.js +3 -30
  57. package/dist/plugins/halopsa/src/index.js +16 -31
  58. package/dist/plugins/microsoftCalendar/src/index.js +2 -1
  59. package/dist/plugins/microsoftFiles/src/index.js +10 -24
  60. package/dist/plugins/microsoftMail/src/index.js +2 -1
  61. package/dist/plugins/paypal/src/index.js +14 -29
  62. package/dist/plugins/plaud/src/index.js +210 -0
  63. package/dist/plugins/plaud/src/shared.js +221 -0
  64. package/dist/plugins/salesforce/src/shared.js +17 -32
  65. package/dist/sdk.d.ts +13 -5
  66. package/dist/sdk.js +35 -29
  67. package/dist/utils/observer.d.ts +2 -0
  68. package/dist/utils/observer.js +11 -0
  69. package/package.json +1 -1
@@ -1,8 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { createRequire } from "node:module";
3
3
  import { Worker } from "node:worker_threads";
4
- import { applyEnvOverrides, updateConnectionConfig } from "../config/loader.js";
4
+ import { MemoryConnectionProvider } from "../connections/memory.js";
5
5
  import { formatValidationError, helpInputs, isTypedInputSchema, normalizeActionInput, validateTypedInput, } from "../plugin/schema.js";
6
+ import { notifyObserver } from "../utils/observer.js";
6
7
  /**
7
8
  * How many bodies one worker may run before it is retired.
8
9
  *
@@ -89,12 +90,16 @@ export class ExecutionEngine {
89
90
  registry;
90
91
  config;
91
92
  hooks;
93
+ connectionProvider;
92
94
  pooled = null;
93
95
  maxRunsPerWorker;
94
96
  constructor(registry, config, hooks = {}) {
95
97
  this.registry = registry;
96
98
  this.config = config;
97
99
  this.hooks = hooks;
100
+ this.connectionProvider =
101
+ hooks.connectionProvider ??
102
+ new MemoryConnectionProvider(config.connections);
98
103
  this.maxRunsPerWorker =
99
104
  config.maxRunsPerWorker ?? DEFAULT_MAX_RUNS_PER_WORKER;
100
105
  }
@@ -338,7 +343,23 @@ export class ExecutionEngine {
338
343
  throw new Error(`Unknown action: ${path}${this.selfPrefixHint(path)}`);
339
344
  }
340
345
  const { plugin, action } = resolved;
341
- const connection = this.resolveConnection(plugin);
346
+ if (isTypedInputSchema(action.inputSchema)) {
347
+ const validation = validateTypedInput(action.inputSchema, args);
348
+ if (!validation.ok) {
349
+ throw new Error(formatValidationError(path, validation));
350
+ }
351
+ }
352
+ const handle = await this.connectionProvider.resolve({
353
+ plugin: plugin.name,
354
+ action: action.name,
355
+ schema: plugin.connectionConfigSchema,
356
+ context,
357
+ });
358
+ const connection = structuredClone(await handle.read());
359
+ if (connection.plugin !== plugin.name) {
360
+ throw new Error(`Connection provider returned the wrong plugin for ${path}`);
361
+ }
362
+ const connectionName = connection.name;
342
363
  const ctx = {
343
364
  connection,
344
365
  context,
@@ -347,19 +368,15 @@ export class ExecutionEngine {
347
368
  warn: (msg) => console.warn(`[${plugin.name}] ${msg}`),
348
369
  error: (msg) => console.error(`[${plugin.name}] ${msg}`),
349
370
  },
350
- updateConnection: async (patch) => {
351
- // Mutate the in-memory copy so the rest of this action
352
- // sees the new values without re-reading disk.
353
- Object.assign(connection.config, patch);
354
- await updateConnectionConfig(connection.name, patch);
371
+ updateConnection: async (change) => {
372
+ const committed = await handle.update(change);
373
+ if (committed.plugin !== plugin.name ||
374
+ committed.name !== connectionName) {
375
+ throw new Error(`Connection provider changed identity during update for ${path}`);
376
+ }
377
+ connection.config = structuredClone(committed.config);
355
378
  },
356
379
  };
357
- if (isTypedInputSchema(action.inputSchema)) {
358
- const validation = validateTypedInput(action.inputSchema, args);
359
- if (!validation.ok) {
360
- throw new Error(formatValidationError(path, validation));
361
- }
362
- }
363
380
  const started = performance.now();
364
381
  try {
365
382
  const value = await action.execute(args, ctx);
@@ -374,21 +391,13 @@ export class ExecutionEngine {
374
391
  }
375
392
  }
376
393
  fireOnAction(pluginName, actionName, path, started, failed, error) {
377
- const onAction = this.hooks.onAction;
378
- if (!onAction)
379
- return;
380
- try {
381
- onAction({
382
- plugin: pluginName,
383
- action: actionName,
384
- path,
385
- durationMs: performance.now() - started,
386
- ...(failed ? { error: error ?? new Error("Unknown error") } : {}),
387
- });
388
- }
389
- catch {
390
- // Observability never breaks a run.
391
- }
394
+ notifyObserver(this.hooks.onAction, {
395
+ plugin: pluginName,
396
+ action: actionName,
397
+ path,
398
+ durationMs: performance.now() - started,
399
+ ...(failed ? { error: error ?? new Error("Unknown error") } : {}),
400
+ });
392
401
  }
393
402
  /**
394
403
  * A miss on "salesforce.status" is often a plugin that registered
@@ -409,15 +418,6 @@ export class ExecutionEngine {
409
418
  `action name with the plugin prefix ("${path}"), so the full callable ` +
410
419
  `path is "${doubled}".`);
411
420
  }
412
- resolveConnection(plugin) {
413
- const conn = this.config.connections.find((c) => c.plugin === plugin.name);
414
- const base = conn ?? {
415
- name: "default",
416
- plugin: plugin.name,
417
- config: {},
418
- };
419
- return applyEnvOverrides(base, plugin.connectionConfigSchema);
420
- }
421
421
  }
422
422
  // ── Helpers ──────────────────────────────
423
423
  /**
@@ -433,9 +433,8 @@ function newRunId() {
433
433
  return randomUUID();
434
434
  }
435
435
  /**
436
- * JSON round-trip to (a) guarantee structured-clone compatibility and
437
- * (b) preserve the previous engine's value semantics, where every action
438
- * result crossed a JSON boundary (Dates → ISO strings, no Maps, etc.).
436
+ * Action results cross a JSON boundary (Dates → ISO strings, no Maps).
437
+ * The round-trip also guarantees structured-clone compatibility.
439
438
  */
440
439
  function toPlainJson(value) {
441
440
  if (value === undefined)
@@ -453,8 +452,8 @@ function formatError(cause) {
453
452
  }
454
453
  return String(cause);
455
454
  }
456
- // ferrosearch is a Rust native addon, so unlike the old minisearch UMD it
457
- // cannot be inlined into the worker source as text. Resolve its entry point
455
+ // ferrosearch is a Rust native addon and cannot be inlined as worker source.
456
+ // Resolve its entry point
458
457
  // host-side and require it by absolute path inside the worker: eval'd worker
459
458
  // code gets a cwd-relative `require`, so a bare specifier would resolve
460
459
  // against the caller's cwd instead of runline's own node_modules.
@@ -495,10 +494,8 @@ export function buildRunBody(code) {
495
494
  }
496
495
  function buildWorkerSource(pluginNames = [], helpData = {}) {
497
496
  // Injected into every body's scope. `require` is here because bodies are
498
- // compiled with `new Function`, whose scope is the worker's *global* object
499
- // — not the module scope that used to hold the inlined body. Without it,
500
- // pooling would silently revoke a documented capability ("agent code gets
501
- // the full host JS runtime") as a side effect of a memory fix.
497
+ // compiled with `new Function`, whose scope is the worker's global object.
498
+ // Explicit injection gives bodies access to the host JS runtime.
502
499
  const injectNames = [
503
500
  ...pluginNames,
504
501
  "actions",
@@ -596,9 +593,8 @@ const __fmtErr = (e) => {
596
593
  const __toJson = (v) => v === undefined ? undefined : JSON.parse(JSON.stringify(v));
597
594
 
598
595
  // ferrosearch native addon, resolved to an absolute path by the host. Its
599
- // index memory lives outside the V8 heap, which is the point — minisearch's
600
- // JS index counted against maxOldGenerationSizeMb and grew worker RSS — but
601
- // also means an index built by agent code is invisible to that limit.
596
+ // index memory lives outside the V8 heap. An index built by agent code is
597
+ // therefore invisible to maxOldGenerationSizeMb.
602
598
  const { FerroSearch } = require(${JSON.stringify(ferrosearchPath)});
603
599
 
604
600
  // The host's own validator, for actions.check on typed schemas.
@@ -936,12 +932,9 @@ const __scrubGlobals = () => {
936
932
 
937
933
  // The host channel is the one capability a body must not hold.
938
934
  //
939
- // A body can require("node:worker_threads"). Before pooling that was
940
- // harmless: anything it attached died with the worker after a single run.
941
- // Under reuse a surviving listener sees the {t:"run", runId} of EVERY later
942
- // body and can post a forged "done" for it, handing that run attacker-chosen
943
- // output. Randomising the run id does not help — the listener is simply told
944
- // what it is.
935
+ // A surviving parentPort listener sees the {t:"run", runId} of every later
936
+ // body and can forge its "done" message. Random run IDs alone cannot protect
937
+ // a channel that exposes those IDs to the body.
945
938
  //
946
939
  // Bun's MessagePort exposes no listenerCount/removeAllListeners, so listeners
947
940
  // cannot be enumerated and stripped after the fact. Instead the route in is
@@ -22,7 +22,9 @@
22
22
  * wants (redirect-URI paste, browser popup with postMessage,
23
23
  * public HTTPS callback, …), calls exchangeAuthCode.
24
24
  */
25
+ import type { OAuthTokens } from "../auth/types.js";
25
26
  import type { OAuthConfig } from "../plugin/types.js";
27
+ export type { OAuthTokens } from "../auth/types.js";
26
28
  /**
27
29
  * Fixed callback port for OAuth redirects. Pinned so users can
28
30
  * register `http://127.0.0.1:<PORT>/callback` once with the
@@ -34,24 +36,26 @@ import type { OAuthConfig } from "../plugin/types.js";
34
36
  export declare const OAUTH_CALLBACK_PORT: number;
35
37
  /** Canonical localhost redirect URI for CLI-based flows. */
36
38
  export declare const OAUTH_CALLBACK_URI: string;
37
- export interface OAuthTokens {
38
- accessToken: string;
39
- refreshToken: string;
40
- /** Milliseconds since epoch. */
41
- expiresAt: number;
42
- scope?: string;
43
- tokenType?: string;
39
+ interface Callback {
40
+ uri: string;
41
+ port: number;
42
+ path: string;
44
43
  }
44
+ /** Only loopback HTTP callbacks are accepted; the flow never listens on other interfaces. */
45
+ export declare function oauthCallback(config: OAuthConfig): Callback;
45
46
  export interface RunOAuthOptions {
46
47
  clientId: string;
47
- clientSecret: string;
48
+ /** Omitted for public clients; PKCE protects the exchange instead. */
49
+ clientSecret?: string;
48
50
  /**
49
51
  * Called with the consent URL before the browser is opened.
50
52
  * Lets the CLI print a clickable link in case auto-open fails.
51
53
  */
52
- onAuthUrl?: (url: string) => void;
54
+ onAuthUrl?: (url: string) => void | Promise<void>;
53
55
  /** Override the browser launcher. Defaults to OS-appropriate command. */
54
- openBrowser?: (url: string) => void;
56
+ openBrowser?: (url: string) => void | Promise<void>;
57
+ /** Maximum wait for browser consent. Defaults to five minutes. */
58
+ callbackTimeoutMs?: number;
55
59
  }
56
60
  export interface BuildAuthUrlOptions {
57
61
  clientId: string;
@@ -62,11 +66,13 @@ export interface BuildAuthUrlOptions {
62
66
  }
63
67
  export interface ExchangeCodeOptions {
64
68
  clientId: string;
65
- clientSecret: string;
69
+ clientSecret?: string;
66
70
  code: string;
67
71
  redirectUri: string;
68
72
  /** PKCE verifier matching the challenge sent on the auth URL. */
69
73
  codeVerifier?: string;
74
+ /** Host-validated state; sent only when the provider protocol requires it. */
75
+ state?: string;
70
76
  }
71
77
  export interface PKCEPair {
72
78
  verifier: string;
@@ -85,15 +91,14 @@ export declare function generatePKCE(): PKCEPair;
85
91
  */
86
92
  export declare function buildAuthUrl(config: OAuthConfig, opts: BuildAuthUrlOptions): string;
87
93
  /**
88
- * Exchange an authorization code for tokens. Throws if the
89
- * provider doesn't return a refresh_token — that's almost always a
90
- * misconfiguration (e.g. missing `access_type=offline` for Google).
94
+ * Exchange an authorization code using the shared OAuth2 protocol runtime.
95
+ * Refresh tokens and expiry are optional provider capabilities.
91
96
  */
92
97
  export declare function exchangeAuthCode(config: OAuthConfig, opts: ExchangeCodeOptions): Promise<OAuthTokens>;
93
98
  /**
94
99
  * Run the full OAuth2 authorization-code flow end-to-end.
95
100
  * Resolves with the exchanged tokens once the user completes the
96
- * browser consent and the token endpoint returns a refresh token.
101
+ * browser consent and the token endpoint returns a valid access token.
97
102
  *
98
103
  * Uses the pinned localhost callback port. If you can't run a
99
104
  * local callback server, drive the flow yourself with
@@ -25,6 +25,8 @@
25
25
  import { spawn } from "node:child_process";
26
26
  import { createHash, randomBytes } from "node:crypto";
27
27
  import { createServer, } from "node:http";
28
+ import { AuthError } from "../auth/errors.js";
29
+ import { buildOAuth2AuthorizationUrl, exchangeOAuth2Code, } from "../auth/oauth2.js";
28
30
  /**
29
31
  * Fixed callback port for OAuth redirects. Pinned so users can
30
32
  * register `http://127.0.0.1:<PORT>/callback` once with the
@@ -44,6 +46,64 @@ export const OAUTH_CALLBACK_PORT = (() => {
44
46
  })();
45
47
  /** Canonical localhost redirect URI for CLI-based flows. */
46
48
  export const OAUTH_CALLBACK_URI = `http://127.0.0.1:${OAUTH_CALLBACK_PORT}/callback`;
49
+ /** Browsers resolve `localhost` to either family; IPv6 may be unavailable. */
50
+ const LOOPBACK_HOSTS = ["127.0.0.1", "::1"];
51
+ const IPV6_UNAVAILABLE = new Set([
52
+ "EAFNOSUPPORT",
53
+ "EADDRNOTAVAIL",
54
+ "EINVAL",
55
+ "EPROTONOSUPPORT",
56
+ ]);
57
+ /** Only loopback HTTP callbacks are accepted; the flow never listens on other interfaces. */
58
+ export function oauthCallback(config) {
59
+ if (config.redirectUri === undefined)
60
+ return {
61
+ uri: OAUTH_CALLBACK_URI,
62
+ port: OAUTH_CALLBACK_PORT,
63
+ path: "/callback",
64
+ };
65
+ let url;
66
+ try {
67
+ url = new URL(config.redirectUri);
68
+ }
69
+ catch {
70
+ throw new AuthError("invalid_definition");
71
+ }
72
+ const port = Number(url.port);
73
+ if (url.protocol !== "http:" ||
74
+ !["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) ||
75
+ !Number.isInteger(port) ||
76
+ port <= 0 ||
77
+ url.username ||
78
+ url.password ||
79
+ url.search ||
80
+ url.hash ||
81
+ url.pathname === "/")
82
+ throw new AuthError("invalid_definition");
83
+ return { uri: url.toString(), port, path: url.pathname };
84
+ }
85
+ /** Plugin OAuth declarations retain their body-auth default at the compatibility boundary. */
86
+ function definition(config) {
87
+ if (config.protocol !== undefined) {
88
+ if (!config.protocol ||
89
+ config.authUrl !== undefined ||
90
+ config.tokenUrl !== undefined ||
91
+ config.authParams !== undefined)
92
+ throw new AuthError("invalid_definition");
93
+ return config.protocol;
94
+ }
95
+ if (config.publicClient)
96
+ throw new AuthError("invalid_definition");
97
+ return {
98
+ id: "plugin-oauth",
99
+ provider: "legacy",
100
+ authorization: { url: config.authUrl, parameters: config.authParams },
101
+ exchange: {
102
+ url: config.tokenUrl,
103
+ clientAuthentication: "client_secret_post",
104
+ },
105
+ };
106
+ }
47
107
  // ─── Primitives ──────────────────────────────────────────────────
48
108
  /**
49
109
  * Generate a PKCE verifier and its SHA-256 challenge. The verifier
@@ -60,140 +120,178 @@ export function generatePKCE() {
60
120
  * Pass `pkceChallenge` to include PKCE; omit for plain auth-code.
61
121
  */
62
122
  export function buildAuthUrl(config, opts) {
63
- const url = new URL(config.authUrl);
64
- url.searchParams.set("client_id", opts.clientId);
65
- url.searchParams.set("redirect_uri", opts.redirectUri);
66
- url.searchParams.set("response_type", "code");
67
- url.searchParams.set("scope", config.scopes.join(" "));
68
- url.searchParams.set("state", opts.state);
69
- if (opts.pkceChallenge) {
70
- url.searchParams.set("code_challenge", opts.pkceChallenge);
71
- url.searchParams.set("code_challenge_method", "S256");
72
- }
73
- for (const [k, v] of Object.entries(config.authParams ?? {})) {
74
- url.searchParams.set(k, v);
75
- }
76
- return url.toString();
123
+ return buildOAuth2AuthorizationUrl(definition(config), {
124
+ application: { clientId: opts.clientId },
125
+ redirectUri: opts.redirectUri,
126
+ state: opts.state,
127
+ scopes: config.scopes,
128
+ pkceChallenge: opts.pkceChallenge,
129
+ });
77
130
  }
78
131
  /**
79
- * Exchange an authorization code for tokens. Throws if the
80
- * provider doesn't return a refresh_token — that's almost always a
81
- * misconfiguration (e.g. missing `access_type=offline` for Google).
132
+ * Exchange an authorization code using the shared OAuth2 protocol runtime.
133
+ * Refresh tokens and expiry are optional provider capabilities.
82
134
  */
83
135
  export async function exchangeAuthCode(config, opts) {
84
- const body = new URLSearchParams({
136
+ const protocol = definition(config);
137
+ if (config.publicClient ? opts.clientSecret !== undefined : !opts.clientSecret)
138
+ throw new AuthError("invalid_credentials");
139
+ return exchangeOAuth2Code(protocol, {
140
+ application: { clientId: opts.clientId, clientSecret: opts.clientSecret },
85
141
  code: opts.code,
86
- client_id: opts.clientId,
87
- client_secret: opts.clientSecret,
88
- redirect_uri: opts.redirectUri,
89
- grant_type: "authorization_code",
90
- });
91
- if (opts.codeVerifier)
92
- body.set("code_verifier", opts.codeVerifier);
93
- const res = await fetch(config.tokenUrl, {
94
- method: "POST",
95
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
96
- body: body.toString(),
142
+ redirectUri: opts.redirectUri,
143
+ codeVerifier: opts.codeVerifier,
144
+ state: opts.state,
97
145
  });
98
- if (!res.ok) {
99
- throw new Error(`OAuth: token exchange failed (${res.status}): ${await res.text()}`);
100
- }
101
- const data = (await res.json());
102
- if (!data.refresh_token) {
103
- throw new Error("OAuth: provider did not return a refresh token. This usually means a prior consent is cached — revoke access with the provider and retry. For Google, set authParams: { access_type: 'offline', prompt: 'consent' }.");
104
- }
105
- return {
106
- accessToken: data.access_token,
107
- refreshToken: data.refresh_token,
108
- expiresAt: Date.now() + data.expires_in * 1000,
109
- scope: data.scope,
110
- tokenType: data.token_type,
111
- };
112
146
  }
113
147
  // ─── End-to-end CLI flow ─────────────────────────────────────────
114
148
  /**
115
149
  * Run the full OAuth2 authorization-code flow end-to-end.
116
150
  * Resolves with the exchanged tokens once the user completes the
117
- * browser consent and the token endpoint returns a refresh token.
151
+ * browser consent and the token endpoint returns a valid access token.
118
152
  *
119
153
  * Uses the pinned localhost callback port. If you can't run a
120
154
  * local callback server, drive the flow yourself with
121
155
  * `buildAuthUrl` + `exchangeAuthCode`.
122
156
  */
123
157
  export async function runOAuth(config, options) {
124
- const redirectUri = OAUTH_CALLBACK_URI;
158
+ // Consent and exchange use the same detached setup snapshot.
159
+ let flowConfig;
160
+ try {
161
+ flowConfig = structuredClone(config);
162
+ }
163
+ catch {
164
+ throw new AuthError("invalid_definition");
165
+ }
166
+ const { clientId, clientSecret, onAuthUrl, openBrowser = defaultOpenBrowser, callbackTimeoutMs = 300_000, } = options;
167
+ const callback = oauthCallback(flowConfig);
168
+ const redirectUri = callback.uri;
125
169
  const state = randomState();
126
170
  const { verifier, challenge } = generatePKCE();
127
- const authUrl = buildAuthUrl(config, {
128
- clientId: options.clientId,
171
+ const authUrl = buildAuthUrl(flowConfig, {
172
+ clientId,
129
173
  redirectUri,
130
174
  state,
131
175
  pkceChallenge: challenge,
132
176
  });
133
- // Always make the URL visible. `onAuthUrl` is the preferred
134
- // channel (CLI formats it inline); if absent, fall back to
135
- // stderr so headless invocations (SSH, CI) aren't left waiting
136
- // on a browser that never opens.
137
- if (options.onAuthUrl) {
138
- options.onAuthUrl(authUrl);
139
- }
140
- else {
141
- console.error(`Open this URL to authorize:\n ${authUrl}`);
142
- }
143
- const open = options.openBrowser ?? defaultOpenBrowser;
144
- open(authUrl);
145
- const { code } = await captureCode(OAUTH_CALLBACK_PORT, state);
146
- return exchangeAuthCode(config, {
147
- clientId: options.clientId,
148
- clientSecret: options.clientSecret,
177
+ const { code } = await captureCode(callback, state, async (signal) => {
178
+ // Publication may outlive the flow; never launch a finished consent request.
179
+ if (onAuthUrl)
180
+ await onAuthUrl(authUrl);
181
+ else
182
+ console.error(`Open this URL to authorize:\n ${authUrl}`);
183
+ if (!signal.aborted)
184
+ await openBrowser(authUrl);
185
+ }, callbackTimeoutMs);
186
+ return exchangeAuthCode(flowConfig, {
187
+ clientId,
188
+ clientSecret,
149
189
  code,
150
190
  redirectUri,
151
191
  codeVerifier: verifier,
192
+ state,
152
193
  });
153
194
  }
154
195
  // ─── Local callback server ───────────────────────────────────────
155
- function captureCode(port, expectedState) {
196
+ function listenLoopback(port, handler, onError) {
197
+ const servers = [];
198
+ const close = () => {
199
+ for (const server of servers)
200
+ server.close();
201
+ };
202
+ const listen = (host) => new Promise((resolve, reject) => {
203
+ const server = createServer(handler);
204
+ server.once("error", reject);
205
+ server.listen(port, host, () => {
206
+ server.removeListener("error", reject);
207
+ server.on("error", onError);
208
+ servers.push(server);
209
+ resolve();
210
+ });
211
+ });
212
+ return (async () => {
213
+ for (const host of LOOPBACK_HOSTS) {
214
+ try {
215
+ await listen(host);
216
+ }
217
+ catch (error) {
218
+ const code = error.code ?? "";
219
+ if (host !== LOOPBACK_HOSTS[0] && IPV6_UNAVAILABLE.has(code))
220
+ continue;
221
+ close();
222
+ throw error;
223
+ }
224
+ }
225
+ return servers;
226
+ })();
227
+ }
228
+ function captureCode(callback, expectedState, onReady, timeoutMs) {
229
+ if (!Number.isInteger(timeoutMs) ||
230
+ timeoutMs <= 0 ||
231
+ timeoutMs > 2 ** 31 - 1) {
232
+ return Promise.reject(new Error("OAuth: invalid callback timeout"));
233
+ }
156
234
  return new Promise((resolve, reject) => {
157
- const server = createServer((req, res) => {
158
- const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
159
- if (url.pathname !== "/callback") {
160
- res.statusCode = 404;
161
- res.end("Not found");
235
+ const lifecycle = new AbortController();
236
+ let servers = [];
237
+ const finish = (result) => {
238
+ if (lifecycle.signal.aborted)
162
239
  return;
163
- }
164
- const code = url.searchParams.get("code");
165
- const state = url.searchParams.get("state");
166
- const error = url.searchParams.get("error");
167
- if (error) {
168
- res.statusCode = 400;
169
- res.end(`Authorization failed: ${error}. You can close this tab.`);
240
+ lifecycle.abort();
241
+ clearTimeout(timer);
242
+ for (const server of servers)
170
243
  server.close();
171
- reject(new Error(`OAuth: ${error}`));
244
+ if (result instanceof Error)
245
+ reject(result);
246
+ else
247
+ resolve(result);
248
+ };
249
+ const handler = (req, res) => {
250
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
251
+ res.setHeader("Cache-Control", "no-store");
252
+ let url;
253
+ try {
254
+ url = new URL(req.url ?? "/", `http://127.0.0.1:${callback.port}`);
255
+ }
256
+ catch {
257
+ res.writeHead(400).end("Invalid callback");
172
258
  return;
173
259
  }
174
- if (!code) {
175
- res.statusCode = 400;
176
- res.end("Missing code");
260
+ if (req.method !== "GET" || url.pathname !== callback.path) {
261
+ res.writeHead(404).end("Not found");
177
262
  return;
178
263
  }
179
- if (state !== expectedState) {
180
- res.statusCode = 400;
181
- res.end("State mismatch — possible CSRF. Aborting.");
182
- server.close();
183
- reject(new Error("OAuth: state mismatch"));
264
+ // Unauthenticated callbacks cannot cancel or complete a pending flow.
265
+ if (lifecycle.signal.aborted ||
266
+ url.searchParams.getAll("state").length !== 1 ||
267
+ url.searchParams.get("state") !== expectedState) {
268
+ res.writeHead(400).end("State mismatch");
184
269
  return;
185
270
  }
186
- res.statusCode = 200;
187
- res.setHeader("Content-Type", "text/html; charset=utf-8");
188
- res.end(`<!doctype html><meta charset="utf-8"><title>Connected</title>` +
189
- `<body style="font-family:system-ui;padding:2rem">` +
190
- `<h1 style="margin:0 0 0.5rem">Connected \u2713</h1>` +
191
- `<p style="color:#666">You can close this tab and return to your terminal.</p>`);
192
- server.close();
193
- resolve({ code });
194
- });
195
- server.once("error", reject);
196
- server.listen(port, "127.0.0.1");
271
+ if (url.searchParams.has("error")) {
272
+ res.writeHead(400).end("Authorization denied. You can close this tab.");
273
+ finish(new Error("OAuth: authorization denied"));
274
+ return;
275
+ }
276
+ const code = url.searchParams.get("code");
277
+ if (!code || url.searchParams.getAll("code").length !== 1) {
278
+ res.writeHead(400).end("Missing or ambiguous code");
279
+ return;
280
+ }
281
+ res.end("Authorization received. Return to your terminal for the connection result.");
282
+ finish({ code });
283
+ };
284
+ const timer = setTimeout(() => finish(new Error("OAuth: browser consent timed out")), timeoutMs);
285
+ const unavailable = () => finish(new Error("OAuth: callback listener unavailable"));
286
+ listenLoopback(callback.port, handler, unavailable).then((bound) => {
287
+ servers = bound;
288
+ if (lifecycle.signal.aborted) {
289
+ for (const server of servers)
290
+ server.close();
291
+ return;
292
+ }
293
+ void onReady(lifecycle.signal).catch(() => finish(new Error("OAuth: browser launch failed")));
294
+ }, unavailable);
197
295
  });
198
296
  }
199
297
  // ─── Helpers ─────────────────────────────────────────────────────
@@ -218,8 +316,7 @@ function defaultOpenBrowser(url) {
218
316
  detached: true,
219
317
  });
220
318
  proc.on("error", () => {
221
- // Browser failed to open — caller's onAuthUrl prints the URL
222
- // so the user can paste it manually.
319
+ // The displayed consent URL remains available for manual browser navigation.
223
320
  });
224
321
  proc.unref();
225
322
  }
@@ -0,0 +1,19 @@
1
+ import type { HttpMethod } from "./types.js";
2
+ export interface ResourceOptions {
3
+ fetch: typeof globalThis.fetch;
4
+ timeoutMs: number;
5
+ maxResponseBytes: number;
6
+ resumableUpload?: boolean;
7
+ }
8
+ /** Internal send primitive. Callers validate destination policy before reaching it. */
9
+ export declare function sendResource(url: string, method: HttpMethod, headers: Headers, body: Buffer | undefined, options: ResourceOptions): Promise<Response>;
10
+ /**
11
+ * Fetch a provider-issued preauthenticated URL without bearer tokens or cookies.
12
+ * Hosts approve exact origins and enforce DNS/IP egress through their fetch hook.
13
+ * The signed URL is itself secret: errors never include it. No redirects or retries.
14
+ */
15
+ export declare function downloadResource(value: string, options: {
16
+ allowedOrigins: string[];
17
+ fetch: typeof globalThis.fetch;
18
+ maxResponseBytes?: number;
19
+ }): Promise<Response>;