loopengine 0.1.61 → 0.1.63

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 CHANGED
@@ -332,3 +332,20 @@ docker compose up --build
332
332
  Builds the HTTP adapter into a container and starts it alongside Redis.
333
333
  For production, push the image to any container platform with `REDIS_URL`
334
334
  and your model/tool API keys set as environment variables.
335
+
336
+ ### Hosting behind a platform
337
+
338
+ These settings are for running LoopEngine inside a hosting platform
339
+ (one workspace per user, behind a router that has already authenticated
340
+ them). All are off by default; a self-hosted install that doesn't set
341
+ them behaves exactly as described above.
342
+
343
+ | Setting | What it does |
344
+ | --- | --- |
345
+ | `LOOPENGINE_TRUSTED_PROXY_SECRET` | Every request must carry an `X-LoopEngine-Identity` header signed with this secret (`signProxyIdentity` creates one; unsigned requests get 401). `LOOPENGINE_ADMIN_AUTH`, if also set, still works for direct access. Also enables `GET /internal/activity`: in-flight turns, open streams and idle time, so the host never stops a server mid-turn. |
346
+ | `LOOPENGINE_EXIT_ON_CODE_CHANGE=1` | After an ability install, upgrade, uninstall or dependency install through the Admin UI, the server drains and exits so the platform restarts it with the new code loaded. Only set this when something restarts the process. |
347
+ | `LOOPENGINE_USAGE_LOG` | `stdout` (one JSON line per model call) or a file path (JSON lines) — tokens per call with agent, tenant, session and model. Or call `setUsageSink()` in code. |
348
+ | `setSecretStore()` | In code, at startup: keep env values set through the Admin UI somewhere other than `.env` files (a vault, a secret manager). The default `FileSecretStore` is today's behaviour. |
349
+
350
+ `deploy/workspace/Dockerfile` is a runtime image for this: it serves a
351
+ scaffolded project mounted at `/workspace` instead of baking agents in.
@@ -1,3 +1,4 @@
1
+ import type { EnvScope } from '../core/agent-env.js';
1
2
  export declare class AbilityManifestError extends Error {
2
3
  }
3
4
  export declare class AbilityVersionError extends Error {
@@ -26,16 +27,21 @@ export interface AbilityEnvDecl {
26
27
  * env-admin.ts's own serializeEnvValue doc comment). Omit for anything
27
28
  * genuinely single-line. */
28
29
  multiline?: boolean;
29
- /** True when this setting is meaningfully different per agent (e.g.
30
- * which Slack channel to post to) rather than one real shared
31
- * credential every installed copy should agree on (e.g. an API key
32
- * for the one external service they all call). The Admin UI's
33
- * Environment tab then also offers a per-agent override — a second,
34
- * separate var named `<AGENT_NAME>_<this name>` (env-admin.ts's own
35
- * agentScopedEnvVarName) — that an ability's own tool code can check
36
- * first, falling back to this bare name when the override isn't set.
37
- * Omit (the default, false) for anything that's actually fine shared
38
- * across every agent that installs this ability. */
30
+ /** How this var is shared across the agents that install this
31
+ * ability (see core/agent-env.ts's EnvScope), read by tools through
32
+ * ToolContext.env:
33
+ * - `shared` (default): one project-wide value — e.g. an API key for
34
+ * the one external service every installed copy calls.
35
+ * - `overridable`: an agent may set its own value (agents/<name>/.env),
36
+ * falling back to the shared one — e.g. which chat to post to.
37
+ * - `agent`: every agent must set its own; never falls back — e.g. a
38
+ * bot token, where silently sharing one would make two agents speak
39
+ * as the same bot.
40
+ * The Admin UI's Environment tab shows a shared row, a per-agent row,
41
+ * or both, to match. */
42
+ scope?: EnvScope;
43
+ /** @deprecated Use `scope: 'overridable'` — still accepted, and means
44
+ * exactly that when `scope` is unset. */
39
45
  perAgent?: boolean;
40
46
  }
41
47
  /** `name`/`version` deliberately aren't declared here — they're read off
@@ -289,8 +289,29 @@ export interface ToolSchema {
289
289
  /** JSON schema sent to the model so it knows how to call the tool. */
290
290
  input_schema: Record<string, unknown>;
291
291
  }
292
+ /** An agent's view of its environment — see core/agent-env.ts's
293
+ * createAgentEnv for the exact lookup order (this agent's own
294
+ * agents/<name>/.env first, then the project-wide value, unless the
295
+ * declaring ability marked the var `scope: 'agent'`). */
296
+ export interface AgentEnv {
297
+ get(name: string): string | undefined;
298
+ /** Same as `get`, but throws a clear "not set for agent X" error
299
+ * instead of returning undefined. */
300
+ require(name: string): string;
301
+ }
302
+ /** Passed as `execute`'s second argument on every call runAgent (and a
303
+ * durable approval's resume) makes — so a tool shared across several
304
+ * agents, typically one an ability installed, can tell which agent it's
305
+ * running for and read that agent's own settings via `env`, instead of
306
+ * working it out from its own file path or a prefixed env var name. */
307
+ export interface ToolContext {
308
+ agentName: string;
309
+ tenant: string;
310
+ sessionId?: string;
311
+ env: AgentEnv;
312
+ }
292
313
  export interface ToolDefinition extends ToolSchema {
293
- execute: (input: Record<string, unknown>) => Promise<unknown>;
314
+ execute: (input: Record<string, unknown>, ctx: ToolContext) => Promise<unknown>;
294
315
  /** Safe to run in ToolLane's parallel lane alongside other safe calls —
295
316
  * true for a read-only tool with no side effects and no shared mutable
296
317
  * state (a lookup, a search), false/omitted for anything that mutates
@@ -0,0 +1,35 @@
1
+ import type { AgentConfig, AgentEnv } from '#core/agent-config.js';
2
+ /** How a declared ability env var (AbilityEnvDecl.scope) is shared
3
+ * across the agents that install the ability:
4
+ * - `shared`: one project-wide value, from the project .env only.
5
+ * - `overridable`: an agent may set its own value; falls back to the
6
+ * shared one when it doesn't.
7
+ * - `agent`: every agent must set its own; never falls back, so an agent
8
+ * that hasn't set it gets a clear error instead of silently borrowing
9
+ * another agent's value (e.g. a bot token, which decides *who* the
10
+ * agent speaks as). */
11
+ export type EnvScope = 'shared' | 'overridable' | 'agent';
12
+ /** `perAgent: true` predates `scope` and means exactly `overridable` —
13
+ * still accepted so abilities published before `scope` existed keep
14
+ * working unchanged. */
15
+ export declare function envScopeOf(decl: {
16
+ scope?: EnvScope;
17
+ perAgent?: boolean;
18
+ }): EnvScope;
19
+ /** The pre-`scope` per-agent override name — `<AGENT_NAME>_<varName>`,
20
+ * `agentName` upper-cased with anything outside `[A-Z0-9_]` turned into
21
+ * `_`, prefixed with one more `_` if that would start with a digit.
22
+ * Still read as a fallback by createAgentEnv (an agent's own .env wins
23
+ * over it) so a project already configured this way keeps working. */
24
+ export declare function agentScopedEnvVarName(agentName: string, varName: string): string;
25
+ export declare function setAgentDir(config: AgentConfig, dir: string): void;
26
+ export declare function agentDirFor(config: AgentConfig): string;
27
+ /** Lookup order for `get(name)`:
28
+ * 1. `name` among this agent's own values (the secret store's
29
+ * readAgentValues — `<dir>/.env` by default)
30
+ * 2. the legacy prefixed project var (agentScopedEnvVarName)
31
+ * 3. `process.env[name]` — skipped when an installed ability declared
32
+ * `name` with `scope: 'agent'`
33
+ * An undeclared name (a hand-written tool's own var) goes through the
34
+ * same order, so it can be overridden per agent too. */
35
+ export declare function createAgentEnv(agentName: string, dir: string): AgentEnv;
@@ -0,0 +1,88 @@
1
+ // Per-agent environment for tools (ToolContext.env — see
2
+ // core/agent-config.ts). One project shares a single .env/process.env,
3
+ // but several agents can install the same ability and each need their
4
+ // own value for some of its settings (a Telegram bot token, a default
5
+ // chat ID). Rather than every ability's tool code deriving a prefixed
6
+ // name (`<AGENT>_<VAR>`) itself, the runtime resolves it here, from the
7
+ // agent's own values (agents/<name>/.env by default — see
8
+ // core/secret-store.ts) layered over the project's own.
9
+ import { existsSync, readFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { agentDir } from './gateway-tools.js';
12
+ import { agentEnvFilePath, getSecretStore } from './secret-store.js';
13
+ /** `perAgent: true` predates `scope` and means exactly `overridable` —
14
+ * still accepted so abilities published before `scope` existed keep
15
+ * working unchanged. */
16
+ export function envScopeOf(decl) {
17
+ return decl.scope ?? (decl.perAgent ? 'overridable' : 'shared');
18
+ }
19
+ /** The pre-`scope` per-agent override name — `<AGENT_NAME>_<varName>`,
20
+ * `agentName` upper-cased with anything outside `[A-Z0-9_]` turned into
21
+ * `_`, prefixed with one more `_` if that would start with a digit.
22
+ * Still read as a fallback by createAgentEnv (an agent's own .env wins
23
+ * over it) so a project already configured this way keeps working. */
24
+ export function agentScopedEnvVarName(agentName, varName) {
25
+ const prefix = agentName.toUpperCase().replace(/[^A-Z0-9_]/g, '_');
26
+ return `${/^[0-9]/.test(prefix) ? '_' : ''}${prefix}_${varName}`;
27
+ }
28
+ /** Every env var name an ability installed in `dir` declared as
29
+ * `scope: 'agent'` — the ones createAgentEnv must never fall back to the
30
+ * project-wide value for. */
31
+ function agentOnlyVarNames(dir) {
32
+ const path = join(dir, '.loopengine-abilities.json');
33
+ const names = new Set();
34
+ if (!existsSync(path))
35
+ return names;
36
+ const provenance = JSON.parse(readFileSync(path, 'utf8'));
37
+ for (const record of Object.values(provenance)) {
38
+ for (const decl of record.env ?? []) {
39
+ if (envScopeOf(decl) === 'agent')
40
+ names.add(decl.name);
41
+ }
42
+ }
43
+ return names;
44
+ }
45
+ // A subagent's config only carries its bare name, not the nested
46
+ // agents/<parent>/subagents/<child>/ folder it was loaded from (see
47
+ // run-agent.ts's resolveSubagentConfig) — that function records the
48
+ // real folder here so its tools read *its* .env, not a same-named
49
+ // top-level agent's. Keyed by object identity: agentAsTool hands the
50
+ // exact same config object on to runAgent.
51
+ const agentDirs = new WeakMap();
52
+ export function setAgentDir(config, dir) {
53
+ agentDirs.set(config, dir);
54
+ }
55
+ export function agentDirFor(config) {
56
+ return agentDirs.get(config) ?? agentDir(config.name);
57
+ }
58
+ /** Lookup order for `get(name)`:
59
+ * 1. `name` among this agent's own values (the secret store's
60
+ * readAgentValues — `<dir>/.env` by default)
61
+ * 2. the legacy prefixed project var (agentScopedEnvVarName)
62
+ * 3. `process.env[name]` — skipped when an installed ability declared
63
+ * `name` with `scope: 'agent'`
64
+ * An undeclared name (a hand-written tool's own var) goes through the
65
+ * same order, so it can be overridden per agent too. */
66
+ export function createAgentEnv(agentName, dir) {
67
+ function get(name) {
68
+ const own = getSecretStore().readAgentValues(agentName, dir)[name];
69
+ if (own !== undefined)
70
+ return own;
71
+ const legacy = process.env[agentScopedEnvVarName(agentName, name)];
72
+ if (legacy !== undefined)
73
+ return legacy;
74
+ if (agentOnlyVarNames(dir).has(name))
75
+ return undefined;
76
+ return process.env[name];
77
+ }
78
+ return {
79
+ get,
80
+ require(name) {
81
+ const value = get(name);
82
+ if (value === undefined) {
83
+ throw new Error(`${name} is not set for agent '${agentName}' — set it in the Admin UI's Environment tab, or in ${agentEnvFilePath(dir)}.`);
84
+ }
85
+ return value;
86
+ },
87
+ };
88
+ }
@@ -35,7 +35,11 @@ export type GatewayToolEntry = ComposioGatewayToolEntry;
35
35
  * module's own registry read/writes do, rather than re-deriving it (e.g.
36
36
  * from process.cwd()) and risking the two drifting apart. */
37
37
  export declare function agentDir(agentName: string): string;
38
- /** agentDir's own inverse, for a tool file that needs to know which
38
+ /** @deprecated A tool can read `ctx.agentName` (ToolContext, passed as
39
+ * execute's second argument) instead — which, unlike this, is also
40
+ * right for a subagent's tools.
41
+ *
42
+ * agentDir's own inverse, for a tool file that needs to know which
39
43
  * agent it was installed under without an env var to tell it — an
40
44
  * ability's own tool file (copied to agents/<name>/tools/<file>.ts by
41
45
  * add-ability) calls this with its own `import.meta.url`, and gets
@@ -35,7 +35,11 @@ function gatewayToolsPath(dir) {
35
35
  export function agentDir(agentName) {
36
36
  return join(agentsRootDir, agentName);
37
37
  }
38
- /** agentDir's own inverse, for a tool file that needs to know which
38
+ /** @deprecated A tool can read `ctx.agentName` (ToolContext, passed as
39
+ * execute's second argument) instead — which, unlike this, is also
40
+ * right for a subagent's tools.
41
+ *
42
+ * agentDir's own inverse, for a tool file that needs to know which
39
43
  * agent it was installed under without an env var to tell it — an
40
44
  * ability's own tool file (copied to agents/<name>/tools/<file>.ts by
41
45
  * add-ability) calls this with its own `import.meta.url`, and gets
@@ -58,6 +58,7 @@ export function createAnthropicModelCall(options = {}) {
58
58
  }
59
59
  return { type: block.type };
60
60
  });
61
- return { stop_reason: response.stop_reason ?? 'end_turn', content };
61
+ const usage = response.usage ? { input_tokens: response.usage.input_tokens, output_tokens: response.usage.output_tokens } : undefined;
62
+ return { stop_reason: response.stop_reason ?? 'end_turn', content, usage };
62
63
  };
63
64
  }
@@ -48,6 +48,7 @@ export function createDeepSeekModelCall(options) {
48
48
  }))
49
49
  : undefined,
50
50
  });
51
- return toModelResponse(response.choices[0]);
51
+ const usage = response.usage ? { input_tokens: response.usage.prompt_tokens, output_tokens: response.usage.completion_tokens } : undefined;
52
+ return { ...toModelResponse(response.choices[0]), usage };
52
53
  };
53
54
  }
@@ -48,6 +48,7 @@ export function createGeminiModelCall(options) {
48
48
  }))
49
49
  : undefined,
50
50
  });
51
- return toModelResponse(response.choices[0]);
51
+ const usage = response.usage ? { input_tokens: response.usage.prompt_tokens, output_tokens: response.usage.completion_tokens } : undefined;
52
+ return { ...toModelResponse(response.choices[0]), usage };
52
53
  };
53
54
  }
@@ -41,6 +41,7 @@ export function createGlmModelCall(options) {
41
41
  }))
42
42
  : undefined,
43
43
  });
44
- return toModelResponse(response.choices[0]);
44
+ const usage = response.usage ? { input_tokens: response.usage.prompt_tokens, output_tokens: response.usage.completion_tokens } : undefined;
45
+ return { ...toModelResponse(response.choices[0]), usage };
45
46
  };
46
47
  }
@@ -54,6 +54,7 @@ export function createKimiModelCall(options) {
54
54
  }))
55
55
  : undefined,
56
56
  });
57
- return toModelResponse(response.choices[0]);
57
+ const usage = response.usage ? { input_tokens: response.usage.prompt_tokens, output_tokens: response.usage.completion_tokens } : undefined;
58
+ return { ...toModelResponse(response.choices[0]), usage };
58
59
  };
59
60
  }
@@ -122,6 +122,7 @@ export function createOpenAIModelCall(options) {
122
122
  : undefined,
123
123
  reasoning_effort: options.reasoningEffort,
124
124
  });
125
- return toModelResponse(response.choices[0]);
125
+ const usage = response.usage ? { input_tokens: response.usage.prompt_tokens, output_tokens: response.usage.completion_tokens } : undefined;
126
+ return { ...toModelResponse(response.choices[0]), usage };
126
127
  };
127
128
  }
@@ -44,6 +44,12 @@ export interface ModelContentBlock {
44
44
  export interface ModelResponse {
45
45
  stop_reason: string;
46
46
  content: ModelContentBlock[];
47
+ /** Tokens this call used, when the provider reports them — recorded
48
+ * per call by core/usage.ts if a usage sink is configured. */
49
+ usage?: {
50
+ input_tokens: number;
51
+ output_tokens: number;
52
+ };
47
53
  }
48
54
  /** loopengine's own conversation-message type — a superset of
49
55
  * budget.ts's own `{role, content: string}`: `content` can also be a
@@ -14,6 +14,8 @@ import { Compactor } from './compaction.js';
14
14
  import { ToolLane } from './toollane.js';
15
15
  import { Recovery } from './recovery.js';
16
16
  import { collectKnownUrls, correctToolUseInput, recordKnownUrlsFromResult } from './known-urls.js';
17
+ import { agentDirFor, createAgentEnv, setAgentDir } from './agent-env.js';
18
+ import { recordUsage } from './usage.js';
17
19
  import { loadAgentModule } from './discover-agents.js';
18
20
  import { agentAsTool } from './agent-as-tool.js';
19
21
  import { loadGatewayToolsFromDir } from './gateway-tools.js';
@@ -223,6 +225,7 @@ async function resolveSubagentConfig(config, dir) {
223
225
  if (resolved.skillsDirs === undefined) {
224
226
  resolved.skillsDirs = [join(dir, 'skills')];
225
227
  }
228
+ setAgentDir(resolved, dir);
226
229
  return resolved;
227
230
  }
228
231
  /** Auto-loads agents/<name>/subagents/<child>/index.{ts,js} — each one a
@@ -443,6 +446,13 @@ async function buildTurnContext(config, modelCall, options) {
443
446
  askUserTool,
444
447
  questionHandler,
445
448
  httpNotifier,
449
+ modelLabel: { provider: config.model?.provider, model: config.model?.model },
450
+ toolContext: {
451
+ agentName: config.name,
452
+ tenant: scope.tenant,
453
+ sessionId: options.sessionId,
454
+ env: createAgentEnv(config.name, agentDirFor(config)),
455
+ },
446
456
  };
447
457
  }
448
458
  /** The turn's actual ReAct loop — shared by runAgent (a fresh turn, its
@@ -454,7 +464,7 @@ async function buildTurnContext(config, modelCall, options) {
454
464
  * fresh in each case) but mutated here via pushMessage, same as the
455
465
  * single function this was split out of always did. */
456
466
  async function runLoop(ctx, messages, newMessages, starterMessage) {
457
- const { modelCall, log, scope, sessionId, skillGarden, toolsByName, systemToolInstances, toolSchemas, systemPrompt, budgetTracker, compactor, gate, toolLane, maxTurns, tailMessages, askUserTool, questionHandler } = ctx;
467
+ const { modelCall, log, scope, sessionId, skillGarden, toolsByName, systemToolInstances, toolSchemas, systemPrompt, budgetTracker, compactor, gate, toolLane, maxTurns, tailMessages, askUserTool, questionHandler, toolContext, modelLabel } = ctx;
458
468
  function pushMessage(message) {
459
469
  messages.push(message);
460
470
  newMessages.push(message);
@@ -525,7 +535,23 @@ async function runLoop(ctx, messages, newMessages, starterMessage) {
525
535
  // and there's no second type yet to justify a second, more generic
526
536
  // event just to aggregate it (see PromptCompactionEvent's own doc
527
537
  // comment for the removed 'recovery:summary').
528
- const { value: response } = await recovery.call((msgs) => modelCall(msgs, systemPrompt, toolSchemas), messages);
538
+ // Recorded inside the retried call, not after it, so a retry that
539
+ // spent tokens and then failed is still counted.
540
+ const { value: response } = await recovery.call(async (msgs) => {
541
+ const result = await modelCall(msgs, systemPrompt, toolSchemas);
542
+ if (result.usage) {
543
+ recordUsage({
544
+ at: new Date().toISOString(),
545
+ agent: toolContext.agentName,
546
+ tenant: toolContext.tenant,
547
+ sessionId: toolContext.sessionId,
548
+ ...modelLabel,
549
+ inputTokens: result.usage.input_tokens,
550
+ outputTokens: result.usage.output_tokens,
551
+ });
552
+ }
553
+ return result;
554
+ }, messages);
529
555
  // The model's full response — text and tool_use blocks alike, with
530
556
  // real ids — becomes this turn's assistant message verbatim. Every
531
557
  // tool_use block emitted here gets a matching tool_result pushed
@@ -636,7 +662,7 @@ async function runLoop(ctx, messages, newMessages, starterMessage) {
636
662
  approvedCalls.push({
637
663
  id: block.id,
638
664
  name: block.name,
639
- execute: () => toolsByName.get(block.name).execute(block.input ?? {}),
665
+ execute: () => toolsByName.get(block.name).execute(block.input ?? {}, toolContext),
640
666
  });
641
667
  continue;
642
668
  }
@@ -657,7 +683,7 @@ async function runLoop(ctx, messages, newMessages, starterMessage) {
657
683
  approvedCalls.push({
658
684
  id: block.id,
659
685
  name: block.name,
660
- execute: () => toolsByName.get(block.name).execute(block.input ?? {}),
686
+ execute: () => toolsByName.get(block.name).execute(block.input ?? {}, toolContext),
661
687
  });
662
688
  }
663
689
  else if (decision.decision === 'pending') {
@@ -0,0 +1,32 @@
1
+ export interface SecretStore {
2
+ /** Every value set for this one agent (its own, not the shared ones).
3
+ * Synchronous because ToolContext.env.get is — a store backed by a
4
+ * remote service should keep an in-memory copy and return that. */
5
+ readAgentValues(agentName: string, dir: string): Record<string, string>;
6
+ /** Persist a project-wide value. The caller (web/env-admin.ts) applies
7
+ * it to process.env afterwards, so a store doesn't need to. */
8
+ setShared(name: string, value: string): void | Promise<void>;
9
+ unsetShared(name: string): void | Promise<void>;
10
+ setAgent(agentName: string, dir: string, name: string, value: string): void | Promise<void>;
11
+ unsetAgent(agentName: string, dir: string, name: string): void | Promise<void>;
12
+ }
13
+ export declare function agentEnvFilePath(dir: string): string;
14
+ /** Parsed with Node's own --env-file parser (util.parseEnv), so a value
15
+ * serializeEnvValue writes reads back exactly the way it does from the
16
+ * project .env. Read fresh on every call, not cached — it's a few lines,
17
+ * read at most a handful of times per tool call, and this way an edit
18
+ * (from the Admin UI or by hand) is seen immediately. */
19
+ export declare function readAgentEnvFile(dir: string): Record<string, string>;
20
+ /** The project's .env for shared values, agents/<name>/.env for each
21
+ * agent's own — the default, and what every self-hosted install uses. */
22
+ export declare class FileSecretStore implements SecretStore {
23
+ readAgentValues(_agentName: string, dir: string): Record<string, string>;
24
+ setShared(name: string, value: string): void;
25
+ unsetShared(name: string): void;
26
+ setAgent(_agentName: string, dir: string, name: string, value: string): void;
27
+ unsetAgent(_agentName: string, dir: string, name: string): void;
28
+ }
29
+ /** Replaces the store for this whole process — call once at startup,
30
+ * before the server handles requests. */
31
+ export declare function setSecretStore(next: SecretStore): void;
32
+ export declare function getSecretStore(): SecretStore;
@@ -0,0 +1,116 @@
1
+ // Where env var values set through the Admin UI (web/env-admin.ts) are
2
+ // persisted, and where an agent's own values (ToolContext.env — see
3
+ // core/agent-env.ts) are read from. The default, FileSecretStore, is the
4
+ // project's .env plus each agent's agents/<name>/.env — exactly what a
5
+ // self-hosted install has always used. A host that keeps secrets
6
+ // somewhere else (a hosted control plane's vault, Vault, a cloud secret
7
+ // manager) swaps it in once at startup with setSecretStore().
8
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { parseEnv } from 'node:util';
11
+ export function agentEnvFilePath(dir) {
12
+ return join(dir, '.env');
13
+ }
14
+ /** Parsed with Node's own --env-file parser (util.parseEnv), so a value
15
+ * serializeEnvValue writes reads back exactly the way it does from the
16
+ * project .env. Read fresh on every call, not cached — it's a few lines,
17
+ * read at most a handful of times per tool call, and this way an edit
18
+ * (from the Admin UI or by hand) is seen immediately. */
19
+ export function readAgentEnvFile(dir) {
20
+ const path = agentEnvFilePath(dir);
21
+ if (!existsSync(path))
22
+ return {};
23
+ return parseEnv(readFileSync(path, 'utf8'));
24
+ }
25
+ // process.cwd(), matching exactly where bin/cli.ts's own runTsx passes
26
+ // --env-file-if-exists=.env (Node's own --env-file resolves relative to
27
+ // cwd too) — not core/agent-registry.ts's projectDir(), which resolves
28
+ // relative to *that compiled file's own location* and would point at
29
+ // node_modules/loopengine/dist/ in a real scaffolded project, not the
30
+ // project's own root where .env actually lives.
31
+ function projectEnvFilePath() {
32
+ return join(process.cwd(), '.env');
33
+ }
34
+ // Node's own --env-file parser (what bin/cli.ts's runTsx already passes
35
+ // as --env-file-if-exists=.env) needs a value containing whitespace, '#',
36
+ // or a quote character quoted, so it reads back as one value instead of
37
+ // being reinterpreted as a comment or truncated at the first space.
38
+ // Single-quoting wins over double — confirmed live against Node's actual
39
+ // parser that a single-quoted value reads back fully literally, with no
40
+ // backslash-escape processing inside it at all: a real embedded newline,
41
+ // a literal double-quote, or a literal backslash (a JSON secret like
42
+ // GOOGLE_APPLICATION_CREDENTIALS_JSON has all three at once) all survive
43
+ // untouched. Double-quoting a value with an *escaped* embedded quote does
44
+ // NOT round-trip — confirmed live that Node's parser has no support for
45
+ // \" as an escaped literal quote inside a double-quoted value; it just
46
+ // ends the value there instead (`X="a\"b"` reads back as only `a\`,
47
+ // silently dropping `b"` entirely). The double-quote fallback below is
48
+ // only reached when the value itself contains a literal single quote —
49
+ // the one character single-quoting can't represent at all.
50
+ function serializeEnvValue(value) {
51
+ if (!/[\s#"'\\]/.test(value) && value !== '')
52
+ return value;
53
+ if (!value.includes("'"))
54
+ return `'${value}'`;
55
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
56
+ }
57
+ /** Upserts `NAME=VALUE` into the .env file at `path`, preserving every
58
+ * other line (comments, blank lines, unrelated keys). */
59
+ function upsertEnvFileLine(path, name, value) {
60
+ const lines = existsSync(path) ? readFileSync(path, 'utf8').split('\n') : [];
61
+ const newLine = `${name}=${serializeEnvValue(value)}`;
62
+ const existingIndex = lines.findIndex((line) => line.startsWith(`${name}=`));
63
+ if (existingIndex === -1) {
64
+ // Drop a single trailing blank line (from the file's own final
65
+ // newline splitting into an empty last element) before appending,
66
+ // so this doesn't accumulate a growing gap of blank lines across
67
+ // repeated calls.
68
+ if (lines.length > 0 && lines[lines.length - 1] === '')
69
+ lines.pop();
70
+ lines.push(newLine);
71
+ }
72
+ else {
73
+ lines[existingIndex] = newLine;
74
+ }
75
+ writeFileSync(path, lines.join('\n') + '\n');
76
+ }
77
+ /** Removes NAME= from the .env file at `path`, if present. Only
78
+ * rewrites the file when something actually changed — a no-op removal
79
+ * shouldn't still touch the file's own mtime/trailing-newline shape for
80
+ * no reason. */
81
+ function removeEnvFileLine(path, name) {
82
+ if (!existsSync(path))
83
+ return;
84
+ const lines = readFileSync(path, 'utf8').split('\n');
85
+ const filtered = lines.filter((line) => !line.startsWith(`${name}=`));
86
+ if (filtered.length !== lines.length)
87
+ writeFileSync(path, filtered.join('\n').replace(/\n*$/, '\n'));
88
+ }
89
+ /** The project's .env for shared values, agents/<name>/.env for each
90
+ * agent's own — the default, and what every self-hosted install uses. */
91
+ export class FileSecretStore {
92
+ readAgentValues(_agentName, dir) {
93
+ return readAgentEnvFile(dir);
94
+ }
95
+ setShared(name, value) {
96
+ upsertEnvFileLine(projectEnvFilePath(), name, value);
97
+ }
98
+ unsetShared(name) {
99
+ removeEnvFileLine(projectEnvFilePath(), name);
100
+ }
101
+ setAgent(_agentName, dir, name, value) {
102
+ upsertEnvFileLine(agentEnvFilePath(dir), name, value);
103
+ }
104
+ unsetAgent(_agentName, dir, name) {
105
+ removeEnvFileLine(agentEnvFilePath(dir), name);
106
+ }
107
+ }
108
+ let store = new FileSecretStore();
109
+ /** Replaces the store for this whole process — call once at startup,
110
+ * before the server handles requests. */
111
+ export function setSecretStore(next) {
112
+ store = next;
113
+ }
114
+ export function getSecretStore() {
115
+ return store;
116
+ }
@@ -0,0 +1,15 @@
1
+ export declare const TRUSTED_PROXY_IDENTITY_HEADER = "x-loopengine-identity";
2
+ export interface ProxyIdentity {
3
+ /** The authenticated user (or `system` for the control plane itself). */
4
+ sub: string;
5
+ workspace?: string;
6
+ role: string;
7
+ /** Expiry, Unix seconds. */
8
+ exp: number;
9
+ }
10
+ /** What the proxy puts in X-LoopEngine-Identity — exported so a router
11
+ * (and tests) produce exactly the format verifyProxyIdentity accepts. */
12
+ export declare function signProxyIdentity(identity: ProxyIdentity, secret: string): string;
13
+ /** The identity, or undefined for a missing, malformed, wrongly-signed
14
+ * or expired header. */
15
+ export declare function verifyProxyIdentity(header: string | string[] | undefined, secret: string, nowMs?: number): ProxyIdentity | undefined;
@@ -0,0 +1,47 @@
1
+ // Auth for a server that only ever receives requests through a trusted
2
+ // proxy (a hosting router that has already authenticated the user) —
3
+ // enabled by LOOPENGINE_TRUSTED_PROXY_SECRET, off otherwise. The proxy
4
+ // sends who the caller is in an X-LoopEngine-Identity header, signed with
5
+ // that shared secret, so this server can trust it without seeing the
6
+ // user's own credentials.
7
+ //
8
+ // Header format: `<payload>.<signature>`, both base64url — payload is the
9
+ // JSON identity, signature is HMAC-SHA256(secret, payload). `exp` (Unix
10
+ // seconds) keeps a captured header from being replayed indefinitely.
11
+ import { createHmac, timingSafeEqual } from 'node:crypto';
12
+ export const TRUSTED_PROXY_IDENTITY_HEADER = 'x-loopengine-identity';
13
+ function sign(payload, secret) {
14
+ return createHmac('sha256', secret).update(payload).digest('base64url');
15
+ }
16
+ /** What the proxy puts in X-LoopEngine-Identity — exported so a router
17
+ * (and tests) produce exactly the format verifyProxyIdentity accepts. */
18
+ export function signProxyIdentity(identity, secret) {
19
+ const payload = Buffer.from(JSON.stringify(identity)).toString('base64url');
20
+ return `${payload}.${sign(payload, secret)}`;
21
+ }
22
+ /** The identity, or undefined for a missing, malformed, wrongly-signed
23
+ * or expired header. */
24
+ export function verifyProxyIdentity(header, secret, nowMs = Date.now()) {
25
+ if (typeof header !== 'string')
26
+ return undefined;
27
+ const dot = header.indexOf('.');
28
+ if (dot <= 0)
29
+ return undefined;
30
+ const payload = header.slice(0, dot);
31
+ const provided = Buffer.from(header.slice(dot + 1));
32
+ const expected = Buffer.from(sign(payload, secret));
33
+ if (provided.length !== expected.length || !timingSafeEqual(provided, expected))
34
+ return undefined;
35
+ let identity;
36
+ try {
37
+ identity = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ if (typeof identity.sub !== 'string' || typeof identity.role !== 'string' || typeof identity.exp !== 'number')
43
+ return undefined;
44
+ if (identity.exp * 1000 <= nowMs)
45
+ return undefined;
46
+ return identity;
47
+ }
@@ -0,0 +1,18 @@
1
+ export interface UsageEvent {
2
+ at: string;
3
+ agent: string;
4
+ tenant: string;
5
+ sessionId?: string;
6
+ /** From AgentConfig.model — absent for an agent with its own
7
+ * createModelCall. */
8
+ provider?: string;
9
+ model?: string;
10
+ inputTokens: number;
11
+ outputTokens: number;
12
+ }
13
+ export type UsageSink = (event: UsageEvent) => void;
14
+ /** Replaces the sink for this whole process; undefined turns usage
15
+ * recording off. */
16
+ export declare function setUsageSink(next: UsageSink | undefined): void;
17
+ /** Never throws — a broken sink must not fail the agent's turn. */
18
+ export declare function recordUsage(event: UsageEvent): void;
@@ -0,0 +1,32 @@
1
+ // Token usage per model call — off unless a sink is configured, so a
2
+ // self-hosted install that doesn't ask for it sees no change at all.
3
+ // Configure with LOOPENGINE_USAGE_LOG (`stdout` for one JSON line per
4
+ // call on stdout, or a file path to append JSON lines to), or in code
5
+ // with setUsageSink() — a host billing for model usage, or anyone
6
+ // wanting a per-agent token count.
7
+ import { appendFileSync } from 'node:fs';
8
+ function sinkFromEnv() {
9
+ const target = process.env.LOOPENGINE_USAGE_LOG;
10
+ if (!target)
11
+ return undefined;
12
+ if (target === 'stdout')
13
+ return (event) => console.log(JSON.stringify({ type: 'loopengine:usage', ...event }));
14
+ return (event) => appendFileSync(target, JSON.stringify(event) + '\n');
15
+ }
16
+ let sink = sinkFromEnv();
17
+ /** Replaces the sink for this whole process; undefined turns usage
18
+ * recording off. */
19
+ export function setUsageSink(next) {
20
+ sink = next;
21
+ }
22
+ /** Never throws — a broken sink must not fail the agent's turn. */
23
+ export function recordUsage(event) {
24
+ if (!sink)
25
+ return;
26
+ try {
27
+ sink(event);
28
+ }
29
+ catch (err) {
30
+ console.error('[loopengine] usage sink failed:', err);
31
+ }
32
+ }
package/dist/index.d.ts CHANGED
@@ -2,7 +2,11 @@ export { runAgent } from '#core/run-agent.js';
2
2
  export { storageSigners } from '#core/storage-signers/index.js';
3
3
  export type { SignRequest, StorageSigner } from '#core/storage-signers/types.js';
4
4
  export type { Message, ModelCall, ModelContentBlock, ModelResponse, RunAgentOptions, RunAgentResult, } from '#core/run-agent.js';
5
- export type { AgentConfig, AgentModelConfig, ToolSchema, ToolDefinition } from '#core/agent-config.js';
5
+ export type { AgentConfig, AgentModelConfig, ToolSchema, ToolDefinition, ToolContext, AgentEnv } from '#core/agent-config.js';
6
+ export { createAgentEnv, agentDirFor, envScopeOf, type EnvScope } from '#core/agent-env.js';
7
+ export { FileSecretStore, setSecretStore, getSecretStore, type SecretStore } from '#core/secret-store.js';
8
+ export { signProxyIdentity, verifyProxyIdentity, TRUSTED_PROXY_IDENTITY_HEADER, type ProxyIdentity } from '#core/trusted-proxy.js';
9
+ export { setUsageSink, recordUsage, type UsageEvent, type UsageSink } from '#core/usage.js';
6
10
  export type * from './core/loop-events.js';
7
11
  export { streamMessage, streamMessageWithCallbacks, sendMessage, approveCall, denyCall, answerQuestion, getSessionHistory, } from './core/client.js';
8
12
  export type { RequestOptions, SendMessageResult, PendingApprovalResult, PendingQuestionResult, PendingResult, MessageResult, StreamMessageCallbacks, } from './core/client.js';
@@ -55,7 +59,7 @@ export { globalConfigPageHtml } from './web/global-config-page.js';
55
59
  export { playgroundHtml } from './web/playground.js';
56
60
  export { readSkill, writeSkill, deleteSkill, SkillInvalidIdError, SkillNotFoundError } from './web/skills-admin.js';
57
61
  export type { SkillContent } from './web/skills-admin.js';
58
- export { listDeclaredEnvVars, setEnvVar, unsetEnvVar, agentScopedEnvVarName, EnvVarNameError } from './web/env-admin.js';
62
+ export { listDeclaredEnvVars, setEnvVar, unsetEnvVar, setAgentEnvVar, unsetAgentEnvVar, agentScopedEnvVarName, EnvVarNameError } from './web/env-admin.js';
59
63
  export type { DeclaredEnvVar } from './web/env-admin.js';
60
64
  export { readActauthConfig, addActauthRule, updateActauthRule, removeActauthRule, setDefaultDecision, ActauthRuleExistsError, ActauthRuleNotFoundError, } from './web/actauth-admin.js';
61
65
  export type { ActauthRuleInput, ActauthConfigView } from './web/actauth-admin.js';
package/dist/index.js CHANGED
@@ -5,6 +5,10 @@
5
5
  // "Using loopengine as a library" for the boundary and why.
6
6
  export { runAgent } from '#core/run-agent.js';
7
7
  export { storageSigners } from '#core/storage-signers/index.js';
8
+ export { createAgentEnv, agentDirFor, envScopeOf } from '#core/agent-env.js';
9
+ export { FileSecretStore, setSecretStore, getSecretStore } from '#core/secret-store.js';
10
+ export { signProxyIdentity, verifyProxyIdentity, TRUSTED_PROXY_IDENTITY_HEADER } from '#core/trusted-proxy.js';
11
+ export { setUsageSink, recordUsage } from '#core/usage.js';
8
12
  // The framework-agnostic browser client for adapters/http.ts's own wire
9
13
  // protocol (see client.ts's own header comment) — this, not a deep
10
14
  // subpath import, is how a consumer's React/Vue/etc. chat UI is meant to
@@ -50,7 +54,7 @@ export { agentsListPageHtml } from './web/agents-list-page.js';
50
54
  export { globalConfigPageHtml } from './web/global-config-page.js';
51
55
  export { playgroundHtml } from './web/playground.js';
52
56
  export { readSkill, writeSkill, deleteSkill, SkillInvalidIdError, SkillNotFoundError } from './web/skills-admin.js';
53
- export { listDeclaredEnvVars, setEnvVar, unsetEnvVar, agentScopedEnvVarName, EnvVarNameError } from './web/env-admin.js';
57
+ export { listDeclaredEnvVars, setEnvVar, unsetEnvVar, setAgentEnvVar, unsetAgentEnvVar, agentScopedEnvVarName, EnvVarNameError } from './web/env-admin.js';
54
58
  export { readActauthConfig, addActauthRule, updateActauthRule, removeActauthRule, setDefaultDecision, ActauthRuleExistsError, ActauthRuleNotFoundError, } from './web/actauth-admin.js';
55
59
  export { installAbility, upgradeAbility, removeAbility, listInstalledAbilities, backfillDependencies, AbilityManifestError, AbilityVersionError, AbilityCollisionError, AbilityAlreadyInstalledError, AbilityNotInstalledError, } from './bin/ability-manager.js';
56
60
  export { searchPublicAbilities, fetchLatestAbilityVersion } from './web/abilities-admin.js';
@@ -1177,11 +1177,23 @@ export const agentsConfigPageHtml = `<!doctype html>
1177
1177
  // of a bare "set", since the whole point of looking here is usually
1178
1178
  // to check *what* something is currently configured to, not just
1179
1179
  // whether it has a value.
1180
+ // An agent row (v.slot === 'agent' — this agent's own value, see
1181
+ // env-admin.ts's DeclaredEnvVar.slot) for an overridable var isn't a
1182
+ // problem when unset: it just falls back to the shared row. One for
1183
+ // an agent-scoped var has no fallback, so unset really is missing.
1180
1184
  var statusHtml = !v.set
1181
- ? '<span class="error">not set</span>'
1185
+ ? (v.slot === 'agent' && v.scope === 'overridable'
1186
+ ? '<span class="hint">not set &mdash; uses shared value</span>'
1187
+ : '<span class="error">not set</span>')
1182
1188
  : v.secret
1183
1189
  ? '<span class="hint">set</span>'
1184
1190
  : '<code>' + escapeHtml(v.value || '') + '</code>';
1191
+ if (v.set && v.source === 'legacy') {
1192
+ statusHtml += ' <span class="hint" title="Read from the older prefixed project variable. Saving a value here moves it into this agent&#39;s own .env.">(from ' + escapeHtml(v.legacyName || '') + ')</span>';
1193
+ }
1194
+ var slotNote = v.slot === 'agent'
1195
+ ? ' <span class="hint">' + (v.scope === 'agent' ? 'this agent only' : 'this agent&#39;s override') + '</span>'
1196
+ : (v.scope === 'overridable' ? ' <span class="hint">shared</span>' : '');
1185
1197
  // A var with a closed set of valid values (AbilityEnvDecl.options,
1186
1198
  // e.g. a provider switch) gets a <select> instead of a free-text
1187
1199
  // field, so there's nothing to mistype — the same "value" form field
@@ -1208,13 +1220,13 @@ export const agentsConfigPageHtml = `<!doctype html>
1208
1220
  // them, on a freshly installed ability — see renderEnvConfigHtml's
1209
1221
  // own comment on why "not set" isn't itself treated as a problem).
1210
1222
  var removeHtml = v.set
1211
- ? ' <button type="button" class="env-var-remove" data-name="' + escapeHtml(v.name) + '">Remove</button>'
1223
+ ? ' <button type="button" class="env-var-remove" data-name="' + escapeHtml(v.name) + '" data-slot="' + escapeHtml(v.slot) + '">Remove</button>'
1212
1224
  : '';
1213
1225
  return '<tr>' +
1214
- '<td><code>' + escapeHtml(v.name) + '</code>' + sharedNote + '</td>' +
1226
+ '<td><code>' + escapeHtml(v.name) + '</code>' + slotNote + sharedNote + '</td>' +
1215
1227
  '<td style="max-width:320px">' + escapeHtml(v.description || '') + '</td>' +
1216
1228
  '<td>' + statusHtml + '</td>' +
1217
- '<td><form class="add-source env-var-form" data-name="' + escapeHtml(v.name) + '">' +
1229
+ '<td><form class="add-source env-var-form" data-name="' + escapeHtml(v.name) + '" data-slot="' + escapeHtml(v.slot) + '">' +
1218
1230
  fieldHtml +
1219
1231
  '<button type="submit">Save</button>' +
1220
1232
  '</form>' + removeHtml + '</td>' +
@@ -1280,10 +1292,11 @@ export const agentsConfigPageHtml = `<!doctype html>
1280
1292
  ev.preventDefault();
1281
1293
  var form = ev.currentTarget;
1282
1294
  var varName = form.getAttribute('data-name');
1295
+ var slot = form.getAttribute('data-slot');
1283
1296
  var value = new FormData(form).get('value');
1284
1297
  var submitBtn = form.querySelector('button[type="submit"]');
1285
1298
  submitBtn.disabled = true;
1286
- fetch('/agents/' + encodeURIComponent(name) + '/env/' + encodeURIComponent(varName), {
1299
+ fetch('/agents/' + encodeURIComponent(name) + '/env/' + encodeURIComponent(varName) + '?slot=' + encodeURIComponent(slot), {
1287
1300
  method: 'PUT',
1288
1301
  headers: { 'content-type': 'application/json' },
1289
1302
  body: JSON.stringify({ value: value }),
@@ -1304,13 +1317,15 @@ export const agentsConfigPageHtml = `<!doctype html>
1304
1317
  removeButtons[j].addEventListener('click', function (ev) {
1305
1318
  var btn = ev.currentTarget;
1306
1319
  var varName = btn.getAttribute('data-name');
1320
+ var slot = btn.getAttribute('data-slot');
1307
1321
  // A destructive action (clearing a configured value, possibly a
1308
1322
  // secret nothing else has a copy of) gets the one confirm() this
1309
1323
  // whole tab uses — every other action here is a plain upsert,
1310
1324
  // nothing else to lose by clicking it again.
1311
- if (!confirm('Remove ' + varName + '? Any tool/ability relying on it falls back to its own default (if it has one) or starts failing until it is set again.')) return;
1325
+ var what = slot === 'agent' ? 'the agent-specific value of ' + varName : varName;
1326
+ if (!confirm('Remove ' + what + '? Any tool/ability relying on it falls back to its own default (if it has one) or starts failing until it is set again.')) return;
1312
1327
  btn.disabled = true;
1313
- fetch('/agents/' + encodeURIComponent(name) + '/env/' + encodeURIComponent(varName), { method: 'DELETE' })
1328
+ fetch('/agents/' + encodeURIComponent(name) + '/env/' + encodeURIComponent(varName) + '?slot=' + encodeURIComponent(slot), { method: 'DELETE' })
1314
1329
  .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
1315
1330
  .then(function (result) {
1316
1331
  if (!result.ok) throw new Error(result.body.error || 'request failed');
@@ -1,8 +1,23 @@
1
+ import { agentScopedEnvVarName, type EnvScope } from '../core/agent-env.js';
2
+ export { agentScopedEnvVarName };
1
3
  export declare class EnvVarNameError extends Error {
2
4
  }
3
5
  export interface DeclaredEnvVar {
4
6
  name: string;
5
7
  description?: string;
8
+ /** Which value this row reads and writes: `shared` = the project
9
+ * .env, `agent` = this agent's own agents/<name>/.env. A var declared
10
+ * `overridable` gets one row of each; `agent` only the agent row. */
11
+ slot: 'shared' | 'agent';
12
+ /** The scope the declaring ability gave this var (see
13
+ * core/agent-env.ts's EnvScope) — the strictest one when more than
14
+ * one ability declares it. */
15
+ scope: EnvScope;
16
+ /** Agent rows only, when set: `agent` = from agents/<name>/.env,
17
+ * `legacy` = from the older prefixed project var named `legacyName`
18
+ * (see agentScopedEnvVarName), still honored until replaced. */
19
+ source?: 'agent' | 'legacy';
20
+ legacyName?: string;
6
21
  secret: boolean;
7
22
  /** Every ability that declares this name, in install order — usually
8
23
  * one, but see listDeclaredEnvVars's own doc comment for why this is
@@ -25,8 +40,10 @@ export interface DeclaredEnvVar {
25
40
  multiline?: boolean;
26
41
  }
27
42
  /** Every env var any ability installed for `agentName` declared it
28
- * needs, merged by name. There's one .env per *project*, not per agent
29
- * or per ability, so this can't actually resolve a genuine cross-ability
43
+ * needs, merged by name — one row per slot it can be set in (see
44
+ * DeclaredEnvVar.slot): a `shared` row for the project .env, an `agent`
45
+ * row for this agent's own .env when the var's scope allows one. There's
46
+ * one shared .env per *project*, not per ability, so this can't actually resolve a genuine cross-ability
30
47
  * name collision (two abilities declaring, say, "API_KEY" for two
31
48
  * unrelated services still both read whichever single value ends up
32
49
  * set — there is no per-ability slot to give them) — unlike a tool or
@@ -45,37 +62,35 @@ export interface DeclaredEnvVar {
45
62
  * safer default, since treating a real secret as non-secret because a
46
63
  * different ability's own declaration happened to be checked first
47
64
  * would be the one direction genuinely worth avoiding. `set` is read
48
- * live off `process.env`, not cached, so it reflects whatever the last
49
- * `setEnvVar` call — or a plain restart picking up `.env` — actually
50
- * did. */
51
- /** `varName`'s own per-agent override name (see AbilityEnvDecl.perAgent)
52
- * — `<AGENT_NAME>_<varName>`, with `agentName` upper-cased and anything
53
- * that isn't `[A-Z0-9_]` (a hyphen, most likely — agent directory names
54
- * are kebab-case) turned into `_`, same alphabet ENV_VAR_NAME_PATTERN
55
- * already requires. Guards against a result starting with a digit (a
56
- * from-digits agent name, or an agent name that's entirely punctuation)
57
- * the same way that pattern would otherwise reject — by prefixing one
58
- * more `_` — though in practice agent names are never actually bare
59
- * digits. */
60
- export declare function agentScopedEnvVarName(agentName: string, varName: string): string;
65
+ * live (process.env for a shared row, the agent's own .env file for an
66
+ * agent row), not cached. */
61
67
  export declare function listDeclaredEnvVars(agentName: string): DeclaredEnvVar[];
62
- /** Upserts `NAME=VALUE` into the project's `.env` file — preserving
63
- * every other line (comments, blank lines, unrelated keys) — and
64
- * applies it to *this* running process immediately via `process.env`,
65
- * so a newly-installed ability's tools work without a restart. Callers
66
- * (the PUT route in adapters/http.ts) are responsible for refusing to
67
- * call this at all when `LOOPENGINE_ADMIN_AUTH` isn't set — this
68
- * function itself has no notion of HTTP auth, it just writes. */
69
- export declare function setEnvVar(name: string, value: string): void;
70
- /** Removes NAME= entirely from the project's .env file (not just
71
- * blanking its value — an empty string is itself a real, distinct
72
- * value some vars would treat differently from "unset," e.g. falling
73
- * through to a default only when truly absent) and clears it from
74
- * *this* running process's own process.env immediately, the same
75
- * "no restart needed" promise setEnvVar already makes. A no-op,
76
- * not an error, when the var was never set in .env to begin with —
77
- * same reasoning the DELETE route calling this treats a missing name
78
- * as success rather than a 404: the end state ("not set") is identical
79
- * either way, and the caller has no way to distinguish "I removed it"
80
- * from "it was already gone" that would actually matter to them. */
81
- export declare function unsetEnvVar(name: string): void;
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.
88
+ * Deliberately never copied into process.env: that's shared by every
89
+ * agent in the process, which is exactly what a per-agent value must
90
+ * not be. Same auth responsibility for callers as setEnvVar. */
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,9 +6,12 @@
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 { agentScopedEnvVarName, envScopeOf } from '../core/agent-env.js';
13
+ import { getSecretStore } from '../core/secret-store.js';
14
+ export { agentScopedEnvVarName };
12
15
  export class EnvVarNameError extends Error {
13
16
  }
14
17
  // Same shape web/http-tool-admin.ts's own ENV_VAR_PATTERN already
@@ -17,9 +20,12 @@ const ENV_VAR_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
17
20
  function provenancePath(agentName) {
18
21
  return join(agentDir(agentName), '.loopengine-abilities.json');
19
22
  }
23
+ const SCOPE_STRICTNESS = { shared: 0, overridable: 1, agent: 2 };
20
24
  /** Every env var any ability installed for `agentName` declared it
21
- * needs, merged by name. There's one .env per *project*, not per agent
22
- * or per ability, so this can't actually resolve a genuine cross-ability
25
+ * needs, merged by name — one row per slot it can be set in (see
26
+ * DeclaredEnvVar.slot): a `shared` row for the project .env, an `agent`
27
+ * row for this agent's own .env when the var's scope allows one. There's
28
+ * one shared .env per *project*, not per ability, so this can't actually resolve a genuine cross-ability
23
29
  * name collision (two abilities declaring, say, "API_KEY" for two
24
30
  * unrelated services still both read whichever single value ends up
25
31
  * set — there is no per-ability slot to give them) — unlike a tool or
@@ -38,41 +44,53 @@ function provenancePath(agentName) {
38
44
  * safer default, since treating a real secret as non-secret because a
39
45
  * different ability's own declaration happened to be checked first
40
46
  * would be the one direction genuinely worth avoiding. `set` is read
41
- * live off `process.env`, not cached, so it reflects whatever the last
42
- * `setEnvVar` call — or a plain restart picking up `.env` — actually
43
- * did. */
44
- /** `varName`'s own per-agent override name (see AbilityEnvDecl.perAgent)
45
- * — `<AGENT_NAME>_<varName>`, with `agentName` upper-cased and anything
46
- * that isn't `[A-Z0-9_]` (a hyphen, most likely — agent directory names
47
- * are kebab-case) turned into `_`, same alphabet ENV_VAR_NAME_PATTERN
48
- * already requires. Guards against a result starting with a digit (a
49
- * from-digits agent name, or an agent name that's entirely punctuation)
50
- * the same way that pattern would otherwise reject — by prefixing one
51
- * more `_` — though in practice agent names are never actually bare
52
- * digits. */
53
- export function agentScopedEnvVarName(agentName, varName) {
54
- const prefix = agentName.toUpperCase().replace(/[^A-Z0-9_]/g, '_');
55
- return `${/^[0-9]/.test(prefix) ? '_' : ''}${prefix}_${varName}`;
56
- }
47
+ * live (process.env for a shared row, the agent's own .env file for an
48
+ * agent row), not cached. */
57
49
  export function listDeclaredEnvVars(agentName) {
58
50
  const path = provenancePath(agentName);
59
51
  if (!existsSync(path))
60
52
  return [];
61
53
  const provenance = JSON.parse(readFileSync(path, 'utf8'));
62
- const byName = new Map();
63
- function upsert(name, abilityName, decl, description) {
64
- const existing = byName.get(name);
54
+ const agentValues = getSecretStore().readAgentValues(agentName, agentDir(agentName));
55
+ const rows = new Map();
56
+ function upsert(slot, abilityName, decl, description) {
57
+ const scope = envScopeOf(decl);
58
+ const key = `${slot}:${decl.name}`;
59
+ const existing = rows.get(key);
65
60
  if (existing) {
66
61
  existing.abilityNames.push(abilityName);
67
62
  existing.secret = existing.secret || decl.secret === true;
68
63
  if (existing.secret)
69
64
  existing.value = undefined;
65
+ if (SCOPE_STRICTNESS[scope] > SCOPE_STRICTNESS[existing.scope])
66
+ existing.scope = scope;
70
67
  return;
71
68
  }
72
- const rawValue = process.env[name];
73
- byName.set(name, {
74
- name,
69
+ let rawValue;
70
+ let source;
71
+ let legacyName;
72
+ if (slot === 'shared') {
73
+ rawValue = process.env[decl.name];
74
+ }
75
+ else if (agentValues[decl.name] !== undefined) {
76
+ rawValue = agentValues[decl.name];
77
+ source = 'agent';
78
+ }
79
+ else {
80
+ const legacy = agentScopedEnvVarName(agentName, decl.name);
81
+ rawValue = process.env[legacy];
82
+ if (rawValue !== undefined) {
83
+ source = 'legacy';
84
+ legacyName = legacy;
85
+ }
86
+ }
87
+ rows.set(key, {
88
+ name: decl.name,
75
89
  description,
90
+ slot,
91
+ scope,
92
+ source,
93
+ legacyName,
76
94
  secret: decl.secret === true,
77
95
  abilityNames: [abilityName],
78
96
  set: rawValue !== undefined,
@@ -83,110 +101,72 @@ export function listDeclaredEnvVars(agentName) {
83
101
  }
84
102
  for (const [abilityName, record] of Object.entries(provenance)) {
85
103
  for (const decl of record.env) {
86
- upsert(decl.name, abilityName, decl, decl.description);
87
- // A perAgent var also gets its own separate, independently
88
- // set/unset row for this one agent's override — see
89
- // AbilityEnvDecl.perAgent's own doc comment for why this is a
90
- // second row, not a replacement for the shared one above (an
91
- // ability's own tool code checks the override first and falls
92
- // back to the shared value, so both need to stay independently
93
- // visible and settable here).
94
- if (decl.perAgent) {
95
- const scopedName = agentScopedEnvVarName(agentName, decl.name);
96
- const overrideDescription = `Overrides ${decl.name} for this agent only — if unset, falls back to the shared value above.${decl.description ? ` ${decl.description}` : ''}`;
97
- upsert(scopedName, abilityName, decl, overrideDescription);
104
+ const scope = envScopeOf(decl);
105
+ if (scope !== 'agent')
106
+ upsert('shared', abilityName, decl, decl.description);
107
+ if (scope === 'overridable') {
108
+ upsert('agent', abilityName, decl, `This agent's own value — if unset, falls back to the shared value.${decl.description ? ` ${decl.description}` : ''}`);
109
+ }
110
+ else if (scope === 'agent') {
111
+ upsert('agent', abilityName, decl, `Set per agent — never shared with or borrowed from other agents.${decl.description ? ` ${decl.description}` : ''}`);
98
112
  }
99
113
  }
100
114
  }
101
- return [...byName.values()];
102
- }
103
- // process.cwd(), matching exactly where bin/cli.ts's own runTsx passes
104
- // --env-file-if-exists=.env (Node's own --env-file resolves relative to
105
- // cwd too) — not core/agent-registry.ts's projectDir(), which resolves
106
- // relative to *that compiled file's own location* and would point at
107
- // node_modules/loopengine/dist/ in a real scaffolded project, not the
108
- // project's own root where .env actually lives.
109
- function envFilePath() {
110
- return join(process.cwd(), '.env');
111
- }
112
- // Node's own --env-file parser (what bin/cli.ts's runTsx already passes
113
- // as --env-file-if-exists=.env) needs a value containing whitespace, '#',
114
- // or a quote character quoted, so it reads back as one value instead of
115
- // being reinterpreted as a comment or truncated at the first space.
116
- // Single-quoting wins over double — confirmed live against Node's actual
117
- // parser that a single-quoted value reads back fully literally, with no
118
- // backslash-escape processing inside it at all: a real embedded newline,
119
- // a literal double-quote, or a literal backslash (a JSON secret like
120
- // GOOGLE_APPLICATION_CREDENTIALS_JSON has all three at once) all survive
121
- // untouched. Double-quoting a value with an *escaped* embedded quote does
122
- // NOT round-trip — confirmed live that Node's parser has no support for
123
- // \" as an escaped literal quote inside a double-quoted value; it just
124
- // ends the value there instead (`X="a\"b"` reads back as only `a\`,
125
- // silently dropping `b"` entirely). The double-quote fallback below is
126
- // only reached when the value itself contains a literal single quote —
127
- // the one character single-quoting can't represent at all.
128
- function serializeEnvValue(value) {
129
- if (!/[\s#"'\\]/.test(value) && value !== '')
130
- return value;
131
- if (!value.includes("'"))
132
- return `'${value}'`;
133
- return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
115
+ // A name that's `agent`-scoped by any declaring ability is never read
116
+ // from the shared slot (core/agent-env.ts's createAgentEnv), so a
117
+ // shared row for it would only mislead.
118
+ for (const [key, row] of rows) {
119
+ if (row.slot === 'shared' && rows.get(`agent:${row.name}`)?.scope === 'agent')
120
+ rows.delete(key);
121
+ }
122
+ return [...rows.values()];
134
123
  }
135
- /** Upserts `NAME=VALUE` into the project's `.env` file — preserving
136
- * every other line (comments, blank lines, unrelated keys) — and
137
- * applies it to *this* running process immediately via `process.env`,
138
- * so a newly-installed ability's tools work without a restart. Callers
139
- * (the PUT route in adapters/http.ts) are responsible for refusing to
140
- * call this at all when `LOOPENGINE_ADMIN_AUTH` isn't set — this
141
- * function itself has no notion of HTTP auth, it just writes. */
142
- export function setEnvVar(name, value) {
124
+ function assertEnvVarName(name) {
143
125
  if (!ENV_VAR_NAME_PATTERN.test(name)) {
144
126
  throw new EnvVarNameError(`"${name}" isn't a valid env var name (uppercase letters, digits, underscore, not starting with a digit).`);
145
127
  }
146
- const path = envFilePath();
147
- const lines = existsSync(path) ? readFileSync(path, 'utf8').split('\n') : [];
148
- const newLine = `${name}=${serializeEnvValue(value)}`;
149
- const existingIndex = lines.findIndex((line) => line.startsWith(`${name}=`));
150
- if (existingIndex === -1) {
151
- // Drop a single trailing blank line (from the file's own final
152
- // newline splitting into an empty last element) before appending,
153
- // so this doesn't accumulate a growing gap of blank lines across
154
- // repeated calls.
155
- if (lines.length > 0 && lines[lines.length - 1] === '')
156
- lines.pop();
157
- lines.push(newLine);
158
- }
159
- else {
160
- lines[existingIndex] = newLine;
161
- }
162
- writeFileSync(path, lines.join('\n') + '\n');
128
+ }
129
+ /** Persists a project-wide value through the secret store (the project's
130
+ * `.env` by default — see core/secret-store.ts) and applies it to *this*
131
+ * running process immediately via `process.env`, so a newly-installed
132
+ * ability's tools work without a restart. Callers (the PUT route in
133
+ * adapters/http.ts) are responsible for refusing to call this at all
134
+ * when the server has no auth configured — this function itself has no
135
+ * notion of HTTP auth, it just writes. */
136
+ export async function setEnvVar(name, value) {
137
+ assertEnvVarName(name);
138
+ await getSecretStore().setShared(name, value);
163
139
  process.env[name] = value;
164
140
  }
165
- /** Removes NAME= entirely from the project's .env file (not just
166
- * blanking its value — an empty string is itself a real, distinct
167
- * value some vars would treat differently from "unset," e.g. falling
168
- * through to a default only when truly absent) and clears it from
169
- * *this* running process's own process.env immediately, the same
170
- * "no restart needed" promise setEnvVar already makes. A no-op,
171
- * not an error, when the var was never set in .env to begin with —
172
- * same reasoning the DELETE route calling this treats a missing name
173
- * as success rather than a 404: the end state ("not set") is identical
174
- * either way, and the caller has no way to distinguish "I removed it"
175
- * from "it was already gone" that would actually matter to them. */
176
- export function unsetEnvVar(name) {
177
- if (!ENV_VAR_NAME_PATTERN.test(name)) {
178
- throw new EnvVarNameError(`"${name}" isn't a valid env var name (uppercase letters, digits, underscore, not starting with a digit).`);
179
- }
180
- const path = envFilePath();
181
- if (existsSync(path)) {
182
- const lines = readFileSync(path, 'utf8').split('\n');
183
- const filtered = lines.filter((line) => !line.startsWith(`${name}=`));
184
- // Only rewrites the file when something actually changed — a
185
- // no-op removal shouldn't still touch the file's own mtime/trailing-
186
- // newline shape for no reason. Matches setEnvVar's own exactly-one-
187
- // trailing-newline convention when it does.
188
- if (filtered.length !== lines.length)
189
- writeFileSync(path, filtered.join('\n').replace(/\n*$/, '\n'));
190
- }
141
+ /** Removes a project-wide value entirely (not just blanking it — an
142
+ * empty string is itself a real, distinct value some vars would treat
143
+ * differently from "unset," e.g. falling through to a default only when
144
+ * truly absent) and clears it from *this* running process's own
145
+ * process.env immediately, the same "no restart needed" promise
146
+ * setEnvVar already makes. A no-op, not an error, when the var was
147
+ * never set to begin with — the end state ("not set") is identical
148
+ * either way. */
149
+ export async function unsetEnvVar(name) {
150
+ assertEnvVarName(name);
151
+ await getSecretStore().unsetShared(name);
191
152
  delete process.env[name];
192
153
  }
154
+ /** Persists `agentName`'s own value (agents/<name>/.env by default) —
155
+ * read by that agent's tools through ToolContext.env (core/agent-env.ts),
156
+ * which reads it fresh on every lookup, so no restart is needed.
157
+ * Deliberately never copied into process.env: that's shared by every
158
+ * agent in the process, which is exactly what a per-agent value must
159
+ * not be. Same auth responsibility for callers as setEnvVar. */
160
+ export async function setAgentEnvVar(agentName, name, value) {
161
+ assertEnvVarName(name);
162
+ await getSecretStore().setAgent(agentName, agentDir(agentName), name, value);
163
+ }
164
+ /** Removes `agentName`'s own value — and the legacy prefixed project var
165
+ * for it too (see agentScopedEnvVarName), since createAgentEnv still
166
+ * falls back to that one, and "Remove" on this agent's row should leave
167
+ * the agent with no value of its own either way. */
168
+ export async function unsetAgentEnvVar(agentName, name) {
169
+ assertEnvVarName(name);
170
+ await getSecretStore().unsetAgent(agentName, agentDir(agentName), name);
171
+ await unsetEnvVar(agentScopedEnvVarName(agentName, name));
172
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.1.61",
3
+ "version": "0.1.63",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",
@@ -36,6 +36,14 @@
36
36
  "dist/core/client.d.ts",
37
37
  "dist/core/agent-config.js",
38
38
  "dist/core/agent-config.d.ts",
39
+ "dist/core/agent-env.js",
40
+ "dist/core/agent-env.d.ts",
41
+ "dist/core/secret-store.js",
42
+ "dist/core/secret-store.d.ts",
43
+ "dist/core/trusted-proxy.js",
44
+ "dist/core/trusted-proxy.d.ts",
45
+ "dist/core/usage.js",
46
+ "dist/core/usage.d.ts",
39
47
  "dist/core/session-store.js",
40
48
  "dist/core/session-store.d.ts",
41
49
  "dist/core/vector-index.js",
@@ -1,25 +0,0 @@
1
- ---
2
- name: composio-large-outputs
3
- description: How to retrieve a gateway tool's real output when its result says storedInFile is true instead of returning the data inline.
4
- ---
5
-
6
- # Gateway tools with a large result
7
-
8
- Some gateway tools (Composio-sourced ones in particular) return a small
9
- pointer object instead of their real output when the actual result is
10
- too large to return inline:
11
-
12
- ```
13
- {
14
- "successful": true,
15
- "storedInFile": true,
16
- "tokenCount": 10234,
17
- "outputFilePath": "/tmp/.../SOME_TOOL_OUTPUT_xxxxx.json"
18
- }
19
- ```
20
-
21
- When you see `storedInFile: true` in a tool result, the real data is
22
- **not** in that object — it's in the file at `outputFilePath`. Call
23
- `system_read_file` with that exact path to retrieve the actual content
24
- before answering. Don't guess at an answer, summarize from the pointer
25
- alone, or claim you don't have the data — read the file first.