@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.
- package/dist/arena/arena.d.ts +2 -0
- package/dist/arena/arena.js +30 -1
- package/dist/arena/phases/adjudication.d.ts +2 -1
- package/dist/arena/phases/adjudication.js +2 -1
- package/dist/arena/phases/build-consensus.d.ts +2 -1
- package/dist/arena/phases/build-consensus.js +3 -1
- package/dist/arena/phases/cross-review.d.ts +3 -1
- package/dist/arena/phases/cross-review.js +8 -2
- package/dist/arena/phases/debate-rounds.d.ts +2 -1
- package/dist/arena/phases/debate-rounds.js +4 -2
- package/dist/arena/phases/participant-research.d.ts +2 -1
- package/dist/arena/phases/participant-research.js +3 -1
- package/dist/arena/phases/planning-detail-expansion.d.ts +2 -1
- package/dist/arena/phases/planning-detail-expansion.js +2 -1
- package/dist/arena/planner.d.ts +2 -1
- package/dist/arena/planner.js +2 -1
- package/dist/arena/types.d.ts +5 -1
- package/dist/cli/agent-server-tcp.js +13 -2
- package/dist/context/manager.d.ts +3 -3
- package/dist/context/manager.js +6 -6
- package/dist/context/token-counter.js +5 -3
- package/dist/context/tool-result-storage.d.ts +6 -0
- package/dist/context/tool-result-storage.js +25 -4
- package/dist/credentials/access.d.ts +10 -1
- package/dist/credentials/access.js +76 -2
- package/dist/credentials/index.d.ts +3 -2
- package/dist/credentials/index.js +2 -1
- package/dist/credentials/oauth.d.ts +6 -1
- package/dist/credentials/oauth.js +66 -1
- package/dist/credentials/store.d.ts +1 -1
- package/dist/credentials/store.js +16 -7
- package/dist/credentials/types.d.ts +38 -1
- package/dist/credentials/types.js +16 -1
- package/dist/engine/engine.d.ts +61 -32
- package/dist/engine/engine.js +289 -55
- package/dist/engine/goal.d.ts +5 -3
- package/dist/engine/session-title.d.ts +2 -1
- package/dist/engine/session-title.js +4 -1
- package/dist/engine/turn-loop.d.ts +18 -0
- package/dist/engine/turn-loop.js +202 -27
- package/dist/engine/types.d.ts +6 -2
- package/dist/git/worktree/crud.d.ts +3 -0
- package/dist/git/worktree/crud.js +32 -3
- package/dist/git/worktree/git-exec.d.ts +2 -2
- package/dist/git/worktree/git-exec.js +47 -11
- package/dist/git/worktree/query.d.ts +8 -7
- package/dist/git/worktree/query.js +27 -20
- package/dist/hooks/events.d.ts +3 -0
- package/dist/hooks/events.js +0 -3
- package/dist/hooks/goal-stop-hook.d.ts +14 -4
- package/dist/hooks/goal-stop-hook.js +626 -71
- package/dist/hooks/registry.js +3 -0
- package/dist/hooks/shell-runner.d.ts +12 -1
- package/dist/hooks/shell-runner.js +160 -9
- package/dist/index.d.ts +7 -6
- package/dist/index.js +6 -5
- package/dist/llm/client-base.js +12 -10
- package/dist/llm/types.d.ts +12 -5
- package/dist/plugins/pluginCommandHook.d.ts +4 -4
- package/dist/plugins/pluginCommandHook.js +111 -13
- package/dist/protocol/chat-session-manager.d.ts +13 -2
- package/dist/protocol/chat-session-manager.js +90 -18
- package/dist/protocol/chat-session.d.ts +12 -0
- package/dist/protocol/chat-session.js +30 -5
- package/dist/protocol/client.d.ts +5 -2
- package/dist/protocol/client.js +22 -1
- package/dist/protocol/server.d.ts +25 -11
- package/dist/protocol/server.js +290 -72
- package/dist/protocol/types.d.ts +34 -2
- package/dist/protocol/types.js +2 -0
- package/dist/services/dream-consolidation.d.ts +3 -0
- package/dist/services/dream-consolidation.js +4 -1
- package/dist/services/index.d.ts +1 -1
- package/dist/services/index.js +1 -1
- package/dist/services/oauth.d.ts +34 -10
- package/dist/services/oauth.js +233 -98
- package/dist/session/session-manager.d.ts +35 -6
- package/dist/session/session-manager.js +362 -26
- package/dist/session/transcript.d.ts +30 -1
- package/dist/session/transcript.js +119 -4
- package/dist/tool-system/builtin/agent.js +5 -1
- package/dist/tool-system/builtin/arena.js +1 -0
- package/dist/tool-system/builtin/background-jobs.d.ts +8 -5
- package/dist/tool-system/builtin/background-jobs.js +15 -11
- package/dist/tool-system/builtin/background-work.d.ts +6 -1
- package/dist/tool-system/builtin/background-work.js +5 -1
- package/dist/tool-system/builtin/bash.d.ts +3 -5
- package/dist/tool-system/builtin/bash.js +10 -5
- package/dist/tool-system/builtin/browser-tools.d.ts +2 -2
- package/dist/tool-system/builtin/edit.d.ts +2 -1
- package/dist/tool-system/builtin/edit.js +12 -4
- package/dist/tool-system/builtin/generate-video.d.ts +4 -0
- package/dist/tool-system/builtin/generate-video.js +138 -21
- package/dist/tool-system/builtin/glob.d.ts +2 -1
- package/dist/tool-system/builtin/glob.js +28 -3
- package/dist/tool-system/builtin/grep.d.ts +1 -0
- package/dist/tool-system/builtin/grep.js +82 -17
- package/dist/tool-system/builtin/index.d.ts +25 -11
- package/dist/tool-system/builtin/index.js +45 -2
- package/dist/tool-system/builtin/video-providers.d.ts +12 -15
- package/dist/tool-system/builtin/video-providers.js +1 -0
- package/dist/tool-system/builtin/view-image.d.ts +2 -2
- package/dist/tool-system/builtin/web-fetch.js +44 -3
- package/dist/tool-system/builtin/worktree.js +25 -7
- package/dist/tool-system/builtin/write.d.ts +2 -1
- package/dist/tool-system/builtin/write.js +14 -4
- package/dist/tool-system/context.d.ts +17 -5
- package/dist/tool-system/executor.js +24 -8
- package/dist/tool-system/mcp-manager.d.ts +6 -0
- package/dist/tool-system/mcp-manager.js +55 -5
- package/dist/tool-system/path-policy.d.ts +19 -0
- package/dist/tool-system/path-policy.js +62 -1
- package/dist/tool-system/permission.d.ts +43 -3
- package/dist/tool-system/permission.js +383 -30
- package/dist/tool-system/registry.d.ts +3 -2
- package/dist/tool-system/registry.js +52 -34
- package/dist/types.d.ts +15 -0
- package/package.json +1 -1
package/dist/protocol/types.d.ts
CHANGED
|
@@ -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
|
package/dist/protocol/types.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/dist/services/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/services/index.js
CHANGED
|
@@ -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";
|
package/dist/services/oauth.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
21
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
|
50
|
+
export declare function authorize(config: OAuthConfig, options?: OAuthAuthorizeOptions): Promise<OAuthTokens>;
|
|
51
|
+
export declare function refreshToken(config: OAuthConfig, refreshTokenValue: string, options?: OAuthRefreshOptions): Promise<OAuthTokens>;
|
package/dist/services/oauth.js
CHANGED
|
@@ -1,81 +1,209 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
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 {
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
28
|
-
*
|
|
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
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
|
60
|
-
const code =
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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 (
|
|
72
|
-
res.writeHead(
|
|
73
|
-
res.end("<
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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(`
|
|
226
|
+
throw new Error(`OAuth token exchange failed (${tokenRes.status})`);
|
|
94
227
|
}
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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("<
|
|
112
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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(`
|
|
141
|
-
}
|
|
142
|
-
|
|
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
|
}
|