loopengine 0.1.62 → 0.1.64

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.
@@ -65,31 +65,32 @@ export interface DeclaredEnvVar {
65
65
  * live (process.env for a shared row, the agent's own .env file for an
66
66
  * agent row), not cached. */
67
67
  export declare function listDeclaredEnvVars(agentName: string): DeclaredEnvVar[];
68
- /** Upserts `NAME=VALUE` into the project's `.env` file and applies it to
69
- * *this* running process immediately via `process.env`, so a
70
- * newly-installed ability's tools work without a restart. Callers (the
71
- * PUT route in adapters/http.ts) are responsible for refusing to call
72
- * this at all when `LOOPENGINE_ADMIN_AUTH` isn't set — this function
73
- * itself has no notion of HTTP auth, it just writes. */
74
- export declare function setEnvVar(name: string, value: string): void;
75
- /** Removes NAME= entirely from the project's .env file (not just
76
- * blanking its value — an empty string is itself a real, distinct
77
- * value some vars would treat differently from "unset," e.g. falling
78
- * through to a default only when truly absent) and clears it from
79
- * *this* running process's own process.env immediately, the same
80
- * "no restart needed" promise setEnvVar already makes. A no-op,
81
- * not an error, when the var was never set in .env to begin with —
82
- * the end state ("not set") is identical either way. */
83
- export declare function unsetEnvVar(name: string): void;
84
- /** Upserts `NAME=VALUE` into `agentName`'s own agents/<name>/.env — read
85
- * by that agent's tools through ToolContext.env (core/agent-env.ts),
86
- * which reads the file fresh on every lookup, so no restart is needed.
68
+ /** Persists a project-wide value through the secret store (the project's
69
+ * `.env` by default — see core/secret-store.ts) and applies it to *this*
70
+ * running process immediately via `process.env`, so a newly-installed
71
+ * ability's tools work without a restart. Callers (the PUT route in
72
+ * adapters/http.ts) are responsible for refusing to call this at all
73
+ * when the server has no auth configured — this function itself has no
74
+ * notion of HTTP auth, it just writes. */
75
+ export declare function setEnvVar(name: string, value: string): Promise<void>;
76
+ /** Removes a project-wide value entirely (not just blanking it — an
77
+ * empty string is itself a real, distinct value some vars would treat
78
+ * differently from "unset," e.g. falling through to a default only when
79
+ * truly absent) and clears it from *this* running process's own
80
+ * process.env immediately, the same "no restart needed" promise
81
+ * setEnvVar already makes. A no-op, not an error, when the var was
82
+ * never set to begin with — the end state ("not set") is identical
83
+ * either way. */
84
+ export declare function unsetEnvVar(name: string): Promise<void>;
85
+ /** Persists `agentName`'s own value (agents/<name>/.env by default) —
86
+ * read by that agent's tools through ToolContext.env (core/agent-env.ts),
87
+ * which reads it fresh on every lookup, so no restart is needed.
87
88
  * Deliberately never copied into process.env: that's shared by every
88
89
  * agent in the process, which is exactly what a per-agent value must
89
90
  * not be. Same auth responsibility for callers as setEnvVar. */
90
- export declare function setAgentEnvVar(agentName: string, name: string, value: string): void;
91
- /** Removes NAME= from `agentName`'s own .env — and the legacy prefixed
92
- * project var for it too (see agentScopedEnvVarName), since createAgentEnv
93
- * still falls back to that one, and "Remove" on this agent's row should
94
- * leave the agent with no value of its own either way. */
95
- export declare function unsetAgentEnvVar(agentName: string, name: string): void;
91
+ export declare function setAgentEnvVar(agentName: string, name: string, value: string): Promise<void>;
92
+ /** Removes `agentName`'s own value — and the legacy prefixed project var
93
+ * for it too (see agentScopedEnvVarName), since createAgentEnv still
94
+ * falls back to that one, and "Remove" on this agent's row should leave
95
+ * the agent with no value of its own either way. */
96
+ export declare function unsetAgentEnvVar(agentName: string, name: string): Promise<void>;
@@ -6,10 +6,11 @@
6
6
  // `secret` is never echoed back once set — same never-echo-a-secret rule
7
7
  // web/http-tool-admin.ts's own `{{ENV_VAR}}` header handling already
8
8
  // establishes for a tool's own secrets.
9
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { existsSync, readFileSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
11
  import { agentDir } from '../core/gateway-tools.js';
12
- import { agentEnvFilePath, agentScopedEnvVarName, envScopeOf, readAgentEnvFile } from '../core/agent-env.js';
12
+ import { agentScopedEnvVarName, envScopeOf } from '../core/agent-env.js';
13
+ import { getSecretStore } from '../core/secret-store.js';
13
14
  export { agentScopedEnvVarName };
14
15
  export class EnvVarNameError extends Error {
15
16
  }
@@ -50,7 +51,7 @@ export function listDeclaredEnvVars(agentName) {
50
51
  if (!existsSync(path))
51
52
  return [];
52
53
  const provenance = JSON.parse(readFileSync(path, 'utf8'));
53
- const agentValues = readAgentEnvFile(agentDir(agentName));
54
+ const agentValues = getSecretStore().readAgentValues(agentName, agentDir(agentName));
54
55
  const rows = new Map();
55
56
  function upsert(slot, abilityName, decl, description) {
56
57
  const scope = envScopeOf(decl);
@@ -103,12 +104,10 @@ export function listDeclaredEnvVars(agentName) {
103
104
  const scope = envScopeOf(decl);
104
105
  if (scope !== 'agent')
105
106
  upsert('shared', abilityName, decl, decl.description);
106
- if (scope === 'overridable') {
107
- upsert('agent', abilityName, decl, `This agent's own value — if unset, falls back to the shared value.${decl.description ? ` ${decl.description}` : ''}`);
108
- }
109
- else if (scope === 'agent') {
110
- upsert('agent', abilityName, decl, `Set per agent — never shared with or borrowed from other agents.${decl.description ? ` ${decl.description}` : ''}`);
111
- }
107
+ // `slot` and `scope` already say whose value a row is and whether
108
+ // it falls back, so the agent row keeps the ability's own words.
109
+ if (scope !== 'shared')
110
+ upsert('agent', abilityName, decl, decl.description);
112
111
  }
113
112
  }
114
113
  // A name that's `agent`-scoped by any declaring ability is never read
@@ -120,115 +119,52 @@ export function listDeclaredEnvVars(agentName) {
120
119
  }
121
120
  return [...rows.values()];
122
121
  }
123
- // process.cwd(), matching exactly where bin/cli.ts's own runTsx passes
124
- // --env-file-if-exists=.env (Node's own --env-file resolves relative to
125
- // cwd too) — not core/agent-registry.ts's projectDir(), which resolves
126
- // relative to *that compiled file's own location* and would point at
127
- // node_modules/loopengine/dist/ in a real scaffolded project, not the
128
- // project's own root where .env actually lives.
129
- function envFilePath() {
130
- return join(process.cwd(), '.env');
131
- }
132
- // Node's own --env-file parser (what bin/cli.ts's runTsx already passes
133
- // as --env-file-if-exists=.env) needs a value containing whitespace, '#',
134
- // or a quote character quoted, so it reads back as one value instead of
135
- // being reinterpreted as a comment or truncated at the first space.
136
- // Single-quoting wins over double — confirmed live against Node's actual
137
- // parser that a single-quoted value reads back fully literally, with no
138
- // backslash-escape processing inside it at all: a real embedded newline,
139
- // a literal double-quote, or a literal backslash (a JSON secret like
140
- // GOOGLE_APPLICATION_CREDENTIALS_JSON has all three at once) all survive
141
- // untouched. Double-quoting a value with an *escaped* embedded quote does
142
- // NOT round-trip — confirmed live that Node's parser has no support for
143
- // \" as an escaped literal quote inside a double-quoted value; it just
144
- // ends the value there instead (`X="a\"b"` reads back as only `a\`,
145
- // silently dropping `b"` entirely). The double-quote fallback below is
146
- // only reached when the value itself contains a literal single quote —
147
- // the one character single-quoting can't represent at all.
148
- function serializeEnvValue(value) {
149
- if (!/[\s#"'\\]/.test(value) && value !== '')
150
- return value;
151
- if (!value.includes("'"))
152
- return `'${value}'`;
153
- return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
154
- }
155
122
  function assertEnvVarName(name) {
156
123
  if (!ENV_VAR_NAME_PATTERN.test(name)) {
157
124
  throw new EnvVarNameError(`"${name}" isn't a valid env var name (uppercase letters, digits, underscore, not starting with a digit).`);
158
125
  }
159
126
  }
160
- /** Upserts `NAME=VALUE` into the .env file at `path`, preserving every
161
- * other line (comments, blank lines, unrelated keys). */
162
- function upsertEnvFileLine(path, name, value) {
163
- const lines = existsSync(path) ? readFileSync(path, 'utf8').split('\n') : [];
164
- const newLine = `${name}=${serializeEnvValue(value)}`;
165
- const existingIndex = lines.findIndex((line) => line.startsWith(`${name}=`));
166
- if (existingIndex === -1) {
167
- // Drop a single trailing blank line (from the file's own final
168
- // newline splitting into an empty last element) before appending,
169
- // so this doesn't accumulate a growing gap of blank lines across
170
- // repeated calls.
171
- if (lines.length > 0 && lines[lines.length - 1] === '')
172
- lines.pop();
173
- lines.push(newLine);
174
- }
175
- else {
176
- lines[existingIndex] = newLine;
177
- }
178
- writeFileSync(path, lines.join('\n') + '\n');
179
- }
180
- /** Removes NAME= from the .env file at `path`, if present. Only
181
- * rewrites the file when something actually changed — a no-op removal
182
- * shouldn't still touch the file's own mtime/trailing-newline shape for
183
- * no reason. */
184
- function removeEnvFileLine(path, name) {
185
- if (!existsSync(path))
186
- return;
187
- const lines = readFileSync(path, 'utf8').split('\n');
188
- const filtered = lines.filter((line) => !line.startsWith(`${name}=`));
189
- if (filtered.length !== lines.length)
190
- writeFileSync(path, filtered.join('\n').replace(/\n*$/, '\n'));
191
- }
192
- /** Upserts `NAME=VALUE` into the project's `.env` file and applies it to
193
- * *this* running process immediately via `process.env`, so a
194
- * newly-installed ability's tools work without a restart. Callers (the
195
- * PUT route in adapters/http.ts) are responsible for refusing to call
196
- * this at all when `LOOPENGINE_ADMIN_AUTH` isn't set — this function
197
- * itself has no notion of HTTP auth, it just writes. */
198
- export function setEnvVar(name, value) {
127
+ /** Persists a project-wide value through the secret store (the project's
128
+ * `.env` by default — see core/secret-store.ts) and applies it to *this*
129
+ * running process immediately via `process.env`, so a newly-installed
130
+ * ability's tools work without a restart. Callers (the PUT route in
131
+ * adapters/http.ts) are responsible for refusing to call this at all
132
+ * when the server has no auth configured — this function itself has no
133
+ * notion of HTTP auth, it just writes. */
134
+ export async function setEnvVar(name, value) {
199
135
  assertEnvVarName(name);
200
- upsertEnvFileLine(envFilePath(), name, value);
136
+ await getSecretStore().setShared(name, value);
201
137
  process.env[name] = value;
202
138
  }
203
- /** Removes NAME= entirely from the project's .env file (not just
204
- * blanking its value — an empty string is itself a real, distinct
205
- * value some vars would treat differently from "unset," e.g. falling
206
- * through to a default only when truly absent) and clears it from
207
- * *this* running process's own process.env immediately, the same
208
- * "no restart needed" promise setEnvVar already makes. A no-op,
209
- * not an error, when the var was never set in .env to begin with —
210
- * the end state ("not set") is identical either way. */
211
- export function unsetEnvVar(name) {
139
+ /** Removes a project-wide value entirely (not just blanking it — an
140
+ * empty string is itself a real, distinct value some vars would treat
141
+ * differently from "unset," e.g. falling through to a default only when
142
+ * truly absent) and clears it from *this* running process's own
143
+ * process.env immediately, the same "no restart needed" promise
144
+ * setEnvVar already makes. A no-op, not an error, when the var was
145
+ * never set to begin with — the end state ("not set") is identical
146
+ * either way. */
147
+ export async function unsetEnvVar(name) {
212
148
  assertEnvVarName(name);
213
- removeEnvFileLine(envFilePath(), name);
149
+ await getSecretStore().unsetShared(name);
214
150
  delete process.env[name];
215
151
  }
216
- /** Upserts `NAME=VALUE` into `agentName`'s own agents/<name>/.env — read
217
- * by that agent's tools through ToolContext.env (core/agent-env.ts),
218
- * which reads the file fresh on every lookup, so no restart is needed.
152
+ /** Persists `agentName`'s own value (agents/<name>/.env by default) —
153
+ * read by that agent's tools through ToolContext.env (core/agent-env.ts),
154
+ * which reads it fresh on every lookup, so no restart is needed.
219
155
  * Deliberately never copied into process.env: that's shared by every
220
156
  * agent in the process, which is exactly what a per-agent value must
221
157
  * not be. Same auth responsibility for callers as setEnvVar. */
222
- export function setAgentEnvVar(agentName, name, value) {
158
+ export async function setAgentEnvVar(agentName, name, value) {
223
159
  assertEnvVarName(name);
224
- upsertEnvFileLine(agentEnvFilePath(agentDir(agentName)), name, value);
160
+ await getSecretStore().setAgent(agentName, agentDir(agentName), name, value);
225
161
  }
226
- /** Removes NAME= from `agentName`'s own .env — and the legacy prefixed
227
- * project var for it too (see agentScopedEnvVarName), since createAgentEnv
228
- * still falls back to that one, and "Remove" on this agent's row should
229
- * leave the agent with no value of its own either way. */
230
- export function unsetAgentEnvVar(agentName, name) {
162
+ /** Removes `agentName`'s own value — and the legacy prefixed project var
163
+ * for it too (see agentScopedEnvVarName), since createAgentEnv still
164
+ * falls back to that one, and "Remove" on this agent's row should leave
165
+ * the agent with no value of its own either way. */
166
+ export async function unsetAgentEnvVar(agentName, name) {
231
167
  assertEnvVarName(name);
232
- removeEnvFileLine(agentEnvFilePath(agentDir(agentName)), name);
233
- unsetEnvVar(agentScopedEnvVarName(agentName, name));
168
+ await getSecretStore().unsetAgent(agentName, agentDir(agentName), name);
169
+ await unsetEnvVar(agentScopedEnvVarName(agentName, name));
234
170
  }
@@ -20,67 +20,19 @@
20
20
  // hazard (template-literal escapes run twice: once when this constant
21
21
  // is built, once when a browser parses the served <script> as JS).
22
22
  import { devUiCss } from './dev-ui-styles.js';
23
+ import { consoleShellScript, consoleSidebarHtml } from './console-shell.js';
23
24
  export const globalConfigPageHtml = `<!doctype html>
24
25
  <html lang="en">
25
26
  <head>
26
27
  <meta charset="utf-8">
27
28
  <meta name="viewport" content="width=device-width, initial-scale=1">
28
- <title>LoopEngine Config</title>
29
+ <title>LoopEngine Settings</title>
29
30
  <style>${devUiCss}
30
- body {
31
- display: flex;
32
- flex-direction: column;
33
- height: 100vh;
34
- }
35
- .layout { flex: 1; display: flex; min-height: 0; }
36
- nav.sidebar {
37
- width: 180px;
38
- flex-shrink: 0;
39
- border-right: 1px solid light-dark(#ddd, #333);
40
- padding: 6px;
41
- }
42
- nav.sidebar button, nav.sidebar a {
43
- display: block;
44
- width: 100%;
45
- text-align: left;
46
- background: none;
47
- border: none;
48
- border-radius: 6px;
49
- padding: 8px 10px;
50
- font-size: 13px;
51
- cursor: pointer;
52
- text-decoration: none;
53
- color: inherit;
54
- box-sizing: border-box;
55
- }
56
- nav.sidebar button:hover, nav.sidebar a:hover { background: light-dark(#eee, #26262b); }
57
- nav.sidebar button.active { background: light-dark(#dbeafe, #1e3a5f); font-weight: 600; }
58
- main {
59
- flex: 1;
60
- overflow-y: auto;
61
- padding: 20px 28px;
62
- }
63
- h2 { font-size: 20px; margin: 0 0 4px; font-family: ui-monospace, monospace; }
64
- section { margin-bottom: 22px; }
65
- section h3 {
66
- font-size: 12px;
67
- text-transform: uppercase;
68
- letter-spacing: 0.04em;
69
- color: light-dark(#666, #999);
70
- margin: 0 0 8px;
71
- border-bottom: 1px solid light-dark(#eee, #2a2a2e);
72
- padding-bottom: 4px;
73
- }
74
- table { width: 100%; border-collapse: collapse; font-size: 12px; }
75
- th, td {
76
- text-align: left;
77
- padding: 6px 8px;
78
- border-bottom: 1px solid light-dark(#eee, #2a2a2e);
79
- vertical-align: top;
80
- }
81
- th { color: light-dark(#666, #999); font-weight: 600; }
31
+ section { margin-bottom: 28px; }
32
+ section h3 { font-size: 15px; font-weight: 600; margin: 0 0 10px; }
33
+ section > table { border: 1px solid var(--line); border-radius: var(--radius); border-collapse: separate; border-spacing: 0; overflow: hidden; }
82
34
  .source {
83
- border: 1px solid light-dark(#ddd, #3a3a3e);
35
+ border: 1px solid var(--line);
84
36
  border-radius: 8px;
85
37
  padding: 12px 14px;
86
38
  margin-bottom: 10px;
@@ -88,10 +40,10 @@ export const globalConfigPageHtml = `<!doctype html>
88
40
  .source.unsupported { opacity: 0.6; }
89
41
  .source-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
90
42
  .source-head h4 { margin: 0; font-size: 14px; font-family: ui-monospace, monospace; }
91
- .hint { font-size: 11px; color: light-dark(#666, #999); }
43
+ .hint { font-size: 11px; color: var(--ink-muted); }
92
44
  pre {
93
- background: light-dark(#fff, #26262b);
94
- border: 1px solid light-dark(#ddd, #3a3a3e);
45
+ background: var(--bg);
46
+ border: 1px solid var(--line);
95
47
  border-radius: 6px;
96
48
  padding: 8px 10px;
97
49
  font-size: 12px;
@@ -105,36 +57,30 @@ export const globalConfigPageHtml = `<!doctype html>
105
57
  this pink-for-destructive convention is meant to drift between them. */
106
58
  .delete-btn {
107
59
  font-size: 12px;
108
- color: light-dark(#991b1b, #f87171);
109
- background: light-dark(#fee2e2, #3a1f1f);
110
- border-color: light-dark(#f3b4b4, #6b3232);
60
+ color: var(--bad);
61
+ background: var(--bad-soft);
62
+ border-color: var(--bad);
111
63
  }
112
- .delete-btn:hover { background: light-dark(#fecaca, #4a1f1f); }
64
+ .delete-btn:hover { background: var(--bad-soft); }
113
65
  </style>
114
66
  </head>
115
- <body>
116
- <nav class="topnav">
117
- <a href="/agents">Agents</a>
118
- <a href="/agents/config" class="active">Config</a>
119
- <a href="/playground">Playground</a>
120
- </nav>
121
- <div class="layout">
122
- <nav class="sidebar">
123
- <a href="/agents/config">Agents</a>
124
- <button class="section-btn active" data-section="models">Models</button>
125
- <button class="section-btn" data-section="gateways">Gateways</button>
126
- </nav>
127
- <main>
128
- <div class="section-panel" data-section-panel="models">
129
- <h2>Models</h2>
130
- <div id="modelsContent"><p class="hint">Loading&hellip;</p></div>
67
+ <body data-le-page="config">
68
+ <div class="le-app">
69
+ ${consoleSidebarHtml}
70
+ <main class="le-main">
71
+ <div class="section-panel" data-section-panel="models" style="display:contents">
72
+ <div class="le-head plain"><div class="le-title"><button type="button" class="le-menu-btn" data-le-menu aria-label="Open navigation">&#9776;</button>
73
+ <div><h1>Models</h1><p>Which model providers have an API key set, and which agents use each one.</p></div></div></div>
74
+ <div class="le-scroll"><div class="le-page" style="display:block"><div id="modelsContent"><p class="hint">Loading&hellip;</p></div></div></div>
131
75
  </div>
132
76
  <div class="section-panel" data-section-panel="gateways" style="display:none">
133
- <h2>Gateways</h2>
134
- <div id="gatewaysContent"><p class="hint">Loading&hellip;</p></div>
77
+ <div class="le-head plain"><div class="le-title"><button type="button" class="le-menu-btn" data-le-menu aria-label="Open navigation">&#9776;</button>
78
+ <div><h1>Integrations</h1><p>Outside tool sources (such as Composio) that agents can add tools from.</p></div></div></div>
79
+ <div class="le-scroll"><div class="le-page" style="display:block"><div id="gatewaysContent"><p class="hint">Loading&hellip;</p></div></div></div>
135
80
  </div>
136
81
  </main>
137
82
  </div>
83
+ ${consoleShellScript}
138
84
  <script>
139
85
  (function () {
140
86
  var main = document.querySelector('main');
@@ -279,7 +225,7 @@ export const globalConfigPageHtml = `<!doctype html>
279
225
  }
280
226
  var panels = main.querySelectorAll('.section-panel');
281
227
  for (var j = 0; j < panels.length; j++) {
282
- panels[j].style.display = panels[j].dataset.sectionPanel === section ? 'block' : 'none';
228
+ panels[j].style.display = panels[j].dataset.sectionPanel === section ? 'contents' : 'none';
283
229
  }
284
230
  if (section === 'models' && !loadedSections.models) loadModels();
285
231
  if (section === 'gateways' && !loadedSections.gateways) loadGateways();