@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/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.4",
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.18.30"
30
+ "opencode-ai": "1.19.0"
30
31
  }
31
32
  }
@@ -24,7 +24,7 @@
24
24
  "node": ">=18"
25
25
  },
26
26
  "elliot": {
27
- "opencodeRange": ">=1.18.19 <1.19.0"
27
+ "opencodeRange": ">=1.18.19 <1.20.0"
28
28
  },
29
29
  "oc-themes": [
30
30
  "themes/elliot.json"
@@ -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 `elliot-cli/src/tools/bash-policy.ts`,
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
- * Elliot token read + refresh — the credential half of the proxy provider.
2
+ * Reads `~/.elliot/config.json` — written by `eai setup`/`elliot-ai setup`.
3
3
  *
4
- * Ported (trimmed, not imported) from `elliot-cli/src/auth/refresh.ts` and
5
- * `elliot-cli/src/config.ts`: same `~/.elliot/config.json` shape, same
6
- * refresh endpoint contract (`POST {backend_url}/auth/refresh` ->
7
- * `{access_token, expires_in_seconds}`), same "never throw" discipline. This
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
- * `freshToken` is the only export the proxy provider's `auth.loader`/`fetch`
13
- * needs: it never throws, so a stale/missing/corrupt config degrades to "no
14
- * token" (the proxy 401s) rather than crashing OpenCode's model call.
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
- access_token_expires_at?: string
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
+ }