@sema-agent/connectors 0.4.0 → 0.6.0

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/CHANGELOG.md CHANGED
@@ -4,6 +4,91 @@ All notable changes to this package are documented here. Released sections are
4
4
  frozen: once a version is published its section is never edited again; new text
5
5
  goes into the next version's section.
6
6
 
7
+ ## 0.6.0 (2026-10-16)
8
+
9
+ ### Changed (breaking)
10
+
11
+ - `binaries.lock.json`, which `pinnedUpstream` reads (CN-027): an upstream is now
12
+ any number of archives and one program, `{"<upstream>": {"version",
13
+ "archives": [{"platform", "file", "source", "sha256", "unpack"}],
14
+ "program"}}`. An archive names one platform or `"*"` (every platform the lock
15
+ pins); its file name gives its format (`.tar.gz` or `.tgz`, `.zip`); `unpack`
16
+ is `{"entry": <name>}` (the one file of the archive, as before) or
17
+ `{"directory": <top directory>, "to": <path below bin>}` (every entry below
18
+ that directory). `program` is `{"binary": <path>}` or `{"node": <path>}`, a
19
+ Node.js script run by the Node.js that runs the connector. The lock of the
20
+ earlier shape (`source` and `platforms`) is refused; connector packages move
21
+ to the new shape with this version.
22
+ - `install` fetches every archive of the platform into the bin directory and
23
+ compares each with its sha256, and checks what it unpacks, before anything is
24
+ unpacked: an archive that does not match, or cannot be unpacked as the lock
25
+ says, installs nothing. Archives are streamed to disk instead of held in
26
+ memory; an archive is kept in the bin directory only once what it unpacks is
27
+ in place. The start check reads every archive's sha256, compares each tree
28
+ with its archive's listing (paths, kinds, sizes, modes, link targets; a tree
29
+ that differs is reinstalled by `install`) and the program with its entry.
30
+
31
+ ### Added
32
+
33
+ - `readHostUrl(value)` (CN-045): the one rule for an address a connector stores as a non-secret host handle and hands
34
+ its upstream program (an `https` URL, or an `http` URL on `127.0.0.1` or `localhost`, without credentials, a query
35
+ or a fragment, stored as the URL's origin and path without a trailing slash); moved here from the Gitea connector's
36
+ own `readHost` (CN-037), so a second connector that needs a host does not carry a second copy of the rule.
37
+ - `readStdinJson(limit, keys, optional?)` takes a third, optional list of keys a caller's JSON object may also carry
38
+ besides `keys` (CN-045): the same refusal shape (exactly `keys`, at most `optional` besides them, no other key, a
39
+ key named twice refused, every value a string). Omitted, behaviour is exactly as before.
40
+ - `pinnedUpstream({ ..., optionalHandles })` (CN-045): handles a connector's server may use when stored, but starts
41
+ without; given, an optional handle's `<NAME>_FILE` is checked the same way a required one's is (a regular file
42
+ inside the connector's directory) and named in `UpstreamStart.files`; absent, `start()` does not wait for it. A
43
+ required or an optional handle's `<NAME>_FILE` is kept from the program either way.
44
+ - Zip archives and archive trees (CN-027): a tree is checked whole before the
45
+ first byte is written (no entry outside its directory, no `..`, empty or `.`
46
+ segment, no absolute path, no name twice, nothing below a file or a link, no
47
+ hard link, device or pipe, a symbolic link only to a relative path below its
48
+ own directory), written with modes 0644 and 0755 only, zip entries streamed
49
+ through inflate with their size and CRC-32 checked; at most 1 GiB per
50
+ archive. Split, ZIP64 and encrypted zip archives are refused.
51
+ - `UpstreamStart.binDir`, the bin directory holding the pinned build.
52
+
53
+ ### Fixed
54
+
55
+ - The CLI kit's program name follows mount's whole name rule (CN-053): a
56
+ reserved name such as `sema-connector` is refused with the launcher's own
57
+ prefix, `sema-connector: mount refused (reserved_name): ...`, as `INTEGRATION.md`
58
+ says, instead of `sema-connector-sema-connector:`. A copy of the package whose
59
+ own manifest is out of reach (bundled, KL-14) refuses `statusCommand` in one
60
+ line with exit 1 instead of failing with a thrown error.
61
+
62
+ ## 0.5.0 (2026-10-15)
63
+
64
+ ### Added
65
+
66
+ - `FileSecrets.remove(name)` (CN-048): removes a handle's value file and its
67
+ metadata file together, inside the lock of the connector's directory
68
+ (unless `withLock` already holds it); returns whether the handle existed
69
+ before. Absent is a no-op.
70
+
71
+ ### Changed
72
+
73
+ - `FileSecrets.put` refuses the empty string (CN-048): a handle always has a
74
+ value, so `put(name, '')` throws a `TypeError` instead of writing one.
75
+ Nothing is written; an existing handle's value and metadata are untouched
76
+ byte for byte. A connector that needs to take a handle away uses `remove`.
77
+
78
+ ### Fixed
79
+
80
+ - A `tools/call` outside the granted scope, sent as a notification (no
81
+ `id`): the launcher no longer answers it with an id-null
82
+ `connector.forbidden_scope` error and no longer writes its receipt (CN-047).
83
+ Nothing answers a notification, whatever refuses it; this already held for
84
+ a parked session's refusal (CN-046) and now holds for the scope judge's
85
+ too, the same form (`id === null ? [] : ...`).
86
+ - `mountCommand` (and every CLI kit command) with a connector name `mount`
87
+ refuses no longer throws before `mount` is called: the refusal arrives as the
88
+ documented one line on standard error, `mount refused (invalid_name): ...`,
89
+ with exit 1 (CN-050). `cliOutput` gives such a name the launcher's own program
90
+ name instead of refusing it itself; the name rule is judged once, by `mount`.
91
+
7
92
  ## 0.4.0 (2026-10-15)
8
93
 
9
94
  ### Added
package/INTEGRATION.md CHANGED
@@ -39,7 +39,10 @@ const result = mount({ name: 'gitea', configDir: dir })
39
39
  - `mountCommand(name, configDir): number` is the same call with the CLI's output: on success one line on
40
40
  standard output (`mounted connector <name> in <file>; restart the engine to apply`, or
41
41
  `connector <name> is already mounted in <file>`) and exit 0; on a refusal one line on standard error,
42
- `mount refused (<reason>): <message>`, and exit 1. A thin wrapper returns its number as its exit code.
42
+ `sema-connector-<name>: mount refused (<reason>): <message>`, and exit 1. The prefix is the program name
43
+ of the connector's own CLI; a name `mount` refuses (`invalid_name`, `reserved_name`) gets the launcher's own,
44
+ `sema-connector: `, since no connector program exists for it. A thin wrapper returns its number as its
45
+ exit code and passes both streams through.
43
46
  - `mount` does not check that the connector package is installed: an entry for a connector that is not
44
47
  installed makes the launcher exit 4 (`connector.not_installed`) when the engine starts it. A tool that
45
48
  wants only installed connectors mounted uses the seed.
@@ -58,6 +61,23 @@ const result = mount({ name: 'gitea', configDir: dir })
58
61
  command line). A tool that offers "mount the defaults" runs the seed; a tool that offers "mount this one"
59
62
  calls `mount`.
60
63
 
64
+ ## Installing the pieces
65
+
66
+ `@sema-agent/connectors` is consumed as an installed package, not bundled: `statusCommand` starts the
67
+ `sema-connector` launcher of the very copy it belongs to, and `seed` resolves connector packages from that
68
+ copy's place in `node_modules`, both through the package's own `package.json` (`import.meta.url`), so a copy
69
+ inlined into another program's bundle finds neither and refuses in one line (KL-14 in the repository's
70
+ `docs/KNOWN-LIMITS.md`). `mount` and `mountCommand` have no such dependency.
71
+
72
+ The entry `mount` writes names the command `sema-connector`; the engine runs it from its `PATH`. The
73
+ launcher is the `bin` of `@sema-agent/connectors`, and a connector is the `bin` of
74
+ `@sema-agent/connector-<name>`, installed where that launcher resolves it (the same `node_modules`, a global
75
+ install, or `NODE_PATH`). A tool that mounts a connector does not install either; it tells the person what to
76
+ install (`npm install -g @sema-agent/connectors @sema-agent/connector-<name>`, then the connector's `install`
77
+ and `auth` with the same `CONFIG_LOCAL_DIR`). An entry whose launcher is not on the engine's `PATH`, or whose
78
+ connector package is not installed, fails at the engine's next start (exit 4, `connector.not_installed`);
79
+ `seed` mounts only connectors the launcher that runs it finds.
80
+
61
81
  ## What the engine sees
62
82
 
63
83
  Entries are read by the engine from `config.d/mcp.json` through the server's local file configuration
package/dist/cli-kit.d.ts CHANGED
@@ -29,6 +29,6 @@ export type StdinJson = {
29
29
  readonly problem: string;
30
30
  };
31
31
  export declare const listed: (keys: readonly string[]) => string;
32
- export declare function readStdinJson(limit: number, keys: readonly string[]): Promise<StdinJson>;
32
+ export declare function readStdinJson(limit: number, keys: readonly string[], optional?: readonly string[]): Promise<StdinJson>;
33
33
  export declare function mountCommand(name: string, configDir: string): number;
34
34
  export declare function statusCommand(name: string, configDir: string): Promise<number>;
package/dist/cli-kit.js CHANGED
@@ -1,15 +1,12 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { resolveConfigDir } from './config-dir.js';
3
- import { CONNECTOR_NAME } from './connector-name.js';
4
3
  import { hasRepeatedKey } from './json-text.js';
5
- import { mount } from './mount.js';
4
+ import { mount, nameRefusal } from './mount.js';
6
5
  import { ownManifest } from './own-package.js';
7
6
  import { MCP_PROTOCOL_VERSION } from './session.js';
8
7
  import { statusUri } from './status.js';
9
8
  export function cliOutput(name) {
10
- if (typeof name !== 'string' || !CONNECTOR_NAME.test(name))
11
- throw new Error('a connector name is lowercase letters and digits in hyphen-separated words');
12
- const program = `sema-connector-${name}`;
9
+ const program = nameRefusal(name) === null ? `sema-connector-${name}` : 'sema-connector';
13
10
  return {
14
11
  program,
15
12
  say: (line) => void process.stdout.write(`${line}\n`),
@@ -64,12 +61,14 @@ export async function readStdinLine(limit) {
64
61
  return { ok: true, line };
65
62
  }
66
63
  export const listed = (keys) => (keys.length < 2 ? keys.join('') : `${keys.slice(0, -1).join(', ')} and ${keys[keys.length - 1]}`);
67
- export async function readStdinJson(limit, keys) {
64
+ export async function readStdinJson(limit, keys, optional = []) {
68
65
  const read = await readStdinLine(limit);
69
66
  if (!read.ok)
70
67
  return read;
71
- const exactly = keys.length === 1 ? `exactly the key ${listed(keys)}` : `exactly the keys ${listed(keys)}`;
72
- const shape = `standard input must be one JSON object with ${exactly}, ${keys.length === 1 ? 'a string' : 'each a string'}, on one line`;
68
+ const all = [...keys, ...optional];
69
+ const phrase = (how, names) => `${how} the ${names.length === 1 ? 'key' : 'keys'} ${listed(names)}`;
70
+ const exactly = [...(keys.length > 0 ? [phrase('exactly', keys)] : []), ...(optional.length > 0 ? [phrase('at most', optional)] : [])].join(' and ') || 'no keys';
71
+ const shape = `standard input must be one JSON object with ${exactly}, ${all.length === 1 ? 'a string' : 'each a string'}, on one line`;
73
72
  let value;
74
73
  try {
75
74
  value = JSON.parse(read.line);
@@ -83,11 +82,11 @@ export async function readStdinJson(limit, keys) {
83
82
  return { ok: false, problem: 'the object names a key more than once' };
84
83
  const object = value;
85
84
  const own = Object.keys(object);
86
- if (own.length !== keys.length || !keys.every((k) => own.includes(k)))
85
+ if (!keys.every((k) => own.includes(k)) || !own.every((k) => all.includes(k)))
87
86
  return { ok: false, problem: `the object must have ${exactly}` };
88
- if (!keys.every((k) => typeof object[k] === 'string'))
87
+ if (!own.every((k) => typeof object[k] === 'string'))
89
88
  return { ok: false, problem: shape };
90
- return { ok: true, values: Object.fromEntries(keys.map((k) => [k, object[k]])) };
89
+ return { ok: true, values: Object.fromEntries(own.map((k) => [k, object[k]])) };
91
90
  }
92
91
  export function mountCommand(name, configDir) {
93
92
  const { say, complain } = cliOutput(name);
@@ -145,7 +144,14 @@ function lineReader(stream) {
145
144
  }
146
145
  export async function statusCommand(name, configDir) {
147
146
  const { program, say, complain } = cliOutput(name);
148
- const own = ownManifest();
147
+ let own;
148
+ try {
149
+ own = ownManifest();
150
+ }
151
+ catch {
152
+ complain('the package\'s own manifest cannot be read, so its launcher cannot be found: this package is consumed installed, not bundled (INTEGRATION.md, KL-14)');
153
+ return 1;
154
+ }
149
155
  const child = spawn(process.execPath, [own.launcher, 'run', name, '--mode', 'standalone', '--transport', 'stdio', '--config-dir', configDir], { stdio: ['pipe', 'pipe', 'pipe'] });
150
156
  const exited = new Promise((resolve) => child.on('exit', (code) => resolve(code)));
151
157
  const errors = [];
@@ -0,0 +1,8 @@
1
+ export type HostReading = {
2
+ readonly ok: true;
3
+ readonly host: string;
4
+ } | {
5
+ readonly ok: false;
6
+ readonly problem: string;
7
+ };
8
+ export declare function readHostUrl(value: string): HostReading;
@@ -0,0 +1,19 @@
1
+ import { isLoopbackHttpOrHttps } from './jwks.js';
2
+ export function readHostUrl(value) {
3
+ if (/[\s\u0000-\u001f\u007f]/.test(value))
4
+ return { ok: false, problem: 'the host is not a URL (it holds whitespace or a control character)' };
5
+ let url;
6
+ try {
7
+ url = new URL(value);
8
+ }
9
+ catch {
10
+ return { ok: false, problem: 'the host is not a URL' };
11
+ }
12
+ if (url.username !== '' || url.password !== '')
13
+ return { ok: false, problem: 'the host carries credentials; a host URL never does (the token is stored on its own)' };
14
+ if (!isLoopbackHttpOrHttps(url))
15
+ return { ok: false, problem: 'the host must be an https URL, or an http URL on 127.0.0.1 or localhost' };
16
+ if (url.search !== '' || url.hash !== '')
17
+ return { ok: false, problem: 'the host URL takes no query and no fragment' };
18
+ return { ok: true, host: url.origin + url.pathname.replace(/\/+$/, '') };
19
+ }
package/dist/index.d.ts CHANGED
@@ -11,6 +11,8 @@ export type { DefaultConnector, SeedOptions, SeedReading, SeedRefusal, SeedResul
11
11
  export { cliOutput, connectorCli, mountCommand, readStdinJson, readStdinLine, statusCommand } from './cli-kit.js';
12
12
  export type { CliOutput, ConnectorCommands, StdinJson, StdinLine } from './cli-kit.js';
13
13
  export { isLoopbackHttpOrHttps } from './jwks.js';
14
+ export { readHostUrl } from './host-url.js';
15
+ export type { HostReading } from './host-url.js';
14
16
  export { pinnedUpstream } from './pinned-upstream.js';
15
17
  export type { PinnedUpstream, PinnedUpstreamOptions, UpstreamStart } from './pinned-upstream.js';
16
18
  export { writeAll } from './write-all.js';
package/dist/index.js CHANGED
@@ -5,5 +5,6 @@ export { mount } from './mount.js';
5
5
  export { DEFAULT_CONNECTORS, SEED_VERSION, seed } from './seed.js';
6
6
  export { cliOutput, connectorCli, mountCommand, readStdinJson, readStdinLine, statusCommand } from './cli-kit.js';
7
7
  export { isLoopbackHttpOrHttps } from './jwks.js';
8
+ export { readHostUrl } from './host-url.js';
8
9
  export { pinnedUpstream } from './pinned-upstream.js';
9
10
  export { writeAll } from './write-all.js';
@@ -4,10 +4,12 @@ export interface PinnedUpstreamOptions {
4
4
  readonly upstream: string;
5
5
  readonly lockFile: URL;
6
6
  readonly handles: readonly string[];
7
+ readonly optionalHandles?: readonly string[];
7
8
  }
8
9
  export type UpstreamStart = {
9
10
  readonly ok: true;
10
11
  readonly connectorDir: string;
12
+ readonly binDir: string;
11
13
  readonly files: Readonly<Record<string, string>>;
12
14
  run(args: readonly string[], extra: Readonly<Record<string, string>>): Promise<number>;
13
15
  } | {