@sema-agent/connectors 0.1.0 → 0.3.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,122 @@ 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.3.0 (2026-10-15)
8
+
9
+ ### Added
10
+
11
+ - The default catalog seed (CN-009): `sema-connector seed --config-dir
12
+ <absolute path>`, the launcher's second command, and `seed({ configDir })`
13
+ mount every installed connector of the default catalog into
14
+ `config.d/mcp.json`. The catalog is `DEFAULT_CONNECTORS` (today `gitea` and
15
+ `github`, CN-044),
16
+ the one list of default connectors; `SEED_VERSION` is the newest version
17
+ that added a name. Each connector is resolved with the launcher's own rule
18
+ first (one that is not installed gets no entry) and mounted only through
19
+ `mount`, so another entry under its name is never overwritten.
20
+ `config.d/.sema-connector-seed.json` records what each name came to
21
+ (mounted, present, not installed): a name recorded as mounted or present is
22
+ never mounted again, so an entry a person removed stays removed; a conflict
23
+ or a refusal is not recorded and is tried again. Seeds on one directory
24
+ exclude each other with `config.d/.sema-connector-seed.json.lock` (a second
25
+ seed refuses as `locked`). One line per connector on stdout; exit 0 when
26
+ every connector is mounted, present or not installed, 1 otherwise. A marker
27
+ the seed cannot read refuses it before any mount; a marker write that fails
28
+ leaves the old marker as it was and the mounted entries in place.
29
+ - `pinnedUpstream({ connector, upstream, lockFile, handles })` (CN-044): what
30
+ a connector that runs a third-party MCP server takes from the shared layer,
31
+ so that no connector package carries its own copy. `install(configDir)` is
32
+ the install subcommand: this platform's release archive from the source the
33
+ package's `binaries.lock.json` gives (`https`, redirects followed only to
34
+ `https`, or `file` for a local copy of the releases), its sha256 compared
35
+ with the lock before anything is written, then the archive and its
36
+ `upstream` entry kept in `<configuration directory>/connectors/<name>/bin/`
37
+ (directory 0700, program 0755), each file written whole before it replaces
38
+ the old one; nothing changes when the pinned build is there. `start(env)`
39
+ checks `CONNECTOR_DIR`, one `<NAME>_FILE` per handle naming a regular file
40
+ (not a symbolic link) inside it, and the pinned build in the bin directory
41
+ (the same check as install's), and refuses with a sentence naming what to
42
+ run; its `run(args, extra)` starts the program as the connector's child on
43
+ the same standard input, output and error, with the connector's environment
44
+ minus `CONNECTOR_DIR` and the handle paths plus `extra`, passes `SIGTERM`,
45
+ `SIGINT` and `SIGHUP` on, and ends as the program ends. The Gitea connector
46
+ is its first user (moved from its own package) and the GitHub connector its
47
+ second.
48
+
49
+ ### Changed
50
+
51
+ - A launcher command line that starts with neither `run` nor `seed` is
52
+ refused with `expected the command "run" or "seed"` (exit 2, as before).
53
+ - `readStdinJson` with one key names it as "the key" in its refusals
54
+ (`the object must have exactly the key token`); with two or more keys the
55
+ wording is as before.
56
+
57
+ ## 0.2.0 (2026-10-15)
58
+
59
+ ### Added
60
+
61
+ - Handles that are not secrets (CN-038). `<handle>.meta.json` may say
62
+ `"secret": false`; such a handle is delivered to the connector like every
63
+ handle (a per-launch 0600 copy named by `<NAME>_FILE`), but the launcher
64
+ does not withhold its value and the status does not count it. Every other
65
+ handle is a secret, as before. `openFileSecrets().put(name, value,
66
+ { secret: false })` writes it, `SecretHandle.secret` reports it, and a
67
+ later `put` without it makes the handle a secret again.
68
+ - `CONNECTOR_DIR` (CN-038): the launcher gives its connector the absolute path
69
+ of the connector's directory, `<configuration directory>/connectors/<name>`
70
+ (standalone; it may not exist), so a connector no longer infers it from the
71
+ path of a handle copy. A hosted connector, which has no configuration
72
+ directory and no handles in this release, gets a fresh path of its own that
73
+ does not exist. A `CONNECTOR_DIR` set for the launcher never reaches the
74
+ connector.
75
+ - The lock of a connector's directory (CN-038), `.auth.lock`: every write of
76
+ the secrets backend and every launch's listing and copy of the handles hold
77
+ it, so a launch copies a group of writes before or after the group, never in
78
+ between. `openFileSecrets().withLock(fn)` holds it across several writes. A
79
+ taker waits up to 5 seconds for another holder, then refuses (the launcher
80
+ with exit 2 and `connector.internal`); only the holder removes the lock, and
81
+ one left by a process that died is removed by hand. `FileSecrets.directory`
82
+ names the connector's directory.
83
+ - The connector CLI kit (CN-039): `connectorCli` runs a connector package's
84
+ bin (no arguments: the connector's server; `install`, `auth`, `mount`,
85
+ `status` without arguments on the configuration directory of
86
+ `resolveConfigDir`), `cliOutput`, `readStdinLine`, `readStdinJson` (one line
87
+ holding a JSON object with exactly the given keys, each named once, every
88
+ value a string), `mountCommand`, `statusCommand` (the status resource read
89
+ through the launcher within 10 seconds) and `isLoopbackHttpOrHttps`, the
90
+ one rule for an address a connector sends requests to (the rule of a key set
91
+ URL).
92
+ - `writeAll(fd, data)` (CN-042): the one whole write. It writes every byte of
93
+ `data` into the empty file open at `fd` (a write that writes nothing or fails
94
+ ends it with an error), syncs the file and checks that its size is the number
95
+ of bytes, or throws. Every file this package writes goes through it, and a
96
+ connector package writes its own files with it.
97
+
98
+ ### Changed
99
+
100
+ - The status counts secret handles only: a connector whose only handles are
101
+ not secrets is `unauthorized`, and the expiry of a handle that is not a
102
+ secret changes nothing (CN-038).
103
+ - `<handle>.meta.json` must be a JSON object whose only keys are `expiresAt`
104
+ and `secret`, each named once; another key, a key named twice or a `secret`
105
+ that is not `true` or `false` makes the directory refused (another key used
106
+ to be ignored, and an object without `expiresAt` refused) (CN-038).
107
+
108
+ ### Fixed
109
+
110
+ - A write cut short is no longer taken as done (CN-042). A write can stop short
111
+ without an error (a file size limit, a full disk); `mount` used to rename the
112
+ cut-short text over `mcp.json` and report it written, losing the other
113
+ entries, and `put` stored a cut-short value and reported success. Now nothing
114
+ is put in place before it is written whole: `mount` refuses with the new
115
+ reason `write_failed` and `put` throws, the temporary file is removed and the
116
+ old file (or its absence) stays as it was. The lock files and the per-launch
117
+ copies of the handles are written the same way.
118
+ - The scope judge never grants the empty tool name (CN-036): a `tools/call`
119
+ whose name is `""` is refused with `connector.forbidden_scope` also under
120
+ `tool:*` (or a bare `tool:`), and a listed tool whose name is `""` is not
121
+ listed.
122
+
7
123
  ## 0.1.0 (2026-10-14)
8
124
 
9
125
  ### Added
@@ -0,0 +1,34 @@
1
+ import { type Env } from './config-dir.js';
2
+ export interface CliOutput {
3
+ readonly program: string;
4
+ say(line: string): void;
5
+ complain(line: string): void;
6
+ }
7
+ export declare function cliOutput(name: string): CliOutput;
8
+ export interface ConnectorCommands {
9
+ readonly name: string;
10
+ readonly serve: (env: Env) => void | number | Promise<number>;
11
+ readonly install: (configDir: string) => number | Promise<number>;
12
+ readonly auth: (configDir: string) => number | Promise<number>;
13
+ readonly authReads: string;
14
+ }
15
+ export declare function connectorCli(commands: ConnectorCommands, argv: readonly string[], env: Env): Promise<number | undefined>;
16
+ export type StdinLine = {
17
+ readonly ok: true;
18
+ readonly line: string;
19
+ } | {
20
+ readonly ok: false;
21
+ readonly problem: string;
22
+ };
23
+ export declare function readStdinLine(limit: number): Promise<StdinLine>;
24
+ export type StdinJson = {
25
+ readonly ok: true;
26
+ readonly values: Readonly<Record<string, string>>;
27
+ } | {
28
+ readonly ok: false;
29
+ readonly problem: string;
30
+ };
31
+ export declare const listed: (keys: readonly string[]) => string;
32
+ export declare function readStdinJson(limit: number, keys: readonly string[]): Promise<StdinJson>;
33
+ export declare function mountCommand(name: string, configDir: string): number;
34
+ export declare function statusCommand(name: string, configDir: string): Promise<number>;
@@ -0,0 +1,224 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { resolveConfigDir } from './config-dir.js';
6
+ import { CONNECTOR_NAME } from './connector-name.js';
7
+ import { hasRepeatedKey } from './json-text.js';
8
+ import { mount } from './mount.js';
9
+ 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}`;
13
+ return {
14
+ program,
15
+ say: (line) => void process.stdout.write(`${line}\n`),
16
+ complain: (line) => void process.stderr.write(`${program}: ${line}\n`),
17
+ };
18
+ }
19
+ const SUBCOMMANDS = ['install', 'auth', 'mount', 'status'];
20
+ export async function connectorCli(commands, argv, env) {
21
+ const { complain, program } = cliOutput(commands.name);
22
+ const [command, ...rest] = argv;
23
+ if (command === undefined) {
24
+ const code = await commands.serve(env);
25
+ return typeof code === 'number' ? code : undefined;
26
+ }
27
+ const usage = `usage: ${program} [${SUBCOMMANDS.join(' | ')}] (no argument: the connector's MCP stdio server; auth reads ${commands.authReads} from standard input)`;
28
+ if (!SUBCOMMANDS.includes(command)) {
29
+ complain(`unknown subcommand\n${usage}`);
30
+ return 2;
31
+ }
32
+ if (rest.length > 0) {
33
+ complain(`${command} takes no arguments${command === 'auth' ? ` (it reads ${commands.authReads} from standard input, never from an argument)` : ''}\n${usage}`);
34
+ return 2;
35
+ }
36
+ const config = resolveConfigDir(env);
37
+ if (!config.ok) {
38
+ complain(config.reason);
39
+ return 2;
40
+ }
41
+ if (command === 'install')
42
+ return commands.install(config.dir);
43
+ if (command === 'auth')
44
+ return commands.auth(config.dir);
45
+ if (command === 'mount')
46
+ return mountCommand(commands.name, config.dir);
47
+ return statusCommand(commands.name, config.dir);
48
+ }
49
+ export async function readStdinLine(limit) {
50
+ if (process.stdin.isTTY)
51
+ return { ok: false, problem: 'standard input is a terminal here; pipe the input in instead (a token typed on a terminal would be shown)' };
52
+ const parts = [];
53
+ let size = 0;
54
+ for await (const chunk of process.stdin) {
55
+ const b = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
56
+ size += b.length;
57
+ if (size > limit)
58
+ return { ok: false, problem: `standard input holds more than ${limit} bytes; one line is read` };
59
+ parts.push(b);
60
+ }
61
+ const line = Buffer.concat(parts).toString('utf8').replace(/\r?\n$/, '');
62
+ if (/[\r\n]/.test(line))
63
+ return { ok: false, problem: 'standard input must hold exactly one line' };
64
+ return { ok: true, line };
65
+ }
66
+ 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) {
68
+ const read = await readStdinLine(limit);
69
+ if (!read.ok)
70
+ 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`;
73
+ let value;
74
+ try {
75
+ value = JSON.parse(read.line);
76
+ }
77
+ catch {
78
+ return { ok: false, problem: shape };
79
+ }
80
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
81
+ return { ok: false, problem: shape };
82
+ if (hasRepeatedKey(read.line))
83
+ return { ok: false, problem: 'the object names a key more than once' };
84
+ const object = value;
85
+ const own = Object.keys(object);
86
+ if (own.length !== keys.length || !keys.every((k) => own.includes(k)))
87
+ return { ok: false, problem: `the object must have ${exactly}` };
88
+ if (!keys.every((k) => typeof object[k] === 'string'))
89
+ return { ok: false, problem: shape };
90
+ return { ok: true, values: Object.fromEntries(keys.map((k) => [k, object[k]])) };
91
+ }
92
+ export function mountCommand(name, configDir) {
93
+ const { say, complain } = cliOutput(name);
94
+ let r;
95
+ try {
96
+ r = mount({ name, configDir });
97
+ }
98
+ catch (e) {
99
+ complain(`mount failed: ${e instanceof Error ? e.message : 'unknown error'}`);
100
+ return 1;
101
+ }
102
+ if (!r.ok) {
103
+ complain(`mount refused (${r.reason}): ${r.message}`);
104
+ return 1;
105
+ }
106
+ say(r.changed ? `mounted connector ${name} in ${r.file}; restart the engine to apply` : `connector ${name} is already mounted in ${r.file}`);
107
+ return 0;
108
+ }
109
+ const PROTOCOL_VERSION = '2025-11-25';
110
+ const STATUS_TIMEOUT_MS = 10_000;
111
+ const KILL_GRACE_MS = 2_000;
112
+ const SHOWN_LINES = 5;
113
+ const RECEIPT_PREFIX = 'sema-connector: receipt ';
114
+ function ownManifest() {
115
+ const url = new URL('../package.json', import.meta.url);
116
+ const manifest = JSON.parse(readFileSync(url, 'utf8'));
117
+ return { version: manifest.version, launcher: join(dirname(fileURLToPath(url)), manifest.bin['sema-connector'] ?? '') };
118
+ }
119
+ function lineReader(stream) {
120
+ const lines = [];
121
+ const waiters = [];
122
+ let rest = '';
123
+ let ended = false;
124
+ stream.setEncoding('utf8');
125
+ stream.on('data', (chunk) => {
126
+ rest += chunk;
127
+ let at;
128
+ while ((at = rest.indexOf('\n')) !== -1) {
129
+ const line = rest.slice(0, at);
130
+ rest = rest.slice(at + 1);
131
+ const waiter = waiters.shift();
132
+ if (waiter)
133
+ waiter(line);
134
+ else
135
+ lines.push(line);
136
+ }
137
+ });
138
+ stream.on('close', () => {
139
+ ended = true;
140
+ for (const waiter of waiters.splice(0))
141
+ waiter(null);
142
+ });
143
+ return () => {
144
+ const line = lines.shift();
145
+ if (line !== undefined)
146
+ return Promise.resolve(line);
147
+ if (ended)
148
+ return Promise.resolve(null);
149
+ return new Promise((resolve) => waiters.push(resolve));
150
+ };
151
+ }
152
+ export async function statusCommand(name, configDir) {
153
+ const { program, say, complain } = cliOutput(name);
154
+ const own = ownManifest();
155
+ const child = spawn(process.execPath, [own.launcher, 'run', name, '--mode', 'standalone', '--transport', 'stdio', '--config-dir', configDir], { stdio: ['pipe', 'pipe', 'pipe'] });
156
+ const exited = new Promise((resolve) => child.on('exit', (code) => resolve(code)));
157
+ const errors = [];
158
+ child.stderr.on('data', (d) => errors.push(d));
159
+ child.stdin.on('error', () => undefined);
160
+ const nextLine = lineReader(child.stdout);
161
+ let timedOut = false;
162
+ const timer = setTimeout(() => {
163
+ timedOut = true;
164
+ child.stdout.destroy();
165
+ child.stderr.destroy();
166
+ child.kill('SIGTERM');
167
+ setTimeout(() => child.kill('SIGKILL'), KILL_GRACE_MS).unref();
168
+ }, STATUS_TIMEOUT_MS);
169
+ const write = (message) => void child.stdin.write(JSON.stringify({ jsonrpc: '2.0', ...message }) + '\n');
170
+ const reply = async (id, method, params) => {
171
+ write({ id, method, ...(params === undefined ? {} : { params }) });
172
+ for (;;) {
173
+ const line = await nextLine();
174
+ if (line === null)
175
+ return null;
176
+ try {
177
+ const m = JSON.parse(line);
178
+ if (m?.id === id)
179
+ return m;
180
+ }
181
+ catch { }
182
+ }
183
+ };
184
+ let found = null;
185
+ let problem = null;
186
+ try {
187
+ const init = await reply(1, 'initialize', { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: program, version: own.version } });
188
+ if (init?.result === undefined)
189
+ problem = 'the launcher did not complete initialize';
190
+ else {
191
+ write({ method: 'notifications/initialized' });
192
+ const list = await reply(2, 'resources/list');
193
+ const entries = list?.result?.resources;
194
+ const uri = Array.isArray(entries) && entries.length === 1 ? entries[0]?.uri : undefined;
195
+ if (typeof uri !== 'string')
196
+ problem = 'the launcher did not list exactly one resource, its status';
197
+ else {
198
+ const read = await reply(3, 'resources/read', { uri });
199
+ const text = read?.result?.contents?.[0]?.text;
200
+ if (typeof text !== 'string')
201
+ problem = `the status could not be read${typeof read?.error?.message === 'string' ? `: ${read.error.message}` : ''}`;
202
+ else
203
+ found = JSON.parse(text);
204
+ }
205
+ }
206
+ }
207
+ catch {
208
+ problem = 'the launcher answered with something that is not JSON';
209
+ }
210
+ finally {
211
+ child.stdin.end();
212
+ }
213
+ const code = await exited;
214
+ clearTimeout(timer);
215
+ if (found !== null && !timedOut) {
216
+ say(JSON.stringify(found));
217
+ return 0;
218
+ }
219
+ complain(timedOut ? `the launcher did not answer within ${STATUS_TIMEOUT_MS} ms` : (problem ?? `the launcher exited with code ${String(code)}`));
220
+ const said = Buffer.concat(errors).toString('utf8').split('\n').filter((l) => l.trim() !== '' && !l.startsWith(RECEIPT_PREFIX));
221
+ for (const line of said.slice(-SHOWN_LINES))
222
+ complain(`the launcher said: ${line}`);
223
+ return 1;
224
+ }
package/dist/index.d.ts CHANGED
@@ -6,3 +6,11 @@ export { ASSERTION_HEADER, hostedResolver, standaloneResolver, verifyAssertion }
6
6
  export type { AnonymousPrincipal, AssertionPrincipal, AssertionStep, Principal, PrincipalResolver, PrincipalResult, RequestContext, VerifyOptions, VerifyResult, } from './principal.js';
7
7
  export { mount } from './mount.js';
8
8
  export type { MountOptions, MountRefusal, MountResult } from './mount.js';
9
+ export { DEFAULT_CONNECTORS, SEED_VERSION, seed } from './seed.js';
10
+ export type { DefaultConnector, SeedOptions, SeedReading, SeedRefusal, SeedResult } from './seed.js';
11
+ export { cliOutput, connectorCli, mountCommand, readStdinJson, readStdinLine, statusCommand } from './cli-kit.js';
12
+ export type { CliOutput, ConnectorCommands, StdinJson, StdinLine } from './cli-kit.js';
13
+ export { isLoopbackHttpOrHttps } from './jwks.js';
14
+ export { pinnedUpstream } from './pinned-upstream.js';
15
+ export type { PinnedUpstream, PinnedUpstreamOptions, UpstreamStart } from './pinned-upstream.js';
16
+ export { writeAll } from './write-all.js';
package/dist/index.js CHANGED
@@ -2,3 +2,8 @@ export { resolveConfigDir } from './config-dir.js';
2
2
  export { openFileSecrets } from './secrets.js';
3
3
  export { ASSERTION_HEADER, hostedResolver, standaloneResolver, verifyAssertion } from './principal.js';
4
4
  export { mount } from './mount.js';
5
+ export { DEFAULT_CONNECTORS, SEED_VERSION, seed } from './seed.js';
6
+ export { cliOutput, connectorCli, mountCommand, readStdinJson, readStdinLine, statusCommand } from './cli-kit.js';
7
+ export { isLoopbackHttpOrHttps } from './jwks.js';
8
+ export { pinnedUpstream } from './pinned-upstream.js';
9
+ export { writeAll } from './write-all.js';
package/dist/jwks.d.ts CHANGED
@@ -14,5 +14,6 @@ export type KeySet = {
14
14
  readonly ok: false;
15
15
  readonly problem: string;
16
16
  };
17
+ export declare function isLoopbackHttpOrHttps(url: URL): boolean;
17
18
  export declare function keySetSource(env: Env, issuer: string): KeySetSource | string;
18
19
  export declare function obtainKeySet(source: KeySetSource): Promise<KeySet>;
package/dist/jwks.js CHANGED
@@ -5,6 +5,9 @@ const INLINE = 'CONNECTOR_ASSERTION_JWKS';
5
5
  const OVERRIDE = 'CONNECTOR_ASSERTION_JWKS_URL';
6
6
  const WELL_KNOWN = '/.well-known/jwks.json';
7
7
  const LOOPBACK_HOSTS = ['127.0.0.1', 'localhost'];
8
+ export function isLoopbackHttpOrHttps(url) {
9
+ return url.protocol === 'https:' || (url.protocol === 'http:' && LOOPBACK_HOSTS.includes(url.hostname));
10
+ }
8
11
  const FETCH_LIMIT_BYTES = 1024 * 1024;
9
12
  const FETCH_TIMEOUT_MS = 10_000;
10
13
  export function keySetSource(env, issuer) {
@@ -24,7 +27,7 @@ export function keySetSource(env, issuer) {
24
27
  }
25
28
  if (url.username !== '' || url.password !== '')
26
29
  return `${from} carries credentials; a key set URL never does`;
27
- if (url.protocol === 'https:' || (url.protocol === 'http:' && LOOPBACK_HOSTS.includes(url.hostname)))
30
+ if (isLoopbackHttpOrHttps(url))
28
31
  return { kind: 'url', from, url };
29
32
  return `${from} must be an https URL, or an http URL on 127.0.0.1 or localhost`;
30
33
  }