@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
@@ -0,0 +1,288 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { gzipSync } from "node:zlib";
3
+ import { Effect, Exit, Fiber, Predicate, Scope, Stream } from "effect";
4
+ import { HttpBody, HttpClientRequest, type HttpClient, type HttpClientResponse } from "effect/http";
5
+ import { guardAll, makeCancellation, startDeadline, startIdleDeadline, type Cancellation } from "../cancellation.ts";
6
+ import { Cancelled, isAuthenticationError, RPCError, toDevinError, type DevinError, type DevinFailure } from "../errors.ts";
7
+ import { redactFailure, requestSecrets, safeError } from "../redaction.ts";
8
+ import type { JsonObject } from "../types.ts";
9
+ import { failWith, object } from "../validation.ts";
10
+ import { endpoint } from "./endpoint.ts";
11
+ import { decodeFrames, encodeFrame, MAX_FRAME_BYTES, type Frame } from "./frames.ts";
12
+ import { HttpTransport } from "../services/http-transport.ts";
13
+ import { discardResponse, fromHttpClientError, readBoundedBytes, readBoundedJSON, responseChunks } from "./http.ts";
14
+ import { CHAT_METHOD, chatRequestCodec, unaryCodecs } from "./protobuf.ts";
15
+
16
+ // The CLI compresses frames only when they are at least 8192 bytes.
17
+ export const DEFAULT_COMPRESSION_THRESHOLD_BYTES = 8 * 1024;
18
+ export const ASSIGN_MODEL_METHOD = "exa.api_server_pb.ApiServerService/AssignModel";
19
+ const DEFAULT_TIMEOUT_MS = 90_000;
20
+ const UNARY_RESPONSE_BYTES = 4 * 1024 * 1024;
21
+ const ERROR_BODY_BYTES = 64 * 1024;
22
+ /** Only an initial compressed request rejected with these statuses is resent once uncompressed. */
23
+ const COMPRESSION_FALLBACK_STATUSES = new Set([408, 415, 500, 502, 503, 504]);
24
+ type Encoding = "proto" | "json" | "stream";
25
+
26
+ export interface RequestAttempt {
27
+ method: "POST" | "GET";
28
+ path: string;
29
+ attempt: number;
30
+ isMetered: boolean;
31
+ isCompressed: boolean;
32
+ stage?: string;
33
+ modelUid?: string;
34
+ }
35
+
36
+ export interface StreamRequestContext {
37
+ stage?: string;
38
+ /** Runs once, right after the first attempt was handed to the transport. */
39
+ onDispatched?: Effect.Effect<void, DevinFailure>;
40
+ headers?: Record<string, string | null>;
41
+ onPayload?: (payload: JsonObject) => Effect.Effect<JsonObject | undefined, DevinFailure>;
42
+ onResponse?: (response: { status: number; headers: Record<string, string> }) => Effect.Effect<void, DevinFailure>;
43
+ /** Observes each parsed frame before normalization and error classification. */
44
+ onFrame?: (frame: Frame) => Effect.Effect<void, DevinFailure>;
45
+ }
46
+
47
+ export interface ConnectClientOptions {
48
+ baseURL: string;
49
+ token?: string;
50
+ /** A unary call must finish within it; a stream fails after this long without data. Defaults to 90000ms. */
51
+ timeoutMs?: number;
52
+ compressionThresholdBytes?: number;
53
+ trace?: () => string;
54
+ /** Sees every attempt before it is sent and can refuse it, for example when a request budget is used up. */
55
+ onRequest?: (attempt: RequestAttempt) => Effect.Effect<void, DevinFailure>;
56
+ }
57
+
58
+ export interface ConnectClient {
59
+ readonly baseURL: string;
60
+ readonly token: string | undefined;
61
+ readonly timeoutMs: number;
62
+ readonly compressionThresholdBytes: number;
63
+ readonly isAuthenticationInvalid: Effect.Effect<boolean>;
64
+ readonly assertAuthenticated: Effect.Effect<void, DevinFailure>;
65
+ /** Ends with the client: closing it or a confirmed 401 also stops waits between requests. */
66
+ readonly lifetime: Cancellation;
67
+ readonly close: Effect.Effect<void>;
68
+ unary(method: string, body: JsonObject, options?: { encoding?: "proto" | "json" }): Effect.Effect<JsonObject, DevinFailure>;
69
+ stream(method: string, body: JsonObject, context?: StreamRequestContext): Stream.Stream<Frame, DevinFailure>;
70
+ getJSON(origin: string, path: string): Effect.Effect<JsonObject, DevinFailure>;
71
+ }
72
+
73
+ function retryAfterMs(headers: Headers | Record<string, string | undefined>): number | undefined {
74
+ const value = (headers instanceof Headers ? headers.get("retry-after") : headers["retry-after"])?.trim();
75
+ if (!value) return undefined;
76
+ const milliseconds = /^\d+$/.test(value) ? Number(value) * 1000
77
+ : /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), /.test(value) ? Date.parse(value) - Date.now() : NaN;
78
+ if (!Number.isFinite(milliseconds) || !Number.isSafeInteger(milliseconds)) return undefined;
79
+ return Math.max(0, milliseconds);
80
+ }
81
+
82
+ function requestHeaders(defaults: Record<string, string>, overrides: StreamRequestContext["headers"]): Effect.Effect<Record<string, string>, DevinError> {
83
+ return Effect.gen(function* () {
84
+ const headers = yield* Effect.try({ try: () => new Headers(defaults), catch: toDevinError });
85
+ for (const [name, value] of Object.entries(overrides ?? {})) {
86
+ if (["host", "content-length"].includes(name.toLowerCase()) && value !== headers.get(name)) {
87
+ return yield* failWith("Host and Content-Length are managed by the transport from the actual destination and body.");
88
+ }
89
+ yield* Effect.try({ try: () => { if (value === null) headers.delete(name); else headers.set(name, value); }, catch: toDevinError });
90
+ }
91
+ return Object.fromEntries(headers.entries());
92
+ });
93
+ }
94
+
95
+ const defaultTrace = () => `${randomBytes(16).toString("hex")}-${randomBytes(8).toString("hex")}-0`;
96
+
97
+ /**
98
+ * A Connect client bound to one endpoint and token, sending through its own connection from `HttpTransport`.
99
+ * It closes with the scope, or earlier through `close`.
100
+ */
101
+ export function makeConnectClient(options: ConnectClientOptions): Effect.Effect<ConnectClient, DevinError, HttpTransport | Scope.Scope> {
102
+ return Effect.gen(function* () {
103
+ const baseURL = yield* endpoint(options.baseURL);
104
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
105
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2_147_483_647) {
106
+ return yield* failWith("The request timeout must be an integer from 1 to 2147483647ms.");
107
+ }
108
+ const compressionThresholdBytes = options.compressionThresholdBytes ?? DEFAULT_COMPRESSION_THRESHOLD_BYTES;
109
+ if (!Number.isSafeInteger(compressionThresholdBytes) || compressionThresholdBytes < 1) {
110
+ return yield* failWith("The compression threshold must be a positive integer number of bytes.");
111
+ }
112
+ const token = options.token;
113
+ const trace = options.trace ?? defaultTrace;
114
+ const transport = yield* HttpTransport;
115
+ const poolScope = yield* Scope.make();
116
+ const http: HttpClient.HttpClient = yield* transport.open.pipe(Scope.provide(poolScope));
117
+ const lifetime = makeCancellation();
118
+ let authenticationError: RPCError | undefined;
119
+ let isClosed = false;
120
+
121
+ const close = Effect.suspend(() => {
122
+ if (isClosed) return Effect.void;
123
+ isClosed = true;
124
+ lifetime.cancelUnsafe(new Cancelled({ message: "The client is closed." }));
125
+ return Scope.close(poolScope, Exit.void);
126
+ });
127
+ yield* Effect.addFinalizer(() => close);
128
+
129
+ const assertAuthenticated: Effect.Effect<void, DevinFailure> = Effect.suspend((): Effect.Effect<void, DevinFailure> => authenticationError ? Effect.fail(authenticationError)
130
+ : isClosed ? failWith("The client is closed.") : Effect.void);
131
+
132
+ /** Only a confirmed 401 of this client invalidates it. Other in-flight requests stop too; the stored token is kept and nothing logs in again. */
133
+ const failure = (secrets: () => readonly string[]) => (error: DevinFailure): Effect.Effect<never, DevinFailure> => Effect.suspend(() => {
134
+ if (isAuthenticationError(error) && token && !authenticationError) {
135
+ authenticationError = error;
136
+ lifetime.cancelUnsafe(error);
137
+ }
138
+ return Effect.fail(redactFailure(error, secrets()));
139
+ });
140
+
141
+ /** Runs one RPC under its deadline and the client's lifetime. The deadline starts before any hook or byte is sent. */
142
+ const withRequestScope = <A>(secrets: () => readonly string[], effect: Effect.Effect<A, DevinFailure, Scope.Scope>) => Effect.scoped(Effect.gen(function* () {
143
+ yield* assertAuthenticated;
144
+ const deadline = yield* startDeadline(timeoutMs);
145
+ return yield* guardAll([deadline, lifetime])(effect);
146
+ })).pipe(Effect.catch(failure(secrets)));
147
+
148
+ const post = (method: string, body: JsonObject, encoding: Encoding, context: StreamRequestContext = {}): Effect.Effect<HttpClientResponse.HttpClientResponse, DevinFailure, Scope.Scope> => Effect.gen(function* () {
149
+ yield* assertAuthenticated;
150
+ const isStreaming = encoding === "stream";
151
+ if (isStreaming && method !== CHAT_METHOD) return yield* failWith("Unsupported streaming RPC.");
152
+ const bytes = isStreaming ? yield* chatRequestCodec.encode(body)
153
+ : encoding === "proto" ? yield* Effect.flatMap(unaryCodecs(method), (codecs) => codecs.request.encode(body)) : Buffer.from(JSON.stringify(body));
154
+ if (bytes.length > MAX_FRAME_BYTES) return yield* failWith("The request body exceeds 8 MiB.");
155
+ const isMetered = method === CHAT_METHOD || method === ASSIGN_MODEL_METHOD;
156
+ const sentryTrace = method === CHAT_METHOD ? trace() : undefined;
157
+ let isCompressed = isStreaming && bytes.length >= compressionThresholdBytes;
158
+ const contentType = isStreaming ? "application/connect+proto" : encoding === "proto" ? "application/proto" : "application/json";
159
+ const modelUid = Predicate.isString(body.chatModelUid) ? body.chatModelUid : Predicate.isString(body.modelRouterUid) ? body.modelRouterUid : undefined;
160
+ for (let attempt = 1; ; attempt++) {
161
+ yield* assertAuthenticated;
162
+ const compressed = isCompressed;
163
+ const payload = isStreaming
164
+ ? yield* encodeFrame(compressed ? yield* Effect.try({ try: () => gzipSync(bytes), catch: toDevinError }) : bytes, compressed ? 1 : 0)
165
+ : Buffer.from(bytes);
166
+ const headers = yield* requestHeaders({
167
+ ...(token ? { authorization: `Basic ${token}-${token}` } : {}),
168
+ ...(sentryTrace ? { "sentry-trace": sentryTrace } : {}),
169
+ ...(compressed ? { "connect-content-encoding": "gzip" } : {}),
170
+ "content-type": contentType,
171
+ "connect-protocol-version": "1",
172
+ accept: "*/*",
173
+ "accept-encoding": "gzip",
174
+ "content-length": String(payload.byteLength),
175
+ }, context.headers);
176
+ if (options.onRequest) yield* options.onRequest({ method: "POST", path: method, attempt, isMetered, isCompressed: compressed, stage: context.stage, modelUid });
177
+ const request = HttpClientRequest.post(`${baseURL}/${method}`).pipe(
178
+ HttpClientRequest.setBody(HttpBody.raw(encoding === "json" ? payload.toString("utf8") : new Uint8Array(payload))),
179
+ HttpClientRequest.setHeaders(headers),
180
+ );
181
+ // The request leaves before onDispatched runs. If it is never joined, closing the scope stops it and discards a late response.
182
+ const dispatched = yield* Effect.forkScoped(http.execute(request), { startImmediately: true });
183
+ if (attempt === 1 && context.onDispatched) yield* context.onDispatched;
184
+ const response = yield* Fiber.join(dispatched).pipe(Effect.mapError(fromHttpClientError));
185
+ // An unread body is released on every path except a successful hand-off to the caller.
186
+ yield* Effect.addFinalizer((exit) => Exit.isSuccess(exit) ? Effect.void : discardResponse(response));
187
+ if (context.onResponse) yield* context.onResponse({ status: response.status, headers: { ...response.headers } });
188
+ // Compression fallback the CLI uses. Keeps the same deadline, body, fingerprint, trace and per-attempt budget.
189
+ // 401/403/429 and one-time login exchanges are never resent through this path.
190
+ if (attempt === 1 && compressed && COMPRESSION_FALLBACK_STATUSES.has(response.status)) {
191
+ yield* discardResponse(response);
192
+ isCompressed = false;
193
+ continue;
194
+ }
195
+ if (response.status === 401) {
196
+ yield* discardResponse(response);
197
+ return yield* Effect.fail(RPCError.make("unauthenticated", "HTTP 401: the server rejected the authentication credential.", 401));
198
+ }
199
+ if (response.status < 200 || response.status >= 300) {
200
+ const secrets = requestSecrets(body, token);
201
+ const details = yield* readBoundedJSON(response, ERROR_BODY_BYTES).pipe(Effect.flatMap((value) => object(value, "RPC error")), Effect.orElseSucceed((): JsonObject => ({})));
202
+ return yield* Effect.fail(RPCError.make(Predicate.isString(details.code) ? safeError(details.code, secrets) : `http_${response.status}`,
203
+ safeError(Predicate.isString(details.message) ? details.message : `HTTP ${response.status}`, secrets),
204
+ response.status, response.status === 429 ? retryAfterMs(response.headers) : undefined));
205
+ }
206
+ if (response.headers["content-type"]?.split(";")[0].trim().toLowerCase() !== contentType) {
207
+ yield* discardResponse(response);
208
+ return yield* failWith(`Unexpected Content-Type (expected: ${contentType})`);
209
+ }
210
+ return response;
211
+ }
212
+ });
213
+
214
+ const unary: ConnectClient["unary"] = (method, body, unaryOptions = {}) => {
215
+ const encoding = unaryOptions.encoding ?? "proto";
216
+ return withRequestScope(() => requestSecrets(body, token), Effect.gen(function* () {
217
+ const response = yield* post(method, body, encoding);
218
+ return encoding === "json" ? yield* object(yield* readBoundedJSON(response, UNARY_RESPONSE_BYTES), "RPC response")
219
+ : yield* Effect.flatMap(unaryCodecs(method), (codecs) => Effect.flatMap(readBoundedBytes(response, UNARY_RESPONSE_BYTES), codecs.response.decode));
220
+ }));
221
+ };
222
+
223
+ const stream: ConnectClient["stream"] = (method, body, context = {}) => {
224
+ const secrets = requestSecrets(body, token);
225
+ // A custom header name alone cannot tell whether its value is secret.
226
+ for (const value of Object.values(context.headers ?? {})) if (value) secrets.push(value);
227
+ const frames = Stream.unwrap(Effect.gen(function* () {
228
+ yield* assertAuthenticated;
229
+ // A stream may answer for a long time; it ends only after `timeoutMs` without data, like Pi's idle HTTP timeout.
230
+ const deadline = yield* startIdleDeadline(timeoutMs);
231
+ const guard = guardAll([deadline, lifetime]);
232
+ const opened = guard(Effect.gen(function* () {
233
+ let payload = body;
234
+ if (context.onPayload) {
235
+ const replacement = yield* context.onPayload(body);
236
+ if (replacement !== undefined) payload = yield* object(replacement, "request payload");
237
+ }
238
+ secrets.push(...requestSecrets(payload, token));
239
+ const response = yield* post(method, payload, "stream", context);
240
+ const compression = response.headers["connect-content-encoding"] ?? "identity";
241
+ if (compression !== "identity" && compression !== "gzip") {
242
+ yield* discardResponse(response);
243
+ return yield* failWith("Unsupported Connect response compression.");
244
+ }
245
+ return decodeFrames(responseChunks(response).pipe(Stream.tap(() => deadline.touch)), { compression }).pipe(Stream.mapEffect((frame) => Effect.gen(function* () {
246
+ if (context.onFrame) yield* context.onFrame(frame);
247
+ if (frame.isEnd && frame.data.error) {
248
+ const error = yield* object(frame.data.error, "EndStream error");
249
+ return yield* Effect.fail(RPCError.make(Predicate.isString(error.code) ? safeError(error.code, secrets) : "unknown",
250
+ safeError(Predicate.isString(error.message) ? error.message : "Connect stream failed", secrets)));
251
+ }
252
+ return frame;
253
+ })));
254
+ }));
255
+ return Stream.unwrap(opened).pipe(Stream.interruptWhen(deadline.await), Stream.interruptWhen(lifetime.await));
256
+ }));
257
+ return frames.pipe(Stream.catch((error) => Stream.fromEffect(failure(() => secrets)(error))));
258
+ };
259
+
260
+ const getJSON: ConnectClient["getJSON"] = (origin, path) => Effect.gen(function* () {
261
+ const base = yield* endpoint(origin);
262
+ if (!path.startsWith("/v3/") || path.includes("..") || path.includes("?") || path.includes("#")) {
263
+ return yield* failWith("Unsupported Devin REST path.");
264
+ }
265
+ return yield* withRequestScope(() => token ? [token] : [], Effect.gen(function* () {
266
+ if (options.onRequest) yield* options.onRequest({ method: "GET", path, attempt: 1, isMetered: false, isCompressed: false });
267
+ const request = HttpClientRequest.get(`${base}${path}`).pipe(HttpClientRequest.setHeaders({
268
+ ...(token ? { authorization: `Bearer ${token}` } : {}), accept: "*/*",
269
+ ...(path === "/v3/self" ? {} : { "accept-encoding": "gzip" }),
270
+ }));
271
+ const response = yield* http.execute(request).pipe(Effect.mapError(fromHttpClientError));
272
+ yield* Effect.addFinalizer((exit) => Exit.isSuccess(exit) ? Effect.void : discardResponse(response));
273
+ if (response.status < 200 || response.status >= 300) {
274
+ yield* discardResponse(response);
275
+ return yield* Effect.fail(RPCError.make(response.status === 401 ? "unauthenticated" : `http_${response.status}`,
276
+ `Devin REST HTTP ${response.status}`, response.status, response.status === 429 ? retryAfterMs(response.headers) : undefined));
277
+ }
278
+ return yield* object(yield* readBoundedJSON(response, UNARY_RESPONSE_BYTES), "Devin REST response");
279
+ }));
280
+ });
281
+
282
+ return {
283
+ baseURL, token, timeoutMs, compressionThresholdBytes, lifetime, close, assertAuthenticated,
284
+ isAuthenticationInvalid: Effect.sync(() => authenticationError !== undefined),
285
+ unary, stream, getJSON,
286
+ };
287
+ });
288
+ }
@@ -1,5 +1,8 @@
1
- import { execFileSync } from "node:child_process";
2
1
  import { networkInterfaces, userInfo, type NetworkInterfaceInfo } from "node:os";
2
+ import { Effect } from "effect";
3
+ import { ChildProcessSpawner } from "effect/process";
4
+ import { toDevinError, type DevinError } from "../errors.ts";
5
+ import { commandOutput } from "../services/platform.ts";
3
6
 
4
7
  export interface DeviceIdentity {
5
8
  serial: string;
@@ -10,25 +13,22 @@ export interface DeviceIdentity {
10
13
  /** OS inputs read by device collection. Tests control only this boundary; collection rules and the fingerprint computation run unchanged. */
11
14
  export interface DeviceSystem {
12
15
  platform: NodeJS.Platform;
13
- run(file: string, args: string[]): string;
14
- networkInterfaces(): NodeJS.Dict<NetworkInterfaceInfo[]>;
15
- username(): string;
16
+ run(file: string, args: readonly string[]): Effect.Effect<string, DevinError>;
17
+ networkInterfaces(): Effect.Effect<NodeJS.Dict<NetworkInterfaceInfo[]>, DevinError>;
18
+ username(): Effect.Effect<string, DevinError>;
16
19
  }
17
20
 
18
21
  // The MAC collection shell input is a fixed string and never includes user input.
19
22
  const MAC_COMMAND = `ifconfig 2>/dev/null | awk '/^[a-z]/{iface=$1} /inet6? /{has_ip[iface]=1} /ether /{mac[iface]=$2} END{for(i in has_ip) if(mac[i] && mac[i]!="00:00:00:00:00:00") print mac[i]}' | tr '[:upper:]' '[:lower:]' | sort -u`;
20
- const COMMAND_OPTIONS = {
21
- encoding: "utf8" as const, timeout: 3000, maxBuffer: 1024 * 1024,
22
- env: { PATH: "/usr/bin:/bin:/usr/sbin:/sbin", LC_ALL: "C" },
23
- stdio: ["ignore", "pipe", "ignore"] as ["ignore", "pipe", "ignore"],
24
- };
23
+ const COMMAND_OPTIONS = { env: { PATH: "/usr/bin:/bin:/usr/sbin:/sbin", LC_ALL: "C" }, timeoutMs: 3000, maxOutputBytes: 1024 * 1024 };
25
24
 
26
- function nodeDeviceSystem(): DeviceSystem {
25
+ /** This machine's OS inputs. Commands run through the given process spawner. */
26
+ export function nodeDeviceSystem(spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]): DeviceSystem {
27
27
  return {
28
28
  platform: process.platform,
29
- run: (file, args) => execFileSync(file, args, COMMAND_OPTIONS),
30
- networkInterfaces,
31
- username: () => userInfo().username,
29
+ run: (file, args) => commandOutput(file, args, COMMAND_OPTIONS).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)),
30
+ networkInterfaces: () => Effect.try({ try: () => networkInterfaces(), catch: toDevinError }),
31
+ username: () => Effect.try({ try: () => userInfo().username, catch: toDevinError }),
32
32
  };
33
33
  }
34
34
 
@@ -48,26 +48,25 @@ function interfaceMACs(interfaces: NodeJS.Dict<NetworkInterfaceInfo[]>): string[
48
48
  * Device values are used only for the fingerprint. Credentials and settings of an installed CLI are never read.
49
49
  * macOS reads ioreg/ifconfig. Serial collection on other OSes is unverified, so the
50
50
  * serial is left empty instead of being invented; MACs come from the common Node API under the same conditions.
51
+ * Every failed read becomes the documented empty value; the output may be sensitive, so it never reaches an error.
51
52
  */
52
- export function collectDeviceIdentity(system: DeviceSystem = nodeDeviceSystem()): DeviceIdentity {
53
- const isDarwin = system.platform === "darwin";
54
- let serial = "";
55
- if (isDarwin) {
56
- try {
57
- const result = system.run("/usr/sbin/ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"]);
58
- const match = result.match(/"IOPlatformSerialNumber"\s*=\s*("(?:\\.|[^"\\])*")/);
59
- if (match) serial = JSON.parse(match[1]) as string;
60
- } catch { /* A failed serial read becomes an empty value. The output may be sensitive, so it is not included in errors. */ }
61
- }
62
- let macs: string[] = [];
63
- if (Buffer.byteLength(serial) < 4) {
64
- try {
65
- macs = isDarwin
66
- ? system.run("/bin/sh", ["-c", MAC_COMMAND]).split(/\r?\n/).filter(Boolean)
67
- : interfaceMACs(system.networkInterfaces());
68
- } catch { /* A failed MAC collection yields an empty list. */ }
69
- }
70
- let username = "unknown";
71
- try { username = system.username(); } catch { /* value used when the system call fails */ }
72
- return { serial, macs, username };
53
+ export function collectDeviceIdentity(system: DeviceSystem): Effect.Effect<DeviceIdentity> {
54
+ return Effect.gen(function* () {
55
+ const isDarwin = system.platform === "darwin";
56
+ let serial = "";
57
+ if (isDarwin) {
58
+ const output = yield* system.run("/usr/sbin/ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"]).pipe(Effect.orElseSucceed(() => ""));
59
+ const match = output.match(/"IOPlatformSerialNumber"\s*=\s*("(?:\\.|[^"\\])*")/);
60
+ if (match) serial = yield* Effect.try({ try: () => JSON.parse(match[1]) as string, catch: () => "" }).pipe(Effect.orElseSucceed(() => ""));
61
+ }
62
+ let macs: string[] = [];
63
+ if (Buffer.byteLength(serial) < 4) {
64
+ macs = yield* (isDarwin
65
+ ? Effect.map(system.run("/bin/sh", ["-c", MAC_COMMAND]), (output) => output.split(/\r?\n/).filter(Boolean))
66
+ : Effect.map(system.networkInterfaces(), interfaceMACs)
67
+ ).pipe(Effect.orElseSucceed((): string[] => []));
68
+ }
69
+ const username = yield* system.username().pipe(Effect.orElseSucceed(() => "unknown"));
70
+ return { serial, macs, username };
71
+ });
73
72
  }
@@ -0,0 +1,22 @@
1
+ import { Effect } from "effect";
2
+ import { toDevinError, type DevinError } from "../errors.ts";
3
+ import { failWith } from "../validation.ts";
4
+
5
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "[::1]", "localhost"]);
6
+
7
+ export function isLoopbackHost(hostname: string): boolean {
8
+ return LOOPBACK_HOSTS.has(hostname);
9
+ }
10
+
11
+ /** The origin of an HTTPS endpoint, or of loopback HTTP for local verification, without credentials or a path. */
12
+ export function endpoint(value: string): Effect.Effect<string, DevinError> {
13
+ return Effect.try({ try: () => new URL(value), catch: toDevinError }).pipe(Effect.flatMap((url) => {
14
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHost(url.hostname))) {
15
+ return failWith("The endpoint must be HTTPS, or loopback HTTP for local verification.");
16
+ }
17
+ if (url.username || url.password || url.search || url.hash || url.pathname !== "/") {
18
+ return failWith("The endpoint must not contain credentials, a path, a query, or a fragment.");
19
+ }
20
+ return Effect.succeed(url.origin);
21
+ }));
22
+ }
@@ -1,6 +1,9 @@
1
1
  import { createCipheriv, createHash, randomBytes } from "node:crypto";
2
2
  import { performance } from "node:perf_hooks";
3
- import { collectDeviceIdentity, type DeviceIdentity } from "./device.ts";
3
+ import { Effect } from "effect";
4
+ import { toDevinError, type DevinError } from "../errors.ts";
5
+ import { failWith } from "../validation.ts";
6
+ import type { DeviceIdentity } from "./device.ts";
4
7
 
5
8
  // Matches the CLI's fingerprint construction; verified with fake-token requests only.
6
9
  // This is not encryption for credential storage. f contains the API token, so it is never logged.
@@ -8,8 +11,10 @@ const KEY_MATERIAL = "df97465349f38646af1d66c263e25e08";
8
11
  const KEY = createHash("sha256").update(KEY_MATERIAL, "utf8").digest();
9
12
 
10
13
  export interface FingerprintProvider {
11
- create(): string;
12
- device(): string;
14
+ /** A new ciphertext per request: the same device, a fresh timestamp and nonce. */
15
+ readonly create: Effect.Effect<string, DevinError>;
16
+ /** The device hash, collected once on first use. */
17
+ readonly device: Effect.Effect<string, DevinError>;
13
18
  }
14
19
 
15
20
  export function hashDeviceIdentity(identity: DeviceIdentity): string {
@@ -17,20 +22,20 @@ export function hashDeviceIdentity(identity: DeviceIdentity): string {
17
22
  return createHash("sha512").update(macs + identity.serial + identity.username, "utf8").digest("hex");
18
23
  }
19
24
 
20
- export function createFingerprintProvider(sessionToken: string, options: {
21
- collectIdentity?: () => DeviceIdentity;
25
+ export interface FingerprintOptions {
26
+ /** Device values for this provider. Collected on first use and kept; concurrent first requests share one collection. */
27
+ collectIdentity: Effect.Effect<DeviceIdentity, DevinError>;
22
28
  nowNanoseconds?: () => bigint;
23
29
  nonce?: () => Uint8Array;
24
- } = {}): FingerprintProvider {
25
- let device: string | undefined;
26
- const getDevice = () => device ??= hashDeviceIdentity((options.collectIdentity ?? collectDeviceIdentity)());
27
- return {
28
- device: getDevice,
29
- create: () => encodeFingerprint({
30
- deviceFingerprint: getDevice(), sessionToken,
31
- timestampNanoseconds: options.nowNanoseconds?.(), nonce: options.nonce?.(),
32
- }),
33
- };
30
+ }
31
+
32
+ export function makeFingerprintProvider(sessionToken: string, options: FingerprintOptions): Effect.Effect<FingerprintProvider> {
33
+ return Effect.map(Effect.cached(Effect.map(options.collectIdentity, hashDeviceIdentity)), (device) => ({
34
+ device,
35
+ create: Effect.flatMap(device, (deviceFingerprint) => encodeFingerprint({
36
+ deviceFingerprint, sessionToken, timestampNanoseconds: options.nowNanoseconds?.(), nonce: options.nonce?.(),
37
+ })),
38
+ }));
34
39
  }
35
40
 
36
41
  export function unixNanoseconds(): bigint {
@@ -45,15 +50,22 @@ export function encodeFingerprint(options: {
45
50
  sessionToken: string;
46
51
  timestampNanoseconds?: bigint;
47
52
  nonce?: Uint8Array;
48
- }): string {
49
- if (!/^[0-9a-f]{128}$/.test(options.deviceFingerprint)) throw new Error("The device identifier must be 128 lowercase hex characters.");
50
- if (!options.sessionToken || /[|\s\u0000-\u001f\u007f]/.test(options.sessionToken)) throw new Error("Invalid session token format.");
51
- const timestamp = options.timestampNanoseconds ?? unixNanoseconds();
52
- if (timestamp < 0n || timestamp >= (1n << 128n)) throw new Error("Invalid fingerprint timestamp.");
53
- const nonce = options.nonce ?? randomBytes(12);
54
- if (nonce.length !== 12) throw new Error("The fingerprint nonce must be 12 bytes.");
55
- const plaintext = `${options.deviceFingerprint}|${options.sessionToken}|${timestamp}`;
56
- const cipher = createCipheriv("aes-256-gcm", KEY, nonce);
57
- const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
58
- return Buffer.concat([nonce, ciphertext, cipher.getAuthTag()]).toString("hex");
53
+ }): Effect.Effect<string, DevinError> {
54
+ return Effect.suspend(() => {
55
+ if (!/^[0-9a-f]{128}$/.test(options.deviceFingerprint)) return failWith("The device identifier must be 128 lowercase hex characters.");
56
+ if (!options.sessionToken || /[|\s\u0000-\u001f\u007f]/.test(options.sessionToken)) return failWith("Invalid session token format.");
57
+ const timestamp = options.timestampNanoseconds ?? unixNanoseconds();
58
+ if (timestamp < 0n || timestamp >= (1n << 128n)) return failWith("Invalid fingerprint timestamp.");
59
+ const nonce = options.nonce ?? randomBytes(12);
60
+ if (nonce.length !== 12) return failWith("The fingerprint nonce must be 12 bytes.");
61
+ const plaintext = `${options.deviceFingerprint}|${options.sessionToken}|${timestamp}`;
62
+ return Effect.try({
63
+ try: () => {
64
+ const cipher = createCipheriv("aes-256-gcm", KEY, nonce);
65
+ const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
66
+ return Buffer.concat([nonce, ciphertext, cipher.getAuthTag()]).toString("hex");
67
+ },
68
+ catch: toDevinError,
69
+ });
70
+ });
59
71
  }
@@ -0,0 +1,101 @@
1
+ import { gunzipSync } from "node:zlib";
2
+ import { Effect, Stream } from "effect";
3
+ import { DevinError } from "../errors.ts";
4
+ import type { JsonObject } from "../types.ts";
5
+ import { failWith, object } from "../validation.ts";
6
+ import { chatResponseCodec } from "./protobuf.ts";
7
+
8
+ export const MAX_FRAME_BYTES = 8 * 1024 * 1024;
9
+ export const MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
10
+
11
+ export interface Frame {
12
+ isEnd: boolean;
13
+ data: JsonObject;
14
+ }
15
+
16
+ export function encodeFrame(bytes: Uint8Array, flags = 0): Effect.Effect<Buffer, DevinError> {
17
+ return Effect.suspend(() => {
18
+ const payload = Buffer.from(bytes);
19
+ if (payload.length > MAX_FRAME_BYTES) return failWith("The request frame exceeds 8 MiB.");
20
+ const header = Buffer.alloc(5);
21
+ header[0] = flags;
22
+ header.writeUInt32BE(payload.length, 1);
23
+ return Effect.succeed(Buffer.concat([header, payload]));
24
+ });
25
+ }
26
+
27
+ /** The connection ended before the stream was complete, so a new request may succeed. */
28
+ const prematureClose = (message: string) => new DevinError({ message, code: "ERR_STREAM_PREMATURE_CLOSE" });
29
+
30
+ /** Parses one response's Connect envelopes. HTTP chunk boundaries and Connect frame boundaries are independent. */
31
+ class FrameDecoder {
32
+ private buffered: Buffer = Buffer.alloc(0);
33
+ private hasEnded = false;
34
+ private wireBytes = 0;
35
+ private decodedBytes = 0;
36
+ private readonly decoder = new TextDecoder("utf-8", { fatal: true });
37
+ private readonly compression: "identity" | "gzip" | undefined;
38
+
39
+ constructor(compression: "identity" | "gzip" | undefined) {
40
+ this.compression = compression;
41
+ }
42
+
43
+ push(chunk: Uint8Array): Effect.Effect<Frame[], DevinError> {
44
+ const self = this;
45
+ return Effect.gen(function* () {
46
+ self.wireBytes += chunk.byteLength;
47
+ if (self.wireBytes > MAX_RESPONSE_BYTES) return yield* failWith("The response exceeds 64 MiB.");
48
+ if (self.hasEnded && chunk.byteLength > 0) return yield* failWith("Data follows EndStream.");
49
+ self.buffered = Buffer.concat([self.buffered, chunk]);
50
+ const frames: Frame[] = [];
51
+ while (self.buffered.length >= 5) {
52
+ if (self.hasEnded) return yield* failWith("A frame follows EndStream.");
53
+ const flags = self.buffered[0];
54
+ if ((flags & ~3) !== 0) return yield* failWith(`Unknown Connect flags: ${flags}`);
55
+ const payloadBytes = self.buffered.readUInt32BE(1);
56
+ if (payloadBytes > MAX_FRAME_BYTES) return yield* failWith("The response frame exceeds 8 MiB.");
57
+ if (self.buffered.length < 5 + payloadBytes) break;
58
+ let payload: Buffer = self.buffered.subarray(5, 5 + payloadBytes);
59
+ self.buffered = self.buffered.subarray(5 + payloadBytes);
60
+ if (flags & 1) {
61
+ if (self.compression !== "gzip") return yield* failWith("A compressed frame has no valid gzip encoding header.");
62
+ const compressed = payload;
63
+ payload = yield* Effect.try({
64
+ try: () => gunzipSync(compressed, { maxOutputLength: MAX_FRAME_BYTES }),
65
+ catch: () => new DevinError({ message: "The gzip frame is corrupt or exceeds the decompression limit." }),
66
+ });
67
+ }
68
+ self.decodedBytes += payload.byteLength;
69
+ if (self.decodedBytes > MAX_RESPONSE_BYTES) return yield* failWith("The decompressed response exceeds 64 MiB.");
70
+ self.hasEnded = Boolean(flags & 2);
71
+ const bytes = payload;
72
+ const data = self.hasEnded
73
+ ? yield* Effect.try({ try: () => JSON.parse(self.decoder.decode(bytes)) as unknown, catch: () => undefined }).pipe(
74
+ Effect.flatMap((value) => object(value, "EndStream")),
75
+ Effect.mapError(() => new DevinError({ message: "EndStream is not a valid UTF-8 JSON object." })),
76
+ )
77
+ : yield* chatResponseCodec.decode(bytes);
78
+ frames.push({ isEnd: self.hasEnded, data });
79
+ }
80
+ return frames;
81
+ });
82
+ }
83
+
84
+ get end(): Effect.Effect<void, DevinError> {
85
+ if (this.buffered.length !== 0) return Effect.fail(prematureClose("The connection ended with an incomplete Connect frame."));
86
+ if (!this.hasEnded) return Effect.fail(prematureClose("The connection ended without EndStream."));
87
+ return Effect.void;
88
+ }
89
+ }
90
+
91
+ /** Decodes Connect frames from a response body. A truncated stream fails as a premature close. */
92
+ export function decodeFrames<E, R>(chunks: Stream.Stream<Uint8Array, E, R>, options: { compression?: "identity" | "gzip" } = {}): Stream.Stream<Frame, E | DevinError, R> {
93
+ return Stream.suspend(() => {
94
+ const decoder = new FrameDecoder(options.compression);
95
+ return chunks.pipe(
96
+ Stream.mapEffect((chunk) => decoder.push(chunk)),
97
+ Stream.flattenIterable,
98
+ Stream.concat(Stream.fromEffectDrain(Effect.suspend(() => decoder.end))),
99
+ );
100
+ });
101
+ }
@@ -0,0 +1,42 @@
1
+ import { Effect } from "effect";
2
+ import { HttpClient, type HttpClientError, type HttpClientRequest, type HttpClientResponse } from "effect/http";
3
+ import { requestBody, transportError } from "./node-client.ts";
4
+ import { DevinHttpResponse } from "./response.ts";
5
+
6
+ const ignore = () => {};
7
+
8
+ /**
9
+ * `HttpClient` over an injected `fetch`, such as the one Pi passes in stream options. Sends the transport's exact headers
10
+ * and body, never follows redirects, and discards a response that arrives after its request ended.
11
+ */
12
+ export function makeFetchHttpClient(fetchImpl: typeof fetch): HttpClient.HttpClient {
13
+ const send = (request: HttpClientRequest.HttpClientRequest) => Effect.callback<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError>((resume) => {
14
+ const controller = new AbortController();
15
+ let isSettled = false;
16
+ let pending: Promise<Response>;
17
+ try {
18
+ pending = Promise.resolve(fetchImpl(request.url, {
19
+ method: request.method, redirect: "error", headers: Object.fromEntries(Object.entries(request.headers)),
20
+ body: requestBody(request) as BodyInit | undefined, signal: controller.signal,
21
+ }));
22
+ } catch (error) {
23
+ resume(Effect.fail(transportError(request, error)));
24
+ return;
25
+ }
26
+ pending.then((response) => {
27
+ if (isSettled) { void response.body?.cancel().catch(ignore); return; }
28
+ isSettled = true;
29
+ resume(Effect.succeed(new DevinHttpResponse(request, response, () => controller.abort())));
30
+ }, (error: unknown) => {
31
+ if (isSettled) return;
32
+ isSettled = true;
33
+ resume(Effect.fail(transportError(request, error)));
34
+ });
35
+ return Effect.sync(() => {
36
+ if (isSettled) return;
37
+ isSettled = true;
38
+ controller.abort();
39
+ });
40
+ });
41
+ return HttpClient.makeWith<HttpClientError.HttpClientError, never, HttpClientError.HttpClientError, never>((request) => Effect.flatMap(request, send), Effect.succeed);
42
+ }