@elliotsystems/eai 0.1.4 → 0.1.7

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/lib/stage.js CHANGED
@@ -24,10 +24,40 @@
24
24
  * upstream's own chrome.
25
25
  */
26
26
 
27
+ const crypto = require("crypto")
27
28
  const fs = require("fs")
28
29
  const os = require("os")
29
30
  const path = require("path")
30
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
+
31
61
  /**
32
62
  * Where the staged overlay lives.
33
63
  *
@@ -43,6 +73,43 @@ function stageRoot(version, env = process.env) {
43
73
  return path.join(base, version)
44
74
  }
45
75
 
76
+ /**
77
+ * A cheap content fingerprint of the overlay source.
78
+ *
79
+ * Staging used to be considered done whenever `.staged` held the current
80
+ * version, on the assumption that a version identifies its contents. That is
81
+ * false twice over: a package can be reinstalled with different contents under
82
+ * the same version (a pre-release build of 0.1.4 overwritten by the published
83
+ * 0.1.4), and a developer editing the plugin in place never changes the version
84
+ * at all. Both leave a stale tree staged, and the runtime then loads an OLD
85
+ * plugin out of a directory named after the NEW version -- with nothing logged,
86
+ * because from the runtime's point of view the plugin loaded fine. That is how
87
+ * a shipped fix (the agent calling itself Elliot rather than opencode) reached
88
+ * an installed machine and still did not take effect.
89
+ *
90
+ * Path + size + mtime, not file contents: this runs on every launch, and
91
+ * reading every file would put the cost back into startup that the marker
92
+ * exists to avoid. Anything npm or a build writes changes at least one mtime.
93
+ */
94
+ function fingerprint(dir) {
95
+ const hash = crypto.createHash("sha256")
96
+ const walk = (current, prefix) => {
97
+ for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
98
+ if (entry.name === "node_modules") continue
99
+ const abs = path.join(current, entry.name)
100
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name
101
+ if (entry.isDirectory()) {
102
+ walk(abs, rel)
103
+ continue
104
+ }
105
+ const stat = fs.statSync(abs)
106
+ hash.update(`${rel}\0${stat.size}\0${Math.trunc(stat.mtimeMs)}\n`)
107
+ }
108
+ }
109
+ walk(dir, "")
110
+ return hash.digest("hex").slice(0, 16)
111
+ }
112
+
46
113
  function copyDir(from, to) {
47
114
  fs.mkdirSync(to, { recursive: true })
48
115
  for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
@@ -66,6 +133,12 @@ function stageOverlay(opts) {
66
133
  const packageRoot = opts.packageRoot
67
134
  const version = opts.version
68
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
69
142
 
70
143
  const pluginSource = path.join(packageRoot, "plugin", "opencode-plugin")
71
144
  const themeSource = path.join(packageRoot, "config", "themes")
@@ -76,15 +149,36 @@ function stageOverlay(opts) {
76
149
  const pluginDir = path.join(root, "plugin", "opencode-plugin")
77
150
 
78
151
  try {
79
- // `.staged` records the version this tree was built from. Re-staging on
80
- // every launch would add hundreds of file copies to startup.
81
- const done = fs.existsSync(marker) && fs.readFileSync(marker, "utf8").trim() === version
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()
160
+ const done = fs.existsSync(marker) && fs.readFileSync(marker, "utf8").trim() === stamp
82
161
  if (!done) {
83
162
  fs.rmSync(root, { recursive: true, force: true })
84
163
  copyDir(pluginSource, pluginDir)
85
164
  if (fs.existsSync(themeSource)) copyDir(themeSource, path.join(root, "themes"))
86
165
 
87
- const plugin = [pluginDir]
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
+ : []
88
182
  fs.writeFileSync(
89
183
  path.join(root, "tui.json"),
90
184
  JSON.stringify(
@@ -103,7 +197,7 @@ function stageOverlay(opts) {
103
197
  path.join(root, "opencode.json"),
104
198
  JSON.stringify({ $schema: "https://opencode.ai/config.json", plugin }, null, 2),
105
199
  )
106
- fs.writeFileSync(marker, version)
200
+ fs.writeFileSync(marker, stamp)
107
201
  }
108
202
  return root
109
203
  } catch {
@@ -113,4 +207,4 @@ function stageOverlay(opts) {
113
207
  }
114
208
  }
115
209
 
116
- module.exports = { stageOverlay, stageRoot }
210
+ module.exports = { stageOverlay, stageRoot, readGatewayAlias }
package/lib/status.js ADDED
@@ -0,0 +1,133 @@
1
+ "use strict"
2
+
3
+ /**
4
+ * `eai status` — is this machine configured, can it reach the backend, and
5
+ * which connectors are live.
6
+ *
7
+ * Migrated from `elliot-ai status` as that CLI is retired. It is the one
8
+ * question `eai` could not answer about itself: the launcher knows where its
9
+ * config is, but a user staring at a failing session had no way to tell a
10
+ * missing token from an unreachable backend from a connector that was never
11
+ * authorised.
12
+ *
13
+ * Three facts, in the order they can fail:
14
+ *
15
+ * 1. config present — nothing else is meaningful without it
16
+ * 2. backend reachable — /health, so a network or DNS fault is named as one
17
+ * 3. connectors — /connectors/<tenant>, the thing people actually ask
18
+ *
19
+ * Each step degrades rather than aborting: an unreachable backend still prints
20
+ * the workspace block, because "which workspace am I in" is answerable offline
21
+ * and is half of what the command is for.
22
+ */
23
+
24
+ const fs = require("fs")
25
+
26
+ const { elliotConfigPath } = require("./setup.js")
27
+
28
+ /** Read ~/.elliot/config.json, or null when absent/unreadable. */
29
+ function readElliotConfig() {
30
+ try {
31
+ const parsed = JSON.parse(fs.readFileSync(elliotConfigPath(), "utf8"))
32
+ return parsed && typeof parsed === "object" ? parsed : null
33
+ } catch {
34
+ return null
35
+ }
36
+ }
37
+
38
+ /**
39
+ * `/health`, with a timeout.
40
+ *
41
+ * Without one this hangs on a black-holed connection for the OS default, and a
42
+ * status command that hangs is worse than one that says "offline" — the user is
43
+ * running it precisely because something is already wrong.
44
+ */
45
+ async function checkBackendHealth(backendUrl, timeoutMs = 5000) {
46
+ const controller = new AbortController()
47
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
48
+ try {
49
+ const res = await fetch(`${String(backendUrl).replace(/\/+$/, "")}/health`, {
50
+ signal: controller.signal,
51
+ })
52
+ return res.ok
53
+ } catch {
54
+ return false
55
+ } finally {
56
+ clearTimeout(timer)
57
+ }
58
+ }
59
+
60
+ /** Connector rows for this tenant, or [] when they cannot be fetched. */
61
+ async function fetchConnectors(config, timeoutMs = 8000) {
62
+ const controller = new AbortController()
63
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
64
+ try {
65
+ const base = String(config.backend_url || "").replace(/\/+$/, "")
66
+ const res = await fetch(`${base}/connectors/${config.tenant_id}`, {
67
+ headers: { Authorization: `Bearer ${config.jwt_token}` },
68
+ signal: controller.signal,
69
+ })
70
+ if (!res.ok) return []
71
+ const body = await res.json()
72
+ if (Array.isArray(body)) return body
73
+ return Array.isArray(body && body.connectors) ? body.connectors : []
74
+ } catch {
75
+ return []
76
+ } finally {
77
+ clearTimeout(timer)
78
+ }
79
+ }
80
+
81
+ /** Title-case a provider id for display, without assuming it is non-empty. */
82
+ function displayName(provider) {
83
+ const name = String(provider || "unknown")
84
+ return name.charAt(0).toUpperCase() + name.slice(1)
85
+ }
86
+
87
+ /**
88
+ * @param {{log?: (s: string) => void, config?: object|null}} [opts]
89
+ * @returns {Promise<number>} the process exit code
90
+ */
91
+ async function runStatus(opts = {}) {
92
+ const log = opts.log || ((s) => process.stdout.write(s + "\n"))
93
+ const config = opts.config !== undefined ? opts.config : readElliotConfig()
94
+
95
+ if (!config) {
96
+ log("Not configured. Copy the setup command from the Elliot portal and run it.")
97
+ return 1
98
+ }
99
+
100
+ log("")
101
+ log("Elliot-AI status")
102
+ log("─────────────────────────────")
103
+ log(`Workspace: ${config.org_name || "(unknown)"}`)
104
+ // The id, not just the name: it is the value the portal URLs and the setup
105
+ // command both carry, so it is what makes the CLI and the web app comparable
106
+ // at a glance.
107
+ log(`Workspace ID: ${config.tenant_id || "(unknown)"}`)
108
+ if (config.stack) log(`Stack: ${config.stack}`)
109
+ log(`Backend: ${config.backend_url || "(unset)"}`)
110
+
111
+ const healthy = config.backend_url ? await checkBackendHealth(config.backend_url) : false
112
+ log(`Reachable: ${healthy ? "yes" : "no"}`)
113
+
114
+ log("─────────────────────────────")
115
+ log("Connectors:")
116
+ const connectors = healthy ? await fetchConnectors(config) : []
117
+ if (!connectors.length) {
118
+ // Deliberately not "no connectors": an unreachable backend and an
119
+ // unconfigured workspace produce the same empty list, and saying which is
120
+ // which is the whole value of the line.
121
+ log(healthy ? " (none reported)" : " (backend unreachable — cannot tell)")
122
+ } else {
123
+ for (const c of connectors) {
124
+ const state = c.status === "connected" ? "connected" : c.status || "unknown"
125
+ log(` ${displayName(c.provider).padEnd(12)} ${state}`)
126
+ }
127
+ }
128
+ log("")
129
+
130
+ return healthy ? 0 : 1
131
+ }
132
+
133
+ module.exports = { runStatus, readElliotConfig, checkBackendHealth, fetchConnectors, displayName }
package/lib/token-env.js CHANGED
@@ -1,14 +1,22 @@
1
1
  "use strict"
2
2
 
3
3
  /**
4
- * Inject the Elliot access token into the environment the OpenCode runtime
5
- * inherits, so its `elliot` provider (configured by `elliot-ai setup` to use
6
- * `apiKey: "{env:ELLIOT_TOKEN}"`) authenticates against the LightLLM proxy.
4
+ * Inject LiteLLM's shared master key into the environment the OpenCode
5
+ * runtime inherits, so its `elliot` provider (configured by `eai setup` to
6
+ * use `apiKey: "{env:ELLIOT_TOKEN}"`) authenticates against LiteLLM directly.
7
7
  *
8
- * ~/.elliot/config.json is the single source of truth for the token; the CLI
9
- * (`elliot-ai`) refreshes it there. We only READ it here and copy the current
10
- * `jwt_token` into ELLIOT_TOKEN — no network, no writes, so this adds no latency
11
- * and cannot corrupt the config.
8
+ * The env var name (ELLIOT_TOKEN) is unchanged, but what it holds is not: it
9
+ * used to be the user's own per-instance JWT (`jwt_token`), revocable and
10
+ * scoped to them; it is now `litellm_master_key`, ONE secret shared by the
11
+ * whole gateway (LiteLLM is deliberately DB-less — no per-tenant credential
12
+ * is possible without one). `eai setup` writes it into
13
+ * ~/.elliot/config.json's `litellm_master_key` field verbatim, since the
14
+ * backend's `/onboarding/config` response includes it directly for exactly
15
+ * this purpose.
16
+ *
17
+ * ~/.elliot/config.json is the single source of truth; `eai setup` refreshes
18
+ * it there. We only READ it here — no network, no writes, so this adds no
19
+ * latency and cannot corrupt the config.
12
20
  *
13
21
  * Entirely best-effort: a missing/unreadable/corrupt config, or an env that
14
22
  * already sets ELLIOT_TOKEN, leaves things exactly as they were. The launcher's
@@ -20,27 +28,55 @@ const os = require("os")
20
28
  const path = require("path")
21
29
 
22
30
  /**
31
+ * TWO CREDENTIALS, TWO HOPS. They are not interchangeable and the difference
32
+ * is the whole reason both exist:
33
+ *
34
+ * ELLIOT_TOKEN the LiteLLM key. Authenticates this machine TO the
35
+ * gateway, and is consumed there — LiteLLM strips it.
36
+ * Used by the model path and as the MCP entry's
37
+ * `Authorization`.
38
+ * ELLIOT_MCP_TOKEN the Elliot MCP credential. LiteLLM forwards it as
39
+ * Elliot's own `Authorization`, and it is what carries
40
+ * tenant, user and the CLI surface. Minted by
41
+ * `/onboarding/config` with the MCP audience.
42
+ *
43
+ * Putting the MCP credential in `Authorization` would authenticate it to
44
+ * LiteLLM as an unknown key; putting the LiteLLM key in `x-mcp-auth` would
45
+ * reach Elliot as a credential it cannot decode. Each is useless in the
46
+ * other's place, which is why they are separate variables rather than one.
47
+ *
23
48
  * @param {NodeJS.ProcessEnv} env the environment to mutate (defaults to process.env)
24
49
  * @returns {boolean} true iff ELLIOT_TOKEN was set from the config file
25
50
  */
26
51
  function injectToken(env) {
27
52
  const target = env || process.env
28
53
 
29
- // An explicit ELLIOT_TOKEN in the environment wins — it lets a caller point
30
- // `eai` at a specific token without touching the config file.
31
- if (target.ELLIOT_TOKEN) return false
32
-
54
+ let config = null
33
55
  try {
34
56
  const configPath = path.join(os.homedir(), ".elliot", "config.json")
35
- const raw = fs.readFileSync(configPath, "utf-8")
36
- const config = JSON.parse(raw)
37
- if (config && typeof config.jwt_token === "string" && config.jwt_token) {
38
- target.ELLIOT_TOKEN = config.jwt_token
39
- return true
40
- }
57
+ config = JSON.parse(fs.readFileSync(configPath, "utf-8"))
41
58
  } catch {
42
59
  // No config yet (setup not run), unreadable, or malformed — the runtime
43
60
  // will simply have no elliot token, which is the pre-existing behaviour.
61
+ return false
62
+ }
63
+
64
+ // Set INDEPENDENTLY of ELLIOT_TOKEN below. An explicit gateway key in the
65
+ // environment is a deliberate override of the gateway hop and says nothing
66
+ // about Elliot identity, so it must not also suppress the MCP credential —
67
+ // doing so would leave MCP silently unauthenticated whenever someone pointed
68
+ // `eai` at a different LiteLLM key.
69
+ if (!target.ELLIOT_MCP_TOKEN && config && typeof config.mcp_token === "string" && config.mcp_token) {
70
+ target.ELLIOT_MCP_TOKEN = config.mcp_token
71
+ }
72
+
73
+ // An explicit ELLIOT_TOKEN in the environment wins — it lets a caller point
74
+ // `eai` at a specific token without touching the config file.
75
+ if (target.ELLIOT_TOKEN) return false
76
+
77
+ if (config && typeof config.litellm_master_key === "string" && config.litellm_master_key) {
78
+ target.ELLIOT_TOKEN = config.litellm_master_key
79
+ return true
44
80
  }
45
81
  return false
46
82
  }
@@ -0,0 +1,27 @@
1
+ "use strict"
2
+
3
+ /**
4
+ * The detached half of the update notification.
5
+ *
6
+ * Spawned by `maybeNotify`, at most once a day, with stdio ignored and the
7
+ * handle unref'd — so it cannot print anything, cannot hold the parent open,
8
+ * and cannot fail in a way the user sees. Its only job is to leave the answer
9
+ * in the cache for the NEXT run to report.
10
+ *
11
+ * It is a separate file rather than an inline `-e` script so that the code is
12
+ * readable, testable and visible in a stack trace, and so a future change does
13
+ * not have to be escaped through a command line.
14
+ */
15
+
16
+ const { latestPublished, readState, writeState, notificationsDisabled } = require("./update.js")
17
+
18
+ if (notificationsDisabled(process.env)) process.exit(0)
19
+
20
+ latestPublished()
21
+ .then((latest) => {
22
+ if (latest) writeState({ ...readState(), latest, checkedAt: Date.now() })
23
+ })
24
+ .catch(() => {
25
+ // Silence is the contract. The parent already stamped checkedAt, so a
26
+ // failure here just means the answer is refreshed tomorrow instead.
27
+ })