@100xprompt/brain 0.1.3 → 0.1.5
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.md +70 -2
- package/package.json +1 -1
- package/src/cli.ts +82 -9
- package/src/dashboard/cli.ts +25 -9
- package/src/dashboard/report.ts +93 -0
- package/src/dashboard/send.ts +20 -4
- package/src/dashboard/state.ts +67 -5
- package/src/fsutil.ts +19 -1
- package/src/gateway/connect.ts +252 -101
- package/src/gateway/connectors/hermes.ts +1 -1
- package/src/gateway/signin.ts +44 -12
- package/src/hooks.ts +20 -9
- package/src/lock.ts +272 -0
- package/src/memory/audit.ts +6 -4
- package/src/memory/cli.ts +6 -0
- package/src/memory/externalize.ts +17 -4
- package/src/memory/mcp-install.ts +63 -7
- package/src/memory/mcp-server.ts +4 -0
- package/src/memory/prepare.ts +12 -4
- package/src/memory/sync.ts +3 -0
- package/src/service.ts +473 -0
- package/src/settings/account.ts +391 -0
- package/src/settings/apply.ts +306 -0
- package/src/settings/catalog.ts +476 -0
- package/src/settings/cli.ts +446 -0
- package/src/settings/env.ts +103 -0
- package/src/settings/pair.ts +86 -0
- package/src/settings/store.ts +216 -0
- package/src/settings/values.ts +80 -0
package/src/gateway/connect.ts
CHANGED
|
@@ -15,9 +15,10 @@
|
|
|
15
15
|
// stores the key that comes back in <home>/.brain/gateway.key (0600). For scripts and CI, --key-env VAR takes it from an
|
|
16
16
|
// environment variable instead; --from-cli reuses the 100xprompt CLI's sign-in. It is never printed. Tools read it at run time where they can (Claude Code's apiKeyHelper,
|
|
17
17
|
// OpenCode's {file:...}); Codex and Gemini CLI can only take it in their own config, which is then made private (0600).
|
|
18
|
-
import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync,
|
|
18
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmdirSync, rmSync, statSync } from "node:fs"
|
|
19
19
|
import { basename, dirname, join, resolve } from "node:path"
|
|
20
20
|
import {
|
|
21
|
+
AUTO_ROUTE,
|
|
21
22
|
brainDir,
|
|
22
23
|
type Codec,
|
|
23
24
|
type Connector,
|
|
@@ -44,6 +45,10 @@ import {
|
|
|
44
45
|
} from "./common.ts"
|
|
45
46
|
import { EXTRA_CONNECTORS } from "./connectors/index.ts"
|
|
46
47
|
import { askHidden, browserSignIn, type SignInDeps } from "./signin.ts"
|
|
48
|
+
import { writeAtomic } from "../fsutil.ts"
|
|
49
|
+
import { withBrainLockSync } from "../lock.ts"
|
|
50
|
+
import { toolGatewayPrefs } from "../settings/values.ts"
|
|
51
|
+
import { readMachineToken } from "../dashboard/state.ts"
|
|
47
52
|
|
|
48
53
|
export {
|
|
49
54
|
AUTO_MODEL,
|
|
@@ -78,7 +83,17 @@ interface State {
|
|
|
78
83
|
keySource?: string
|
|
79
84
|
/** Who signed in (from the sign-in page), shown by status. */
|
|
80
85
|
email?: string
|
|
81
|
-
tools: Record<string,
|
|
86
|
+
tools: Record<string, ToolRecord>
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface ToolRecord {
|
|
90
|
+
at: string
|
|
91
|
+
changes: Change[]
|
|
92
|
+
created?: string[]
|
|
93
|
+
createdDirs?: string[]
|
|
94
|
+
modes?: Record<string, number>
|
|
95
|
+
/** The settings this tool was connected with (older records have none: `settings` above is the last connect's). */
|
|
96
|
+
settings?: GatewaySettings
|
|
82
97
|
}
|
|
83
98
|
|
|
84
99
|
function loadState(home: string): State {
|
|
@@ -97,11 +112,7 @@ function saveState(home: string, s: State) {
|
|
|
97
112
|
|
|
98
113
|
/** Atomic write with mode 0600 (the key and the record of what was changed). */
|
|
99
114
|
function writePrivate(path: string, text: string) {
|
|
100
|
-
|
|
101
|
-
rmSync(tmp, { force: true })
|
|
102
|
-
writeFileSync(tmp, text, { mode: 0o600 })
|
|
103
|
-
chmodSync(tmp, 0o600)
|
|
104
|
-
renameSync(tmp, path)
|
|
115
|
+
writeAtomic(path, text, 0o600)
|
|
105
116
|
}
|
|
106
117
|
|
|
107
118
|
/**
|
|
@@ -124,11 +135,7 @@ function writeToolFile(home: string, tool: string, path: string, text: string, m
|
|
|
124
135
|
keep = statSync(real).mode & 0o777
|
|
125
136
|
} catch {}
|
|
126
137
|
}
|
|
127
|
-
|
|
128
|
-
rmSync(tmp, { force: true })
|
|
129
|
-
writeFileSync(tmp, text, keep === undefined ? {} : { mode: keep })
|
|
130
|
-
if (keep !== undefined) chmodSync(tmp, keep)
|
|
131
|
-
renameSync(tmp, real)
|
|
138
|
+
writeAtomic(real, text, keep)
|
|
132
139
|
}
|
|
133
140
|
|
|
134
141
|
// ---- the key ----------------------------------------------------------------------------------------------------------
|
|
@@ -169,6 +176,10 @@ export function hasKey(home: string): boolean {
|
|
|
169
176
|
* on the old one while status names the new. Returns the tools whose copy was replaced.
|
|
170
177
|
*/
|
|
171
178
|
export function storeKey(home: string, key: string, source: string, email?: string, env: Env = process.env): string[] {
|
|
179
|
+
return withBrainLockSync(home, () => storeKeyLocked(home, key, source, email, env), { what: "storing the gateway key" })
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function storeKeyLocked(home: string, key: string, source: string, email: string | undefined, env: Env): string[] {
|
|
172
183
|
const old = readKey(home)
|
|
173
184
|
mkdirSync(brainDir(home), { recursive: true, mode: 0o700 })
|
|
174
185
|
writePrivate(keyPath(home), `${key}\n`)
|
|
@@ -253,6 +264,10 @@ export async function signIn(home: string, d: SignInDeps, env: Env = process.env
|
|
|
253
264
|
|
|
254
265
|
/** Forgets the key. Tools still connected stop working until the next login, which is said. */
|
|
255
266
|
export function logout(home: string, env: Record<string, string | undefined> = process.env): string[] {
|
|
267
|
+
return withBrainLockSync(home, () => logoutLocked(home, env), { what: "signing out" })
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function logoutLocked(home: string, env: Record<string, string | undefined>): string[] {
|
|
256
271
|
const had = hasKey(home)
|
|
257
272
|
rmSync(keyPath(home), { force: true })
|
|
258
273
|
const s = loadState(home)
|
|
@@ -577,6 +592,8 @@ export async function probeKey(home: string, c: Connector, s: GatewaySettings, f
|
|
|
577
592
|
export interface Result {
|
|
578
593
|
ok: boolean
|
|
579
594
|
lines: string[]
|
|
595
|
+
/** connect: the settings the tool was connected with. */
|
|
596
|
+
settings?: GatewaySettings
|
|
580
597
|
}
|
|
581
598
|
|
|
582
599
|
export function installedHere(c: Connector, home: string, env: Record<string, string | undefined>): boolean {
|
|
@@ -585,106 +602,219 @@ export function installedHere(c: Connector, home: string, env: Record<string, st
|
|
|
585
602
|
return existsSync(f) || (resolve(dirname(f)) !== resolve(home) && existsSync(dirname(f))) || c.probe.some((p) => existsSync(join(home, p)))
|
|
586
603
|
}
|
|
587
604
|
|
|
605
|
+
/** Whether a tool is on the gateway now, read from its files (gateway.json alone can be wrong after a hand edit). */
|
|
606
|
+
export interface Connection {
|
|
607
|
+
/** brain's values are all in the tool's files, some of them, or none (or brain never connected it). */
|
|
608
|
+
state: "connected" | "partial" | "not-connected"
|
|
609
|
+
/** The settings it was connected with; undefined when an older brain connected it and they cannot be told. */
|
|
610
|
+
settings?: GatewaySettings
|
|
611
|
+
/** What no longer holds brain's value, when it is not all there. */
|
|
612
|
+
why?: string
|
|
613
|
+
/** The values brain set that the files no longer hold (JSON), to tell which part was changed. */
|
|
614
|
+
changed?: string[]
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** Every string in a value (and every key of an object in it). */
|
|
618
|
+
const stringsOf = (v: unknown): string[] =>
|
|
619
|
+
typeof v === "string" ? [v] : Array.isArray(v) ? v.flatMap(stringsOf) : v && typeof v === "object" ? Object.entries(v).flatMap(([k, x]) => [k, ...stringsOf(x)]) : []
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* The settings a tool was connected with. Brains before 0.1.4 kept only the last connect's settings (for every tool): those
|
|
623
|
+
* are this tool's only when it was the last one connected. Otherwise they are read back from what connect wrote in the
|
|
624
|
+
* tool's files: the gateway address and route, the model, and whether the context-editing header is there.
|
|
625
|
+
*/
|
|
626
|
+
function settingsOf(st: State, id: string, values: unknown[]): GatewaySettings | undefined {
|
|
627
|
+
const rec = st.tools[id]
|
|
628
|
+
if (!rec) return undefined
|
|
629
|
+
if (rec.settings) return rec.settings
|
|
630
|
+
const newest = Object.entries(st.tools).sort((a, b) => (b[1].at ?? "").localeCompare(a[1].at ?? ""))[0]?.[0]
|
|
631
|
+
if (newest === id && st.settings) return st.settings
|
|
632
|
+
let url: string | undefined
|
|
633
|
+
let route: string | undefined
|
|
634
|
+
let model: string | undefined
|
|
635
|
+
let edit = false
|
|
636
|
+
for (const t of values.flatMap(stringsOf)) {
|
|
637
|
+
if (t.includes(EDIT_HEADER)) edit = true
|
|
638
|
+
model ??= Object.keys(MODEL_NAMES).find((m) => t === m || t.endsWith(`/${m}`))
|
|
639
|
+
const m = /^(https?:\/\/\S+?)\/([a-z0-9-]{1,40})(?:\/v1)?\/?$/.exec(t)
|
|
640
|
+
if (m && !url) {
|
|
641
|
+
url = m[1]
|
|
642
|
+
route = m[2]
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return url && route && model ? { url, route, model, contextEdit: edit } : undefined
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
export function connectionState(home: string, toolId: string, env: Env = process.env): Connection {
|
|
649
|
+
const c = connectorFor(toolId)
|
|
650
|
+
if (!c) return { state: "not-connected" }
|
|
651
|
+
const st = loadState(home)
|
|
652
|
+
const rec = st.tools[c.id]
|
|
653
|
+
if (!rec) return { state: "not-connected" }
|
|
654
|
+
const codecs = codecsOf(c, home, st, env)
|
|
655
|
+
const docs = new Map<string, Doc | { error: string }>()
|
|
656
|
+
let held = 0
|
|
657
|
+
let first: string | undefined
|
|
658
|
+
const changed: string[] = []
|
|
659
|
+
// What connect wrote: its recorded values, and for entries holding the key, what the file has (the key itself is never kept).
|
|
660
|
+
const written: unknown[] = []
|
|
661
|
+
for (const x of rec.changes) {
|
|
662
|
+
if (!docs.has(x.file)) docs.set(x.file, load(x.format, x.file, codecs.get(x.file)))
|
|
663
|
+
const d = docs.get(x.file)!
|
|
664
|
+
const current = "error" in d ? undefined : getKey(d, x.key)
|
|
665
|
+
const secret = x.after === "(contains the key)"
|
|
666
|
+
const ours = !("error" in d) && (secret ? current !== undefined : same(current, x.after))
|
|
667
|
+
written.push(secret ? current : x.after)
|
|
668
|
+
if (ours) held++
|
|
669
|
+
else {
|
|
670
|
+
first ??= `${basename(x.file)}: ${x.key.replace(/\//g, ".")}`
|
|
671
|
+
changed.push(JSON.stringify(x.after))
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
const settings = settingsOf(st, c.id, written)
|
|
675
|
+
const withSettings = settings ? { settings } : {}
|
|
676
|
+
if (held === rec.changes.length) return { state: "connected", ...withSettings }
|
|
677
|
+
if (held === 0) return { state: "not-connected", ...withSettings, why: `brain's settings are no longer in ${c.label}'s files (changed by hand?)`, changed }
|
|
678
|
+
return { state: "partial", ...withSettings, why: `${first} changed since brain connected it`, changed }
|
|
679
|
+
}
|
|
680
|
+
|
|
588
681
|
export async function connect(
|
|
589
682
|
home: string,
|
|
590
683
|
toolId: string,
|
|
591
|
-
o: {
|
|
684
|
+
o: {
|
|
685
|
+
settings?: Partial<GatewaySettings>
|
|
686
|
+
dryRun?: boolean
|
|
687
|
+
probe?: boolean
|
|
688
|
+
env?: Record<string, string | undefined>
|
|
689
|
+
fetchImpl?: typeof fetch
|
|
690
|
+
/** Where a flag leaves the model or context editing open, use the ones saved for this tool with `brain settings`. */
|
|
691
|
+
usePrefs?: boolean
|
|
692
|
+
/** Called after the files are written, still under the brain lock, with the settings used (to record them). */
|
|
693
|
+
onWritten?: (s: GatewaySettings) => void
|
|
694
|
+
} = {},
|
|
592
695
|
): Promise<Result> {
|
|
593
696
|
const env = o.env ?? process.env
|
|
594
697
|
const c = connectorFor(toolId)
|
|
595
698
|
if (!c) return { ok: false, lines: [`unknown tool "${toolId}"; \`brain gateway tools\` lists the ones that can be connected`] }
|
|
596
699
|
if (!hasKey(home) && !o.dryRun) return { ok: false, lines: ["not signed in to the gateway yet: run `brain gateway login`"] }
|
|
597
|
-
|
|
598
|
-
//
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
route
|
|
604
|
-
model:
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
700
|
+
// The settings to connect with, from the flags, then (with usePrefs) what is saved for this tool, then the last connect's.
|
|
701
|
+
// Worked out again inside the lock: another brain may have changed them while the key was checked.
|
|
702
|
+
const resolveSettings = (st: State): GatewaySettings | { why: string } => {
|
|
703
|
+
const given = o.settings ?? {}
|
|
704
|
+
const prefs = o.usePrefs ? toolGatewayPrefs(home, c.id) : {}
|
|
705
|
+
// With --route, a saved model is used only when neither is auto (the auto model and the auto route go together).
|
|
706
|
+
const model = given.model ?? (prefs.model !== undefined && (given.route === undefined || (given.route !== AUTO_ROUTE && prefs.model !== "auto")) ? prefs.model : undefined)
|
|
707
|
+
// --model auto goes with the auto route (and back to flash after it): common.ts routeAndModel.
|
|
708
|
+
const rm = routeAndModel({ route: given.route, model }, st.settings ?? {})
|
|
709
|
+
if ("why" in rm) return rm
|
|
710
|
+
const s: GatewaySettings = { url: given.url ?? st.settings?.url ?? DEFAULT_URL, route: rm.route, model: rm.model, contextEdit: given.contextEdit ?? prefs.contextEdit ?? st.settings?.contextEdit ?? true }
|
|
711
|
+
if (!/^https?:\/\/[^\s]+$/.test(s.url) || (/^http:/.test(s.url) && !/^http:\/\/(127\.0\.0\.1|localhost)(:|\/|$)/.test(s.url))) return { why: "--url must be https:// (http:// only for a gateway on this machine)" }
|
|
712
|
+
if (!/^[a-z0-9-]{1,40}$/.test(s.route)) return { why: "--route must be a gateway route name such as flash or pro" }
|
|
713
|
+
if (!/^[\w.:/-]{1,80}$/.test(s.model)) return { why: "--model must be pro, flash, auto or a plain model name" }
|
|
714
|
+
return s
|
|
715
|
+
}
|
|
716
|
+
const first = resolveSettings(loadState(home))
|
|
717
|
+
if ("why" in first) return { ok: false, lines: [first.why] }
|
|
718
|
+
const checked: GatewaySettings = first
|
|
610
719
|
const main = c.file(home, env)
|
|
611
720
|
if (!installedHere(c, home, env)) return { ok: false, lines: [`${c.label} has not been run on this machine (nothing at ${main}); run it once, then connect it`] }
|
|
612
721
|
let limit: string | undefined
|
|
613
|
-
|
|
614
|
-
|
|
722
|
+
const probed = o.probe !== false && !o.dryRun
|
|
723
|
+
if (probed) {
|
|
724
|
+
const p = await probeKey(home, c, checked, o.fetchImpl)
|
|
615
725
|
limit = p.limit
|
|
616
|
-
if (p.verdict === "rejected") return { ok: false, lines: [`the gateway at ${routeUrl(
|
|
617
|
-
if (p.verdict === "unreachable") return { ok: false, lines: [`could not reach ${routeUrl(
|
|
618
|
-
}
|
|
619
|
-
// Read and
|
|
620
|
-
|
|
621
|
-
const
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
if (
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
726
|
+
if (p.verdict === "rejected") return { ok: false, lines: [`the gateway at ${routeUrl(checked)} refused the key the way ${c.label} sends it (${c.authHeader}); nothing was changed`] }
|
|
727
|
+
if (p.verdict === "unreachable") return { ok: false, lines: [`could not reach ${routeUrl(checked)}; nothing was changed (pass --no-probe to connect anyway)`] }
|
|
728
|
+
}
|
|
729
|
+
// Read, change and write under the brain lock (the network check above is done before taking it), from the state
|
|
730
|
+
// as it is now: another brain may have changed it while the key was checked.
|
|
731
|
+
const write = (): Result => {
|
|
732
|
+
const st = loadState(home)
|
|
733
|
+
// Signed out while the key was checked: nothing to put in the tool's files.
|
|
734
|
+
if (!o.dryRun && !hasKey(home)) return { ok: false, lines: ["not signed in to the gateway any more (signed out meanwhile); nothing was changed"] }
|
|
735
|
+
const fresh = resolveSettings(st)
|
|
736
|
+
if ("why" in fresh) return { ok: false, lines: [fresh.why] }
|
|
737
|
+
if (probed && routeUrl(fresh) !== routeUrl(checked)) return { ok: false, lines: [`the gateway address changed to ${routeUrl(fresh)} while the key was checked at ${routeUrl(checked)}; nothing was changed: run it again`] }
|
|
738
|
+
const s = fresh
|
|
739
|
+
// Read and check every file first: nothing is written unless all of them can be changed.
|
|
740
|
+
const edits = c.edits(home, s, env)
|
|
741
|
+
const docs: Array<{ e: FileEdit; d: Doc }> = []
|
|
742
|
+
for (const e of edits) {
|
|
743
|
+
const d = load(e.format, e.path, e.codec)
|
|
744
|
+
if ("error" in d) return { ok: false, lines: [`${e.path}: ${d.error}; nothing was changed`] }
|
|
745
|
+
docs.push({ e, d })
|
|
746
|
+
}
|
|
747
|
+
const prior = st.tools[c.id]?.changes ?? []
|
|
748
|
+
const changes: Change[] = []
|
|
749
|
+
const shown: string[] = []
|
|
750
|
+
try {
|
|
751
|
+
for (const { e, d } of docs) {
|
|
752
|
+
for (const [key, after] of Object.entries(e.values)) {
|
|
753
|
+
const current = getKey(d, key)
|
|
754
|
+
// A reconnect keeps the value from before the FIRST connect, so disconnect still restores the person's own.
|
|
755
|
+
const was = prior.find((x) => x.file === e.path && x.key === key)
|
|
756
|
+
const secret = isSecret(e, key)
|
|
757
|
+
changes.push({ file: e.path, format: e.format, key, before: was ? was.before : current, after: secret ? "(contains the key)" : after })
|
|
758
|
+
if (!same(current, after)) setKey(d, key, after)
|
|
759
|
+
shown.push(` ${basename(e.path)}: ${key.replace(/\//g, ".")} = ${secret ? "(gateway settings and key)" : shownValue(after)}`)
|
|
760
|
+
}
|
|
761
|
+
// A key an earlier connect set that this one no longer wants (--no-context-edit after a connect with it): put it back now.
|
|
762
|
+
for (const x of prior) {
|
|
763
|
+
if (x.file !== e.path || x.key in e.values) continue
|
|
764
|
+
const current = getKey(d, x.key)
|
|
765
|
+
if (x.after === "(contains the key)" ? current !== undefined : same(current, x.after)) setKey(d, x.key, x.before)
|
|
766
|
+
}
|
|
646
767
|
}
|
|
768
|
+
} catch (err) {
|
|
769
|
+
// A file format the connector's codec could not change after all: nothing has been written yet.
|
|
770
|
+
return { ok: false, lines: [`could not change ${c.label}'s settings (${(err as Error).message.slice(0, 200)}); nothing was changed`] }
|
|
647
771
|
}
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
772
|
+
const named = MODEL_NAMES[s.model] ? [`note: models are shown by their 100xprompt names; the files get the gateway's id for ${modelName(s.model)}`] : []
|
|
773
|
+
if (o.dryRun) return { ok: true, lines: [`would change:`, ...shown, ...named, ...c.notes.map((n) => `note: ${n}`)] }
|
|
774
|
+
const created = new Set(st.tools[c.id]?.created ?? [])
|
|
775
|
+
for (const { e } of docs) if (!existsSync(e.path)) created.add(e.path)
|
|
776
|
+
// Folders connect has to create (Goose's custom_providers/), so disconnect can take them away again when they are empty.
|
|
777
|
+
const createdDirs = new Set(st.tools[c.id]?.createdDirs ?? [])
|
|
778
|
+
for (const { e } of docs) for (let dir = dirname(e.path); !existsSync(dir) && dir.startsWith(`${resolve(home)}/`); dir = dirname(dir)) createdDirs.add(dir)
|
|
779
|
+
// A file that is made private (it gets the key) goes back to the mode it had when disconnect gives it back.
|
|
780
|
+
const modes: Record<string, number> = { ...(st.tools[c.id]?.modes ?? {}) }
|
|
781
|
+
for (const { e } of docs) {
|
|
782
|
+
if (!e.holdsKey || !existsSync(e.path) || modes[e.path] !== undefined) continue
|
|
783
|
+
try {
|
|
784
|
+
modes[e.path] = statSync(e.path).mode & 0o777
|
|
785
|
+
} catch {}
|
|
786
|
+
}
|
|
787
|
+
const first = !st.tools[c.id]
|
|
788
|
+
for (const { e, d } of docs) writeToolFile(home, c.id, e.path, render(d), e.holdsKey ? 0o600 : undefined, first)
|
|
789
|
+
st.settings = s
|
|
790
|
+
st.tools[c.id] = { at: new Date().toISOString(), changes, ...(created.size ? { created: [...created] } : {}), ...(createdDirs.size ? { createdDirs: [...createdDirs] } : {}), ...(Object.keys(modes).length ? { modes } : {}), settings: s }
|
|
791
|
+
saveState(home, st)
|
|
663
792
|
try {
|
|
664
|
-
|
|
793
|
+
o.onWritten?.(s)
|
|
665
794
|
} catch {}
|
|
795
|
+
return {
|
|
796
|
+
ok: true,
|
|
797
|
+
settings: s,
|
|
798
|
+
lines: [
|
|
799
|
+
`connected ${c.label} to ${routeUrl(s)} (${modelName(s.model)}${s.contextEdit ? ", context editing on" : ""})`,
|
|
800
|
+
...(limit ? [`warning: the gateway accepts the key, but this account cannot use it right now: ${limit}`, `warning: ${c.label} will get that error on every request until the limit resets (or brain gateway disconnect --tool ${c.id} puts it back).`] : []),
|
|
801
|
+
"changed:",
|
|
802
|
+
...shown,
|
|
803
|
+
...c.notes.map((n) => `note: ${n}`),
|
|
804
|
+
`undo: brain gateway disconnect --tool ${c.id}`,
|
|
805
|
+
],
|
|
806
|
+
}
|
|
666
807
|
}
|
|
667
|
-
|
|
668
|
-
for (const { e, d } of docs) writeToolFile(home, c.id, e.path, render(d), e.holdsKey ? 0o600 : undefined, first)
|
|
669
|
-
st.settings = s
|
|
670
|
-
st.tools[c.id] = { at: new Date().toISOString(), changes, ...(created.size ? { created: [...created] } : {}), ...(createdDirs.size ? { createdDirs: [...createdDirs] } : {}), ...(Object.keys(modes).length ? { modes } : {}) }
|
|
671
|
-
saveState(home, st)
|
|
672
|
-
return {
|
|
673
|
-
ok: true,
|
|
674
|
-
lines: [
|
|
675
|
-
`connected ${c.label} to ${routeUrl(s)} (${modelName(s.model)}${s.contextEdit ? ", context editing on" : ""})`,
|
|
676
|
-
...(limit ? [`warning: the gateway accepts the key, but this account cannot use it right now: ${limit}`, `warning: ${c.label} will get that error on every request until the limit resets (or brain gateway disconnect --tool ${c.id} puts it back).`] : []),
|
|
677
|
-
"changed:",
|
|
678
|
-
...shown,
|
|
679
|
-
...c.notes.map((n) => `note: ${n}`),
|
|
680
|
-
`undo: brain gateway disconnect --tool ${c.id}`,
|
|
681
|
-
],
|
|
682
|
-
}
|
|
808
|
+
return o.dryRun ? write() : withBrainLockSync(home, write, { what: `connecting ${c.label}` })
|
|
683
809
|
}
|
|
684
810
|
|
|
685
811
|
export function disconnect(home: string, toolId: string, o: { dryRun?: boolean; env?: Env } = {}): Result {
|
|
686
812
|
const c = connectorFor(toolId)
|
|
687
813
|
if (!c) return { ok: false, lines: [`unknown tool "${toolId}"`] }
|
|
814
|
+
return o.dryRun ? disconnectLocked(home, c, o) : withBrainLockSync(home, () => disconnectLocked(home, c, o), { what: `disconnecting ${c.label}` })
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function disconnectLocked(home: string, c: Connector, o: { dryRun?: boolean; env?: Env }): Result {
|
|
688
818
|
const st = loadState(home)
|
|
689
819
|
const rec = st.tools[c.id]
|
|
690
820
|
if (!rec) return { ok: true, lines: [`${c.label} is not connected by brain; nothing to undo`] }
|
|
@@ -749,6 +879,7 @@ export function status(home: string, env: Record<string, string | undefined> = p
|
|
|
749
879
|
|
|
750
880
|
// ---- the command --------------------------------------------------------------------------------------------------------
|
|
751
881
|
|
|
882
|
+
|
|
752
883
|
const USAGE = [
|
|
753
884
|
"brain gateway login [--no-browser] sign in to 100xprompt in your browser, as `100xprompt auth login` does",
|
|
754
885
|
"brain gateway login --key-env VAR | --from-cli take the key from VAR (scripts, CI) or from the 100xprompt CLI's sign-in",
|
|
@@ -787,6 +918,8 @@ export async function gatewayCommand(
|
|
|
787
918
|
}
|
|
788
919
|
const refreshedNote = (labels: string[]) => {
|
|
789
920
|
if (labels.length) io.out(`the new key also replaced the old one in ${labels.join(" and ")}'s own config`)
|
|
921
|
+
// Signed in but not paired: the one step that puts this computer's settings in the dashboard.
|
|
922
|
+
if (!readMachineToken(home)) io.out("to see this computer's settings in your dashboard: brain pair")
|
|
790
923
|
}
|
|
791
924
|
// The browser sign-in, with a hidden paste prompt when there is a terminal to ask in.
|
|
792
925
|
const browserLogin = async () => {
|
|
@@ -819,25 +952,43 @@ export async function gatewayCommand(
|
|
|
819
952
|
io.out(`usage: ${USAGE[sub === "connect" ? 3 : 4]}`)
|
|
820
953
|
return 2
|
|
821
954
|
}
|
|
822
|
-
|
|
955
|
+
// What connect and disconnect do is recorded as this computer's settings (`brain settings` shows who set what),
|
|
956
|
+
// inside the same hold of the brain lock as the change itself. Loaded here, before connecting: the settings modules
|
|
957
|
+
// import this one.
|
|
958
|
+
const { recordConnected } = await import("../settings/apply.ts")
|
|
959
|
+
if (sub === "disconnect") {
|
|
960
|
+
if (has("dry-run")) return print(disconnect(home, tool, { dryRun: true, env }))
|
|
961
|
+
const r = withBrainLockSync(
|
|
962
|
+
home,
|
|
963
|
+
() => {
|
|
964
|
+
const d = disconnect(home, tool, { env })
|
|
965
|
+
if (d.ok && connectorFor(tool)) recordConnected(home, connectorFor(tool)!.id, undefined)
|
|
966
|
+
return d
|
|
967
|
+
},
|
|
968
|
+
{ what: "disconnecting a tool" },
|
|
969
|
+
)
|
|
970
|
+
return print(r)
|
|
971
|
+
}
|
|
823
972
|
const settings = { ...(flag("url") ? { url: flag("url")! } : {}), ...(flag("route") ? { route: flag("route")! } : {}), ...(flag("model") ? { model: flag("model")! } : {}), ...(has("no-context-edit") ? { contextEdit: false } : has("context-edit") ? { contextEdit: true } : {}) }
|
|
824
973
|
// Not signed in yet: sign in first, as the 100xprompt CLI would, then connect. Everything that can be checked here
|
|
825
974
|
// (the tool, its files, the flags) is checked first, so nobody signs in for a connect that was bound to fail.
|
|
826
975
|
if (!hasKey(home) && !has("dry-run")) {
|
|
827
|
-
const pre = await connect(home, tool, { settings, dryRun: true, env })
|
|
976
|
+
const pre = await connect(home, tool, { settings, dryRun: true, env, usePrefs: true })
|
|
828
977
|
if (!pre.ok) return print(pre)
|
|
829
978
|
io.out("not signed in to the gateway yet; signing in first")
|
|
830
979
|
if (!(await browserLogin())) return 1
|
|
831
980
|
}
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
}),
|
|
840
|
-
|
|
981
|
+
const id = connectorFor(tool)?.id
|
|
982
|
+
const r = await connect(home, tool, {
|
|
983
|
+
settings,
|
|
984
|
+
dryRun: has("dry-run"),
|
|
985
|
+
probe: !has("no-probe"),
|
|
986
|
+
env,
|
|
987
|
+
usePrefs: true,
|
|
988
|
+
...(id ? { onWritten: (s: GatewaySettings) => recordConnected(home, id, s) } : {}),
|
|
989
|
+
...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}),
|
|
990
|
+
})
|
|
991
|
+
return print(r)
|
|
841
992
|
}
|
|
842
993
|
case "status":
|
|
843
994
|
status(home, env).forEach((l) => io.out(l))
|
|
@@ -105,7 +105,7 @@ function section(lines: string[], name: string): Section | undefined {
|
|
|
105
105
|
const at = lines.findIndex((l) => keyLine("", name).test(l))
|
|
106
106
|
if (at < 0) return undefined
|
|
107
107
|
const inline = inlineValue(lines[at]!)
|
|
108
|
-
if (inline && !/^(?:\{\s*\}|null|~)$/.test(inline)) throw new Error(`${name}: is written on one line
|
|
108
|
+
if (inline && !/^(?:\{\s*\}|null|~)$/.test(inline)) throw new Error(`${name}: is written on one line, not as a section`)
|
|
109
109
|
const end = blockEnd(lines, at)
|
|
110
110
|
const first = lines.slice(at + 1, end).find((l) => !blank(l))
|
|
111
111
|
const indent = first ? indentOf(first) : ""
|
package/src/gateway/signin.ts
CHANGED
|
@@ -118,8 +118,38 @@ export interface SignInDeps {
|
|
|
118
118
|
|
|
119
119
|
export type SignIn = { ok: true; key: string; email?: string; via: "browser" | "code" } | { ok: false; why: string }
|
|
120
120
|
|
|
121
|
+
type Took = { ok: true; key: string; email?: string } | { ok: false; why: string }
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Which browser round trip to run: the sign-in (`brain gateway login`: /auth/cli, a gateway key comes back) or pairing
|
|
125
|
+
* (`brain pair`: /auth/brain, the computer's own token comes back). Everything else (the loopback server, the state,
|
|
126
|
+
* the paste fallback) is the same.
|
|
127
|
+
*/
|
|
128
|
+
export interface BrowserFlow {
|
|
129
|
+
/** The command the person ran, named in the refusal page. */
|
|
130
|
+
command: string
|
|
131
|
+
/** The site's page, and what else it is told. */
|
|
132
|
+
path: string
|
|
133
|
+
params?: Record<string, string>
|
|
134
|
+
/** What a callback's token or a pasted text must be to be kept. */
|
|
135
|
+
accept(token: string): Took
|
|
136
|
+
/** Words: what the browser is opened for, the page shown when it worked, and what to paste. */
|
|
137
|
+
opening: string
|
|
138
|
+
done: (email?: string) => { title: string; body: string }
|
|
139
|
+
paste: string
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export const SIGN_IN_FLOW: BrowserFlow = {
|
|
143
|
+
command: "brain gateway login",
|
|
144
|
+
path: "/auth/cli",
|
|
145
|
+
accept: (token) => callbackKey(token),
|
|
146
|
+
opening: "sign in to 100xprompt",
|
|
147
|
+
done: (email) => ({ title: "Signed in", body: `brain is now signed in to the 100xprompt gateway${email ? ` as ${email}` : ""}. You can close this tab and go back to your terminal.` }),
|
|
148
|
+
paste: "Paste the key from the page's Copy key button, or the address the browser could not open",
|
|
149
|
+
}
|
|
150
|
+
|
|
121
151
|
/** 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> {
|
|
152
|
+
export async function browserSignIn(d: SignInDeps, flow: BrowserFlow = SIGN_IN_FLOW): Promise<SignIn> {
|
|
123
153
|
const base = signInBase(d.authUrl)
|
|
124
154
|
if (!base) return { ok: false, why: "HUNDREDXPROMPT_AUTH_URL must be an http:// or https:// address" }
|
|
125
155
|
const { challenge } = await pkce()
|
|
@@ -145,35 +175,37 @@ export async function browserSignIn(d: SignInDeps): Promise<SignIn> {
|
|
|
145
175
|
const got = url.searchParams.get("state") ?? ""
|
|
146
176
|
const error = url.searchParams.get("error")
|
|
147
177
|
// 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",
|
|
178
|
+
if (!/^[0-9a-f]{64}$/.test(got) || !sameState(got, state)) return page("Sign-in refused", `This link was not started by this ${flow.command}. Start again from your terminal.`, 400)
|
|
149
179
|
if (error) {
|
|
150
180
|
settle({ ok: false, why: `the sign-in page said: ${error.slice(0, 200)}` })
|
|
151
181
|
return page("Sign-in failed", `${error.slice(0, 200)}. Go back to your terminal.`, 400)
|
|
152
182
|
}
|
|
153
|
-
const c =
|
|
183
|
+
const c = flow.accept(url.searchParams.get("token") ?? "")
|
|
154
184
|
if (!c.ok) {
|
|
155
185
|
settle({ ok: false, why: c.why })
|
|
156
186
|
return page("Sign-in failed", "No usable key came back. Go back to your terminal.", 400)
|
|
157
187
|
}
|
|
158
188
|
const email = url.searchParams.get("email") || c.email || undefined
|
|
159
189
|
settle({ ok: true, key: c.key, ...(email ? { email } : {}), via: "browser" })
|
|
160
|
-
|
|
190
|
+
const done = flow.done(email)
|
|
191
|
+
return page(done.title, done.body)
|
|
161
192
|
},
|
|
162
193
|
})
|
|
163
|
-
const
|
|
194
|
+
const extra = Object.entries(flow.params ?? {}).map(([k, v]) => `&${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("")
|
|
195
|
+
const url = `${base}${flow.path}?port=${server.port}&state=${state}&challenge=${challenge}${extra}`
|
|
164
196
|
const stopAsking = new AbortController()
|
|
165
197
|
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
198
|
try {
|
|
167
|
-
if (d.browser === false) d.say(`Open this address in a browser to
|
|
199
|
+
if (d.browser === false) d.say(`Open this address in a browser to ${flow.opening}:\n ${url}`)
|
|
168
200
|
else {
|
|
169
|
-
d.say(`Opening your browser to
|
|
201
|
+
d.say(`Opening your browser to ${flow.opening}:\n ${url}`)
|
|
170
202
|
try {
|
|
171
203
|
;(d.open ?? openInBrowser)(url)
|
|
172
204
|
} catch {}
|
|
173
205
|
}
|
|
174
206
|
if (d.ask) {
|
|
175
207
|
d.say(
|
|
176
|
-
|
|
208
|
+
`Waiting for the browser. Signing in on another machine (SSH)? ${flow.paste}, then press Enter (Escape cancels).`,
|
|
177
209
|
)
|
|
178
210
|
// The paste runs alongside the browser: whichever comes first wins. A bad paste asks again; Escape ends the sign-in
|
|
179
211
|
// (an abandoned hidden prompt would leave the terminal echoing whatever is pasted next, and the shell reading it).
|
|
@@ -183,7 +215,7 @@ export async function browserSignIn(d: SignInDeps): Promise<SignIn> {
|
|
|
183
215
|
if (settled) return
|
|
184
216
|
if (pasted === undefined) return settle({ ok: false, why: "sign-in cancelled" })
|
|
185
217
|
if (!pasted.trim()) continue
|
|
186
|
-
const c = fromPaste(pasted.trim(), state)
|
|
218
|
+
const c = fromPaste(pasted.trim(), state, flow)
|
|
187
219
|
if (c.ok) return settle({ ok: true, key: c.key, ...(c.email ? { email: c.email } : {}), via: "code" })
|
|
188
220
|
if ("fatal" in c) return settle({ ok: false, why: c.why })
|
|
189
221
|
d.say(` ${c.why}`)
|
|
@@ -207,7 +239,7 @@ export async function browserSignIn(d: SignInDeps): Promise<SignIn> {
|
|
|
207
239
|
* 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
240
|
* state, so one from another sign-in, or one made up, is refused.
|
|
209
241
|
*/
|
|
210
|
-
export function fromPaste(pasted: string, state: string): { ok: true; key: string; email?: string } | { ok: false; why: string } | { ok: false; why: string; fatal: true } {
|
|
242
|
+
export function fromPaste(pasted: string, state: string, flow: BrowserFlow = SIGN_IN_FLOW): { ok: true; key: string; email?: string } | { ok: false; why: string } | { ok: false; why: string; fatal: true } {
|
|
211
243
|
if (/^https?:\/\//i.test(pasted)) {
|
|
212
244
|
let u: URL
|
|
213
245
|
try {
|
|
@@ -220,12 +252,12 @@ export function fromPaste(pasted: string, state: string): { ok: true; key: strin
|
|
|
220
252
|
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
253
|
const error = u.searchParams.get("error")
|
|
222
254
|
if (error) return { ok: false, why: `the sign-in page said: ${error.slice(0, 200)}`, fatal: true }
|
|
223
|
-
const c =
|
|
255
|
+
const c = flow.accept(u.searchParams.get("token") ?? "")
|
|
224
256
|
if (!c.ok) return c
|
|
225
257
|
const email = u.searchParams.get("email") || c.email
|
|
226
258
|
return { ok: true, key: c.key, ...(email ? { email } : {}) }
|
|
227
259
|
}
|
|
228
|
-
return checkCredential(pasted)
|
|
260
|
+
return flow === SIGN_IN_FLOW ? checkCredential(pasted) : flow.accept(pasted)
|
|
229
261
|
}
|
|
230
262
|
|
|
231
263
|
/**
|