@elliotsystems/eai 0.1.5 → 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 +54 -2
- package/lib/auth.js +173 -0
- package/lib/context.js +52 -0
- package/lib/login.js +114 -0
- package/lib/setup.js +364 -94
- package/lib/stage.js +60 -7
- package/lib/status.js +2 -0
- package/lib/teams.js +233 -0
- package/package.json +2 -2
- package/plugin/opencode-plugin/package.json +1 -1
- 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/identity-headers.ts +81 -0
- package/plugin/opencode-plugin/src/index.ts +118 -3
- package/plugin/opencode-plugin/src/mcp-keys.ts +4 -0
- package/plugin/opencode-plugin/src/proxy-provider.ts +31 -2
- package/plugin/opencode-plugin/src/session-affinity.ts +41 -0
- package/plugin/opencode-plugin/src/tui/format.ts +37 -0
- package/plugin/opencode-plugin/src/tui/sidebar.tsx +90 -0
- package/plugin/opencode-plugin/src/tui/usage.ts +132 -0
package/lib/stage.js
CHANGED
|
@@ -29,6 +29,35 @@ const fs = require("fs")
|
|
|
29
29
|
const os = require("os")
|
|
30
30
|
const path = require("path")
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* The LiteLLM MCP gateway's alias, from `~/.elliot/config.json`'s
|
|
34
|
+
* `gateway_alias` field -- `eai setup` writes it there straight out of
|
|
35
|
+
* `/onboarding/config/{tenant_id}`'s own `gateway_alias`, which is
|
|
36
|
+
* `litellm_mcp_server_alias` (default `"elliot"`) read server-side.
|
|
37
|
+
*
|
|
38
|
+
* REQUIRED by the plugin's own options schema
|
|
39
|
+
* (`opencode-plugin/src/policy.ts`), with no default there on purpose: it
|
|
40
|
+
* used to be a hardcoded literal in the plugin itself, a fourth copy of a
|
|
41
|
+
* value that already lived in the backend settings, the backend's own
|
|
42
|
+
* namespace module and the LiteLLM YAML -- and it went stale exactly once,
|
|
43
|
+
* silently. This file must not reintroduce that by guessing `"elliot"` when
|
|
44
|
+
* the config is missing it; `undefined` here means "do not configure the
|
|
45
|
+
* plugin with a value nobody actually set", not "assume the default".
|
|
46
|
+
*
|
|
47
|
+
* Best-effort, like `token-env.js::injectToken` right next to it in the same
|
|
48
|
+
* launch path: a missing, unreadable or pre-this-field config must not block
|
|
49
|
+
* startup over branding.
|
|
50
|
+
*/
|
|
51
|
+
function readGatewayAlias() {
|
|
52
|
+
try {
|
|
53
|
+
const configPath = path.join(os.homedir(), ".elliot", "config.json")
|
|
54
|
+
const config = JSON.parse(fs.readFileSync(configPath, "utf8"))
|
|
55
|
+
return typeof config.gateway_alias === "string" && config.gateway_alias ? config.gateway_alias : undefined
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
32
61
|
/**
|
|
33
62
|
* Where the staged overlay lives.
|
|
34
63
|
*
|
|
@@ -104,6 +133,12 @@ function stageOverlay(opts) {
|
|
|
104
133
|
const packageRoot = opts.packageRoot
|
|
105
134
|
const version = opts.version
|
|
106
135
|
const env = opts.env || process.env
|
|
136
|
+
// Resolved by the CALLER's choice of source (bin/eai reads it from
|
|
137
|
+
// ~/.elliot/config.json; tests pass one directly) rather than read again in
|
|
138
|
+
// here, so there is exactly one place that decides where this value comes
|
|
139
|
+
// from. See readGatewayAlias's own docstring for why `undefined` must not
|
|
140
|
+
// become a guessed `"elliot"`.
|
|
141
|
+
const gatewayAlias = opts.gatewayAlias
|
|
107
142
|
|
|
108
143
|
const pluginSource = path.join(packageRoot, "plugin", "opencode-plugin")
|
|
109
144
|
const themeSource = path.join(packageRoot, "config", "themes")
|
|
@@ -114,18 +149,36 @@ function stageOverlay(opts) {
|
|
|
114
149
|
const pluginDir = path.join(root, "plugin", "opencode-plugin")
|
|
115
150
|
|
|
116
151
|
try {
|
|
117
|
-
// `.staged` records the version
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
152
|
+
// `.staged` records the version, a content fingerprint of the source this
|
|
153
|
+
// tree was built from, AND the gateway alias baked into the written
|
|
154
|
+
// config -- re-staging on every launch would add hundreds of file copies
|
|
155
|
+
// to startup, but skipping it on an alias change would leave a machine
|
|
156
|
+
// running `eai setup` against a renamed gateway alias (or configuring it
|
|
157
|
+
// for the first time) stuck on the stale tui.json/opencode.json until
|
|
158
|
+
// something else invalidated the stamp.
|
|
159
|
+
const stamp = `${version} ${fingerprint(pluginSource)} ${gatewayAlias || ""}`.trim()
|
|
122
160
|
const done = fs.existsSync(marker) && fs.readFileSync(marker, "utf8").trim() === stamp
|
|
123
161
|
if (!done) {
|
|
124
162
|
fs.rmSync(root, { recursive: true, force: true })
|
|
125
163
|
copyDir(pluginSource, pluginDir)
|
|
126
164
|
if (fs.existsSync(themeSource)) copyDir(themeSource, path.join(root, "themes"))
|
|
127
165
|
|
|
128
|
-
|
|
166
|
+
// The plugin's own OptionsSchema makes `gatewayAlias` REQUIRED with no
|
|
167
|
+
// default (`opencode-plugin/src/policy.ts`) -- a plugin spec with no
|
|
168
|
+
// options tuple here, the bare-path form `[pluginDir]`, fails that
|
|
169
|
+
// validation on every launch. That was this file's actual behaviour
|
|
170
|
+
// until the incident this comment documents: every bit of TUI branding
|
|
171
|
+
// the plugin provides (the "Elliot AI" footer, the knowledge panel)
|
|
172
|
+
// silently never rendered, logged but never surfaced.
|
|
173
|
+
//
|
|
174
|
+
// With no alias available (a config from before this field existed, or
|
|
175
|
+
// `eai setup` never run against an updated backend), the plugin is left
|
|
176
|
+
// OUT of the array entirely rather than included in a form guaranteed
|
|
177
|
+
// to fail — "plain OpenCode chrome" is the same visible outcome either
|
|
178
|
+
// way, but without logging a load error on every single launch.
|
|
179
|
+
const plugin = gatewayAlias
|
|
180
|
+
? [[pluginDir, { serverNames: ["elliot"], gatewayAlias }]]
|
|
181
|
+
: []
|
|
129
182
|
fs.writeFileSync(
|
|
130
183
|
path.join(root, "tui.json"),
|
|
131
184
|
JSON.stringify(
|
|
@@ -154,4 +207,4 @@ function stageOverlay(opts) {
|
|
|
154
207
|
}
|
|
155
208
|
}
|
|
156
209
|
|
|
157
|
-
module.exports = { stageOverlay, stageRoot }
|
|
210
|
+
module.exports = { stageOverlay, stageRoot, readGatewayAlias }
|
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.9",
|
|
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.
|
|
30
|
+
"opencode-ai": "1.19.4"
|
|
31
31
|
}
|
|
32
32
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strips a Jev-blocked exchange out of what the MODEL sees, before every
|
|
3
|
+
* chat completion request.
|
|
4
|
+
*
|
|
5
|
+
* ## The bug this fixes
|
|
6
|
+
*
|
|
7
|
+
* Jev blocking a prompt stops it from ever reaching the model — but the
|
|
8
|
+
* user's raw text and the resulting error still live on in THIS session's
|
|
9
|
+
* own message history (needed for the UI: the "Request blocked" card's
|
|
10
|
+
* "Try again"/"Edit message" actions, and the transcript itself). OpenCode
|
|
11
|
+
* sends that FULL history with every later turn in the same session, so the
|
|
12
|
+
* very next (benign) prompt's completion request still carried the earlier,
|
|
13
|
+
* never-actually-sent "Help me steal someone's password" as an unanswered
|
|
14
|
+
* prior user turn.
|
|
15
|
+
*
|
|
16
|
+
* The model, seeing an unanswered request sitting in its own context,
|
|
17
|
+
* reasonably tries to be complete and addresses BOTH: the benign question
|
|
18
|
+
* asked just now, and the flagged one it was never actually asked to answer
|
|
19
|
+
* but can still see. Reported directly: asking for a C++ Hello World after
|
|
20
|
+
* an earlier blocked prompt produced a response that opened with a refusal
|
|
21
|
+
* about password theft before getting to the actual Hello World code —
|
|
22
|
+
* confusing, and a response to something that was never really asked of it
|
|
23
|
+
* in the first place (the guardrail is what stopped that request, not the
|
|
24
|
+
* model declining it; the model never got the chance to).
|
|
25
|
+
*
|
|
26
|
+
* ## The fix
|
|
27
|
+
*
|
|
28
|
+
* `experimental.chat.messages.transform` (opencode/packages/plugin/src/
|
|
29
|
+
* index.ts) runs right before the message history is converted to the
|
|
30
|
+
* provider's wire format (session/prompt.ts, immediately before
|
|
31
|
+
* `MessageV2.toModelMessagesEffect`). Removing a blocked exchange here means
|
|
32
|
+
* the model's context for the NEXT turn looks exactly as if the blocked
|
|
33
|
+
* prompt had never been sent at all — the benign follow-up is answered on
|
|
34
|
+
* its own, because that is the only thing the model is shown having been
|
|
35
|
+
* asked.
|
|
36
|
+
*
|
|
37
|
+
* This changes ONLY what the model is shown for a NEW completion request.
|
|
38
|
+
* It does not touch the stored session history (OpenCode's own message
|
|
39
|
+
* store, read by `GET .../messages`) at all, so the UI's own transcript —
|
|
40
|
+
* the blocked turn's card, Try again, Edit message — is completely
|
|
41
|
+
* unaffected; the user still sees exactly what happened. Only the model's
|
|
42
|
+
* own view of "what has this conversation actually asked of me" changes.
|
|
43
|
+
*
|
|
44
|
+
* ## Why in this plugin, not the backend
|
|
45
|
+
*
|
|
46
|
+
* The backend's own per-prompt isolation (jev_decisions_logic.py's
|
|
47
|
+
* `latest_turn_window`) already solves the ANALOGOUS problem for JEV
|
|
48
|
+
* ITSELF — a later prompt is judged on its own, not against an earlier
|
|
49
|
+
* flagged one. That fix has no effect here: it narrows what JEV sees when
|
|
50
|
+
* deciding whether to block, which is a completely different question from
|
|
51
|
+
* what the MODEL sees for an already-ALLOWED prompt. Once Jev allows a
|
|
52
|
+
* request through, this plugin — which sits on the client building that
|
|
53
|
+
* exact request — is the only thing positioned to also narrow what the
|
|
54
|
+
* model is shown.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Every wording `jev_policy_guardrail.py`'s `BLOCKED_MESSAGE` has had.
|
|
59
|
+
* Mirrors `terminal/src/opencode/guardrail.ts`'s own `KNOWN_BLOCKED_
|
|
60
|
+
* MESSAGES` deliberately rather than importing it (different package,
|
|
61
|
+
* different runtime — this one ships inside the OpenCode plugin host, that
|
|
62
|
+
* one inside the Web Chat bundle). Matched by substring for the same reason
|
|
63
|
+
* that file matches by substring: a message blocked under an older backend
|
|
64
|
+
* wording still carries that exact text in session history forever, and
|
|
65
|
+
* this must keep recognizing it as blocked rather than start leaking old
|
|
66
|
+
* blocked prompts into model context the moment the backend's wording next
|
|
67
|
+
* changes. Keep all three lists (here, guardrail.ts, and
|
|
68
|
+
* jev_policy_guardrail.py's own BLOCKED_MESSAGE) in sync.
|
|
69
|
+
*/
|
|
70
|
+
const KNOWN_BLOCKED_MESSAGES = [
|
|
71
|
+
"Guardrail raised an exception, Guardrail: jev-policy, Message: Blocked by policy:",
|
|
72
|
+
"Request blocked by EAI's security policy. Please revise your request and try again.",
|
|
73
|
+
"This message couldn't be sent because it may violate our usage policy. Try rephrasing it, or ask something else.",
|
|
74
|
+
// The four stage/outcome-distinguishing wordings (Jev audit, 2026-10) --
|
|
75
|
+
// this file only ever needs flat yes/no detection (strip from model
|
|
76
|
+
// context, or don't), never which specific kind, so all four are listed
|
|
77
|
+
// here without the `BlockKind` tagging `terminal/src/opencode/
|
|
78
|
+
// guardrail.ts`'s own copy carries for its UI. Mirrors
|
|
79
|
+
// `jev_policy_logic.py`'s four `MESSAGE_*` constants exactly --
|
|
80
|
+
// `opencode-plugin/test/jev-blocked-message-parity.test.ts` pins it.
|
|
81
|
+
"This message was blocked by EAI's usage policy. Please rephrase your request and try again.",
|
|
82
|
+
"EAI couldn't verify this message against its usage policy in time. Please try sending it again.",
|
|
83
|
+
"EAI's usage policy blocked this response before it reached you. Try rephrasing your request, or ask something else.",
|
|
84
|
+
"EAI generated a response but couldn't verify it against its usage policy in time. Please try again.",
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
interface MessageError {
|
|
88
|
+
name?: unknown
|
|
89
|
+
data?: { message?: unknown }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface HistoryEntry {
|
|
93
|
+
info?: { role?: unknown; error?: unknown; [k: string]: unknown }
|
|
94
|
+
parts?: unknown[]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isKnownBlockedText(message: unknown): boolean {
|
|
98
|
+
return typeof message === "string" && KNOWN_BLOCKED_MESSAGES.some((known) => message.includes(known))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** `true` for an assistant turn whose `info.error` is a confirmed Jev
|
|
102
|
+
* guardrail block — never for an ordinary provider error (rate limit,
|
|
103
|
+
* context overflow, a dead connection), which the model legitimately
|
|
104
|
+
* needs to see to make sense of its own earlier failed attempt. */
|
|
105
|
+
export function isBlockedAssistantTurn(info: HistoryEntry["info"]): boolean {
|
|
106
|
+
const error = info?.error as MessageError | undefined
|
|
107
|
+
if (!error || error.name !== "APIError") return false
|
|
108
|
+
return isKnownBlockedText(error.data?.message)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Removes every blocked exchange (the blocked assistant turn AND the user
|
|
113
|
+
* turn immediately before it) from a session's message history. Returns the
|
|
114
|
+
* SAME array instance when nothing needed removing — the common case, so a
|
|
115
|
+
* normal, never-blocked session incurs no extra allocation on every turn.
|
|
116
|
+
*
|
|
117
|
+
* Walks backward from each blocked assistant turn to find its own user
|
|
118
|
+
* turn, skipping only turns already claimed by a LATER blocked exchange
|
|
119
|
+
* (so consecutive blocked turns in the same session each still find their
|
|
120
|
+
* own user message rather than double-claiming one). Stops looking the
|
|
121
|
+
* moment it hits another assistant turn first — an unexpected shape (two
|
|
122
|
+
* assistant turns with no user turn between them) is left alone rather
|
|
123
|
+
* than guessed at, since removing the wrong turn would be worse than
|
|
124
|
+
* removing none.
|
|
125
|
+
*/
|
|
126
|
+
export function stripBlockedTurns<T extends HistoryEntry>(messages: T[]): T[] {
|
|
127
|
+
const drop = new Set<number>()
|
|
128
|
+
|
|
129
|
+
for (let i = 0; i < messages.length; i++) {
|
|
130
|
+
if (messages[i]?.info?.role !== "assistant" || !isBlockedAssistantTurn(messages[i].info)) continue
|
|
131
|
+
drop.add(i)
|
|
132
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
133
|
+
if (drop.has(j)) continue
|
|
134
|
+
const role = messages[j]?.info?.role
|
|
135
|
+
if (role === "user") {
|
|
136
|
+
drop.add(j)
|
|
137
|
+
break
|
|
138
|
+
}
|
|
139
|
+
if (role === "assistant") break
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (drop.size === 0) return messages
|
|
144
|
+
return messages.filter((_, index) => !drop.has(index))
|
|
145
|
+
}
|
|
@@ -50,6 +50,16 @@ export interface ElliotConfig {
|
|
|
50
50
|
backend_url?: string
|
|
51
51
|
litellm_base_url?: string
|
|
52
52
|
litellm_master_key?: string
|
|
53
|
+
/** The organization this config was written for — present since `eai
|
|
54
|
+
* setup` has always spread the onboarding response (which always
|
|
55
|
+
* includes it) into this file. Typed here (was previously only reachable
|
|
56
|
+
* through the index signature) because guardrail-sync.ts needs it to
|
|
57
|
+
* call `GET /teams/{team_id}` with the right tenant context. */
|
|
58
|
+
tenant_id?: string
|
|
59
|
+
/** The single team this config was scoped to at `eai setup` time — absent
|
|
60
|
+
* for a tenant-wide (owner/admin) setup, which has no one team to check
|
|
61
|
+
* a guardrail for. See guardrail-sync.ts. */
|
|
62
|
+
team_id?: string
|
|
53
63
|
/**
|
|
54
64
|
* Every credential this person can use, personal key first (Phase 4).
|
|
55
65
|
* Absent on a config written by an older `eai setup` (or a backend that
|