@elliotsystems/eai 0.1.4 → 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 +84 -4
- package/bin/eai +133 -0
- package/lib/connect.js +205 -0
- package/lib/gdrive.js +238 -0
- package/lib/logout.js +73 -0
- package/lib/setup.js +392 -23
- package/lib/stage.js +45 -4
- 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 +2 -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/index.ts +1 -28
- package/plugin/opencode-plugin/src/mcp-keys.ts +128 -1
- package/plugin/opencode-plugin/src/policy.ts +36 -4
- package/plugin/opencode-plugin/src/proxy-provider.ts +124 -72
- package/plugin/opencode-plugin/src/scope.ts +2 -1
- package/plugin/opencode-plugin/src/tui/knowledge.ts +9 -3
|
@@ -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
|
-
}
|
|
@@ -20,8 +20,7 @@
|
|
|
20
20
|
* `.opencode/plugin/` file re-export.
|
|
21
21
|
*/
|
|
22
22
|
import { isElliotKnowledgeTool, parseOptions, shellVerdict } from "./policy.js"
|
|
23
|
-
import {
|
|
24
|
-
import { readElliotConfig } from "./elliot-auth.js"
|
|
23
|
+
import { configHook } from "./proxy-provider.js"
|
|
25
24
|
import { formatScope, scopeForWorktree } from "./scope.js"
|
|
26
25
|
import { applyElliotIdentity } from "./identity.js"
|
|
27
26
|
|
|
@@ -87,25 +86,6 @@ export const ElliotPlugin: Plugin = async (input, rawOptions) => {
|
|
|
87
86
|
// the one source the model cannot influence.
|
|
88
87
|
const worktree = input.worktree || input.directory
|
|
89
88
|
|
|
90
|
-
// Seed a placeholder auth entry for the `elliot` provider so the host's
|
|
91
|
-
// auth loader fires at all (`provider.ts:1584` skips the loader entirely
|
|
92
|
-
// when nothing is stored for its provider id — see Task-1 findings). The
|
|
93
|
-
// value itself is never the credential that reaches the wire; the auth
|
|
94
|
-
// loader's `fetch` attaches the live bearer token per request. Guarded so
|
|
95
|
-
// a client-RPC failure never breaks plugin init, and skipped outright when
|
|
96
|
-
// the user isn't logged into Elliot.
|
|
97
|
-
if (readElliotConfig()) {
|
|
98
|
-
try {
|
|
99
|
-
await input.client?.auth?.set({
|
|
100
|
-
path: { id: ELLIOT_PROVIDER_ID },
|
|
101
|
-
body: { type: "api", key: "elliot-managed" },
|
|
102
|
-
})
|
|
103
|
-
} catch {
|
|
104
|
-
// Never break plugin init on a seeding failure — the auth loader
|
|
105
|
-
// simply won't fire this session, same as the no-login path.
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
89
|
return {
|
|
110
90
|
/**
|
|
111
91
|
* One handler, two jobs — a JS object literal may carry only one
|
|
@@ -195,13 +175,6 @@ export const ElliotPlugin: Plugin = async (input, rawOptions) => {
|
|
|
195
175
|
config: async (c) => {
|
|
196
176
|
configHook(c)
|
|
197
177
|
},
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Self-refreshing auth loader for the `elliot` provider — see
|
|
201
|
-
* `proxy-provider.ts`. Only fires once the seeding above has stored an
|
|
202
|
-
* auth entry for `ELLIOT_PROVIDER_ID`.
|
|
203
|
-
*/
|
|
204
|
-
auth: makeAuthHook(),
|
|
205
178
|
}
|
|
206
179
|
}
|
|
207
180
|
|
|
@@ -24,7 +24,134 @@ export function sanitizeKey(value: string): string {
|
|
|
24
24
|
/** The backend tool behind every Elliot knowledge lookup. */
|
|
25
25
|
export const KNOWLEDGE_TOOL = "knowledge_search"
|
|
26
26
|
|
|
27
|
-
/** The agent-visible key for `KNOWLEDGE_TOOL` on a given MCP server.
|
|
27
|
+
/** The agent-visible key for `KNOWLEDGE_TOOL` on a given MCP server.
|
|
28
|
+
*
|
|
29
|
+
* Still the PRE-GATEWAY spelling, and still correct for a runtime wired
|
|
30
|
+
* straight to Elliot. It is no longer the only spelling that arrives — see
|
|
31
|
+
* `toCanonicalTool`.
|
|
32
|
+
*/
|
|
28
33
|
export function knowledgeToolKey(serverName: string): string {
|
|
29
34
|
return `${sanitizeKey(serverName)}_${KNOWLEDGE_TOOL}`
|
|
30
35
|
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* LiteLLM joins its server alias to the tool name with a HYPHEN; OpenCode
|
|
39
|
+
* joins its own server key with an UNDERSCORE. Both appear in one wire name
|
|
40
|
+
* and they are not interchangeable.
|
|
41
|
+
*
|
|
42
|
+
* The SEPARATOR is LiteLLM's composition rule and is fixed. The ALIAS is
|
|
43
|
+
* configuration — it is the `mcp_servers` key in `deploy/litellm/config.yaml`
|
|
44
|
+
* — and this module no longer owns a copy of it. It used to:
|
|
45
|
+
*
|
|
46
|
+
* export const GATEWAY_SERVER_ALIAS = "elliot"
|
|
47
|
+
*
|
|
48
|
+
* restated from the backend and "kept in step by a test". That is four copies
|
|
49
|
+
* of one literal across two languages, and the failure when they diverge is
|
|
50
|
+
* silent: an unrecognised tool name is one whose repo scope is never injected,
|
|
51
|
+
* so the agent answers tenant-wide and says nothing about it.
|
|
52
|
+
*
|
|
53
|
+
* The alias now arrives as a plugin option, served by the backend from
|
|
54
|
+
* `litellm_mcp_server_alias` (`routers/well_known.py`).
|
|
55
|
+
*/
|
|
56
|
+
const GATEWAY_SEPARATOR = "-"
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The canonical Elliot tool name behind an agent-visible key.
|
|
60
|
+
*
|
|
61
|
+
* Runtime traffic now reaches Elliot through LiteLLM's MCP gateway, and each
|
|
62
|
+
* hop adds its own namespace. All four spellings below are real, depending on
|
|
63
|
+
* whether the runtime is behind the gateway and how the host composed the key:
|
|
64
|
+
*
|
|
65
|
+
* elliot_elliot-knowledge_search OpenCode prefix + gateway prefix
|
|
66
|
+
* elliot-knowledge_search gateway prefix only
|
|
67
|
+
* elliot_knowledge_search OpenCode prefix only (pre-gateway)
|
|
68
|
+
* knowledge_search bare/canonical
|
|
69
|
+
*
|
|
70
|
+
* Stripping is DETERMINISTIC and bounded: at most one server prefix, then at
|
|
71
|
+
* most one gateway prefix, each only from the front. Never a suffix or
|
|
72
|
+
* substring match — that is what stops a server called `not-elliot` from
|
|
73
|
+
* collecting scope hints meant for ours, and it is why the configured server
|
|
74
|
+
* names are required rather than inferred.
|
|
75
|
+
*
|
|
76
|
+
* Idempotent: feeding a canonical name back in returns it unchanged.
|
|
77
|
+
*/
|
|
78
|
+
/**
|
|
79
|
+
* The canonical tool name when the gateway alias is NOT knowable.
|
|
80
|
+
*
|
|
81
|
+
* For the TUI only. The TUI plugin entry (`tui: TuiPlugin = async (api)`)
|
|
82
|
+
* receives no options — the host hands it a UI api and nothing else — so it
|
|
83
|
+
* cannot be told the configured alias, and inventing a config channel for a
|
|
84
|
+
* sidebar would be a worse answer than this one.
|
|
85
|
+
*
|
|
86
|
+
* So it matches on STRUCTURE rather than on a specific alias: strip one
|
|
87
|
+
* configured server prefix, then allow ONE unknown `<something>-` layer.
|
|
88
|
+
*
|
|
89
|
+
* THIS IS NOT A FALLBACK TO A GUESSED ALIAS, and it does not weaken the
|
|
90
|
+
* anti-substring property that `toCanonicalTool` exists to hold. That property
|
|
91
|
+
* comes from the SERVER prefix, which the TUI does know (`api.state.mcp()`
|
|
92
|
+
* supplies it) and which is still required here. By the time an unknown
|
|
93
|
+
* gateway layer is considered, the key has already been proven to belong to a
|
|
94
|
+
* configured Elliot server — `not-elliot_knowledge_search` never reaches that
|
|
95
|
+
* line.
|
|
96
|
+
*
|
|
97
|
+
* Confined to display. Nothing authorizes on this: the sidebar decides which
|
|
98
|
+
* transcript rows to render, and the cost of a wrong answer is a missing or
|
|
99
|
+
* extra row, not a wrong scope. Authorization uses `toCanonicalTool` with the
|
|
100
|
+
* configured alias, on the backend, where the alias is known.
|
|
101
|
+
*/
|
|
102
|
+
export function toCanonicalToolUnderAnyGateway(
|
|
103
|
+
toolKey: string,
|
|
104
|
+
serverNames: readonly string[],
|
|
105
|
+
): string {
|
|
106
|
+
if (!toolKey) return toolKey
|
|
107
|
+
let rest = toolKey
|
|
108
|
+
for (const server of serverNames) {
|
|
109
|
+
const prefix = `${sanitizeKey(server)}_`
|
|
110
|
+
if (rest.startsWith(prefix)) {
|
|
111
|
+
rest = rest.slice(prefix.length)
|
|
112
|
+
break
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// One unknown gateway layer, and only one: `<alias>-<tool>`. The alias may
|
|
116
|
+
// not itself contain the separator, which is what keeps this bounded.
|
|
117
|
+
const layer = rest.indexOf(GATEWAY_SEPARATOR)
|
|
118
|
+
if (layer > 0) {
|
|
119
|
+
const remainder = rest.slice(layer + GATEWAY_SEPARATOR.length)
|
|
120
|
+
if (remainder) return remainder
|
|
121
|
+
}
|
|
122
|
+
return rest
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function toCanonicalTool(
|
|
126
|
+
toolKey: string,
|
|
127
|
+
serverNames: readonly string[],
|
|
128
|
+
gatewayAlias: string,
|
|
129
|
+
): string {
|
|
130
|
+
if (!toolKey) return toolKey
|
|
131
|
+
if (!gatewayAlias) {
|
|
132
|
+
// No silent `|| "elliot"`. A missing alias means the caller is
|
|
133
|
+
// misconfigured, and guessing one would restore the duplicated literal
|
|
134
|
+
// this parameter exists to remove — while hiding the misconfiguration
|
|
135
|
+
// behind behaviour that happens to be right in one deployment.
|
|
136
|
+
throw new Error("toCanonicalTool requires the configured gateway alias")
|
|
137
|
+
}
|
|
138
|
+
const gatewayPrefix = `${gatewayAlias}${GATEWAY_SEPARATOR}`
|
|
139
|
+
let rest = toolKey
|
|
140
|
+
|
|
141
|
+
// One OpenCode `<server>_` prefix, and only for a server we are configured
|
|
142
|
+
// for. An unconfigured server's tools are not ours to recognise.
|
|
143
|
+
for (const server of serverNames) {
|
|
144
|
+
const prefix = `${sanitizeKey(server)}_`
|
|
145
|
+
if (rest.startsWith(prefix)) {
|
|
146
|
+
rest = rest.slice(prefix.length)
|
|
147
|
+
break
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Then one LiteLLM `<alias>-` prefix.
|
|
152
|
+
if (rest.startsWith(gatewayPrefix)) {
|
|
153
|
+
rest = rest.slice(gatewayPrefix.length)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return rest
|
|
157
|
+
}
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
killsAgentRuntime,
|
|
20
20
|
looksLikeCredentialExfiltration,
|
|
21
21
|
} from "./classifier.js"
|
|
22
|
-
import { KNOWLEDGE_TOOL,
|
|
22
|
+
import { KNOWLEDGE_TOOL, sanitizeKey, toCanonicalTool } from "./mcp-keys.js"
|
|
23
23
|
|
|
24
24
|
const OptionsSchema = z
|
|
25
25
|
.object({
|
|
@@ -30,6 +30,27 @@ const OptionsSchema = z
|
|
|
30
30
|
* what let the hooks recognise Elliot tools.
|
|
31
31
|
*/
|
|
32
32
|
serverNames: z.array(z.string().min(1)).min(1).default(["elliot"]),
|
|
33
|
+
/**
|
|
34
|
+
* The LiteLLM MCP gateway's alias for Elliot — the `mcp_servers` key in
|
|
35
|
+
* `deploy/litellm/config.yaml`, which LiteLLM also uses as the prefix on
|
|
36
|
+
* every tool it proxies (`<alias>-<tool>`).
|
|
37
|
+
*
|
|
38
|
+
* REQUIRED, and deliberately without a default. This value used to be a
|
|
39
|
+
* constant in `mcp-keys.ts`, a fourth copy of a literal that also lives in
|
|
40
|
+
* the backend settings, the backend namespace module and the LiteLLM YAML.
|
|
41
|
+
* A default here would be that copy again: a plugin configured without the
|
|
42
|
+
* option would keep working against a guess, and would break silently on
|
|
43
|
+
* the day someone renamed the YAML key — recognising no gateway-prefixed
|
|
44
|
+
* tool, injecting no repo scope, and reporting nothing.
|
|
45
|
+
*
|
|
46
|
+
* The backend serves it with the plugin spec (`routers/well_known.py`),
|
|
47
|
+
* from `litellm_mcp_server_alias`.
|
|
48
|
+
*
|
|
49
|
+
* Distinct from `serverNames`, which is OpenCode's own server key and
|
|
50
|
+
* joins with an UNDERSCORE. Both read "elliot" in the default deployment
|
|
51
|
+
* and they are not the same value.
|
|
52
|
+
*/
|
|
53
|
+
gatewayAlias: z.string().min(1),
|
|
33
54
|
})
|
|
34
55
|
.strict()
|
|
35
56
|
|
|
@@ -53,11 +74,22 @@ export { KNOWLEDGE_TOOL, sanitizeKey }
|
|
|
53
74
|
|
|
54
75
|
/**
|
|
55
76
|
* Is this agent-visible tool key Elliot's knowledge tool on one of our
|
|
56
|
-
* configured servers?
|
|
57
|
-
*
|
|
77
|
+
* configured servers?
|
|
78
|
+
*
|
|
79
|
+
* NORMALISED FIRST, then matched exactly. It used to compare against one
|
|
80
|
+
* composed spelling, which stopped matching the moment runtime traffic moved
|
|
81
|
+
* behind the LiteLLM MCP gateway and the key gained a second namespace
|
|
82
|
+
* (`elliot_elliot-knowledge_search`). A knowledge call that is not recognised
|
|
83
|
+
* here is one that silently loses its repository scope — the agent still
|
|
84
|
+
* answers, just tenant-wide instead of repo-focused, and nothing in the reply
|
|
85
|
+
* says so.
|
|
86
|
+
*
|
|
87
|
+
* The anti-substring property is unchanged: `toCanonicalTool` strips only a
|
|
88
|
+
* prefix belonging to a CONFIGURED server, so `not-elliot_knowledge_search`
|
|
89
|
+
* still does not match.
|
|
58
90
|
*/
|
|
59
91
|
export function isElliotKnowledgeTool(toolKey: string, options: ElliotPluginOptions): boolean {
|
|
60
|
-
return options.serverNames.
|
|
92
|
+
return toCanonicalTool(toolKey, options.serverNames, options.gatewayAlias) === KNOWLEDGE_TOOL
|
|
61
93
|
}
|
|
62
94
|
|
|
63
95
|
// -- Shell policy verdicts ---------------------------------------------------
|
|
@@ -1,96 +1,148 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The Elliot
|
|
3
|
-
*
|
|
4
|
-
* `auth` hook whose `loader` returns a self-refreshing `fetch` that attaches
|
|
5
|
-
* a live bearer token per request.
|
|
2
|
+
* The Elliot provider: a `config` hook that registers a custom `elliot`
|
|
3
|
+
* OpenCode provider pointed at LiteLLM directly.
|
|
6
4
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* - The loader only runs once a stored auth entry exists for its
|
|
16
|
-
* `provider` id (`provider.ts:1584`); the plugin seeds that entry itself
|
|
17
|
-
* at init (see `index.ts`) so no interactive `opencode auth login` step
|
|
18
|
-
* is ever required.
|
|
5
|
+
* Since `/llm-proxy` was removed, this no longer needs a self-refreshing
|
|
6
|
+
* per-request `auth` hook (Task-1's pinned mechanism, `.superpowers/sdd/
|
|
7
|
+
* 2026-09-03-eai-proxy-auth/task-1-findings.md` — kept only as history: the
|
|
8
|
+
* per-instance JWT that mechanism refreshed doesn't authenticate this path
|
|
9
|
+
* anymore). LiteLLM's `litellm_master_key` is ONE static secret for the whole
|
|
10
|
+
* gateway — it never expires, so it is baked into `options.apiKey` directly,
|
|
11
|
+
* the same way OpenCode's own `{env:VAR}` config substitution would, just
|
|
12
|
+
* read from `~/.elliot/config.json` instead of the process environment.
|
|
19
13
|
*
|
|
20
14
|
* Everything here is a no-op when the user isn't logged in
|
|
21
15
|
* (`readElliotConfig()` returns null) — OpenCode then runs exactly as it
|
|
22
16
|
* would without this plugin.
|
|
23
17
|
*/
|
|
24
|
-
import {
|
|
18
|
+
import { readElliotConfig, type ElliotCredential } from "./elliot-auth.js"
|
|
25
19
|
|
|
26
|
-
export const ELLIOT_LLM_PROXY_URL = "https://backend.elliotsystemsonline.com/llm-proxy"
|
|
27
20
|
export const ELLIOT_PROVIDER_ID = "elliot"
|
|
21
|
+
export const ELLIOT_GENERAL_MODEL_ID = "elliot-general"
|
|
22
|
+
export const ELLIOT_CODER_MODEL_ID = "elliot-coder"
|
|
23
|
+
|
|
24
|
+
const ALL_MODELS = {
|
|
25
|
+
[ELLIOT_GENERAL_MODEL_ID]: { name: "Elliot General" },
|
|
26
|
+
[ELLIOT_CODER_MODEL_ID]: { name: "Elliot Coder" },
|
|
27
|
+
}
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
30
|
+
* The `models` block for one registered provider, filtered to
|
|
31
|
+
* `allowedModels` (Phase 4 governance, docs/LITELLM_GATEWAY.md §14) —
|
|
32
|
+
* LiteLLM gateway audit finding #5, mirrors the identical fix already made
|
|
33
|
+
* to `app/services/opencode/teamconfig.py::build_opencode_config` and
|
|
34
|
+
* `elliot-launcher/lib/setup.js::elliotProvider`. LiteLLM was always the
|
|
35
|
+
* real enforcement point regardless of what this offers; this only stops
|
|
36
|
+
* the menu from offering a model that will be rejected. Falsy
|
|
37
|
+
* (undefined/null/empty) — every team until an admin restricts it — offers
|
|
38
|
+
* both, unchanged from before this parameter existed.
|
|
39
|
+
*
|
|
40
|
+
* A team restricted to ONLY elliot-classifier (never offered here —
|
|
41
|
+
* internal-only, deploy/litellm/config.yaml §5) would otherwise get an
|
|
42
|
+
* empty, unusable menu; degrades to offering both for that listing only,
|
|
43
|
+
* same as the two sibling generators — LiteLLM still rejects any actual
|
|
44
|
+
* request the team isn't allowed to make either way.
|
|
34
45
|
*/
|
|
35
|
-
|
|
36
|
-
if (!
|
|
46
|
+
function offeredModels(allowedModels?: string[] | null): typeof ALL_MODELS {
|
|
47
|
+
if (!allowedModels || allowedModels.length === 0) return ALL_MODELS
|
|
37
48
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
models: { "elliot-governed": { name: "Elliot (governed)" } },
|
|
44
|
-
}
|
|
49
|
+
const allowed = new Set(allowedModels)
|
|
50
|
+
const offered = Object.keys(ALL_MODELS).filter((id) => allowed.has(id)) as Array<
|
|
51
|
+
keyof typeof ALL_MODELS
|
|
52
|
+
>
|
|
53
|
+
if (offered.length === 0) return ALL_MODELS
|
|
45
54
|
|
|
46
|
-
|
|
55
|
+
const filtered = {} as typeof ALL_MODELS
|
|
56
|
+
for (const id of offered) filtered[id] = ALL_MODELS[id]
|
|
57
|
+
return filtered
|
|
47
58
|
}
|
|
48
59
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
60
|
+
function registerProvider(
|
|
61
|
+
cfg: any,
|
|
62
|
+
base: string,
|
|
63
|
+
providerId: string,
|
|
64
|
+
label: string,
|
|
65
|
+
apiKey: string,
|
|
66
|
+
allowedModels?: string[] | null,
|
|
67
|
+
): void {
|
|
68
|
+
cfg.provider ??= {}
|
|
69
|
+
// Never clobber a provider id the user (or an earlier plugin) already
|
|
70
|
+
// configured — same "additive only" contract as the single-provider path
|
|
71
|
+
// this replaces.
|
|
72
|
+
cfg.provider[providerId] ??= {
|
|
73
|
+
npm: "@ai-sdk/openai-compatible",
|
|
74
|
+
name: label,
|
|
75
|
+
options: { baseURL: `${base}/v1`, apiKey },
|
|
76
|
+
models: offeredModels(allowedModels),
|
|
77
|
+
}
|
|
58
78
|
}
|
|
59
79
|
|
|
60
80
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
81
|
+
* Registers one OpenCode provider per LiteLLM credential this person has
|
|
82
|
+
* (Phase 4, docs/LITELLM_GATEWAY.md §14): their own personal key under
|
|
83
|
+
* `elliot`, plus one `elliot-team-<slug>` provider per team they belong to —
|
|
84
|
+
* so `opencode.json`'s own global+project-local merge, not this plugin, is
|
|
85
|
+
* what decides which key a given folder actually uses (a project-local
|
|
86
|
+
* config pins `model` to a team's provider; the global default falls back to
|
|
87
|
+
* the personal one). Mutating `cfg` in place.
|
|
88
|
+
*
|
|
89
|
+
* Falls back to registering a single `elliot` provider from the legacy
|
|
90
|
+
* `litellm_master_key` field when `elliot.credentials` is absent — an
|
|
91
|
+
* `eai setup` written before this phase, or a backend that predates it,
|
|
92
|
+
* keeps working unchanged rather than losing LLM access outright.
|
|
93
|
+
*
|
|
94
|
+
* No-op when the user isn't logged in. Never overwrites a model the user
|
|
95
|
+
* already configured, or any other existing key under
|
|
96
|
+
* `cfg.provider`/`cfg.plugin`/`cfg.mcp`/etc.
|
|
66
97
|
*/
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
98
|
+
export function configHook(cfg: any): void {
|
|
99
|
+
const elliot = readElliotConfig()
|
|
100
|
+
if (!elliot?.litellm_base_url) return
|
|
101
|
+
|
|
102
|
+
const base = elliot.litellm_base_url.replace(/\/+$/, "")
|
|
103
|
+
|
|
104
|
+
// The DEFAULT provider's own offered models decide the default `model`
|
|
105
|
+
// below — never just `elliot-general` unconditionally, since the
|
|
106
|
+
// fallback (single-provider) path can itself be restricted. In the
|
|
107
|
+
// credentials path this is always the personal credential (provider_id
|
|
108
|
+
// "elliot"), which has no team and so is never restricted — `ALL_MODELS`
|
|
109
|
+
// unless a future personal restriction concept changes that.
|
|
110
|
+
let defaultProviderModels: typeof ALL_MODELS = ALL_MODELS
|
|
111
|
+
|
|
112
|
+
if (elliot.credentials?.length) {
|
|
113
|
+
for (const credential of elliot.credentials as ElliotCredential[]) {
|
|
114
|
+
if (!credential.provider_id || !credential.api_key) continue
|
|
115
|
+
registerProvider(
|
|
116
|
+
cfg,
|
|
117
|
+
base,
|
|
118
|
+
credential.provider_id,
|
|
119
|
+
credential.label,
|
|
120
|
+
credential.api_key,
|
|
121
|
+
credential.allowed_models,
|
|
122
|
+
)
|
|
123
|
+
if (credential.provider_id === ELLIOT_PROVIDER_ID) {
|
|
124
|
+
defaultProviderModels = offeredModels(credential.allowed_models)
|
|
125
|
+
}
|
|
73
126
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
127
|
+
} else if (elliot.litellm_master_key) {
|
|
128
|
+
registerProvider(
|
|
129
|
+
cfg,
|
|
130
|
+
base,
|
|
131
|
+
ELLIOT_PROVIDER_ID,
|
|
132
|
+
"Elliot (LiteLLM)",
|
|
133
|
+
elliot.litellm_master_key,
|
|
134
|
+
elliot.allowed_models,
|
|
135
|
+
)
|
|
136
|
+
defaultProviderModels = offeredModels(elliot.allowed_models)
|
|
137
|
+
} else {
|
|
138
|
+
return
|
|
77
139
|
}
|
|
78
|
-
}
|
|
79
140
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
export function makeAuthHook() {
|
|
88
|
-
return {
|
|
89
|
-
provider: ELLIOT_PROVIDER_ID,
|
|
90
|
-
methods: [] as unknown[],
|
|
91
|
-
loader: async (_auth: () => Promise<unknown>, _provider: unknown) => ({
|
|
92
|
-
apiKey: "elliot-managed",
|
|
93
|
-
fetch: elliotFetch,
|
|
94
|
-
}),
|
|
95
|
-
}
|
|
141
|
+
// Follows the restriction, not always General — a team restricted to
|
|
142
|
+
// Coder-only must not default to a model its own menu just excluded.
|
|
143
|
+
const defaultModelId =
|
|
144
|
+
ELLIOT_GENERAL_MODEL_ID in defaultProviderModels
|
|
145
|
+
? ELLIOT_GENERAL_MODEL_ID
|
|
146
|
+
: Object.keys(defaultProviderModels)[0]
|
|
147
|
+
cfg.model ??= `${ELLIOT_PROVIDER_ID}/${defaultModelId}`
|
|
96
148
|
}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Repository scope from the session worktree — the trusted-context half of the
|
|
3
3
|
* "the model never names a repository" invariant.
|
|
4
4
|
*
|
|
5
|
-
* Ported (trimmed, not imported) from
|
|
5
|
+
* Ported (trimmed, not imported) from the retired elliot-ai CLI's
|
|
6
|
+
* `src/project.ts`: the same
|
|
6
7
|
* `execFile` discipline — a fixed, hardcoded argument array, never a string a
|
|
7
8
|
* shell would parse, and never anything the LLM supplies. The worktree path
|
|
8
9
|
* comes from OpenCode's own `PluginInput`/`ToolContext`, which is session
|