@sema-agent/connectors 0.5.0 → 0.7.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,81 @@ 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.7.0 (2026-10-16)
8
+
9
+ ### Added
10
+
11
+ - A standalone local per-tool policy (CN-054): a hand-written `connectors/<name>/tools.json`,
12
+ `{"allow": ["<tool name>", …]}` (exactly one key, a list of non-empty tool names with no whitespace, no control
13
+ character, no repeat and never the literal `*`, the scope judge's own wildcard token), read once with the
14
+ connector's secret handles (inside the same directory lock, with a non-blocking open so a FIFO in the file's
15
+ place cannot hang the launch) and returned as `LaunchCopy.policy`. `standaloneResolver(policy?)` takes it and
16
+ mints the standalone principal's scope from it instead of `tool:*` when it is given (one `tool:<name>` per
17
+ allowed name; an empty list grants none). Absent, a policy file this process cannot read, one of another
18
+ shape, or a symbolic link in its place, all stop the launcher before the connector starts (exit 2,
19
+ `connector.internal`, naming the file relative to the configuration directory, never its content); absent the
20
+ file, behaviour is exactly as before. No CLI subcommand writes this file. `tools/list`, the status resource's
21
+ `tools` and `tools/call` (with its receipt) already followed the one scope judge (`isGranted`); they need no
22
+ change to follow the narrower grant too. A parked session (CN-046) carries the same principal, so the scope
23
+ stage still refuses a tool outside the policy first, with `connector.forbidden_scope`, before the parked
24
+ `connector.auth_required` a tool inside it gets. Hosted mode never reads this file (its grant comes from the
25
+ assertion).
26
+
27
+ ## 0.6.0 (2026-10-16)
28
+
29
+ ### Changed (breaking)
30
+
31
+ - `binaries.lock.json`, which `pinnedUpstream` reads (CN-027): an upstream is now
32
+ any number of archives and one program, `{"<upstream>": {"version",
33
+ "archives": [{"platform", "file", "source", "sha256", "unpack"}],
34
+ "program"}}`. An archive names one platform or `"*"` (every platform the lock
35
+ pins); its file name gives its format (`.tar.gz` or `.tgz`, `.zip`); `unpack`
36
+ is `{"entry": <name>}` (the one file of the archive, as before) or
37
+ `{"directory": <top directory>, "to": <path below bin>}` (every entry below
38
+ that directory). `program` is `{"binary": <path>}` or `{"node": <path>}`, a
39
+ Node.js script run by the Node.js that runs the connector. The lock of the
40
+ earlier shape (`source` and `platforms`) is refused; connector packages move
41
+ to the new shape with this version.
42
+ - `install` fetches every archive of the platform into the bin directory and
43
+ compares each with its sha256, and checks what it unpacks, before anything is
44
+ unpacked: an archive that does not match, or cannot be unpacked as the lock
45
+ says, installs nothing. Archives are streamed to disk instead of held in
46
+ memory; an archive is kept in the bin directory only once what it unpacks is
47
+ in place. The start check reads every archive's sha256, compares each tree
48
+ with its archive's listing (paths, kinds, sizes, modes, link targets; a tree
49
+ that differs is reinstalled by `install`) and the program with its entry.
50
+
51
+ ### Added
52
+
53
+ - `readHostUrl(value)` (CN-045): the one rule for an address a connector stores as a non-secret host handle and hands
54
+ its upstream program (an `https` URL, or an `http` URL on `127.0.0.1` or `localhost`, without credentials, a query
55
+ or a fragment, stored as the URL's origin and path without a trailing slash); moved here from the Gitea connector's
56
+ own `readHost` (CN-037), so a second connector that needs a host does not carry a second copy of the rule.
57
+ - `readStdinJson(limit, keys, optional?)` takes a third, optional list of keys a caller's JSON object may also carry
58
+ besides `keys` (CN-045): the same refusal shape (exactly `keys`, at most `optional` besides them, no other key, a
59
+ key named twice refused, every value a string). Omitted, behaviour is exactly as before.
60
+ - `pinnedUpstream({ ..., optionalHandles })` (CN-045): handles a connector's server may use when stored, but starts
61
+ without; given, an optional handle's `<NAME>_FILE` is checked the same way a required one's is (a regular file
62
+ inside the connector's directory) and named in `UpstreamStart.files`; absent, `start()` does not wait for it. A
63
+ required or an optional handle's `<NAME>_FILE` is kept from the program either way.
64
+ - Zip archives and archive trees (CN-027): a tree is checked whole before the
65
+ first byte is written (no entry outside its directory, no `..`, empty or `.`
66
+ segment, no absolute path, no name twice, nothing below a file or a link, no
67
+ hard link, device or pipe, a symbolic link only to a relative path below its
68
+ own directory), written with modes 0644 and 0755 only, zip entries streamed
69
+ through inflate with their size and CRC-32 checked; at most 1 GiB per
70
+ archive. Split, ZIP64 and encrypted zip archives are refused.
71
+ - `UpstreamStart.binDir`, the bin directory holding the pinned build.
72
+
73
+ ### Fixed
74
+
75
+ - The CLI kit's program name follows mount's whole name rule (CN-053): a
76
+ reserved name such as `sema-connector` is refused with the launcher's own
77
+ prefix, `sema-connector: mount refused (reserved_name): ...`, as `INTEGRATION.md`
78
+ says, instead of `sema-connector-sema-connector:`. A copy of the package whose
79
+ own manifest is out of reach (bundled, KL-14) refuses `statusCommand` in one
80
+ line with exit 1 instead of failing with a thrown error.
81
+
7
82
  ## 0.5.0 (2026-10-15)
8
83
 
9
84
  ### Added
package/INTEGRATION.md CHANGED
@@ -66,7 +66,8 @@ const result = mount({ name: 'gitea', configDir: dir })
66
66
  `@sema-agent/connectors` is consumed as an installed package, not bundled: `statusCommand` starts the
67
67
  `sema-connector` launcher of the very copy it belongs to, and `seed` resolves connector packages from that
68
68
  copy's place in `node_modules`, both through the package's own `package.json` (`import.meta.url`), so a copy
69
- inlined into another program's bundle finds neither. `mount` and `mountCommand` have no such dependency.
69
+ inlined into another program's bundle finds neither and refuses in one line (KL-14 in the repository's
70
+ `docs/KNOWN-LIMITS.md`). `mount` and `mountCommand` have no such dependency.
70
71
 
71
72
  The entry `mount` writes names the command `sema-connector`; the engine runs it from its `PATH`. The
72
73
  launcher is the `bin` of `@sema-agent/connectors`, and a connector is the `bin` of
package/dist/cli-kit.d.ts CHANGED
@@ -29,6 +29,6 @@ export type StdinJson = {
29
29
  readonly problem: string;
30
30
  };
31
31
  export declare const listed: (keys: readonly string[]) => string;
32
- export declare function readStdinJson(limit: number, keys: readonly string[]): Promise<StdinJson>;
32
+ export declare function readStdinJson(limit: number, keys: readonly string[], optional?: readonly string[]): Promise<StdinJson>;
33
33
  export declare function mountCommand(name: string, configDir: string): number;
34
34
  export declare function statusCommand(name: string, configDir: string): Promise<number>;
package/dist/cli-kit.js CHANGED
@@ -1,13 +1,12 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { resolveConfigDir } from './config-dir.js';
3
- import { CONNECTOR_NAME } from './connector-name.js';
4
3
  import { hasRepeatedKey } from './json-text.js';
5
- import { mount } from './mount.js';
4
+ import { mount, nameRefusal } from './mount.js';
6
5
  import { ownManifest } from './own-package.js';
7
6
  import { MCP_PROTOCOL_VERSION } from './session.js';
8
7
  import { statusUri } from './status.js';
9
8
  export function cliOutput(name) {
10
- const program = typeof name === 'string' && CONNECTOR_NAME.test(name) ? `sema-connector-${name}` : 'sema-connector';
9
+ const program = nameRefusal(name) === null ? `sema-connector-${name}` : 'sema-connector';
11
10
  return {
12
11
  program,
13
12
  say: (line) => void process.stdout.write(`${line}\n`),
@@ -62,12 +61,14 @@ export async function readStdinLine(limit) {
62
61
  return { ok: true, line };
63
62
  }
64
63
  export const listed = (keys) => (keys.length < 2 ? keys.join('') : `${keys.slice(0, -1).join(', ')} and ${keys[keys.length - 1]}`);
65
- export async function readStdinJson(limit, keys) {
64
+ export async function readStdinJson(limit, keys, optional = []) {
66
65
  const read = await readStdinLine(limit);
67
66
  if (!read.ok)
68
67
  return read;
69
- const exactly = keys.length === 1 ? `exactly the key ${listed(keys)}` : `exactly the keys ${listed(keys)}`;
70
- const shape = `standard input must be one JSON object with ${exactly}, ${keys.length === 1 ? 'a string' : 'each a string'}, on one line`;
68
+ const all = [...keys, ...optional];
69
+ const phrase = (how, names) => `${how} the ${names.length === 1 ? 'key' : 'keys'} ${listed(names)}`;
70
+ const exactly = [...(keys.length > 0 ? [phrase('exactly', keys)] : []), ...(optional.length > 0 ? [phrase('at most', optional)] : [])].join(' and ') || 'no keys';
71
+ const shape = `standard input must be one JSON object with ${exactly}, ${all.length === 1 ? 'a string' : 'each a string'}, on one line`;
71
72
  let value;
72
73
  try {
73
74
  value = JSON.parse(read.line);
@@ -81,11 +82,11 @@ export async function readStdinJson(limit, keys) {
81
82
  return { ok: false, problem: 'the object names a key more than once' };
82
83
  const object = value;
83
84
  const own = Object.keys(object);
84
- if (own.length !== keys.length || !keys.every((k) => own.includes(k)))
85
+ if (!keys.every((k) => own.includes(k)) || !own.every((k) => all.includes(k)))
85
86
  return { ok: false, problem: `the object must have ${exactly}` };
86
- if (!keys.every((k) => typeof object[k] === 'string'))
87
+ if (!own.every((k) => typeof object[k] === 'string'))
87
88
  return { ok: false, problem: shape };
88
- return { ok: true, values: Object.fromEntries(keys.map((k) => [k, object[k]])) };
89
+ return { ok: true, values: Object.fromEntries(own.map((k) => [k, object[k]])) };
89
90
  }
90
91
  export function mountCommand(name, configDir) {
91
92
  const { say, complain } = cliOutput(name);
@@ -143,7 +144,14 @@ function lineReader(stream) {
143
144
  }
144
145
  export async function statusCommand(name, configDir) {
145
146
  const { program, say, complain } = cliOutput(name);
146
- const own = ownManifest();
147
+ let own;
148
+ try {
149
+ own = ownManifest();
150
+ }
151
+ catch {
152
+ complain('the package\'s own manifest cannot be read, so its launcher cannot be found: this package is consumed installed, not bundled (INTEGRATION.md, KL-14)');
153
+ return 1;
154
+ }
147
155
  const child = spawn(process.execPath, [own.launcher, 'run', name, '--mode', 'standalone', '--transport', 'stdio', '--config-dir', configDir], { stdio: ['pipe', 'pipe', 'pipe'] });
148
156
  const exited = new Promise((resolve) => child.on('exit', (code) => resolve(code)));
149
157
  const errors = [];
@@ -0,0 +1,8 @@
1
+ export type HostReading = {
2
+ readonly ok: true;
3
+ readonly host: string;
4
+ } | {
5
+ readonly ok: false;
6
+ readonly problem: string;
7
+ };
8
+ export declare function readHostUrl(value: string): HostReading;
@@ -0,0 +1,19 @@
1
+ import { isLoopbackHttpOrHttps } from './jwks.js';
2
+ export function readHostUrl(value) {
3
+ if (/[\s\u0000-\u001f\u007f]/.test(value))
4
+ return { ok: false, problem: 'the host is not a URL (it holds whitespace or a control character)' };
5
+ let url;
6
+ try {
7
+ url = new URL(value);
8
+ }
9
+ catch {
10
+ return { ok: false, problem: 'the host is not a URL' };
11
+ }
12
+ if (url.username !== '' || url.password !== '')
13
+ return { ok: false, problem: 'the host carries credentials; a host URL never does (the token is stored on its own)' };
14
+ if (!isLoopbackHttpOrHttps(url))
15
+ return { ok: false, problem: 'the host must be an https URL, or an http URL on 127.0.0.1 or localhost' };
16
+ if (url.search !== '' || url.hash !== '')
17
+ return { ok: false, problem: 'the host URL takes no query and no fragment' };
18
+ return { ok: true, host: url.origin + url.pathname.replace(/\/+$/, '') };
19
+ }
package/dist/index.d.ts CHANGED
@@ -11,6 +11,8 @@ export type { DefaultConnector, SeedOptions, SeedReading, SeedRefusal, SeedResul
11
11
  export { cliOutput, connectorCli, mountCommand, readStdinJson, readStdinLine, statusCommand } from './cli-kit.js';
12
12
  export type { CliOutput, ConnectorCommands, StdinJson, StdinLine } from './cli-kit.js';
13
13
  export { isLoopbackHttpOrHttps } from './jwks.js';
14
+ export { readHostUrl } from './host-url.js';
15
+ export type { HostReading } from './host-url.js';
14
16
  export { pinnedUpstream } from './pinned-upstream.js';
15
17
  export type { PinnedUpstream, PinnedUpstreamOptions, UpstreamStart } from './pinned-upstream.js';
16
18
  export { writeAll } from './write-all.js';
package/dist/index.js CHANGED
@@ -5,5 +5,6 @@ export { mount } from './mount.js';
5
5
  export { DEFAULT_CONNECTORS, SEED_VERSION, seed } from './seed.js';
6
6
  export { cliOutput, connectorCli, mountCommand, readStdinJson, readStdinLine, statusCommand } from './cli-kit.js';
7
7
  export { isLoopbackHttpOrHttps } from './jwks.js';
8
+ export { readHostUrl } from './host-url.js';
8
9
  export { pinnedUpstream } from './pinned-upstream.js';
9
10
  export { writeAll } from './write-all.js';
package/dist/launcher.js CHANGED
@@ -504,8 +504,8 @@ function parkedRun(session, connector, stdout, log) {
504
504
  }),
505
505
  };
506
506
  }
507
- async function runStdio(packageName, run, session, stdout, log) {
508
- const resolution = standaloneResolver().resolve({ headers: {} });
507
+ async function runStdio(packageName, run, session, stdout, log, policy) {
508
+ const resolution = standaloneResolver(policy).resolve({ headers: {} });
509
509
  if (!resolution.ok)
510
510
  throw new Error('the standalone principal cannot be refused');
511
511
  const anonymous = resolution.principal;
@@ -628,7 +628,7 @@ export async function main(argv, env) {
628
628
  const run = parked
629
629
  ? parkedRun(session, plan.name, stdout, log)
630
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));
631
+ return finish(await runStdio(resolved.packageName, run, session, stdout, log, secrets.policy));
632
632
  }
633
633
  finally {
634
634
  secrets.remove();
@@ -4,10 +4,12 @@ export interface PinnedUpstreamOptions {
4
4
  readonly upstream: string;
5
5
  readonly lockFile: URL;
6
6
  readonly handles: readonly string[];
7
+ readonly optionalHandles?: readonly string[];
7
8
  }
8
9
  export type UpstreamStart = {
9
10
  readonly ok: true;
10
11
  readonly connectorDir: string;
12
+ readonly binDir: string;
11
13
  readonly files: Readonly<Record<string, string>>;
12
14
  run(args: readonly string[], extra: Readonly<Record<string, string>>): Promise<number>;
13
15
  } | {