@buyong/pi-devin 0.1.4 → 0.1.6

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 (116) hide show
  1. package/README.ko.md +222 -0
  2. package/README.md +37 -41
  3. package/index.ts +6 -12
  4. package/package.json +4 -1
  5. package/src/core/auth/access.ts +7 -3
  6. package/src/core/auth/callback-server.ts +57 -0
  7. package/src/core/auth/index.ts +2 -1
  8. package/src/core/auth/login.ts +104 -151
  9. package/src/core/auth/pkce.ts +32 -0
  10. package/src/core/auth/rest-exchange.ts +32 -30
  11. package/src/core/cancellation.ts +99 -0
  12. package/src/core/client.ts +127 -122
  13. package/src/core/errors.ts +115 -0
  14. package/src/core/index.ts +18 -4
  15. package/src/core/inference/accumulator.ts +174 -0
  16. package/src/core/inference/images.ts +22 -15
  17. package/src/core/inference/request.ts +59 -0
  18. package/src/core/inference/retry.ts +31 -31
  19. package/src/core/inference/state.ts +7 -3
  20. package/src/core/inference/stream-chat.ts +48 -0
  21. package/src/core/models/access.ts +4 -3
  22. package/src/core/models/catalog.ts +29 -20
  23. package/src/core/models/select.ts +14 -12
  24. package/src/core/models/variants.ts +85 -72
  25. package/src/core/protocol/connect-client.ts +288 -0
  26. package/src/core/protocol/device.ts +33 -34
  27. package/src/core/protocol/endpoint.ts +22 -0
  28. package/src/core/protocol/fingerprint.ts +38 -26
  29. package/src/core/protocol/frames.ts +101 -0
  30. package/src/core/protocol/http/fetch-client.ts +42 -0
  31. package/src/core/protocol/http/node-client.ts +130 -0
  32. package/src/core/protocol/http/response.ts +99 -0
  33. package/src/core/protocol/http.ts +41 -101
  34. package/src/core/protocol/protobuf.ts +93 -74
  35. package/src/core/redaction.ts +33 -0
  36. package/src/core/services/device-identity.ts +15 -0
  37. package/src/core/services/http-transport.ts +28 -0
  38. package/src/core/services/platform.ts +54 -0
  39. package/src/core/types.ts +2 -23
  40. package/src/core/validation.ts +25 -0
  41. package/src/pi/adapters/pi/devin-command.ts +82 -0
  42. package/src/pi/adapters/pi/fusion-command.ts +92 -0
  43. package/src/pi/adapters/pi/oauth.ts +14 -0
  44. package/src/pi/adapters/pi/provider.ts +22 -0
  45. package/src/pi/adapters/pi/registration.ts +89 -0
  46. package/src/pi/adapters/pi/response-stream.ts +164 -0
  47. package/src/pi/adapters/pi/sidekick-tool.ts +45 -0
  48. package/src/pi/adapters/pi/transcript.ts +177 -0
  49. package/src/pi/adapters/pi/types.ts +14 -0
  50. package/src/pi/{devin-panel.ts → adapters/pi/ui/devin-panel.ts} +11 -9
  51. package/src/pi/{fusion-picker.ts → adapters/pi/ui/fusion-picker.ts} +1 -1
  52. package/src/pi/adapters/pi/usage-status.ts +65 -0
  53. package/src/pi/adapters/storage/files.ts +66 -0
  54. package/src/pi/adapters/storage/json-file.ts +31 -0
  55. package/src/pi/app/config.ts +25 -0
  56. package/src/pi/app/environment.ts +30 -0
  57. package/src/pi/app/layer.ts +43 -0
  58. package/src/pi/app/runtime.ts +20 -0
  59. package/src/pi/app/session.ts +26 -0
  60. package/src/pi/features/accounts/service.ts +69 -0
  61. package/src/pi/features/accounts/types.ts +25 -0
  62. package/src/pi/features/auth/credential.ts +40 -0
  63. package/src/pi/features/auth/guard.ts +25 -0
  64. package/src/pi/features/auth/login.ts +116 -0
  65. package/src/pi/features/auth/state.ts +48 -0
  66. package/src/pi/features/auth/types.ts +36 -0
  67. package/src/pi/features/bounded.ts +4 -0
  68. package/src/pi/features/catalog/constants.ts +10 -0
  69. package/src/pi/features/catalog/fusion.ts +45 -0
  70. package/src/pi/features/catalog/ids.ts +79 -0
  71. package/src/pi/features/catalog/projection.ts +93 -0
  72. package/src/pi/features/catalog/remote.ts +15 -0
  73. package/src/pi/features/catalog/restore.ts +53 -0
  74. package/src/pi/features/catalog/service.ts +158 -0
  75. package/src/pi/features/catalog/types.ts +56 -0
  76. package/src/pi/features/clients/service.ts +47 -0
  77. package/src/pi/features/clients/types.ts +22 -0
  78. package/src/pi/features/default-scheduler.ts +12 -0
  79. package/src/pi/features/inference/request.ts +190 -0
  80. package/src/pi/features/inference/service.ts +33 -0
  81. package/src/pi/features/inference/types.ts +11 -0
  82. package/src/pi/features/lifetime/service.ts +19 -0
  83. package/src/pi/features/lifetime/types.ts +13 -0
  84. package/src/pi/features/runs/admission.ts +36 -0
  85. package/src/pi/features/runs/lifecycle.ts +207 -0
  86. package/src/pi/features/runs/service.ts +10 -0
  87. package/src/pi/features/runs/state.ts +37 -0
  88. package/src/pi/features/runs/types.ts +43 -0
  89. package/src/pi/features/settings/service.ts +46 -0
  90. package/src/pi/features/settings/types.ts +36 -0
  91. package/src/pi/features/sidekick/child.ts +228 -0
  92. package/src/pi/features/sidekick/command.ts +80 -0
  93. package/src/pi/features/sidekick/launcher.ts +12 -0
  94. package/src/pi/features/sidekick/prompts.ts +17 -0
  95. package/src/pi/features/sidekick/report.ts +51 -0
  96. package/src/pi/features/sidekick/service.ts +176 -0
  97. package/src/pi/features/sidekick/types.ts +99 -0
  98. package/src/pi/features/usage/changes.ts +20 -0
  99. package/src/pi/{usage.ts → features/usage/report.ts} +3 -34
  100. package/src/pi/features/usage/types.ts +33 -0
  101. package/src/core/inference/chat.ts +0 -234
  102. package/src/core/protocol/transport.ts +0 -513
  103. package/src/pi/auth.ts +0 -145
  104. package/src/pi/devin-command.ts +0 -76
  105. package/src/pi/devin-settings.ts +0 -50
  106. package/src/pi/extension.ts +0 -80
  107. package/src/pi/fusion-command.ts +0 -83
  108. package/src/pi/fusion-settings.ts +0 -44
  109. package/src/pi/lifecycle.ts +0 -217
  110. package/src/pi/messages.ts +0 -131
  111. package/src/pi/models.ts +0 -379
  112. package/src/pi/provider.ts +0 -249
  113. package/src/pi/sidekick-report.ts +0 -47
  114. package/src/pi/sidekick-runtime.ts +0 -300
  115. package/src/pi/sidekick.ts +0 -211
  116. package/src/pi/stream.ts +0 -124
@@ -1,7 +1,15 @@
1
- import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
- import { createServer } from "node:http";
3
- import { awaitWithSignal, ConnectClient, endpoint, RPCError, safeError } from "../protocol/transport.ts";
4
- import { requiredString, type Credential } from "../types.ts";
1
+ import { randomUUID } from "node:crypto";
2
+ import { Deferred, Effect, Exit, Fiber, Predicate } from "effect";
3
+ import { startDeadline } from "../cancellation.ts";
4
+ import { DevinError, RequestTimeout, toDevinError, type DevinFailure } from "../errors.ts";
5
+ import { makeConnectClient } from "../protocol/connect-client.ts";
6
+ import { endpoint } from "../protocol/endpoint.ts";
7
+ import { redactFailure } from "../redaction.ts";
8
+ import type { HttpTransport } from "../services/http-transport.ts";
9
+ import type { Credential } from "../types.ts";
10
+ import { failWith, requiredString } from "../validation.ts";
11
+ import { startCallbackServer } from "./callback-server.ts";
12
+ import { codeForExchange, createPKCE, isAuthorizationCode, matchesState, PKCE_CODE_PREFIX } from "./pkce.ts";
5
13
  import { exchangeBrowserCode } from "./rest-exchange.ts";
6
14
 
7
15
  export const DEFAULT_API_SERVER_URL = "https://server.codeium.com";
@@ -9,169 +17,114 @@ export const DEFAULT_WEBAPP_URL = "https://app.devin.ai";
9
17
  export const DEFAULT_DEVIN_API_URL = "https://api.devin.ai";
10
18
  const EXCHANGE_METHOD = "exa.seat_management_pb.SeatManagementService/ExchangeDevinCLIPKCECode";
11
19
  const PKCE_EXCHANGE_METHOD = "exa.seat_management_pb.SeatManagementService/ExchangePKCEAuthorizationCode";
12
- const PKCE_CODE_PREFIX = "wspkce$";
13
- const codeForExchange = (code: string) => code.startsWith(PKCE_CODE_PREFIX) ? code.slice(PKCE_CODE_PREFIX.length) : code;
20
+ const LOGIN_TIMEOUT_MS = 300_000;
21
+ const CONNECT_EXCHANGE_TIMEOUT_MS = 30_000;
22
+
14
23
  export type LoginFlow = "browser" | "manual" | "legacy-connect";
15
24
  export interface LoginOptions {
16
25
  apiServerURL?: string;
17
26
  webappURL?: string;
18
27
  devinAPIURL?: string;
19
28
  timeoutMs?: number;
20
- signal?: AbortSignal;
21
- onURL: (url: string, signal: AbortSignal) => void | Promise<void>;
22
- /** Receives a one-time authorization code, never an API key or session token. */
23
- promptCode?: (signal: AbortSignal) => Promise<string>;
24
- fetch?: typeof fetch;
29
+ /** Shows or opens the authorization URL. */
30
+ onURL: (url: string) => Effect.Effect<void, DevinFailure>;
31
+ /** Reads a one-time authorization code, never an API key or session token. Interrupted as soon as the browser callback wins. */
32
+ promptCode?: Effect.Effect<string, DevinFailure>;
25
33
  encoding?: "proto" | "json";
26
34
  flow?: LoginFlow;
27
35
  }
28
36
 
29
- export function createPKCE(): { verifier: string; challenge: string; state: string } {
30
- const verifier = randomBytes(32).toString("base64url");
31
- return {
32
- verifier,
33
- challenge: createHash("sha256").update(verifier).digest("base64url"),
34
- state: randomBytes(32).toString("base64url"),
35
- };
36
- }
37
-
38
- function matchesState(received: string | null, expected: string): boolean {
39
- if (!received) return false;
40
- const left = Buffer.from(received), right = Buffer.from(expected);
41
- return left.length === right.length && timingSafeEqual(left, right);
37
+ /** The new account UI defaults to the REST browser flow. An explicit encoding keeps the legacy Connect path. Code exchanges go through `HttpTransport`. */
38
+ export function loginAccount(options: LoginOptions): Effect.Effect<Credential, DevinFailure, HttpTransport> {
39
+ return login({ ...options, flow: options.flow ?? (options.encoding === undefined ? "browser" : "legacy-connect") });
42
40
  }
43
41
 
44
- function isAuthorizationCode(code: string): boolean {
45
- return code.length > 0 && code.length <= 8192 && codeForExchange(code).length > 0
46
- && !/[\s\u0000-\u001f\u007f]/.test(code)
47
- && !/^(devin-session-token\$|cog_|sk-ws-01-|Bearer |Basic )/.test(code);
42
+ function validate(options: LoginOptions, flow: LoginFlow): Effect.Effect<void, DevinError> {
43
+ if (!["browser", "manual", "legacy-connect"].includes(flow)) return failWith("Unsupported login flow.");
44
+ if (options.encoding !== undefined && options.encoding !== "proto" && options.encoding !== "json") return failWith("The login encoding must be proto or json.");
45
+ if (flow === "browser" && options.encoding !== undefined) return failWith("A Connect encoding cannot be used with the REST browser login.");
46
+ if (flow !== "browser" && options.devinAPIURL !== undefined) return failWith("The Devin REST login server can only be set for the REST browser flow.");
47
+ if (flow === "manual" && !options.promptCode) return failWith("Manual login requires an authorization code prompt.");
48
+ return Effect.void;
48
49
  }
49
50
 
50
- /** The new account UI defaults to the REST browser flow. An explicit encoding keeps the legacy Connect path. */
51
- export function loginAccount(options: LoginOptions): Promise<Credential> {
52
- return login({ ...options, flow: options.flow ?? (options.encoding === undefined ? "browser" : "legacy-connect") });
51
+ function connectExchange(apiServerURL: string, code: string, verifier: string, options: LoginOptions): Effect.Effect<string, DevinFailure, HttpTransport> {
52
+ return Effect.scoped(Effect.gen(function* () {
53
+ const client = yield* makeConnectClient({ baseURL: apiServerURL, timeoutMs: CONNECT_EXCHANGE_TIMEOUT_MS });
54
+ const isWindsurfCode = code.startsWith(PKCE_CODE_PREFIX);
55
+ const method = isWindsurfCode ? PKCE_EXCHANGE_METHOD : EXCHANGE_METHOD;
56
+ const body = isWindsurfCode ? { authorizationCode: codeForExchange(code), codeVerifier: verifier } : { code, codeVerifier: verifier };
57
+ const exchanged = yield* client.unary(method, body, { encoding: options.encoding ?? "proto" });
58
+ if (isWindsurfCode && Predicate.isString(exchanged.apiServerUrl) && exchanged.apiServerUrl && (yield* endpoint(exchanged.apiServerUrl)) !== apiServerURL) {
59
+ return yield* failWith("The login returned a different API server. Set the target with --api-server-url and sign in again.");
60
+ }
61
+ const tokenField = isWindsurfCode ? "apiKey" : "sessionToken";
62
+ return yield* requiredString(exchanged[tokenField], `${method}.${tokenField}`);
63
+ }));
53
64
  }
54
65
 
55
66
  /** Keeps the default Connect contract of the existing shared login(). Storage and opening the browser belong to the caller. */
56
- export async function login(options: LoginOptions): Promise<Credential> {
57
- const flow = options.flow ?? "legacy-connect";
58
- if (!["browser", "manual", "legacy-connect"].includes(flow)) throw new Error("Unsupported login flow.");
59
- if (options.encoding !== undefined && options.encoding !== "proto" && options.encoding !== "json") throw new Error("The login encoding must be proto or json.");
60
- if (flow === "browser" && options.encoding !== undefined) throw new Error("A Connect encoding cannot be used with the REST browser login.");
61
- if (flow !== "browser" && options.devinAPIURL !== undefined) throw new Error("The Devin REST login server can only be set for the REST browser flow.");
62
- if (flow === "manual" && !options.promptCode) throw new Error("Manual login requires an authorization code prompt.");
63
- const apiServerURL = endpoint(options.apiServerURL ?? DEFAULT_API_SERVER_URL);
64
- const webappURL = endpoint(options.webappURL ?? DEFAULT_WEBAPP_URL);
65
- const devinAPIURL = endpoint(options.devinAPIURL ?? DEFAULT_DEVIN_API_URL);
66
- const signal = AbortSignal.any([...(options.signal ? [options.signal] : []), AbortSignal.timeout(options.timeoutMs ?? 300_000)]);
67
- signal.throwIfAborted();
68
- const pkce = createPKCE();
69
- if (flow !== "legacy-connect") pkce.state = randomUUID();
70
- let resolveCode!: (code: string) => void, rejectCode!: (error: Error) => void;
71
- let hasAcceptedCode = false, authorizationCode = "";
72
- let client: ConnectClient | undefined;
73
- let redirectURL: string | undefined;
74
- const inputController = new AbortController();
75
- const inputSignal = AbortSignal.any([signal, inputController.signal]);
76
- const codePromise = new Promise<string>((resolve, reject) => { resolveCode = resolve; rejectCode = reject; });
77
- void codePromise.catch(() => {});
78
- const acceptCode = (code: string) => {
79
- if (hasAcceptedCode || !isAuthorizationCode(code)) return false;
80
- hasAcceptedCode = true;
81
- authorizationCode = code;
82
- resolveCode(code);
83
- inputController.abort(new DOMException("Authorization code received.", "AbortError"));
84
- return true;
85
- };
86
- const server = createServer((request, response) => {
87
- response.setHeader("Content-Type", "text/plain; charset=utf-8");
88
- response.setHeader("Cache-Control", "no-store");
89
- response.setHeader("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'");
90
- response.setHeader("Referrer-Policy", "no-referrer");
91
- let url: URL;
92
- try { url = new URL(request.url ?? "/", "http://127.0.0.1"); }
93
- catch { response.writeHead(400).end("Invalid callback URL"); return; }
94
- if (request.method !== "GET" || url.pathname !== "/callback") { response.writeHead(404).end("Not found"); return; }
95
- if (hasAcceptedCode || !matchesState(url.searchParams.get("state"), pkce.state)) { response.writeHead(400).end("Invalid OAuth state"); return; }
96
- if (url.searchParams.has("error")) {
97
- hasAcceptedCode = true;
98
- response.writeHead(400).end("Login was denied.");
99
- rejectCode(new Error("OAuth login was denied."));
100
- inputController.abort();
101
- return;
102
- }
103
- if (!acceptCode(url.searchParams.get("code") ?? "")) { response.writeHead(400).end("Missing authorization code"); return; }
104
- response.end("Authorization code received. Check the token exchange result in your terminal.");
105
- });
106
- const onAbort = () => rejectCode(new DOMException(
107
- signal.reason?.name === "TimeoutError" ? "OAuth login timed out." : "OAuth login was cancelled.",
108
- signal.reason?.name === "TimeoutError" ? "TimeoutError" : "AbortError",
109
- ));
110
- signal.addEventListener("abort", onAbort, { once: true });
111
- try {
112
- if (flow !== "manual") {
113
- await new Promise<void>((resolve, reject) => {
114
- server.once("error", reject);
115
- server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(); });
116
- });
117
- const address = server.address();
118
- if (!address || typeof address === "string") throw new Error("Could not determine the callback port.");
119
- redirectURL = `http://127.0.0.1:${address.port}/callback`;
120
- }
121
- const authorizationURL = new URL(flow === "manual" ? "/devin/account/login" : "/auth/cli/continue", webappURL);
122
- authorizationURL.search = new URLSearchParams({
123
- redirect_uri: flow === "manual" ? "chisel-show-auth-token" : redirectURL!,
124
- state: pkce.state,
125
- ...(flow === "manual" ? {} : { prompt: "select_account" }),
126
- code_challenge: pkce.challenge, code_challenge_method: "S256",
127
- }).toString();
128
- signal.throwIfAborted();
129
- await awaitWithSignal(Promise.resolve(options.onURL(authorizationURL.href, signal)), signal);
130
- if (options.promptCode && !hasAcceptedCode) {
131
- void awaitWithSignal(Promise.resolve().then(() => options.promptCode!(inputSignal)), inputSignal).then((value) => {
132
- if (hasAcceptedCode || signal.aborted) return;
133
- authorizationCode = value.trim(); // Also keeps the raw code out of errors.
134
- let code = authorizationCode;
135
- if (/^https?:\/\//.test(code)) {
136
- const pasted = new URL(code);
137
- if (!redirectURL || pasted.origin + pasted.pathname !== redirectURL || !matchesState(pasted.searchParams.get("state"), pkce.state)) {
138
- throw new Error("The pasted callback URL or state does not match.");
67
+ export function login(options: LoginOptions): Effect.Effect<Credential, DevinFailure, HttpTransport> {
68
+ return Effect.gen(function* () {
69
+ const flow = options.flow ?? "legacy-connect";
70
+ yield* validate(options, flow);
71
+ const apiServerURL = yield* endpoint(options.apiServerURL ?? DEFAULT_API_SERVER_URL);
72
+ const webappURL = yield* endpoint(options.webappURL ?? DEFAULT_WEBAPP_URL);
73
+ const devinAPIURL = yield* endpoint(options.devinAPIURL ?? DEFAULT_DEVIN_API_URL);
74
+ const pkce = createPKCE();
75
+ if (flow !== "legacy-connect") pkce.state = randomUUID();
76
+ let authorizationCode = "";
77
+ const secrets = () => [authorizationCode, codeForExchange(authorizationCode), pkce.verifier];
78
+
79
+ return yield* Effect.scoped(Effect.gen(function* () {
80
+ const deadline = yield* startDeadline(options.timeoutMs ?? LOGIN_TIMEOUT_MS, () => new RequestTimeout({ message: "OAuth login timed out." }));
81
+ return yield* deadline.guard(Effect.gen(function* () {
82
+ const code = yield* Deferred.make<string, DevinFailure>();
83
+ let hasAcceptedCode = false;
84
+ const accept = (value: string) => {
85
+ if (hasAcceptedCode || !isAuthorizationCode(value)) return false;
86
+ hasAcceptedCode = true;
87
+ authorizationCode = value;
88
+ Deferred.doneUnsafe(code, Exit.succeed(value));
89
+ return true;
90
+ };
91
+ const redirectURL = flow === "manual" ? undefined : yield* startCallbackServer({
92
+ state: pkce.state, accept, isSettled: () => hasAcceptedCode,
93
+ deny: () => {
94
+ hasAcceptedCode = true;
95
+ Deferred.doneUnsafe(code, Exit.fail(new DevinError({ message: "OAuth login was denied." })));
96
+ },
97
+ });
98
+ const authorizationURL = new URL(flow === "manual" ? "/devin/account/login" : "/auth/cli/continue", webappURL);
99
+ authorizationURL.search = new URLSearchParams({
100
+ redirect_uri: flow === "manual" ? "chisel-show-auth-token" : redirectURL!,
101
+ state: pkce.state,
102
+ ...(flow === "manual" ? {} : { prompt: "select_account" }),
103
+ code_challenge: pkce.challenge, code_challenge_method: "S256",
104
+ }).toString();
105
+ yield* options.onURL(authorizationURL.href);
106
+ // Typed input races the browser callback; the first valid code wins and the other wait is stopped.
107
+ const input = options.promptCode && !hasAcceptedCode ? yield* Effect.forkScoped(Effect.gen(function* () {
108
+ const value = (yield* options.promptCode!).trim();
109
+ if (hasAcceptedCode) return;
110
+ authorizationCode = value; // Also keeps the raw code out of errors.
111
+ let candidate = value;
112
+ if (/^https?:\/\//.test(candidate)) {
113
+ const pasted = yield* Effect.try({ try: () => new URL(candidate), catch: toDevinError });
114
+ if (!redirectURL || pasted.origin + pasted.pathname !== redirectURL || !matchesState(pasted.searchParams.get("state"), pkce.state)) {
115
+ return yield* failWith("The pasted callback URL or state does not match.");
116
+ }
117
+ candidate = pasted.searchParams.get("code") ?? "";
139
118
  }
140
- code = pasted.searchParams.get("code") ?? "";
141
- }
142
- if (!acceptCode(code)) throw new Error("Enter a valid authorization code. API keys and session tokens are not accepted.");
143
- }).catch((error) => { if (!hasAcceptedCode && !signal.aborted) rejectCode(error); });
144
- }
145
- authorizationCode = await codePromise;
146
- signal.throwIfAborted();
147
- let sessionToken: string;
148
- if (flow === "browser") {
149
- sessionToken = await exchangeBrowserCode({ origin: devinAPIURL, code: authorizationCode, verifier: pkce.verifier, signal, fetch: options.fetch });
150
- } else {
151
- client = new ConnectClient({ baseURL: apiServerURL, timeoutMs: 30_000, fetch: options.fetch });
152
- const isWindsurfCode = authorizationCode.startsWith(PKCE_CODE_PREFIX);
153
- const method = isWindsurfCode ? PKCE_EXCHANGE_METHOD : EXCHANGE_METHOD;
154
- const body = isWindsurfCode ? { authorizationCode: codeForExchange(authorizationCode), codeVerifier: pkce.verifier }
155
- : { code: authorizationCode, codeVerifier: pkce.verifier };
156
- const exchanged = await client.unary(method, body, signal, { encoding: options.encoding ?? "proto" });
157
- if (isWindsurfCode && typeof exchanged.apiServerUrl === "string" && exchanged.apiServerUrl && endpoint(exchanged.apiServerUrl) !== apiServerURL) {
158
- throw new Error("The login returned a different API server. Set the target with --api-server-url and sign in again.");
159
- }
160
- const tokenField = isWindsurfCode ? "apiKey" : "sessionToken";
161
- sessionToken = requiredString(exchanged[tokenField], `${method}.${tokenField}`);
162
- }
163
- return { version: 1, sessionToken, apiServerURL, createdAt: new Date().toISOString() };
164
- } catch (error) {
165
- const secrets = [authorizationCode, codeForExchange(authorizationCode), pkce.verifier];
166
- if (error instanceof RPCError) { error.message = safeError(error, secrets); error.code = safeError(error.code, secrets); throw error; }
167
- const failure = new Error(safeError(error, secrets));
168
- if (error instanceof Error) failure.name = error.name;
169
- throw failure;
170
- } finally {
171
- inputController.abort();
172
- client?.close();
173
- signal.removeEventListener("abort", onAbort);
174
- server.closeAllConnections();
175
- await new Promise<void>((resolve) => server.close(() => resolve()));
176
- }
119
+ if (!accept(candidate)) return yield* failWith("Enter a valid authorization code. API keys and session tokens are not accepted.");
120
+ }).pipe(Effect.catch((error) => hasAcceptedCode ? Effect.void : Deferred.fail(code, error)))) : undefined;
121
+ const accepted = yield* Deferred.await(code);
122
+ if (input) yield* Fiber.interrupt(input);
123
+ const sessionToken = flow === "browser"
124
+ ? yield* exchangeBrowserCode({ origin: devinAPIURL, code: accepted, verifier: pkce.verifier })
125
+ : yield* connectExchange(apiServerURL, accepted, pkce.verifier, options);
126
+ return { version: 1, sessionToken, apiServerURL, createdAt: new Date().toISOString() } satisfies Credential;
127
+ }));
128
+ })).pipe(Effect.catch((error) => Effect.fail(redactFailure(error, secrets()))));
129
+ });
177
130
  }
@@ -0,0 +1,32 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+
3
+ export const PKCE_CODE_PREFIX = "wspkce$";
4
+
5
+ export interface PKCE { verifier: string; challenge: string; state: string }
6
+
7
+ export function createPKCE(): PKCE {
8
+ const verifier = randomBytes(32).toString("base64url");
9
+ return {
10
+ verifier,
11
+ challenge: createHash("sha256").update(verifier).digest("base64url"),
12
+ state: randomBytes(32).toString("base64url"),
13
+ };
14
+ }
15
+
16
+ /** The code the exchange RPC expects: a Windsurf-style code without its prefix. */
17
+ export function codeForExchange(code: string): string {
18
+ return code.startsWith(PKCE_CODE_PREFIX) ? code.slice(PKCE_CODE_PREFIX.length) : code;
19
+ }
20
+
21
+ export function matchesState(received: string | null, expected: string): boolean {
22
+ if (!received) return false;
23
+ const left = Buffer.from(received), right = Buffer.from(expected);
24
+ return left.length === right.length && timingSafeEqual(left, right);
25
+ }
26
+
27
+ /** A one-time authorization code. API keys, session tokens and credentials of other schemes are refused. */
28
+ export function isAuthorizationCode(code: string): boolean {
29
+ return code.length > 0 && code.length <= 8192 && codeForExchange(code).length > 0
30
+ && !/[\s\u0000-\u001f\u007f]/.test(code)
31
+ && !/^(devin-session-token\$|cog_|sk-ws-01-|Bearer |Basic )/.test(code);
32
+ }
@@ -1,35 +1,37 @@
1
- import { createHTTPTransport } from "../protocol/http.ts";
2
- import { awaitWithSignal, boundedJSON, discardResponse, endpoint, RPCError } from "../protocol/transport.ts";
3
- import { object, requiredString } from "../types.ts";
1
+ import { Effect, Exit } from "effect";
2
+ import { HttpBody, HttpClientRequest } from "effect/http";
3
+ import { startDeadline } from "../cancellation.ts";
4
+ import { RPCError, type DevinFailure } from "../errors.ts";
5
+ import { endpoint } from "../protocol/endpoint.ts";
6
+ import { discardResponse, fromHttpClientError, readBoundedJSON } from "../protocol/http.ts";
7
+ import { HttpTransport } from "../services/http-transport.ts";
8
+ import { failWith, object, requiredString } from "../validation.ts";
4
9
 
5
10
  const TOKEN_PREFIX = "devin-session-token$";
11
+ const EXCHANGE_TIMEOUT_MS = 30_000;
12
+ const RESPONSE_BYTES = 64 * 1024;
6
13
 
7
14
  /** REST authorization code exchange, same as the public client. Never resends or creates a refresh grant. */
8
- export async function exchangeBrowserCode(options: {
9
- origin: string; code: string; verifier: string; signal: AbortSignal; fetch?: typeof fetch;
10
- }): Promise<string> {
11
- const origin = endpoint(options.origin);
12
- const signal = AbortSignal.any([options.signal, AbortSignal.timeout(30_000)]);
13
- signal.throwIfAborted();
14
- const transport = options.fetch ? undefined : createHTTPTransport();
15
- try {
16
- const response = await awaitWithSignal((options.fetch ?? transport!.fetch)(`${origin}/auth/cli/token`, {
17
- method: "POST", redirect: "error", signal,
18
- headers: { "content-type": "application/json", accept: "application/json" },
19
- body: JSON.stringify({ code: options.code, code_verifier: options.verifier }),
20
- }), signal, discardResponse);
21
- if (!response.ok) {
22
- discardResponse(response);
23
- throw new RPCError(response.status === 401 ? "unauthenticated" : `http_${response.status}`, "Devin login code exchange failed", response.status);
24
- }
25
- let body: unknown;
26
- try { body = await boundedJSON(response, 64 * 1024, signal); }
27
- catch (error) {
28
- if (error instanceof SyntaxError) throw new Error("The Devin login response is not valid JSON.");
29
- throw error;
30
- }
31
- const token = requiredString(object(body, "Devin login response").token, "Devin login response.token");
32
- if (/[\s\u0000-\u001f\u007f]/.test(token)) throw new Error("The token in the Devin login response has an invalid format.");
33
- return token.startsWith(TOKEN_PREFIX) ? token : TOKEN_PREFIX + token;
34
- } finally { transport?.close(); }
15
+ export function exchangeBrowserCode(options: { origin: string; code: string; verifier: string }): Effect.Effect<string, DevinFailure, HttpTransport> {
16
+ return Effect.scoped(Effect.gen(function* () {
17
+ const origin = yield* endpoint(options.origin);
18
+ const deadline = yield* startDeadline(EXCHANGE_TIMEOUT_MS);
19
+ const http = yield* (yield* HttpTransport).open;
20
+ return yield* deadline.guard(Effect.gen(function* () {
21
+ const request = HttpClientRequest.post(`${origin}/auth/cli/token`).pipe(
22
+ HttpClientRequest.setBody(HttpBody.raw(JSON.stringify({ code: options.code, code_verifier: options.verifier }))),
23
+ HttpClientRequest.setHeaders({ "content-type": "application/json", accept: "application/json" }),
24
+ );
25
+ const response = yield* http.execute(request).pipe(Effect.mapError(fromHttpClientError));
26
+ yield* Effect.addFinalizer((exit) => Exit.isSuccess(exit) ? Effect.void : discardResponse(response));
27
+ if (response.status < 200 || response.status >= 300) {
28
+ yield* discardResponse(response);
29
+ return yield* Effect.fail(RPCError.make(response.status === 401 ? "unauthenticated" : `http_${response.status}`, "Devin login code exchange failed", response.status));
30
+ }
31
+ const body = yield* object(yield* readBoundedJSON(response, RESPONSE_BYTES), "Devin login response");
32
+ const token = yield* requiredString(body.token, "Devin login response.token");
33
+ if (/[\s\u0000-\u001f\u007f]/.test(token)) return yield* failWith("The token in the Devin login response has an invalid format.");
34
+ return token.startsWith(TOKEN_PREFIX) ? token : TOKEN_PREFIX + token;
35
+ }));
36
+ }));
35
37
  }
@@ -0,0 +1,99 @@
1
+ import { Deferred, Duration, Effect, Exit, type Scope } from "effect";
2
+ import { RequestTimeout, toDevinFailure, type DevinFailure } from "./errors.ts";
3
+
4
+ /**
5
+ * A one-shot stop switch that carries its reason. Interruption has no reason, so lifetimes such as a closed client,
6
+ * an invalidated login or an ended run fail the guarded work with the reason instead.
7
+ */
8
+ export interface Cancellation {
9
+ /** Fails with the reason once cancelled; never succeeds. */
10
+ readonly await: Effect.Effect<never, DevinFailure>;
11
+ readonly isCancelled: () => boolean;
12
+ readonly reason: () => DevinFailure | undefined;
13
+ /** Cancels once; later reasons are ignored. Also usable from synchronous host callbacks. */
14
+ readonly cancelUnsafe: (reason: DevinFailure) => void;
15
+ readonly cancel: (reason: DevinFailure) => Effect.Effect<void>;
16
+ /** Stops the effect when cancelled and fails it with the reason. Already cancelled work does not start. */
17
+ readonly guard: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E | DevinFailure, R>;
18
+ }
19
+
20
+ export function makeCancellation(): Cancellation {
21
+ const deferred = Deferred.makeUnsafe<never, DevinFailure>();
22
+ let current: DevinFailure | undefined;
23
+ const cancelUnsafe = (reason: DevinFailure) => {
24
+ if (current) return;
25
+ current = reason;
26
+ Deferred.doneUnsafe(deferred, Exit.fail(reason));
27
+ };
28
+ return {
29
+ await: Deferred.await(deferred),
30
+ isCancelled: () => current !== undefined,
31
+ reason: () => current,
32
+ cancelUnsafe,
33
+ cancel: (reason) => Effect.sync(() => cancelUnsafe(reason)),
34
+ guard: (effect) => Effect.suspend(() => current ? Effect.fail(current) : Effect.raceFirst(effect, Deferred.await(deferred))),
35
+ };
36
+ }
37
+
38
+ /** Guards an effect with several cancellations; the first one wins. */
39
+ export function guardAll(cancellations: readonly Cancellation[]) {
40
+ return <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E | DevinFailure, R> =>
41
+ cancellations.reduce<Effect.Effect<A, E | DevinFailure, R>>((guarded, cancellation) => cancellation.guard(guarded), effect);
42
+ }
43
+
44
+ /**
45
+ * Starts a deadline at once. The timer is registered before the guarded work runs any of its own code,
46
+ * so hooks that advance or block time cannot slip past it. The timer stops with the scope.
47
+ */
48
+ export function startDeadline(timeoutMs: number, reason: () => DevinFailure = () => new RequestTimeout({ message: `The request exceeded the ${timeoutMs}ms timeout.` })): Effect.Effect<Cancellation, never, Scope.Scope> {
49
+ return Effect.gen(function* () {
50
+ const deadline = makeCancellation();
51
+ yield* Effect.forkScoped(Effect.sleep(Duration.millis(timeoutMs)).pipe(Effect.andThen(deadline.cancel(reason()))), { startImmediately: true });
52
+ return deadline;
53
+ });
54
+ }
55
+
56
+ /** A deadline that restarts whenever data arrives. */
57
+ export interface IdleDeadline extends Cancellation {
58
+ /** Restarts the wait. Has no effect once the deadline has passed. */
59
+ readonly touch: Effect.Effect<void>;
60
+ }
61
+
62
+ /**
63
+ * Starts an idle deadline at once: it passes only when nothing arrives for `timeoutMs`, so a long response that keeps
64
+ * streaming is not cut off. Like `startDeadline`, the timer exists before the guarded work runs. It stops with the scope.
65
+ */
66
+ export function startIdleDeadline(timeoutMs: number, reason: () => DevinFailure = () => new RequestTimeout({ message: `The request received no data for ${timeoutMs}ms.` })): Effect.Effect<IdleDeadline, never, Scope.Scope> {
67
+ return Effect.gen(function* () {
68
+ const deadline = makeCancellation();
69
+ let timer: ReturnType<typeof setTimeout> | undefined;
70
+ const arm = () => {
71
+ clearTimeout(timer);
72
+ if (!deadline.isCancelled()) timer = setTimeout(() => deadline.cancelUnsafe(reason()), timeoutMs);
73
+ };
74
+ arm();
75
+ yield* Effect.addFinalizer(() => Effect.sync(() => clearTimeout(timer)));
76
+ return { ...deadline, touch: Effect.sync(arm) };
77
+ });
78
+ }
79
+
80
+ /** Fails with the signal's reason once it aborts; never succeeds. */
81
+ export function whenAborted(signal: AbortSignal): Effect.Effect<never, DevinFailure> {
82
+ return Effect.callback<never, DevinFailure>((resume) => {
83
+ const onAbort = () => resume(Effect.fail(toDevinFailure(signal.reason)));
84
+ if (signal.aborted) { onAbort(); return; }
85
+ signal.addEventListener("abort", onAbort, { once: true });
86
+ return Effect.sync(() => signal.removeEventListener("abort", onAbort));
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Bridges a host `AbortSignal`: the effect stops when it aborts and fails with the signal's reason,
92
+ * because hosts tell cancellation from a timeout by that reason.
93
+ */
94
+ export function abortWith(signal: AbortSignal | undefined) {
95
+ return <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E | DevinFailure, R> => {
96
+ if (!signal) return effect;
97
+ return Effect.suspend(() => signal.aborted ? Effect.fail(toDevinFailure(signal.reason)) : Effect.raceFirst(effect, whenAborted(signal)));
98
+ };
99
+ }