@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 +48 -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/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/elliot-auth.ts +10 -0
- package/plugin/opencode-plugin/src/guardrail-sync.ts +330 -0
- package/plugin/opencode-plugin/src/index.ts +97 -0
- package/plugin/opencode-plugin/src/session-affinity.ts +41 -0
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
|
-
|
|
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
|
}
|