loopengine 0.3.9 → 0.3.11

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.
@@ -26,6 +26,7 @@ import { parse as parseYaml } from 'yaml';
26
26
  import semver from 'semver';
27
27
  import { agentDir } from '../core/gateway-tools.js';
28
28
  import { abilityFetchAuth } from '../core/ability-sources.js';
29
+ import { assertOfficialAbility } from '../core/official-abilities.js';
29
30
  import { clearMcpSchemaCache, resolveMcpManifest, McpManifestError } from '../core/mcp-abilities.js';
30
31
  import { appRuleName } from '../core/mcp-ability-generator.js';
31
32
  import { addToolToIndex, removeToolFromIndex, toCamelCase } from '../web/http-tool-admin.js';
@@ -363,6 +364,8 @@ function resolveMcp(manifest) {
363
364
  }
364
365
  }
365
366
  export async function installAbility(agentName, spec, options = {}) {
367
+ // A public ability only if LoopEngine publishes it (core/official-abilities.ts).
368
+ assertOfficialAbility(spec);
366
369
  const fetch = options.fetchAbilityDir ?? fetchAbilityDir;
367
370
  const abilityDir = fetch(spec);
368
371
  const manifest = readManifest(abilityDir);
@@ -635,6 +638,8 @@ export async function upgradeAbility(agentName, abilityName, options = {}) {
635
638
  throw new AbilityNotInstalledError(`Ability '${abilityName}' isn't installed for '${agentName}'.`);
636
639
  }
637
640
  const spec = options.spec ?? abilityName;
641
+ // New code is fetched: the same check as an install.
642
+ assertOfficialAbility(spec);
638
643
  const oldDir = fetch(oldContentSpec(record.spec, abilityName, record.version));
639
644
  const newDir = fetch(spec);
640
645
  const oldManifest = readManifest(oldDir);
@@ -20,7 +20,9 @@ export interface SourceAbility {
20
20
  spec: string;
21
21
  }
22
22
  export interface AbilityOrigin {
23
- kind: 'public' | 'private';
23
+ /** official: under LoopEngine's @loopengine-co scope; public: any
24
+ * other npm package (an older lp-* one, say). */
25
+ kind: 'official' | 'public' | 'private';
24
26
  /** Where it came from, for the console's badge: "npm", "GitHub
25
27
  * loopengine-co/lp-github", "Folder ../loopengine-abilities", ... */
26
28
  label: string;
@@ -260,7 +260,7 @@ export function abilityOrigin(spec, name, sources = safeReadSources()) {
260
260
  const npmSource = scope ? sources.find((s) => s.type === 'npm' && s.scope === scope) : undefined;
261
261
  if (npmSource)
262
262
  return { kind: 'private', label: describeSource(npmSource) };
263
- return { kind: 'public', label: 'npm' };
263
+ return { kind: target.toLowerCase().startsWith('@loopengine-co/') ? 'official' : 'public', label: 'npm' };
264
264
  }
265
265
  function safeReadSources() {
266
266
  try {
@@ -26,6 +26,22 @@ export declare const MCP_REGISTRY_URL_ENV = "LOOPENGINE_MCP_REGISTRY_URL";
26
26
  export declare const MCP_SERVERS_FILE_ENV = "LOOPENGINE_MCP_SERVERS_FILE";
27
27
  /** The workspace's saved servers. A malformed entry is skipped. */
28
28
  export declare function savedMcpServers(): McpDirectoryServer[];
29
+ export declare class McpServerInputError extends Error {
30
+ }
31
+ /** A server the workspace adds by its address, checked and given an id
32
+ * from its name (the same id again for an address already saved). Not
33
+ * saved yet: saveMcpServer, once it has connected. */
34
+ export declare function ownMcpServer(input: {
35
+ title?: unknown;
36
+ url?: unknown;
37
+ description?: unknown;
38
+ header?: unknown;
39
+ }, saved?: McpDirectoryServer[]): McpDirectoryServer;
40
+ /** Adds a server to the saved servers, or replaces the one with its id.
41
+ * The file's other entries are kept as they are. */
42
+ export declare function saveMcpServer(server: McpDirectoryServer): void;
43
+ /** Removes a saved server; false when there's none with that id. */
44
+ export declare function removeSavedMcpServer(id: string): boolean;
29
45
  /** Saved servers matching `query`, then registry ones, verified first. */
30
46
  export declare function searchMcpServers(query: string, limit?: number): Promise<McpDirectoryServer[]>;
31
47
  /** One server by id: a saved one, else the registry's latest entry. */
@@ -11,12 +11,15 @@
11
11
  // url: https://mcp.acme.internal/mcp
12
12
  // auth: { header: X-Api-Key } # optional; default Authorization: Bearer
13
13
  //
14
+ // The console's Apps page adds to the same file ("Add your own server"):
15
+ // a company's own server, connected like any app.
16
+ //
14
17
  // A registry server counts as verified when its URL is on the domain its
15
18
  // registry name was verified for (com.stripe/mcp at mcp.stripe.com); the
16
19
  // rest are shown as unverified.
17
- import { existsSync, readFileSync } from 'node:fs';
20
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
18
21
  import { resolve } from 'node:path';
19
- import { parse as parseYaml } from 'yaml';
22
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
20
23
  export const MCP_REGISTRY_URL_ENV = 'LOOPENGINE_MCP_REGISTRY_URL';
21
24
  export const MCP_SERVERS_FILE_ENV = 'LOOPENGINE_MCP_SERVERS_FILE';
22
25
  const DEFAULT_REGISTRY = 'https://registry.modelcontextprotocol.io';
@@ -27,9 +30,12 @@ function registryBase() {
27
30
  return undefined;
28
31
  return (value || DEFAULT_REGISTRY).replace(/\/+$/, '');
29
32
  }
33
+ function savedServersPath() {
34
+ return resolve(process.cwd(), process.env[MCP_SERVERS_FILE_ENV]?.trim() || 'mcp-servers.yml');
35
+ }
30
36
  /** The workspace's saved servers. A malformed entry is skipped. */
31
37
  export function savedMcpServers() {
32
- const path = resolve(process.cwd(), process.env[MCP_SERVERS_FILE_ENV]?.trim() || 'mcp-servers.yml');
38
+ const path = savedServersPath();
33
39
  if (!existsSync(path))
34
40
  return [];
35
41
  let raw;
@@ -71,6 +77,93 @@ function isRemoteUrl(url) {
71
77
  return false;
72
78
  }
73
79
  }
80
+ export class McpServerInputError extends Error {
81
+ }
82
+ const HEADER_NAME = /^[A-Za-z0-9-]{1,64}$/;
83
+ /** A server the workspace adds by its address, checked and given an id
84
+ * from its name (the same id again for an address already saved). Not
85
+ * saved yet: saveMcpServer, once it has connected. */
86
+ export function ownMcpServer(input, saved = savedMcpServers()) {
87
+ const text = (v) => (typeof v === 'string' ? v.trim() : '');
88
+ const title = text(input.title);
89
+ const url = text(input.url);
90
+ const description = text(input.description);
91
+ const header = text(input.header);
92
+ if (!title)
93
+ throw new McpServerInputError('Give the server a name, e.g. "Acme CRM".');
94
+ if (title.length > 60)
95
+ throw new McpServerInputError('Keep the name under 60 characters.');
96
+ if (description.length > 200)
97
+ throw new McpServerInputError('Keep the description under 200 characters.');
98
+ let parsed;
99
+ try {
100
+ parsed = new URL(url);
101
+ }
102
+ catch {
103
+ throw new McpServerInputError('Enter the server\'s full address, e.g. https://mcp.yourcompany.com/mcp.');
104
+ }
105
+ if (!isRemoteUrl(parsed.href)) {
106
+ throw new McpServerInputError(parsed.protocol === 'http:'
107
+ ? 'Use the server\'s https address: its key would otherwise be sent unencrypted.'
108
+ : 'Enter the server\'s full https address, with nothing left to fill in, e.g. https://mcp.yourcompany.com/mcp.');
109
+ }
110
+ if (header && !HEADER_NAME.test(header))
111
+ throw new McpServerInputError('A header name is letters, digits and dashes, e.g. X-Api-Key.');
112
+ const same = saved.find((s) => s.url === url);
113
+ let id = same?.id;
114
+ if (!id) {
115
+ const base = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'server';
116
+ id = base;
117
+ for (let n = 2; saved.some((s) => s.id === id); n++)
118
+ id = `${base}-${n}`;
119
+ }
120
+ return {
121
+ id,
122
+ title,
123
+ description,
124
+ url,
125
+ transport: 'streamable-http',
126
+ source: 'saved',
127
+ verified: true,
128
+ ...(header && header.toLowerCase() !== 'authorization' ? { auth: { header } } : {}),
129
+ };
130
+ }
131
+ /** Adds a server to the saved servers, or replaces the one with its id.
132
+ * The file's other entries are kept as they are. */
133
+ export function saveMcpServer(server) {
134
+ const path = savedServersPath();
135
+ let doc = {};
136
+ if (existsSync(path)) {
137
+ try {
138
+ doc = parseYaml(readFileSync(path, 'utf8')) ?? {};
139
+ }
140
+ catch (err) {
141
+ throw new McpServerInputError(`${path} couldn't be read, so the server wasn't saved: ${err instanceof Error ? err.message : String(err)}`);
142
+ }
143
+ }
144
+ const servers = Array.isArray(doc.servers) ? doc.servers.filter((e) => e?.id !== server.id) : [];
145
+ servers.push({
146
+ id: server.id,
147
+ title: server.title,
148
+ ...(server.description ? { description: server.description } : {}),
149
+ url: server.url,
150
+ ...(server.auth?.header ? { auth: { header: server.auth.header } } : {}),
151
+ });
152
+ writeFileSync(path, stringifyYaml({ ...doc, servers }));
153
+ }
154
+ /** Removes a saved server; false when there's none with that id. */
155
+ export function removeSavedMcpServer(id) {
156
+ const path = savedServersPath();
157
+ if (!existsSync(path))
158
+ return false;
159
+ const doc = parseYaml(readFileSync(path, 'utf8')) ?? {};
160
+ const servers = Array.isArray(doc.servers) ? doc.servers : [];
161
+ const kept = servers.filter((e) => e?.id !== id);
162
+ if (kept.length === servers.length)
163
+ return false;
164
+ writeFileSync(path, stringifyYaml({ ...doc, servers: kept }));
165
+ return true;
166
+ }
74
167
  /** io.github.firecrawl/… → firecrawl: the GitHub organisation the
75
168
  * registry verified the name for. */
76
169
  function githubOrg(name) {
@@ -0,0 +1,14 @@
1
+ /** LoopEngine's npm organisation: its scope is official by itself. */
2
+ export declare const OFFICIAL_ABILITY_SCOPE = "@loopengine-co";
3
+ /** Whether a package name is under LoopEngine's npm organisation. */
4
+ export declare function isOfficialScope(name: string): boolean;
5
+ export declare class UnofficialAbilityError extends Error {
6
+ }
7
+ export declare function unofficialAbilitiesAllowed(): boolean;
8
+ /** The package name when a spec installs from the public npm registry:
9
+ * a bare name or @scope/name, with an optional @version, whose scope isn't
10
+ * one of the workspace's private registries. Undefined for anything else. */
11
+ export declare function publicNpmPackage(spec: string): string | undefined;
12
+ /** Refuses a public npm ability that isn't official, before anything is
13
+ * fetched. */
14
+ export declare function assertOfficialAbility(spec: string): void;
@@ -0,0 +1,58 @@
1
+ // Official abilities: of the public ones on npm, only those LoopEngine
2
+ // publishes are listed and installed. Anyone can publish a package with the
3
+ // loopengine-ability keyword, or an lp-* name, and an ability runs code in
4
+ // the agent's server with the agent's keys; so "official" is decided by who
5
+ // can publish it: anything under the @loopengine-co scope, which only
6
+ // members of LoopEngine's npm organisation can publish to. The older
7
+ // unscoped ones (lp-gmail and the rest) aren't, and can't be installed or
8
+ // upgraded; their official versions are under the scope.
9
+ //
10
+ // Private abilities are the workspace's own and stay allowed: a workspace
11
+ // ability (file:), one from a GitHub source (github:owner/repo, git URLs),
12
+ // and one from a private npm registry the workspace set up for a scope
13
+ // (core/ability-sources.ts).
14
+ //
15
+ // LOOPENGINE_ALLOW_UNOFFICIAL_ABILITIES=1 turns the check off, for someone
16
+ // developing their own public ability on a self-hosted server.
17
+ import { readAbilitySources } from './ability-sources.js';
18
+ /** LoopEngine's npm organisation: its scope is official by itself. */
19
+ export const OFFICIAL_ABILITY_SCOPE = '@loopengine-co';
20
+ /** Whether a package name is under LoopEngine's npm organisation. */
21
+ export function isOfficialScope(name) {
22
+ return name.toLowerCase().startsWith(`${OFFICIAL_ABILITY_SCOPE}/`);
23
+ }
24
+ export class UnofficialAbilityError extends Error {
25
+ }
26
+ export function unofficialAbilitiesAllowed() {
27
+ return process.env.LOOPENGINE_ALLOW_UNOFFICIAL_ABILITIES === '1';
28
+ }
29
+ /** The package name when a spec installs from the public npm registry:
30
+ * a bare name or @scope/name, with an optional @version, whose scope isn't
31
+ * one of the workspace's private registries. Undefined for anything else. */
32
+ export function publicNpmPackage(spec) {
33
+ const match = /^((?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*)(?:@[^/]+)?$/i.exec(spec.trim());
34
+ if (!match)
35
+ return undefined;
36
+ const name = match[1];
37
+ if (name.startsWith('@')) {
38
+ const scope = name.split('/')[0].toLowerCase();
39
+ let privateScopes = [];
40
+ try {
41
+ privateScopes = readAbilitySources().flatMap((s) => (s.type === 'npm' ? [s.scope.toLowerCase()] : []));
42
+ }
43
+ catch { }
44
+ if (privateScopes.includes(scope))
45
+ return undefined;
46
+ }
47
+ return name;
48
+ }
49
+ /** Refuses a public npm ability that isn't official, before anything is
50
+ * fetched. */
51
+ export function assertOfficialAbility(spec) {
52
+ if (unofficialAbilitiesAllowed())
53
+ return;
54
+ const name = publicNpmPackage(spec);
55
+ if (!name || isOfficialScope(name))
56
+ return;
57
+ throw new UnofficialAbilityError(`${name} isn't an official LoopEngine ability, so it can't be installed: official abilities are under ${OFFICIAL_ABILITY_SCOPE} on npm (search them on the Abilities page). To use your own, add it as a private ability (Settings → Private ability sources, or upload it on the Abilities page).`);
58
+ }
@@ -1,4 +1,5 @@
1
1
  import type { AgentCandidate } from './interviewer.js';
2
+ import type { WorkflowParameter } from '../workflows.js';
2
3
  export interface TemplateRule {
3
4
  name: string;
4
5
  tool: string;
@@ -9,6 +10,14 @@ export interface TemplateSkill {
9
10
  id: string;
10
11
  description: string;
11
12
  body: string;
13
+ /** Makes it a workflow, with `body` as its instructions. `webhook`: its
14
+ * checklist says to add a webhook that sends each result (the URL and
15
+ * the sender's signing secret are set up with the sender). */
16
+ workflow?: {
17
+ title: string;
18
+ parameters?: Record<string, WorkflowParameter>;
19
+ webhook?: boolean;
20
+ };
12
21
  }
13
22
  export interface TemplateSchedule {
14
23
  /** How often, in the scheduler's terms: "10m", "1h", "7d". */
@@ -62,6 +71,10 @@ export interface StarterTemplate {
62
71
  candidate: AgentCandidate;
63
72
  setup: TemplateSetup;
64
73
  }
74
+ /** @deprecated Templates are now workflow examples
75
+ * (./workflow-examples.ts). Kept for a project server from an older
76
+ * template, whose Templates page still lists these: each example as the
77
+ * template it creates as a new agent. */
65
78
  export declare const STARTER_TEMPLATES: readonly StarterTemplate[];
66
79
  export declare function starterTemplate(id: string): StarterTemplate | undefined;
67
80
  /** The first run of a template's schedule: its time of day (and weekday)