@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/secrets.js CHANGED
@@ -1,12 +1,20 @@
1
1
  import { randomBytes } from 'node:crypto';
2
- import { closeSync, constants, fchmodSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, writeSync } from 'node:fs';
2
+ import { closeSync, constants, fchmodSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, renameSync, rmSync } from 'node:fs';
3
3
  import { basename, dirname, isAbsolute, join } from 'node:path';
4
4
  import { CONNECTOR_NAME } from './connector-name.js';
5
+ import { hasRepeatedKey } from './json-text.js';
6
+ import { takeLockFile } from './lock-file.js';
7
+ import { writeAll } from './write-all.js';
8
+ export const isSecret = (handle) => handle.secret !== false;
5
9
  export const HANDLE_NAME = /^[A-Z][A-Z0-9_]*$/;
6
- const EXPIRY_SUFFIX = '.meta.json';
10
+ const META_SUFFIX = '.meta.json';
11
+ const META_KEYS = ['expiresAt', 'secret'];
7
12
  const TIMESTAMP = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?Z$/;
8
13
  const FILE_MODE = 0o600;
9
14
  const DIRECTORY_MODE = 0o700;
15
+ export const LOCK_FILE = '.auth.lock';
16
+ export const LOCK_WAIT_MS = 5000;
17
+ const LOCK_POLL_MS = 25;
10
18
  export function handleDirectory(configDir, connector) {
11
19
  return join(configDir, 'connectors', connector);
12
20
  }
@@ -22,22 +30,75 @@ export function readSecretValue(configDir, handle) {
22
30
  closeSync(fd);
23
31
  }
24
32
  }
25
- export function copyForLaunch(configDir, connector, handles) {
26
- if (handles.length === 0)
27
- return { files: new Map(), values: [], remove: () => undefined };
28
- const dir = mkdtempSync(join(handleDirectory(configDir, connector), '.launch-'));
29
- const remove = () => rmSync(dir, { recursive: true, force: true });
33
+ const held = new Set();
34
+ const pause = (ms) => void Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
35
+ function locked(dir, connector, fn) {
36
+ const path = join(dir, LOCK_FILE);
37
+ if (held.has(path))
38
+ return { ok: true, value: fn() };
39
+ const deadline = Date.now() + LOCK_WAIT_MS;
40
+ let release = takeLockFile(path);
41
+ while (release === null) {
42
+ if (Date.now() >= deadline)
43
+ return { ok: false, problem: `connector ${connector}: another process held connectors/${connector}/${LOCK_FILE} for ${LOCK_WAIT_MS / 1000} s (an auth writing the handles, or a launch copying them); a lock left by a process that died is removed by hand` };
44
+ pause(LOCK_POLL_MS);
45
+ release = takeLockFile(path);
46
+ }
47
+ held.add(path);
48
+ try {
49
+ const value = fn();
50
+ if (typeof value?.then === 'function')
51
+ throw new Error('withLock runs a synchronous function: the lock is released as soon as it returns');
52
+ return { ok: true, value };
53
+ }
54
+ finally {
55
+ held.delete(path);
56
+ release();
57
+ }
58
+ }
59
+ export const NO_HANDLES = { handles: [], files: new Map(), values: [], remove: () => undefined };
60
+ export function copyForLaunch(configDir, connector) {
61
+ const dir = handleDirectory(configDir, connector);
62
+ try {
63
+ lstatSync(dir);
64
+ }
65
+ catch (e) {
66
+ if (e.code === 'ENOENT')
67
+ return { ok: true, ...NO_HANDLES };
68
+ return { ok: false, problem: `connector ${connector}: the secrets directory cannot be read` };
69
+ }
70
+ const dirProblem = entryProblem(dir, 'directory', 'the secrets directory');
71
+ if (dirProblem !== null)
72
+ return { ok: false, problem: `connector ${connector}: ${dirProblem}` };
73
+ const r = locked(dir, connector, () => copyHandles(configDir, dir, connector));
74
+ return r.ok ? r.value : r;
75
+ }
76
+ function copyHandles(configDir, dir, connector) {
77
+ const listing = listHandles(dir, connector);
78
+ if (!listing.ok)
79
+ return listing;
80
+ if (listing.handles.length === 0)
81
+ return { ok: true, ...NO_HANDLES };
82
+ const copies = mkdtempSync(join(dir, '.launch-'));
83
+ const remove = () => rmSync(copies, { recursive: true, force: true });
30
84
  try {
31
85
  const files = new Map();
32
86
  const values = [];
33
- for (const handle of handles) {
87
+ for (const handle of listing.handles) {
34
88
  const bytes = readSecretValue(configDir, handle);
35
- const path = join(dir, handle.name);
36
- writeFileSync(path, bytes, { mode: FILE_MODE, flag: 'wx' });
89
+ const path = join(copies, handle.name);
90
+ const fd = openSync(path, 'wx', FILE_MODE);
91
+ try {
92
+ writeAll(fd, bytes);
93
+ }
94
+ finally {
95
+ closeSync(fd);
96
+ }
37
97
  files.set(handle.name, path);
38
- values.push(bytes.toString('utf8'));
98
+ if (isSecret(handle))
99
+ values.push(bytes.toString('utf8'));
39
100
  }
40
- return { files, values, remove };
101
+ return { ok: true, handles: listing.handles, files, values, remove };
41
102
  }
42
103
  catch (e) {
43
104
  remove();
@@ -45,6 +106,25 @@ export function copyForLaunch(configDir, connector, handles) {
45
106
  }
46
107
  }
47
108
  const validExpiry = (v) => typeof v === 'string' && TIMESTAMP.test(v) && Number.isFinite(Date.parse(v));
109
+ function readMeta(text) {
110
+ let v;
111
+ try {
112
+ v = JSON.parse(text);
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ if (v === null || typeof v !== 'object' || Array.isArray(v) || hasRepeatedKey(text))
118
+ return null;
119
+ const meta = v;
120
+ if (!Object.keys(meta).every((k) => META_KEYS.includes(k)))
121
+ return null;
122
+ if (Object.hasOwn(meta, 'expiresAt') && !validExpiry(meta.expiresAt))
123
+ return null;
124
+ if (Object.hasOwn(meta, 'secret') && typeof meta.secret !== 'boolean')
125
+ return null;
126
+ return { ...(validExpiry(meta.expiresAt) ? { expiresAt: meta.expiresAt } : {}), secret: meta.secret !== false };
127
+ }
48
128
  function entryProblem(path, kind, label) {
49
129
  let st;
50
130
  try {
@@ -89,24 +169,24 @@ function listHandles(dir, connector) {
89
169
  const problem = entryProblem(join(dir, name), 'file', `secret file ${name}`);
90
170
  if (problem !== null)
91
171
  return refuse(problem);
92
- const expiryFile = name + EXPIRY_SUFFIX;
93
- if (!names.includes(expiryFile)) {
94
- handles.push({ connector, name, source: 'file' });
172
+ const metaFile = name + META_SUFFIX;
173
+ if (!names.includes(metaFile)) {
174
+ handles.push({ connector, name, source: 'file', secret: true });
95
175
  continue;
96
176
  }
97
- const expiryProblem = entryProblem(join(dir, expiryFile), 'file', `expiry file ${expiryFile}`);
98
- if (expiryProblem !== null)
99
- return refuse(expiryProblem);
100
- let expiresAt;
177
+ const metaProblem = entryProblem(join(dir, metaFile), 'file', `metadata file ${metaFile}`);
178
+ if (metaProblem !== null)
179
+ return refuse(metaProblem);
180
+ let meta;
101
181
  try {
102
- expiresAt = JSON.parse(readFileSync(join(dir, expiryFile), 'utf8')).expiresAt;
182
+ meta = readMeta(readFileSync(join(dir, metaFile), 'utf8'));
103
183
  }
104
184
  catch {
105
- expiresAt = undefined;
185
+ meta = null;
106
186
  }
107
- if (!validExpiry(expiresAt))
108
- return refuse(`expiry file ${expiryFile} does not hold {"expiresAt": "<RFC 3339 UTC time>"}`);
109
- handles.push({ connector, name, source: 'file', expiresAt });
187
+ if (meta === null)
188
+ return refuse(`metadata file ${metaFile} does not hold a JSON object whose only keys are "expiresAt" (an RFC 3339 UTC time) and "secret" (true or false), each named once`);
189
+ handles.push({ connector, name, source: 'file', ...meta });
110
190
  }
111
191
  return { ok: true, handles };
112
192
  }
@@ -116,8 +196,7 @@ function writePrivately(path, text) {
116
196
  const fd = openSync(tmp, 'wx', FILE_MODE);
117
197
  try {
118
198
  fchmodSync(fd, FILE_MODE);
119
- writeSync(fd, text);
120
- fsyncSync(fd);
199
+ writeAll(fd, text);
121
200
  }
122
201
  finally {
123
202
  closeSync(fd);
@@ -129,6 +208,25 @@ function writePrivately(path, text) {
129
208
  throw e;
130
209
  }
131
210
  }
211
+ function ensureDirectory(dir, connector) {
212
+ mkdirSync(dir, { recursive: true, mode: DIRECTORY_MODE });
213
+ const dirProblem = entryProblem(dir, 'directory', 'the secrets directory');
214
+ if (dirProblem !== null)
215
+ throw new Error(`connector ${connector}: ${dirProblem}`);
216
+ }
217
+ function writeHandle(dir, connector, name, value, options) {
218
+ const secret = options.secret !== false;
219
+ const expiry = options.expiresAt === undefined ? {} : { expiresAt: options.expiresAt };
220
+ const meta = { ...expiry, ...(secret ? {} : { secret: false }) };
221
+ const metaFile = join(dir, name + META_SUFFIX);
222
+ const writeMeta = () => (Object.keys(meta).length === 0 ? rmSync(metaFile, { force: true }) : writePrivately(metaFile, JSON.stringify(meta) + '\n'));
223
+ if (secret)
224
+ writeMeta();
225
+ writePrivately(join(dir, name), value);
226
+ if (!secret)
227
+ writeMeta();
228
+ return { connector, name, source: 'file', secret, ...expiry };
229
+ }
132
230
  function putHandle(dir, connector, name, value, options) {
133
231
  if (!HANDLE_NAME.test(name))
134
232
  throw new Error('a handle name is upper-case letters, digits and underscores, starting with a letter');
@@ -136,18 +234,13 @@ function putHandle(dir, connector, name, value, options) {
136
234
  throw new Error('a secret value is a string');
137
235
  if (options.expiresAt !== undefined && !validExpiry(options.expiresAt))
138
236
  throw new Error('expiresAt must be an RFC 3339 UTC time ending in Z');
139
- mkdirSync(dir, { recursive: true, mode: DIRECTORY_MODE });
140
- const dirProblem = entryProblem(dir, 'directory', 'the secrets directory');
141
- if (dirProblem !== null)
142
- throw new Error(`connector ${connector}: ${dirProblem}`);
143
- writePrivately(join(dir, name), value);
144
- const expiryFile = join(dir, name + EXPIRY_SUFFIX);
145
- if (options.expiresAt === undefined) {
146
- rmSync(expiryFile, { force: true });
147
- return { connector, name, source: 'file' };
148
- }
149
- writePrivately(expiryFile, JSON.stringify({ expiresAt: options.expiresAt }) + '\n');
150
- return { connector, name, source: 'file', expiresAt: options.expiresAt };
237
+ if (options.secret !== undefined && typeof options.secret !== 'boolean')
238
+ throw new Error('secret must be true or false');
239
+ ensureDirectory(dir, connector);
240
+ const r = locked(dir, connector, () => writeHandle(dir, connector, name, value, options));
241
+ if (!r.ok)
242
+ throw new Error(r.problem);
243
+ return r.value;
151
244
  }
152
245
  export function openFileSecrets(options) {
153
246
  if (typeof options?.configDir !== 'string' || !isAbsolute(options.configDir))
@@ -157,7 +250,15 @@ export function openFileSecrets(options) {
157
250
  const { connector } = options;
158
251
  const dir = handleDirectory(options.configDir, connector);
159
252
  return {
253
+ directory: dir,
160
254
  list: () => listHandles(dir, connector),
161
255
  put: (name, value, putOptions = {}) => putHandle(dir, connector, name, value, putOptions),
256
+ withLock: (fn) => {
257
+ ensureDirectory(dir, connector);
258
+ const r = locked(dir, connector, fn);
259
+ if (!r.ok)
260
+ throw new Error(r.problem);
261
+ return r.value;
262
+ },
162
263
  };
163
264
  }
package/dist/seed.d.ts ADDED
@@ -0,0 +1,35 @@
1
+ import { type MountRefusal } from './mount.js';
2
+ export interface DefaultConnector {
3
+ readonly name: string;
4
+ readonly since: number;
5
+ }
6
+ export declare const DEFAULT_CONNECTORS: readonly DefaultConnector[];
7
+ export declare const SEED_VERSION: number;
8
+ export interface SeedOptions {
9
+ readonly configDir: string;
10
+ }
11
+ export type SeedReading = {
12
+ readonly name: string;
13
+ readonly outcome: 'mounted' | 'present' | 'conflict';
14
+ } | {
15
+ readonly name: string;
16
+ readonly outcome: 'not_installed';
17
+ readonly message: string;
18
+ } | {
19
+ readonly name: string;
20
+ readonly outcome: 'refused';
21
+ readonly reason: MountRefusal;
22
+ readonly message: string;
23
+ };
24
+ export type SeedRefusal = 'config_dir' | 'locked' | 'unreadable' | 'unexpected_shape' | 'write_failed';
25
+ export type SeedResult = {
26
+ readonly ok: true;
27
+ readonly readings: readonly SeedReading[];
28
+ readonly marker: string;
29
+ } | {
30
+ readonly ok: false;
31
+ readonly reason: SeedRefusal;
32
+ readonly message: string;
33
+ readonly readings: readonly SeedReading[];
34
+ };
35
+ export declare function seed(options: SeedOptions): SeedResult;
package/dist/seed.js ADDED
@@ -0,0 +1,134 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { absoluteDir } from './config-dir.js';
5
+ import { hasRepeatedKey } from './json-text.js';
6
+ import { mount, nameRefusal } from './mount.js';
7
+ import { takeLockFile } from './lock-file.js';
8
+ import { resolveConnector } from './resolve-connector.js';
9
+ import { writeFailure, writeAll } from './write-all.js';
10
+ export const DEFAULT_CONNECTORS = Object.freeze([Object.freeze({ name: 'gitea', since: 1 }), Object.freeze({ name: 'github', since: 1 })]);
11
+ export const SEED_VERSION = newestVersion(DEFAULT_CONNECTORS);
12
+ function newestVersion(catalog) {
13
+ const names = new Set();
14
+ for (const c of catalog) {
15
+ const refusal = nameRefusal(c.name);
16
+ if (refusal !== null)
17
+ throw new Error(`the default catalog names a connector mount refuses (${refusal.reason})`);
18
+ if (names.has(c.name))
19
+ throw new Error(`the default catalog names ${c.name} twice`);
20
+ names.add(c.name);
21
+ if (!Number.isSafeInteger(c.since) || c.since < 1)
22
+ throw new Error(`the default catalog gives ${c.name} a since that is not a positive integer`);
23
+ }
24
+ if (catalog.length === 0)
25
+ throw new Error('the default catalog is empty');
26
+ return Math.max(...catalog.map((c) => c.since));
27
+ }
28
+ const MARKER_FILE = '.sema-connector-seed.json';
29
+ const SEED_LOCK_FILE = `${MARKER_FILE}.lock`;
30
+ const RECORDED = ['mounted', 'present', 'not_installed'];
31
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
32
+ function readMarker(file) {
33
+ const where = `config.d/${MARKER_FILE}`;
34
+ let text;
35
+ try {
36
+ text = readFileSync(file, 'utf8');
37
+ }
38
+ catch (e) {
39
+ if (e.code === 'ENOENT')
40
+ return { marker: null };
41
+ return { refusal: 'unreadable', message: `${where} cannot be read` };
42
+ }
43
+ let doc;
44
+ try {
45
+ doc = JSON.parse(text);
46
+ }
47
+ catch {
48
+ return { refusal: 'unreadable', message: `${where} is not valid JSON` };
49
+ }
50
+ const shape = `${where} must be one JSON object {"version": <a positive integer>, "entries": {"<name>": "mounted" | "present" | "not_installed"}}`;
51
+ if (!isObject(doc) || Object.keys(doc).sort().join() !== 'entries,version' || hasRepeatedKey(text))
52
+ return { refusal: 'unexpected_shape', message: shape };
53
+ const { version, entries } = doc;
54
+ if (typeof version !== 'number' || !Number.isSafeInteger(version) || version < 1 || !isObject(entries))
55
+ return { refusal: 'unexpected_shape', message: shape };
56
+ for (const [name, value] of Object.entries(entries)) {
57
+ if (nameRefusal(name) !== null || typeof value !== 'string' || !RECORDED.includes(value))
58
+ return { refusal: 'unexpected_shape', message: shape };
59
+ }
60
+ return { marker: { version, entries: entries } };
61
+ }
62
+ export function seed(options) {
63
+ const configDir = absoluteDir(options?.configDir);
64
+ if (configDir === null)
65
+ return { ok: false, reason: 'config_dir', message: 'configDir must be the configuration directory as an absolute path; the seed reads no environment variable and has no default', readings: [] };
66
+ const dir = join(configDir, 'config.d');
67
+ let unlock;
68
+ try {
69
+ mkdirSync(dir, { recursive: true });
70
+ unlock = takeLockFile(join(dir, SEED_LOCK_FILE));
71
+ }
72
+ catch (e) {
73
+ return { ok: false, reason: 'write_failed', message: `config.d or the seed's lock could not be written (${writeFailure(e)}); nothing was mounted`, readings: [] };
74
+ }
75
+ if (unlock === null)
76
+ return { ok: false, reason: 'locked', message: `another seed is running on this directory; nothing was mounted: try again, and if no seed is running remove config.d/${SEED_LOCK_FILE}`, readings: [] };
77
+ try {
78
+ return seedLocked(configDir, dir);
79
+ }
80
+ finally {
81
+ unlock();
82
+ }
83
+ }
84
+ function seedLocked(configDir, dir) {
85
+ const file = join(dir, MARKER_FILE);
86
+ const read = readMarker(file);
87
+ if ('refusal' in read)
88
+ return { ok: false, reason: read.refusal, message: `${read.message}; nothing was mounted`, readings: [] };
89
+ const before = read.marker;
90
+ const entries = { ...before?.entries };
91
+ const readings = [];
92
+ for (const { name } of DEFAULT_CONNECTORS) {
93
+ const recorded = before !== null && Object.hasOwn(before.entries, name) ? before.entries[name] : undefined;
94
+ if (recorded === 'mounted' || recorded === 'present') {
95
+ readings.push({ name, outcome: 'present' });
96
+ continue;
97
+ }
98
+ const resolved = resolveConnector(name);
99
+ if ('reason' in resolved) {
100
+ readings.push({ name, outcome: 'not_installed', message: `package ${resolved.packageName} ${resolved.reason}` });
101
+ entries[name] = 'not_installed';
102
+ continue;
103
+ }
104
+ const r = mount({ name, configDir });
105
+ if (r.ok) {
106
+ const outcome = r.changed ? 'mounted' : 'present';
107
+ readings.push({ name, outcome });
108
+ entries[name] = outcome;
109
+ }
110
+ else if (r.reason === 'conflict')
111
+ readings.push({ name, outcome: 'conflict' });
112
+ else
113
+ readings.push({ name, outcome: 'refused', reason: r.reason, message: r.message });
114
+ }
115
+ const after = { version: SEED_VERSION, entries };
116
+ if (before === null || JSON.stringify(after) !== JSON.stringify(before)) {
117
+ const tmp = join(dir, `${MARKER_FILE}.${randomBytes(6).toString('hex')}.tmp`);
118
+ try {
119
+ const fd = openSync(tmp, 'wx', 0o666);
120
+ try {
121
+ writeAll(fd, JSON.stringify(after, null, 2) + '\n');
122
+ }
123
+ finally {
124
+ closeSync(fd);
125
+ }
126
+ renameSync(tmp, file);
127
+ }
128
+ catch (e) {
129
+ rmSync(tmp, { force: true });
130
+ return { ok: false, reason: 'write_failed', message: `config.d/${MARKER_FILE} could not be written (${writeFailure(e)}); it was left as it was, and the entries mounted stay mounted`, readings };
131
+ }
132
+ }
133
+ return { ok: true, readings, marker: file };
134
+ }
package/dist/status.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ConnectorMintedStatus } from '@sema-agent/connectors-contract';
2
- import type { SecretHandle } from './secrets.js';
2
+ import { type SecretHandle } from './secrets.js';
3
3
  export declare const STATUS_MIME_TYPE = "application/json";
4
4
  export declare const AUTHORIZE_URL_PATTERN: RegExp;
5
5
  export declare function statusUri(connector: string): string;
package/dist/status.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isSecret } from './secrets.js';
1
2
  export const STATUS_MIME_TYPE = 'application/json';
2
3
  export const AUTHORIZE_URL_PATTERN = /^https?:\/\//;
3
4
  export function statusUri(connector) {
@@ -13,7 +14,8 @@ export function statusResource(connector) {
13
14
  };
14
15
  }
15
16
  export function mintStatus(input) {
16
- const state = input.handles.length === 0 ? 'unauthorized' : input.handles.some((h) => h.expiresAt !== undefined && Date.parse(h.expiresAt) <= input.nowMs) ? 'expired' : 'authorized';
17
+ const secrets = input.handles.filter(isSecret);
18
+ const state = secrets.length === 0 ? 'unauthorized' : secrets.some((h) => h.expiresAt !== undefined && Date.parse(h.expiresAt) <= input.nowMs) ? 'expired' : 'authorized';
17
19
  const link = state === 'unauthorized' && input.authorizeUrl !== undefined && AUTHORIZE_URL_PATTERN.test(input.authorizeUrl) ? { authorizeUrl: input.authorizeUrl } : {};
18
20
  return { state, tools: [...input.tools], ...link };
19
21
  }
@@ -0,0 +1,2 @@
1
+ export declare function writeAll(fd: number, data: string | Uint8Array): void;
2
+ export declare function writeFailure(e: unknown): string;
@@ -0,0 +1,19 @@
1
+ import { fstatSync, fsyncSync, writeSync } from 'node:fs';
2
+ export function writeAll(fd, data) {
3
+ const bytes = typeof data === 'string' ? Buffer.from(data, 'utf8') : data;
4
+ let written = 0;
5
+ while (written < bytes.length) {
6
+ const n = writeSync(fd, bytes, written, bytes.length - written, written);
7
+ if (n <= 0)
8
+ throw new Error(`a write stopped after ${written} of ${bytes.length} bytes`);
9
+ written += n;
10
+ }
11
+ fsyncSync(fd);
12
+ const size = fstatSync(fd).size;
13
+ if (size !== bytes.length)
14
+ throw new Error(`the file holds ${size} bytes after a write of ${bytes.length}`);
15
+ }
16
+ export function writeFailure(e) {
17
+ const code = e?.code;
18
+ return typeof code === 'string' ? code : e instanceof Error ? e.message : 'unknown error';
19
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/connectors",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Shared connector layer and the sema-connector launcher (MCP stdio proxy with principal, scope, normalize, status and trace stages).",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",