@sema-agent/connectors 0.0.0-stage → 0.2.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 +247 -0
- package/bin/sema-connector.mjs +6 -0
- package/dist/cli-kit.d.ts +33 -0
- package/dist/cli-kit.js +223 -0
- package/dist/config-dir.d.ts +11 -0
- package/dist/config-dir.js +12 -0
- package/dist/connector-name.d.ts +1 -0
- package/dist/connector-name.js +1 -0
- package/dist/http.d.ts +26 -0
- package/dist/http.js +264 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +7 -0
- package/dist/json-text.d.ts +11 -0
- package/dist/json-text.js +93 -0
- package/dist/jwks.d.ts +19 -0
- package/dist/jwks.js +81 -0
- package/dist/launcher.d.ts +2 -0
- package/dist/launcher.js +585 -0
- package/dist/lock-file.d.ts +1 -0
- package/dist/lock-file.js +34 -0
- package/dist/mount.d.ts +23 -0
- package/dist/mount.js +160 -0
- package/dist/normalize.d.ts +1 -0
- package/dist/normalize.js +19 -0
- package/dist/principal.d.ts +50 -0
- package/dist/principal.js +127 -0
- package/dist/redact.d.ts +18 -0
- package/dist/redact.js +121 -0
- package/dist/scope.d.ts +1 -0
- package/dist/scope.js +5 -0
- package/dist/secrets.d.ts +49 -0
- package/dist/secrets.js +264 -0
- package/dist/session.d.ts +59 -0
- package/dist/session.js +256 -0
- package/dist/status.d.ts +12 -0
- package/dist/status.js +21 -0
- package/dist/write-all.d.ts +1 -0
- package/dist/write-all.js +15 -0
- package/package.json +37 -4
- package/README.md +0 -3
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { closeSync, constants, fchmodSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, renameSync, rmSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, isAbsolute, join } from 'node:path';
|
|
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;
|
|
9
|
+
export const HANDLE_NAME = /^[A-Z][A-Z0-9_]*$/;
|
|
10
|
+
const META_SUFFIX = '.meta.json';
|
|
11
|
+
const META_KEYS = ['expiresAt', 'secret'];
|
|
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$/;
|
|
13
|
+
const FILE_MODE = 0o600;
|
|
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;
|
|
18
|
+
export function handleDirectory(configDir, connector) {
|
|
19
|
+
return join(configDir, 'connectors', connector);
|
|
20
|
+
}
|
|
21
|
+
export function handleFilePath(configDir, handle) {
|
|
22
|
+
return join(handleDirectory(configDir, handle.connector), handle.name);
|
|
23
|
+
}
|
|
24
|
+
export function readSecretValue(configDir, handle) {
|
|
25
|
+
const fd = openSync(handleFilePath(configDir, handle), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
26
|
+
try {
|
|
27
|
+
return readFileSync(fd);
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
closeSync(fd);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
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 });
|
|
84
|
+
try {
|
|
85
|
+
const files = new Map();
|
|
86
|
+
const values = [];
|
|
87
|
+
for (const handle of listing.handles) {
|
|
88
|
+
const bytes = readSecretValue(configDir, handle);
|
|
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
|
+
}
|
|
97
|
+
files.set(handle.name, path);
|
|
98
|
+
if (isSecret(handle))
|
|
99
|
+
values.push(bytes.toString('utf8'));
|
|
100
|
+
}
|
|
101
|
+
return { ok: true, handles: listing.handles, files, values, remove };
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
remove();
|
|
105
|
+
throw e;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
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
|
+
}
|
|
128
|
+
function entryProblem(path, kind, label) {
|
|
129
|
+
let st;
|
|
130
|
+
try {
|
|
131
|
+
st = lstatSync(path);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return `${label} cannot be read`;
|
|
135
|
+
}
|
|
136
|
+
if (st.isSymbolicLink())
|
|
137
|
+
return `${label} is a symbolic link`;
|
|
138
|
+
if (kind === 'directory' ? !st.isDirectory() : !st.isFile())
|
|
139
|
+
return `${label} is not a ${kind === 'directory' ? 'directory' : 'regular file'}`;
|
|
140
|
+
const allowed = kind === 'directory' ? DIRECTORY_MODE : FILE_MODE;
|
|
141
|
+
if ((st.mode & 0o777 & ~allowed) !== 0)
|
|
142
|
+
return `${label} has permissions wider than ${kind === 'directory' ? '0700' : '0600'}`;
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
function listHandles(dir, connector) {
|
|
146
|
+
const refuse = (problem) => ({ ok: false, problem: `connector ${connector}: ${problem}` });
|
|
147
|
+
try {
|
|
148
|
+
lstatSync(dir);
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
if (e.code === 'ENOENT')
|
|
152
|
+
return { ok: true, handles: [] };
|
|
153
|
+
return refuse('the secrets directory cannot be read');
|
|
154
|
+
}
|
|
155
|
+
const dirProblem = entryProblem(dir, 'directory', 'the secrets directory');
|
|
156
|
+
if (dirProblem !== null)
|
|
157
|
+
return refuse(dirProblem);
|
|
158
|
+
let names;
|
|
159
|
+
try {
|
|
160
|
+
names = readdirSync(dir).sort();
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return refuse('the secrets directory cannot be listed');
|
|
164
|
+
}
|
|
165
|
+
const handles = [];
|
|
166
|
+
for (const name of names) {
|
|
167
|
+
if (!HANDLE_NAME.test(name))
|
|
168
|
+
continue;
|
|
169
|
+
const problem = entryProblem(join(dir, name), 'file', `secret file ${name}`);
|
|
170
|
+
if (problem !== null)
|
|
171
|
+
return refuse(problem);
|
|
172
|
+
const metaFile = name + META_SUFFIX;
|
|
173
|
+
if (!names.includes(metaFile)) {
|
|
174
|
+
handles.push({ connector, name, source: 'file', secret: true });
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const metaProblem = entryProblem(join(dir, metaFile), 'file', `metadata file ${metaFile}`);
|
|
178
|
+
if (metaProblem !== null)
|
|
179
|
+
return refuse(metaProblem);
|
|
180
|
+
let meta;
|
|
181
|
+
try {
|
|
182
|
+
meta = readMeta(readFileSync(join(dir, metaFile), 'utf8'));
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
meta = null;
|
|
186
|
+
}
|
|
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 });
|
|
190
|
+
}
|
|
191
|
+
return { ok: true, handles };
|
|
192
|
+
}
|
|
193
|
+
function writePrivately(path, text) {
|
|
194
|
+
const tmp = join(dirname(path), `.${basename(path)}.${randomBytes(6).toString('hex')}.tmp`);
|
|
195
|
+
try {
|
|
196
|
+
const fd = openSync(tmp, 'wx', FILE_MODE);
|
|
197
|
+
try {
|
|
198
|
+
fchmodSync(fd, FILE_MODE);
|
|
199
|
+
writeAll(fd, text);
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
closeSync(fd);
|
|
203
|
+
}
|
|
204
|
+
renameSync(tmp, path);
|
|
205
|
+
}
|
|
206
|
+
catch (e) {
|
|
207
|
+
rmSync(tmp, { force: true });
|
|
208
|
+
throw e;
|
|
209
|
+
}
|
|
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
|
+
}
|
|
230
|
+
function putHandle(dir, connector, name, value, options) {
|
|
231
|
+
if (!HANDLE_NAME.test(name))
|
|
232
|
+
throw new Error('a handle name is upper-case letters, digits and underscores, starting with a letter');
|
|
233
|
+
if (typeof value !== 'string')
|
|
234
|
+
throw new Error('a secret value is a string');
|
|
235
|
+
if (options.expiresAt !== undefined && !validExpiry(options.expiresAt))
|
|
236
|
+
throw new Error('expiresAt must be an RFC 3339 UTC time ending in Z');
|
|
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;
|
|
244
|
+
}
|
|
245
|
+
export function openFileSecrets(options) {
|
|
246
|
+
if (typeof options?.configDir !== 'string' || !isAbsolute(options.configDir))
|
|
247
|
+
throw new Error('configDir must be an absolute path');
|
|
248
|
+
if (typeof options.connector !== 'string' || !CONNECTOR_NAME.test(options.connector))
|
|
249
|
+
throw new Error('connector must be lowercase letters and digits in hyphen-separated words');
|
|
250
|
+
const { connector } = options;
|
|
251
|
+
const dir = handleDirectory(options.configDir, connector);
|
|
252
|
+
return {
|
|
253
|
+
directory: dir,
|
|
254
|
+
list: () => listHandles(dir, connector),
|
|
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
|
+
},
|
|
263
|
+
};
|
|
264
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ConnectorErrorCode, ConnectorMintedStatus, ConnectorReceipt } from '@sema-agent/connectors-contract';
|
|
2
|
+
import type { Principal } from './principal.js';
|
|
3
|
+
export type JsonRpcId = string | number;
|
|
4
|
+
export type Action = {
|
|
5
|
+
readonly to: 'upstream' | 'connector';
|
|
6
|
+
readonly data: Buffer | string;
|
|
7
|
+
} | {
|
|
8
|
+
readonly to: 'receipt';
|
|
9
|
+
readonly receipt: ConnectorReceipt;
|
|
10
|
+
};
|
|
11
|
+
export declare const INSPECT_LIMIT_BYTES: number;
|
|
12
|
+
export declare const INVALID_REQUEST = -32600;
|
|
13
|
+
export declare const INTERNAL_ERROR = -32603;
|
|
14
|
+
export declare const STATUS_BEFORE_INITIALIZE = "the status is not available before the connector has answered initialize";
|
|
15
|
+
export type Message = Record<string, unknown>;
|
|
16
|
+
export interface SessionOptions {
|
|
17
|
+
readonly connector: string;
|
|
18
|
+
readonly status: (tools: readonly string[]) => ConnectorMintedStatus;
|
|
19
|
+
readonly ownIdPrefix: string;
|
|
20
|
+
readonly withheld: (line: Buffer, parsed: unknown) => boolean;
|
|
21
|
+
}
|
|
22
|
+
export declare const isId: (v: unknown) => v is JsonRpcId;
|
|
23
|
+
export declare const idKey: (id: JsonRpcId) => string;
|
|
24
|
+
export type Reading = {
|
|
25
|
+
readonly kind: 'message';
|
|
26
|
+
readonly m: Message;
|
|
27
|
+
} | {
|
|
28
|
+
readonly kind: 'unreadable';
|
|
29
|
+
} | {
|
|
30
|
+
readonly kind: 'other';
|
|
31
|
+
readonly value?: unknown;
|
|
32
|
+
};
|
|
33
|
+
export declare function read(raw: Buffer): Reading;
|
|
34
|
+
export declare const errorLine: (id: JsonRpcId | null, code: number, message: string, dataCode: ConnectorErrorCode) => string;
|
|
35
|
+
export declare class Session {
|
|
36
|
+
private readonly options;
|
|
37
|
+
private readonly uri;
|
|
38
|
+
private readonly pending;
|
|
39
|
+
private readonly own;
|
|
40
|
+
private readonly retired;
|
|
41
|
+
private capabilities;
|
|
42
|
+
private ownCount;
|
|
43
|
+
constructor(options: SessionOptions);
|
|
44
|
+
fromUpstream(raw: Buffer, principal: Principal): Action[];
|
|
45
|
+
statusFor(id: JsonRpcId, principal: Principal): Action[];
|
|
46
|
+
upstreamOversized(): Action[];
|
|
47
|
+
fromConnector(raw: Buffer): Action[];
|
|
48
|
+
connectorOversized(): Action[];
|
|
49
|
+
private connectorUnreadable;
|
|
50
|
+
connectorGone(how: string, abnormal: boolean): Action[];
|
|
51
|
+
private forward;
|
|
52
|
+
private settle;
|
|
53
|
+
private settleReply;
|
|
54
|
+
private visibleTools;
|
|
55
|
+
private rewrite;
|
|
56
|
+
private readStatus;
|
|
57
|
+
private askTools;
|
|
58
|
+
private statusReply;
|
|
59
|
+
}
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { hasRepeatedKey } from './json-text.js';
|
|
2
|
+
import { normalizeTool } from './normalize.js';
|
|
3
|
+
import { REDACTED } from './redact.js';
|
|
4
|
+
import { isGranted } from './scope.js';
|
|
5
|
+
import { STATUS_MIME_TYPE, statusResource, statusUri } from './status.js';
|
|
6
|
+
export const INSPECT_LIMIT_BYTES = 4 * 1024 * 1024;
|
|
7
|
+
const LIMIT_TEXT = `${INSPECT_LIMIT_BYTES / (1024 * 1024)} MiB`;
|
|
8
|
+
const MAX_STATUS_PAGES = 64;
|
|
9
|
+
const FORBIDDEN_SCOPE = 'connector.forbidden_scope';
|
|
10
|
+
const INTERNAL = 'connector.internal';
|
|
11
|
+
export const INVALID_REQUEST = -32600;
|
|
12
|
+
const INVALID_PARAMS = -32602;
|
|
13
|
+
export const INTERNAL_ERROR = -32603;
|
|
14
|
+
const UNREADABLE_REQUEST = `the launcher forwards only single JSON-RPC messages of at most ${LIMIT_TEXT}, on one line and without repeated keys, which it can check against the granted scope`;
|
|
15
|
+
const WITHHELD = 'the connector wrote one of its secret values into this reply, so the launcher withheld it';
|
|
16
|
+
const NO_ID = 'the launcher forwards a request only with a string or number id, and needs one on the requests whose replies it rewrites or answers';
|
|
17
|
+
export const STATUS_BEFORE_INITIALIZE = 'the status is not available before the connector has answered initialize';
|
|
18
|
+
const TAKEN_ID = 'the connector may still answer an earlier request with this id (it has not answered it yet, or the launcher answered it after a line it could not read); use another id';
|
|
19
|
+
const UNREADABLE_REPLY = `the connector wrote a message the launcher cannot read (over ${LIMIT_TEXT}, a batch, or a repeated key), which may have been this reply`;
|
|
20
|
+
const ERROR_CODES = new Set(Object.keys({
|
|
21
|
+
'connector.auth_required': 0,
|
|
22
|
+
'connector.auth_expired': 0,
|
|
23
|
+
'connector.forbidden_scope': 0,
|
|
24
|
+
'connector.upstream_unreachable': 0,
|
|
25
|
+
'connector.bad_assertion': 0,
|
|
26
|
+
'connector.internal': 0,
|
|
27
|
+
'connector.not_installed': 0,
|
|
28
|
+
}));
|
|
29
|
+
const NEWLINE = Buffer.from('\n');
|
|
30
|
+
const LINE_FEED = 0x0a;
|
|
31
|
+
const CARRIAGE_RETURN = 0x0d;
|
|
32
|
+
export const isId = (v) => typeof v === 'string' || typeof v === 'number';
|
|
33
|
+
export const idKey = (id) => `${typeof id}:${String(id)}`;
|
|
34
|
+
const asObject = (v) => (v !== null && typeof v === 'object' && !Array.isArray(v) ? v : null);
|
|
35
|
+
export function read(raw) {
|
|
36
|
+
const text = raw.toString('utf8');
|
|
37
|
+
let value;
|
|
38
|
+
try {
|
|
39
|
+
value = JSON.parse(text);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return { kind: 'other' };
|
|
43
|
+
}
|
|
44
|
+
if (Array.isArray(value))
|
|
45
|
+
return { kind: 'unreadable' };
|
|
46
|
+
const m = asObject(value);
|
|
47
|
+
if (m === null)
|
|
48
|
+
return { kind: 'other', value };
|
|
49
|
+
return hasRepeatedKey(text) ? { kind: 'unreadable' } : { kind: 'message', m };
|
|
50
|
+
}
|
|
51
|
+
const lineOf = (m) => JSON.stringify(m) + '\n';
|
|
52
|
+
export const errorLine = (id, code, message, dataCode) => lineOf({ jsonrpc: '2.0', id, error: { code, message, data: { code: dataCode } } });
|
|
53
|
+
const up = (data) => ({ to: 'upstream', data });
|
|
54
|
+
const down = (data) => ({ to: 'connector', data });
|
|
55
|
+
const same = (raw) => Buffer.concat([raw, NEWLINE]);
|
|
56
|
+
export class Session {
|
|
57
|
+
options;
|
|
58
|
+
uri;
|
|
59
|
+
pending = new Map();
|
|
60
|
+
own = new Map();
|
|
61
|
+
retired = new Set();
|
|
62
|
+
capabilities = null;
|
|
63
|
+
ownCount = 0;
|
|
64
|
+
constructor(options) {
|
|
65
|
+
this.options = options;
|
|
66
|
+
this.uri = statusUri(options.connector);
|
|
67
|
+
}
|
|
68
|
+
fromUpstream(raw, principal) {
|
|
69
|
+
const startedAt = new Date().toISOString();
|
|
70
|
+
const started = performance.now();
|
|
71
|
+
const grant = principal.scope;
|
|
72
|
+
const body = raw[raw.length - 1] === CARRIAGE_RETURN ? raw.subarray(0, -1) : raw;
|
|
73
|
+
if (body.includes(LINE_FEED) || body.includes(CARRIAGE_RETURN))
|
|
74
|
+
return [up(errorLine(null, INVALID_REQUEST, UNREADABLE_REQUEST, FORBIDDEN_SCOPE))];
|
|
75
|
+
const r = read(raw);
|
|
76
|
+
if (r.kind !== 'message')
|
|
77
|
+
return [up(errorLine(null, INVALID_REQUEST, UNREADABLE_REQUEST, FORBIDDEN_SCOPE))];
|
|
78
|
+
const m = r.m;
|
|
79
|
+
if (typeof m.method !== 'string')
|
|
80
|
+
return [down(same(raw))];
|
|
81
|
+
const params = asObject(m.params);
|
|
82
|
+
const name = params?.name;
|
|
83
|
+
const call = m.method === 'tools/call' && typeof name === 'string' && name !== '' ? { tool: name, startedAt, started } : undefined;
|
|
84
|
+
if ('id' in m && !isId(m.id))
|
|
85
|
+
return [up(errorLine(null, INVALID_REQUEST, NO_ID, FORBIDDEN_SCOPE)), ...this.settle(call, 'refused', FORBIDDEN_SCOPE)];
|
|
86
|
+
const id = isId(m.id) ? m.id : null;
|
|
87
|
+
if (id !== null && (this.pending.has(idKey(id)) || this.retired.has(idKey(id))))
|
|
88
|
+
return [up(errorLine(id, INVALID_REQUEST, TAKEN_ID, FORBIDDEN_SCOPE)), ...this.settle(call, 'refused', FORBIDDEN_SCOPE)];
|
|
89
|
+
const needsId = () => [up(errorLine(null, INVALID_REQUEST, NO_ID, FORBIDDEN_SCOPE))];
|
|
90
|
+
switch (m.method) {
|
|
91
|
+
case 'tools/call': {
|
|
92
|
+
if (typeof name !== 'string' || !isGranted(grant, name))
|
|
93
|
+
return [up(errorLine(id, INVALID_PARAMS, 'the requested tool is not in the granted scope', FORBIDDEN_SCOPE)), ...this.settle(call, 'refused', FORBIDDEN_SCOPE)];
|
|
94
|
+
return this.forward(raw, id, grant, undefined, undefined, call);
|
|
95
|
+
}
|
|
96
|
+
case 'resources/read':
|
|
97
|
+
if (params?.uri !== this.uri)
|
|
98
|
+
return this.forward(raw, id, grant);
|
|
99
|
+
return id === null ? needsId() : this.readStatus(id, grant);
|
|
100
|
+
case 'resources/list': {
|
|
101
|
+
if (id === null)
|
|
102
|
+
return needsId();
|
|
103
|
+
const firstPage = params?.cursor === undefined;
|
|
104
|
+
if (this.capabilities !== null && asObject(this.capabilities.resources) === null) {
|
|
105
|
+
return [up(lineOf({ jsonrpc: '2.0', id, result: { resources: firstPage ? [statusResource(this.options.connector)] : [] } }))];
|
|
106
|
+
}
|
|
107
|
+
return this.forward(raw, id, grant, 'resources/list', firstPage);
|
|
108
|
+
}
|
|
109
|
+
case 'initialize':
|
|
110
|
+
case 'tools/list':
|
|
111
|
+
return id === null ? needsId() : this.forward(raw, id, grant, m.method);
|
|
112
|
+
default:
|
|
113
|
+
return this.forward(raw, id, grant);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
statusFor(id, principal) {
|
|
117
|
+
return this.readStatus(id, principal.scope);
|
|
118
|
+
}
|
|
119
|
+
upstreamOversized() {
|
|
120
|
+
return [up(errorLine(null, INVALID_REQUEST, UNREADABLE_REQUEST, FORBIDDEN_SCOPE))];
|
|
121
|
+
}
|
|
122
|
+
fromConnector(raw) {
|
|
123
|
+
const r = read(raw);
|
|
124
|
+
if (r.kind === 'unreadable')
|
|
125
|
+
return this.connectorUnreadable();
|
|
126
|
+
const withheld = this.options.withheld(raw, r.kind === 'message' ? r.m : r.value);
|
|
127
|
+
const pass = () => (withheld ? [] : [up(same(raw))]);
|
|
128
|
+
if (r.kind === 'other')
|
|
129
|
+
return pass();
|
|
130
|
+
const m = r.m;
|
|
131
|
+
if (m.method !== undefined)
|
|
132
|
+
return pass();
|
|
133
|
+
if (!isId(m.id) || !('result' in m || 'error' in m))
|
|
134
|
+
return pass();
|
|
135
|
+
const key = idKey(m.id);
|
|
136
|
+
if (this.retired.delete(key))
|
|
137
|
+
return [];
|
|
138
|
+
const own = this.own.get(key);
|
|
139
|
+
if (own !== undefined) {
|
|
140
|
+
const actions = withheld ? [up(errorLine(own.upstreamId, INTERNAL_ERROR, WITHHELD, INTERNAL))] : own.handle(m);
|
|
141
|
+
this.own.delete(key);
|
|
142
|
+
return actions;
|
|
143
|
+
}
|
|
144
|
+
const p = this.pending.get(key);
|
|
145
|
+
if (p === undefined)
|
|
146
|
+
return [];
|
|
147
|
+
const data = withheld ? errorLine(p.id, INTERNAL_ERROR, WITHHELD, INTERNAL) : p.rewrite === undefined || !('result' in m) ? same(raw) : this.rewrite(p, m, raw);
|
|
148
|
+
this.pending.delete(key);
|
|
149
|
+
return [up(data), ...(withheld ? this.settle(p.call, 'failed', INTERNAL) : this.settleReply(p.call, m))];
|
|
150
|
+
}
|
|
151
|
+
connectorOversized() {
|
|
152
|
+
return this.connectorUnreadable();
|
|
153
|
+
}
|
|
154
|
+
connectorUnreadable() {
|
|
155
|
+
const actions = [
|
|
156
|
+
...[...this.pending.values()].flatMap((p) => [up(errorLine(p.id, INTERNAL_ERROR, UNREADABLE_REPLY, INTERNAL)), ...this.settle(p.call, 'failed', INTERNAL)]),
|
|
157
|
+
...[...this.own.values()].map((o) => up(errorLine(o.upstreamId, INTERNAL_ERROR, UNREADABLE_REPLY, INTERNAL))),
|
|
158
|
+
];
|
|
159
|
+
for (const key of this.pending.keys())
|
|
160
|
+
this.retired.add(key);
|
|
161
|
+
this.pending.clear();
|
|
162
|
+
this.own.clear();
|
|
163
|
+
return actions;
|
|
164
|
+
}
|
|
165
|
+
connectorGone(how, abnormal) {
|
|
166
|
+
const actions = [
|
|
167
|
+
...[...this.pending.values()].flatMap((p) => [up(errorLine(p.id, INTERNAL_ERROR, `${how} before it answered`, INTERNAL)), ...this.settle(p.call, 'failed', INTERNAL)]),
|
|
168
|
+
...[...this.own.values()].map((o) => up(errorLine(o.upstreamId, INTERNAL_ERROR, 'the connector ended before it listed its tools for the status', INTERNAL))),
|
|
169
|
+
];
|
|
170
|
+
if (abnormal && this.pending.size === 0)
|
|
171
|
+
actions.push(up(errorLine(null, INTERNAL_ERROR, how, INTERNAL)));
|
|
172
|
+
this.pending.clear();
|
|
173
|
+
this.own.clear();
|
|
174
|
+
return actions;
|
|
175
|
+
}
|
|
176
|
+
forward(raw, id, grant, rewrite, firstPage, call) {
|
|
177
|
+
if (id !== null)
|
|
178
|
+
this.pending.set(idKey(id), { id, grant, ...(rewrite !== undefined ? { rewrite } : {}), ...(firstPage !== undefined ? { firstPage } : {}), ...(call !== undefined ? { call } : {}) });
|
|
179
|
+
return [down(same(raw))];
|
|
180
|
+
}
|
|
181
|
+
settle(call, outcome, errorCode) {
|
|
182
|
+
if (call === undefined)
|
|
183
|
+
return [];
|
|
184
|
+
const durationMs = Math.max(0, Math.round(performance.now() - call.started));
|
|
185
|
+
const tool = this.options.withheld(Buffer.from(call.tool, 'utf8'), call.tool) ? REDACTED : call.tool;
|
|
186
|
+
return [{ to: 'receipt', receipt: { connector: this.options.connector, tool, startedAt: call.startedAt, durationMs, outcome, ...(errorCode !== undefined ? { errorCode } : {}) } }];
|
|
187
|
+
}
|
|
188
|
+
settleReply(call, m) {
|
|
189
|
+
if ('error' in m) {
|
|
190
|
+
const code = asObject(asObject(m.error)?.data)?.code;
|
|
191
|
+
return this.settle(call, 'failed', typeof code === 'string' && ERROR_CODES.has(code) ? code : undefined);
|
|
192
|
+
}
|
|
193
|
+
return this.settle(call, asObject(m.result)?.isError === true ? 'failed' : 'ok');
|
|
194
|
+
}
|
|
195
|
+
visibleTools(tools, grant) {
|
|
196
|
+
return tools.flatMap((t) => {
|
|
197
|
+
const tool = asObject(t);
|
|
198
|
+
return tool !== null && typeof tool.name === 'string' && isGranted(grant, tool.name) ? [normalizeTool(tool)] : [];
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
rewrite(p, m, raw) {
|
|
202
|
+
const result = asObject(m.result);
|
|
203
|
+
if (p.rewrite === 'initialize') {
|
|
204
|
+
const capabilities = result === null ? null : asObject(result.capabilities);
|
|
205
|
+
if (result === null || capabilities === null)
|
|
206
|
+
return errorLine(p.id, INTERNAL_ERROR, 'the connector answered initialize without capabilities', INTERNAL);
|
|
207
|
+
this.capabilities = capabilities;
|
|
208
|
+
if (asObject(capabilities.resources) !== null)
|
|
209
|
+
return same(raw);
|
|
210
|
+
return lineOf({ ...m, result: { ...result, capabilities: { ...capabilities, resources: {} } } });
|
|
211
|
+
}
|
|
212
|
+
if (p.rewrite === 'tools/list') {
|
|
213
|
+
const tools = result?.tools;
|
|
214
|
+
if (result === null || !Array.isArray(tools))
|
|
215
|
+
return errorLine(p.id, INTERNAL_ERROR, 'the connector answered tools/list without a list of tools', INTERNAL);
|
|
216
|
+
return lineOf({ ...m, result: { ...result, tools: this.visibleTools(tools, p.grant) } });
|
|
217
|
+
}
|
|
218
|
+
const resources = result?.resources;
|
|
219
|
+
if (result === null || !Array.isArray(resources))
|
|
220
|
+
return errorLine(p.id, INTERNAL_ERROR, 'the connector answered resources/list without a list of resources', INTERNAL);
|
|
221
|
+
const kept = resources.filter((r) => asObject(r)?.uri !== this.uri);
|
|
222
|
+
return lineOf({ ...m, result: { ...result, resources: p.firstPage ? [...kept, statusResource(this.options.connector)] : kept } });
|
|
223
|
+
}
|
|
224
|
+
readStatus(id, grant) {
|
|
225
|
+
if (this.capabilities === null)
|
|
226
|
+
return [up(errorLine(id, INTERNAL_ERROR, STATUS_BEFORE_INITIALIZE, INTERNAL))];
|
|
227
|
+
if (asObject(this.capabilities.tools) === null)
|
|
228
|
+
return [up(this.statusReply(id, []))];
|
|
229
|
+
return this.askTools(id, undefined, [], 1, grant);
|
|
230
|
+
}
|
|
231
|
+
askTools(upstreamId, cursor, names, page, grant) {
|
|
232
|
+
const ownId = `${this.options.ownIdPrefix}/${++this.ownCount}`;
|
|
233
|
+
this.own.set(idKey(ownId), {
|
|
234
|
+
upstreamId,
|
|
235
|
+
handle: (reply) => {
|
|
236
|
+
const result = asObject(reply.result);
|
|
237
|
+
const tools = result?.tools;
|
|
238
|
+
if (result === null || !Array.isArray(tools))
|
|
239
|
+
return [up(this.statusReply(upstreamId, []))];
|
|
240
|
+
for (const t of this.visibleTools(tools, grant))
|
|
241
|
+
names.push(t.name);
|
|
242
|
+
const next = result.nextCursor;
|
|
243
|
+
if (typeof next !== 'string' || next === '')
|
|
244
|
+
return [up(this.statusReply(upstreamId, names))];
|
|
245
|
+
if (page >= MAX_STATUS_PAGES)
|
|
246
|
+
return [up(errorLine(upstreamId, INTERNAL_ERROR, `the connector listed its tools in more than ${MAX_STATUS_PAGES} pages`, INTERNAL))];
|
|
247
|
+
return this.askTools(upstreamId, next, names, page + 1, grant);
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
return [down(lineOf({ jsonrpc: '2.0', id: ownId, method: 'tools/list', ...(cursor !== undefined ? { params: { cursor } } : {}) }))];
|
|
251
|
+
}
|
|
252
|
+
statusReply(id, tools) {
|
|
253
|
+
const text = JSON.stringify(this.options.status(tools));
|
|
254
|
+
return lineOf({ jsonrpc: '2.0', id, result: { contents: [{ uri: this.uri, mimeType: STATUS_MIME_TYPE, text }] } });
|
|
255
|
+
}
|
|
256
|
+
}
|
package/dist/status.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ConnectorMintedStatus } from '@sema-agent/connectors-contract';
|
|
2
|
+
import { type SecretHandle } from './secrets.js';
|
|
3
|
+
export declare const STATUS_MIME_TYPE = "application/json";
|
|
4
|
+
export declare const AUTHORIZE_URL_PATTERN: RegExp;
|
|
5
|
+
export declare function statusUri(connector: string): string;
|
|
6
|
+
export declare function statusResource(connector: string): Record<string, string>;
|
|
7
|
+
export declare function mintStatus(input: {
|
|
8
|
+
readonly handles: readonly SecretHandle[];
|
|
9
|
+
readonly nowMs: number;
|
|
10
|
+
readonly tools: readonly string[];
|
|
11
|
+
readonly authorizeUrl?: string;
|
|
12
|
+
}): ConnectorMintedStatus;
|
package/dist/status.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { isSecret } from './secrets.js';
|
|
2
|
+
export const STATUS_MIME_TYPE = 'application/json';
|
|
3
|
+
export const AUTHORIZE_URL_PATTERN = /^https?:\/\//;
|
|
4
|
+
export function statusUri(connector) {
|
|
5
|
+
return `sema-connector://${connector}/status`;
|
|
6
|
+
}
|
|
7
|
+
export function statusResource(connector) {
|
|
8
|
+
return {
|
|
9
|
+
uri: statusUri(connector),
|
|
10
|
+
name: 'status',
|
|
11
|
+
title: 'Connector status',
|
|
12
|
+
description: 'Authorization state and visible tools of this connector, as connector-status-v1 JSON.',
|
|
13
|
+
mimeType: STATUS_MIME_TYPE,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export function mintStatus(input) {
|
|
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';
|
|
19
|
+
const link = state === 'unauthorized' && input.authorizeUrl !== undefined && AUTHORIZE_URL_PATTERN.test(input.authorizeUrl) ? { authorizeUrl: input.authorizeUrl } : {};
|
|
20
|
+
return { state, tools: [...input.tools], ...link };
|
|
21
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function writeAll(fd: number, data: string | Uint8Array): void;
|
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sema-agent/connectors",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Shared connector layer and the sema-connector launcher (MCP stdio proxy with principal, scope, normalize, status and trace stages).",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"bin": {
|
|
10
|
+
"sema-connector": "bin/sema-connector.mjs"
|
|
11
|
+
},
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"bin",
|
|
22
|
+
"CHANGELOG.md"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc -p tsconfig.json"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=22"
|
|
30
|
+
},
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/sema-agent/sema-connectors.git",
|
|
34
|
+
"directory": "packages/core"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
}
|
|
39
|
+
}
|