@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elliotsystems/eai",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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"
@@ -8,14 +8,16 @@
8
8
  "files": [
9
9
  "bin",
10
10
  "lib",
11
+ "config",
12
+ "plugin",
11
13
  "README.md"
12
14
  ],
13
15
  "scripts": {
14
16
  "test": "node --test test/*.test.js",
15
- "vendor": "node scripts/vendor.js",
16
- "assemble": "node scripts/assemble.js",
17
17
  "publish:npm": "node scripts/publish.js",
18
- "build:runtime": "bun run --cwd ../opencode/packages/opencode script/build.ts --skip-embed-web-ui"
18
+ "bundle": "node scripts/bundle.js",
19
+ "prepack": "node scripts/bundle.js",
20
+ "verify": "node scripts/verify-package.js"
19
21
  },
20
22
  "engines": {
21
23
  "node": ">=18.0.0"
@@ -23,11 +25,7 @@
23
25
  "publishConfig": {
24
26
  "access": "public"
25
27
  },
26
- "optionalDependencies": {
27
- "@elliotsystems/eai-darwin-arm64": "0.1.2",
28
- "@elliotsystems/eai-darwin-x64": "0.1.2",
29
- "@elliotsystems/eai-linux-x64": "0.1.2",
30
- "@elliotsystems/eai-linux-arm64": "0.1.2",
31
- "@elliotsystems/eai-windows-x64": "0.1.2"
28
+ "dependencies": {
29
+ "opencode-ai": "1.18.30"
32
30
  }
33
31
  }
@@ -0,0 +1,64 @@
1
+ # @elliotsystems/opencode-plugin
2
+
3
+ Elliot AI's OpenCode integration. Phase C2 scope: repository-scope injection for
4
+ `elliot_knowledge_search`.
5
+
6
+ ## What it does — and pointedly does not
7
+
8
+ The Elliot backend serves `knowledge_search` as an MCP tool on `/mcp/v1`; through the MCP
9
+ server name `elliot`, the agent sees **`elliot_knowledge_search`**. This plugin's single
10
+ job is to inject the session's repository (`owner/name`, derived from the worktree's git
11
+ remote) into that tool's arguments, so retrieval can be repo-focused **while the model
12
+ never names a repository**. The backend's `resolve_repo_scope` remains the only
13
+ authorizer — a wrong or forged hint can select nothing the tenant wasn't already granted.
14
+
15
+ No tokens (login/refresh/storage is OpenCode's native MCP OAuth — `opencode mcp auth
16
+ elliot`), no transport, no prompt injection.
17
+
18
+ ## Setup
19
+
20
+ `opencode.json`:
21
+
22
+ ```json
23
+ {
24
+ "$schema": "https://opencode.ai/config.json",
25
+ "mcp": {
26
+ "elliot": {
27
+ "type": "remote",
28
+ "url": "https://backend.elliotsystemsonline.com/mcp/v1",
29
+ "oauth": { "clientId": "elliot-opencode" }
30
+ }
31
+ },
32
+ "plugin": [["@elliotsystems/opencode-plugin@0.1.0", { "serverNames": ["elliot"] }]]
33
+ }
34
+ ```
35
+
36
+ Then `opencode mcp auth elliot` once. Pin the plugin version — never `@latest`.
37
+
38
+ `serverNames` must match your `mcp` key(s); the plugin recognises the composed tool key
39
+ `<server>_knowledge_search` exactly (OpenCode composes MCP tool keys as
40
+ `sanitize(client) + "_" + sanitize(tool)`).
41
+
42
+ ## Development
43
+
44
+ ```bash
45
+ npm install
46
+ npm test # vitest — includes the security cases (scope overwrite/delete)
47
+ npm run typecheck
48
+ ```
49
+
50
+ Load the working copy into OpenCode with a path spec:
51
+ `"plugin": [["file:///abs/path/to/opencode-plugin/src/index.ts", {}]]`.
52
+
53
+ Windows note: if `bun`/OpenCode installs hit `EPERM (NtSetInformationFile())` on native
54
+ packages, that's AV interference during cache renames — use
55
+ `bun install --backend=copyfile` and/or add an AV exclusion (see Phase B findings).
56
+
57
+ ## Security invariants (tested)
58
+
59
+ - Session truth **overwrites** any pre-existing `repo_scope_hint` in tool args.
60
+ - With no derivable scope, a smuggled hint is **deleted**; search degrades to
61
+ tenant-wide (authorized, just unfocused) — never an error, never a forged scope.
62
+ - Only the exact composed key on configured servers is touched; every other tool's
63
+ args pass through byte-identical.
64
+ - Options are zod-`strict()`: unknown keys fail the plugin at load, loudly.
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@elliotsystems/opencode-plugin",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Elliot AI integration for OpenCode: tenant-aware knowledge tools, repository scope, and policy.",
6
+ "license": "MIT",
7
+ "main": "./src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./tui": "./src/tui/index.tsx"
11
+ },
12
+ "files": [
13
+ "src",
14
+ "themes",
15
+ "README.md"
16
+ ],
17
+ "peerDependencies": {
18
+ "@opentui/core": ">=0.4.5 <0.5",
19
+ "@opentui/solid": ">=0.4.5 <0.5",
20
+ "solid-js": ">=1.9.10 <2",
21
+ "zod": ">=3.23"
22
+ },
23
+ "engines": {
24
+ "node": ">=18"
25
+ },
26
+ "elliot": {
27
+ "opencodeRange": ">=1.18.19 <1.19.0"
28
+ },
29
+ "oc-themes": [
30
+ "themes/elliot.json"
31
+ ]
32
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Shell-command classification — the policy intelligence glob patterns cannot express.
3
+ *
4
+ * Ported (adapted, not copied wholesale) from `elliot-cli/src/tools/bash-policy.ts`,
5
+ * which is pure and dependency-free. Three things here are beyond what OpenCode's
6
+ * native `permission.bash` globs can represent, which is exactly why this file exists:
7
+ *
8
+ * 1. **Category labels** (READ/TEST/BUILD/MUTATION/DESTRUCTIVE/UNKNOWN) — a glob
9
+ * yields a verdict, never a reason. A human approving a prompt deserves the reason.
10
+ * 2. **Chain-splitting with worst-case ranking** — OpenCode does decompose pipelines
11
+ * per command node (`tool/shell.ts:408`), but the *classification* of a compound
12
+ * command as "only as safe as its most dangerous segment" is ours.
13
+ * 3. **The credential-exfiltration heuristic** — a relationship between two tokens
14
+ * ("names a secret-shaped path" AND "invokes a network tool") in one command line.
15
+ * No prefix pattern can see a relationship.
16
+ *
17
+ * Failure direction is inherited deliberately from the original: over-trigger toward
18
+ * `ask`, never under-trigger. A spurious prompt is a cost; a missed bypass is an
19
+ * incident. Notably `CHAIN_SPLIT_RE` is a naive split, not a shell parser — a literal
20
+ * `;` inside a quoted commit message can cause an extra prompt. That is the correct
21
+ * trade.
22
+ *
23
+ * Nothing here is repository-specific: every pattern is a generic command shape.
24
+ */
25
+
26
+ export type Category = "READ" | "TEST" | "BUILD" | "MUTATION" | "DESTRUCTIVE" | "UNKNOWN"
27
+
28
+ function firstToken(cmd: string): string {
29
+ return cmd.trim().split(/\s+/)[0] ?? ""
30
+ }
31
+
32
+ /**
33
+ * git is classified by SUBCOMMAND and flags, never lumped together: `git status` and
34
+ * `git push --force` share a prefix and share nothing else.
35
+ */
36
+ function classifyGit(cmd: string): Category {
37
+ const rest = cmd.trim().replace(/^git\s+/i, "")
38
+ const sub = firstToken(rest).toLowerCase()
39
+
40
+ if (/(^|\s)(--force|-f)(\s|$)/i.test(rest) && sub === "push") return "DESTRUCTIVE"
41
+ if (sub === "push") return "MUTATION"
42
+ if (sub === "reset" && /--hard/i.test(rest)) return "DESTRUCTIVE"
43
+ if (sub === "reset") return "MUTATION"
44
+ if (sub === "clean" && /-\w*f/i.test(rest)) return "DESTRUCTIVE"
45
+ if (sub === "clean") return "MUTATION"
46
+ if (["commit", "checkout", "merge", "rebase", "stash", "branch"].includes(sub)) {
47
+ // Listing branches is a read; deleting one is not.
48
+ if (sub === "branch") return /(^|\s)-[dD]\b/.test(rest) ? "MUTATION" : "READ"
49
+ return "MUTATION"
50
+ }
51
+ if (["status", "log", "diff", "show", "remote"].includes(sub)) return "READ"
52
+ return "UNKNOWN"
53
+ }
54
+
55
+ const READ_PATTERNS: RegExp[] = [
56
+ /^(ls|dir)\b/i, /^cat\b/i, /^type\b/i, /^head\b/i, /^tail\b/i, /^wc\b/i,
57
+ /^pwd$/i, /^cd\b/i, /^echo\b/i,
58
+ /^find\b(?!.*(-delete|-exec))/i,
59
+ /^(which|where)\b/i,
60
+ /^node\s+(-v|--version)$/i,
61
+ /^npm\s+(ls|list)\b/i,
62
+ // Read-only pipeline filters. Present because OpenCode asks about every node of a
63
+ // pipeline, so an unclassified filter would drag an otherwise-READ pipeline to UNKNOWN.
64
+ // `sed`/`awk`/`xargs` are deliberately absent: `sed -i` writes, `awk` has
65
+ // `print > file` and `system()`, `xargs` executes whatever follows.
66
+ /^(grep|findstr|cut|sort|uniq|tr|jq)\b/i,
67
+ ]
68
+
69
+ const TEST_PATTERNS: RegExp[] = [
70
+ /^npm\s+test\b/i, /^npm\s+run\s+test/i, /^(yarn|pnpm)\s+test\b/i,
71
+ /^python\s+-m\s+pytest\b/i, /^pytest\b/i, /^jest\b/i, /^vitest\b/i,
72
+ /^npx\s+vitest\b/i, /^go\s+test\b/i, /^cargo\s+test\b/i, /^mvn\s+test\b/i,
73
+ /^dotnet\s+test\b/i, /^make\s+test\b/i,
74
+ ]
75
+
76
+ const BUILD_PATTERNS: RegExp[] = [
77
+ /^npm\s+run\s+build\b/i, /^(yarn|pnpm)\s+build\b/i, /^tsc\b/i,
78
+ /^go\s+build\b/i, /^cargo\s+build\b/i, /^mvn\s+package\b/i, /^make(\s+build)?$/i,
79
+ ]
80
+
81
+ const MUTATION_PATTERNS: RegExp[] = [
82
+ /^npm\s+(install|uninstall|ci)\b/i, /^pip\s+(install|uninstall)\b/i,
83
+ /^mv\b/i, /^cp\b/i, /^mkdir\b/i, /^touch\b/i, /^chmod\b/i, /^chown\b/i,
84
+ /^docker\s+(run|stop|rm)\b/i, /^kill\b/i, /^taskkill\b/i,
85
+ // A single, non-recursive delete — real but bounded. `rm -rf` is caught as
86
+ // DESTRUCTIVE before this list is consulted.
87
+ /^rm\b/i, /^del\b/i,
88
+ ]
89
+
90
+ /**
91
+ * Catastrophic and with no legitimate interactive use. Confirmation adds no safety
92
+ * against a runaway loop, so these are refused outright. Intentionally tiny — broad
93
+ * denial belongs to the DESTRUCTIVE default (`ask`), not here.
94
+ */
95
+ const HARD_DENY_PATTERNS: RegExp[] = [
96
+ /:\(\)\s*\{\s*:\|\s*:&\s*\}\s*;\s*:/, // classic fork bomb
97
+ /^rm\s+-rf?\s+(\/|~)(\s|$)/i, // rm -rf / | rm -rf ~
98
+ /^rm\s+-rf?\s+\/\*(\s|$)/i, // rm -rf /*
99
+ /^rmdir\s+\/s\s+\/q\s+[a-z]:\\?\s*$/i, // rmdir /s /q C:\
100
+ /^mkfs\b/i,
101
+ /^format\s+[a-z]:/i, // Windows format C:
102
+ ]
103
+
104
+ const DESTRUCTIVE_PATTERNS: RegExp[] = [
105
+ /^rm\s+-rf?\b/i, /^rmdir\s+\/s\b/i, /^del\b.*(\/f|\/s|\/q|\*)/i,
106
+ /\b(drop|truncate)\s+(table|database)\b/i, /\bdelete\s+from\b/i,
107
+ /^format\b/i, /^mkfs\b/i, /^dd\s+if=/i,
108
+ /^(shutdown|reboot|halt)\b/i, /^chmod\s+-R\s+777\s+\//i,
109
+ /(curl|wget)\b.*\|\s*(sh|bash|powershell)\b/i, // pipe remote script into a shell
110
+ ]
111
+
112
+ /**
113
+ * Process-kill tools, and the process names Elliot itself runs under.
114
+ *
115
+ * `eai` is an npm bin shim, so the launcher IS a `node.exe` process and the
116
+ * runtime is `opencode.exe`. That makes a by-name kill self-inflicted: asked to
117
+ * restart a dev stack, the agent reached for
118
+ *
119
+ * Get-Process node | Stop-Process -Force
120
+ *
121
+ * which matched its own host and terminated the session mid-command. Because
122
+ * `Stop-Process -Force` maps to TerminateProcess, no signal handler, `finally`
123
+ * or `exit` hook runs -- so the terminal was also left in mouse-reporting mode,
124
+ * flooding the next shell with escape sequences. It killed every unrelated
125
+ * `node.exe` on the machine too.
126
+ *
127
+ * Denied outright rather than asked, because the confirmation prompt does not
128
+ * convey the real consequence: someone approving "restart the project" is not
129
+ * agreeing to kill the session asking the question.
130
+ */
131
+ // `spps` and bare `kill` are PowerShell aliases for Stop-Process, so the same
132
+ // self-kill works spelled either way; both are word-bounded, so `kill` matches
133
+ // only the standalone verb and not `taskkill`/`pkill`/`killall`.
134
+ const PROCESS_KILL_TOOL = /\b(Stop-Process|spps|taskkill|pkill|killall|kill)\b/i
135
+
136
+ /** Word-bounded so `node_exporter` and `mynode` are not caught. */
137
+ const AGENT_RUNTIME_NAME = /\b(node|bun|opencode|eai)(\.exe)?\b/i
138
+
139
+ /**
140
+ * An explicit numeric PID means the caller already identified one process, which
141
+ * is the safe way to do this and stays allowed.
142
+ */
143
+ const SCOPED_BY_PID = /(-Id\b|\/PID\b)\s*:?\s*\d+/i
144
+
145
+ /**
146
+ * True when a command would kill processes by NAME and that name matches the
147
+ * runtime Elliot is running inside.
148
+ *
149
+ * Killing `uvicorn`, `vite` or any other dev process by name stays allowed --
150
+ * this is not a ban on process management, only on the shape that takes the
151
+ * agent down with it.
152
+ */
153
+ export function killsAgentRuntime(command: string): boolean {
154
+ if (!PROCESS_KILL_TOOL.test(command)) return false
155
+ if (SCOPED_BY_PID.test(command)) return false
156
+ return AGENT_RUNTIME_NAME.test(command)
157
+ }
158
+
159
+ /** Secret-shaped paths and network tools — the two halves of the exfil heuristic. */
160
+ const SECRET_PATH_PATTERN = /(\.env\b|id_rsa|\.aws\b|\.ssh\b|credentials|\.pem\b|\.p12\b)/i
161
+ const NETWORK_TOOL_PATTERN = /\b(curl|wget|nc|ncat|scp|ftp|Invoke-WebRequest|Invoke-RestMethod)\b/i
162
+
163
+ /**
164
+ * True when one command line both names a secret-shaped path AND invokes a network
165
+ * tool. Best-effort by design: it cannot prove intent, and it is the one rule here
166
+ * that no glob pattern could ever approximate.
167
+ */
168
+ export function looksLikeCredentialExfiltration(command: string): boolean {
169
+ return SECRET_PATH_PATTERN.test(command) && NETWORK_TOOL_PATTERN.test(command)
170
+ }
171
+
172
+ /** Classify ONE command with no shell chaining in it. */
173
+ function classifySingle(command: string): Category {
174
+ const cmd = command.trim()
175
+ if (!cmd) return "UNKNOWN"
176
+
177
+ if (/^git\b/i.test(cmd)) return classifyGit(cmd)
178
+
179
+ if (HARD_DENY_PATTERNS.some((re) => re.test(cmd))) return "DESTRUCTIVE"
180
+ if (DESTRUCTIVE_PATTERNS.some((re) => re.test(cmd))) return "DESTRUCTIVE"
181
+ if (looksLikeCredentialExfiltration(cmd)) return "DESTRUCTIVE"
182
+
183
+ if (TEST_PATTERNS.some((re) => re.test(cmd))) return "TEST"
184
+ if (BUILD_PATTERNS.some((re) => re.test(cmd))) return "BUILD"
185
+ if (MUTATION_PATTERNS.some((re) => re.test(cmd))) return "MUTATION"
186
+ if (READ_PATTERNS.some((re) => re.test(cmd))) return "READ"
187
+
188
+ return "UNKNOWN"
189
+ }
190
+
191
+ /** How restrictive each category is — used to pick the worst segment of a chain. */
192
+ const RANK: Record<Category, number> = {
193
+ READ: 0, TEST: 0, BUILD: 0,
194
+ MUTATION: 1, UNKNOWN: 1,
195
+ DESTRUCTIVE: 2,
196
+ }
197
+
198
+ function worstOf(categories: Category[]): Category {
199
+ return categories.reduce((worst, c) => (RANK[c] > RANK[worst] ? c : worst))
200
+ }
201
+
202
+ /**
203
+ * Metacharacters that chain, pipe, substitute, or redirect — each hands control to a
204
+ * SECOND command the anchored (`^`) patterns above would never see. Without splitting,
205
+ * `git status ; rm -rf /` classifies as the READ head alone and the tail rides along.
206
+ */
207
+ const CHAIN_SPLIT_RE = /(?:&&|\|\||>>|;|\||`|\$\(|\n|>|<|&)/
208
+
209
+ function segmentsOf(command: string): string[] {
210
+ return command.trim().split(CHAIN_SPLIT_RE).map((s) => s.trim()).filter(Boolean)
211
+ }
212
+
213
+ export function classifyCommand(command: string): Category {
214
+ const cmd = command.trim()
215
+ if (!cmd) return "UNKNOWN"
216
+
217
+ // Whole-string checks first: a catastrophic or exfil shape is caught wherever it sits
218
+ // relative to a chaining operator.
219
+ if (HARD_DENY_PATTERNS.some((re) => re.test(cmd))) return "DESTRUCTIVE"
220
+ if (DESTRUCTIVE_PATTERNS.some((re) => re.test(cmd))) return "DESTRUCTIVE"
221
+ if (looksLikeCredentialExfiltration(cmd)) return "DESTRUCTIVE"
222
+ // Checked whole-string: the kill tool and the process name routinely sit on
223
+ // opposite sides of a pipe, so per-segment classification would see neither.
224
+ if (killsAgentRuntime(cmd)) return "DESTRUCTIVE"
225
+
226
+ const segments = segmentsOf(cmd)
227
+ if (segments.length <= 1) return classifySingle(cmd)
228
+
229
+ // A compound command is only as safe as its most dangerous segment.
230
+ return worstOf(segments.map(classifySingle))
231
+ }
232
+
233
+ export function isHardDenied(command: string): boolean {
234
+ const cmd = command.trim()
235
+ if (HARD_DENY_PATTERNS.some((re) => re.test(cmd))) return true
236
+ // A hard-deny command can hide as one segment of a chain.
237
+ return segmentsOf(cmd).some((seg) => HARD_DENY_PATTERNS.some((re) => re.test(seg)))
238
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Elliot token read + refresh — the credential half of the proxy provider.
3
+ *
4
+ * Ported (trimmed, not imported) from `elliot-cli/src/auth/refresh.ts` and
5
+ * `elliot-cli/src/config.ts`: same `~/.elliot/config.json` shape, same
6
+ * refresh endpoint contract (`POST {backend_url}/auth/refresh` ->
7
+ * `{access_token, expires_in_seconds}`), same "never throw" discipline. This
8
+ * file exists inside `opencode-plugin` rather than importing the CLI package
9
+ * because the plugin ships and is loaded independently of `elliot-cli` — see
10
+ * `docs/superpowers/plans/2026-09-03-eai-proxy-auth.md`.
11
+ *
12
+ * `freshToken` is the only export the proxy provider's `auth.loader`/`fetch`
13
+ * needs: it never throws, so a stale/missing/corrupt config degrades to "no
14
+ * token" (the proxy 401s) rather than crashing OpenCode's model call.
15
+ */
16
+ import * as fs from "node:fs"
17
+ import * as os from "node:os"
18
+ import * as path from "node:path"
19
+
20
+ export interface ElliotConfig {
21
+ jwt_token?: string
22
+ refresh_token?: string
23
+ backend_url?: string
24
+ access_token_expires_at?: string
25
+ [k: string]: unknown
26
+ }
27
+
28
+ /** `${ELLIOT_HOME}/config.json`, or `~/.elliot/config.json` when unset. */
29
+ export function elliotConfigPath(): string {
30
+ const home = process.env.ELLIOT_HOME ?? path.join(os.homedir(), ".elliot")
31
+ return path.join(home, "config.json")
32
+ }
33
+
34
+ /** Parsed config, or null when absent, unreadable, or not valid JSON. */
35
+ export function readElliotConfig(): ElliotConfig | null {
36
+ try {
37
+ const raw = fs.readFileSync(elliotConfigPath(), "utf-8")
38
+ return JSON.parse(raw) as ElliotConfig
39
+ } catch {
40
+ return null
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Persist the config, owner-only (0600) — this file holds a live bearer
46
+ * token. Creates `~/.elliot` (0700) if it doesn't exist yet.
47
+ */
48
+ export function writeElliotConfig(cfg: ElliotConfig): void {
49
+ const configPath = elliotConfigPath()
50
+ const configDir = path.dirname(configPath)
51
+ if (!fs.existsSync(configDir)) {
52
+ fs.mkdirSync(configDir, { recursive: true, mode: 0o700 })
53
+ }
54
+ fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2), { mode: 0o600 })
55
+ fs.chmodSync(configPath, 0o600)
56
+ }
57
+
58
+ /** `exp` (seconds since epoch) from a JWT's middle segment, or null. */
59
+ function decodeJwtExp(token: string): number | null {
60
+ try {
61
+ const parts = token.split(".")
62
+ if (parts.length < 2) return null
63
+ const payload = JSON.parse(Buffer.from(parts[1], "base64").toString("utf-8"))
64
+ return typeof payload?.exp === "number" ? payload.exp : null
65
+ } catch {
66
+ return null
67
+ }
68
+ }
69
+
70
+ const REFRESH_SKEW_MS = 60_000
71
+
72
+ /**
73
+ * True if the access token is expired or within `REFRESH_SKEW_MS` of
74
+ * expiring. Prefers the stored `access_token_expires_at`; falls back to
75
+ * decoding `jwt_token`'s `exp` claim; true (treat as expired -> refresh) when
76
+ * neither is decodable.
77
+ */
78
+ export function isExpired(cfg: ElliotConfig, nowMs = Date.now()): boolean {
79
+ if (cfg.access_token_expires_at) {
80
+ const expiresAtMs = new Date(cfg.access_token_expires_at).getTime()
81
+ if (Number.isFinite(expiresAtMs)) {
82
+ return expiresAtMs - REFRESH_SKEW_MS <= nowMs
83
+ }
84
+ }
85
+ const exp = cfg.jwt_token ? decodeJwtExp(cfg.jwt_token) : null
86
+ if (exp === null) return true
87
+ return exp * 1000 - REFRESH_SKEW_MS <= nowMs
88
+ }
89
+
90
+ interface RefreshResponse {
91
+ access_token: string
92
+ expires_in_seconds: number
93
+ }
94
+
95
+ interface FreshTokenDeps {
96
+ fetchImpl?: typeof fetch
97
+ write?: (cfg: ElliotConfig) => void
98
+ }
99
+
100
+ /**
101
+ * A valid access token, refreshing + persisting when expired and a
102
+ * `refresh_token`/`backend_url` are present. NEVER throws: any failure
103
+ * (network, non-2xx, malformed response) falls back to the existing
104
+ * `jwt_token` — the proxy 401s on a stale token rather than this crashing the
105
+ * model call.
106
+ */
107
+ export async function freshToken(
108
+ cfg: ElliotConfig,
109
+ deps?: FreshTokenDeps,
110
+ ): Promise<string | null> {
111
+ try {
112
+ if (cfg.jwt_token && !isExpired(cfg)) return cfg.jwt_token
113
+ if (!cfg.refresh_token || !cfg.backend_url) return cfg.jwt_token ?? null
114
+
115
+ const fetchImpl = deps?.fetchImpl ?? fetch
116
+ const write = deps?.write ?? writeElliotConfig
117
+
118
+ const response = await fetchImpl(`${cfg.backend_url}/auth/refresh`, {
119
+ method: "POST",
120
+ headers: { "Content-Type": "application/json" },
121
+ body: JSON.stringify({ refresh_token: cfg.refresh_token }),
122
+ })
123
+ if (!response.ok) return cfg.jwt_token ?? null
124
+
125
+ const data = (await response.json()) as RefreshResponse
126
+ cfg.jwt_token = data.access_token
127
+ cfg.access_token_expires_at = new Date(Date.now() + data.expires_in_seconds * 1000).toISOString()
128
+ write(cfg)
129
+ return data.access_token
130
+ } catch {
131
+ return cfg.jwt_token ?? null
132
+ }
133
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Make the agent call itself Elliot-AI.
3
+ *
4
+ * The visual layer was branded long before this: wordmark, palette, terminal
5
+ * title. But the agent's own sense of identity lives in the system prompt, and
6
+ * on a stock runtime that prompt opens with "You are OpenCode, ...". So asking
7
+ * the CLI "who are you?" got "I'm opencode" — every surface said Elliot except
8
+ * the one the user actually talks to.
9
+ *
10
+ * Fixed here rather than in the prompt files, because those are compiled into
11
+ * the stock upstream binary and patching them would put Elliot back inside
12
+ * OpenCode. `experimental.chat.system.transform` is upstream's own hook for
13
+ * exactly this (`packages/plugin/src/index.ts`), fired from both
14
+ * `agent/agent.ts` and `session/llm/request.ts`, so the fix stays in the
15
+ * overlay and survives an upstream upgrade.
16
+ */
17
+
18
+ /** The product name the assistant should use for itself. */
19
+ export const PRODUCT_NAME = "Elliot-AI"
20
+
21
+ /**
22
+ * Matches ONLY the identity clause that opens a system prompt.
23
+ *
24
+ * Anchored to the start and requiring the following comma, because the prompts
25
+ * also contain `https://opencode.ai/docs` and
26
+ * `https://github.com/anomalyco/opencode`. Those are real upstream links: a
27
+ * blanket rename would silently turn working URLs into 404s, and would also
28
+ * corrupt references to `opencode.json` and `.opencode/` that the tool guidance
29
+ * depends on. Eight identity variants exist across the prompt files (`You are
30
+ * OpenCode,` / `You are opencode,`), and all of them share this shape.
31
+ */
32
+ const IDENTITY = /^You are opencode,/i
33
+
34
+ /**
35
+ * Prepended so the model can answer "what is your name" and "who made you"
36
+ * without inferring it from a rewritten sentence halfway down a long prompt.
37
+ *
38
+ * It states only what is true. The underlying model is deliberately not named:
39
+ * that is a provider detail that changes, and guessing it is how an assistant
40
+ * ends up confidently telling a customer something wrong.
41
+ */
42
+ const IDENTITY_PREAMBLE = [
43
+ `You are ${PRODUCT_NAME}, the coding agent made by Elliot Systems.`,
44
+ `When asked who or what you are, say you are ${PRODUCT_NAME} by Elliot Systems.`,
45
+ `Never refer to yourself as OpenCode.`,
46
+ `OpenCode is the open-source harness you run on, not your identity. If a user`,
47
+ `asks directly what you are built on, you may say you are built on OpenCode.`,
48
+ ].join(" ")
49
+
50
+ /**
51
+ * Rewrite the system prompt in place.
52
+ *
53
+ * Idempotent: re-running leaves the result unchanged, which matters because the
54
+ * hook fires per request and on a long session would otherwise stack the
55
+ * preamble dozens of times and eat the context window.
56
+ */
57
+ export function applyElliotIdentity(system: string[]): string[] {
58
+ if (system.some((entry) => entry.startsWith(IDENTITY_PREAMBLE))) return system
59
+
60
+ for (let i = 0; i < system.length; i++) {
61
+ const entry = system[i]
62
+ if (typeof entry === "string" && IDENTITY.test(entry)) {
63
+ system[i] = entry.replace(IDENTITY, `You are ${PRODUCT_NAME},`)
64
+ }
65
+ }
66
+
67
+ system.unshift(IDENTITY_PREAMBLE)
68
+ return system
69
+ }