@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.
- package/README.ko.md +3 -5
- package/README.md +3 -5
- package/index.ts +6 -15
- package/package.json +3 -1
- package/src/core/auth/access.ts +7 -3
- package/src/core/auth/callback-server.ts +57 -0
- package/src/core/auth/index.ts +2 -1
- package/src/core/auth/login.ts +104 -151
- package/src/core/auth/pkce.ts +32 -0
- package/src/core/auth/rest-exchange.ts +32 -30
- package/src/core/cancellation.ts +99 -0
- package/src/core/client.ts +127 -122
- package/src/core/errors.ts +115 -0
- package/src/core/index.ts +18 -4
- package/src/core/inference/accumulator.ts +174 -0
- package/src/core/inference/images.ts +22 -15
- package/src/core/inference/request.ts +59 -0
- package/src/core/inference/retry.ts +31 -31
- package/src/core/inference/state.ts +7 -3
- package/src/core/inference/stream-chat.ts +48 -0
- package/src/core/models/access.ts +4 -3
- package/src/core/models/catalog.ts +29 -20
- package/src/core/models/select.ts +14 -12
- package/src/core/models/variants.ts +85 -72
- package/src/core/protocol/connect-client.ts +288 -0
- package/src/core/protocol/device.ts +33 -34
- package/src/core/protocol/endpoint.ts +22 -0
- package/src/core/protocol/fingerprint.ts +38 -26
- package/src/core/protocol/frames.ts +101 -0
- package/src/core/protocol/http/fetch-client.ts +42 -0
- package/src/core/protocol/http/node-client.ts +130 -0
- package/src/core/protocol/http/response.ts +99 -0
- package/src/core/protocol/http.ts +41 -101
- package/src/core/protocol/protobuf.ts +93 -74
- package/src/core/redaction.ts +33 -0
- package/src/core/services/device-identity.ts +15 -0
- package/src/core/services/http-transport.ts +28 -0
- package/src/core/services/platform.ts +54 -0
- package/src/core/types.ts +2 -23
- package/src/core/validation.ts +25 -0
- package/src/pi/adapters/pi/devin-command.ts +82 -0
- package/src/pi/adapters/pi/fusion-command.ts +92 -0
- package/src/pi/adapters/pi/oauth.ts +14 -0
- package/src/pi/adapters/pi/provider.ts +22 -0
- package/src/pi/adapters/pi/registration.ts +89 -0
- package/src/pi/adapters/pi/response-stream.ts +164 -0
- package/src/pi/adapters/pi/sidekick-tool.ts +45 -0
- package/src/pi/adapters/pi/transcript.ts +177 -0
- package/src/pi/adapters/pi/types.ts +14 -0
- package/src/pi/{devin-panel.ts → adapters/pi/ui/devin-panel.ts} +11 -9
- package/src/pi/{fusion-picker.ts → adapters/pi/ui/fusion-picker.ts} +1 -1
- package/src/pi/adapters/pi/usage-status.ts +65 -0
- package/src/pi/adapters/storage/files.ts +66 -0
- package/src/pi/adapters/storage/json-file.ts +31 -0
- package/src/pi/app/config.ts +25 -0
- package/src/pi/app/environment.ts +30 -0
- package/src/pi/app/layer.ts +43 -0
- package/src/pi/app/runtime.ts +20 -0
- package/src/pi/app/session.ts +26 -0
- package/src/pi/features/accounts/service.ts +69 -0
- package/src/pi/features/accounts/types.ts +25 -0
- package/src/pi/features/auth/credential.ts +40 -0
- package/src/pi/features/auth/guard.ts +25 -0
- package/src/pi/features/auth/login.ts +116 -0
- package/src/pi/features/auth/state.ts +48 -0
- package/src/pi/features/auth/types.ts +36 -0
- package/src/pi/features/bounded.ts +4 -0
- package/src/pi/features/catalog/constants.ts +10 -0
- package/src/pi/features/catalog/fusion.ts +45 -0
- package/src/pi/features/catalog/ids.ts +79 -0
- package/src/pi/features/catalog/projection.ts +93 -0
- package/src/pi/features/catalog/remote.ts +15 -0
- package/src/pi/features/catalog/restore.ts +53 -0
- package/src/pi/features/catalog/service.ts +158 -0
- package/src/pi/features/catalog/types.ts +56 -0
- package/src/pi/features/clients/service.ts +47 -0
- package/src/pi/features/clients/types.ts +22 -0
- package/src/pi/features/default-scheduler.ts +12 -0
- package/src/pi/features/inference/request.ts +190 -0
- package/src/pi/features/inference/service.ts +33 -0
- package/src/pi/features/inference/types.ts +11 -0
- package/src/pi/features/lifetime/service.ts +19 -0
- package/src/pi/features/lifetime/types.ts +13 -0
- package/src/pi/features/runs/admission.ts +36 -0
- package/src/pi/features/runs/lifecycle.ts +207 -0
- package/src/pi/features/runs/service.ts +10 -0
- package/src/pi/features/runs/state.ts +37 -0
- package/src/pi/features/runs/types.ts +43 -0
- package/src/pi/features/settings/service.ts +46 -0
- package/src/pi/features/settings/types.ts +36 -0
- package/src/pi/features/sidekick/child.ts +228 -0
- package/src/pi/features/sidekick/command.ts +80 -0
- package/src/pi/features/sidekick/launcher.ts +12 -0
- package/src/pi/features/sidekick/prompts.ts +17 -0
- package/src/pi/features/sidekick/report.ts +51 -0
- package/src/pi/features/sidekick/service.ts +176 -0
- package/src/pi/features/sidekick/types.ts +99 -0
- package/src/pi/features/usage/changes.ts +20 -0
- package/src/pi/{usage.ts → features/usage/report.ts} +3 -34
- package/src/pi/features/usage/types.ts +33 -0
- package/src/core/inference/chat.ts +0 -234
- package/src/core/protocol/transport.ts +0 -513
- package/src/pi/auth.ts +0 -166
- package/src/pi/devin-command.ts +0 -86
- package/src/pi/devin-settings.ts +0 -50
- package/src/pi/extension.ts +0 -89
- package/src/pi/fusion-command.ts +0 -83
- package/src/pi/fusion-settings.ts +0 -44
- package/src/pi/lifecycle.ts +0 -217
- package/src/pi/messages.ts +0 -131
- package/src/pi/models.ts +0 -415
- package/src/pi/provider.ts +0 -278
- package/src/pi/sidekick-report.ts +0 -47
- package/src/pi/sidekick-runtime.ts +0 -300
- package/src/pi/sidekick.ts +0 -211
- package/src/pi/stream.ts +0 -124
- package/src/pi/usage-store.ts +0 -75
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Effect, Predicate } from "effect";
|
|
2
|
+
import type { DevinError } from "../errors.ts";
|
|
3
|
+
import type { JsonObject, ModelInfo } from "../types.ts";
|
|
4
|
+
import { failWith, object, requiredString, tokenCount } from "../validation.ts";
|
|
2
5
|
import { parseModelCatalog, type ModelCatalog } from "./catalog.ts";
|
|
3
6
|
import { getModelByUID } from "./select.ts";
|
|
4
7
|
|
|
@@ -43,20 +46,23 @@ export function groupModelID(family: string, dimensions: readonly ModelDimension
|
|
|
43
46
|
return "family:" + encodeURIComponent(family) + (dimensions.length ? ":" + Buffer.from(key).toString("base64url") : "");
|
|
44
47
|
}
|
|
45
48
|
|
|
46
|
-
function dimensionsOf(config: JsonObject): ModelDimension[] {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
49
|
+
function dimensionsOf(config: JsonObject): Effect.Effect<ModelDimension[], DevinError> {
|
|
50
|
+
return Effect.gen(function* () {
|
|
51
|
+
const family = yield* object(config.modelFamilyMetadata ?? {}, "modelFamilyMetadata");
|
|
52
|
+
if (family.entries === undefined) return [];
|
|
53
|
+
if (!Array.isArray(family.entries)) return yield* failWith("Model dimension entries are not an array.");
|
|
54
|
+
const seen = new Set<string>();
|
|
55
|
+
const dimensions: ModelDimension[] = [];
|
|
56
|
+
for (const item of family.entries) {
|
|
57
|
+
const entry = yield* object(item, "model dimension"), value = yield* object(entry.value ?? {}, "dimension value");
|
|
58
|
+
const key = yield* requiredString(entry.key, "dimension key");
|
|
59
|
+
if (seen.has(key)) return yield* failWith(`Duplicate model dimension: ${key}`);
|
|
60
|
+
seen.add(key);
|
|
61
|
+
const order = yield* tokenCount(value.order ?? 0, "dimension order");
|
|
62
|
+
dimensions.push({ key, order, name: Predicate.isString(value.name) ? value.name : "" });
|
|
63
|
+
}
|
|
64
|
+
return dimensions.sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
|
|
65
|
+
});
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
function classify(model: ModelInfo, dimensions: readonly ModelDimension[]): Pick<ModelVariant, "level" | "unsupported"> {
|
|
@@ -73,8 +79,7 @@ function classify(model: ModelInfo, dimensions: readonly ModelDimension[]): Pick
|
|
|
73
79
|
return { unsupported: "thinking strength metadata is unknown" };
|
|
74
80
|
}
|
|
75
81
|
|
|
76
|
-
function sanitizedConfig(
|
|
77
|
-
const info = object(config.modelInfo, "modelInfo");
|
|
82
|
+
function sanitizedConfig(info: JsonObject, model: ModelInfo, familyName: string, dimensions: readonly ModelDimension[]): JsonObject {
|
|
78
83
|
return {
|
|
79
84
|
modelUid: model.id, label: model.name, disabled: model.isDisabled, maxTokens: model.contextWindow,
|
|
80
85
|
supportsImages: model.supportsImages,
|
|
@@ -83,7 +88,7 @@ function sanitizedConfig(config: JsonObject, model: ModelInfo, familyName: strin
|
|
|
83
88
|
modelInfo: {
|
|
84
89
|
modelUid: model.id, modelFamilyUid: model.familyId || model.id,
|
|
85
90
|
maxTokens: model.contextWindow, maxOutputTokens: model.maxOutputTokens,
|
|
86
|
-
...(
|
|
91
|
+
...(Predicate.isString(info.modelType) ? { modelType: info.modelType } : {}),
|
|
87
92
|
isModelRouter: model.isRouter, aliases: [...(model.aliases ?? [])],
|
|
88
93
|
modelFeatures: {
|
|
89
94
|
supportsImages: model.supportsImages, supportsToolCalls: model.supportsToolCalls,
|
|
@@ -98,66 +103,74 @@ function capabilityKey(model: ModelInfo): string {
|
|
|
98
103
|
return JSON.stringify([model.contextWindow, model.maxOutputTokens, model.supportsImages, model.supportsToolCalls, model.supportsParallelToolCalls]);
|
|
99
104
|
}
|
|
100
105
|
|
|
101
|
-
export function projectModelVariants(catalog: ModelCatalog): VariantProjection {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
106
|
+
export function projectModelVariants(catalog: ModelCatalog): Effect.Effect<VariantProjection, DevinError> {
|
|
107
|
+
return Effect.gen(function* () {
|
|
108
|
+
const configs = catalog.raw.clientModelConfigs;
|
|
109
|
+
if (!Array.isArray(configs) || configs.length !== catalog.models.length) return yield* failWith("The raw and projected model lists do not match.");
|
|
110
|
+
const ids = new Set<string>();
|
|
111
|
+
const variants: ModelVariant[] = [];
|
|
112
|
+
for (const [index, original] of catalog.models.entries()) {
|
|
113
|
+
if (ids.has(original.id)) return yield* failWith(`Duplicate model UID: ${original.id}`);
|
|
114
|
+
ids.add(original.id);
|
|
115
|
+
const config = yield* object(configs[index], "model config"), info = yield* object(config.modelInfo, "modelInfo");
|
|
116
|
+
if (config.modelUid !== original.id) return yield* failWith("Model UIDs do not match the raw order.");
|
|
117
|
+
const allDimensions = yield* dimensionsOf(config);
|
|
118
|
+
const family = yield* object(config.modelFamilyMetadata ?? {}, "modelFamilyMetadata");
|
|
119
|
+
const model = { ...original, aliases: [...(original.aliases ?? [])], contextWindow: yield* tokenCount(config.maxTokens ?? original.contextWindow, "context window") };
|
|
120
|
+
const familyName = Predicate.isString(family.modelFamilyLabel) && family.modelFamilyLabel ? family.modelFamilyLabel : model.familyId || model.name;
|
|
121
|
+
const classification = Predicate.isString(info.modelType) && info.modelType !== "MODEL_TYPE_CHAT"
|
|
122
|
+
? { unsupported: "not a chat model" } : classify(model, allDimensions);
|
|
123
|
+
variants.push({
|
|
124
|
+
model, family: model.familyId || model.id, familyName,
|
|
125
|
+
dimensions: allDimensions.filter((entry) => !thinkingDimensions.has(entry.key)), ...classification,
|
|
126
|
+
config: sanitizedConfig(info, model, familyName, allDimensions),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
const groupMap = new Map<string, VariantGroup>();
|
|
130
|
+
for (const variant of variants) {
|
|
131
|
+
const id = groupModelID(variant.family, variant.dimensions);
|
|
132
|
+
let group = groupMap.get(id);
|
|
133
|
+
if (!group) {
|
|
134
|
+
group = { id, family: variant.family, name: variant.familyName, dimensions: variant.dimensions, variants: [], mappings: {}, canPublish: true };
|
|
135
|
+
groupMap.set(id, group);
|
|
136
|
+
}
|
|
137
|
+
group.variants.push(variant);
|
|
138
|
+
if (variant.level === undefined || variant.unsupported) continue;
|
|
139
|
+
if (group.mappings[variant.level]) { group.canPublish = false; group.reason = "ambiguous UID for thinking level"; }
|
|
140
|
+
else group.mappings[variant.level] = variant.model.id;
|
|
141
|
+
}
|
|
142
|
+
for (const group of groupMap.values()) {
|
|
143
|
+
const supported = group.variants.filter((variant) => variant.level !== undefined && !variant.unsupported);
|
|
144
|
+
if (!supported.length) { group.canPublish = false; group.reason = "no verified thinking mapping"; }
|
|
145
|
+
else if (new Set(supported.map((variant) => capabilityKey(variant.model))).size !== 1) {
|
|
146
|
+
group.canPublish = false; group.reason = "variant capabilities differ; use exact UID";
|
|
147
|
+
}
|
|
148
|
+
}
|
|
116
149
|
return {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
config: sanitizedConfig(config, model, familyName, allDimensions),
|
|
150
|
+
variants, groups: [...groupMap.values()],
|
|
151
|
+
unsupported: variants.flatMap((variant) => variant.unsupported ? [{ uid: variant.model.id, reason: variant.unsupported }] : []),
|
|
120
152
|
};
|
|
121
153
|
});
|
|
122
|
-
const groupMap = new Map<string, VariantGroup>();
|
|
123
|
-
for (const variant of variants) {
|
|
124
|
-
const id = groupModelID(variant.family, variant.dimensions);
|
|
125
|
-
let group = groupMap.get(id);
|
|
126
|
-
if (!group) {
|
|
127
|
-
group = { id, family: variant.family, name: variant.familyName, dimensions: variant.dimensions, variants: [], mappings: {}, canPublish: true };
|
|
128
|
-
groupMap.set(id, group);
|
|
129
|
-
}
|
|
130
|
-
group.variants.push(variant);
|
|
131
|
-
if (variant.level === undefined || variant.unsupported) continue;
|
|
132
|
-
if (group.mappings[variant.level]) { group.canPublish = false; group.reason = "ambiguous UID for thinking level"; }
|
|
133
|
-
else group.mappings[variant.level] = variant.model.id;
|
|
134
|
-
}
|
|
135
|
-
for (const group of groupMap.values()) {
|
|
136
|
-
const supported = group.variants.filter((variant) => variant.level !== undefined && !variant.unsupported);
|
|
137
|
-
if (!supported.length) { group.canPublish = false; group.reason = "no verified thinking mapping"; }
|
|
138
|
-
else if (new Set(supported.map((variant) => capabilityKey(variant.model))).size !== 1) {
|
|
139
|
-
group.canPublish = false; group.reason = "variant capabilities differ; use exact UID";
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
return { variants, groups: [...groupMap.values()], unsupported: variants.filter((variant) => variant.unsupported).map((variant) => ({ uid: variant.model.id, reason: variant.unsupported! })) };
|
|
143
154
|
}
|
|
144
155
|
|
|
145
|
-
export function restoreModelVariants(configs: readonly JsonObject[]): VariantProjection {
|
|
146
|
-
return
|
|
156
|
+
export function restoreModelVariants(configs: readonly JsonObject[]): Effect.Effect<VariantProjection, DevinError> {
|
|
157
|
+
return Effect.flatMap(parseModelCatalog({ clientModelConfigs: [...configs] }), projectModelVariants);
|
|
147
158
|
}
|
|
148
159
|
|
|
149
160
|
/** Accepts only the effective level that Pi already clamped or selected. Never falls back to an alias, price or default model. */
|
|
150
|
-
export function resolveBackendSelection(projection: VariantProjection, modelID: string, level: EffectiveThinkingLevel): BackendModelSelection {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (variant
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
161
|
+
export function resolveBackendSelection(projection: VariantProjection, modelID: string, level: EffectiveThinkingLevel): Effect.Effect<BackendModelSelection, DevinError> {
|
|
162
|
+
return Effect.gen(function* () {
|
|
163
|
+
if (!EFFECTIVE_THINKING_LEVELS.includes(level)) return yield* failWith(`Unsupported thinking level: ${level}`);
|
|
164
|
+
let variant = projection.variants.find((entry) => modelIDForUID(entry.model.id) === modelID);
|
|
165
|
+
if (variant) {
|
|
166
|
+
if (variant.unsupported || variant.level !== level) return yield* failWith(`This exact UID does not support the requested thinking level: ${modelID} (${level})`);
|
|
167
|
+
} else {
|
|
168
|
+
const group = projection.groups.find((entry) => entry.id === modelID);
|
|
169
|
+
const uid = group?.canPublish ? group.mappings[level] : undefined;
|
|
170
|
+
variant = uid === undefined ? undefined : projection.variants.find((entry) => entry.model.id === uid);
|
|
171
|
+
if (!variant) return yield* failWith(`Model or thinking level unavailable: ${modelID} (${level})`);
|
|
172
|
+
}
|
|
173
|
+
const model = yield* getModelByUID(projection.variants.map((entry) => entry.model), variant.model.id);
|
|
174
|
+
return { uid: model.id, model, level, variant };
|
|
175
|
+
});
|
|
163
176
|
}
|
|
@@ -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
|
-
|
|
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) =>
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
const match =
|
|
59
|
-
if (match) serial = JSON.parse(match[1]) as string;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
+
}
|