@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 +97 -0
- package/INTEGRATION.md +68 -0
- package/dist/cli-kit.d.ts +1 -0
- package/dist/cli-kit.js +14 -26
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/launcher.js +118 -67
- package/dist/mount.d.ts +4 -0
- package/dist/mount.js +13 -11
- package/dist/own-package.d.ts +4 -0
- package/dist/own-package.js +8 -0
- package/dist/pinned-upstream.d.ts +21 -0
- package/dist/pinned-upstream.js +347 -0
- package/dist/release-archive.d.ts +8 -0
- package/dist/release-archive.js +96 -0
- package/dist/resolve-connector.d.ts +8 -0
- package/dist/resolve-connector.js +28 -0
- package/dist/seed.d.ts +35 -0
- package/dist/seed.js +134 -0
- package/dist/session.d.ts +6 -0
- package/dist/session.js +31 -1
- package/dist/status.d.ts +5 -1
- package/dist/status.js +5 -2
- package/dist/write-all.d.ts +1 -0
- package/dist/write-all.js +4 -0
- package/package.json +3 -2
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
|
|
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
|
|
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:
|
|
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
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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 {
|
|
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 {
|
|
16
|
-
import {
|
|
17
|
-
|
|
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
|
|
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 =
|
|
39
|
-
const flag =
|
|
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
|
|
390
|
-
|
|
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
|
-
|
|
393
|
+
toConnector(a.data);
|
|
411
394
|
}
|
|
412
395
|
};
|
|
413
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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: {
|