@cjhyy/code-shell-core 0.7.0 → 0.7.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 (118) hide show
  1. package/dist/arena/arena.d.ts +2 -0
  2. package/dist/arena/arena.js +30 -1
  3. package/dist/arena/phases/adjudication.d.ts +2 -1
  4. package/dist/arena/phases/adjudication.js +2 -1
  5. package/dist/arena/phases/build-consensus.d.ts +2 -1
  6. package/dist/arena/phases/build-consensus.js +3 -1
  7. package/dist/arena/phases/cross-review.d.ts +3 -1
  8. package/dist/arena/phases/cross-review.js +8 -2
  9. package/dist/arena/phases/debate-rounds.d.ts +2 -1
  10. package/dist/arena/phases/debate-rounds.js +4 -2
  11. package/dist/arena/phases/participant-research.d.ts +2 -1
  12. package/dist/arena/phases/participant-research.js +3 -1
  13. package/dist/arena/phases/planning-detail-expansion.d.ts +2 -1
  14. package/dist/arena/phases/planning-detail-expansion.js +2 -1
  15. package/dist/arena/planner.d.ts +2 -1
  16. package/dist/arena/planner.js +2 -1
  17. package/dist/arena/types.d.ts +5 -1
  18. package/dist/cli/agent-server-tcp.js +13 -2
  19. package/dist/context/manager.d.ts +3 -3
  20. package/dist/context/manager.js +6 -6
  21. package/dist/context/token-counter.js +5 -3
  22. package/dist/context/tool-result-storage.d.ts +6 -0
  23. package/dist/context/tool-result-storage.js +25 -4
  24. package/dist/credentials/access.d.ts +10 -1
  25. package/dist/credentials/access.js +76 -2
  26. package/dist/credentials/index.d.ts +3 -2
  27. package/dist/credentials/index.js +2 -1
  28. package/dist/credentials/oauth.d.ts +6 -1
  29. package/dist/credentials/oauth.js +66 -1
  30. package/dist/credentials/store.d.ts +1 -1
  31. package/dist/credentials/store.js +16 -7
  32. package/dist/credentials/types.d.ts +38 -1
  33. package/dist/credentials/types.js +16 -1
  34. package/dist/engine/engine.d.ts +61 -32
  35. package/dist/engine/engine.js +289 -55
  36. package/dist/engine/goal.d.ts +5 -3
  37. package/dist/engine/session-title.d.ts +2 -1
  38. package/dist/engine/session-title.js +4 -1
  39. package/dist/engine/turn-loop.d.ts +18 -0
  40. package/dist/engine/turn-loop.js +202 -27
  41. package/dist/engine/types.d.ts +6 -2
  42. package/dist/git/worktree/crud.d.ts +3 -0
  43. package/dist/git/worktree/crud.js +32 -3
  44. package/dist/git/worktree/git-exec.d.ts +2 -2
  45. package/dist/git/worktree/git-exec.js +47 -11
  46. package/dist/git/worktree/query.d.ts +8 -7
  47. package/dist/git/worktree/query.js +27 -20
  48. package/dist/hooks/events.d.ts +3 -0
  49. package/dist/hooks/events.js +0 -3
  50. package/dist/hooks/goal-stop-hook.d.ts +14 -4
  51. package/dist/hooks/goal-stop-hook.js +626 -71
  52. package/dist/hooks/registry.js +3 -0
  53. package/dist/hooks/shell-runner.d.ts +12 -1
  54. package/dist/hooks/shell-runner.js +160 -9
  55. package/dist/index.d.ts +7 -6
  56. package/dist/index.js +6 -5
  57. package/dist/llm/client-base.js +12 -10
  58. package/dist/llm/types.d.ts +12 -5
  59. package/dist/plugins/pluginCommandHook.d.ts +4 -4
  60. package/dist/plugins/pluginCommandHook.js +111 -13
  61. package/dist/protocol/chat-session-manager.d.ts +13 -2
  62. package/dist/protocol/chat-session-manager.js +90 -18
  63. package/dist/protocol/chat-session.d.ts +12 -0
  64. package/dist/protocol/chat-session.js +30 -5
  65. package/dist/protocol/client.d.ts +5 -2
  66. package/dist/protocol/client.js +22 -1
  67. package/dist/protocol/server.d.ts +25 -11
  68. package/dist/protocol/server.js +290 -72
  69. package/dist/protocol/types.d.ts +34 -2
  70. package/dist/protocol/types.js +2 -0
  71. package/dist/services/dream-consolidation.d.ts +3 -0
  72. package/dist/services/dream-consolidation.js +4 -1
  73. package/dist/services/index.d.ts +1 -1
  74. package/dist/services/index.js +1 -1
  75. package/dist/services/oauth.d.ts +34 -10
  76. package/dist/services/oauth.js +233 -98
  77. package/dist/session/session-manager.d.ts +35 -6
  78. package/dist/session/session-manager.js +362 -26
  79. package/dist/session/transcript.d.ts +30 -1
  80. package/dist/session/transcript.js +119 -4
  81. package/dist/tool-system/builtin/agent.js +5 -1
  82. package/dist/tool-system/builtin/arena.js +1 -0
  83. package/dist/tool-system/builtin/background-jobs.d.ts +8 -5
  84. package/dist/tool-system/builtin/background-jobs.js +15 -11
  85. package/dist/tool-system/builtin/background-work.d.ts +6 -1
  86. package/dist/tool-system/builtin/background-work.js +5 -1
  87. package/dist/tool-system/builtin/bash.d.ts +3 -5
  88. package/dist/tool-system/builtin/bash.js +10 -5
  89. package/dist/tool-system/builtin/browser-tools.d.ts +2 -2
  90. package/dist/tool-system/builtin/edit.d.ts +2 -1
  91. package/dist/tool-system/builtin/edit.js +12 -4
  92. package/dist/tool-system/builtin/generate-video.d.ts +4 -0
  93. package/dist/tool-system/builtin/generate-video.js +138 -21
  94. package/dist/tool-system/builtin/glob.d.ts +2 -1
  95. package/dist/tool-system/builtin/glob.js +28 -3
  96. package/dist/tool-system/builtin/grep.d.ts +1 -0
  97. package/dist/tool-system/builtin/grep.js +82 -17
  98. package/dist/tool-system/builtin/index.d.ts +25 -11
  99. package/dist/tool-system/builtin/index.js +45 -2
  100. package/dist/tool-system/builtin/video-providers.d.ts +12 -15
  101. package/dist/tool-system/builtin/video-providers.js +1 -0
  102. package/dist/tool-system/builtin/view-image.d.ts +2 -2
  103. package/dist/tool-system/builtin/web-fetch.js +44 -3
  104. package/dist/tool-system/builtin/worktree.js +25 -7
  105. package/dist/tool-system/builtin/write.d.ts +2 -1
  106. package/dist/tool-system/builtin/write.js +14 -4
  107. package/dist/tool-system/context.d.ts +17 -5
  108. package/dist/tool-system/executor.js +24 -8
  109. package/dist/tool-system/mcp-manager.d.ts +6 -0
  110. package/dist/tool-system/mcp-manager.js +55 -5
  111. package/dist/tool-system/path-policy.d.ts +19 -0
  112. package/dist/tool-system/path-policy.js +62 -1
  113. package/dist/tool-system/permission.d.ts +43 -3
  114. package/dist/tool-system/permission.js +383 -30
  115. package/dist/tool-system/registry.d.ts +3 -2
  116. package/dist/tool-system/registry.js +52 -34
  117. package/dist/types.d.ts +15 -0
  118. package/package.json +1 -1
@@ -7,7 +7,7 @@
7
7
  * Server → Client: notifications (stream events, approval requests)
8
8
  * Server → Client: responses (results of requests)
9
9
  */
10
- import type { StreamEvent, TokenUsage, TerminalReason, ApprovalRequest, ApprovalResult, PermissionMode } from "../types.js";
10
+ import type { StreamEvent, TokenUsage, TerminalReason, ApprovalRequest, ApprovalResult, PermissionMode, SessionForkLineage, SessionWorkspace } from "../types.js";
11
11
  export interface RpcRequest {
12
12
  jsonrpc: "2.0";
13
13
  id: string | number;
@@ -48,7 +48,7 @@ export declare const ErrorCodes: {
48
48
  readonly Cancelled: -32005;
49
49
  };
50
50
  export type InputAttachmentKind = "image" | "file" | "directory";
51
- export type InputAttachmentOrigin = "paste" | "os-drop" | "file-panel" | "picker" | "mention" | "generated" | "tool";
51
+ export type InputAttachmentOrigin = "paste" | "os-drop" | "file-panel" | "picker" | "mention" | "generated" | "mobile" | "tool";
52
52
  export interface InputAttachmentMeta {
53
53
  id: string;
54
54
  sessionId: string;
@@ -139,9 +139,30 @@ export interface RunResult {
139
139
  turnCount: number;
140
140
  usage: TokenUsage;
141
141
  }
142
+ export interface ForkSessionParams {
143
+ sourceSessionId: string;
144
+ targetSessionId?: string;
145
+ mode: "full";
146
+ /** Inclusive source event cursor; omitted freezes the current tail. */
147
+ throughEventId?: string;
148
+ /** Desktop host ownership generation; ignored by protocol-only hosts. */
149
+ quickChatClaimId?: string;
150
+ }
151
+ export interface ForkSessionResult {
152
+ sessionId: string;
153
+ mode: "full";
154
+ forkedFrom: SessionForkLineage;
155
+ workspace: SessionWorkspace;
156
+ copiedEventCount: number;
157
+ titleSuggestion?: string;
158
+ }
142
159
  /** Respond to an approval request from the server. */
143
160
  export interface ApproveParams {
144
161
  sessionId: string;
162
+ /** Connection owner echoed from ApprovalRequestNotification when present. */
163
+ connectionId?: string;
164
+ /** Session-owner generation echoed from ApprovalRequestNotification when present. */
165
+ generation?: number;
145
166
  requestId: string;
146
167
  decision: ApprovalResult;
147
168
  }
@@ -294,6 +315,10 @@ export interface AgentStreamEventNotification {
294
315
  export interface ApprovalRequestNotification {
295
316
  /** Originating engine session when known. */
296
317
  sessionId?: string;
318
+ /** Connection owner required by strict multi-connection hosts. */
319
+ connectionId?: string;
320
+ /** Monotonic owner generation required by strict multi-connection hosts. */
321
+ generation?: number;
297
322
  requestId: string;
298
323
  request: ApprovalRequest;
299
324
  }
@@ -308,8 +333,14 @@ export interface StatusNotification {
308
333
  status: "ready" | "running" | "error" | "shutdown";
309
334
  message?: string;
310
335
  }
336
+ /** Server accepted and queued an agent/run request after synchronous validation. */
337
+ export interface RunAcceptedNotification {
338
+ requestId: string | number;
339
+ sessionId: string;
340
+ }
311
341
  export declare const Methods: {
312
342
  readonly Run: "agent/run";
343
+ readonly ForkSession: "agent/forkSession";
313
344
  readonly Approve: "agent/approve";
314
345
  readonly Cancel: "agent/cancel";
315
346
  readonly Configure: "agent/configure";
@@ -336,6 +367,7 @@ export declare const Methods: {
336
367
  * for the desktop background panel. Output/kill of a shell still goes through
337
368
  * BackgroundShells by shellId; this is list-only across all three kinds. */
338
369
  readonly BackgroundWork: "agent/backgroundWork";
370
+ readonly RunAccepted: "agent/runAccepted";
339
371
  readonly StreamEvent: "agent/streamEvent";
340
372
  readonly ApprovalRequest: "agent/approvalRequest";
341
373
  /** Server-initiated resolution of a pending approval/ask (e.g. a goal-mode
@@ -29,6 +29,7 @@ export const ErrorCodes = {
29
29
  export const Methods = {
30
30
  // Client → Server
31
31
  Run: "agent/run",
32
+ ForkSession: "agent/forkSession",
32
33
  Approve: "agent/approve",
33
34
  Cancel: "agent/cancel",
34
35
  Configure: "agent/configure",
@@ -56,6 +57,7 @@ export const Methods = {
56
57
  * BackgroundShells by shellId; this is list-only across all three kinds. */
57
58
  BackgroundWork: "agent/backgroundWork",
58
59
  // Server → Client (notifications, no id)
60
+ RunAccepted: "agent/runAccepted",
59
61
  StreamEvent: "agent/streamEvent",
60
62
  ApprovalRequest: "agent/approvalRequest",
61
63
  /** Server-initiated resolution of a pending approval/ask (e.g. a goal-mode
@@ -21,6 +21,7 @@
21
21
  import type { LLMClientBase } from "../llm/client-base.js";
22
22
  import type { ToolRegistry } from "../tool-system/registry.js";
23
23
  import type { ToolContext } from "../tool-system/context.js";
24
+ import type { TokenUsage } from "../types.js";
24
25
  export interface DreamConsolidationInput {
25
26
  /** LLM client for the consolidation calls. */
26
27
  llmClient: LLMClientBase;
@@ -36,6 +37,8 @@ export interface DreamConsolidationInput {
36
37
  projectDir?: string;
37
38
  /** For log attribution. */
38
39
  sessionId?: string;
40
+ /** Owning Engine session/Goal recorder; process billing already happened. */
41
+ onUsage?: (usage: TokenUsage) => void;
39
42
  }
40
43
  export interface DreamConsolidationResult {
41
44
  /** True if the loop ran (with or without writes); false if it bailed early. */
@@ -71,9 +71,12 @@ export async function runDreamConsolidation(input) {
71
71
  messages,
72
72
  tools: toolDefs,
73
73
  maxTokens: 2048,
74
- recordUsage: false,
74
+ billingEnabled: true,
75
+ requestVisible: false,
75
76
  reasoning: { mode: "off" },
76
77
  });
78
+ if (resp.usage)
79
+ input.onUsage?.(resp.usage);
77
80
  if (resp.toolCalls.length === 0) {
78
81
  logger.info("memory.dream_finished", {
79
82
  sessionId,
@@ -2,7 +2,7 @@
2
2
  * Services index — re-exports all service modules.
3
3
  */
4
4
  export { analytics, trackEvent } from "./analytics.js";
5
- export { authorize, refreshToken, type OAuthConfig, type OAuthTokens } from "./oauth.js";
5
+ export { authorize, refreshToken, generatePKCE, createHardenedOAuthFetch, type OAuthConfig, type OAuthTokens, type OAuthAuthorizeOptions, type OAuthRefreshOptions, type HardenedOAuthFetchOptions, } from "./oauth.js";
6
6
  export { notify, notifyComplete, notifyError } from "./notifier.js";
7
7
  export { diagnostics } from "./diagnostics.js";
8
8
  export { buildExtractionPrompt, parseExtractionResponse, type ExtractedMemory, } from "./extract-memories.js";
@@ -8,7 +8,7 @@ export { analytics, trackEvent } from "./analytics.js";
8
8
  // `microCompact` that truncated long *messages* by char count, unrelated
9
9
  // to the tool_result-based microcompact in ContextManager). Removed to end
10
10
  // the "which microcompact?" ambiguity — the source of truth is ContextManager.
11
- export { authorize, refreshToken } from "./oauth.js";
11
+ export { authorize, refreshToken, generatePKCE, createHardenedOAuthFetch, } from "./oauth.js";
12
12
  export { notify, notifyComplete, notifyError } from "./notifier.js";
13
13
  export { diagnostics } from "./diagnostics.js";
14
14
  export { buildExtractionPrompt, parseExtractionResponse, } from "./extract-memories.js";
@@ -1,27 +1,51 @@
1
- /**
2
- * OAuth service — OAuth 2.0 authorization code flow with PKCE.
3
- *
4
- * Supports browser-based login flow for API authentication.
5
- */
1
+ /** OAuth 2.0 authorization-code + PKCE primitives for host applications. */
6
2
  export interface OAuthConfig {
7
3
  clientId: string;
4
+ clientSecret?: string;
8
5
  authorizationEndpoint: string;
9
6
  tokenEndpoint: string;
10
7
  redirectUri?: string;
11
8
  scopes?: string[];
9
+ resource?: string;
12
10
  }
13
11
  export interface OAuthTokens {
14
12
  accessToken: string;
15
13
  refreshToken?: string;
16
14
  expiresAt?: number;
17
15
  tokenType: string;
16
+ scope?: string;
17
+ }
18
+ export interface OAuthAuthorizeOptions {
19
+ signal?: AbortSignal;
20
+ timeoutMs?: number;
21
+ openExternal?: (url: string) => Promise<void> | void;
22
+ fetch?: typeof fetch;
23
+ callbackHost?: "127.0.0.1";
24
+ /** Zero asks the OS for an ephemeral port. */
25
+ callbackPort?: number;
26
+ }
27
+ export interface OAuthRefreshOptions {
28
+ fetch?: typeof fetch;
29
+ now?: number;
30
+ }
31
+ export interface HardenedOAuthFetchOptions {
32
+ /** Maximum redirects followed after the initial request. */
33
+ maxRedirects?: number;
18
34
  }
19
35
  /**
20
- * Run the OAuth authorization code flow.
21
- * Opens a browser for user login and waits for the callback.
36
+ * Wrap fetch with OAuth-specific redirect handling. Every hop is validated;
37
+ * POST/Authorization requests never cross origins, so authorization codes,
38
+ * verifiers, refresh tokens, client secrets, and revocation tokens cannot be
39
+ * forwarded by an upstream 30x response.
22
40
  */
23
- export declare function authorize(config: OAuthConfig): Promise<OAuthTokens>;
41
+ export declare function createHardenedOAuthFetch(baseFetch?: typeof fetch, options?: HardenedOAuthFetchOptions): typeof fetch;
42
+ export declare function generatePKCE(): {
43
+ verifier: string;
44
+ challenge: string;
45
+ };
24
46
  /**
25
- * Refresh an access token using a refresh token.
47
+ * Run an OAuth authorization-code flow. The host owns browser launching and
48
+ * injects `openExternal`; core only listens on an ephemeral loopback port.
26
49
  */
27
- export declare function refreshToken(config: OAuthConfig, refreshTokenValue: string): Promise<OAuthTokens>;
50
+ export declare function authorize(config: OAuthConfig, options?: OAuthAuthorizeOptions): Promise<OAuthTokens>;
51
+ export declare function refreshToken(config: OAuthConfig, refreshTokenValue: string, options?: OAuthRefreshOptions): Promise<OAuthTokens>;
@@ -1,81 +1,209 @@
1
- /**
2
- * OAuth service OAuth 2.0 authorization code flow with PKCE.
3
- *
4
- * Supports browser-based login flow for API authentication.
5
- */
1
+ /** OAuth 2.0 authorization-code + PKCE primitives for host applications. */
2
+ import { createHash, randomBytes } from "node:crypto";
6
3
  import { createServer } from "node:http";
7
- import { randomBytes, createHash } from "node:crypto";
8
- import { execFile } from "node:child_process";
9
- import { browserOpenCommand } from "./browser-open.js";
4
+ import { mergeOAuthTokenResponse } from "../credentials/oauth.js";
5
+ const OAUTH_REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
6
+ function normalizedHostname(hostname) {
7
+ return hostname
8
+ .replace(/^\[|\]$/g, "")
9
+ .replace(/\.$/, "")
10
+ .toLowerCase();
11
+ }
12
+ function ipv4Octets(hostname) {
13
+ const parts = normalizedHostname(hostname).split(".");
14
+ if (parts.length !== 4 || parts.some((part) => !/^\d{1,3}$/.test(part)))
15
+ return undefined;
16
+ const octets = parts.map(Number);
17
+ return octets.every((part) => part >= 0 && part <= 255) ? octets : undefined;
18
+ }
19
+ function isLoopbackHostname(hostname) {
20
+ const host = normalizedHostname(hostname);
21
+ const octets = ipv4Octets(host);
22
+ return host === "localhost" || host === "::1" || Boolean(octets && octets[0] === 127);
23
+ }
24
+ function isPrivateNetworkHostname(hostname) {
25
+ const host = normalizedHostname(hostname);
26
+ const octets = ipv4Octets(host);
27
+ if (octets) {
28
+ const [a, b] = octets;
29
+ return (a === 0 ||
30
+ a === 10 ||
31
+ (a === 100 && b >= 64 && b <= 127) ||
32
+ (a === 169 && b === 254) ||
33
+ (a === 172 && b >= 16 && b <= 31) ||
34
+ (a === 192 && b === 168) ||
35
+ (a === 198 && (b === 18 || b === 19)));
36
+ }
37
+ if (!host.includes(":"))
38
+ return false;
39
+ return host === "::" || host.startsWith("fc") || host.startsWith("fd") || /^fe[89ab]/.test(host);
40
+ }
41
+ function validateOAuthRequestUrl(url) {
42
+ if (url.username || url.password)
43
+ throw new Error("OAuth endpoint must not include credentials");
44
+ const loopback = isLoopbackHostname(url.hostname);
45
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
46
+ throw new Error("OAuth endpoint must use HTTPS (HTTP is allowed only for localhost)");
47
+ }
48
+ if (!loopback && isPrivateNetworkHostname(url.hostname)) {
49
+ throw new Error("OAuth endpoint must not target a private network address");
50
+ }
51
+ }
10
52
  /**
11
- * Generate PKCE code verifier and challenge.
53
+ * Wrap fetch with OAuth-specific redirect handling. Every hop is validated;
54
+ * POST/Authorization requests never cross origins, so authorization codes,
55
+ * verifiers, refresh tokens, client secrets, and revocation tokens cannot be
56
+ * forwarded by an upstream 30x response.
12
57
  */
13
- function generatePKCE() {
58
+ export function createHardenedOAuthFetch(baseFetch = fetch, options = {}) {
59
+ const maxRedirects = Math.max(0, Math.min(10, options.maxRedirects ?? 5));
60
+ return (async (input, init) => {
61
+ const initial = new Request(input, init);
62
+ let currentUrl = new URL(initial.url);
63
+ const initialLoopback = isLoopbackHostname(currentUrl.hostname);
64
+ let method = initial.method.toUpperCase();
65
+ let headers = new Headers(initial.headers);
66
+ let body = method === "GET" || method === "HEAD"
67
+ ? undefined
68
+ : new Uint8Array(await initial.clone().arrayBuffer());
69
+ const secretBearing = (method !== "GET" && method !== "HEAD") || headers.has("authorization");
70
+ for (let redirectCount = 0;; redirectCount++) {
71
+ validateOAuthRequestUrl(currentUrl);
72
+ const request = new Request(currentUrl, {
73
+ method,
74
+ headers,
75
+ body: body ? body.slice() : undefined,
76
+ redirect: "manual",
77
+ signal: initial.signal,
78
+ });
79
+ const response = await baseFetch(request);
80
+ if (!OAUTH_REDIRECT_STATUSES.has(response.status))
81
+ return response;
82
+ const location = response.headers.get("location");
83
+ if (!location)
84
+ return response;
85
+ if (redirectCount >= maxRedirects)
86
+ throw new Error("OAuth redirect limit exceeded");
87
+ const nextUrl = new URL(location, currentUrl);
88
+ validateOAuthRequestUrl(nextUrl);
89
+ if (!initialLoopback && isLoopbackHostname(nextUrl.hostname)) {
90
+ throw new Error("OAuth redirect to a local network target is not allowed");
91
+ }
92
+ if (secretBearing && nextUrl.origin !== currentUrl.origin) {
93
+ throw new Error("OAuth secret-bearing request refused a cross-origin redirect");
94
+ }
95
+ if (response.status === 303 ||
96
+ ((response.status === 301 || response.status === 302) && method === "POST")) {
97
+ method = "GET";
98
+ body = undefined;
99
+ headers = new Headers(headers);
100
+ headers.delete("content-type");
101
+ headers.delete("content-length");
102
+ }
103
+ currentUrl = nextUrl;
104
+ }
105
+ });
106
+ }
107
+ export function generatePKCE() {
14
108
  const verifier = randomBytes(32).toString("base64url");
15
109
  const challenge = createHash("sha256").update(verifier).digest("base64url");
16
110
  return { verifier, challenge };
17
111
  }
18
- /**
19
- * Open a URL in the system browser.
20
- */
21
- function openBrowser(url) {
22
- // execFile with an argv array — no shell, so the URL can't be interpreted.
23
- const { cmd, args } = browserOpenCommand(process.platform, url);
24
- execFile(cmd, args, () => { });
112
+ function tokensFromSecret(secret) {
113
+ return {
114
+ accessToken: secret.accessToken,
115
+ refreshToken: secret.refreshToken,
116
+ expiresAt: secret.expiresAt ? Date.parse(secret.expiresAt) : undefined,
117
+ tokenType: secret.tokenType ?? "Bearer",
118
+ scope: secret.scope,
119
+ };
120
+ }
121
+ function callbackSettings(config, options) {
122
+ const host = options.callbackHost ?? "127.0.0.1";
123
+ if (!config.redirectUri) {
124
+ return { host, port: options.callbackPort ?? 0, path: "/callback" };
125
+ }
126
+ const redirect = new URL(config.redirectUri);
127
+ if (redirect.protocol !== "http:" ||
128
+ (redirect.hostname !== "127.0.0.1" && redirect.hostname !== "localhost")) {
129
+ throw new Error("OAuth redirectUri must be an HTTP loopback URL");
130
+ }
131
+ const port = options.callbackPort ?? Number(redirect.port || "80");
132
+ return {
133
+ host,
134
+ port,
135
+ path: redirect.pathname || "/callback",
136
+ fixedRedirect: options.callbackPort === undefined ? redirect.toString() : undefined,
137
+ };
25
138
  }
26
139
  /**
27
- * Run the OAuth authorization code flow.
28
- * Opens a browser for user login and waits for the callback.
140
+ * Run an OAuth authorization-code flow. The host owns browser launching and
141
+ * injects `openExternal`; core only listens on an ephemeral loopback port.
29
142
  */
30
- export async function authorize(config) {
143
+ export async function authorize(config, options = {}) {
144
+ if (options.signal?.aborted)
145
+ throw new Error("OAuth authorization aborted");
146
+ const callback = callbackSettings(config, options);
31
147
  const { verifier, challenge } = generatePKCE();
32
148
  const state = randomBytes(16).toString("hex");
33
- const port = 18910 + Math.floor(Math.random() * 100);
34
- const redirectUri = config.redirectUri ?? `http://localhost:${port}/callback`;
35
- // Build authorization URL
36
- const params = new URLSearchParams({
37
- response_type: "code",
38
- client_id: config.clientId,
39
- redirect_uri: redirectUri,
40
- state,
41
- code_challenge: challenge,
42
- code_challenge_method: "S256",
43
- });
44
- if (config.scopes?.length) {
45
- params.set("scope", config.scopes.join(" "));
46
- }
47
- const authUrl = `${config.authorizationEndpoint}?${params.toString()}`;
149
+ const fetchFn = createHardenedOAuthFetch(options.fetch ?? fetch);
150
+ const openExternal = options.openExternal;
151
+ if (!openExternal)
152
+ throw new Error("OAuth authorize requires an openExternal host callback");
48
153
  return new Promise((resolve, reject) => {
49
- const timeout = setTimeout(() => {
50
- server.close();
51
- reject(new Error("OAuth authorization timed out (120s)"));
52
- }, 120_000);
154
+ let settled = false;
155
+ let timer;
156
+ let redirectUri = "";
157
+ const cleanup = () => {
158
+ if (timer)
159
+ clearTimeout(timer);
160
+ options.signal?.removeEventListener("abort", onAbort);
161
+ if (server.listening)
162
+ server.close();
163
+ };
164
+ const fail = (err) => {
165
+ if (settled)
166
+ return;
167
+ settled = true;
168
+ cleanup();
169
+ reject(err instanceof Error ? err : new Error(String(err)));
170
+ };
171
+ const succeed = (tokens) => {
172
+ if (settled)
173
+ return;
174
+ settled = true;
175
+ cleanup();
176
+ resolve(tokens);
177
+ };
178
+ const onAbort = () => fail(new Error("OAuth authorization aborted"));
53
179
  const server = createServer(async (req, res) => {
54
- if (!req.url?.startsWith("/callback")) {
180
+ const requestUrl = new URL(req.url ?? "/", redirectUri || "http://127.0.0.1");
181
+ if (requestUrl.pathname !== callback.path) {
55
182
  res.writeHead(404);
56
183
  res.end("Not found");
57
184
  return;
58
185
  }
59
- const url = new URL(req.url, `http://localhost:${port}`);
60
- const code = url.searchParams.get("code");
61
- const returnedState = url.searchParams.get("state");
62
- const error = url.searchParams.get("error");
63
- clearTimeout(timeout);
64
- if (error) {
65
- res.writeHead(200, { "Content-Type": "text/html" });
66
- res.end("<html><body><h1>Authorization failed</h1><p>You can close this window.</p></body></html>");
67
- server.close();
68
- reject(new Error(`OAuth error: ${error}`));
186
+ const returnedState = requestUrl.searchParams.get("state");
187
+ const code = requestUrl.searchParams.get("code");
188
+ const oauthError = requestUrl.searchParams.get("error");
189
+ if (returnedState !== state) {
190
+ res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
191
+ res.end("<h1>Invalid OAuth callback</h1><p>You can close this window.</p>");
192
+ fail(new Error("Invalid OAuth callback: state mismatch"));
69
193
  return;
70
194
  }
71
- if (!code || returnedState !== state) {
72
- res.writeHead(400, { "Content-Type": "text/html" });
73
- res.end("<html><body><h1>Invalid callback</h1></body></html>");
74
- server.close();
75
- reject(new Error("Invalid OAuth callback: missing code or state mismatch"));
195
+ if (oauthError) {
196
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
197
+ res.end("<h1>Authorization was not completed</h1><p>You can close this window.</p>");
198
+ fail(new Error(oauthError === "access_denied" ? "OAuth access denied" : "OAuth failed"));
199
+ return;
200
+ }
201
+ if (!code) {
202
+ res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
203
+ res.end("<h1>Invalid OAuth callback</h1><p>You can close this window.</p>");
204
+ fail(new Error("Invalid OAuth callback: missing code"));
76
205
  return;
77
206
  }
78
- // Exchange code for tokens
79
207
  try {
80
208
  const tokenParams = new URLSearchParams({
81
209
  grant_type: "authorization_code",
@@ -84,68 +212,75 @@ export async function authorize(config) {
84
212
  client_id: config.clientId,
85
213
  code_verifier: verifier,
86
214
  });
87
- const tokenRes = await fetch(config.tokenEndpoint, {
215
+ if (config.clientSecret)
216
+ tokenParams.set("client_secret", config.clientSecret);
217
+ if (config.resource)
218
+ tokenParams.set("resource", config.resource);
219
+ const tokenRes = await fetchFn(config.tokenEndpoint, {
88
220
  method: "POST",
89
221
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
90
222
  body: tokenParams.toString(),
223
+ signal: options.signal,
91
224
  });
92
225
  if (!tokenRes.ok) {
93
- throw new Error(`Token exchange failed: ${tokenRes.status} ${await tokenRes.text()}`);
226
+ throw new Error(`OAuth token exchange failed (${tokenRes.status})`);
94
227
  }
95
- const data = (await tokenRes.json());
96
- const tokens = {
97
- accessToken: data.access_token,
98
- refreshToken: data.refresh_token,
99
- expiresAt: data.expires_in
100
- ? Date.now() + data.expires_in * 1000
101
- : undefined,
102
- tokenType: data.token_type ?? "Bearer",
103
- };
104
- res.writeHead(200, { "Content-Type": "text/html" });
105
- res.end("<html><body><h1>Authorization successful!</h1><p>You can close this window.</p></body></html>");
106
- server.close();
107
- resolve(tokens);
228
+ const secret = mergeOAuthTokenResponse(undefined, (await tokenRes.json()));
229
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
230
+ res.end("<h1>Authorization successful</h1><p>You can close this window.</p>");
231
+ succeed(tokensFromSecret(secret));
108
232
  }
109
233
  catch (err) {
110
- res.writeHead(500, { "Content-Type": "text/html" });
111
- res.end("<html><body><h1>Token exchange failed</h1></body></html>");
112
- server.close();
113
- reject(err);
234
+ res.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
235
+ res.end("<h1>Token exchange failed</h1><p>You can close this window.</p>");
236
+ fail(err);
114
237
  }
115
238
  });
116
- server.listen(port, () => {
117
- openBrowser(authUrl);
118
- });
119
- server.on("error", (err) => {
120
- clearTimeout(timeout);
121
- reject(new Error(`OAuth server error: ${err.message}`));
239
+ options.signal?.addEventListener("abort", onAbort, { once: true });
240
+ server.once("error", (err) => fail(new Error(`OAuth callback server failed: ${err.message}`)));
241
+ server.listen(callback.port, callback.host, () => {
242
+ const address = server.address();
243
+ if (!address || typeof address === "string") {
244
+ fail(new Error("OAuth callback server did not expose a loopback port"));
245
+ return;
246
+ }
247
+ redirectUri =
248
+ callback.fixedRedirect ?? `http://${callback.host}:${address.port}${callback.path}`;
249
+ const authUrl = new URL(config.authorizationEndpoint);
250
+ authUrl.searchParams.set("response_type", "code");
251
+ authUrl.searchParams.set("client_id", config.clientId);
252
+ authUrl.searchParams.set("redirect_uri", redirectUri);
253
+ authUrl.searchParams.set("state", state);
254
+ authUrl.searchParams.set("code_challenge", challenge);
255
+ authUrl.searchParams.set("code_challenge_method", "S256");
256
+ if (config.scopes?.length)
257
+ authUrl.searchParams.set("scope", config.scopes.join(" "));
258
+ if (config.resource)
259
+ authUrl.searchParams.set("resource", config.resource);
260
+ timer = setTimeout(() => fail(new Error("OAuth authorization timed out")), options.timeoutMs ?? 120_000);
261
+ Promise.resolve(openExternal(authUrl.toString())).catch(fail);
122
262
  });
123
263
  });
124
264
  }
125
- /**
126
- * Refresh an access token using a refresh token.
127
- */
128
- export async function refreshToken(config, refreshTokenValue) {
265
+ export async function refreshToken(config, refreshTokenValue, options = {}) {
129
266
  const params = new URLSearchParams({
130
267
  grant_type: "refresh_token",
131
268
  client_id: config.clientId,
132
269
  refresh_token: refreshTokenValue,
133
270
  });
134
- const res = await fetch(config.tokenEndpoint, {
271
+ if (config.clientSecret)
272
+ params.set("client_secret", config.clientSecret);
273
+ if (config.scopes?.length)
274
+ params.set("scope", config.scopes.join(" "));
275
+ if (config.resource)
276
+ params.set("resource", config.resource);
277
+ const res = await createHardenedOAuthFetch(options.fetch ?? fetch)(config.tokenEndpoint, {
135
278
  method: "POST",
136
279
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
137
280
  body: params.toString(),
138
281
  });
139
- if (!res.ok) {
140
- throw new Error(`Token refresh failed: ${res.status}`);
141
- }
142
- const data = (await res.json());
143
- return {
144
- accessToken: data.access_token,
145
- refreshToken: data.refresh_token ?? refreshTokenValue,
146
- expiresAt: data.expires_in
147
- ? Date.now() + data.expires_in * 1000
148
- : undefined,
149
- tokenType: data.token_type ?? "Bearer",
150
- };
282
+ if (!res.ok)
283
+ throw new Error(`OAuth token refresh failed (${res.status})`);
284
+ const secret = mergeOAuthTokenResponse({ accessToken: "replaced", refreshToken: refreshTokenValue }, (await res.json()), { now: options.now });
285
+ return tokensFromSecret(secret);
151
286
  }