@buyong/pi-devin 0.1.5 → 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 (117) hide show
  1. package/README.ko.md +3 -5
  2. package/README.md +3 -5
  3. package/index.ts +6 -15
  4. package/package.json +3 -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 -166
  104. package/src/pi/devin-command.ts +0 -86
  105. package/src/pi/devin-settings.ts +0 -50
  106. package/src/pi/extension.ts +0 -89
  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 -415
  112. package/src/pi/provider.ts +0 -278
  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
  117. package/src/pi/usage-store.ts +0 -75
package/README.ko.md CHANGED
@@ -73,12 +73,11 @@ TUI에서 `/devin`을 실행하면 다음을 한 화면에 보여 줍니다. ↑
73
73
 
74
74
  - 계정 사용량 한도: 일간·주간 남은 비율 막대, 재설정 시각, 데이터를 가져온 시각
75
75
  - 이 세션의 사용량: 요청 수와 입력·출력·캐시 토큰. 보조 모델 대화는 포함하지 않습니다.
76
- - 실행 예산: 현재(또는 마지막) 사용자 메시지가 지금까지 쓴 모델 요청 수를 메시지당 한도에 대한 막대로 표시([제한과 동작](#제한과-동작) 참고)
77
76
 
78
77
  설정 두 개가 있으며 둘 다 기본값은 꺼짐입니다. 바꾸면 모든 세션에 한 번에 적용됩니다.
79
78
 
80
79
  - **Recommended models only**: `/model`에 Devin이 Recommended로 표시한 변형이 있는 모델과 Devin Fusion만 보여 줍니다. Devin은 `swe-2-high` 같은 개별 변형을 표시하지만, 목록에는 모델 자체(`swe-2`)가 남으므로 추론 수준은 계속 `/thinking`으로 고릅니다. 권장 수준이 자동으로 적용되지는 않습니다. 계정에 권장 모델이 없으면 전체 목록을 보여 줍니다.
81
- - **Usage in status line**: TUI 하단에 남은 일간·주간 사용량 막대와 이 세션의 입력·출력 토큰을 `devin day ▕█████████░▏98% week ▕█████████░▏98% · ↑263 ↓49`처럼 표시합니다. 막대는 40% 이하에서 경고 색, 20% 이하에서 오류 색으로 바뀝니다.
80
+ - **Usage in status line**: TUI 하단에 남은 일간·주간 사용량 막대와 이 세션의 입력·출력 토큰을 `devin day ▕█████████░▏98% week ▕█████████░▏98% · ↑263 ↓49`처럼 표시합니다. 막대는 40% 이하에서 경고 색, 20% 이하에서 오류 색으로 바뀝니다. Devin Fusion을 포함해 Devin 모델을 선택했을 때만 보이고, 다른 공급자의 모델로 바꾸면 사라집니다. `/devin`은 어떤 모델을 선택했든 사용량을 보여 줍니다.
82
81
 
83
82
  사용량 한도는 실시간이 아닙니다. 로그인할 때와 각 모델 요청 직전의 계정 확인에서 갱신되며, 같은 Pi agent 디렉터리를 쓰는 서브에이전트와 Fusion 보조 모델의 요청도 여기에 포함됩니다. 따라서 방금 보낸 메시지의 사용량은 다음 요청 뒤에 반영됩니다. `/devin`과 상태 표시줄은 네트워크 요청을 보내지 않으며, 상태 표시줄은 다른 Pi 프로세스가 기록한 값을 턴이 끝날 때마다 반영합니다. 크레딧, 요청별 비용, 사용 기록은 표시하지 않습니다.
84
83
 
@@ -105,10 +104,9 @@ RPC 모드처럼 TUI가 아닌 환경에서는 `/devin recommended on|off`와 `/
105
104
 
106
105
  | 항목 | 동작 |
107
106
  | --- | --- |
108
- | 모델 요청 예산 | 사용자 메시지 하나를 처리하는 동안 도구 후속 요청, 재시도, 자동 압축을 포함해 기본 최대 8번 모델을 호출합니다. 보조 모델 작업마다 같은 한도를 따로 받습니다. `PI_DEVIN_MAX_MODEL_CALLS`(1–100)로 바꿉니다. |
109
- | 요청 제한 시간 | Pi가 설정한 값을 따릅니다(기본 300초). |
107
+ | 요청 제한 시간 | Pi가 설정한 값을 따릅니다(기본 300초). 응답이 계속 들어오는 동안에는 끊지 않고, 그 시간 동안 데이터가 오지 않을 때만 실패합니다. |
110
108
  | 프롬프트 캐시 | 기본은 짧은 캐시입니다. `PI_CACHE_RETENTION=long`은 Devin이 24시간 캐시로 표시한 모델에만 허용하며, 다른 모델에서는 요청을 보내기 전에 거부합니다. |
111
- | 이미지 | 지원하는 모델에서 이미지당 최대 4 MiB, 메시지당 합계 최대 5 MiB입니다. |
109
+ | 이미지 | 지원하는 모델에서 이미지당 최대 4 MiB, 메시지당 합계 최대 5 MiB입니다. 이미지를 지원하지 않는 모델로 바꾸면 이전 이미지는 생략했다는 안내 문구로 바뀝니다. |
112
110
  | 보조 모델 보고 | 주 모델은 보고마다 상태·오류·안내를 포함해 최대 약 131,072토큰(524,288자, Pi의 문맥 추정처럼 4자를 1토큰으로 계산)을 받습니다. 더 긴 보고는 잘리고, 끝에 전체 보고가 담긴 임시 파일 경로가 붙습니다([저장 위치](#저장-위치) 참고). 저장에 실패하면 잘린 보고에 안내를 대신 붙입니다. |
113
111
  | print·JSON 모드 | 이 모드는 모델 목록을 가져오지 않고, 이전 TUI나 RPC 세션이 저장한 목록을 씁니다. 로그인한 상태로 TUI나 RPC 모드를 한 번 이상 실행해 목록을 저장해야 하며, 그러지 않으면 `--model devin/<모델 ID>`를 찾지 못합니다. |
114
112
 
package/README.md CHANGED
@@ -71,12 +71,11 @@ Running `/devin` in the TUI shows the following on one screen. Use ↑↓ to mov
71
71
 
72
72
  - Account quota: daily and weekly remaining-percentage bars, reset times and when the data was fetched
73
73
  - Usage in this session: request count and input, output and cache tokens. Sidekick conversations are not included.
74
- - Run budget: a bar of the model requests used so far by the current (or last) user message, out of the per-message limit (see [Limits and behavior](#limits-and-behavior))
75
74
 
76
75
  There are two toggles, both off by default. Changes apply to all sessions at once.
77
76
 
78
77
  - **Recommended models only**: `/model` shows only the models that have a variant Devin marks as Recommended, plus Devin Fusion. Devin marks single variants such as `swe-2-high`, but the list keeps the model itself (`swe-2`), so you still pick the thinking level with `/thinking`. The recommended level is not applied automatically. If your account has no recommended models, the full list is shown.
79
- - **Usage in status line**: the TUI footer shows the remaining daily and weekly quota as bars, plus this session's input and output tokens, like `devin day ▕█████████░▏98% week ▕█████████░▏98% · ↑263 ↓49`. A bar turns to the warning color at 40% or less and to the error color at 20% or less.
78
+ - **Usage in status line**: the TUI footer shows the remaining daily and weekly quota as bars, plus this session's input and output tokens, like `devin day ▕█████████░▏98% week ▕█████████░▏98% · ↑263 ↓49`. A bar turns to the warning color at 40% or less and to the error color at 20% or less. It appears only while a Devin model, including Devin Fusion, is selected, and disappears when you switch to another provider's model. `/devin` shows usage whichever model is selected.
80
79
 
81
80
  Quota is not live. It is refreshed when you log in and by the account check right before each model request, including requests from subagents and the Fusion sidekick that use the same Pi agent directory, so the usage of the message you just sent shows up after the next request. `/devin` and the status line send no network requests; the status line picks up quota recorded by other Pi processes at the end of each turn. Credits, per-request costs and usage history are not shown.
82
81
 
@@ -103,10 +102,9 @@ Picking **Devin Fusion** (`fusion`) in `/model` turns on the `devin_sidekick` to
103
102
 
104
103
  | Item | Behavior |
105
104
  | --- | --- |
106
- | Model request budget | Processing one user message calls models up to 8 times by default, including tool follow-ups, retries and auto-compaction. Each sidekick task gets the same limit separately. Change it with `PI_DEVIN_MAX_MODEL_CALLS` (1–100). |
107
- | Request timeout | Follows the value set by Pi (300 seconds by default). |
105
+ | Request timeout | Follows the value set by Pi (300 seconds by default). A response that keeps arriving is not cut off; the request fails only when no data arrives for that long. |
108
106
  | Prompt cache | Short cache by default. `PI_CACHE_RETENTION=long` is allowed only for models Devin marks with a 24-hour cache; for other models the request is refused before sending. |
109
- | Images | On supported models, up to 4 MiB per image and 5 MiB in total per message. |
107
+ | Images | On supported models, up to 4 MiB per image and 5 MiB in total per message. After switching to a model without image support, earlier images are replaced with a note saying they were left out. |
110
108
  | Sidekick report | The lead receives at most about 131,072 tokens of each report (524,288 characters, counted like Pi's context estimate of 4 characters per token), including status, errors and notices. A longer report is cut and ends with the path of a temporary file that holds the complete report (see [Storage locations](#storage-locations)). If saving fails, the cut report includes a notice instead. |
111
109
  | print and JSON modes | These modes do not fetch the model list; they use the list stored by an earlier TUI or RPC session. Run the TUI or RPC mode at least once while logged in so the list is stored; otherwise `--model devin/<model ID>` is not found. |
112
110
 
package/index.ts CHANGED
@@ -1,19 +1,10 @@
1
- import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { devinSettingsPath, fileDevinSettings } from "./src/pi/devin-settings.ts";
3
- import { registerDevinExtension } from "./src/pi/extension.ts";
4
- import { fileFusionSettings, fusionSettingsPath } from "./src/pi/fusion-settings.ts";
5
- import { modelCallLimit } from "./src/pi/lifecycle.ts";
6
- import { FUSION_CHILD_ENV } from "./src/pi/sidekick.ts";
7
- import { accountUsagePath, fileAccountUsageStore } from "./src/pi/usage-store.ts";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { registerDevinExtension } from "./src/pi/adapters/pi/registration.ts";
3
+ import { productionEnvironment } from "./src/pi/app/environment.ts";
4
+ import { FUSION_CHILD_ENV } from "./src/pi/features/sidekick/prompts.ts";
8
5
 
6
+ /** Pi's entry point. */
9
7
  export default function devinExtension(pi: ExtensionAPI): void {
10
8
  const isFusionChild = process.env[FUSION_CHILD_ENV] === "1";
11
- registerDevinExtension(pi, {
12
- maxModelCalls: modelCallLimit(process.env.PI_DEVIN_MAX_MODEL_CALLS),
13
- isFusionChild,
14
- // The sidekick child records its quota too, so the lead's status shows what the sidekick used.
15
- accountUsage: fileAccountUsageStore(accountUsagePath(getAgentDir())),
16
- fusionSettings: isFusionChild ? undefined : fileFusionSettings(fusionSettingsPath(getAgentDir())),
17
- devinSettings: isFusionChild ? undefined : fileDevinSettings(devinSettingsPath(getAgentDir())),
18
- });
9
+ registerDevinExtension(pi, productionEnvironment({ isFusionChild }));
19
10
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buyong/pi-devin",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "description": "Unofficial Pi provider for Devin accounts (OAuth)",
6
6
  "license": "MIT",
@@ -41,6 +41,8 @@
41
41
  "themes": []
42
42
  },
43
43
  "dependencies": {
44
+ "@effect/platform-node": "4.0.5",
45
+ "effect": "4.0.5",
44
46
  "protobufjs": "8.8.1"
45
47
  },
46
48
  "peerDependencies": {
@@ -1,6 +1,10 @@
1
+ import { Effect } from "effect";
2
+ import type { DevinError } from "../errors.ts";
1
3
  import type { JsonObject } from "../types.ts";
4
+ import { failWith } from "../validation.ts";
2
5
 
3
- export function assertCanInfer(status: JsonObject): void {
4
- if (status.canUseCli !== true) throw new Error("Could not confirm that this account may use the CLI.");
5
- if (status.dailyQuotaRemainingPercent === 0 || status.weeklyQuotaRemainingPercent === 0) throw new Error("The reported quota is 0, so no inference is run.");
6
+ export function assertCanInfer(status: JsonObject): Effect.Effect<void, DevinError> {
7
+ if (status.canUseCli !== true) return failWith("Could not confirm that this account may use the CLI.");
8
+ if (status.dailyQuotaRemainingPercent === 0 || status.weeklyQuotaRemainingPercent === 0) return failWith("The reported quota is 0, so no inference is run.");
9
+ return Effect.void;
6
10
  }
@@ -0,0 +1,57 @@
1
+ import { createServer } from "node:http";
2
+ import { Effect, type Scope } from "effect";
3
+ import { HttpServerRequest, HttpServerResponse } from "effect/http";
4
+ // A subpath import: the package index also loads NodeRedis, whose `redis` peer Pi's installer does not install.
5
+ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
6
+ import { toDevinError, type DevinError } from "../errors.ts";
7
+ import { failWith } from "../validation.ts";
8
+ import { matchesState } from "./pkce.ts";
9
+
10
+ export interface CallbackHandlers {
11
+ state: string;
12
+ /** A code was accepted or the login was denied, so every later callback is refused. */
13
+ isSettled(): boolean;
14
+ /** Accepts a valid code once and reports whether it was taken. */
15
+ accept(code: string): boolean;
16
+ deny(): void;
17
+ }
18
+
19
+ const SECURITY_HEADERS = {
20
+ "cache-control": "no-store",
21
+ "content-security-policy": "default-src 'none'; frame-ancestors 'none'",
22
+ "referrer-policy": "no-referrer",
23
+ };
24
+
25
+ const respond = (status: number, body: string) => HttpServerResponse.text(body, { status, headers: SECURITY_HEADERS, contentType: "text/plain; charset=utf-8" });
26
+
27
+ function callback(handlers: CallbackHandlers): Effect.Effect<HttpServerResponse.HttpServerResponse, never, HttpServerRequest.HttpServerRequest> {
28
+ return Effect.gen(function* () {
29
+ const request = yield* HttpServerRequest.HttpServerRequest;
30
+ let url: URL;
31
+ try { url = new URL(request.url, "http://127.0.0.1"); } catch { return respond(400, "Invalid callback URL"); }
32
+ if (request.method !== "GET" || url.pathname !== "/callback") return respond(404, "Not found");
33
+ if (handlers.isSettled() || !matchesState(url.searchParams.get("state"), handlers.state)) return respond(400, "Invalid OAuth state");
34
+ if (url.searchParams.has("error")) {
35
+ handlers.deny();
36
+ return respond(400, "Login was denied.");
37
+ }
38
+ if (!handlers.accept(url.searchParams.get("code") ?? "")) return respond(400, "Missing authorization code");
39
+ return respond(200, "Authorization code received. Check the token exchange result in your terminal.");
40
+ });
41
+ }
42
+
43
+ /** Serves the OAuth callback on a loopback port for the scope's lifetime and returns its redirect URL. Closing drops open connections at once. */
44
+ export function startCallbackServer(handlers: CallbackHandlers): Effect.Effect<string, DevinError, Scope.Scope> {
45
+ return Effect.gen(function* () {
46
+ const server = createServer();
47
+ const httpServer = yield* NodeHttpServer.make(() => server, { port: 0, host: "127.0.0.1", disablePreemptiveShutdown: true }).pipe(
48
+ Effect.mapError((error) => toDevinError(error.cause ?? error)),
49
+ );
50
+ // Runs before the server's own shutdown, which would otherwise wait for idle keep-alive connections.
51
+ yield* Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()));
52
+ yield* httpServer.serve(callback(handlers));
53
+ const address = server.address();
54
+ if (!address || typeof address === "string") return yield* failWith("Could not determine the callback port.");
55
+ return `http://127.0.0.1:${address.port}/callback`;
56
+ });
57
+ }
@@ -1,2 +1,3 @@
1
- export { login, loginAccount, createPKCE, DEFAULT_API_SERVER_URL, DEFAULT_WEBAPP_URL, DEFAULT_DEVIN_API_URL } from "./login.ts";
1
+ export { login, loginAccount, DEFAULT_API_SERVER_URL, DEFAULT_WEBAPP_URL, DEFAULT_DEVIN_API_URL } from "./login.ts";
2
2
  export type { LoginOptions, LoginFlow } from "./login.ts";
3
+ export { createPKCE } from "./pkce.ts";
@@ -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
  }