@elliotsystems/eai 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -5
- package/bin/eai +53 -3
- package/config/opencode.json +4 -0
- package/config/themes/elliot.json +105 -0
- package/config/tui.json +9 -0
- package/lib/resolve.js +44 -12
- package/lib/setup.js +231 -0
- package/lib/stage.js +116 -0
- package/package.json +8 -10
- package/plugin/opencode-plugin/README.md +64 -0
- package/plugin/opencode-plugin/package.json +32 -0
- package/plugin/opencode-plugin/src/classifier.ts +238 -0
- package/plugin/opencode-plugin/src/elliot-auth.ts +133 -0
- package/plugin/opencode-plugin/src/identity.ts +69 -0
- package/plugin/opencode-plugin/src/index.ts +211 -0
- package/plugin/opencode-plugin/src/mcp-keys.ts +30 -0
- package/plugin/opencode-plugin/src/policy.ts +122 -0
- package/plugin/opencode-plugin/src/proxy-provider.ts +96 -0
- package/plugin/opencode-plugin/src/scope.ts +78 -0
- package/plugin/opencode-plugin/src/tui/api.ts +117 -0
- package/plugin/opencode-plugin/src/tui/format.ts +73 -0
- package/plugin/opencode-plugin/src/tui/home.tsx +88 -0
- package/plugin/opencode-plugin/src/tui/host.d.ts +95 -0
- package/plugin/opencode-plugin/src/tui/index.tsx +99 -0
- package/plugin/opencode-plugin/src/tui/knowledge.ts +243 -0
- package/plugin/opencode-plugin/src/tui/prompt.tsx +62 -0
- package/plugin/opencode-plugin/src/tui/sidebar.tsx +184 -0
- package/plugin/opencode-plugin/src/tui/wordmark.tsx +109 -0
- package/plugin/opencode-plugin/themes/elliot.json +105 -0
- package/plugin/opencode-plugin/tsconfig.json +19 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @elliotsystems/opencode-plugin — Elliot AI's OpenCode integration.
|
|
3
|
+
*
|
|
4
|
+
* Phase C2 scope: exactly one job. The Elliot backend serves `knowledge_search`
|
|
5
|
+
* over the native MCP + OAuth boundary (agent-visible as
|
|
6
|
+
* `elliot_knowledge_search`); this plugin injects the session's repository
|
|
7
|
+
* scope into that tool's arguments so retrieval can be repo-focused — while the
|
|
8
|
+
* model itself never names a repository, and the backend's `resolve_repo_scope`
|
|
9
|
+
* remains the only authorizer.
|
|
10
|
+
*
|
|
11
|
+
* What this plugin deliberately does NOT do:
|
|
12
|
+
* - no tokens: OAuth lifecycle (login, storage, refresh) is OpenCode's native
|
|
13
|
+
* MCP client, verified end-to-end against this backend;
|
|
14
|
+
* - no transport: there is no tool-invoke server route at v1.18.19, and
|
|
15
|
+
* reading mcp-auth.json would create a second credential path;
|
|
16
|
+
* - no prompt stuffing: knowledge arrives when the agent elects the tool.
|
|
17
|
+
*
|
|
18
|
+
* Loading (any of): opencode.json `"plugin": [["@elliotsystems/opencode-plugin",
|
|
19
|
+
* {"serverNames": ["elliot"]}]]`, a path spec during development, or a
|
|
20
|
+
* `.opencode/plugin/` file re-export.
|
|
21
|
+
*/
|
|
22
|
+
import { isElliotKnowledgeTool, parseOptions, shellVerdict } from "./policy.js"
|
|
23
|
+
import { ELLIOT_PROVIDER_ID, configHook, makeAuthHook } from "./proxy-provider.js"
|
|
24
|
+
import { readElliotConfig } from "./elliot-auth.js"
|
|
25
|
+
import { formatScope, scopeForWorktree } from "./scope.js"
|
|
26
|
+
import { applyElliotIdentity } from "./identity.js"
|
|
27
|
+
|
|
28
|
+
// Structural mirrors of the host types (packages/plugin/src/index.ts @
|
|
29
|
+
// v1.18.19). Declared locally so this package type-checks and unit-tests
|
|
30
|
+
// without the opencode workspace on the module path; the host validates
|
|
31
|
+
// plugins structurally (`readV1Plugin`), so compatibility is by shape, not by
|
|
32
|
+
// nominal import.
|
|
33
|
+
export interface PluginInput {
|
|
34
|
+
directory: string
|
|
35
|
+
worktree: string
|
|
36
|
+
project?: unknown
|
|
37
|
+
client?: {
|
|
38
|
+
auth?: {
|
|
39
|
+
set: (input: { path: { id: string }; body: Record<string, unknown> }) => Promise<unknown>
|
|
40
|
+
}
|
|
41
|
+
[k: string]: unknown
|
|
42
|
+
}
|
|
43
|
+
serverUrl?: URL
|
|
44
|
+
$?: unknown
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The host's Permission payload — only the fields this plugin reads. */
|
|
48
|
+
export interface PermissionInput {
|
|
49
|
+
type: string
|
|
50
|
+
pattern?: string | string[]
|
|
51
|
+
metadata?: Record<string, unknown>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Structural mirror of the host's `AuthHook` (packages/plugin/src/index.ts). */
|
|
55
|
+
export interface AuthHook {
|
|
56
|
+
provider: string
|
|
57
|
+
methods: unknown[]
|
|
58
|
+
loader?: (auth: () => Promise<unknown>, provider: unknown) => Promise<Record<string, any>>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface Hooks {
|
|
62
|
+
"tool.execute.before"?: (
|
|
63
|
+
input: { tool: string; sessionID: string; callID: string },
|
|
64
|
+
output: { args: Record<string, unknown> },
|
|
65
|
+
) => Promise<void>
|
|
66
|
+
"permission.ask"?: (
|
|
67
|
+
input: PermissionInput,
|
|
68
|
+
output: { status: "ask" | "deny" | "allow" },
|
|
69
|
+
) => Promise<void>
|
|
70
|
+
"experimental.chat.system.transform"?: (
|
|
71
|
+
input: { sessionID?: string; model: unknown },
|
|
72
|
+
output: { system: string[] },
|
|
73
|
+
) => Promise<void>
|
|
74
|
+
config?: (input: any) => Promise<void>
|
|
75
|
+
auth?: AuthHook
|
|
76
|
+
dispose?: () => Promise<void>
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type Plugin = (input: PluginInput, options?: Record<string, unknown>) => Promise<Hooks>
|
|
80
|
+
|
|
81
|
+
export const ElliotPlugin: Plugin = async (input, rawOptions) => {
|
|
82
|
+
// Fail loudly at load on bad options — a plugin that limps with a typo'd
|
|
83
|
+
// config is worse than one that says why it refused to start.
|
|
84
|
+
const options = parseOptions(rawOptions)
|
|
85
|
+
|
|
86
|
+
// Session truth, resolved once. The worktree comes from OpenCode itself —
|
|
87
|
+
// the one source the model cannot influence.
|
|
88
|
+
const worktree = input.worktree || input.directory
|
|
89
|
+
|
|
90
|
+
// Seed a placeholder auth entry for the `elliot` provider so the host's
|
|
91
|
+
// auth loader fires at all (`provider.ts:1584` skips the loader entirely
|
|
92
|
+
// when nothing is stored for its provider id — see Task-1 findings). The
|
|
93
|
+
// value itself is never the credential that reaches the wire; the auth
|
|
94
|
+
// loader's `fetch` attaches the live bearer token per request. Guarded so
|
|
95
|
+
// a client-RPC failure never breaks plugin init, and skipped outright when
|
|
96
|
+
// the user isn't logged into Elliot.
|
|
97
|
+
if (readElliotConfig()) {
|
|
98
|
+
try {
|
|
99
|
+
await input.client?.auth?.set({
|
|
100
|
+
path: { id: ELLIOT_PROVIDER_ID },
|
|
101
|
+
body: { type: "api", key: "elliot-managed" },
|
|
102
|
+
})
|
|
103
|
+
} catch {
|
|
104
|
+
// Never break plugin init on a seeding failure — the auth loader
|
|
105
|
+
// simply won't fire this session, same as the no-login path.
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
/**
|
|
111
|
+
* One handler, two jobs — a JS object literal may carry only one
|
|
112
|
+
* `tool.execute.before` key, and a second would silently shadow the first.
|
|
113
|
+
* (a) the Elliot knowledge tool -> inject worktree-derived repo scope
|
|
114
|
+
* (b) the shell tool -> enforce org hard-deny / credential policy
|
|
115
|
+
*/
|
|
116
|
+
"tool.execute.before": async (hookInput, output) => {
|
|
117
|
+
// (a) Knowledge-tool scope injection. The model never names a repository;
|
|
118
|
+
// this value comes from the session worktree, and the backend authorizes it.
|
|
119
|
+
if (isElliotKnowledgeTool(hookInput.tool, options)) {
|
|
120
|
+
const scope = await scopeForWorktree(worktree)
|
|
121
|
+
if (scope) {
|
|
122
|
+
// Unconditional overwrite: session truth beats anything already in args.
|
|
123
|
+
// The backend schema forbids model-supplied scope, but if one ever slipped
|
|
124
|
+
// through a future contract change, it dies here first.
|
|
125
|
+
output.args["repo_scope_hint"] = formatScope(scope)
|
|
126
|
+
} else {
|
|
127
|
+
// No derivable scope -> guarantee none is smuggled either. Tenant-wide
|
|
128
|
+
// search is the correct degradation (authorized, just unfocused).
|
|
129
|
+
delete output.args["repo_scope_hint"]
|
|
130
|
+
}
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// (b) Shell policy, enforced HERE rather than in `permission.ask`.
|
|
135
|
+
//
|
|
136
|
+
// Forced by upstream reality, verified by scanning every `plugin.trigger(...)`
|
|
137
|
+
// site in v1.18.19: thirteen hooks are triggered and `permission.ask` is NOT
|
|
138
|
+
// among them. It is declared in the public plugin API
|
|
139
|
+
// (packages/plugin/src/index.ts:261) but never invoked, so a handler there is
|
|
140
|
+
// dead code in this version. `tool.execute.before` IS triggered
|
|
141
|
+
// (session/prompt.ts), and throwing from it aborts the tool call — the only
|
|
142
|
+
// plugin-side hard stop available today.
|
|
143
|
+
//
|
|
144
|
+
// Why bother when the org glob policy already asks/denies? Defence in depth
|
|
145
|
+
// against what globs structurally cannot see: a catastrophic shape hiding
|
|
146
|
+
// behind a safe-looking chain segment (`git status ; rm -rf /`), and a command
|
|
147
|
+
// that both names a secret-shaped path and invokes a network tool. If an org
|
|
148
|
+
// policy is ever loosened by accident, these still do not get through.
|
|
149
|
+
//
|
|
150
|
+
// Deliberately narrow: it only ever BLOCKS, and only on the hard-deny and
|
|
151
|
+
// credential classes. Commands the classifier merely wants confirmed are left
|
|
152
|
+
// to the host's own ask flow, which already handles them well.
|
|
153
|
+
if (hookInput.tool !== "bash" && !hookInput.tool.endsWith("_bash")) return
|
|
154
|
+
|
|
155
|
+
const command = output.args?.["command"]
|
|
156
|
+
if (typeof command !== "string") return
|
|
157
|
+
|
|
158
|
+
const verdict = shellVerdict(command)
|
|
159
|
+
if (verdict?.status !== "deny") return
|
|
160
|
+
|
|
161
|
+
throw new Error(
|
|
162
|
+
`Blocked by Elliot organization policy: ${verdict.reason}. Command: ${command}`,
|
|
163
|
+
)
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Kept deliberately, though currently INERT: `permission.ask` has no trigger
|
|
168
|
+
* site in v1.18.19 (verified across every `plugin.trigger` call). When upstream
|
|
169
|
+
* wires it, policy decisions belong here — a returned status is cleaner than a
|
|
170
|
+
* thrown tool error — so this is the forward-compatible home. It can only ever
|
|
171
|
+
* tighten.
|
|
172
|
+
*/
|
|
173
|
+
// The agent introduces itself as OpenCode on a stock runtime, because that
|
|
174
|
+
// is what the compiled system prompt says. Rewritten here so the fix lives
|
|
175
|
+
// in the overlay instead of in the harness. See ./identity.ts.
|
|
176
|
+
"experimental.chat.system.transform": async (_hookInput, output) => {
|
|
177
|
+
applyElliotIdentity(output.system)
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
"permission.ask": async (permInput, output) => {
|
|
181
|
+
if (permInput.type !== "bash") return
|
|
182
|
+
const command = permInput.metadata?.["command"]
|
|
183
|
+
if (typeof command !== "string") return
|
|
184
|
+
const verdict = shellVerdict(command)
|
|
185
|
+
if (!verdict) return
|
|
186
|
+
if (output.status === "deny") return // never loosen
|
|
187
|
+
output.status = verdict.status
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Registers the `elliot` provider (proxy baseURL) and defaults the
|
|
192
|
+
* model to it — no-op when the user isn't logged into Elliot. See
|
|
193
|
+
* `proxy-provider.ts`.
|
|
194
|
+
*/
|
|
195
|
+
config: async (c) => {
|
|
196
|
+
configHook(c)
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Self-refreshing auth loader for the `elliot` provider — see
|
|
201
|
+
* `proxy-provider.ts`. Only fires once the seeding above has stored an
|
|
202
|
+
* auth entry for `ELLIOT_PROVIDER_ID`.
|
|
203
|
+
*/
|
|
204
|
+
auth: makeAuthHook(),
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Both export styles the host loader accepts (`getLegacyPlugins` takes any
|
|
209
|
+
// function export; `PluginModule` prefers `server`).
|
|
210
|
+
export const server = ElliotPlugin
|
|
211
|
+
export default ElliotPlugin
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How OpenCode composes agent-visible MCP tool keys.
|
|
3
|
+
*
|
|
4
|
+
* Its own module, with ZERO imports, because two runtimes need it. `policy.ts`
|
|
5
|
+
* uses it in the server plugin (where zod is available) and `tui/knowledge.ts`
|
|
6
|
+
* uses it in the TUI plugin runtime — which shares only a fixed list of modules
|
|
7
|
+
* with plugins (`solid-js`, `@opentui/*`, `bun`; see `tui/host.d.ts`) and would
|
|
8
|
+
* fail to load a module that reached for `zod` transitively.
|
|
9
|
+
*
|
|
10
|
+
* Duplicating the rule in both places was the alternative. A drifted copy of a
|
|
11
|
+
* key-composition rule fails silently — the tool simply stops being recognised —
|
|
12
|
+
* so one definition, imported twice, is the safer shape.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Mirror of OpenCode's `McpCatalog.sanitize` (mcp/catalog.ts:117) so composed
|
|
17
|
+
* keys are predicted exactly, including server names carrying characters MCP
|
|
18
|
+
* sanitises away.
|
|
19
|
+
*/
|
|
20
|
+
export function sanitizeKey(value: string): string {
|
|
21
|
+
return value.replace(/[^a-zA-Z0-9_-]/g, "_")
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The backend tool behind every Elliot knowledge lookup. */
|
|
25
|
+
export const KNOWLEDGE_TOOL = "knowledge_search"
|
|
26
|
+
|
|
27
|
+
/** The agent-visible key for `KNOWLEDGE_TOOL` on a given MCP server. */
|
|
28
|
+
export function knowledgeToolKey(serverName: string): string {
|
|
29
|
+
return `${sanitizeKey(serverName)}_${KNOWLEDGE_TOOL}`
|
|
30
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* C2-minimal policy: validate the plugin's own options, and identify which
|
|
3
|
+
* agent-visible tool keys are Elliot knowledge tools.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately small. The full bash-policy port (command classification,
|
|
6
|
+
* hard-deny set) is a later phase — nothing in C2 executes shell commands, and
|
|
7
|
+
* carrying unused policy code would be scope creep wearing a safety costume.
|
|
8
|
+
*
|
|
9
|
+
* `PluginOptions` is untyped upstream (`Record<string, unknown>`), so a typo'd
|
|
10
|
+
* option would otherwise vanish silently; zod turns that into a load-time
|
|
11
|
+
* message instead.
|
|
12
|
+
*/
|
|
13
|
+
import { z } from "zod"
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
type Category,
|
|
17
|
+
classifyCommand,
|
|
18
|
+
isHardDenied,
|
|
19
|
+
killsAgentRuntime,
|
|
20
|
+
looksLikeCredentialExfiltration,
|
|
21
|
+
} from "./classifier.js"
|
|
22
|
+
import { KNOWLEDGE_TOOL, knowledgeToolKey, sanitizeKey } from "./mcp-keys.js"
|
|
23
|
+
|
|
24
|
+
const OptionsSchema = z
|
|
25
|
+
.object({
|
|
26
|
+
/**
|
|
27
|
+
* MCP server name(s) this plugin should treat as Elliot. Must match the
|
|
28
|
+
* key(s) under `mcp` in opencode.json. OpenCode composes agent-visible MCP
|
|
29
|
+
* tool keys as `<client>_<tool>` (mcp/catalog.ts:119), so these names are
|
|
30
|
+
* what let the hooks recognise Elliot tools.
|
|
31
|
+
*/
|
|
32
|
+
serverNames: z.array(z.string().min(1)).min(1).default(["elliot"]),
|
|
33
|
+
})
|
|
34
|
+
.strict()
|
|
35
|
+
|
|
36
|
+
export type ElliotPluginOptions = z.infer<typeof OptionsSchema>
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse options, throwing a load-time error on unknown/invalid keys. A plugin
|
|
40
|
+
* that starts with wrong options and limps is worse than one that says why it
|
|
41
|
+
* refused to.
|
|
42
|
+
*/
|
|
43
|
+
export function parseOptions(raw: unknown): ElliotPluginOptions {
|
|
44
|
+
return OptionsSchema.parse(raw ?? {})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Re-exported so existing importers (and the test suite) keep one entry point
|
|
49
|
+
* for the key rule, which now lives in `mcp-keys.ts` — see that module for why
|
|
50
|
+
* it had to become import-free.
|
|
51
|
+
*/
|
|
52
|
+
export { KNOWLEDGE_TOOL, sanitizeKey }
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Is this agent-visible tool key Elliot's knowledge tool on one of our
|
|
56
|
+
* configured servers? Exact composed-key match only — a suffix match would let
|
|
57
|
+
* an unrelated MCP server named e.g. "not-elliot" receive our scope hints.
|
|
58
|
+
*/
|
|
59
|
+
export function isElliotKnowledgeTool(toolKey: string, options: ElliotPluginOptions): boolean {
|
|
60
|
+
return options.serverNames.some((server) => toolKey === knowledgeToolKey(server))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// -- Shell policy verdicts ---------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* What the plugin does to a shell permission decision, and — just as importantly —
|
|
67
|
+
* what it refuses to do.
|
|
68
|
+
*
|
|
69
|
+
* `undefined` means "leave the host's decision untouched". That is the default for
|
|
70
|
+
* anything read-shaped, and it matters: the org's glob policy already decided those,
|
|
71
|
+
* and a plugin that *widened* a permission it did not grant would be a privilege
|
|
72
|
+
* escalation wearing a helpful face. This function can therefore only ever tighten.
|
|
73
|
+
*/
|
|
74
|
+
export function shellVerdict(
|
|
75
|
+
command: string,
|
|
76
|
+
): { status: "deny" | "ask"; category: Category; reason: string } | undefined {
|
|
77
|
+
if (!command.trim()) {
|
|
78
|
+
// Nothing to inspect: say nothing rather than guess. The host's own policy
|
|
79
|
+
// (`bash *: ask` in the org default) remains in force.
|
|
80
|
+
return undefined
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (isHardDenied(command)) {
|
|
84
|
+
return {
|
|
85
|
+
status: "deny",
|
|
86
|
+
category: "DESTRUCTIVE",
|
|
87
|
+
reason:
|
|
88
|
+
"catastrophic command shape (org hard-deny list) — refused outright; " +
|
|
89
|
+
"confirmation adds no safety against a runaway loop",
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (killsAgentRuntime(command)) {
|
|
94
|
+
return {
|
|
95
|
+
status: "deny",
|
|
96
|
+
category: "DESTRUCTIVE",
|
|
97
|
+
reason:
|
|
98
|
+
"kills processes by name matching Elliot's own runtime (node/bun/opencode), " +
|
|
99
|
+
"which terminates this session mid-command and leaves the terminal in " +
|
|
100
|
+
"mouse-reporting mode — target a specific PID instead (Stop-Process -Id, " +
|
|
101
|
+
"taskkill /PID), or kill the service by its own process name",
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (looksLikeCredentialExfiltration(command)) {
|
|
106
|
+
return {
|
|
107
|
+
status: "deny",
|
|
108
|
+
category: "DESTRUCTIVE",
|
|
109
|
+
reason:
|
|
110
|
+
"names a secret-shaped path AND invokes a network tool — refused as possible " +
|
|
111
|
+
"credential exfiltration",
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const category = classifyCommand(command)
|
|
116
|
+
if (category === "DESTRUCTIVE" || category === "MUTATION" || category === "UNKNOWN") {
|
|
117
|
+
return { status: "ask", category, reason: `classified ${category} — needs confirmation` }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// READ / TEST / BUILD: the org globs already allow these. Stay out of the way.
|
|
121
|
+
return undefined
|
|
122
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Elliot proxy provider: a `config` hook that registers a custom
|
|
3
|
+
* `elliot` OpenCode provider pointed at the Elliot LightLLM proxy, and an
|
|
4
|
+
* `auth` hook whose `loader` returns a self-refreshing `fetch` that attaches
|
|
5
|
+
* a live bearer token per request.
|
|
6
|
+
*
|
|
7
|
+
* Mechanism pinned in `.superpowers/sdd/2026-09-03-eai-proxy-auth/task-1-findings.md`:
|
|
8
|
+
* - `config` hook mutates its input in place (no separate output param).
|
|
9
|
+
* - `auth.loader`'s return value is spread wholesale into the provider's
|
|
10
|
+
* resolved `options`, so `{ apiKey, fetch }` land there directly.
|
|
11
|
+
* - `apiKey` is baked into the AI-SDK client at construction time and
|
|
12
|
+
* never changes; the actually-rotating credential is injected per
|
|
13
|
+
* request by `options.fetch`'s `Authorization` header (never the static
|
|
14
|
+
* `apiKey`) — same pattern as OpenCode's bundled snowflake-cortex plugin.
|
|
15
|
+
* - The loader only runs once a stored auth entry exists for its
|
|
16
|
+
* `provider` id (`provider.ts:1584`); the plugin seeds that entry itself
|
|
17
|
+
* at init (see `index.ts`) so no interactive `opencode auth login` step
|
|
18
|
+
* is ever required.
|
|
19
|
+
*
|
|
20
|
+
* Everything here is a no-op when the user isn't logged in
|
|
21
|
+
* (`readElliotConfig()` returns null) — OpenCode then runs exactly as it
|
|
22
|
+
* would without this plugin.
|
|
23
|
+
*/
|
|
24
|
+
import { freshToken, readElliotConfig } from "./elliot-auth.js"
|
|
25
|
+
|
|
26
|
+
export const ELLIOT_LLM_PROXY_URL = "https://backend.elliotsystemsonline.com/llm-proxy"
|
|
27
|
+
export const ELLIOT_PROVIDER_ID = "elliot"
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Registers the `elliot` provider (baseURL = the LightLLM proxy) and
|
|
31
|
+
* defaults `cfg.model` to it, mutating `cfg` in place. No-op when the user
|
|
32
|
+
* isn't logged in. Never overwrites a model the user already configured, or
|
|
33
|
+
* any other existing key under `cfg.provider`/`cfg.plugin`/`cfg.mcp`/etc.
|
|
34
|
+
*/
|
|
35
|
+
export function configHook(cfg: any): void {
|
|
36
|
+
if (!readElliotConfig()) return
|
|
37
|
+
|
|
38
|
+
cfg.provider ??= {}
|
|
39
|
+
cfg.provider[ELLIOT_PROVIDER_ID] = {
|
|
40
|
+
npm: "@ai-sdk/anthropic",
|
|
41
|
+
name: "Elliot (governed)",
|
|
42
|
+
options: { baseURL: ELLIOT_LLM_PROXY_URL },
|
|
43
|
+
models: { "elliot-governed": { name: "Elliot (governed)" } },
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
cfg.model ??= "elliot/elliot-governed"
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Merge `Authorization: Bearer <token>` into whatever headers `init` already
|
|
51
|
+
* carries, without dropping them. Works whether `init.headers` is a plain
|
|
52
|
+
* object, a `Headers` instance, or an array of pairs.
|
|
53
|
+
*/
|
|
54
|
+
function withAuthHeader(init: RequestInit | undefined, token: string): RequestInit {
|
|
55
|
+
const headers = new Headers(init?.headers)
|
|
56
|
+
headers.set("Authorization", `Bearer ${token}`)
|
|
57
|
+
return { ...init, headers }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The per-request fetch wrapper: reads `~/.elliot/config.json` fresh on
|
|
62
|
+
* every call (so a refresh picked up by another process is honored
|
|
63
|
+
* immediately), refreshes via `freshToken` when needed, and attaches the
|
|
64
|
+
* bearer token. Never throws — a missing/expired/unrefreshable token just
|
|
65
|
+
* means the request goes out unauthenticated and the proxy 401s.
|
|
66
|
+
*/
|
|
67
|
+
async function elliotFetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
|
|
68
|
+
try {
|
|
69
|
+
const cfg = readElliotConfig()
|
|
70
|
+
const token = cfg ? await freshToken(cfg) : null
|
|
71
|
+
if (token) {
|
|
72
|
+
return globalThis.fetch(input, withAuthHeader(init, token))
|
|
73
|
+
}
|
|
74
|
+
return globalThis.fetch(input, init)
|
|
75
|
+
} catch {
|
|
76
|
+
return globalThis.fetch(input, init)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Builds the `auth` hook object per the Task-1 pinned `AuthHook` shape.
|
|
82
|
+
* `methods` is empty — this provider is never set up via interactive
|
|
83
|
+
* `opencode auth login`; the plugin seeds the stored auth entry itself
|
|
84
|
+
* (see `index.ts`) so the loader always fires once the user is logged into
|
|
85
|
+
* Elliot.
|
|
86
|
+
*/
|
|
87
|
+
export function makeAuthHook() {
|
|
88
|
+
return {
|
|
89
|
+
provider: ELLIOT_PROVIDER_ID,
|
|
90
|
+
methods: [] as unknown[],
|
|
91
|
+
loader: async (_auth: () => Promise<unknown>, _provider: unknown) => ({
|
|
92
|
+
apiKey: "elliot-managed",
|
|
93
|
+
fetch: elliotFetch,
|
|
94
|
+
}),
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repository scope from the session worktree — the trusted-context half of the
|
|
3
|
+
* "the model never names a repository" invariant.
|
|
4
|
+
*
|
|
5
|
+
* Ported (trimmed, not imported) from `elliot-cli/src/project.ts`: the same
|
|
6
|
+
* `execFile` discipline — a fixed, hardcoded argument array, never a string a
|
|
7
|
+
* shell would parse, and never anything the LLM supplies. The worktree path
|
|
8
|
+
* comes from OpenCode's own `PluginInput`/`ToolContext`, which is session
|
|
9
|
+
* truth, not model output.
|
|
10
|
+
*
|
|
11
|
+
* Authorization is NOT here and never will be: the derived `owner/name` is a
|
|
12
|
+
* hint the Elliot backend authorizes via `resolve_repo_scope`. A wrong value
|
|
13
|
+
* can therefore select nothing the tenant was not already granted — which is
|
|
14
|
+
* why every failure path below degrades to `null` (tenant-wide search) rather
|
|
15
|
+
* than throwing. Scope is an optimization, never a gate.
|
|
16
|
+
*/
|
|
17
|
+
import { execFile } from "node:child_process"
|
|
18
|
+
|
|
19
|
+
export interface RepoScope {
|
|
20
|
+
owner: string
|
|
21
|
+
name: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Handles SSH (git@host:owner/repo.git) and HTTPS (https://host/owner/repo.git)
|
|
26
|
+
* remote forms generically — not GitHub-specific. Same regex the CLI used.
|
|
27
|
+
*/
|
|
28
|
+
export function parseOwnerRepo(remoteUrl: string | null | undefined): RepoScope | null {
|
|
29
|
+
if (!remoteUrl) return null
|
|
30
|
+
const match = remoteUrl.match(/[:/]([^/:]+)\/([^/]+?)(?:\.git)?$/)
|
|
31
|
+
if (!match) return null
|
|
32
|
+
return { owner: match[1], name: match[2] }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `owner/name`, the wire form `repo_scope_hint` carries. */
|
|
36
|
+
export function formatScope(scope: RepoScope): string {
|
|
37
|
+
return `${scope.owner}/${scope.name}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function gitRemoteUrl(worktree: string): Promise<string | null> {
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
execFile(
|
|
43
|
+
"git",
|
|
44
|
+
["remote", "get-url", "origin"],
|
|
45
|
+
{ cwd: worktree, timeout: 5_000, windowsHide: true },
|
|
46
|
+
(err, stdout) => {
|
|
47
|
+
// Any failure — not a repo, no origin, git absent, timeout — is a
|
|
48
|
+
// normal discovery miss, never an error to surface.
|
|
49
|
+
if (err) return resolve(null)
|
|
50
|
+
const url = stdout.trim()
|
|
51
|
+
resolve(url.length ? url : null)
|
|
52
|
+
},
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Derive `owner/name` for a worktree, or null when it cannot be known.
|
|
59
|
+
*
|
|
60
|
+
* Cached per worktree path for the plugin's lifetime: the remote of an open
|
|
61
|
+
* session's repository does not meaningfully change mid-session, and the CLI's
|
|
62
|
+
* rule ("detected once, never re-run per tool call") carries over.
|
|
63
|
+
*/
|
|
64
|
+
const cache = new Map<string, Promise<RepoScope | null>>()
|
|
65
|
+
|
|
66
|
+
export function scopeForWorktree(worktree: string | null | undefined): Promise<RepoScope | null> {
|
|
67
|
+
if (!worktree) return Promise.resolve(null)
|
|
68
|
+
const hit = cache.get(worktree)
|
|
69
|
+
if (hit) return hit
|
|
70
|
+
const pending = gitRemoteUrl(worktree).then(parseOwnerRepo)
|
|
71
|
+
cache.set(worktree, pending)
|
|
72
|
+
return pending
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Test-only: clear the per-worktree cache. */
|
|
76
|
+
export function resetScopeCache(): void {
|
|
77
|
+
cache.clear()
|
|
78
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural mirror of the slice of `TuiPluginApi` this chrome uses.
|
|
3
|
+
*
|
|
4
|
+
* `@opencode-ai/plugin/tui` is deliberately absent from the module list the host
|
|
5
|
+
* shares with runtime-loaded plugins (see `host.d.ts`), so its types cannot be
|
|
6
|
+
* imported. The host validates plugins by SHAPE, not by nominal type, which is
|
|
7
|
+
* what makes a local mirror correct rather than a workaround.
|
|
8
|
+
*
|
|
9
|
+
* Only the members the chrome actually reads appear here. A mirror that copied
|
|
10
|
+
* the whole surface would be a second copy of upstream's API to keep in sync,
|
|
11
|
+
* and every unused member in it is a silent drift risk.
|
|
12
|
+
*/
|
|
13
|
+
import type { RGBA } from "@opentui/core"
|
|
14
|
+
|
|
15
|
+
/** Theme roles this chrome paints with. Names match upstream's exactly. */
|
|
16
|
+
export interface ThemeCurrent {
|
|
17
|
+
readonly primary: RGBA
|
|
18
|
+
readonly text: RGBA
|
|
19
|
+
readonly textMuted: RGBA
|
|
20
|
+
readonly background: RGBA
|
|
21
|
+
readonly backgroundPanel: RGBA
|
|
22
|
+
readonly border: RGBA
|
|
23
|
+
readonly success: RGBA
|
|
24
|
+
readonly warning: RGBA
|
|
25
|
+
readonly error: RGBA
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** `TuiSidebarFileItem`. */
|
|
29
|
+
export interface FileItem {
|
|
30
|
+
readonly file: string
|
|
31
|
+
readonly additions: number
|
|
32
|
+
readonly deletions: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `TuiSidebarMcpItem`. */
|
|
36
|
+
export interface McpStatusItem {
|
|
37
|
+
readonly name: string
|
|
38
|
+
readonly status: string
|
|
39
|
+
readonly error?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A `Message`, narrowed to the id needed to fetch its parts. */
|
|
43
|
+
export interface MessageLike {
|
|
44
|
+
readonly id: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A `Part`, narrowed to what a knowledge citation needs. */
|
|
48
|
+
export interface PartLike {
|
|
49
|
+
readonly type: string
|
|
50
|
+
readonly tool?: string
|
|
51
|
+
readonly state?: {
|
|
52
|
+
readonly status?: string
|
|
53
|
+
readonly output?: string
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** `TuiState` — the read-only session view the chrome derives everything from. */
|
|
58
|
+
export interface StateApi {
|
|
59
|
+
readonly ready: boolean
|
|
60
|
+
readonly path: {
|
|
61
|
+
readonly worktree: string
|
|
62
|
+
readonly directory: string
|
|
63
|
+
}
|
|
64
|
+
readonly vcs: { readonly branch?: string; readonly default_branch?: string } | undefined
|
|
65
|
+
readonly session: {
|
|
66
|
+
count: () => number
|
|
67
|
+
diff: (sessionID: string) => ReadonlyArray<FileItem>
|
|
68
|
+
messages: (sessionID: string) => ReadonlyArray<MessageLike>
|
|
69
|
+
}
|
|
70
|
+
part: (messageID: string) => ReadonlyArray<PartLike>
|
|
71
|
+
mcp: () => ReadonlyArray<McpStatusItem>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** `TuiPromptProps` — the host's prompt component, reused rather than rebuilt. */
|
|
75
|
+
export interface PromptProps {
|
|
76
|
+
sessionID?: string
|
|
77
|
+
visible?: boolean
|
|
78
|
+
disabled?: boolean
|
|
79
|
+
onSubmit?: () => void
|
|
80
|
+
ref?: (ref: unknown) => void
|
|
81
|
+
hint?: unknown
|
|
82
|
+
right?: unknown
|
|
83
|
+
showPlaceholder?: boolean
|
|
84
|
+
placeholders?: { normal?: string[]; shell?: string[] }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface TuiPluginApi {
|
|
88
|
+
readonly app: { readonly version: string }
|
|
89
|
+
readonly theme: {
|
|
90
|
+
readonly current: ThemeCurrent
|
|
91
|
+
/**
|
|
92
|
+
* Copies a theme file from the plugin root into a directory the host's
|
|
93
|
+
* theme discovery scans. Resolved relative to the plugin root by
|
|
94
|
+
* `createThemeInstaller` (packages/opencode/src/plugin/tui/runtime.ts:245).
|
|
95
|
+
*/
|
|
96
|
+
install: (jsonPath: string) => Promise<void>
|
|
97
|
+
has: (name: string) => boolean
|
|
98
|
+
}
|
|
99
|
+
readonly state: StateApi
|
|
100
|
+
readonly ui: {
|
|
101
|
+
Prompt: (props: PromptProps) => unknown
|
|
102
|
+
}
|
|
103
|
+
readonly slots: {
|
|
104
|
+
register(input: { order?: number; slots: Record<string, unknown> }): unknown
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export type TuiPlugin = (api: TuiPluginApi) => Promise<void>
|
|
109
|
+
|
|
110
|
+
/** Props the host passes to the session-scoped slots. */
|
|
111
|
+
export interface SessionSlotProps {
|
|
112
|
+
readonly session_id: string
|
|
113
|
+
readonly visible?: boolean
|
|
114
|
+
readonly disabled?: boolean
|
|
115
|
+
readonly on_submit?: () => void
|
|
116
|
+
readonly ref?: (ref: unknown) => void
|
|
117
|
+
}
|