@elliotsystems/eai 0.1.7 → 0.1.9

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 CHANGED
@@ -31,6 +31,9 @@ const { runStatus } = require("../lib/status.js")
31
31
  const { runLogout } = require("../lib/logout.js")
32
32
  const { runConnect } = require("../lib/connect.js")
33
33
  const { runUpdate, maybeNotify } = require("../lib/update.js")
34
+ const { runLogin } = require("../lib/login.js")
35
+ const { runTeamsList, runTeamsUse, runTeamsCurrent, runTeamsClear } = require("../lib/teams.js")
36
+ const { startupSummary } = require("../lib/context.js")
34
37
  const gdrive = require("../lib/gdrive.js")
35
38
 
36
39
  /**
@@ -119,6 +122,37 @@ if (process.argv[2] === "setup") {
119
122
  return
120
123
  }
121
124
 
125
+ // `eai login` and `eai teams` are intercepted here for the same reason as
126
+ // `setup`/`status`/`logout`: they configure this machine's Elliot session
127
+ // and must work when nothing is configured yet (or before the runtime can
128
+ // even be located). Neither is an OpenCode command, so nothing is shadowed.
129
+ //
130
+ // `login` reuses the paste-token provisioning path (`lib/login.js`), and
131
+ // `teams` the same session service (`lib/auth.js`) + the exact setup
132
+ // rematerialisation path (`lib/setup.js::runSetup`) — one auth implementation
133
+ // for every entry point.
134
+ if (process.argv[2] === "login") {
135
+ const argv = process.argv.slice(3)
136
+ const backendIdx = argv.indexOf("--backend-url")
137
+ const backendUrl = backendIdx >= 0 && argv[backendIdx + 1] ? argv[backendIdx + 1] : undefined
138
+ finishWith(runLogin({ backendUrl }), "login")
139
+ return
140
+ }
141
+
142
+ if (process.argv[2] === "teams") {
143
+ const verb = process.argv[3]
144
+ const run =
145
+ verb === "use"
146
+ ? () => runTeamsUse({ team: process.argv[4] })
147
+ : verb === "current"
148
+ ? runTeamsCurrent
149
+ : verb === "clear"
150
+ ? runTeamsClear
151
+ : runTeamsList
152
+ finishWith(run(), "teams")
153
+ return
154
+ }
155
+
122
156
  // `--version` answers for BOTH layers, because a bug report that says only
123
157
  // "1.18.30" names the runtime and not the product — and the two move
124
158
  // independently: one is this package, the other is its `opencode-ai`
@@ -230,6 +264,20 @@ if (process.env.ELLIOT_LAUNCHER_DEBUG) {
230
264
  // the child inherits process.env, so setting it here is enough.
231
265
  injectToken(process.env)
232
266
 
267
+ // A concise startup context summary (TTY only, read-only, network-free — see
268
+ // lib/context.js). The fail-closed gate for an invalid context lives at the
269
+ // CREDENTIAL level (empty ELLIOT_TOKEN => LiteLLM rejects, no master-key
270
+ // fallback), so this never blocks or fails startup; it just tells the user
271
+ // what context the session resolves to.
272
+ if (process.stdout.isTTY) {
273
+ try {
274
+ for (const line of startupSummary()) process.stdout.write(`${line}\n`)
275
+ process.stdout.write("\n")
276
+ } catch {
277
+ // A context summary may never be the reason a session fails to start.
278
+ }
279
+ }
280
+
233
281
  // Elliot's identity travels WITH this package, not with the directory the user
234
282
  // happens to be standing in. Before this, branding came from a `tui.json` in the
235
283
  // Elliot repository root, so an installed `eai` outside that repo rendered as
package/lib/auth.js ADDED
@@ -0,0 +1,173 @@
1
+ "use strict"
2
+
3
+ /**
4
+ * Shared authentication/session service for the EAI CLI.
5
+ *
6
+ * `eai login`, `eai teams`, `eai status` and the startup path all resolve the
7
+ * same facts from the same file and the same backend endpoints, so this
8
+ * module is where those facts live once:
9
+ *
10
+ * ~/.elliot/config.json the secure session store (0600, written by
11
+ * `eai login`/`eai setup`, single source of truth)
12
+ * POST /auth/cli-token exchange a portal token for a CLI access/refresh
13
+ * pair (backend/app/auth/router.py:238)
14
+ * POST /auth/refresh refresh a session (backend/app/auth/router.py:175)
15
+ * GET /usage/me verify the session and read the user (usage.py:86)
16
+ *
17
+ * Security rules from docs/architecture/EAI_ONBOARDING_BILLING_AUDIT.md §5/§7:
18
+ * - the backend is the only authority for identity and membership;
19
+ * - a missing/expired/revoked session fails closed with an actionable
20
+ * message — never a silent fallback to a privileged credential;
21
+ * - credentials are never printed or written to project files.
22
+ */
23
+
24
+ const fs = require("fs")
25
+ const os = require("os")
26
+ const path = require("path")
27
+
28
+ const { elliotConfigPath, writeElliotConfig } = require("./setup.js")
29
+
30
+ // The default backend a pasted login token targets. The token itself carries
31
+ // no backend URL (it is minted by whichever portal produced it), so an
32
+ // existing config's `backend_url` wins, `--backend-url` overrides, and this
33
+ // production default is the fallback — see runLogin.
34
+ const DEFAULT_BACKEND_URL = "https://backend.elliotsystemsonline.com"
35
+
36
+ /** Read ~/.elliot/config.json, or null when absent/unreadable. */
37
+ function readConfig() {
38
+ try {
39
+ const parsed = JSON.parse(fs.readFileSync(elliotConfigPath(), "utf8"))
40
+ return parsed && typeof parsed === "object" ? parsed : null
41
+ } catch {
42
+ return null
43
+ }
44
+ }
45
+
46
+ /** The backend URL to use, from config or the default. */
47
+ function backendUrl(config) {
48
+ return String((config && config.backend_url) || "").replace(/\/+$/, "") || DEFAULT_BACKEND_URL
49
+ }
50
+
51
+ /**
52
+ * Resolve a fresh access token, refreshing from `~/.elliot/config.json` when
53
+ * the stored one is expired or the backend rejects it. Returns the token, or
54
+ * null when no session is usable (and explains why).
55
+ *
56
+ * `POST /auth/refresh` takes the refresh token in the body (auth/router.py:
57
+ * 175-235); it is the credential there, and is refused for audience-bound
58
+ * tokens. Non-rotating: the same refresh token keeps working until revoked.
59
+ *
60
+ * @param {{log?: (s: string) => void}} [opts]
61
+ * @returns {Promise<{token: string, config: object} | null>}
62
+ */
63
+ async function ensureFreshAccessToken(opts = {}) {
64
+ const log = opts.log || ((s) => process.stdout.write(s + "\n"))
65
+ const config = readConfig()
66
+ if (!config) {
67
+ log("Not logged in. Run `eai login` first.")
68
+ return null
69
+ }
70
+ if (!config.jwt_token) {
71
+ log("Your login is incomplete (no session token). Run `eai login` again.")
72
+ return null
73
+ }
74
+
75
+ // Refresh proactively when we know the access token has expired. Parsed
76
+ // leniently: a missing/odd `expires_at` simply means "don't refresh early".
77
+ const expiresAt = Date.parse(String(config.access_token_expires_at || ""))
78
+ const needsRefresh = Number.isFinite(expiresAt) && expiresAt <= Date.now()
79
+
80
+ if (needsRefresh && config.refresh_token) {
81
+ const fresh = await refresh(config)
82
+ if (fresh) return { token: fresh.token, config: fresh.config }
83
+ log(
84
+ "Your session has expired and could not be refreshed. Run `eai login` again.",
85
+ )
86
+ return null
87
+ }
88
+
89
+ return { token: config.jwt_token, config }
90
+ }
91
+
92
+ /**
93
+ * Call `POST /auth/refresh` and persist the new access token. Returns
94
+ * `{token, config}` on success, null on any failure (never throws).
95
+ */
96
+ async function refresh(config) {
97
+ try {
98
+ const base = backendUrl(config)
99
+ const res = await fetch(`${base}/auth/refresh`, {
100
+ method: "POST",
101
+ headers: { "Content-Type": "application/json" },
102
+ body: JSON.stringify({ refresh_token: config.refresh_token }),
103
+ })
104
+ if (!res.ok) return null
105
+ const body = await res.json()
106
+ if (!body || typeof body.access_token !== "string") return null
107
+ const next = {
108
+ ...config,
109
+ jwt_token: body.access_token,
110
+ ...(typeof body.access_token_expires_at === "string"
111
+ ? { access_token_expires_at: body.access_token_expires_at }
112
+ : {}),
113
+ }
114
+ writeElliotConfig(next)
115
+ return { token: next.jwt_token, config: next }
116
+ } catch {
117
+ return null
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Authenticated GET. Uses `ensureFreshAccessToken` for a fresh bearer, and
123
+ * retries once after a refresh when the backend answers 401 (a token that
124
+ * was still unexpired locally but revoked server-side).
125
+ *
126
+ * @returns {Promise<{ok: boolean, status: number, body: any} | null>}
127
+ * null when no usable session exists.
128
+ */
129
+ async function authorizedGet(url, opts = {}) {
130
+ const session = await ensureFreshAccessToken(opts)
131
+ if (!session) return null
132
+ const { token, config } = session
133
+ const base = backendUrl(config)
134
+
135
+ let res
136
+ try {
137
+ res = await fetch(`${base}${url}`, {
138
+ headers: { Authorization: `Bearer ${token}` },
139
+ signal: opts.signal,
140
+ })
141
+ } catch (error) {
142
+ opts.log && opts.log(`Could not reach the backend at ${base}: ${error.message}`)
143
+ return { ok: false, status: 0, body: null }
144
+ }
145
+
146
+ if (res.status === 401 && config.refresh_token) {
147
+ const fresh = await refresh(config)
148
+ if (fresh) {
149
+ res = await fetch(`${base}${url}`, {
150
+ headers: { Authorization: `Bearer ${fresh.token}` },
151
+ signal: opts.signal,
152
+ })
153
+ }
154
+ }
155
+
156
+ let body = null
157
+ try {
158
+ body = await res.json()
159
+ } catch {
160
+ // Non-JSON body; callers fall back to the status.
161
+ }
162
+ return { ok: res.ok, status: res.status, body }
163
+ }
164
+
165
+ module.exports = {
166
+ DEFAULT_BACKEND_URL,
167
+ readConfig,
168
+ backendUrl,
169
+ ensureFreshAccessToken,
170
+ refresh,
171
+ authorizedGet,
172
+ elliotConfigPath,
173
+ }
package/lib/context.js ADDED
@@ -0,0 +1,52 @@
1
+ "use strict"
2
+
3
+ /**
4
+ * The EAI CLI's effective billing context, resolved from the local session.
5
+ *
6
+ * The BACKEND remains the authority for identity, membership and the
7
+ * budgeted credential — this module only renders what a session already
8
+ * resolved (see `docs/architecture/EAI_ONBOARDING_BILLING_AUDIT.md` §6/§7).
9
+ * It is read-only and network-free, so it can run on the startup path and in
10
+ * `eai status` without adding latency or a hard dependency on the backend.
11
+ *
12
+ * The fail-closed gate for an invalid/unresolved context is at the CREDENTIAL
13
+ * level, not here: if `~/.elliot/config.json` carries no budgeted LiteLLM key
14
+ * (`litellm_master_key`), `lib/token-env.js` injects no `ELLIOT_TOKEN`, and
15
+ * the runtime's model calls are rejected by LiteLLM — no unbudgeted spend, no
16
+ * shared-master-key fallback.
17
+ */
18
+
19
+ const { readConfig } = require("./auth.js")
20
+
21
+ /** `{type: "team"|"individual", label, teamId}` from the local session. */
22
+ function billingContext(config) {
23
+ if (!config) return { type: "none", label: "Not signed in", teamId: null }
24
+ if (config.team_id) {
25
+ return {
26
+ type: "team",
27
+ label: `Team — ${config.team_name || config.team_id}`,
28
+ teamId: String(config.team_id),
29
+ }
30
+ }
31
+ return { type: "individual", label: "Individual", teamId: null }
32
+ }
33
+
34
+ /**
35
+ * The lines printed when starting `eai` (TTY only) and in `eai status`.
36
+ * Never prints credentials; identifiers are shown only where they are the
37
+ * user-facing value (e.g. the team id when no name is known).
38
+ */
39
+ function startupSummary(config = readConfig()) {
40
+ const billing = billingContext(config)
41
+ const signedIn = config && (config.email || config.user_id) ? config.email || config.user_id : "(none)"
42
+ const gateway = config && config.litellm_base_url ? "LiteLLM" : "(unconfigured)"
43
+ return [
44
+ "Elliot AI",
45
+ `Signed in: ${signedIn}`,
46
+ `Billing: ${billing.label}`,
47
+ `Gateway: ${gateway}`,
48
+ "Project: (none)",
49
+ ]
50
+ }
51
+
52
+ module.exports = { billingContext, startupSummary }
package/lib/login.js ADDED
@@ -0,0 +1,114 @@
1
+ "use strict"
2
+
3
+ /**
4
+ * `eai login` — establish an authenticated CLI session with a pasted token.
5
+ *
6
+ * Approved flow (docs/architecture/EAI_ONBOARDING_BILLING_AUDIT.md §5/§15):
7
+ * reuse the existing paste-token mechanism. The token is obtained from the
8
+ * portal (or the existing `eai setup` handoff), prompted for SECURELY (never
9
+ * via argv, so it cannot leak into shell history or `ps`), exchanged for a
10
+ * CLI access/refresh pair at `POST /auth/cli-token`, then provisioned through
11
+ * the exact same path `eai setup` uses (`lib/setup.js::runSetup`), so there
12
+ * is ONE authentication implementation, not two.
13
+ *
14
+ * Fail-closed: an unreadable/invalid token, an expired/revoked session or a
15
+ * backend that cannot resolve a budgeted credential all abort with an
16
+ * actionable message. No credential is ever printed.
17
+ */
18
+
19
+ const readline = require("readline")
20
+
21
+ const auth = require("./auth.js")
22
+ const { decodeJwtPayload, runSetup } = require("./setup.js")
23
+
24
+ /**
25
+ * Prompt for a token without echoing it to the terminal (no shell history, no
26
+ * process-argument exposure). On a non-TTY stdin (piped/CI), reads a plain
27
+ * line — callers can also pass `opts.token` to skip the prompt entirely.
28
+ */
29
+ function promptHidden(question) {
30
+ return new Promise((resolve) => {
31
+ if (!process.stdin.isTTY) {
32
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
33
+ rl.question(`${question} `, (answer) => {
34
+ rl.close()
35
+ resolve(String(answer || "").trim())
36
+ })
37
+ return
38
+ }
39
+ process.stdout.write(question + " ")
40
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true })
41
+ rl._writeToOutput = (str) => {
42
+ // Mask the echoed token as asterisks; keep newlines/control output.
43
+ if (/[\r\n]/.test(str)) rl.output.write(str)
44
+ else rl.output.write("*".repeat(str.length))
45
+ }
46
+ rl.question("", (answer) => {
47
+ process.stdout.write("\n")
48
+ rl.close()
49
+ resolve(String(answer || "").trim())
50
+ })
51
+ })
52
+ }
53
+
54
+ /**
55
+ * @param {{token?: string, backendUrl?: string, log?: (s: string) => void}} opts
56
+ * @returns {Promise<number>} exit code
57
+ */
58
+ async function runLogin(opts = {}) {
59
+ const log = opts.log || ((s) => process.stdout.write(s + "\n"))
60
+ const existing = auth.readConfig()
61
+ const backendUrl = String(
62
+ opts.backendUrl || auth.backendUrl(existing) || auth.DEFAULT_BACKEND_URL,
63
+ ).replace(/\/+$/, "")
64
+
65
+ log("Elliot AI login")
66
+ log("─────────────────────────────")
67
+ log(
68
+ "Get a login token from the Elliot portal (or from `eai setup`'s own " +
69
+ "handoff), then paste it below. It is not echoed and never appears in " +
70
+ "your shell history.",
71
+ )
72
+ if (!existing) {
73
+ log(`Backend: ${backendUrl} (use --backend-url to target another environment)`)
74
+ }
75
+ log("")
76
+
77
+ const token = opts.token || (await promptHidden("Paste your login token:"))
78
+ if (!token) {
79
+ log("Login cancelled — no token entered.")
80
+ return 1
81
+ }
82
+
83
+ // Validate before any network call: the token must look like an Elliot
84
+ // access token carrying a tenant claim.
85
+ const claims = decodeJwtPayload(token) || {}
86
+ if (!claims.tenant_id) {
87
+ log(
88
+ "That does not look like a valid Elliot login token (no tenant). " +
89
+ "Copy the full token from the portal and try again.",
90
+ )
91
+ return 1
92
+ }
93
+
94
+ log("")
95
+ try {
96
+ // The exact provisioning path `eai setup` uses: cli-token exchange,
97
+ // /onboarding/config fetch, secure config writes, opencode.json
98
+ // materialisation, agent files. One implementation, both entry points.
99
+ const result = await runSetup({ token, backendUrl, log })
100
+ log("")
101
+ log("Logged in.")
102
+ log("Next: run `eai teams` to view your teams, `eai teams use \"<name>\"`")
103
+ log(" to pick a billing team, or `eai` to start with individual billing.")
104
+ return result ? 0 : 1
105
+ } catch (error) {
106
+ // Includes the fail-closed 503 from /onboarding/config when no budgeted
107
+ // credential resolves — the message from the backend is actionable and
108
+ // confirms no model request was configured.
109
+ log(String(error && error.message ? error.message : error))
110
+ return 1
111
+ }
112
+ }
113
+
114
+ module.exports = { runLogin, promptHidden }
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
- if (!allowedModels || allowedModels.length === 0) return all
138
-
139
- const allowed = new Set(allowedModels)
140
- const offered = Object.keys(all).filter((id) => allowed.has(id))
141
- if (offered.length === 0) return all
142
-
143
- const filtered = {}
144
- for (const id of offered) filtered[id] = all[id]
145
- return filtered
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
- * Every agent the backend's `eai_agents` response named, in one pass.
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
- * Stale-file cleanup is deliberately NOT done here: deleting a file this
552
- * launcher did not just enumerate would mean trusting a naming convention to
553
- * tell an EAI-generated agent apart from one the user wrote themselves under
554
- * the same directory, which is exactly the kind of guess `elliotCommands()`'s
555
- * "OURS TO REPLACE, THEIRS TO KEEP" rule exists to avoid making.
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
- return (agents || []).map((agent) => writeAgentFile(agent, env))
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
- throw new Error(`backend returned ${res.status} fetching the organisation config`)
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
  }