@100xprompt/brain 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +48 -0
- package/README.md +235 -2
- package/package.json +48 -4
- package/src/adapters/aider/index.ts +8 -0
- package/src/adapters/aider/reader.ts +129 -0
- package/src/adapters/aider/records.ts +220 -0
- package/src/adapters/aider/writer.ts +269 -0
- package/src/adapters/amazon-q-cli/index.ts +8 -0
- package/src/adapters/amazon-q-cli/reader.ts +84 -0
- package/src/adapters/amazon-q-cli/records.ts +191 -0
- package/src/adapters/amazon-q-cli/writer.ts +370 -0
- package/src/adapters/amazon-q-ide/index.ts +9 -0
- package/src/adapters/amazon-q-ide/reader.ts +131 -0
- package/src/adapters/amazon-q-ide/records.ts +141 -0
- package/src/adapters/amazon-q-ide/writer.ts +325 -0
- package/src/adapters/antigravity/index.ts +9 -0
- package/src/adapters/antigravity/proto.ts +194 -0
- package/src/adapters/antigravity/reader.ts +424 -0
- package/src/adapters/antigravity/records.ts +236 -0
- package/src/adapters/antigravity/writer.ts +463 -0
- package/src/adapters/auggie/index.ts +36 -0
- package/src/adapters/claude-code/index.ts +8 -0
- package/src/adapters/claude-code/reader.ts +639 -0
- package/src/adapters/claude-code/records.ts +88 -0
- package/src/adapters/claude-code/writer.ts +329 -0
- package/src/adapters/cline/index.ts +12 -0
- package/src/adapters/cline/reader.ts +432 -0
- package/src/adapters/cline/records.ts +151 -0
- package/src/adapters/cline/vscdb.ts +56 -0
- package/src/adapters/cline/writer.ts +656 -0
- package/src/adapters/cline/xml.ts +177 -0
- package/src/adapters/cline4/index.ts +9 -0
- package/src/adapters/cline4/reader.ts +179 -0
- package/src/adapters/cline4/records.ts +92 -0
- package/src/adapters/cline4/writer.ts +361 -0
- package/src/adapters/codebuff/index.ts +8 -0
- package/src/adapters/codebuff/project.ts +207 -0
- package/src/adapters/codebuff/reader.ts +215 -0
- package/src/adapters/codebuff/records.ts +173 -0
- package/src/adapters/codebuff/writer.ts +383 -0
- package/src/adapters/codex/index.ts +8 -0
- package/src/adapters/codex/native.ts +239 -0
- package/src/adapters/codex/reader.ts +747 -0
- package/src/adapters/codex/records.ts +114 -0
- package/src/adapters/codex/writer.ts +479 -0
- package/src/adapters/continue/index.ts +9 -0
- package/src/adapters/continue/reader.ts +68 -0
- package/src/adapters/continue/records.ts +418 -0
- package/src/adapters/continue/writer.ts +162 -0
- package/src/adapters/copilot-cli/index.ts +9 -0
- package/src/adapters/copilot-cli/reader.ts +186 -0
- package/src/adapters/copilot-cli/records.ts +241 -0
- package/src/adapters/copilot-cli/writer.ts +353 -0
- package/src/adapters/crush/index.ts +8 -0
- package/src/adapters/crush/reader.ts +180 -0
- package/src/adapters/crush/records.ts +169 -0
- package/src/adapters/crush/writer.ts +333 -0
- package/src/adapters/cursor-cli/index.ts +405 -0
- package/src/adapters/cursor-ide/agentkv.ts +95 -0
- package/src/adapters/cursor-ide/index.ts +501 -0
- package/src/adapters/cursor-ide/transcripts.ts +122 -0
- package/src/adapters/cursor-ide/workspaces.ts +119 -0
- package/src/adapters/cursor-ide/writer.ts +170 -0
- package/src/adapters/devin/index.ts +8 -0
- package/src/adapters/devin/reader.ts +305 -0
- package/src/adapters/devin/records.ts +277 -0
- package/src/adapters/devin/writer.ts +523 -0
- package/src/adapters/factory-droid/index.ts +12 -0
- package/src/adapters/factory-droid/reader.ts +184 -0
- package/src/adapters/factory-droid/records.ts +79 -0
- package/src/adapters/factory-droid/writer.ts +350 -0
- package/src/adapters/gemini-cli/exchanges.ts +228 -0
- package/src/adapters/gemini-cli/index.ts +396 -0
- package/src/adapters/gemini-cli/records.ts +521 -0
- package/src/adapters/gemini-cli/writer.ts +354 -0
- package/src/adapters/goose/index.ts +8 -0
- package/src/adapters/goose/reader.ts +138 -0
- package/src/adapters/goose/records.ts +199 -0
- package/src/adapters/goose/writer.ts +340 -0
- package/src/adapters/grok-cli/image.ts +44 -0
- package/src/adapters/grok-cli/index.ts +8 -0
- package/src/adapters/grok-cli/reader.ts +302 -0
- package/src/adapters/grok-cli/records.ts +172 -0
- package/src/adapters/grok-cli/writer.ts +329 -0
- package/src/adapters/hermes/index.ts +8 -0
- package/src/adapters/hermes/reader.ts +249 -0
- package/src/adapters/hermes/records.ts +285 -0
- package/src/adapters/hermes/writer.ts +452 -0
- package/src/adapters/iflow-cli/index.ts +72 -0
- package/src/adapters/iflow-cli/records.ts +215 -0
- package/src/adapters/iflow-cli/writer.ts +204 -0
- package/src/adapters/index.ts +112 -0
- package/src/adapters/kilo-cli/index.ts +21 -0
- package/src/adapters/kilo-code/index.ts +11 -0
- package/src/adapters/kilo-code/writer.ts +92 -0
- package/src/adapters/kimi/index.ts +9 -0
- package/src/adapters/kimi/reader.ts +92 -0
- package/src/adapters/kimi/records.ts +221 -0
- package/src/adapters/kimi/writer.ts +367 -0
- package/src/adapters/kiro-cli/index.ts +8 -0
- package/src/adapters/kiro-cli/reader.ts +87 -0
- package/src/adapters/kiro-cli/records.ts +260 -0
- package/src/adapters/kiro-cli/writer.ts +305 -0
- package/src/adapters/kiro-ide/index.ts +8 -0
- package/src/adapters/kiro-ide/writer.ts +52 -0
- package/src/adapters/letta/index.ts +8 -0
- package/src/adapters/letta/reader.ts +177 -0
- package/src/adapters/letta/records.ts +119 -0
- package/src/adapters/letta/writer.ts +413 -0
- package/src/adapters/mastracode/index.ts +8 -0
- package/src/adapters/mastracode/reader.ts +323 -0
- package/src/adapters/mastracode/records.ts +336 -0
- package/src/adapters/mastracode/writer.ts +385 -0
- package/src/adapters/mistral-vibe/index.ts +9 -0
- package/src/adapters/mistral-vibe/reader.ts +62 -0
- package/src/adapters/mistral-vibe/records.ts +104 -0
- package/src/adapters/mistral-vibe/writer.ts +359 -0
- package/src/adapters/muse/index.ts +10 -0
- package/src/adapters/muse/reader.ts +332 -0
- package/src/adapters/muse/records.ts +104 -0
- package/src/adapters/muse/writer.ts +387 -0
- package/src/adapters/omp/index.ts +13 -0
- package/src/adapters/omp/reader.ts +314 -0
- package/src/adapters/omp/records.ts +153 -0
- package/src/adapters/omp/writer.ts +216 -0
- package/src/adapters/opencode/ids.ts +52 -0
- package/src/adapters/opencode/index.ts +31 -0
- package/src/adapters/opencode/note.ts +8 -0
- package/src/adapters/opencode/reader.ts +452 -0
- package/src/adapters/opencode/tools.ts +174 -0
- package/src/adapters/opencode/v2.ts +367 -0
- package/src/adapters/opencode/writer.ts +379 -0
- package/src/adapters/openhands/index.ts +8 -0
- package/src/adapters/openhands/reader.ts +222 -0
- package/src/adapters/openhands/records.ts +136 -0
- package/src/adapters/openhands/writer.ts +404 -0
- package/src/adapters/pearai/index.ts +10 -0
- package/src/adapters/pi/index.ts +28 -0
- package/src/adapters/pi/reader.ts +261 -0
- package/src/adapters/pi/records.ts +99 -0
- package/src/adapters/pi/writer.ts +362 -0
- package/src/adapters/qoder-cli/index.ts +8 -0
- package/src/adapters/qoder-cli/reader.ts +83 -0
- package/src/adapters/qoder-cli/records.ts +45 -0
- package/src/adapters/qoder-cli/writer.ts +72 -0
- package/src/adapters/qwen-code/index.ts +84 -0
- package/src/adapters/qwen-code/records.ts +194 -0
- package/src/adapters/qwen-code/writer.ts +224 -0
- package/src/adapters/tabnine-cli/index.ts +8 -0
- package/src/adapters/tabnine-cli/reader.ts +106 -0
- package/src/adapters/tabnine-cli/records.ts +33 -0
- package/src/adapters/tabnine-cli/writer.ts +156 -0
- package/src/adapters/types.ts +73 -0
- package/src/adapters/void/index.ts +9 -0
- package/src/adapters/void/reader.ts +48 -0
- package/src/adapters/void/records.ts +285 -0
- package/src/adapters/void/writer.ts +93 -0
- package/src/adapters/vscode-copilot/index.ts +9 -0
- package/src/adapters/vscode-copilot/reader.ts +212 -0
- package/src/adapters/vscode-copilot/records.ts +388 -0
- package/src/adapters/vscode-copilot/shared.ts +68 -0
- package/src/adapters/vscode-copilot/writer.ts +546 -0
- package/src/adapters/zed/index.ts +8 -0
- package/src/adapters/zed/reader.ts +198 -0
- package/src/adapters/zed/records.ts +87 -0
- package/src/adapters/zed/writer.ts +341 -0
- package/src/brief-writers.ts +71 -0
- package/src/brief.ts +238 -0
- package/src/cli.ts +351 -0
- package/src/dashboard/cli.ts +148 -0
- package/src/dashboard/conversions.ts +170 -0
- package/src/dashboard/report.ts +261 -0
- package/src/dashboard/send.ts +213 -0
- package/src/dashboard/state.ts +95 -0
- package/src/fsutil.ts +133 -0
- package/src/gateway/common.ts +232 -0
- package/src/gateway/connect.ts +847 -0
- package/src/gateway/connectors/aider.ts +356 -0
- package/src/gateway/connectors/amazon-q-cli.ts +6 -0
- package/src/gateway/connectors/amazon-q-ide.ts +6 -0
- package/src/gateway/connectors/antigravity.ts +6 -0
- package/src/gateway/connectors/auggie.ts +6 -0
- package/src/gateway/connectors/cline-sdk.ts +85 -0
- package/src/gateway/connectors/cline.ts +34 -0
- package/src/gateway/connectors/codebuff.ts +6 -0
- package/src/gateway/connectors/continue.ts +579 -0
- package/src/gateway/connectors/copilot-cli.ts +138 -0
- package/src/gateway/connectors/crush.ts +313 -0
- package/src/gateway/connectors/cursor-cli.ts +6 -0
- package/src/gateway/connectors/cursor.ts +6 -0
- package/src/gateway/connectors/devin.ts +6 -0
- package/src/gateway/connectors/factory-droid.ts +308 -0
- package/src/gateway/connectors/goose.ts +79 -0
- package/src/gateway/connectors/grok-cli.ts +82 -0
- package/src/gateway/connectors/hermes.ts +230 -0
- package/src/gateway/connectors/iflow-cli.ts +50 -0
- package/src/gateway/connectors/index.ts +86 -0
- package/src/gateway/connectors/kilo-cli.ts +434 -0
- package/src/gateway/connectors/kilo-code.ts +32 -0
- package/src/gateway/connectors/kimi-code.ts +241 -0
- package/src/gateway/connectors/kiro-cli.ts +6 -0
- package/src/gateway/connectors/kiro-ide.ts +6 -0
- package/src/gateway/connectors/letta-code.ts +170 -0
- package/src/gateway/connectors/mastracode.ts +91 -0
- package/src/gateway/connectors/mistral-vibe.ts +495 -0
- package/src/gateway/connectors/muse.ts +27 -0
- package/src/gateway/connectors/omp.ts +279 -0
- package/src/gateway/connectors/openhands.ts +296 -0
- package/src/gateway/connectors/pearai.ts +52 -0
- package/src/gateway/connectors/pi.ts +357 -0
- package/src/gateway/connectors/qoder-cli.ts +6 -0
- package/src/gateway/connectors/qwen-code.ts +411 -0
- package/src/gateway/connectors/roo.ts +43 -0
- package/src/gateway/connectors/tabnine-cli.ts +6 -0
- package/src/gateway/connectors/void.ts +6 -0
- package/src/gateway/connectors/vscode-copilot.ts +286 -0
- package/src/gateway/connectors/windsurf.ts +6 -0
- package/src/gateway/connectors/zed.ts +29 -0
- package/src/gateway/signin.ts +323 -0
- package/src/handoff.ts +104 -0
- package/src/hooks.ts +455 -0
- package/src/memory/audit.ts +514 -0
- package/src/memory/blobs.ts +94 -0
- package/src/memory/cli.ts +117 -0
- package/src/memory/distill.ts +381 -0
- package/src/memory/externalize.ts +435 -0
- package/src/memory/lookups.ts +70 -0
- package/src/memory/mcp-install.ts +401 -0
- package/src/memory/mcp-server.ts +296 -0
- package/src/memory/prepare.ts +179 -0
- package/src/memory/preview.ts +154 -0
- package/src/memory/project.ts +35 -0
- package/src/memory/redact.ts +72 -0
- package/src/memory/relevance.ts +187 -0
- package/src/memory/repomap.ts +516 -0
- package/src/memory/store.ts +270 -0
- package/src/memory/sync.ts +166 -0
- package/src/memory/tidy.ts +193 -0
- package/src/memory/types.ts +118 -0
- package/src/org/admin-ops.ts +96 -0
- package/src/org/auth.ts +112 -0
- package/src/org/backup.ts +115 -0
- package/src/org/cli.ts +438 -0
- package/src/org/client.ts +404 -0
- package/src/org/config.ts +44 -0
- package/src/org/crypto.ts +537 -0
- package/src/org/db.ts +435 -0
- package/src/org/graph.ts +318 -0
- package/src/org/oidc.ts +435 -0
- package/src/org/policy.ts +398 -0
- package/src/org/server.ts +920 -0
- package/src/org/sso.ts +316 -0
- package/src/org/tls.ts +66 -0
- package/src/org/types.ts +193 -0
- package/src/self.ts +68 -0
- package/src/session/attachments.ts +53 -0
- package/src/session/peekcache.ts +50 -0
- package/src/session/types.ts +104 -0
- package/src/session/util.ts +130 -0
- package/src/sqlite.ts +48 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// Gateway connector for GitHub Copilot Chat in VS Code (checked 2026-11-10 against VS Code 1.144.0 and the Copilot Chat
|
|
2
|
+
// extension 0.72.0 built into it: /Applications/Visual Studio Code.app/Contents/Resources/app/extensions/copilot).
|
|
3
|
+
//
|
|
4
|
+
// Bring-your-own-key models live in the VS Code user folder, in chatLanguageModels.json (out/vs/workbench/
|
|
5
|
+
// workbench.desktop.main.js: profile `languageModelsResource` = <User>/chatLanguageModels.json, a JSON array of provider
|
|
6
|
+
// groups `{ name, vendor, ...settings }` that VS Code watches and parses as JSON with comments). The Copilot extension's
|
|
7
|
+
// "Custom Endpoint" provider (vendor `customendpoint`, package.json contributes.languageModelChatProviders; in Stable since
|
|
8
|
+
// VS Code 1.122, https://code.visualstudio.com/updates/v1_122) takes per model an `id`, `name`, `url`, `apiType`
|
|
9
|
+
// (chat-completions | responses | messages) and `requestHeaders`.
|
|
10
|
+
//
|
|
11
|
+
// The key. A group's `apiKey` is a secret: VS Code keeps only `${input:chat.lm.secret.<n>}` in the file and the value in
|
|
12
|
+
// its secret storage (workbench: _resolveConfiguration / _resolveLanguageModelProviderGroup), which brain cannot write.
|
|
13
|
+
// But the Custom Endpoint provider lets a model's `requestHeaders` carry the authorization itself: dist/extension.js class
|
|
14
|
+
// for `CustomEndpoint` endpoints overrides _isReservedHeader so `authorization` and `api-key` are allowed
|
|
15
|
+
// (_overridableReservedAuthHeaders), and skips its own `Authorization: Bearer <apiKey>` when the model sets one
|
|
16
|
+
// (_userAuthHeaderSuppressionSet). So brain adds one group, "100xprompt gateway", with no apiKey and a model whose
|
|
17
|
+
// requestHeaders hold `Authorization: Bearer <key>` (and `x-100x-context-edit: on`): the key is copied into the file, which
|
|
18
|
+
// is made private (0600). chatLanguageModels.json is not part of Settings Sync (sync resources: settings, keybindings,
|
|
19
|
+
// snippets, prompts, tasks, extensions, globalState, profiles, mcp), so the copy stays on this machine.
|
|
20
|
+
//
|
|
21
|
+
// What brain does not set: the model picked in Copilot Chat. VS Code remembers that in its own storage; the
|
|
22
|
+
// `chat.defaultModel` setting matches models by their id alone (workbench: function resolving it compares metadata.id),
|
|
23
|
+
// and the gateway's model id (gemini-3.8-flash) could equally name a model Copilot serves itself, so the person picks
|
|
24
|
+
// "100xprompt Pro" in the model picker once (the notes say so). The model list is given in the file, so the gateway's
|
|
25
|
+
// /v1/models (403) is never asked (CustomEndpoint.getAllModels reads `models` unless the group has a `url`).
|
|
26
|
+
//
|
|
27
|
+
// Only the default VS Code profile and VS Code Stable ("Code") are handled; Insiders, VSCodium and other profiles keep
|
|
28
|
+
// their own User folders.
|
|
29
|
+
import { join, resolve } from "node:path"
|
|
30
|
+
import { type Codec, type Connector, EDIT_HEADER, type Env, type Json, modelName, readKey, routeUrl } from "../common.ts"
|
|
31
|
+
|
|
32
|
+
const GROUP = "100xprompt gateway"
|
|
33
|
+
const VENDOR = "customendpoint"
|
|
34
|
+
/** Gemini 3.x Flash, which the gateway's models are: a 1M-token window, 64K out. */
|
|
35
|
+
const MAX_OUTPUT = 65_536
|
|
36
|
+
const MAX_INPUT = 1_048_576 - MAX_OUTPUT
|
|
37
|
+
|
|
38
|
+
/** VS Code's user folder: ~/Library/Application Support/Code/User on macOS, $XDG_CONFIG_HOME/Code/User or ~/.config/Code/User elsewhere. */
|
|
39
|
+
export function vscodeUserDir(home: string, env: Env, platform: string = process.platform): string {
|
|
40
|
+
if (platform === "darwin") return join(home, "Library", "Application Support", "Code", "User")
|
|
41
|
+
if (platform === "win32") return join(env.APPDATA ?? join(home, "AppData", "Roaming"), "Code", "User")
|
|
42
|
+
// XDG_CONFIG_HOME describes the real home: followed only when brain works in that home.
|
|
43
|
+
const xdg = env.XDG_CONFIG_HOME?.startsWith("/") && env.HOME && resolve(home) === resolve(env.HOME) ? env.XDG_CONFIG_HOME : join(home, ".config")
|
|
44
|
+
return join(xdg, "Code", "User")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---- JSON with comments, with the position of every value -------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
type Node =
|
|
50
|
+
| { kind: "object"; start: number; end: number; members: Array<{ key: string; value: Node }> }
|
|
51
|
+
| { kind: "array"; start: number; end: number; items: Node[] }
|
|
52
|
+
| { kind: "scalar"; start: number; end: number; value: unknown }
|
|
53
|
+
|
|
54
|
+
/** Parses JSON with // and /* comments and trailing commas (what VS Code accepts in its JSON files); throws otherwise. */
|
|
55
|
+
export function parseJsonc(text: string): Node | undefined {
|
|
56
|
+
let i = 0
|
|
57
|
+
const fail = (what: string): never => {
|
|
58
|
+
throw new Error(`${what} at offset ${i}`)
|
|
59
|
+
}
|
|
60
|
+
const skip = () => {
|
|
61
|
+
for (;;) {
|
|
62
|
+
while (i < text.length && /\s/.test(text[i]!)) i++
|
|
63
|
+
if (text.startsWith("//", i)) {
|
|
64
|
+
const nl = text.indexOf("\n", i)
|
|
65
|
+
i = nl < 0 ? text.length : nl + 1
|
|
66
|
+
} else if (text.startsWith("/*", i)) {
|
|
67
|
+
const end = text.indexOf("*/", i + 2)
|
|
68
|
+
if (end < 0) fail("unclosed comment")
|
|
69
|
+
i = end + 2
|
|
70
|
+
} else return
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const string = (): string => {
|
|
74
|
+
const start = i
|
|
75
|
+
i++
|
|
76
|
+
while (i < text.length && text[i] !== '"') i += text[i] === "\\" ? 2 : 1
|
|
77
|
+
if (i >= text.length) fail("unclosed string")
|
|
78
|
+
i++
|
|
79
|
+
return JSON.parse(text.slice(start, i)) as string
|
|
80
|
+
}
|
|
81
|
+
const value = (): Node => {
|
|
82
|
+
skip()
|
|
83
|
+
const start = i
|
|
84
|
+
const c = text[i]
|
|
85
|
+
if (c === "{") {
|
|
86
|
+
i++
|
|
87
|
+
const members: Array<{ key: string; value: Node }> = []
|
|
88
|
+
for (;;) {
|
|
89
|
+
skip()
|
|
90
|
+
if (text[i] === "}") break
|
|
91
|
+
if (text[i] !== '"') fail("expected a key")
|
|
92
|
+
const key = string()
|
|
93
|
+
skip()
|
|
94
|
+
if (text[i] !== ":") fail("expected :")
|
|
95
|
+
i++
|
|
96
|
+
members.push({ key, value: value() })
|
|
97
|
+
skip()
|
|
98
|
+
if (text[i] === ",") i++
|
|
99
|
+
else if (text[i] !== "}") fail("expected , or }")
|
|
100
|
+
}
|
|
101
|
+
i++
|
|
102
|
+
return { kind: "object", start, end: i, members }
|
|
103
|
+
}
|
|
104
|
+
if (c === "[") {
|
|
105
|
+
i++
|
|
106
|
+
const items: Node[] = []
|
|
107
|
+
for (;;) {
|
|
108
|
+
skip()
|
|
109
|
+
if (text[i] === "]") break
|
|
110
|
+
items.push(value())
|
|
111
|
+
skip()
|
|
112
|
+
if (text[i] === ",") i++
|
|
113
|
+
else if (text[i] !== "]") fail("expected , or ]")
|
|
114
|
+
}
|
|
115
|
+
i++
|
|
116
|
+
return { kind: "array", start, end: i, items }
|
|
117
|
+
}
|
|
118
|
+
if (c === '"') {
|
|
119
|
+
const v = string()
|
|
120
|
+
return { kind: "scalar", start, end: i, value: v }
|
|
121
|
+
}
|
|
122
|
+
const m = /^(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/.exec(text.slice(i, i + 400))
|
|
123
|
+
if (!m) fail("unexpected character")
|
|
124
|
+
i += m![0].length
|
|
125
|
+
return { kind: "scalar", start, end: i, value: JSON.parse(m![0]) }
|
|
126
|
+
}
|
|
127
|
+
skip()
|
|
128
|
+
if (i >= text.length) return undefined
|
|
129
|
+
const root = value()
|
|
130
|
+
skip()
|
|
131
|
+
if (i < text.length) fail("text after the end")
|
|
132
|
+
return root
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function plain(n: Node): unknown {
|
|
136
|
+
if (n.kind === "scalar") return n.value
|
|
137
|
+
if (n.kind === "array") return n.items.map(plain)
|
|
138
|
+
const o: Json = {}
|
|
139
|
+
for (const m of n.members) o[m.key] = plain(m.value)
|
|
140
|
+
return o
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The first `,` between from and to that is not inside a comment, or -1. */
|
|
144
|
+
function commaBetween(text: string, from: number, to: number): number {
|
|
145
|
+
for (let i = from; i < to; i++) {
|
|
146
|
+
if (text.startsWith("//", i)) {
|
|
147
|
+
const nl = text.indexOf("\n", i)
|
|
148
|
+
i = nl < 0 ? to : nl
|
|
149
|
+
} else if (text.startsWith("/*", i)) i = text.indexOf("*/", i + 2) + 1
|
|
150
|
+
else if (text[i] === ",") return i
|
|
151
|
+
}
|
|
152
|
+
return -1
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The whitespace a value at `at` is indented by, when it starts its line; undefined when something precedes it there. */
|
|
156
|
+
function lineIndent(text: string, at: number): string | undefined {
|
|
157
|
+
const lineStart = text.lastIndexOf("\n", at - 1) + 1
|
|
158
|
+
const before = text.slice(lineStart, at)
|
|
159
|
+
return /^[ \t]*$/.test(before) ? before : undefined
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const indentUnit = (text: string) => text.match(/\n([ \t]+)[^\s]/)?.[1]?.replace(/^(\t| {2,4}).*$/, "$1") ?? "\t"
|
|
163
|
+
const render = (v: unknown, indent: string, unit: string) => JSON.stringify(v, null, unit).replace(/\n/g, `\n${indent}`)
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* chatLanguageModels.json: a JSON-with-comments array of model groups. A key names one Custom Endpoint group (its `name`);
|
|
167
|
+
* set adds, replaces or removes that one array element and leaves every other byte as it was, so removing a group this
|
|
168
|
+
* codec added gives the file back exactly. A file that does not parse as such an array is refused.
|
|
169
|
+
*/
|
|
170
|
+
export const languageModelGroups: Codec = {
|
|
171
|
+
check(text) {
|
|
172
|
+
try {
|
|
173
|
+
const root = parseJsonc(text)
|
|
174
|
+
if (root && root.kind !== "array") return "it is not a list of model groups; left as it is"
|
|
175
|
+
} catch {
|
|
176
|
+
return "it is not valid JSON (with comments); left as it is"
|
|
177
|
+
}
|
|
178
|
+
return undefined
|
|
179
|
+
},
|
|
180
|
+
get(text, key) {
|
|
181
|
+
const root = parseJsonc(text)
|
|
182
|
+
if (root?.kind !== "array") return undefined
|
|
183
|
+
const el = findGroup(root, key)
|
|
184
|
+
return el ? plain(el) : undefined
|
|
185
|
+
},
|
|
186
|
+
set(text, key, value) {
|
|
187
|
+
const root = parseJsonc(text)
|
|
188
|
+
const unit = indentUnit(text)
|
|
189
|
+
if (!root) return value === undefined ? text : `[\n${unit}${render(value, unit, unit)}\n]\n`
|
|
190
|
+
if (root.kind !== "array") throw new Error("not a list of model groups")
|
|
191
|
+
const el = findGroup(root, key)
|
|
192
|
+
if (el && value !== undefined) return text.slice(0, el.start) + render(value, lineIndent(text, el.start) ?? unit, unit) + text.slice(el.end)
|
|
193
|
+
if (!el && value === undefined) return text
|
|
194
|
+
if (!el) {
|
|
195
|
+
const last = root.items[root.items.length - 1]
|
|
196
|
+
if (!last) {
|
|
197
|
+
// An empty list: `[]` (VS Code's own empty file) becomes one element on its own lines, closing at the list's indent.
|
|
198
|
+
const close = lineIndent(text, root.end - 1) ?? ""
|
|
199
|
+
const inner = text.slice(root.start + 1, root.end - 1)
|
|
200
|
+
const body = inner.trim() ? `${inner.replace(/\s*$/, "")}\n${close}${unit}` : `\n${close}${unit}`
|
|
201
|
+
return `${text.slice(0, root.start + 1)}${body}${render(value, close + unit, unit)}\n${close}${text.slice(root.end - 1)}`
|
|
202
|
+
}
|
|
203
|
+
const indent = lineIndent(text, last.start) ?? unit
|
|
204
|
+
return `${text.slice(0, last.end)},\n${indent}${render(value, indent, unit)}${text.slice(last.end)}`
|
|
205
|
+
}
|
|
206
|
+
// Remove the element with the comma that joins it to its neighbour.
|
|
207
|
+
const at = root.items.indexOf(el)
|
|
208
|
+
const prev = root.items[at - 1]
|
|
209
|
+
const next = root.items[at + 1]
|
|
210
|
+
let out: string
|
|
211
|
+
if (prev) {
|
|
212
|
+
const gap = text.slice(prev.end, el.start)
|
|
213
|
+
if (/^\s*,\s*$/.test(gap)) out = text.slice(0, prev.end) + text.slice(el.end)
|
|
214
|
+
else {
|
|
215
|
+
// Comments between the two: keep them, drop the comma before this element and the element's own line.
|
|
216
|
+
const comma = commaBetween(text, prev.end, el.start)
|
|
217
|
+
const from = lineIndent(text, el.start) !== undefined ? text.lastIndexOf("\n", el.start - 1) : el.start
|
|
218
|
+
out = text.slice(0, comma) + text.slice(comma + 1, from) + text.slice(el.end)
|
|
219
|
+
}
|
|
220
|
+
} else if (next) {
|
|
221
|
+
const comma = commaBetween(text, el.end, next.start)
|
|
222
|
+
out = text.slice(0, el.start) + (/^\s*,\s*$/.test(text.slice(el.end, next.start)) ? text.slice(next.start) : text.slice(comma + 1).replace(/^[ \t]*\n?[ \t]*/, ""))
|
|
223
|
+
} else {
|
|
224
|
+
const inner = text.slice(root.start + 1, el.start) + text.slice(el.end, root.end - 1)
|
|
225
|
+
out = text.slice(0, root.start + 1) + (inner.trim() ? inner : "") + text.slice(root.end - 1)
|
|
226
|
+
}
|
|
227
|
+
// A file this codec created (one group, "[...]\n") ends empty, so disconnect removes it; VS Code's own "[]" stays.
|
|
228
|
+
return out === "[]\n" ? "" : out
|
|
229
|
+
},
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function findGroup(root: Extract<Node, { kind: "array" }>, name: string) {
|
|
233
|
+
let found: Node | undefined
|
|
234
|
+
for (const it of root.items) {
|
|
235
|
+
if (it.kind !== "object") continue
|
|
236
|
+
const v = plain(it) as Json
|
|
237
|
+
if (v.name === name && v.vendor === VENDOR) found = it
|
|
238
|
+
}
|
|
239
|
+
return found
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---- the connector ------------------------------------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
export const connectors: Connector[] = [
|
|
245
|
+
{
|
|
246
|
+
id: "vscode-copilot",
|
|
247
|
+
label: "Copilot Chat (VS Code)",
|
|
248
|
+
file: (home, env) => join(vscodeUserDir(home, env), "chatLanguageModels.json"),
|
|
249
|
+
probe: [],
|
|
250
|
+
authHeader: "authorization",
|
|
251
|
+
edits: (home, s, env) => [
|
|
252
|
+
{
|
|
253
|
+
path: join(vscodeUserDir(home, env), "chatLanguageModels.json"),
|
|
254
|
+
format: "custom",
|
|
255
|
+
codec: languageModelGroups,
|
|
256
|
+
holdsKey: true,
|
|
257
|
+
secretKeys: [GROUP],
|
|
258
|
+
values: {
|
|
259
|
+
[GROUP]: {
|
|
260
|
+
name: GROUP,
|
|
261
|
+
vendor: VENDOR,
|
|
262
|
+
apiType: "chat-completions",
|
|
263
|
+
models: [
|
|
264
|
+
{
|
|
265
|
+
id: s.model,
|
|
266
|
+
name: modelName(s.model),
|
|
267
|
+
url: `${routeUrl(s)}/v1/chat/completions`,
|
|
268
|
+
apiType: "chat-completions",
|
|
269
|
+
toolCalling: true,
|
|
270
|
+
vision: true,
|
|
271
|
+
maxInputTokens: MAX_INPUT,
|
|
272
|
+
maxOutputTokens: MAX_OUTPUT,
|
|
273
|
+
requestHeaders: { Authorization: `Bearer ${readKey(home)}`, ...(s.contextEdit ? { [EDIT_HEADER]: "on" } : {}) },
|
|
274
|
+
},
|
|
275
|
+
],
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
notes: [
|
|
281
|
+
'In Copilot Chat, open the model picker and choose the model listed under "100xprompt gateway" once; VS Code remembers the choice (brain does not change your default model).',
|
|
282
|
+
"VS Code keeps a custom model's key in its secret storage, which brain cannot write, so the gateway key is in the model's request headers in chatLanguageModels.json instead; that file is now readable by you only (0600) and is not synced by Settings Sync.",
|
|
283
|
+
"Needs VS Code 1.122 or later (the Custom Endpoint provider). On Copilot Business or Enterprise, your organization must allow bring-your-own-key models.",
|
|
284
|
+
],
|
|
285
|
+
},
|
|
286
|
+
]
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Gateway connector for Windsurf (now Devin Desktop): none. Its models are served by Cognition's backend only; the one
|
|
2
|
+
// network setting is an HTTP proxy, and "You cannot point Devin Desktop at your own LLM" (checked 2026-11-10:
|
|
3
|
+
// https://agentgateway.dev/docs/standalone/main/integrations/llm-clients/devin/; docs.windsurf.com now redirects to docs.devin.ai).
|
|
4
|
+
import type { Connector } from "../common.ts"
|
|
5
|
+
|
|
6
|
+
export const connectors: Connector[] = []
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Gateway connector for Zed: NOT BUILT. Zed cannot be given the gateway key through a file brain can write and undo.
|
|
2
|
+
//
|
|
3
|
+
// Checked 2026-11-10 (Zed is not installed on this machine) against the Zed docs and the zed-industries/zed main source:
|
|
4
|
+
// - https://zed.dev/docs/ai/use-api-access ("OpenAI-Compatible Endpoints", "Anthropic-Compatible Endpoints"): providers
|
|
5
|
+
// go in settings.json (JSON with comments, ~/.config/zed/settings.json) under `language_models.openai_compatible.<id>`
|
|
6
|
+
// (`api_url`, `available_models: [{ name, display_name, max_tokens, max_output_tokens, capabilities }]`,
|
|
7
|
+
// `custom_headers`) or `language_models.anthropic_compatible.<name>`, and the agent's model under `agent.default_model`.
|
|
8
|
+
// All of that is writable. The key is not: "Enter the API key in the provider settings UI or set the generated
|
|
9
|
+
// environment variable" (<PROVIDER_ID>_API_KEY, upper snake case), keys saved in Zed "are stored in the system
|
|
10
|
+
// keychain, not in settings.json", and `custom_headers` cannot carry it ("Headers Zed manages, such as provider
|
|
11
|
+
// authentication headers, are ignored with a warning").
|
|
12
|
+
// - crates/language_model/src/api_key.rs (ApiKeyState::load_if_needed): the key comes from the environment variable if it
|
|
13
|
+
// is set and non-empty, otherwise from the credentials provider (the system keychain) keyed by the API URL
|
|
14
|
+
// (read_credentials(url)); there is no file or command source.
|
|
15
|
+
// - crates/language_models/src/provider/open_ai_compatible.rs: OpenAiCompatibleSettings { api_url, available_models,
|
|
16
|
+
// custom_headers }; requests fail with NoApiKey when neither source has a key.
|
|
17
|
+
//
|
|
18
|
+
// So the key could only reach Zed through (a) the keychain, which brain must not write (and could neither refresh on a new
|
|
19
|
+
// sign-in nor remove on disconnect), or (b) an environment variable in Zed's own launch environment, which means the
|
|
20
|
+
// person's shell profile, not Zed's config, and would expose the key to every process started from that shell.
|
|
21
|
+
//
|
|
22
|
+
// A semi-manual connector was considered and not built: brain could add the provider and default model to settings.json
|
|
23
|
+
// and ask the person to paste the key into Zed's Agent settings. It is not automatic, `connect` would report Zed as
|
|
24
|
+
// connected while it has no key, `disconnect` would leave the key in the keychain, and a new `brain gateway login` would
|
|
25
|
+
// leave Zed on the old key without anyone noticing. Not worth it until the core can mark a connector as needing a manual
|
|
26
|
+
// key step (and status can say whether that step was done).
|
|
27
|
+
import type { Connector } from "../common.ts"
|
|
28
|
+
|
|
29
|
+
export const connectors: Connector[] = []
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// `brain gateway login`: sign in to 100xprompt in the browser, the same way the 100xprompt CLI does (`100xprompt auth login`).
|
|
2
|
+
//
|
|
3
|
+
// 1. A one-use server listens on 127.0.0.1 on a free port, for /auth/callback only.
|
|
4
|
+
// 2. The browser opens https://100xprompt.com/auth/cli?port=<port>&state=<random>&challenge=<PKCE S256>. The person signs in
|
|
5
|
+
// there; the site sends the browser back to http://127.0.0.1:<port>/auth/callback?token=...&state=...&email=...
|
|
6
|
+
// 3. The state must be the one sent (so no other page can hand this machine a key); the token is the gateway key.
|
|
7
|
+
// 4. No browser here (SSH), or the callback cannot get through: the person pastes the one-time code or API key the
|
|
8
|
+
// site shows instead (input hidden). It is checked the way the CLI checks it.
|
|
9
|
+
//
|
|
10
|
+
// HUNDREDXPROMPT_AUTH_URL points the sign-in at another site (the CLI reads the same variable). The key is never printed.
|
|
11
|
+
//
|
|
12
|
+
// The state is the only thing that tells the real sign-in apart from a key planted by someone else on this machine, so
|
|
13
|
+
// it is kept out of other processes' view where the system allows: on macOS the address goes to the browser through
|
|
14
|
+
// AppleScript's `open location` on stdin, not on a command line that `ps` shows every user. (Linux and Windows browsers
|
|
15
|
+
// take the address on their command line; there, another user who watches `ps` during the sign-in could plant a key. The
|
|
16
|
+
// 100xprompt CLI has the same limit everywhere.)
|
|
17
|
+
import { timingSafeEqual } from "node:crypto"
|
|
18
|
+
|
|
19
|
+
export const AUTH_URL = "https://100xprompt.com"
|
|
20
|
+
export const TIMEOUT_MS = 5 * 60_000
|
|
21
|
+
|
|
22
|
+
/** PKCE (RFC 7636): a 43-character verifier and its S256 challenge, as the CLI makes them. */
|
|
23
|
+
export async function pkce(): Promise<{ verifier: string; challenge: string }> {
|
|
24
|
+
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
|
25
|
+
const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)), (b) => chars[b % chars.length]).join("")
|
|
26
|
+
const challenge = Buffer.from(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))).toString("base64url")
|
|
27
|
+
return { verifier, challenge }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Whether a pasted credential looks like a 100xprompt one, as the CLI decides it (Auth.validate): a 100x_live_ / 100x_test_ /
|
|
32
|
+
* sk- key, a sign-in token (JWT, not expired), or a 100x_auth_ one-time code wrapping either. Returns the key to store.
|
|
33
|
+
*/
|
|
34
|
+
export function checkCredential(raw: string, now = Date.now()): { ok: true; key: string; email?: string } | { ok: false; why: string } {
|
|
35
|
+
const t = raw.trim()
|
|
36
|
+
if (!t || t.length < 16) return { ok: false, why: "that is too short to be a key or code" }
|
|
37
|
+
if (/\s/.test(t)) return { ok: false, why: "a key or code has no spaces in it" }
|
|
38
|
+
// The sign-in page shows the key shortened ("100x_live_AbCdEfGhIjKl...12345678"); only its Copy key button gives all of it.
|
|
39
|
+
if (/\.\.\.|…/.test(t)) return { ok: false, why: "that is the shortened key the page shows: use its Copy key button, then paste again" }
|
|
40
|
+
if (/^(?:100x_live_|100x_test_|sk-)/.test(t)) {
|
|
41
|
+
if (!/^(?:100x_live_|100x_test_|sk-)[A-Za-z0-9_-]+$/.test(t)) return { ok: false, why: "that key has characters a 100xprompt key never has" }
|
|
42
|
+
return t.length >= 24 ? { ok: true, key: t } : { ok: false, why: "that key is too short" }
|
|
43
|
+
}
|
|
44
|
+
const parts = t.split(".")
|
|
45
|
+
if (parts.length === 3) {
|
|
46
|
+
try {
|
|
47
|
+
const head = JSON.parse(Buffer.from(parts[0]!, "base64url").toString("utf8"))
|
|
48
|
+
const body = JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf8"))
|
|
49
|
+
if (!head?.alg || !body?.sub) return { ok: false, why: "that sign-in token is malformed" }
|
|
50
|
+
if (typeof body.exp === "number" && body.exp < Math.floor(now / 1000)) return { ok: false, why: "that sign-in token has expired: sign in again" }
|
|
51
|
+
return { ok: true, key: t, ...(typeof body.email === "string" ? { email: body.email } : {}) }
|
|
52
|
+
} catch {
|
|
53
|
+
return { ok: false, why: "that sign-in token is malformed" }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (t.startsWith("100x_auth_")) {
|
|
57
|
+
try {
|
|
58
|
+
const inner = JSON.parse(Buffer.from(t.slice("100x_auth_".length), "base64url").toString("utf8"))
|
|
59
|
+
if (typeof inner?.t === "string" && !inner.t.startsWith("100x_auth_")) {
|
|
60
|
+
const r = checkCredential(inner.t, now)
|
|
61
|
+
return r.ok && typeof inner.e === "string" && !r.email ? { ...r, email: inner.e } : r
|
|
62
|
+
}
|
|
63
|
+
} catch {}
|
|
64
|
+
return { ok: false, why: "that one-time code is malformed" }
|
|
65
|
+
}
|
|
66
|
+
return { ok: false, why: `that is not a 100xprompt key or one-time code (copy the code from ${AUTH_URL}/auth/cli, or a 100x_live_ key)` }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const esc = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`)
|
|
70
|
+
const page = (title: string, body: string, status = 200) =>
|
|
71
|
+
new Response(
|
|
72
|
+
`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)}</title>` +
|
|
73
|
+
`<style>body{font-family:system-ui,-apple-system,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#050505;color:#f1ecec}` +
|
|
74
|
+
`main{text-align:center;max-width:32rem;padding:1rem}h1{font-size:1.4rem}p{color:#a8a3a3}</style></head>` +
|
|
75
|
+
`<body><main><h1>${esc(title)}</h1><p>${esc(body)}</p></main></body></html>`,
|
|
76
|
+
// The callback URL carries the key: nothing on this page may cache it or pass it on.
|
|
77
|
+
{ status, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer", "x-frame-options": "DENY" } },
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
/** Constant-time state check. Byte lengths are compared (a non-ASCII state of the right length in characters must not throw). */
|
|
81
|
+
const sameState = (got: string, want: string) => {
|
|
82
|
+
const a = Buffer.from(got)
|
|
83
|
+
const b = Buffer.from(want)
|
|
84
|
+
return a.length === b.length && timingSafeEqual(a, b)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The key in a callback's token: the CLI takes it as it is; brain also unwraps a one-time code and refuses junk. */
|
|
88
|
+
const callbackKey = (token: string): { ok: true; key: string; email?: string } | { ok: false; why: string } =>
|
|
89
|
+
token.startsWith("100x_auth_") ? checkCredential(token) : token.length >= 16 && !/\s/.test(token) ? { ok: true, key: token } : { ok: false, why: "no key came back from the sign-in page" }
|
|
90
|
+
|
|
91
|
+
/** The sign-in site from --auth-url / $HUNDREDXPROMPT_AUTH_URL (empty means unset, as for the CLI); undefined if not http(s). */
|
|
92
|
+
export function signInBase(given?: string): string | undefined {
|
|
93
|
+
const raw = given?.trim() || process.env.HUNDREDXPROMPT_AUTH_URL?.trim() || AUTH_URL
|
|
94
|
+
try {
|
|
95
|
+
const u = new URL(raw)
|
|
96
|
+
if (u.protocol !== "https:" && u.protocol !== "http:") return undefined
|
|
97
|
+
return `${u.origin}${u.pathname.replace(/\/+$/, "")}`
|
|
98
|
+
} catch {
|
|
99
|
+
return undefined
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface SignInDeps {
|
|
104
|
+
/** Opens the sign-in page; default: the system browser. */
|
|
105
|
+
open?: (url: string) => void
|
|
106
|
+
/** false: print the address only (a machine with no browser, or one the person opens elsewhere). */
|
|
107
|
+
browser?: boolean
|
|
108
|
+
/** The sign-in site; default $HUNDREDXPROMPT_AUTH_URL or https://100xprompt.com. */
|
|
109
|
+
authUrl?: string
|
|
110
|
+
timeoutMs?: number
|
|
111
|
+
/**
|
|
112
|
+
* Asks for a pasted code, input hidden; resolves undefined when the person gives up or `signal` aborts. Absent when
|
|
113
|
+
* there is no terminal to ask in (then the browser is the only way).
|
|
114
|
+
*/
|
|
115
|
+
ask?: (message: string, signal: AbortSignal) => Promise<string | undefined>
|
|
116
|
+
say: (line: string) => void
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export type SignIn = { ok: true; key: string; email?: string; via: "browser" | "code" } | { ok: false; why: string }
|
|
120
|
+
|
|
121
|
+
/** Signs in through the browser (or a pasted code). Does not store anything: the caller does. */
|
|
122
|
+
export async function browserSignIn(d: SignInDeps): Promise<SignIn> {
|
|
123
|
+
const base = signInBase(d.authUrl)
|
|
124
|
+
if (!base) return { ok: false, why: "HUNDREDXPROMPT_AUTH_URL must be an http:// or https:// address" }
|
|
125
|
+
const { challenge } = await pkce()
|
|
126
|
+
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex")
|
|
127
|
+
let finish!: (r: SignIn) => void
|
|
128
|
+
const done = new Promise<SignIn>((r) => (finish = r))
|
|
129
|
+
let settled = false
|
|
130
|
+
const settle = (r: SignIn) => {
|
|
131
|
+
if (settled) return
|
|
132
|
+
settled = true
|
|
133
|
+
finish(r)
|
|
134
|
+
}
|
|
135
|
+
const server = Bun.serve({
|
|
136
|
+
hostname: "127.0.0.1",
|
|
137
|
+
port: 0,
|
|
138
|
+
development: false,
|
|
139
|
+
// Anything the handler did not expect: a plain refusal, never a stack trace on the page or in the terminal.
|
|
140
|
+
error: () => new Response("Bad request", { status: 400 }),
|
|
141
|
+
fetch(req) {
|
|
142
|
+
const url = new URL(req.url)
|
|
143
|
+
if (url.pathname !== "/auth/callback" || req.method !== "GET") return new Response("Not found", { status: 404 })
|
|
144
|
+
if (settled) return page("Already signed in", "This sign-in has finished. You can close this tab.", 410)
|
|
145
|
+
const got = url.searchParams.get("state") ?? ""
|
|
146
|
+
const error = url.searchParams.get("error")
|
|
147
|
+
// Only the page this command opened knows the state: anything else is refused and the wait goes on.
|
|
148
|
+
if (!/^[0-9a-f]{64}$/.test(got) || !sameState(got, state)) return page("Sign-in refused", "This link was not started by this brain gateway login. Start again from your terminal.", 400)
|
|
149
|
+
if (error) {
|
|
150
|
+
settle({ ok: false, why: `the sign-in page said: ${error.slice(0, 200)}` })
|
|
151
|
+
return page("Sign-in failed", `${error.slice(0, 200)}. Go back to your terminal.`, 400)
|
|
152
|
+
}
|
|
153
|
+
const c = callbackKey(url.searchParams.get("token") ?? "")
|
|
154
|
+
if (!c.ok) {
|
|
155
|
+
settle({ ok: false, why: c.why })
|
|
156
|
+
return page("Sign-in failed", "No usable key came back. Go back to your terminal.", 400)
|
|
157
|
+
}
|
|
158
|
+
const email = url.searchParams.get("email") || c.email || undefined
|
|
159
|
+
settle({ ok: true, key: c.key, ...(email ? { email } : {}), via: "browser" })
|
|
160
|
+
return page("Signed in", `brain is now signed in to the 100xprompt gateway${email ? ` as ${email}` : ""}. You can close this tab and go back to your terminal.`)
|
|
161
|
+
},
|
|
162
|
+
})
|
|
163
|
+
const url = `${base}/auth/cli?port=${server.port}&state=${state}&challenge=${challenge}`
|
|
164
|
+
const stopAsking = new AbortController()
|
|
165
|
+
const timer = setTimeout(() => settle({ ok: false, why: `no sign-in came back within ${Math.round((d.timeoutMs ?? TIMEOUT_MS) / 60_000) || 1} minutes` }), d.timeoutMs ?? TIMEOUT_MS)
|
|
166
|
+
try {
|
|
167
|
+
if (d.browser === false) d.say(`Open this address in a browser to sign in to 100xprompt:\n ${url}`)
|
|
168
|
+
else {
|
|
169
|
+
d.say(`Opening your browser to sign in to 100xprompt:\n ${url}`)
|
|
170
|
+
try {
|
|
171
|
+
;(d.open ?? openInBrowser)(url)
|
|
172
|
+
} catch {}
|
|
173
|
+
}
|
|
174
|
+
if (d.ask) {
|
|
175
|
+
d.say(
|
|
176
|
+
"Waiting for the browser. Signing in on another machine (SSH)? Paste the key from the page's Copy key button, or the address the browser could not open, then press Enter (Escape cancels).",
|
|
177
|
+
)
|
|
178
|
+
// The paste runs alongside the browser: whichever comes first wins. A bad paste asks again; Escape ends the sign-in
|
|
179
|
+
// (an abandoned hidden prompt would leave the terminal echoing whatever is pasted next, and the shell reading it).
|
|
180
|
+
void (async () => {
|
|
181
|
+
for (;;) {
|
|
182
|
+
const pasted = await d.ask!("key or address: ", stopAsking.signal)
|
|
183
|
+
if (settled) return
|
|
184
|
+
if (pasted === undefined) return settle({ ok: false, why: "sign-in cancelled" })
|
|
185
|
+
if (!pasted.trim()) continue
|
|
186
|
+
const c = fromPaste(pasted.trim(), state)
|
|
187
|
+
if (c.ok) return settle({ ok: true, key: c.key, ...(c.email ? { email: c.email } : {}), via: "code" })
|
|
188
|
+
if ("fatal" in c) return settle({ ok: false, why: c.why })
|
|
189
|
+
d.say(` ${c.why}`)
|
|
190
|
+
}
|
|
191
|
+
})()
|
|
192
|
+
} else d.say("Waiting for the browser…")
|
|
193
|
+
return await done
|
|
194
|
+
} finally {
|
|
195
|
+
clearTimeout(timer)
|
|
196
|
+
stopAsking.abort()
|
|
197
|
+
// The page that just answered must reach the browser before this returns: `brain` exits right after, and an exit
|
|
198
|
+
// with the answer still unsent leaves the person looking at a browser error after a sign-in that worked. A graceful
|
|
199
|
+
// stop waits for that answer; a browser holding the connection open gets cut off after 1.5 s.
|
|
200
|
+
await Promise.race([server.stop(), Bun.sleep(1_500)])
|
|
201
|
+
void server.stop(true)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* A pasted key, one-time code or sign-in token; or the callback address itself (on SSH the browser is sent to
|
|
207
|
+
* 127.0.0.1 on the wrong machine, and the address it could not open holds the key). An address must carry this sign-in's
|
|
208
|
+
* state, so one from another sign-in, or one made up, is refused.
|
|
209
|
+
*/
|
|
210
|
+
export function fromPaste(pasted: string, state: string): { ok: true; key: string; email?: string } | { ok: false; why: string } | { ok: false; why: string; fatal: true } {
|
|
211
|
+
if (/^https?:\/\//i.test(pasted)) {
|
|
212
|
+
let u: URL
|
|
213
|
+
try {
|
|
214
|
+
u = new URL(pasted)
|
|
215
|
+
} catch {
|
|
216
|
+
return { ok: false, why: "that address could not be read" }
|
|
217
|
+
}
|
|
218
|
+
if (!u.pathname.endsWith("/auth/callback")) return { ok: false, why: "that is not the sign-in's return address (it ends in /auth/callback?token=...)" }
|
|
219
|
+
const got = u.searchParams.get("state") ?? ""
|
|
220
|
+
if (!/^[0-9a-f]{64}$/.test(got) || !sameState(got, state)) return { ok: false, why: "that address is from another sign-in: paste the one from this sign-in" }
|
|
221
|
+
const error = u.searchParams.get("error")
|
|
222
|
+
if (error) return { ok: false, why: `the sign-in page said: ${error.slice(0, 200)}`, fatal: true }
|
|
223
|
+
const c = callbackKey(u.searchParams.get("token") ?? "")
|
|
224
|
+
if (!c.ok) return c
|
|
225
|
+
const email = u.searchParams.get("email") || c.email
|
|
226
|
+
return { ok: true, key: c.key, ...(email ? { email } : {}) }
|
|
227
|
+
}
|
|
228
|
+
return checkCredential(pasted)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* How to open the sign-in page on this platform: the command, and what to write to its stdin. On macOS the address goes
|
|
233
|
+
* to AppleScript's `open location` on stdin, so it (and its state) is on no command line.
|
|
234
|
+
*/
|
|
235
|
+
export function browserCommand(url: string, platform: string = process.platform): { argv: string[]; stdin?: string } {
|
|
236
|
+
if (platform === "darwin") return { argv: ["osascript", "-"], stdin: `open location "${url.replace(/[\\"]/g, (c) => `\\${c}`)}"\n` }
|
|
237
|
+
if (platform === "win32") return { argv: ["cmd", "/c", "start", "", url.replace(/&/g, "^&")] }
|
|
238
|
+
return { argv: ["xdg-open", url] }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Opens the sign-in page in the default browser (browserCommand). */
|
|
242
|
+
export function openInBrowser(url: string): void {
|
|
243
|
+
const c = browserCommand(url)
|
|
244
|
+
try {
|
|
245
|
+
const p = Bun.spawn(c.argv, { stdin: c.stdin === undefined ? "ignore" : "pipe", stdout: "ignore", stderr: "ignore" })
|
|
246
|
+
if (c.stdin !== undefined && p.stdin && typeof p.stdin !== "number") {
|
|
247
|
+
p.stdin.write(c.stdin)
|
|
248
|
+
void p.stdin.end()
|
|
249
|
+
}
|
|
250
|
+
p.unref()
|
|
251
|
+
} catch {}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Applies typed or pasted terminal input to `buf`. Enter ends the line; Escape on its own, or Ctrl-D on an empty line,
|
|
256
|
+
* cancels; Ctrl-C interrupts. Arrow, Delete and Home keys, Alt+key and bracketed-paste markers arrive as escape sequences
|
|
257
|
+
* and are skipped, so they neither end the prompt nor land in the key.
|
|
258
|
+
*/
|
|
259
|
+
export function applyKeys(buf: string, chunk: string): { buf: string; end?: "enter" | "cancel" | "interrupt" } {
|
|
260
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
261
|
+
const ch = chunk[i]!
|
|
262
|
+
if (ch === "\r" || ch === "\n") return { buf, end: "enter" }
|
|
263
|
+
if (ch === "\u0003") return { buf, end: "interrupt" }
|
|
264
|
+
if (ch === "\u0004") {
|
|
265
|
+
if (!buf) return { buf, end: "cancel" }
|
|
266
|
+
continue
|
|
267
|
+
}
|
|
268
|
+
if (ch === "\u001b") {
|
|
269
|
+
const next = chunk[i + 1]
|
|
270
|
+
if (next === undefined) return { buf, end: "cancel" }
|
|
271
|
+
if (next === "[") {
|
|
272
|
+
// CSI: parameter bytes, then one final byte in 0x40-0x7E.
|
|
273
|
+
let j = i + 2
|
|
274
|
+
while (j < chunk.length && !(chunk.charCodeAt(j) >= 0x40 && chunk.charCodeAt(j) <= 0x7e)) j++
|
|
275
|
+
i = j
|
|
276
|
+
} else i += next === "O" ? 2 : 1 // SS3 (ESC O x) or Alt+key (ESC x)
|
|
277
|
+
continue
|
|
278
|
+
}
|
|
279
|
+
if (ch === "\u007f" || ch === "\b") buf = buf.slice(0, -1)
|
|
280
|
+
else if (ch >= " ") buf += ch
|
|
281
|
+
}
|
|
282
|
+
return { buf }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Reads one line from the terminal without showing it (the key must not land in the scrollback). undefined: no terminal,
|
|
287
|
+
* cancelled (Escape, Ctrl-D), or `signal` aborted. Ctrl-C ends the command, as it would anywhere else.
|
|
288
|
+
*/
|
|
289
|
+
export function askHidden(message: string, signal: AbortSignal): Promise<string | undefined> {
|
|
290
|
+
const stdin = process.stdin
|
|
291
|
+
if (!stdin.isTTY || signal.aborted) return Promise.resolve(undefined)
|
|
292
|
+
return new Promise((resolve) => {
|
|
293
|
+
let buf = ""
|
|
294
|
+
let over = false
|
|
295
|
+
process.stderr.write(message)
|
|
296
|
+
stdin.setRawMode(true)
|
|
297
|
+
stdin.setEncoding("utf8")
|
|
298
|
+
stdin.resume()
|
|
299
|
+
const end = (v: string | undefined) => {
|
|
300
|
+
if (over) return
|
|
301
|
+
over = true
|
|
302
|
+
stdin.off("data", onData)
|
|
303
|
+
signal.removeEventListener("abort", onAbort)
|
|
304
|
+
stdin.setRawMode(false)
|
|
305
|
+
stdin.pause()
|
|
306
|
+
process.stderr.write("\n")
|
|
307
|
+
resolve(v)
|
|
308
|
+
}
|
|
309
|
+
const onAbort = () => end(undefined)
|
|
310
|
+
const onData = (chunk: string) => {
|
|
311
|
+
const r = applyKeys(buf, chunk)
|
|
312
|
+
buf = r.buf
|
|
313
|
+
if (r.end === "enter") end(buf)
|
|
314
|
+
else if (r.end === "cancel") end(undefined)
|
|
315
|
+
else if (r.end === "interrupt") {
|
|
316
|
+
end(undefined)
|
|
317
|
+
process.exit(130)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
stdin.on("data", onData)
|
|
321
|
+
signal.addEventListener("abort", onAbort)
|
|
322
|
+
})
|
|
323
|
+
}
|