@elliotsystems/eai 0.1.7 → 0.1.14
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/bin/eai +55 -0
- package/lib/auth.js +173 -0
- package/lib/context.js +52 -0
- package/lib/login.js +114 -0
- package/lib/setup.js +152 -23
- package/lib/stage.js +92 -11
- package/lib/status.js +2 -0
- package/lib/teams.js +233 -0
- package/package.json +2 -2
- package/plugin/opencode-plugin/src/blocked-turns.ts +145 -0
- package/plugin/opencode-plugin/src/compaction.ts +600 -0
- package/plugin/opencode-plugin/src/elliot-auth.ts +10 -0
- package/plugin/opencode-plugin/src/guardrail-sync.ts +370 -0
- package/plugin/opencode-plugin/src/index.ts +476 -0
- package/plugin/opencode-plugin/src/session-affinity.ts +73 -0
package/lib/setup.js
CHANGED
|
@@ -36,6 +36,14 @@ const path = require("path")
|
|
|
36
36
|
const PROVIDER_ID = "elliot"
|
|
37
37
|
const GENERAL_MODEL_ID = "elliot-general"
|
|
38
38
|
const CODER_MODEL_ID = "elliot-coder"
|
|
39
|
+
// The guardrail name a team's own request-level `metadata.guardrails`
|
|
40
|
+
// override names — must match the real `guardrail_name` LiteLLM's
|
|
41
|
+
// `deploy/litellm/config.yaml` configures (`jev-policy`), and the exact
|
|
42
|
+
// value `ProviderManager._guardrail_names_for_team` already injects for Web
|
|
43
|
+
// Chat's own RAG/orchestration path. Restated here (mirrors `teamconfig.py`'s
|
|
44
|
+
// own `JEV_GUARDRAIL_NAME`) rather than imported, since this file has no
|
|
45
|
+
// dependency on that Python module — a test pins both equal.
|
|
46
|
+
const JEV_GUARDRAIL_NAME = "jev-policy"
|
|
39
47
|
|
|
40
48
|
/** Matches OpenCode's own resolution (xdg-basedir, whose fallback is ~/.config everywhere). */
|
|
41
49
|
function opencodeConfigDir(env = process.env) {
|
|
@@ -107,8 +115,22 @@ function decodeJwtPayload(token) {
|
|
|
107
115
|
* from OFFERING a model that will be rejected. Falsy (undefined/null/[]) —
|
|
108
116
|
* every team until an admin restricts it — offers both, unchanged from
|
|
109
117
|
* before this parameter existed.
|
|
118
|
+
*
|
|
119
|
+
* `jevPolicyEnabled` closes the cross-client enforcement gap the staging
|
|
120
|
+
* integration audit found: the backend's `ProviderManager.
|
|
121
|
+
* _guardrail_names_for_team` already injects the request-level `metadata.
|
|
122
|
+
* guardrails` override for Web Chat's own native RAG/orchestration
|
|
123
|
+
* completions when a team's `jev_policy_enabled` is set, but this CLI (like
|
|
124
|
+
* OpenCode) calls LiteLLM directly via `@ai-sdk/openai-compatible`, outside
|
|
125
|
+
* that Python code path — so the override never reached it. Straight from
|
|
126
|
+
* the same onboarding response's `jev_policy_enabled` field
|
|
127
|
+
* (`app/routers/onboarding.py`). `false`/undefined (every team until
|
|
128
|
+
* explicitly opted in) produces a config identical to one built before this
|
|
129
|
+
* parameter existed. Mirrors the identical fix in `app/services/opencode/
|
|
130
|
+
* teamconfig.py::build_opencode_config` for OpenCode's own web runtimes —
|
|
131
|
+
* same flag, same mechanism, second client.
|
|
110
132
|
*/
|
|
111
|
-
function elliotProvider(litellmBaseUrl, identity = undefined, allowedModels = undefined) {
|
|
133
|
+
function elliotProvider(litellmBaseUrl, identity = undefined, allowedModels = undefined, jevPolicyEnabled = false) {
|
|
112
134
|
const base = String(litellmBaseUrl).replace(/\/+$/, "")
|
|
113
135
|
const options = { baseURL: `${base}/v1`, apiKey: "{env:ELLIOT_TOKEN}" }
|
|
114
136
|
const headers = litellmIdentityHeaders(identity)
|
|
@@ -119,7 +141,7 @@ function elliotProvider(litellmBaseUrl, identity = undefined, allowedModels = un
|
|
|
119
141
|
// — EAI branding only, never the gateway's own vendor name.
|
|
120
142
|
name: "EAI Gateway",
|
|
121
143
|
options,
|
|
122
|
-
models: offeredModels(allowedModels),
|
|
144
|
+
models: offeredModels(allowedModels, jevPolicyEnabled),
|
|
123
145
|
}
|
|
124
146
|
}
|
|
125
147
|
|
|
@@ -131,18 +153,33 @@ function elliotProvider(litellmBaseUrl, identity = undefined, allowedModels = un
|
|
|
131
153
|
* degrades to offering both for that listing only, exactly like the
|
|
132
154
|
* backend's own fallback — LiteLLM still rejects any actual request the
|
|
133
155
|
* team isn't allowed to make either way.
|
|
156
|
+
*
|
|
157
|
+
* `jevPolicyEnabled` adds `options.metadata.guardrails` to EACH offered
|
|
158
|
+
* model — see `elliotProvider`'s own docstring for why. A model-level
|
|
159
|
+
* `options` object is the one static, declarative opencode.json field
|
|
160
|
+
* confirmed (offline, against the real `@ai-sdk/openai-compatible`
|
|
161
|
+
* package) to reach the request body this CLI's runtime actually sends.
|
|
134
162
|
*/
|
|
135
|
-
function offeredModels(allowedModels) {
|
|
163
|
+
function offeredModels(allowedModels, jevPolicyEnabled = false) {
|
|
136
164
|
const all = { [GENERAL_MODEL_ID]: { name: "EAI General" }, [CODER_MODEL_ID]: { name: "EAI Coder" } }
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
165
|
+
let result = all
|
|
166
|
+
if (allowedModels && allowedModels.length > 0) {
|
|
167
|
+
const allowed = new Set(allowedModels)
|
|
168
|
+
const offered = Object.keys(all).filter((id) => allowed.has(id))
|
|
169
|
+
if (offered.length > 0) {
|
|
170
|
+
const filtered = {}
|
|
171
|
+
for (const id of offered) filtered[id] = all[id]
|
|
172
|
+
result = filtered
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (jevPolicyEnabled) {
|
|
176
|
+
const withGuardrails = {}
|
|
177
|
+
for (const [id, entry] of Object.entries(result)) {
|
|
178
|
+
withGuardrails[id] = { ...entry, options: { metadata: { guardrails: [JEV_GUARDRAIL_NAME] } } }
|
|
179
|
+
}
|
|
180
|
+
result = withGuardrails
|
|
181
|
+
}
|
|
182
|
+
return result
|
|
146
183
|
}
|
|
147
184
|
|
|
148
185
|
/** The default `model` field, given whatever `models` ended up offered —
|
|
@@ -298,6 +335,15 @@ function elliotCommands() {
|
|
|
298
335
|
"intentionally omitted\".\n\n" +
|
|
299
336
|
"Do not list individual skills here; SKILLS.md is the skill index and " +
|
|
300
337
|
"`/eai-skills` generates it.\n\n" +
|
|
338
|
+
"Append the standard operating guidance: call the " +
|
|
339
|
+
"`elliot_eai_agents_standard` tool (the `elliot` MCP server exposes it), " +
|
|
340
|
+
"and append its `markdown` value verbatim to the END of AGENTS.md under " +
|
|
341
|
+
"a `## Standard guidance` heading. That tool returns the same canonical " +
|
|
342
|
+
"sections every runtime-generated AGENTS.md carries, from the backend's " +
|
|
343
|
+
"single source of truth — do not paraphrase or rewrite them. If the tool " +
|
|
344
|
+
"is not available yet, try once more after `sleep 3` before skipping; a " +
|
|
345
|
+
"project initialised here should carry the same standard sections a " +
|
|
346
|
+
"team's runtime does.\n\n" +
|
|
301
347
|
"When done, report the file written and the key facts you detected.",
|
|
302
348
|
},
|
|
303
349
|
"eai-skills": {
|
|
@@ -339,6 +385,7 @@ function writeOpencodeConfig(
|
|
|
339
385
|
servers = undefined,
|
|
340
386
|
identity = undefined,
|
|
341
387
|
allowedModels = undefined,
|
|
388
|
+
jevPolicyEnabled = false,
|
|
342
389
|
) {
|
|
343
390
|
const dir = opencodeConfigDir(env)
|
|
344
391
|
const file = path.join(dir, "opencode.json")
|
|
@@ -362,7 +409,7 @@ function writeOpencodeConfig(
|
|
|
362
409
|
// replacement. Every other key is the user's own and survives untouched.
|
|
363
410
|
const { [PROVIDER_ID]: _ours, ...theirs } = existing.mcp || {}
|
|
364
411
|
const mcp = { ...theirs, ...(servers || elliotMcpServers()) }
|
|
365
|
-
const provider = elliotProvider(litellmBaseUrl, identity, allowedModels)
|
|
412
|
+
const provider = elliotProvider(litellmBaseUrl, identity, allowedModels, jevPolicyEnabled)
|
|
366
413
|
// Org commands are spread LAST, same rule as `mcp` above: this repairs a
|
|
367
414
|
// stale `/usage` from an older package rather than leaving it, while a
|
|
368
415
|
// user's own unrelated custom commands are untouched.
|
|
@@ -390,6 +437,16 @@ function writeOpencodeConfig(
|
|
|
390
437
|
// Follows the restriction, not always General — a team restricted to
|
|
391
438
|
// Coder-only must not default to a model its own menu just excluded.
|
|
392
439
|
model: existing.model || `${PROVIDER_ID}/${defaultModelId(provider.models)}`,
|
|
440
|
+
// OpenCode's own self-updater can never succeed here: it detects its
|
|
441
|
+
// install method by grepping `npm list -g --depth=0` for `opencode-ai`,
|
|
442
|
+
// but opencode-ai is a pinned dependency of this package, not its own
|
|
443
|
+
// top-level global install, so that check never matches and `/update`
|
|
444
|
+
// (and the unprompted "Update available" notice) always dead-ends on
|
|
445
|
+
// "Installation method not found". `eai update` is the one supported
|
|
446
|
+
// update path (see lib/update.js) and is unaffected by this setting.
|
|
447
|
+
// Only defaulted, same as `model` above, so a user who already set their
|
|
448
|
+
// own `autoupdate` keeps it.
|
|
449
|
+
autoupdate: existing.autoupdate ?? false,
|
|
393
450
|
mcp,
|
|
394
451
|
command,
|
|
395
452
|
}
|
|
@@ -539,23 +596,80 @@ function writeAgentFile(agent, env = process.env) {
|
|
|
539
596
|
}
|
|
540
597
|
|
|
541
598
|
/**
|
|
542
|
-
*
|
|
599
|
+
* Path to the manifest recording which files in the agents directory THIS
|
|
600
|
+
* launcher itself wrote on its last run.
|
|
601
|
+
*
|
|
602
|
+
* It is the one safe way to tell "ours, now stale" apart from "a user's own
|
|
603
|
+
* file that happens to live here" without guessing from the filename -- which
|
|
604
|
+
* is exactly the guess `elliotCommands()`'s "OURS TO REPLACE, THEIRS TO KEEP"
|
|
605
|
+
* rule exists to avoid making. A file never named in any manifest this
|
|
606
|
+
* launcher wrote is never touched, no matter what it's called.
|
|
607
|
+
*/
|
|
608
|
+
function agentManifestPath(dir) {
|
|
609
|
+
return path.join(dir, ".eai-manifest.json")
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** The manifest's own filenames, or `[]` for "none yet" -- missing, corrupt,
|
|
613
|
+
* or never written are the same case: nothing to clean up against. */
|
|
614
|
+
function readAgentManifest(manifestFile) {
|
|
615
|
+
try {
|
|
616
|
+
const parsed = JSON.parse(fs.readFileSync(manifestFile, "utf-8"))
|
|
617
|
+
return Array.isArray(parsed) ? parsed : []
|
|
618
|
+
} catch {
|
|
619
|
+
return []
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Every agent the backend's `eai_agents` response named, in one pass --
|
|
625
|
+
* and, via the manifest above, nothing it no longer names.
|
|
543
626
|
*
|
|
544
627
|
* `agents` is exactly the `eai_agents` array `fetchOnboardingConfig`'s
|
|
545
628
|
* response carries (or `[]`/undefined for a backend that predates it, which
|
|
546
629
|
* this treats as "nothing to write" rather than an error -- a CLI update
|
|
547
630
|
* must not need a backend update released in lockstep, the same tolerance
|
|
548
631
|
* `writeOpencodeConfig`'s own `servers` fallback already extends to
|
|
549
|
-
* `mcp_servers`).
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
*
|
|
632
|
+
* `mcp_servers`). `undefined` specifically (backend doesn't send the field at
|
|
633
|
+
* all) skips the manifest reconcile too -- it carries no information about
|
|
634
|
+
* what the agent set NOW is, so nothing is safe to remove.
|
|
635
|
+
*
|
|
636
|
+
* A RENAME is a REMOVAL from this function's point of view: migration 090
|
|
637
|
+
* is exactly this -- `dev` became `eai-dev` as the SAME backend row, but
|
|
638
|
+
* `writeAgentFile` only ever knows the key it's given today, so the file
|
|
639
|
+
* named after the OLD key is, from here, indistinguishable from an agent
|
|
640
|
+
* that was deleted outright. Either way, this launcher wrote it and it is
|
|
641
|
+
* no longer in the current set, which is the one condition that makes
|
|
642
|
+
* deleting it safe.
|
|
556
643
|
*/
|
|
557
644
|
function writeAllAgentFiles(agents, env = process.env) {
|
|
558
|
-
|
|
645
|
+
if (!Array.isArray(agents)) return []
|
|
646
|
+
|
|
647
|
+
const dir = path.join(opencodeConfigDir(env), "agents")
|
|
648
|
+
const manifestFile = agentManifestPath(dir)
|
|
649
|
+
const previousFiles = readAgentManifest(manifestFile)
|
|
650
|
+
|
|
651
|
+
const written = agents.map((agent) => writeAgentFile(agent, env))
|
|
652
|
+
const writtenNames = new Set(written.map((f) => path.basename(f)))
|
|
653
|
+
|
|
654
|
+
for (const name of previousFiles) {
|
|
655
|
+
if (writtenNames.has(name)) continue
|
|
656
|
+
try {
|
|
657
|
+
fs.unlinkSync(path.join(dir, name))
|
|
658
|
+
} catch {
|
|
659
|
+
// Already gone (hand-deleted, or a second setup run) -- not an error.
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Only touch the manifest -- and so only ever create the directory -- when
|
|
664
|
+
// there is something to record or clean up. An empty/undefined response
|
|
665
|
+
// with no prior manifest must leave the directory exactly as this function
|
|
666
|
+
// always has: never created at all.
|
|
667
|
+
if (written.length > 0 || previousFiles.length > 0) {
|
|
668
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
669
|
+
fs.writeFileSync(manifestFile, JSON.stringify([...writtenNames].sort(), null, 2))
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
return written
|
|
559
673
|
}
|
|
560
674
|
|
|
561
675
|
/** Owner-only: this file holds a bearer token and a refresh token. */
|
|
@@ -611,7 +725,20 @@ async function fetchOnboardingConfig(backendUrl, tenantId, token, teamId) {
|
|
|
611
725
|
headers: { Authorization: `Bearer ${token}` },
|
|
612
726
|
})
|
|
613
727
|
if (!res.ok) {
|
|
614
|
-
|
|
728
|
+
// Surface the backend's own detail when it sent one (e.g. the fail-closed
|
|
729
|
+
// "no budgeted credential" 503 — EAI_ONBOARDING_BILLING_AUDIT.md §7), so
|
|
730
|
+
// `eai login`/`eai setup`/`eai teams use` show an actionable message
|
|
731
|
+
// rather than a bare status code.
|
|
732
|
+
let detail = ""
|
|
733
|
+
try {
|
|
734
|
+
const body = await res.json()
|
|
735
|
+
if (body && typeof body.detail === "string") detail = body.detail
|
|
736
|
+
} catch {
|
|
737
|
+
// Non-JSON error body; fall back to the status below.
|
|
738
|
+
}
|
|
739
|
+
throw new Error(
|
|
740
|
+
detail || `backend returned ${res.status} fetching the organisation config`,
|
|
741
|
+
)
|
|
615
742
|
}
|
|
616
743
|
return (await res.json()) || {}
|
|
617
744
|
}
|
|
@@ -694,6 +821,7 @@ async function runSetup(opts) {
|
|
|
694
821
|
servers,
|
|
695
822
|
identity,
|
|
696
823
|
orgConfig.allowed_models,
|
|
824
|
+
orgConfig.jev_policy_enabled,
|
|
697
825
|
)
|
|
698
826
|
|
|
699
827
|
const agentFiles = writeAllAgentFiles(orgConfig.eai_agents, process.env)
|
|
@@ -738,4 +866,5 @@ module.exports = {
|
|
|
738
866
|
PROVIDER_ID,
|
|
739
867
|
GENERAL_MODEL_ID,
|
|
740
868
|
CODER_MODEL_ID,
|
|
869
|
+
JEV_GUARDRAIL_NAME,
|
|
741
870
|
}
|
package/lib/stage.js
CHANGED
|
@@ -29,6 +29,62 @@ const fs = require("fs")
|
|
|
29
29
|
const os = require("os")
|
|
30
30
|
const path = require("path")
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* The JEV (fast-jev-compaction-opencode) plugin spec. NOT staged by default:
|
|
34
|
+
* pass it as `jevPlugin` to stage it alongside Elliot's own branding plugin.
|
|
35
|
+
* It is off because the plugin writes its own log lines onto the TUI (and its
|
|
36
|
+
* `github:` spec also triggers a Node `url.parse()` deprecation warning at
|
|
37
|
+
* load), with no option to silence either. Turn it on again once it can log to
|
|
38
|
+
* a file only.
|
|
39
|
+
*
|
|
40
|
+
* Registering it here does NOT mean compaction works: as of this writing its
|
|
41
|
+
* default backend ("typesafe") has no configured credential on a stock
|
|
42
|
+
* install, so its own compaction hook fails closed and OpenCode's native
|
|
43
|
+
* compaction runs instead -- confirmed by reading its own log output, not
|
|
44
|
+
* claimed from its source alone. A plugin that fails to load or fails inside
|
|
45
|
+
* its own hook does not block startup or touch Elliot's own plugin entry;
|
|
46
|
+
* OpenCode loads each plugin independently and logs a per-plugin failure
|
|
47
|
+
* rather than refusing to start (see opencode.log's own "failed to load
|
|
48
|
+
* plugin" entries for Elliot's branding plugin on a missing gatewayAlias,
|
|
49
|
+
* which never affected the rest of the session).
|
|
50
|
+
*
|
|
51
|
+
* A bare string, not a [spec, options] tuple: unlike Elliot's own plugin, it
|
|
52
|
+
* takes no required option here, so there is nothing to pass.
|
|
53
|
+
*/
|
|
54
|
+
const DEFAULT_JEV_PLUGIN_SPEC = "github:overbit/fast-jev-compaction-opencode"
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Credential-shaped keys this function refuses to ever write into a staged
|
|
58
|
+
* config file. Checked by NAME, not by guessing at a value's shape -- a
|
|
59
|
+
* deliberately conservative denylist, since the cost of missing a future
|
|
60
|
+
* secret-shaped option name is much higher than the cost of a false
|
|
61
|
+
* positive dropping a harmless key that happened to share a name.
|
|
62
|
+
*/
|
|
63
|
+
const JEV_PLUGIN_SECRET_OPTION_KEYS = new Set(["apiKey", "localApiKey"])
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `opts.jevPluginOptions`, minus any credential-shaped key -- see
|
|
67
|
+
* DEFAULT_JEV_PLUGIN_SPEC's own docstring above for why a secret should
|
|
68
|
+
* never reach here in the first place. `undefined`/non-object input
|
|
69
|
+
* returns `undefined` (no options tuple at all, same as omitting the
|
|
70
|
+
* field), never throws -- this must never be the reason staging fails.
|
|
71
|
+
*/
|
|
72
|
+
function jevPluginOptionsExcludingSecrets(raw) {
|
|
73
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined
|
|
74
|
+
const clean = {}
|
|
75
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
76
|
+
if (JEV_PLUGIN_SECRET_OPTION_KEYS.has(key)) {
|
|
77
|
+
process.stderr.write(
|
|
78
|
+
`eai: refusing to write "${key}" into the staged JEV plugin config -- ` +
|
|
79
|
+
`credentials must be set as an environment variable (e.g. TYPESAFE_API_KEY), never in a config file.\n`,
|
|
80
|
+
)
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
clean[key] = value
|
|
84
|
+
}
|
|
85
|
+
return Object.keys(clean).length > 0 ? clean : undefined
|
|
86
|
+
}
|
|
87
|
+
|
|
32
88
|
/**
|
|
33
89
|
* The LiteLLM MCP gateway's alias, from `~/.elliot/config.json`'s
|
|
34
90
|
* `gateway_alias` field -- `eai setup` writes it there straight out of
|
|
@@ -139,6 +195,27 @@ function stageOverlay(opts) {
|
|
|
139
195
|
// from. See readGatewayAlias's own docstring for why `undefined` must not
|
|
140
196
|
// become a guessed `"elliot"`.
|
|
141
197
|
const gatewayAlias = opts.gatewayAlias
|
|
198
|
+
// OFF unless a caller passes a spec (the tests do; pass DEFAULT_JEV_PLUGIN_SPEC
|
|
199
|
+
// to turn it on). It was staged for every install, but the plugin copies each
|
|
200
|
+
// of its log lines to the console and has no option to stop it, so on every
|
|
201
|
+
// launch `[fast-jev-compaction-opencode] initialized {...}` was drawn into the
|
|
202
|
+
// TUI prompt box -- for a compaction hook that fails closed on a stock install
|
|
203
|
+
// anyway. See DEFAULT_JEV_PLUGIN_SPEC's docstring.
|
|
204
|
+
const jevPlugin = opts.jevPlugin || null
|
|
205
|
+
// Non-secret backend selection ONLY -- e.g. { backend: "systemone",
|
|
206
|
+
// localBaseUrl: "...", localModel: "..." } to point the plugin at a local
|
|
207
|
+
// decision model instead of its own TypeSafe-hosted default. NEVER a
|
|
208
|
+
// credential: the plugin already reads TYPESAFE_API_KEY straight from
|
|
209
|
+
// process.env on every call (jev-compaction-opencode's own client.ts),
|
|
210
|
+
// and the OpenCode child process already inherits this launcher's full
|
|
211
|
+
// environment (bin/eai's `childProcess.spawn(..., { env })`) -- there is
|
|
212
|
+
// no path by which a secret needs to pass through this function, through
|
|
213
|
+
// a JSON file this function writes, or through anything this launcher
|
|
214
|
+
// logs. `jevPluginOptionsExcludingSecrets` enforces that as a real
|
|
215
|
+
// invariant, not just an absence of code: an `apiKey`/`localApiKey` key
|
|
216
|
+
// here is dropped and a warning is written to stderr rather than ever
|
|
217
|
+
// reaching a file on disk.
|
|
218
|
+
const jevPluginOptions = jevPluginOptionsExcludingSecrets(opts.jevPluginOptions)
|
|
142
219
|
|
|
143
220
|
const pluginSource = path.join(packageRoot, "plugin", "opencode-plugin")
|
|
144
221
|
const themeSource = path.join(packageRoot, "config", "themes")
|
|
@@ -150,13 +227,13 @@ function stageOverlay(opts) {
|
|
|
150
227
|
|
|
151
228
|
try {
|
|
152
229
|
// `.staged` records the version, a content fingerprint of the source this
|
|
153
|
-
// tree was built from,
|
|
154
|
-
//
|
|
155
|
-
// to startup, but skipping it on
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
const stamp = `${version} ${fingerprint(pluginSource)} ${gatewayAlias || ""}`.trim()
|
|
230
|
+
// tree was built from, the gateway alias baked into the written config,
|
|
231
|
+
// AND the JEV plugin spec -- re-staging on every launch would add
|
|
232
|
+
// hundreds of file copies to startup, but skipping it on any of these
|
|
233
|
+
// changing would leave a machine stuck on a stale tui.json/opencode.json
|
|
234
|
+
// (the alias case is the incident this already protects against; the JEV
|
|
235
|
+
// spec is the same risk the moment that spec is ever overridden).
|
|
236
|
+
const stamp = `${version} ${fingerprint(pluginSource)} ${gatewayAlias || ""} ${jevPlugin || ""} ${JSON.stringify(jevPluginOptions || {})}`.trim()
|
|
160
237
|
const done = fs.existsSync(marker) && fs.readFileSync(marker, "utf8").trim() === stamp
|
|
161
238
|
if (!done) {
|
|
162
239
|
fs.rmSync(root, { recursive: true, force: true })
|
|
@@ -176,9 +253,13 @@ function stageOverlay(opts) {
|
|
|
176
253
|
// OUT of the array entirely rather than included in a form guaranteed
|
|
177
254
|
// to fail — "plain OpenCode chrome" is the same visible outcome either
|
|
178
255
|
// way, but without logging a load error on every single launch.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
256
|
+
// JEV is appended independently of gatewayAlias: it has no such
|
|
257
|
+
// required option (see DEFAULT_JEV_PLUGIN_SPEC), so there is nothing
|
|
258
|
+
// here that can fail validation the way a bare Elliot plugin path did.
|
|
259
|
+
const plugin = [
|
|
260
|
+
...(gatewayAlias ? [[pluginDir, { serverNames: ["elliot"], gatewayAlias }]] : []),
|
|
261
|
+
...(jevPlugin ? [jevPluginOptions ? [jevPlugin, jevPluginOptions] : jevPlugin] : []),
|
|
262
|
+
]
|
|
182
263
|
fs.writeFileSync(
|
|
183
264
|
path.join(root, "tui.json"),
|
|
184
265
|
JSON.stringify(
|
|
@@ -207,4 +288,4 @@ function stageOverlay(opts) {
|
|
|
207
288
|
}
|
|
208
289
|
}
|
|
209
290
|
|
|
210
|
-
module.exports = { stageOverlay, stageRoot, readGatewayAlias }
|
|
291
|
+
module.exports = { stageOverlay, stageRoot, readGatewayAlias, DEFAULT_JEV_PLUGIN_SPEC }
|
package/lib/status.js
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
const fs = require("fs")
|
|
25
25
|
|
|
26
26
|
const { elliotConfigPath } = require("./setup.js")
|
|
27
|
+
const { billingContext } = require("./context.js")
|
|
27
28
|
|
|
28
29
|
/** Read ~/.elliot/config.json, or null when absent/unreadable. */
|
|
29
30
|
function readElliotConfig() {
|
|
@@ -107,6 +108,7 @@ async function runStatus(opts = {}) {
|
|
|
107
108
|
log(`Workspace ID: ${config.tenant_id || "(unknown)"}`)
|
|
108
109
|
if (config.stack) log(`Stack: ${config.stack}`)
|
|
109
110
|
log(`Backend: ${config.backend_url || "(unset)"}`)
|
|
111
|
+
log(`Billing: ${billingContext(config).label}`)
|
|
110
112
|
|
|
111
113
|
const healthy = config.backend_url ? await checkBackendHealth(config.backend_url) : false
|
|
112
114
|
log(`Reachable: ${healthy ? "yes" : "no"}`)
|
package/lib/teams.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `eai teams` — list, select, verify and clear the team billing context.
|
|
5
|
+
*
|
|
6
|
+
* Approved model (docs/architecture/EAI_ONBOARDING_BILLING_AUDIT.md §4/§6):
|
|
7
|
+
* the BACKEND is the only authority for membership and for the budgeted
|
|
8
|
+
* credential. These commands never trust a client-supplied team id as proof
|
|
9
|
+
* of membership — `GET /teams` returns only the caller's authorized teams,
|
|
10
|
+
* and `teams use`/`teams clear` re-fetch `/onboarding/config` through the
|
|
11
|
+
* exact provisioning path `eai setup` uses, so the materialised `opencode.json`
|
|
12
|
+
* carries the correct team (or personal) budgeted LiteLLM key. Fail-closed:
|
|
13
|
+
* a revoked membership, unknown/ambiguous team, or missing budgeted
|
|
14
|
+
* credential aborts with an actionable message and no config change.
|
|
15
|
+
*
|
|
16
|
+
* Switching teams rematerialises configuration and requires a FRESH `eai`
|
|
17
|
+
* session — OpenCode cannot swap the embedded key mid-session, so in-flight
|
|
18
|
+
* requests keep their original billing context (audit §3, decision 3).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const { ensureFreshAccessToken, authorizedGet } = require("./auth.js")
|
|
22
|
+
const { runSetup } = require("./setup.js")
|
|
23
|
+
|
|
24
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
25
|
+
|
|
26
|
+
/** Unwrap a /teams response whether the backend returns a bare array or an
|
|
27
|
+
* object with a `teams` key. */
|
|
28
|
+
function teamsFromBody(body) {
|
|
29
|
+
if (Array.isArray(body)) return body
|
|
30
|
+
if (body && Array.isArray(body.teams)) return body.teams
|
|
31
|
+
return []
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Fetch the caller's authorized teams (empty on failure). */
|
|
35
|
+
async function fetchTeams(opts = {}) {
|
|
36
|
+
const session = await ensureFreshAccessToken(opts)
|
|
37
|
+
if (!session) return null
|
|
38
|
+
const { config } = session
|
|
39
|
+
if (!config.tenant_id) {
|
|
40
|
+
opts.log && opts.log("Your login has no tenant. Run `eai login` again.")
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
const result = await authorizedGet(
|
|
44
|
+
`/teams?tenant_id=${encodeURIComponent(config.tenant_id)}`,
|
|
45
|
+
opts,
|
|
46
|
+
)
|
|
47
|
+
if (!result || !result.ok) return []
|
|
48
|
+
return teamsFromBody(result.body)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function runTeamsList(opts = {}) {
|
|
52
|
+
const log = opts.log || ((s) => process.stdout.write(s + "\n"))
|
|
53
|
+
const teams = await fetchTeams(opts)
|
|
54
|
+
if (teams === null) return 1
|
|
55
|
+
if (!teams.length) {
|
|
56
|
+
log("No teams are available to you yet.")
|
|
57
|
+
log("Ask an administrator to add you to a team, or run `eai` to use individual billing.")
|
|
58
|
+
return 0
|
|
59
|
+
}
|
|
60
|
+
log("Your teams:")
|
|
61
|
+
for (const team of teams) {
|
|
62
|
+
const name = team.name || "(unnamed)"
|
|
63
|
+
const budget =
|
|
64
|
+
typeof team.max_budget_usd === "number" && team.max_budget_usd > 0
|
|
65
|
+
? ` — budget $${team.max_budget_usd}/day`
|
|
66
|
+
: ""
|
|
67
|
+
log(` ${name}${budget} (${team.id})`)
|
|
68
|
+
}
|
|
69
|
+
log("")
|
|
70
|
+
log('Select one with: eai teams use "<team name>"')
|
|
71
|
+
log("Show the active context with: eai teams current")
|
|
72
|
+
return 0
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Resolve a `teams use` argument to a single team id, or null (with a
|
|
76
|
+
* message) when it is unknown or ambiguous. */
|
|
77
|
+
function resolveTeamId(teams, arg) {
|
|
78
|
+
if (UUID_RE.test(arg)) {
|
|
79
|
+
const match = teams.find((t) => String(t.id) === arg)
|
|
80
|
+
if (match) return { id: String(match.id), name: match.name }
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
const matches = teams.filter((t) => String(t.name) === arg)
|
|
84
|
+
if (matches.length === 1) {
|
|
85
|
+
return { id: String(matches[0].id), name: matches[0].name }
|
|
86
|
+
}
|
|
87
|
+
if (matches.length > 1) {
|
|
88
|
+
const list = matches.map((t) => ` ${t.name} (${t.id})`).join("\n")
|
|
89
|
+
return {
|
|
90
|
+
ambiguous: true,
|
|
91
|
+
message:
|
|
92
|
+
`Multiple teams are named "${arg}". Use the id instead:\n${list}\n` +
|
|
93
|
+
' eai teams use "<id>"',
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function runTeamsUse(opts = {}) {
|
|
100
|
+
const log = opts.log || ((s) => process.stdout.write(s + "\n"))
|
|
101
|
+
const arg = String(opts.team || "").trim()
|
|
102
|
+
if (!arg) {
|
|
103
|
+
log('Usage: eai teams use "<team name or id>"')
|
|
104
|
+
return 1
|
|
105
|
+
}
|
|
106
|
+
const session = await ensureFreshAccessToken(opts)
|
|
107
|
+
if (!session) return 1
|
|
108
|
+
const { token, config } = session
|
|
109
|
+
const backendUrl = String(opts.backendUrl || config.backend_url || "").replace(/\/+$/, "")
|
|
110
|
+
if (!backendUrl) {
|
|
111
|
+
log("No backend configured. Run `eai login` first.")
|
|
112
|
+
return 1
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const teams = await fetchTeams(opts)
|
|
116
|
+
if (teams === null) return 1
|
|
117
|
+
const resolved = resolveTeamId(teams, arg)
|
|
118
|
+
if (!resolved) {
|
|
119
|
+
log(`You are not a member of a team named or id'd "${arg}".`)
|
|
120
|
+
log("Run `eai teams` to see the teams available to you.")
|
|
121
|
+
return 1
|
|
122
|
+
}
|
|
123
|
+
if (resolved.ambiguous) {
|
|
124
|
+
log(resolved.message)
|
|
125
|
+
return 1
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Rematerialise through the exact setup path: the backend validates
|
|
129
|
+
// membership, resolves the TEAM's budgeted LiteLLM key (fail-closed if
|
|
130
|
+
// none resolves), and writes the config + opencode.json. A failure here
|
|
131
|
+
// leaves the previous config untouched.
|
|
132
|
+
log(`Selecting team "${resolved.name}"...`)
|
|
133
|
+
try {
|
|
134
|
+
await runSetup({ token, backendUrl, teamId: resolved.id, log })
|
|
135
|
+
} catch (error) {
|
|
136
|
+
log(String(error && error.message ? error.message : error))
|
|
137
|
+
log("Team selection did not change. Your previous billing context is unchanged.")
|
|
138
|
+
return 1
|
|
139
|
+
}
|
|
140
|
+
log("")
|
|
141
|
+
log(`Billing team set to "${resolved.name}".`)
|
|
142
|
+
log("Restart EAI (`eai`) to apply the new context. In-flight work is unaffected.")
|
|
143
|
+
return 0
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function runTeamsCurrent(opts = {}) {
|
|
147
|
+
const log = opts.log || ((s) => process.stdout.write(s + "\n"))
|
|
148
|
+
const session = await ensureFreshAccessToken(opts)
|
|
149
|
+
if (!session) return 1
|
|
150
|
+
const { config } = session
|
|
151
|
+
|
|
152
|
+
const displayUser = config.email || config.user_id || "(unknown)"
|
|
153
|
+
const teamId = config.team_id
|
|
154
|
+
const isTeam = Boolean(teamId)
|
|
155
|
+
|
|
156
|
+
// Verify server-side, not from local config alone: resolve the team from
|
|
157
|
+
// the authorized list (revoked membership => 404/absent => unverified).
|
|
158
|
+
let teamName = null
|
|
159
|
+
let verified = false
|
|
160
|
+
const teams = await fetchTeams(opts)
|
|
161
|
+
if (teams) {
|
|
162
|
+
if (isTeam) {
|
|
163
|
+
const team = teams.find((t) => String(t.id) === String(teamId))
|
|
164
|
+
if (team) {
|
|
165
|
+
teamName = team.name
|
|
166
|
+
verified = true
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
// Individual billing: verified as long as we can fetch the authorized
|
|
170
|
+
// list (i.e. the session is live and the caller is an identified user).
|
|
171
|
+
verified = true
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
log("Elliot AI context")
|
|
176
|
+
log("─────────────────────────────")
|
|
177
|
+
log(`Signed in: ${displayUser}`)
|
|
178
|
+
if (isTeam && teamName) {
|
|
179
|
+
log(`Billing: Team — ${teamName}`)
|
|
180
|
+
} else if (isTeam && !teamName) {
|
|
181
|
+
log("Billing: Team (name unavailable)")
|
|
182
|
+
} else {
|
|
183
|
+
log("Billing: Individual")
|
|
184
|
+
}
|
|
185
|
+
log(`Status: ${verified ? "Verified" : "Unverified — cannot confirm with the backend"}`)
|
|
186
|
+
if (isTeam) {
|
|
187
|
+
log("Action: Restart EAI to apply the current context")
|
|
188
|
+
} else {
|
|
189
|
+
log("Action: none — requests are charged to your individual budget")
|
|
190
|
+
}
|
|
191
|
+
log("")
|
|
192
|
+
return verified ? 0 : 1
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function runTeamsClear(opts = {}) {
|
|
196
|
+
const log = opts.log || ((s) => process.stdout.write(s + "\n"))
|
|
197
|
+
const session = await ensureFreshAccessToken(opts)
|
|
198
|
+
if (!session) return 1
|
|
199
|
+
const { token, config } = session
|
|
200
|
+
const backendUrl = String(opts.backendUrl || config.backend_url || "").replace(/\/+$/, "")
|
|
201
|
+
if (!backendUrl) {
|
|
202
|
+
log("No backend configured. Run `eai login` first.")
|
|
203
|
+
return 1
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
log("Clearing team selection — reverting to individual billing...")
|
|
207
|
+
try {
|
|
208
|
+
// No team_id => /onboarding/config resolves the caller's PERSONAL key
|
|
209
|
+
// (fail-closed if none resolves — never the shared master key).
|
|
210
|
+
await runSetup({ token, backendUrl, log })
|
|
211
|
+
} catch (error) {
|
|
212
|
+
log(String(error && error.message ? error.message : error))
|
|
213
|
+
log(
|
|
214
|
+
"Individual billing could not be established. Model execution stays " +
|
|
215
|
+
"blocked until a personal budgeted credential is available. Contact " +
|
|
216
|
+
"your administrator.",
|
|
217
|
+
)
|
|
218
|
+
return 1
|
|
219
|
+
}
|
|
220
|
+
log("")
|
|
221
|
+
log("Team selection cleared — billing is now Individual.")
|
|
222
|
+
log("Restart EAI (`eai`) to apply the new context.")
|
|
223
|
+
return 0
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = {
|
|
227
|
+
fetchTeams,
|
|
228
|
+
resolveTeamId,
|
|
229
|
+
runTeamsList,
|
|
230
|
+
runTeamsUse,
|
|
231
|
+
runTeamsCurrent,
|
|
232
|
+
runTeamsClear,
|
|
233
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elliotsystems/eai",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
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"
|
|
@@ -27,6 +27,6 @@
|
|
|
27
27
|
"access": "public"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"opencode-ai": "1.19.
|
|
30
|
+
"opencode-ai": "1.19.5"
|
|
31
31
|
}
|
|
32
32
|
}
|