@elliotsystems/eai 0.1.4 → 0.1.7
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 +84 -4
- package/bin/eai +139 -2
- package/lib/connect.js +205 -0
- package/lib/gdrive.js +238 -0
- package/lib/logout.js +73 -0
- package/lib/setup.js +534 -24
- package/lib/stage.js +100 -6
- package/lib/status.js +133 -0
- package/lib/token-env.js +53 -17
- package/lib/update-check.js +27 -0
- package/lib/update.js +303 -0
- package/package.json +3 -2
- package/plugin/opencode-plugin/package.json +1 -1
- package/plugin/opencode-plugin/src/classifier.ts +2 -1
- package/plugin/opencode-plugin/src/elliot-auth.ts +56 -104
- package/plugin/opencode-plugin/src/identity-headers.ts +81 -0
- package/plugin/opencode-plugin/src/index.ts +22 -31
- package/plugin/opencode-plugin/src/mcp-keys.ts +132 -1
- package/plugin/opencode-plugin/src/policy.ts +36 -4
- package/plugin/opencode-plugin/src/proxy-provider.ts +153 -72
- package/plugin/opencode-plugin/src/scope.ts +2 -1
- package/plugin/opencode-plugin/src/tui/format.ts +37 -0
- package/plugin/opencode-plugin/src/tui/knowledge.ts +9 -3
- package/plugin/opencode-plugin/src/tui/sidebar.tsx +90 -0
- package/plugin/opencode-plugin/src/tui/usage.ts +132 -0
package/lib/update.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `eai update` — move this machine onto the current published CLI.
|
|
5
|
+
*
|
|
6
|
+
* ## Why this exists rather than "just run npm i -g"
|
|
7
|
+
*
|
|
8
|
+
* Because the user does not necessarily know how `eai` got onto their machine,
|
|
9
|
+
* and the right command differs: a global npm install, a pnpm or bun global, or
|
|
10
|
+
* a `node_modules` inside a project are four different updates, and running the
|
|
11
|
+
* wrong one either fails confusingly or installs a SECOND copy that shadows the
|
|
12
|
+
* first. Onboarding is one `npm i -g @elliotsystems/eai`, so the common case is
|
|
13
|
+
* easy; the point of this command is that the uncommon cases say what to do
|
|
14
|
+
* instead of failing.
|
|
15
|
+
*
|
|
16
|
+
* ## It updates the CLI, which brings the runtime with it
|
|
17
|
+
*
|
|
18
|
+
* `opencode-ai` is a dependency of this package, pinned to an exact version.
|
|
19
|
+
* So the OpenCode runtime a user has is decided by the EAI version they have,
|
|
20
|
+
* and npm fetches the matching runtime as part of the install. There is
|
|
21
|
+
* deliberately no separate "update OpenCode" path: the two moving
|
|
22
|
+
* independently on a user's machine is the drift this whole mechanism exists
|
|
23
|
+
* to prevent, and `check_opencode_versions.mjs` enforces the same pairing in
|
|
24
|
+
* CI.
|
|
25
|
+
*
|
|
26
|
+
* ## Nothing here hardcodes a version
|
|
27
|
+
*
|
|
28
|
+
* The published version is asked of the registry, and the installed one is read
|
|
29
|
+
* from the package. A literal in this file would be a third source of truth
|
|
30
|
+
* that goes stale on the next release and keeps looking authoritative.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const childProcess = require("child_process")
|
|
34
|
+
const fs = require("fs")
|
|
35
|
+
const os = require("os")
|
|
36
|
+
const path = require("path")
|
|
37
|
+
|
|
38
|
+
const PACKAGE_NAME = "@elliotsystems/eai"
|
|
39
|
+
|
|
40
|
+
/** The npm registry's own metadata endpoint for the published version. */
|
|
41
|
+
const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Run a package-manager command, portably and without a deprecation warning.
|
|
45
|
+
*
|
|
46
|
+
* Windows needs a shell: npm, pnpm and bun are all `.cmd` shims there, and
|
|
47
|
+
* execFile cannot run a `.cmd` directly — it throws ENOENT, which silently
|
|
48
|
+
* made every Windows user's global install look like "not a global install".
|
|
49
|
+
*
|
|
50
|
+
* But `shell: true` TOGETHER WITH an args array triggers Node 24's DEP0190
|
|
51
|
+
* ("arguments are not escaped, only concatenated"), which printed a security
|
|
52
|
+
* warning on every `eai update` run. Passing one pre-joined command string
|
|
53
|
+
* instead does the same work without the warning, because the concatenation
|
|
54
|
+
* is then explicitly ours rather than something Node did behind our back.
|
|
55
|
+
*
|
|
56
|
+
* That is only safe because every command and argument here is a literal from
|
|
57
|
+
* the table in detectInstall. Nothing user-supplied is ever interpolated, and
|
|
58
|
+
* nothing in this file should start doing so.
|
|
59
|
+
*/
|
|
60
|
+
function runTool(bin, args, options) {
|
|
61
|
+
if (process.platform === "win32") {
|
|
62
|
+
return childProcess.execSync([bin, ...args].join(" "), { ...options, shell: undefined })
|
|
63
|
+
}
|
|
64
|
+
return childProcess.execFileSync(bin, args, options)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* How was this copy installed?
|
|
69
|
+
*
|
|
70
|
+
* Decided from the path the package actually sits at, not from a guess: an
|
|
71
|
+
* `npm root -g` that matches means a global install, and anything else is
|
|
72
|
+
* reported rather than acted on. Updating the wrong tree is how a user ends up
|
|
73
|
+
* with two `eai` binaries and no idea which one runs.
|
|
74
|
+
*/
|
|
75
|
+
function detectInstall(packageRoot) {
|
|
76
|
+
const managers = [
|
|
77
|
+
{ name: "npm", probe: ["npm", ["root", "-g"]], update: "npm install -g " + PACKAGE_NAME + "@latest" },
|
|
78
|
+
{ name: "pnpm", probe: ["pnpm", ["root", "-g"]], update: "pnpm add -g " + PACKAGE_NAME + "@latest" },
|
|
79
|
+
{ name: "bun", probe: ["bun", ["pm", "bin", "-g"]], update: "bun add -g " + PACKAGE_NAME + "@latest" },
|
|
80
|
+
]
|
|
81
|
+
const resolved = (() => {
|
|
82
|
+
try {
|
|
83
|
+
return fs.realpathSync(packageRoot)
|
|
84
|
+
} catch {
|
|
85
|
+
return packageRoot
|
|
86
|
+
}
|
|
87
|
+
})()
|
|
88
|
+
|
|
89
|
+
for (const m of managers) {
|
|
90
|
+
let root
|
|
91
|
+
try {
|
|
92
|
+
root = runTool(m.probe[0], m.probe[1], {
|
|
93
|
+
encoding: "utf8",
|
|
94
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
95
|
+
timeout: 15000,
|
|
96
|
+
// REQUIRED on Windows, and its absence is not a degraded probe — it is
|
|
97
|
+
// a total one. `npm`, `pnpm` and `bun` are all `.cmd` shims there, and
|
|
98
|
+
// execFileSync cannot execute a `.cmd` without a shell: it throws
|
|
99
|
+
// ENOENT, the catch below skips the manager, every manager is skipped,
|
|
100
|
+
// and `eai update` tells every Windows user their global install is
|
|
101
|
+
// "not a global install" and refuses. Windows is the platform
|
|
102
|
+
// publish-eai.yml validates on.
|
|
103
|
+
//
|
|
104
|
+
}).trim()
|
|
105
|
+
} catch {
|
|
106
|
+
continue // manager not installed; not an error, just not this one
|
|
107
|
+
}
|
|
108
|
+
if (!root) continue
|
|
109
|
+
let realRoot
|
|
110
|
+
try {
|
|
111
|
+
realRoot = fs.realpathSync(root)
|
|
112
|
+
} catch {
|
|
113
|
+
realRoot = root
|
|
114
|
+
}
|
|
115
|
+
if (resolved.startsWith(realRoot + path.sep) || resolved.startsWith(root + path.sep)) {
|
|
116
|
+
return { kind: "global", manager: m.name, command: m.update }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { kind: "unknown", manager: null, command: null }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The published version, or null.
|
|
124
|
+
*
|
|
125
|
+
* Deliberately quiet on failure. An offline machine, a proxy or a registry
|
|
126
|
+
* blip must not turn `eai update` into an error — and must REALLY not turn a
|
|
127
|
+
* background check (see maybeNotify) into one.
|
|
128
|
+
*/
|
|
129
|
+
async function latestPublished(timeoutMs = 5000) {
|
|
130
|
+
const controller = new AbortController()
|
|
131
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
132
|
+
try {
|
|
133
|
+
const response = await fetch(REGISTRY_URL, {
|
|
134
|
+
signal: controller.signal,
|
|
135
|
+
// `application/vnd.npm.install-v1+json` returns an abbreviated document.
|
|
136
|
+
// Smaller and, more to the point, it is the contract npm itself uses, so
|
|
137
|
+
// it is the least likely shape to change under us.
|
|
138
|
+
headers: { accept: "application/vnd.npm.install-v1+json, application/json" },
|
|
139
|
+
})
|
|
140
|
+
if (!response.ok) return null
|
|
141
|
+
const body = await response.json()
|
|
142
|
+
return typeof body?.version === "string" ? body.version : null
|
|
143
|
+
} catch {
|
|
144
|
+
return null
|
|
145
|
+
} finally {
|
|
146
|
+
clearTimeout(timer)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function compareSemver(a, b) {
|
|
151
|
+
const parse = (v) => String(v).split("-")[0].split(".").map((n) => Number(n) || 0)
|
|
152
|
+
const [x, y] = [parse(a), parse(b)]
|
|
153
|
+
return x[0] - y[0] || x[1] - y[1] || x[2] - y[2]
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** `eai update`. */
|
|
157
|
+
async function runUpdate({ packageRoot, version, argv = [] } = {}) {
|
|
158
|
+
const check = argv.includes("--check")
|
|
159
|
+
const latest = await latestPublished()
|
|
160
|
+
|
|
161
|
+
if (!latest) {
|
|
162
|
+
process.stderr.write(
|
|
163
|
+
"eai update: could not reach the npm registry.\n" +
|
|
164
|
+
"Nothing was changed. Check your connection or proxy and try again.\n",
|
|
165
|
+
)
|
|
166
|
+
return 1
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
process.stdout.write(`installed: ${version}\npublished: ${latest}\n`)
|
|
170
|
+
|
|
171
|
+
if (compareSemver(latest, version) <= 0) {
|
|
172
|
+
process.stdout.write("\nAlready up to date.\n")
|
|
173
|
+
return 0
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const install = detectInstall(packageRoot)
|
|
177
|
+
if (check) {
|
|
178
|
+
process.stdout.write(`\nUpdate available. Run: ${install.command || "eai update"}\n`)
|
|
179
|
+
return 0
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (install.kind !== "global") {
|
|
183
|
+
// Not a failure — a refusal to guess. Installing globally over a local
|
|
184
|
+
// checkout or a project dependency would leave two copies and no signal
|
|
185
|
+
// about which one the shell resolves.
|
|
186
|
+
process.stderr.write(
|
|
187
|
+
`\neai update: this copy of ${PACKAGE_NAME} is not a global install.\n` +
|
|
188
|
+
`It is at: ${packageRoot}\n` +
|
|
189
|
+
"Update it the way it was installed — or install globally with:\n" +
|
|
190
|
+
` npm install -g ${PACKAGE_NAME}@latest\n`,
|
|
191
|
+
)
|
|
192
|
+
return 1
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
process.stdout.write(`\nUpdating via ${install.manager}: ${install.command}\n\n`)
|
|
196
|
+
// One command string, not (bin, args) — see runTool above for why the args
|
|
197
|
+
// form prints a DEP0190 security warning on every Windows run.
|
|
198
|
+
const result = process.platform === "win32"
|
|
199
|
+
? childProcess.spawnSync(install.command, { stdio: "inherit", shell: true })
|
|
200
|
+
: childProcess.spawnSync(install.command.split(" ")[0], install.command.split(" ").slice(1), { stdio: "inherit" })
|
|
201
|
+
if (result.status !== 0) {
|
|
202
|
+
process.stderr.write(
|
|
203
|
+
`\neai update: ${install.manager} exited ${result.status}.\n` +
|
|
204
|
+
"On a permissions error, either re-run with the privileges that install needs,\n" +
|
|
205
|
+
"or point npm at a user-writable prefix (`npm config set prefix`).\n",
|
|
206
|
+
)
|
|
207
|
+
return result.status || 1
|
|
208
|
+
}
|
|
209
|
+
process.stdout.write(`\nUpdated to ${latest}. Run \`eai --version\` to confirm.\n`)
|
|
210
|
+
return 0
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── update notification ────────────────────────────────────────────────────
|
|
214
|
+
//
|
|
215
|
+
// A CLI that silently goes stale is a CLI whose users report bugs that were
|
|
216
|
+
// fixed months ago. But a version check on the hot path is worse than the
|
|
217
|
+
// problem: it adds latency to every launch, it fails on an offline machine,
|
|
218
|
+
// and a nag on every single run gets ignored and then resented.
|
|
219
|
+
//
|
|
220
|
+
// So the notice is: throttled to once a day, written to STDERR so it can never
|
|
221
|
+
// corrupt piped output, never blocking (the check runs against a cached answer
|
|
222
|
+
// from a PREVIOUS run, never a live request on this one), and switchable off.
|
|
223
|
+
|
|
224
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
|
|
225
|
+
|
|
226
|
+
function stateFile() {
|
|
227
|
+
return path.join(os.homedir(), ".elliot", "update-check.json")
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function readState() {
|
|
231
|
+
try {
|
|
232
|
+
return JSON.parse(fs.readFileSync(stateFile(), "utf8")) || {}
|
|
233
|
+
} catch {
|
|
234
|
+
return {}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function writeState(state) {
|
|
239
|
+
try {
|
|
240
|
+
fs.mkdirSync(path.dirname(stateFile()), { recursive: true })
|
|
241
|
+
fs.writeFileSync(stateFile(), JSON.stringify(state))
|
|
242
|
+
} catch {
|
|
243
|
+
// A read-only or full home directory must not break the CLI over a
|
|
244
|
+
// convenience feature. Losing the throttle means re-checking tomorrow.
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function notificationsDisabled(env) {
|
|
249
|
+
// Two switches on purpose. ELLIOT_NO_UPDATE_CHECK is ours and explicit;
|
|
250
|
+
// CI is honoured because a version nag in build logs is pure noise and
|
|
251
|
+
// nobody sets a flag they do not know exists.
|
|
252
|
+
return Boolean(env.ELLIOT_NO_UPDATE_CHECK || env.CI || env.NO_UPDATE_NOTIFIER)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Print a notice if a newer version was seen EARLIER, then refresh the cache in
|
|
257
|
+
* the background.
|
|
258
|
+
*
|
|
259
|
+
* The ordering is the whole design. This run reports what the last run already
|
|
260
|
+
* learned, so the common path does no network I/O at all and costs a file read.
|
|
261
|
+
* The refresh is detached and unref'd, so the process exits without waiting on
|
|
262
|
+
* it, and a hung registry delays nothing.
|
|
263
|
+
*/
|
|
264
|
+
function maybeNotify({ version, env = process.env, now = Date.now() } = {}) {
|
|
265
|
+
if (notificationsDisabled(env)) return
|
|
266
|
+
const state = readState()
|
|
267
|
+
|
|
268
|
+
if (state.latest && compareSemver(state.latest, version) > 0) {
|
|
269
|
+
process.stderr.write(
|
|
270
|
+
`\nA newer Elliot CLI is available: ${version} → ${state.latest}\n` +
|
|
271
|
+
"Run `eai update` to upgrade. (`ELLIOT_NO_UPDATE_CHECK=1` silences this.)\n\n",
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (now - (state.checkedAt || 0) < CHECK_INTERVAL_MS) return
|
|
276
|
+
// Stamp BEFORE spawning. If the check fails or the machine is offline, the
|
|
277
|
+
// throttle still holds and we do not spawn a doomed probe on every run.
|
|
278
|
+
writeState({ ...state, checkedAt: now })
|
|
279
|
+
try {
|
|
280
|
+
const child = childProcess.spawn(
|
|
281
|
+
process.execPath,
|
|
282
|
+
[path.join(__dirname, "update-check.js")],
|
|
283
|
+
{ detached: true, stdio: "ignore", env },
|
|
284
|
+
)
|
|
285
|
+
child.unref()
|
|
286
|
+
} catch {
|
|
287
|
+
// Best effort, always.
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
module.exports = {
|
|
292
|
+
PACKAGE_NAME,
|
|
293
|
+
runUpdate,
|
|
294
|
+
latestPublished,
|
|
295
|
+
compareSemver,
|
|
296
|
+
detectInstall,
|
|
297
|
+
maybeNotify,
|
|
298
|
+
notificationsDisabled,
|
|
299
|
+
stateFile,
|
|
300
|
+
readState,
|
|
301
|
+
writeState,
|
|
302
|
+
CHECK_INTERVAL_MS,
|
|
303
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elliotsystems/eai",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Elliot AI CLI entry point (eai) - launches the Elliot runtime with arguments forwarded verbatim.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"eai": "./bin/eai"
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"README.md"
|
|
14
14
|
],
|
|
15
15
|
"scripts": {
|
|
16
|
+
"pretest": "node scripts/bundle.js",
|
|
16
17
|
"test": "node --test test/*.test.js",
|
|
17
18
|
"publish:npm": "node scripts/publish.js",
|
|
18
19
|
"bundle": "node scripts/bundle.js",
|
|
@@ -26,6 +27,6 @@
|
|
|
26
27
|
"access": "public"
|
|
27
28
|
},
|
|
28
29
|
"dependencies": {
|
|
29
|
-
"opencode-ai": "1.
|
|
30
|
+
"opencode-ai": "1.19.0"
|
|
30
31
|
}
|
|
31
32
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shell-command classification — the policy intelligence glob patterns cannot express.
|
|
3
3
|
*
|
|
4
|
-
* Ported (adapted, not copied wholesale) from
|
|
4
|
+
* Ported (adapted, not copied wholesale) from the retired elliot-ai CLI's
|
|
5
|
+
* `src/tools/bash-policy.ts`,
|
|
5
6
|
* which is pure and dependency-free. Three things here are beyond what OpenCode's
|
|
6
7
|
* native `permission.bash` globs can represent, which is exactly why this file exists:
|
|
7
8
|
*
|
|
@@ -1,27 +1,70 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Reads `~/.elliot/config.json` — written by `eai setup`/`elliot-ai setup`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* file exists inside `opencode-plugin` rather than importing the CLI package
|
|
9
|
-
* because the plugin ships and is loaded independently of `elliot-cli` — see
|
|
10
|
-
* `docs/superpowers/plans/2026-09-03-eai-proxy-auth.md`.
|
|
4
|
+
* Used two ways: `readElliotConfig() !== null` is "is the user logged in at
|
|
5
|
+
* all" (the proxy-provider config hook is a no-op otherwise); and its
|
|
6
|
+
* `litellm_base_url`/`litellm_master_key` fields are the LLM provider's
|
|
7
|
+
* connection info directly.
|
|
11
8
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* Formerly also home to `freshToken`/`isExpired` — a self-refreshing JWT
|
|
10
|
+
* mechanism for the per-instance credential `/llm-proxy` used to validate.
|
|
11
|
+
* Removed along with `/llm-proxy`: the LiteLLM gateway this now points at
|
|
12
|
+
* authenticates with ONE static, shared `litellm_master_key` (LiteLLM is
|
|
13
|
+
* deliberately DB-less — no per-tenant/per-instance credential is possible
|
|
14
|
+
* without one), which never expires and so never needs refreshing.
|
|
15
15
|
*/
|
|
16
16
|
import * as fs from "node:fs"
|
|
17
17
|
import * as os from "node:os"
|
|
18
18
|
import * as path from "node:path"
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* One LiteLLM credential this person can use from OpenCode — their own
|
|
22
|
+
* personal key, or a team's shared key (Phase 4, docs/LITELLM_GATEWAY.md
|
|
23
|
+
* §14 / PRD PO-4/4.3/4.4). `provider_id` is the OpenCode provider id
|
|
24
|
+
* `configHook` registers it under directly (e.g. `elliot` for the personal
|
|
25
|
+
* default, `elliot-team-<slug>` per team) — computed backend-side
|
|
26
|
+
* (`onboarding.py`), not derived here, so naming stays in one place.
|
|
27
|
+
*/
|
|
28
|
+
export interface ElliotCredential {
|
|
29
|
+
provider_id: string
|
|
30
|
+
/** Shown in OpenCode's own model picker, e.g. "Elliot (Personal)" or
|
|
31
|
+
* "Elliot — Team Platform". */
|
|
32
|
+
label: string
|
|
33
|
+
api_key: string
|
|
34
|
+
/** Present for a team credential, absent for the personal one. */
|
|
35
|
+
team_id?: string
|
|
36
|
+
/**
|
|
37
|
+
* This credential's own team's Phase 4 model restriction
|
|
38
|
+
* (docs/LITELLM_GATEWAY.md §14), or absent/null for unrestricted — always
|
|
39
|
+
* absent on the personal credential, which has no team and so no
|
|
40
|
+
* restriction concept. LiteLLM gateway audit finding #5: each provider
|
|
41
|
+
* `configHook` registers must be filtered by ITS OWN credential's
|
|
42
|
+
* restriction, not a shared list — a user on both a restricted and an
|
|
43
|
+
* unrestricted team must not have one leak into the other.
|
|
44
|
+
*/
|
|
45
|
+
allowed_models?: string[] | null
|
|
46
|
+
}
|
|
47
|
+
|
|
20
48
|
export interface ElliotConfig {
|
|
21
49
|
jwt_token?: string
|
|
22
|
-
refresh_token?: string
|
|
23
50
|
backend_url?: string
|
|
24
|
-
|
|
51
|
+
litellm_base_url?: string
|
|
52
|
+
litellm_master_key?: string
|
|
53
|
+
/**
|
|
54
|
+
* Every credential this person can use, personal key first (Phase 4).
|
|
55
|
+
* Absent on a config written by an older `eai setup` (or a backend that
|
|
56
|
+
* predates this field) — `configHook` falls back to the single
|
|
57
|
+
* `litellm_master_key` provider in that case, so an old config keeps
|
|
58
|
+
* working unchanged rather than losing LLM access outright.
|
|
59
|
+
*/
|
|
60
|
+
credentials?: ElliotCredential[]
|
|
61
|
+
/**
|
|
62
|
+
* The single scoped team's own Phase 4 model restriction, when
|
|
63
|
+
* `credentials` is absent and the legacy `litellm_master_key` fallback
|
|
64
|
+
* provider is registered instead — same field, same meaning as
|
|
65
|
+
* `ElliotCredential.allowed_models`, just for the one-provider path.
|
|
66
|
+
*/
|
|
67
|
+
allowed_models?: string[] | null
|
|
25
68
|
[k: string]: unknown
|
|
26
69
|
}
|
|
27
70
|
|
|
@@ -40,94 +83,3 @@ export function readElliotConfig(): ElliotConfig | null {
|
|
|
40
83
|
return null
|
|
41
84
|
}
|
|
42
85
|
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Persist the config, owner-only (0600) — this file holds a live bearer
|
|
46
|
-
* token. Creates `~/.elliot` (0700) if it doesn't exist yet.
|
|
47
|
-
*/
|
|
48
|
-
export function writeElliotConfig(cfg: ElliotConfig): void {
|
|
49
|
-
const configPath = elliotConfigPath()
|
|
50
|
-
const configDir = path.dirname(configPath)
|
|
51
|
-
if (!fs.existsSync(configDir)) {
|
|
52
|
-
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 })
|
|
53
|
-
}
|
|
54
|
-
fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2), { mode: 0o600 })
|
|
55
|
-
fs.chmodSync(configPath, 0o600)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** `exp` (seconds since epoch) from a JWT's middle segment, or null. */
|
|
59
|
-
function decodeJwtExp(token: string): number | null {
|
|
60
|
-
try {
|
|
61
|
-
const parts = token.split(".")
|
|
62
|
-
if (parts.length < 2) return null
|
|
63
|
-
const payload = JSON.parse(Buffer.from(parts[1], "base64").toString("utf-8"))
|
|
64
|
-
return typeof payload?.exp === "number" ? payload.exp : null
|
|
65
|
-
} catch {
|
|
66
|
-
return null
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const REFRESH_SKEW_MS = 60_000
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* True if the access token is expired or within `REFRESH_SKEW_MS` of
|
|
74
|
-
* expiring. Prefers the stored `access_token_expires_at`; falls back to
|
|
75
|
-
* decoding `jwt_token`'s `exp` claim; true (treat as expired -> refresh) when
|
|
76
|
-
* neither is decodable.
|
|
77
|
-
*/
|
|
78
|
-
export function isExpired(cfg: ElliotConfig, nowMs = Date.now()): boolean {
|
|
79
|
-
if (cfg.access_token_expires_at) {
|
|
80
|
-
const expiresAtMs = new Date(cfg.access_token_expires_at).getTime()
|
|
81
|
-
if (Number.isFinite(expiresAtMs)) {
|
|
82
|
-
return expiresAtMs - REFRESH_SKEW_MS <= nowMs
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
const exp = cfg.jwt_token ? decodeJwtExp(cfg.jwt_token) : null
|
|
86
|
-
if (exp === null) return true
|
|
87
|
-
return exp * 1000 - REFRESH_SKEW_MS <= nowMs
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
interface RefreshResponse {
|
|
91
|
-
access_token: string
|
|
92
|
-
expires_in_seconds: number
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
interface FreshTokenDeps {
|
|
96
|
-
fetchImpl?: typeof fetch
|
|
97
|
-
write?: (cfg: ElliotConfig) => void
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* A valid access token, refreshing + persisting when expired and a
|
|
102
|
-
* `refresh_token`/`backend_url` are present. NEVER throws: any failure
|
|
103
|
-
* (network, non-2xx, malformed response) falls back to the existing
|
|
104
|
-
* `jwt_token` — the proxy 401s on a stale token rather than this crashing the
|
|
105
|
-
* model call.
|
|
106
|
-
*/
|
|
107
|
-
export async function freshToken(
|
|
108
|
-
cfg: ElliotConfig,
|
|
109
|
-
deps?: FreshTokenDeps,
|
|
110
|
-
): Promise<string | null> {
|
|
111
|
-
try {
|
|
112
|
-
if (cfg.jwt_token && !isExpired(cfg)) return cfg.jwt_token
|
|
113
|
-
if (!cfg.refresh_token || !cfg.backend_url) return cfg.jwt_token ?? null
|
|
114
|
-
|
|
115
|
-
const fetchImpl = deps?.fetchImpl ?? fetch
|
|
116
|
-
const write = deps?.write ?? writeElliotConfig
|
|
117
|
-
|
|
118
|
-
const response = await fetchImpl(`${cfg.backend_url}/auth/refresh`, {
|
|
119
|
-
method: "POST",
|
|
120
|
-
headers: { "Content-Type": "application/json" },
|
|
121
|
-
body: JSON.stringify({ refresh_token: cfg.refresh_token }),
|
|
122
|
-
})
|
|
123
|
-
if (!response.ok) return cfg.jwt_token ?? null
|
|
124
|
-
|
|
125
|
-
const data = (await response.json()) as RefreshResponse
|
|
126
|
-
cfg.jwt_token = data.access_token
|
|
127
|
-
cfg.access_token_expires_at = new Date(Date.now() + data.expires_in_seconds * 1000).toISOString()
|
|
128
|
-
write(cfg)
|
|
129
|
-
return data.access_token
|
|
130
|
-
} catch {
|
|
131
|
-
return cfg.jwt_token ?? null
|
|
132
|
-
}
|
|
133
|
-
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-message LiteLLM `end_user` attribution for a TEAM-shared runtime.
|
|
3
|
+
*
|
|
4
|
+
* A team instance's LiteLLM provider options carry exactly ONE static
|
|
5
|
+
* `x-litellm-end-user-id` header (`teamconfig.py::_litellm_identity_headers`),
|
|
6
|
+
* baked in once at config-generation time as the shared runtime's own
|
|
7
|
+
* instance id — correct for "which runtime", never "which person", because
|
|
8
|
+
* every authorized team member sends messages through that same instance.
|
|
9
|
+
* Confirmed live: two different people chatting through the same team
|
|
10
|
+
* runtime produced identical `end_user` values in LiteLLM's own spend logs.
|
|
11
|
+
*
|
|
12
|
+
* This corrects it per REQUEST via OpenCode's own `chat.headers` hook, which
|
|
13
|
+
* fires once per chat turn with that turn's live session id — late enough to
|
|
14
|
+
* win: `session/llm/request.ts` spreads this hook's returned headers LAST,
|
|
15
|
+
* after the static `options.headers` from `opencode.json`. The real
|
|
16
|
+
* sender's id is read back from that session's own metadata, stamped there
|
|
17
|
+
* by the Elliot backend right after creating the session
|
|
18
|
+
* (`opencode_chat.py::create_session`, `PATCH /session/:id`) — never
|
|
19
|
+
* invented here, never derived from the message content.
|
|
20
|
+
*
|
|
21
|
+
* A session with no such metadata — every session that predates this
|
|
22
|
+
* feature, a generic/personal instance (which already attributes correctly
|
|
23
|
+
* via its own static `owner_user_id` header and has no need of this), or a
|
|
24
|
+
* lookup that fails for any reason — is left alone: the static header keeps
|
|
25
|
+
* doing its job, exactly as before this hook existed.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** The key the backend stamps on a session's own metadata. Mirrors the
|
|
29
|
+
* literal `opencode_chat.py::create_session` writes — not independently
|
|
30
|
+
* configurable, since a drift here would silently stop the handoff. */
|
|
31
|
+
const USER_ID_METADATA_KEY = "elliot_user_id"
|
|
32
|
+
|
|
33
|
+
/** The backend always stamps a real UUID (`User.id`). Anything else reaching
|
|
34
|
+
* here — a typo, a future metadata shape, a stale/foreign value — is not
|
|
35
|
+
* trustworthy as an identity and is left unresolved rather than forwarded
|
|
36
|
+
* verbatim to LiteLLM. */
|
|
37
|
+
const LOOKS_LIKE_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
38
|
+
|
|
39
|
+
/** Structural mirror of the one SDK method this module calls — the host's
|
|
40
|
+
* real client has many more, declared here only to the extent used (same
|
|
41
|
+
* reasoning `index.ts`'s own `PluginInput` mirror gives). */
|
|
42
|
+
export interface SessionClient {
|
|
43
|
+
session?: {
|
|
44
|
+
get?: (input: { path: { id: string } }) => Promise<unknown>
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Pulls the session object out of whatever envelope the client returns —
|
|
49
|
+
* tolerant of an openapi-fetch-style `{data, error}` wrapper or a bare
|
|
50
|
+
* object, since both have been seen across this SDK's own generated
|
|
51
|
+
* methods. */
|
|
52
|
+
function unwrapSession(result: unknown): Record<string, unknown> | undefined {
|
|
53
|
+
if (!result || typeof result !== "object") return undefined
|
|
54
|
+
const withData = (result as { data?: unknown }).data
|
|
55
|
+
if (withData && typeof withData === "object") return withData as Record<string, unknown>
|
|
56
|
+
return result as Record<string, unknown>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The real sender's user id for `sessionID`, or `undefined` when it cannot
|
|
61
|
+
* be determined — never throws, never fabricates.
|
|
62
|
+
*/
|
|
63
|
+
export async function resolveEndUserId(
|
|
64
|
+
client: SessionClient | undefined,
|
|
65
|
+
sessionID: string,
|
|
66
|
+
): Promise<string | undefined> {
|
|
67
|
+
if (!client?.session?.get || !sessionID) return undefined
|
|
68
|
+
try {
|
|
69
|
+
const result = await client.session.get({ path: { id: sessionID } })
|
|
70
|
+
const session = unwrapSession(result)
|
|
71
|
+
const metadata = session?.["metadata"]
|
|
72
|
+
if (!metadata || typeof metadata !== "object") return undefined
|
|
73
|
+
const value = (metadata as Record<string, unknown>)[USER_ID_METADATA_KEY]
|
|
74
|
+
if (typeof value !== "string") return undefined
|
|
75
|
+
const trimmed = value.trim()
|
|
76
|
+
return LOOKS_LIKE_UUID.test(trimmed) ? trimmed : undefined
|
|
77
|
+
} catch {
|
|
78
|
+
// Best-effort only. An identity lookup must never break a chat turn.
|
|
79
|
+
return undefined
|
|
80
|
+
}
|
|
81
|
+
}
|