@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/dist/launcher.js CHANGED
@@ -1,19 +1,21 @@
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
- import { dirname, join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
7
6
  import { absoluteDir, given } from './config-dir.js';
8
7
  import { CONNECTOR_NAME } from './connector-name.js';
9
8
  import { serveHttp } from './http.js';
10
9
  import { keySetSource, obtainKeySet } from './jwks.js';
11
10
  import { hostedResolver, standaloneResolver } from './principal.js';
12
11
  import { Scrubber, secretMatcher } from './redact.js';
13
- import { copyForLaunch, openFileSecrets } from './secrets.js';
12
+ import { resolveConnector } from './resolve-connector.js';
13
+ import { NO_HANDLES, copyForLaunch, handleDirectory } from './secrets.js';
14
+ import { seed } from './seed.js';
14
15
  import { INSPECT_LIMIT_BYTES, Session } from './session.js';
15
16
  import { mintStatus } from './status.js';
16
- const EXIT = { ok: 0, guard: 2, notInstalled: 4, connectorFailed: 5 };
17
+ import { writeFailure } from './write-all.js';
18
+ const EXIT = { ok: 0, notSeeded: 1, guard: 2, notInstalled: 4, connectorFailed: 5 };
17
19
  const NOT_INSTALLED = 'connector.not_installed';
18
20
  const INTERNAL = 'connector.internal';
19
21
  const STOP_SIGNALS = ['SIGTERM', 'SIGINT', 'SIGHUP'];
@@ -21,20 +23,15 @@ const TEARDOWN_STEP_MS = 1000;
21
23
  const STALL_STEPS = 10;
22
24
  const STALL_MS = STALL_STEPS * TEARDOWN_STEP_MS;
23
25
  const PIECE_BYTES = 1024;
24
- const USAGE = 'usage: sema-connector run <name> --mode standalone|hosted --transport stdio|http [--listen <ip>:<port>] [--config-dir <absolute path>]';
26
+ 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>';
25
27
  const FLAGS = ['--mode', '--transport', '--listen', '--config-dir'];
28
+ const SEED_FLAGS = ['--config-dir'];
26
29
  const CHILD_ENV = ['HOME', 'LOGNAME', 'USER', 'PATH', 'SHELL', 'TERM', 'LANG', 'LC_ALL', 'TZ'];
27
- function guard(argv, env) {
28
- if (argv[0] !== 'run')
29
- return 'expected the command "run"';
30
- const name = argv[1];
31
- if (name === undefined || name.startsWith('-'))
32
- return 'missing the connector <name> after "run"';
33
- if (!CONNECTOR_NAME.test(name))
34
- return '<name> must be lowercase letters and digits in hyphen-separated words (it names the package @sema-agent/connector-<name>)';
30
+ const CONNECTOR_DIR = 'CONNECTOR_DIR';
31
+ function flagValues(argv, from, allowed) {
35
32
  const values = new Map();
36
- for (let i = 2; i < argv.length; i += 2) {
37
- const flag = FLAGS.find((f) => f === argv[i]);
33
+ for (let i = from; i < argv.length; i += 2) {
34
+ const flag = allowed.find((f) => f === argv[i]);
38
35
  if (flag === undefined)
39
36
  return `unknown argument at position ${i + 1}`;
40
37
  if (values.has(flag))
@@ -44,6 +41,31 @@ function guard(argv, env) {
44
41
  return `${flag} needs a value`;
45
42
  values.set(flag, value);
46
43
  }
44
+ return values;
45
+ }
46
+ function guard(argv, env) {
47
+ if (argv[0] === 'seed') {
48
+ const values = flagValues(argv, 1, SEED_FLAGS);
49
+ if (typeof values === 'string')
50
+ return values;
51
+ const configDirArgument = values.get('--config-dir');
52
+ if (configDirArgument === undefined)
53
+ return 'seed requires --config-dir <absolute path>, the configuration directory it seeds (there is no default)';
54
+ const configDir = absoluteDir(configDirArgument);
55
+ if (configDir === null)
56
+ return '--config-dir must be an absolute path';
57
+ return { command: 'seed', configDir };
58
+ }
59
+ if (argv[0] !== 'run')
60
+ return 'expected the command "run" or "seed"';
61
+ const name = argv[1];
62
+ if (name === undefined || name.startsWith('-'))
63
+ return 'missing the connector <name> after "run"';
64
+ if (!CONNECTOR_NAME.test(name))
65
+ return '<name> must be lowercase letters and digits in hyphen-separated words (it names the package @sema-agent/connector-<name>)';
66
+ const values = flagValues(argv, 2, FLAGS);
67
+ if (typeof values === 'string')
68
+ return values;
47
69
  const mode = values.get('--mode');
48
70
  const transport = values.get('--transport');
49
71
  const listen = values.get('--listen');
@@ -66,7 +88,7 @@ function guard(argv, env) {
66
88
  const configDir = absoluteDir(configDirArgument);
67
89
  if (configDir === null)
68
90
  return '--config-dir must be an absolute path';
69
- return { name, mode, configDir };
91
+ return { command: 'run', name, mode, configDir };
70
92
  }
71
93
  if (transport !== 'http')
72
94
  return 'hosted mode requires --transport http';
@@ -86,7 +108,7 @@ function guard(argv, env) {
86
108
  const keySet = keySetSource(env, issuer);
87
109
  if (typeof keySet === 'string')
88
110
  return keySet;
89
- return { name, mode, listen: address, issuer, audience, keySet };
111
+ return { command: 'run', name, mode, listen: address, issuer, audience, keySet };
90
112
  }
91
113
  function listenAddress(value) {
92
114
  const m = /^(?:\[([^\]]+)\]|([^:[\]]+)):([1-9][0-9]{0,4})$/.exec(value);
@@ -97,31 +119,6 @@ function listenAddress(value) {
97
119
  const port = Number(m[3]);
98
120
  return hostOk && port <= 65535 ? { host, port } : null;
99
121
  }
100
- function resolveConnector(name) {
101
- const packageName = `@sema-agent/connector-${name}`;
102
- let manifestPath;
103
- try {
104
- manifestPath = createRequire(new URL('../package.json', import.meta.url)).resolve(`${packageName}/package.json`);
105
- }
106
- catch {
107
- return { packageName, reason: 'is not installed where the launcher can resolve it' };
108
- }
109
- let bin;
110
- try {
111
- bin = JSON.parse(readFileSync(manifestPath, 'utf8')).bin;
112
- }
113
- catch {
114
- return { packageName, reason: 'has a package.json that cannot be read' };
115
- }
116
- let rel;
117
- if (typeof bin === 'string')
118
- rel = bin;
119
- else if (bin !== null && typeof bin === 'object' && !Array.isArray(bin) && Object.keys(bin).length === 1)
120
- rel = Object.values(bin)[0];
121
- if (typeof rel !== 'string' || rel === '')
122
- return { packageName, reason: 'does not declare exactly one bin' };
123
- return { packageName, binPath: join(dirname(manifestPath), rel) };
124
- }
125
122
  class Out {
126
123
  stream;
127
124
  held = [];
@@ -369,17 +366,19 @@ function pump(src, dst, framing, also = [], passing, giveUp) {
369
366
  src.on('close', finish);
370
367
  });
371
368
  }
372
- function childEnvironment(env, secrets) {
369
+ function childEnvironment(env, connectorDir, secrets) {
373
370
  const out = {};
374
371
  for (const name of CHILD_ENV) {
375
372
  const value = env[name];
376
373
  if (value !== undefined)
377
374
  out[name] = value;
378
375
  }
376
+ out[CONNECTOR_DIR] = connectorDir;
379
377
  for (const [name, path] of secrets.files)
380
378
  out[`${name}_FILE`] = path;
381
379
  return out;
382
380
  }
381
+ const hostedConnectorDir = (name) => join(tmpdir(), `sema-connector-${randomUUID()}`, 'connectors', name);
383
382
  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)}`}`;
384
383
  const abnormal = (o) => o.code !== 0 || o.stalled === true;
385
384
  function startConnector(binPath, childEnv, session, scrubber, toUpstream, upstreamOut, log, stderrAfterExitMs) {
@@ -491,15 +490,14 @@ async function runStdio(packageName, binPath, childEnv, session, scrubber, stdou
491
490
  return EXIT.connectorFailed;
492
491
  }
493
492
  async function runHosted(plan, keys, packageName, binPath, env, log) {
494
- const childEnv = childEnvironment(env, NO_SECRETS);
495
493
  const start = (deliver) => {
496
494
  const session = new Session({
497
495
  connector: plan.name,
498
- status: (tools) => mintStatus({ handles: [], nowMs: Date.now(), tools }),
496
+ status: (tools) => mintStatus({ handles: NO_HANDLES.handles, nowMs: Date.now(), tools }),
499
497
  ownIdPrefix: `sema-connector/${randomUUID()}`,
500
- withheld: secretMatcher(NO_SECRETS.values),
498
+ withheld: secretMatcher(NO_HANDLES.values),
501
499
  });
502
- const run = startConnector(binPath, childEnv, session, new Scrubber(NO_SECRETS.values), (data) => deliver(String(data)), null, log, TEARDOWN_STEP_MS);
500
+ 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);
503
501
  return {
504
502
  send: (raw, principal) => run.fromUpstream(raw, principal),
505
503
  busy: () => run.toChild.behind(),
@@ -524,7 +522,24 @@ async function runHosted(plan, keys, packageName, binPath, env, log) {
524
522
  setTimeout(abandon, TEARDOWN_STEP_MS);
525
523
  return EXIT.ok;
526
524
  }
527
- const NO_SECRETS = { files: new Map(), values: [], remove: () => undefined };
525
+ const readingLine = (r) => `seed: ${r.name} ${r.outcome}${r.outcome === 'not_installed' ? ` (${r.message})` : r.outcome === 'refused' ? ` (${r.reason}: ${r.message})` : ''}\n`;
526
+ function runSeed(configDir, stdout, log) {
527
+ let result;
528
+ try {
529
+ result = seed({ configDir });
530
+ }
531
+ catch (e) {
532
+ log.line(`sema-connector: seed: failed (${writeFailure(e)}); the marker was not written\n`);
533
+ return EXIT.notSeeded;
534
+ }
535
+ for (const r of result.readings)
536
+ stdout.write(readingLine(r));
537
+ if (!result.ok) {
538
+ log.line(`sema-connector: seed: ${result.reason}: ${result.message}\n`);
539
+ return EXIT.notSeeded;
540
+ }
541
+ return result.readings.every((r) => r.outcome === 'mounted' || r.outcome === 'present' || r.outcome === 'not_installed') ? EXIT.ok : EXIT.notSeeded;
542
+ }
528
543
  export async function main(argv, env) {
529
544
  const stdout = new Out(process.stdout);
530
545
  const log = new Log(new Out(process.stderr));
@@ -537,6 +552,8 @@ export async function main(argv, env) {
537
552
  log.line(`sema-connector: ${plan}\n${USAGE}\n`);
538
553
  return finish(EXIT.guard);
539
554
  }
555
+ if (plan.command === 'seed')
556
+ return finish(runSeed(plan.configDir, stdout, log));
540
557
  if (plan.mode === 'hosted') {
541
558
  const keys = await obtainKeySet(plan.keySet);
542
559
  if (!keys.ok) {
@@ -550,20 +567,18 @@ export async function main(argv, env) {
550
567
  }
551
568
  return finish(await runHosted(plan, keys.keys, resolved.packageName, resolved.binPath, env, log));
552
569
  }
553
- const listing = openFileSecrets({ configDir: plan.configDir, connector: plan.name }).list();
554
- if (!listing.ok) {
555
- log.line(`sema-connector: ${INTERNAL}: ${listing.problem}\n`);
556
- return finish(EXIT.guard);
557
- }
558
- const { handles } = listing;
559
570
  let secrets;
560
571
  try {
561
- secrets = copyForLaunch(plan.configDir, plan.name, handles);
572
+ secrets = copyForLaunch(plan.configDir, plan.name);
562
573
  }
563
574
  catch {
564
575
  log.line(`sema-connector: ${INTERNAL}: connector ${plan.name}: its secrets cannot be read for this launch\n`);
565
576
  return finish(EXIT.guard);
566
577
  }
578
+ if (!secrets.ok) {
579
+ log.line(`sema-connector: ${INTERNAL}: ${secrets.problem}\n`);
580
+ return finish(EXIT.guard);
581
+ }
567
582
  const resolved = resolveConnector(plan.name);
568
583
  if ('reason' in resolved) {
569
584
  secrets.remove();
@@ -572,12 +587,12 @@ export async function main(argv, env) {
572
587
  }
573
588
  const session = new Session({
574
589
  connector: plan.name,
575
- status: (tools) => mintStatus({ handles, nowMs: Date.now(), tools }),
590
+ status: (tools) => mintStatus({ handles: secrets.handles, nowMs: Date.now(), tools }),
576
591
  ownIdPrefix: `sema-connector/${randomUUID()}`,
577
592
  withheld: secretMatcher(secrets.values),
578
593
  });
579
594
  try {
580
- return finish(await runStdio(resolved.packageName, resolved.binPath, childEnvironment(env, secrets), session, new Scrubber(secrets.values), stdout, log));
595
+ return finish(await runStdio(resolved.packageName, resolved.binPath, childEnvironment(env, handleDirectory(plan.configDir, plan.name), secrets), session, new Scrubber(secrets.values), stdout, log));
581
596
  }
582
597
  finally {
583
598
  secrets.remove();
@@ -0,0 +1 @@
1
+ export declare function takeLockFile(path: string): (() => void) | null;
@@ -0,0 +1,34 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { closeSync, linkSync, openSync, readFileSync, rmSync } from 'node:fs';
3
+ import { writeAll } from './write-all.js';
4
+ export function takeLockFile(path) {
5
+ const token = `${process.pid} ${randomBytes(12).toString('hex')}`;
6
+ const tmp = `${path}.${randomBytes(6).toString('hex')}`;
7
+ const fd = openSync(tmp, 'wx', 0o600);
8
+ try {
9
+ try {
10
+ writeAll(fd, token);
11
+ }
12
+ finally {
13
+ closeSync(fd);
14
+ }
15
+ linkSync(tmp, path);
16
+ }
17
+ catch (e) {
18
+ if (e.code === 'EEXIST')
19
+ return null;
20
+ throw e;
21
+ }
22
+ finally {
23
+ rmSync(tmp, { force: true });
24
+ }
25
+ return () => {
26
+ let held = '';
27
+ try {
28
+ held = readFileSync(path, 'utf8');
29
+ }
30
+ catch { }
31
+ if (held === token)
32
+ rmSync(path, { force: true });
33
+ };
34
+ }
package/dist/mount.d.ts CHANGED
@@ -2,7 +2,7 @@ export interface MountOptions {
2
2
  readonly name: string;
3
3
  readonly configDir: string;
4
4
  }
5
- export type MountRefusal = 'config_dir' | 'invalid_name' | 'reserved_name' | 'unreadable' | 'unexpected_shape' | 'conflict' | 'locked' | 'changed_during_write';
5
+ export type MountRefusal = 'config_dir' | 'invalid_name' | 'reserved_name' | 'unreadable' | 'unexpected_shape' | 'conflict' | 'locked' | 'changed_during_write' | 'write_failed';
6
6
  export type MountResult = {
7
7
  readonly ok: true;
8
8
  readonly changed: boolean;
@@ -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: {
package/dist/mount.js CHANGED
@@ -1,10 +1,19 @@
1
- import { closeSync, fchmodSync, fsyncSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from 'node:fs';
1
+ import { closeSync, fchmodSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync } from 'node:fs';
2
2
  import { randomBytes } from 'node:crypto';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { absoluteDir } from './config-dir.js';
5
5
  import { CONNECTOR_NAME } from './connector-name.js';
6
6
  import { skipWhitespace, spansOf } from './json-text.js';
7
+ import { takeLockFile } from './lock-file.js';
8
+ import { writeAll, writeFailure } from './write-all.js';
7
9
  const RESERVED_NAMES = ['sema-connector'];
10
+ export function nameRefusal(name) {
11
+ if (typeof name !== 'string' || !CONNECTOR_NAME.test(name))
12
+ return { reason: 'invalid_name', message: 'a connector name is lowercase letters and digits in hyphen-separated words' };
13
+ if (RESERVED_NAMES.includes(name))
14
+ return { reason: 'reserved_name', message: `${name} is a reserved name` };
15
+ return null;
16
+ }
8
17
  export function standaloneEntry(name, configDir) {
9
18
  return { name, transport: { kind: 'stdio', command: 'sema-connector', args: ['run', name, '--mode', 'standalone', '--transport', 'stdio', '--config-dir', configDir] } };
10
19
  }
@@ -69,32 +78,6 @@ function plan(before, entry) {
69
78
  return { text };
70
79
  }
71
80
  const LOCK_FILE = '.mcp.json.lock';
72
- function lock(dir) {
73
- const path = join(dir, LOCK_FILE);
74
- const token = `${process.pid} ${randomBytes(12).toString('hex')}`;
75
- const tmp = join(dir, `${LOCK_FILE}.${randomBytes(6).toString('hex')}`);
76
- writeFileSync(tmp, token, { mode: 0o600, flag: 'wx' });
77
- try {
78
- linkSync(tmp, path);
79
- }
80
- catch (e) {
81
- if (e.code === 'EEXIST')
82
- return null;
83
- throw e;
84
- }
85
- finally {
86
- rmSync(tmp, { force: true });
87
- }
88
- return () => {
89
- let held = '';
90
- try {
91
- held = readFileSync(path, 'utf8');
92
- }
93
- catch { }
94
- if (held === token)
95
- rmSync(path, { force: true });
96
- };
97
- }
98
81
  const readOrNull = (file) => {
99
82
  try {
100
83
  return readFileSync(file, 'utf8');
@@ -108,17 +91,22 @@ const readOrNull = (file) => {
108
91
  export function mount(options) {
109
92
  const refuse = (reason, message) => ({ ok: false, reason, message });
110
93
  const name = options?.name;
111
- if (typeof name !== 'string' || !CONNECTOR_NAME.test(name))
112
- return refuse('invalid_name', 'a connector name is lowercase letters and digits in hyphen-separated words');
113
- if (RESERVED_NAMES.includes(name))
114
- return refuse('reserved_name', `${name} is a reserved name`);
94
+ const named = nameRefusal(name);
95
+ if (named !== null)
96
+ return refuse(named.reason, named.message);
115
97
  const configDir = absoluteDir(options.configDir);
116
98
  if (configDir === null)
117
99
  return refuse('config_dir', 'configDir must be the configuration directory as an absolute path; mount reads no environment variable and has no default');
118
100
  const dir = join(configDir, 'config.d');
119
101
  const file = join(dir, 'mcp.json');
120
- mkdirSync(dir, { recursive: true });
121
- const unlock = lock(dir);
102
+ let unlock;
103
+ try {
104
+ mkdirSync(dir, { recursive: true });
105
+ unlock = takeLockFile(join(dir, LOCK_FILE));
106
+ }
107
+ catch (e) {
108
+ return refuse('write_failed', `config.d or its lock could not be written (${writeFailure(e)}); config.d/mcp.json was left as it was`);
109
+ }
122
110
  if (unlock === null)
123
111
  return refuse('locked', `another mount is writing config.d/mcp.json; try again, and if no mount is running remove config.d/${LOCK_FILE}`);
124
112
  try {
@@ -155,8 +143,7 @@ function write(file, entry, refuse) {
155
143
  try {
156
144
  if (mode !== null)
157
145
  fchmodSync(fd, mode);
158
- writeSync(fd, text);
159
- fsyncSync(fd);
146
+ writeAll(fd, text);
160
147
  }
161
148
  finally {
162
149
  closeSync(fd);
@@ -169,7 +156,7 @@ function write(file, entry, refuse) {
169
156
  }
170
157
  catch (e) {
171
158
  rmSync(tmp, { force: true });
172
- throw e;
159
+ return refuse('write_failed', `config.d/mcp.json could not be written (${writeFailure(e)}); it was left as it was and nothing else was written`);
173
160
  }
174
161
  return { ok: true, changed: true, file };
175
162
  }
@@ -0,0 +1,21 @@
1
+ import { type Env } from './config-dir.js';
2
+ export interface PinnedUpstreamOptions {
3
+ readonly connector: string;
4
+ readonly upstream: string;
5
+ readonly lockFile: URL;
6
+ readonly handles: readonly string[];
7
+ }
8
+ export type UpstreamStart = {
9
+ readonly ok: true;
10
+ readonly connectorDir: string;
11
+ readonly files: Readonly<Record<string, string>>;
12
+ run(args: readonly string[], extra: Readonly<Record<string, string>>): Promise<number>;
13
+ } | {
14
+ readonly ok: false;
15
+ readonly problem: string;
16
+ };
17
+ export interface PinnedUpstream {
18
+ install(configDir: string): Promise<number>;
19
+ start(env: Env): UpstreamStart;
20
+ }
21
+ export declare function pinnedUpstream(options: PinnedUpstreamOptions): PinnedUpstream;