@sema-agent/connectors 0.2.0 → 0.4.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,103 @@ 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.4.0 (2026-10-15)
8
+
9
+ ### Added
10
+
11
+ - `INTEGRATION.md` ships with the package: how a command-line tool mounts a
12
+ connector of the default catalog through `mount`, `mountCommand` and `seed`,
13
+ what it reads back, and what the engine then sees.
14
+ - The parked session (CN-046): in standalone mode the launcher never starts a
15
+ connector whose status would be `unauthorized` (no secret handle, the
16
+ status's own judgement); it answers the session itself, through the same
17
+ session and stages as a connector run. `initialize` is answered with the
18
+ requested protocol version, the tools and resources capabilities and
19
+ `serverInfo` `sema-connector` at this package's version; `tools/list` lists
20
+ no tools, `resources/list` the status entry alone, the status reads
21
+ `unauthorized` with no tools, and `ping` is answered. A tool call is refused
22
+ with `-32603`, another resource read with `-32602` and any other method with
23
+ `-32601`, each with `connector.auth_required` and the message
24
+ `connector <name> has no credentials; run its auth`; the refused call's
25
+ receipt is `refused` with `connector.auth_required`. The session ends with
26
+ exit 0 at the end of input or on a stop signal. So
27
+ `sema-connector-<name> status` before `auth` prints the `unauthorized`
28
+ status and exits 0, and an upstream program that would begin a login of its
29
+ own without a token never starts.
30
+
31
+ ### Changed
32
+
33
+ - A standalone launcher starts a connector only when it has a secret handle
34
+ (CN-046). Before, it started every connector, and one that refuses to start
35
+ without its handles (as the Gitea and GitHub connectors do) ended the run
36
+ with exit 5 and `connector.internal`. A connector that needs no credential
37
+ at all is parked as well until a secret handle is stored for it. A connector
38
+ whose secret handle has expired, and every hosted connector, is started as
39
+ before.
40
+
41
+ ### Fixed
42
+
43
+ - `sema-connector-<name> status` (CN-049) no longer discovers the status
44
+ resource's URI through `resources/list` and then insists on exactly one
45
+ entry there; it reads the status resource straight by its URI (the one
46
+ `statusUri` makes). Before, a started connector that lists resources of its
47
+ own made `resources/list` answer with more than the status entry the
48
+ launcher adds, so `status` always failed with "the launcher did not list
49
+ exactly one resource, its status" once authorized, even though the launcher
50
+ itself serves the status resource correctly. This affected the GitHub
51
+ connector once authorized (github-mcp-server lists 4 `ui://` resources of
52
+ its own); the Gitea connector, which lists none, was not affected.
53
+
54
+ ## 0.3.0 (2026-10-15)
55
+
56
+ ### Added
57
+
58
+ - The default catalog seed (CN-009): `sema-connector seed --config-dir
59
+ <absolute path>`, the launcher's second command, and `seed({ configDir })`
60
+ mount every installed connector of the default catalog into
61
+ `config.d/mcp.json`. The catalog is `DEFAULT_CONNECTORS` (today `gitea` and
62
+ `github`, CN-044),
63
+ the one list of default connectors; `SEED_VERSION` is the newest version
64
+ that added a name. Each connector is resolved with the launcher's own rule
65
+ first (one that is not installed gets no entry) and mounted only through
66
+ `mount`, so another entry under its name is never overwritten.
67
+ `config.d/.sema-connector-seed.json` records what each name came to
68
+ (mounted, present, not installed): a name recorded as mounted or present is
69
+ never mounted again, so an entry a person removed stays removed; a conflict
70
+ or a refusal is not recorded and is tried again. Seeds on one directory
71
+ exclude each other with `config.d/.sema-connector-seed.json.lock` (a second
72
+ seed refuses as `locked`). One line per connector on stdout; exit 0 when
73
+ every connector is mounted, present or not installed, 1 otherwise. A marker
74
+ the seed cannot read refuses it before any mount; a marker write that fails
75
+ leaves the old marker as it was and the mounted entries in place.
76
+ - `pinnedUpstream({ connector, upstream, lockFile, handles })` (CN-044): what
77
+ a connector that runs a third-party MCP server takes from the shared layer,
78
+ so that no connector package carries its own copy. `install(configDir)` is
79
+ the install subcommand: this platform's release archive from the source the
80
+ package's `binaries.lock.json` gives (`https`, redirects followed only to
81
+ `https`, or `file` for a local copy of the releases), its sha256 compared
82
+ with the lock before anything is written, then the archive and its
83
+ `upstream` entry kept in `<configuration directory>/connectors/<name>/bin/`
84
+ (directory 0700, program 0755), each file written whole before it replaces
85
+ the old one; nothing changes when the pinned build is there. `start(env)`
86
+ checks `CONNECTOR_DIR`, one `<NAME>_FILE` per handle naming a regular file
87
+ (not a symbolic link) inside it, and the pinned build in the bin directory
88
+ (the same check as install's), and refuses with a sentence naming what to
89
+ run; its `run(args, extra)` starts the program as the connector's child on
90
+ the same standard input, output and error, with the connector's environment
91
+ minus `CONNECTOR_DIR` and the handle paths plus `extra`, passes `SIGTERM`,
92
+ `SIGINT` and `SIGHUP` on, and ends as the program ends. The Gitea connector
93
+ is its first user (moved from its own package) and the GitHub connector its
94
+ second.
95
+
96
+ ### Changed
97
+
98
+ - A launcher command line that starts with neither `run` nor `seed` is
99
+ refused with `expected the command "run" or "seed"` (exit 2, as before).
100
+ - `readStdinJson` with one key names it as "the key" in its refusals
101
+ (`the object must have exactly the key token`); with two or more keys the
102
+ wording is as before.
103
+
7
104
  ## 0.2.0 (2026-10-15)
8
105
 
9
106
  ### Added
package/INTEGRATION.md ADDED
@@ -0,0 +1,68 @@
1
+ # Integrating `@sema-agent/connectors` from a command-line tool
2
+
3
+ How a tool outside this repository (the CLI's `connectors add`, for one) mounts a connector of the default
4
+ catalog into the engine's local configuration and reads what happened, using this package only. The rules
5
+ live in the repository's `docs/ARCHITECTURE.md` ("Mounting", "Seeding the default catalog", "Parked
6
+ session"); this page states the surface a tool consumes and does not restate the rules.
7
+
8
+ ## The configuration directory
9
+
10
+ One configuration directory per engine installation, the engine's local one: `CONFIG_LOCAL_DIR`, an
11
+ absolute path. `resolveConfigDir(process.env)` turns the variable into `{ ok: true, dir }` or
12
+ `{ ok: false, reason }` (not set, empty, or not absolute; the reason names the variable, never the value).
13
+ It is the same root `sema-connector seed --config-dir <dir>` seeds and the same root every connector CLI
14
+ (`sema-connector-<name> auth | mount | status`) reads from `CONFIG_LOCAL_DIR`. Entries land in
15
+ `<dir>/config.d/mcp.json`; a connector's secrets live in `<dir>/connectors/<name>/`.
16
+
17
+ ## Mounting one connector
18
+
19
+ ```ts
20
+ import { mount, mountCommand, resolveConfigDir, DEFAULT_CONNECTORS, SEED_VERSION, seed } from '@sema-agent/connectors'
21
+
22
+ const result = mount({ name: 'gitea', configDir: dir })
23
+ ```
24
+
25
+ - `mount(options: { name: string; configDir: string }): MountResult`. There is no `entry` argument: the entry
26
+ is fixed by the package and written as
27
+ `{"name": "<name>", "transport": {"kind": "stdio", "command": "sema-connector", "args": ["run", "<name>",
28
+ "--mode", "standalone", "--transport", "stdio", "--config-dir", "<configDir>"]}}`, never with environment
29
+ references or headers. A tool never composes an entry of its own.
30
+ - `MountResult` is `{ ok: true, changed: boolean, file: string }` (`changed: false` when the same entry was
31
+ already there, byte for byte apart from key order; `file` is the catalog file written) or
32
+ `{ ok: false, reason: MountRefusal, message: string }` with `reason` one of the closed set
33
+ `config_dir | invalid_name | reserved_name | unreadable | unexpected_shape | conflict | locked |
34
+ changed_during_write | write_failed`. `mount` does not throw for a refusal.
35
+ - Idempotence: a second `mount` of the same name changes nothing. Another entry under the same name (other
36
+ arguments, another `--config-dir`, `enabled: false`, environment references) is `conflict` and is never
37
+ overwritten; the tool reports it and leaves the person to remove the entry. `mount` keeps no marker of
38
+ its own.
39
+ - `mountCommand(name, configDir): number` is the same call with the CLI's output: on success one line on
40
+ standard output (`mounted connector <name> in <file>; restart the engine to apply`, or
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.
43
+ - `mount` does not check that the connector package is installed: an entry for a connector that is not
44
+ installed makes the launcher exit 4 (`connector.not_installed`) when the engine starts it. A tool that
45
+ wants only installed connectors mounted uses the seed.
46
+
47
+ ## The default catalog and the seed
48
+
49
+ - `DEFAULT_CONNECTORS: readonly { name: string; since: number }[]`: the one list of default connectors, in
50
+ order; `name` is the connector (the package is `@sema-agent/connector-<name>`), `since` the seed version
51
+ that added it. `SEED_VERSION` is the newest `since`.
52
+ - `seed({ configDir }): SeedResult` mounts every installed connector of the default catalog through `mount`,
53
+ resolving each with the launcher's own rule first, and remembers the outcome per name in
54
+ `<dir>/config.d/.sema-connector-seed.json`, so an entry a person removed is never brought back. Readings per
55
+ name: `mounted | present | conflict | not_installed | refused`; the whole seed is refused as `config_dir |
56
+ locked | unreadable | unexpected_shape | write_failed`. `sema-connector seed --config-dir <dir>` is the same
57
+ call as a command (exit 0 when every connector is mounted, present or not installed, 1 otherwise, 2 for a bad
58
+ command line). A tool that offers "mount the defaults" runs the seed; a tool that offers "mount this one"
59
+ calls `mount`.
60
+
61
+ ## What the engine sees
62
+
63
+ Entries are read by the engine from `config.d/mcp.json` through the server's local file configuration
64
+ (the same path that reads every other stdio entry there); an entry `mount` wrote is read at the engine's next
65
+ start, and the launcher it names reads the connector's secrets under the same directory. Before `auth` has
66
+ stored the connector's credential the launcher parks the session: the connector lists no tools, its status
67
+ reads `unauthorized`, and a tool call is refused with `connector.auth_required`; after `auth`, the next start
68
+ runs the connector.
package/dist/cli-kit.d.ts CHANGED
@@ -28,6 +28,7 @@ export type StdinJson = {
28
28
  readonly ok: false;
29
29
  readonly problem: string;
30
30
  };
31
+ export declare const listed: (keys: readonly string[]) => string;
31
32
  export declare function readStdinJson(limit: number, keys: readonly string[]): Promise<StdinJson>;
32
33
  export declare function mountCommand(name: string, configDir: string): number;
33
34
  export declare function statusCommand(name: string, configDir: string): Promise<number>;
package/dist/cli-kit.js CHANGED
@@ -1,11 +1,11 @@
1
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
2
  import { resolveConfigDir } from './config-dir.js';
6
3
  import { CONNECTOR_NAME } from './connector-name.js';
7
4
  import { hasRepeatedKey } from './json-text.js';
8
5
  import { mount } from './mount.js';
6
+ import { ownManifest } from './own-package.js';
7
+ import { MCP_PROTOCOL_VERSION } from './session.js';
8
+ import { statusUri } from './status.js';
9
9
  export function cliOutput(name) {
10
10
  if (typeof name !== 'string' || !CONNECTOR_NAME.test(name))
11
11
  throw new Error('a connector name is lowercase letters and digits in hyphen-separated words');
@@ -63,12 +63,13 @@ export async function readStdinLine(limit) {
63
63
  return { ok: false, problem: 'standard input must hold exactly one line' };
64
64
  return { ok: true, line };
65
65
  }
66
- const listed = (keys) => (keys.length < 2 ? keys.join('') : `${keys.slice(0, -1).join(', ')} and ${keys[keys.length - 1]}`);
66
+ export const listed = (keys) => (keys.length < 2 ? keys.join('') : `${keys.slice(0, -1).join(', ')} and ${keys[keys.length - 1]}`);
67
67
  export async function readStdinJson(limit, keys) {
68
68
  const read = await readStdinLine(limit);
69
69
  if (!read.ok)
70
70
  return read;
71
- const shape = `standard input must be one JSON object with exactly the keys ${listed(keys)}, each a string, on one line`;
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`;
72
73
  let value;
73
74
  try {
74
75
  value = JSON.parse(read.line);
@@ -83,7 +84,7 @@ export async function readStdinJson(limit, keys) {
83
84
  const object = value;
84
85
  const own = Object.keys(object);
85
86
  if (own.length !== keys.length || !keys.every((k) => own.includes(k)))
86
- return { ok: false, problem: `the object must have exactly the keys ${listed(keys)}` };
87
+ return { ok: false, problem: `the object must have ${exactly}` };
87
88
  if (!keys.every((k) => typeof object[k] === 'string'))
88
89
  return { ok: false, problem: shape };
89
90
  return { ok: true, values: Object.fromEntries(keys.map((k) => [k, object[k]])) };
@@ -105,16 +106,10 @@ export function mountCommand(name, configDir) {
105
106
  say(r.changed ? `mounted connector ${name} in ${r.file}; restart the engine to apply` : `connector ${name} is already mounted in ${r.file}`);
106
107
  return 0;
107
108
  }
108
- const PROTOCOL_VERSION = '2025-11-25';
109
109
  const STATUS_TIMEOUT_MS = 10_000;
110
110
  const KILL_GRACE_MS = 2_000;
111
111
  const SHOWN_LINES = 5;
112
112
  const RECEIPT_PREFIX = 'sema-connector: receipt ';
113
- function ownManifest() {
114
- const url = new URL('../package.json', import.meta.url);
115
- const manifest = JSON.parse(readFileSync(url, 'utf8'));
116
- return { version: manifest.version, launcher: join(dirname(fileURLToPath(url)), manifest.bin['sema-connector'] ?? '') };
117
- }
118
113
  function lineReader(stream) {
119
114
  const lines = [];
120
115
  const waiters = [];
@@ -183,24 +178,17 @@ export async function statusCommand(name, configDir) {
183
178
  let found = null;
184
179
  let problem = null;
185
180
  try {
186
- const init = await reply(1, 'initialize', { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: program, version: own.version } });
181
+ const init = await reply(1, 'initialize', { protocolVersion: MCP_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: program, version: own.version } });
187
182
  if (init?.result === undefined)
188
183
  problem = 'the launcher did not complete initialize';
189
184
  else {
190
185
  write({ method: 'notifications/initialized' });
191
- const list = await reply(2, 'resources/list');
192
- const entries = list?.result?.resources;
193
- const uri = Array.isArray(entries) && entries.length === 1 ? entries[0]?.uri : undefined;
194
- if (typeof uri !== 'string')
195
- problem = 'the launcher did not list exactly one resource, its status';
196
- else {
197
- const read = await reply(3, 'resources/read', { uri });
198
- const text = read?.result?.contents?.[0]?.text;
199
- if (typeof text !== 'string')
200
- problem = `the status could not be read${typeof read?.error?.message === 'string' ? `: ${read.error.message}` : ''}`;
201
- else
202
- found = JSON.parse(text);
203
- }
186
+ const read = await reply(2, 'resources/read', { uri: statusUri(name) });
187
+ const text = read?.result?.contents?.[0]?.text;
188
+ if (typeof text !== 'string')
189
+ problem = `the status could not be read${typeof read?.error?.message === 'string' ? `: ${read.error.message}` : ''}`;
190
+ else
191
+ found = JSON.parse(text);
204
192
  }
205
193
  }
206
194
  catch {
package/dist/index.d.ts CHANGED
@@ -6,7 +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';
9
11
  export { cliOutput, connectorCli, mountCommand, readStdinJson, readStdinLine, statusCommand } from './cli-kit.js';
10
12
  export type { CliOutput, ConnectorCommands, StdinJson, StdinLine } from './cli-kit.js';
11
13
  export { isLoopbackHttpOrHttps } from './jwks.js';
14
+ export { pinnedUpstream } from './pinned-upstream.js';
15
+ export type { PinnedUpstream, PinnedUpstreamOptions, UpstreamStart } from './pinned-upstream.js';
12
16
  export { writeAll } from './write-all.js';
package/dist/index.js CHANGED
@@ -2,6 +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';
5
6
  export { cliOutput, connectorCli, mountCommand, readStdinJson, readStdinLine, statusCommand } from './cli-kit.js';
6
7
  export { isLoopbackHttpOrHttps } from './jwks.js';
8
+ export { pinnedUpstream } from './pinned-upstream.js';
7
9
  export { writeAll } from './write-all.js';
package/dist/launcher.js CHANGED
@@ -1,20 +1,22 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
- import { readFileSync } from 'node:fs';
4
- import { createRequire } from 'node:module';
5
3
  import { isIPv4, isIPv6 } from 'node:net';
6
4
  import { tmpdir } from 'node:os';
7
- import { dirname, join } from 'node:path';
5
+ import { join } from 'node:path';
8
6
  import { absoluteDir, given } from './config-dir.js';
9
7
  import { CONNECTOR_NAME } from './connector-name.js';
10
8
  import { serveHttp } from './http.js';
11
9
  import { keySetSource, obtainKeySet } from './jwks.js';
10
+ import { ownManifest } from './own-package.js';
12
11
  import { hostedResolver, standaloneResolver } from './principal.js';
13
12
  import { Scrubber, secretMatcher } from './redact.js';
13
+ import { resolveConnector } from './resolve-connector.js';
14
14
  import { NO_HANDLES, copyForLaunch, handleDirectory } from './secrets.js';
15
- import { INSPECT_LIMIT_BYTES, Session } from './session.js';
16
- import { mintStatus } from './status.js';
17
- const EXIT = { ok: 0, guard: 2, notInstalled: 4, connectorFailed: 5 };
15
+ import { seed } from './seed.js';
16
+ import { INSPECT_LIMIT_BYTES, Session, parkedAnswer } from './session.js';
17
+ import { mintState, mintStatus } from './status.js';
18
+ import { writeFailure } from './write-all.js';
19
+ const EXIT = { ok: 0, notSeeded: 1, guard: 2, notInstalled: 4, connectorFailed: 5 };
18
20
  const NOT_INSTALLED = 'connector.not_installed';
19
21
  const INTERNAL = 'connector.internal';
20
22
  const STOP_SIGNALS = ['SIGTERM', 'SIGINT', 'SIGHUP'];
@@ -22,21 +24,15 @@ const TEARDOWN_STEP_MS = 1000;
22
24
  const STALL_STEPS = 10;
23
25
  const STALL_MS = STALL_STEPS * TEARDOWN_STEP_MS;
24
26
  const PIECE_BYTES = 1024;
25
- const USAGE = 'usage: sema-connector run <name> --mode standalone|hosted --transport stdio|http [--listen <ip>:<port>] [--config-dir <absolute path>]';
27
+ const USAGE = 'usage: sema-connector run <name> --mode standalone|hosted --transport stdio|http [--listen <ip>:<port>] [--config-dir <absolute path>]\n sema-connector seed --config-dir <absolute path>';
26
28
  const FLAGS = ['--mode', '--transport', '--listen', '--config-dir'];
29
+ const SEED_FLAGS = ['--config-dir'];
27
30
  const CHILD_ENV = ['HOME', 'LOGNAME', 'USER', 'PATH', 'SHELL', 'TERM', 'LANG', 'LC_ALL', 'TZ'];
28
31
  const CONNECTOR_DIR = 'CONNECTOR_DIR';
29
- function guard(argv, env) {
30
- if (argv[0] !== 'run')
31
- return 'expected the command "run"';
32
- const name = argv[1];
33
- if (name === undefined || name.startsWith('-'))
34
- return 'missing the connector <name> after "run"';
35
- if (!CONNECTOR_NAME.test(name))
36
- return '<name> must be lowercase letters and digits in hyphen-separated words (it names the package @sema-agent/connector-<name>)';
32
+ function flagValues(argv, from, allowed) {
37
33
  const values = new Map();
38
- for (let i = 2; i < argv.length; i += 2) {
39
- const flag = FLAGS.find((f) => f === argv[i]);
34
+ for (let i = from; i < argv.length; i += 2) {
35
+ const flag = allowed.find((f) => f === argv[i]);
40
36
  if (flag === undefined)
41
37
  return `unknown argument at position ${i + 1}`;
42
38
  if (values.has(flag))
@@ -46,6 +42,31 @@ function guard(argv, env) {
46
42
  return `${flag} needs a value`;
47
43
  values.set(flag, value);
48
44
  }
45
+ return values;
46
+ }
47
+ function guard(argv, env) {
48
+ if (argv[0] === 'seed') {
49
+ const values = flagValues(argv, 1, SEED_FLAGS);
50
+ if (typeof values === 'string')
51
+ return values;
52
+ const configDirArgument = values.get('--config-dir');
53
+ if (configDirArgument === undefined)
54
+ return 'seed requires --config-dir <absolute path>, the configuration directory it seeds (there is no default)';
55
+ const configDir = absoluteDir(configDirArgument);
56
+ if (configDir === null)
57
+ return '--config-dir must be an absolute path';
58
+ return { command: 'seed', configDir };
59
+ }
60
+ if (argv[0] !== 'run')
61
+ return 'expected the command "run" or "seed"';
62
+ const name = argv[1];
63
+ if (name === undefined || name.startsWith('-'))
64
+ return 'missing the connector <name> after "run"';
65
+ if (!CONNECTOR_NAME.test(name))
66
+ return '<name> must be lowercase letters and digits in hyphen-separated words (it names the package @sema-agent/connector-<name>)';
67
+ const values = flagValues(argv, 2, FLAGS);
68
+ if (typeof values === 'string')
69
+ return values;
49
70
  const mode = values.get('--mode');
50
71
  const transport = values.get('--transport');
51
72
  const listen = values.get('--listen');
@@ -68,7 +89,7 @@ function guard(argv, env) {
68
89
  const configDir = absoluteDir(configDirArgument);
69
90
  if (configDir === null)
70
91
  return '--config-dir must be an absolute path';
71
- return { name, mode, configDir };
92
+ return { command: 'run', name, mode, configDir };
72
93
  }
73
94
  if (transport !== 'http')
74
95
  return 'hosted mode requires --transport http';
@@ -88,7 +109,7 @@ function guard(argv, env) {
88
109
  const keySet = keySetSource(env, issuer);
89
110
  if (typeof keySet === 'string')
90
111
  return keySet;
91
- return { name, mode, listen: address, issuer, audience, keySet };
112
+ return { command: 'run', name, mode, listen: address, issuer, audience, keySet };
92
113
  }
93
114
  function listenAddress(value) {
94
115
  const m = /^(?:\[([^\]]+)\]|([^:[\]]+)):([1-9][0-9]{0,4})$/.exec(value);
@@ -99,31 +120,6 @@ function listenAddress(value) {
99
120
  const port = Number(m[3]);
100
121
  return hostOk && port <= 65535 ? { host, port } : null;
101
122
  }
102
- function resolveConnector(name) {
103
- const packageName = `@sema-agent/connector-${name}`;
104
- let manifestPath;
105
- try {
106
- manifestPath = createRequire(new URL('../package.json', import.meta.url)).resolve(`${packageName}/package.json`);
107
- }
108
- catch {
109
- return { packageName, reason: 'is not installed where the launcher can resolve it' };
110
- }
111
- let bin;
112
- try {
113
- bin = JSON.parse(readFileSync(manifestPath, 'utf8')).bin;
114
- }
115
- catch {
116
- return { packageName, reason: 'has a package.json that cannot be read' };
117
- }
118
- let rel;
119
- if (typeof bin === 'string')
120
- rel = bin;
121
- else if (bin !== null && typeof bin === 'object' && !Array.isArray(bin) && Object.keys(bin).length === 1)
122
- rel = Object.values(bin)[0];
123
- if (typeof rel !== 'string' || rel === '')
124
- return { packageName, reason: 'does not declare exactly one bin' };
125
- return { packageName, binPath: join(dirname(manifestPath), rel) };
126
- }
127
123
  class Out {
128
124
  stream;
129
125
  held = [];
@@ -386,31 +382,20 @@ function childEnvironment(env, connectorDir, secrets) {
386
382
  const hostedConnectorDir = (name) => join(tmpdir(), `sema-connector-${randomUUID()}`, 'connectors', name);
387
383
  const describe = (o) => `connector process ${o.stalled ? `stopped reading its input (stalled input: none of it taken for ${STALL_MS / 1000} s) and ` : ''}${o.error ? `could not be started (${o.error.message})` : o.signal !== null ? `was terminated by signal ${o.signal}` : `exited with code ${String(o.code)}`}`;
388
384
  const abnormal = (o) => o.code !== 0 || o.stalled === true;
389
- function startConnector(binPath, childEnv, session, scrubber, toUpstream, upstreamOut, log, stderrAfterExitMs) {
390
- const child = spawn(process.execPath, [binPath], { stdio: ['pipe', 'pipe', 'pipe'], detached: true, env: childEnv });
391
- const toGroup = (signal) => {
392
- if (child.pid === undefined)
393
- return false;
394
- try {
395
- process.kill(-child.pid, signal);
396
- return true;
397
- }
398
- catch {
399
- return false;
400
- }
401
- };
402
- const toChild = new Out(child.stdin);
403
- const perform = (actions) => {
385
+ function performer(log, toUpstream, toConnector) {
386
+ return (actions) => {
404
387
  for (const a of actions) {
405
388
  if (a.to === 'receipt')
406
389
  log.line(`${RECEIPT_PREFIX}${asciiJson(a.receipt)}\n`);
407
390
  else if (a.to === 'upstream')
408
391
  toUpstream(a.data);
409
392
  else
410
- toChild.write(a.data);
393
+ toConnector(a.data);
411
394
  }
412
395
  };
413
- const judged = (judge, unreadable) => {
396
+ }
397
+ function judging(perform) {
398
+ return (judge, unreadable) => {
414
399
  let actions;
415
400
  try {
416
401
  actions = judge();
@@ -420,6 +405,23 @@ function startConnector(binPath, childEnv, session, scrubber, toUpstream, upstre
420
405
  }
421
406
  perform(actions);
422
407
  };
408
+ }
409
+ function startConnector(binPath, childEnv, session, scrubber, toUpstream, upstreamOut, log, stderrAfterExitMs) {
410
+ const child = spawn(process.execPath, [binPath], { stdio: ['pipe', 'pipe', 'pipe'], detached: true, env: childEnv });
411
+ const toGroup = (signal) => {
412
+ if (child.pid === undefined)
413
+ return false;
414
+ try {
415
+ process.kill(-child.pid, signal);
416
+ return true;
417
+ }
418
+ catch {
419
+ return false;
420
+ }
421
+ };
422
+ const toChild = new Out(child.stdin);
423
+ const perform = performer(log, toUpstream, (data) => toChild.write(data));
424
+ const judged = judging(perform);
423
425
  let rung = 0;
424
426
  let stopped = false;
425
427
  const climb = (to, signal = 'SIGTERM') => {
@@ -463,7 +465,7 @@ function startConnector(binPath, childEnv, session, scrubber, toUpstream, upstre
463
465
  return ended;
464
466
  })();
465
467
  return {
466
- toChild,
468
+ input: toChild,
467
469
  perform,
468
470
  fromUpstream: (line, principal) => judged(() => session.fromUpstream(line, principal), () => session.upstreamOversized()),
469
471
  endInput: () => climb(1),
@@ -477,13 +479,37 @@ function startConnector(binPath, childEnv, session, scrubber, toUpstream, upstre
477
479
  outcome,
478
480
  };
479
481
  }
480
- async function runStdio(packageName, binPath, childEnv, session, scrubber, stdout, log) {
482
+ function parkedRun(session, connector, stdout, log) {
483
+ const serverInfo = { name: 'sema-connector', version: ownManifest().version };
484
+ let end = () => undefined;
485
+ const ended = new Promise((resolve) => (end = () => resolve({ code: 0, signal: null })));
486
+ const perform = performer(log, (data) => stdout.write(data), (data) => {
487
+ const answer = parkedAnswer(data, connector, serverInfo);
488
+ if (answer !== null)
489
+ judged(() => session.fromConnector(Buffer.from(JSON.stringify(answer), 'utf8')), () => session.connectorOversized());
490
+ });
491
+ const judged = judging(perform);
492
+ return {
493
+ input: stdout,
494
+ perform,
495
+ fromUpstream: (line, principal) => judged(() => session.fromUpstream(line, principal), () => session.upstreamOversized()),
496
+ endInput: end,
497
+ stop: () => {
498
+ end();
499
+ setTimeout(abandon, TEARDOWN_STEP_MS).unref();
500
+ },
501
+ outcome: ended.then((outcome) => {
502
+ perform(session.connectorGone('the launcher ended the parked session', false));
503
+ return outcome;
504
+ }),
505
+ };
506
+ }
507
+ async function runStdio(packageName, run, session, stdout, log) {
481
508
  const resolution = standaloneResolver().resolve({ headers: {} });
482
509
  if (!resolution.ok)
483
510
  throw new Error('the standalone principal cannot be refused');
484
511
  const anonymous = resolution.principal;
485
- const run = startConnector(binPath, childEnv, session, scrubber, (data) => stdout.write(data), stdout, log);
486
- const upstream = pump(process.stdin, run.toChild, { line: (line) => run.fromUpstream(line, anonymous), oversized: () => run.perform(session.upstreamOversized()) }, [stdout]);
512
+ const upstream = pump(process.stdin, run.input, { line: (line) => run.fromUpstream(line, anonymous), oversized: () => run.perform(session.upstreamOversized()) }, [stdout]);
487
513
  void upstream.then(() => run.endInput());
488
514
  for (const s of STOP_SIGNALS)
489
515
  process.on(s, (signal) => run.stop(signal));
@@ -505,7 +531,7 @@ async function runHosted(plan, keys, packageName, binPath, env, log) {
505
531
  const run = startConnector(binPath, childEnvironment(env, hostedConnectorDir(plan.name), NO_HANDLES), session, new Scrubber(NO_HANDLES.values), (data) => deliver(String(data)), null, log, TEARDOWN_STEP_MS);
506
532
  return {
507
533
  send: (raw, principal) => run.fromUpstream(raw, principal),
508
- busy: () => run.toChild.behind(),
534
+ busy: () => run.input.behind(),
509
535
  readStatus: (id, principal) => run.perform(session.statusFor(id, principal)),
510
536
  end: (signal) => (signal === undefined ? run.endInput() : run.stop(signal)),
511
537
  gone: run.outcome.then((o) => {
@@ -527,6 +553,24 @@ async function runHosted(plan, keys, packageName, binPath, env, log) {
527
553
  setTimeout(abandon, TEARDOWN_STEP_MS);
528
554
  return EXIT.ok;
529
555
  }
556
+ const readingLine = (r) => `seed: ${r.name} ${r.outcome}${r.outcome === 'not_installed' ? ` (${r.message})` : r.outcome === 'refused' ? ` (${r.reason}: ${r.message})` : ''}\n`;
557
+ function runSeed(configDir, stdout, log) {
558
+ let result;
559
+ try {
560
+ result = seed({ configDir });
561
+ }
562
+ catch (e) {
563
+ log.line(`sema-connector: seed: failed (${writeFailure(e)}); the marker was not written\n`);
564
+ return EXIT.notSeeded;
565
+ }
566
+ for (const r of result.readings)
567
+ stdout.write(readingLine(r));
568
+ if (!result.ok) {
569
+ log.line(`sema-connector: seed: ${result.reason}: ${result.message}\n`);
570
+ return EXIT.notSeeded;
571
+ }
572
+ return result.readings.every((r) => r.outcome === 'mounted' || r.outcome === 'present' || r.outcome === 'not_installed') ? EXIT.ok : EXIT.notSeeded;
573
+ }
530
574
  export async function main(argv, env) {
531
575
  const stdout = new Out(process.stdout);
532
576
  const log = new Log(new Out(process.stderr));
@@ -539,6 +583,8 @@ export async function main(argv, env) {
539
583
  log.line(`sema-connector: ${plan}\n${USAGE}\n`);
540
584
  return finish(EXIT.guard);
541
585
  }
586
+ if (plan.command === 'seed')
587
+ return finish(runSeed(plan.configDir, stdout, log));
542
588
  if (plan.mode === 'hosted') {
543
589
  const keys = await obtainKeySet(plan.keySet);
544
590
  if (!keys.ok) {
@@ -570,14 +616,19 @@ export async function main(argv, env) {
570
616
  log.line(`sema-connector: ${NOT_INSTALLED}: package ${resolved.packageName} ${resolved.reason}\n`);
571
617
  return finish(EXIT.notInstalled);
572
618
  }
619
+ const parked = mintState({ handles: secrets.handles, nowMs: Date.now() }) === 'unauthorized';
573
620
  const session = new Session({
574
621
  connector: plan.name,
575
622
  status: (tools) => mintStatus({ handles: secrets.handles, nowMs: Date.now(), tools }),
576
623
  ownIdPrefix: `sema-connector/${randomUUID()}`,
577
624
  withheld: secretMatcher(secrets.values),
625
+ ...(parked ? { parked: true } : {}),
578
626
  });
579
627
  try {
580
- return finish(await runStdio(resolved.packageName, resolved.binPath, childEnvironment(env, handleDirectory(plan.configDir, plan.name), secrets), session, new Scrubber(secrets.values), stdout, log));
628
+ const run = parked
629
+ ? parkedRun(session, plan.name, stdout, log)
630
+ : startConnector(resolved.binPath, childEnvironment(env, handleDirectory(plan.configDir, plan.name), secrets), session, new Scrubber(secrets.values), (data) => stdout.write(data), stdout, log);
631
+ return finish(await runStdio(resolved.packageName, run, session, stdout, log));
581
632
  }
582
633
  finally {
583
634
  secrets.remove();
package/dist/mount.d.ts CHANGED
@@ -12,6 +12,10 @@ export type MountResult = {
12
12
  readonly reason: MountRefusal;
13
13
  readonly message: string;
14
14
  };
15
+ export declare function nameRefusal(name: unknown): {
16
+ readonly reason: 'invalid_name' | 'reserved_name';
17
+ readonly message: string;
18
+ } | null;
15
19
  export declare function standaloneEntry(name: string, configDir: string): {
16
20
  name: string;
17
21
  transport: {