@sema-agent/connectors 0.1.0 → 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 +66 -0
- package/dist/cli-kit.d.ts +33 -0
- package/dist/cli-kit.js +223 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/jwks.d.ts +1 -0
- package/dist/jwks.js +4 -1
- package/dist/launcher.js +16 -16
- package/dist/lock-file.d.ts +1 -0
- package/dist/lock-file.js +34 -0
- package/dist/mount.d.ts +1 -1
- package/dist/mount.js +17 -32
- package/dist/scope.js +2 -0
- package/dist/secrets.d.ts +16 -1
- package/dist/secrets.js +139 -38
- package/dist/status.d.ts +1 -1
- package/dist/status.js +3 -1
- package/dist/write-all.d.ts +1 -0
- package/dist/write-all.js +15 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,72 @@ 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.2.0 (2026-10-15)
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Handles that are not secrets (CN-038). `<handle>.meta.json` may say
|
|
12
|
+
`"secret": false`; such a handle is delivered to the connector like every
|
|
13
|
+
handle (a per-launch 0600 copy named by `<NAME>_FILE`), but the launcher
|
|
14
|
+
does not withhold its value and the status does not count it. Every other
|
|
15
|
+
handle is a secret, as before. `openFileSecrets().put(name, value,
|
|
16
|
+
{ secret: false })` writes it, `SecretHandle.secret` reports it, and a
|
|
17
|
+
later `put` without it makes the handle a secret again.
|
|
18
|
+
- `CONNECTOR_DIR` (CN-038): the launcher gives its connector the absolute path
|
|
19
|
+
of the connector's directory, `<configuration directory>/connectors/<name>`
|
|
20
|
+
(standalone; it may not exist), so a connector no longer infers it from the
|
|
21
|
+
path of a handle copy. A hosted connector, which has no configuration
|
|
22
|
+
directory and no handles in this release, gets a fresh path of its own that
|
|
23
|
+
does not exist. A `CONNECTOR_DIR` set for the launcher never reaches the
|
|
24
|
+
connector.
|
|
25
|
+
- The lock of a connector's directory (CN-038), `.auth.lock`: every write of
|
|
26
|
+
the secrets backend and every launch's listing and copy of the handles hold
|
|
27
|
+
it, so a launch copies a group of writes before or after the group, never in
|
|
28
|
+
between. `openFileSecrets().withLock(fn)` holds it across several writes. A
|
|
29
|
+
taker waits up to 5 seconds for another holder, then refuses (the launcher
|
|
30
|
+
with exit 2 and `connector.internal`); only the holder removes the lock, and
|
|
31
|
+
one left by a process that died is removed by hand. `FileSecrets.directory`
|
|
32
|
+
names the connector's directory.
|
|
33
|
+
- The connector CLI kit (CN-039): `connectorCli` runs a connector package's
|
|
34
|
+
bin (no arguments: the connector's server; `install`, `auth`, `mount`,
|
|
35
|
+
`status` without arguments on the configuration directory of
|
|
36
|
+
`resolveConfigDir`), `cliOutput`, `readStdinLine`, `readStdinJson` (one line
|
|
37
|
+
holding a JSON object with exactly the given keys, each named once, every
|
|
38
|
+
value a string), `mountCommand`, `statusCommand` (the status resource read
|
|
39
|
+
through the launcher within 10 seconds) and `isLoopbackHttpOrHttps`, the
|
|
40
|
+
one rule for an address a connector sends requests to (the rule of a key set
|
|
41
|
+
URL).
|
|
42
|
+
- `writeAll(fd, data)` (CN-042): the one whole write. It writes every byte of
|
|
43
|
+
`data` into the empty file open at `fd` (a write that writes nothing or fails
|
|
44
|
+
ends it with an error), syncs the file and checks that its size is the number
|
|
45
|
+
of bytes, or throws. Every file this package writes goes through it, and a
|
|
46
|
+
connector package writes its own files with it.
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
|
|
50
|
+
- The status counts secret handles only: a connector whose only handles are
|
|
51
|
+
not secrets is `unauthorized`, and the expiry of a handle that is not a
|
|
52
|
+
secret changes nothing (CN-038).
|
|
53
|
+
- `<handle>.meta.json` must be a JSON object whose only keys are `expiresAt`
|
|
54
|
+
and `secret`, each named once; another key, a key named twice or a `secret`
|
|
55
|
+
that is not `true` or `false` makes the directory refused (another key used
|
|
56
|
+
to be ignored, and an object without `expiresAt` refused) (CN-038).
|
|
57
|
+
|
|
58
|
+
### Fixed
|
|
59
|
+
|
|
60
|
+
- A write cut short is no longer taken as done (CN-042). A write can stop short
|
|
61
|
+
without an error (a file size limit, a full disk); `mount` used to rename the
|
|
62
|
+
cut-short text over `mcp.json` and report it written, losing the other
|
|
63
|
+
entries, and `put` stored a cut-short value and reported success. Now nothing
|
|
64
|
+
is put in place before it is written whole: `mount` refuses with the new
|
|
65
|
+
reason `write_failed` and `put` throws, the temporary file is removed and the
|
|
66
|
+
old file (or its absence) stays as it was. The lock files and the per-launch
|
|
67
|
+
copies of the handles are written the same way.
|
|
68
|
+
- The scope judge never grants the empty tool name (CN-036): a `tools/call`
|
|
69
|
+
whose name is `""` is refused with `connector.forbidden_scope` also under
|
|
70
|
+
`tool:*` (or a bare `tool:`), and a listed tool whose name is `""` is not
|
|
71
|
+
listed.
|
|
72
|
+
|
|
7
73
|
## 0.1.0 (2026-10-14)
|
|
8
74
|
|
|
9
75
|
### Added
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type Env } from './config-dir.js';
|
|
2
|
+
export interface CliOutput {
|
|
3
|
+
readonly program: string;
|
|
4
|
+
say(line: string): void;
|
|
5
|
+
complain(line: string): void;
|
|
6
|
+
}
|
|
7
|
+
export declare function cliOutput(name: string): CliOutput;
|
|
8
|
+
export interface ConnectorCommands {
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly serve: (env: Env) => void | number | Promise<number>;
|
|
11
|
+
readonly install: (configDir: string) => number | Promise<number>;
|
|
12
|
+
readonly auth: (configDir: string) => number | Promise<number>;
|
|
13
|
+
readonly authReads: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function connectorCli(commands: ConnectorCommands, argv: readonly string[], env: Env): Promise<number | undefined>;
|
|
16
|
+
export type StdinLine = {
|
|
17
|
+
readonly ok: true;
|
|
18
|
+
readonly line: string;
|
|
19
|
+
} | {
|
|
20
|
+
readonly ok: false;
|
|
21
|
+
readonly problem: string;
|
|
22
|
+
};
|
|
23
|
+
export declare function readStdinLine(limit: number): Promise<StdinLine>;
|
|
24
|
+
export type StdinJson = {
|
|
25
|
+
readonly ok: true;
|
|
26
|
+
readonly values: Readonly<Record<string, string>>;
|
|
27
|
+
} | {
|
|
28
|
+
readonly ok: false;
|
|
29
|
+
readonly problem: string;
|
|
30
|
+
};
|
|
31
|
+
export declare function readStdinJson(limit: number, keys: readonly string[]): Promise<StdinJson>;
|
|
32
|
+
export declare function mountCommand(name: string, configDir: string): number;
|
|
33
|
+
export declare function statusCommand(name: string, configDir: string): Promise<number>;
|
package/dist/cli-kit.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { resolveConfigDir } from './config-dir.js';
|
|
6
|
+
import { CONNECTOR_NAME } from './connector-name.js';
|
|
7
|
+
import { hasRepeatedKey } from './json-text.js';
|
|
8
|
+
import { mount } from './mount.js';
|
|
9
|
+
export function cliOutput(name) {
|
|
10
|
+
if (typeof name !== 'string' || !CONNECTOR_NAME.test(name))
|
|
11
|
+
throw new Error('a connector name is lowercase letters and digits in hyphen-separated words');
|
|
12
|
+
const program = `sema-connector-${name}`;
|
|
13
|
+
return {
|
|
14
|
+
program,
|
|
15
|
+
say: (line) => void process.stdout.write(`${line}\n`),
|
|
16
|
+
complain: (line) => void process.stderr.write(`${program}: ${line}\n`),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
const SUBCOMMANDS = ['install', 'auth', 'mount', 'status'];
|
|
20
|
+
export async function connectorCli(commands, argv, env) {
|
|
21
|
+
const { complain, program } = cliOutput(commands.name);
|
|
22
|
+
const [command, ...rest] = argv;
|
|
23
|
+
if (command === undefined) {
|
|
24
|
+
const code = await commands.serve(env);
|
|
25
|
+
return typeof code === 'number' ? code : undefined;
|
|
26
|
+
}
|
|
27
|
+
const usage = `usage: ${program} [${SUBCOMMANDS.join(' | ')}] (no argument: the connector's MCP stdio server; auth reads ${commands.authReads} from standard input)`;
|
|
28
|
+
if (!SUBCOMMANDS.includes(command)) {
|
|
29
|
+
complain(`unknown subcommand\n${usage}`);
|
|
30
|
+
return 2;
|
|
31
|
+
}
|
|
32
|
+
if (rest.length > 0) {
|
|
33
|
+
complain(`${command} takes no arguments${command === 'auth' ? ` (it reads ${commands.authReads} from standard input, never from an argument)` : ''}\n${usage}`);
|
|
34
|
+
return 2;
|
|
35
|
+
}
|
|
36
|
+
const config = resolveConfigDir(env);
|
|
37
|
+
if (!config.ok) {
|
|
38
|
+
complain(config.reason);
|
|
39
|
+
return 2;
|
|
40
|
+
}
|
|
41
|
+
if (command === 'install')
|
|
42
|
+
return commands.install(config.dir);
|
|
43
|
+
if (command === 'auth')
|
|
44
|
+
return commands.auth(config.dir);
|
|
45
|
+
if (command === 'mount')
|
|
46
|
+
return mountCommand(commands.name, config.dir);
|
|
47
|
+
return statusCommand(commands.name, config.dir);
|
|
48
|
+
}
|
|
49
|
+
export async function readStdinLine(limit) {
|
|
50
|
+
if (process.stdin.isTTY)
|
|
51
|
+
return { ok: false, problem: 'standard input is a terminal here; pipe the input in instead (a token typed on a terminal would be shown)' };
|
|
52
|
+
const parts = [];
|
|
53
|
+
let size = 0;
|
|
54
|
+
for await (const chunk of process.stdin) {
|
|
55
|
+
const b = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
56
|
+
size += b.length;
|
|
57
|
+
if (size > limit)
|
|
58
|
+
return { ok: false, problem: `standard input holds more than ${limit} bytes; one line is read` };
|
|
59
|
+
parts.push(b);
|
|
60
|
+
}
|
|
61
|
+
const line = Buffer.concat(parts).toString('utf8').replace(/\r?\n$/, '');
|
|
62
|
+
if (/[\r\n]/.test(line))
|
|
63
|
+
return { ok: false, problem: 'standard input must hold exactly one line' };
|
|
64
|
+
return { ok: true, line };
|
|
65
|
+
}
|
|
66
|
+
const listed = (keys) => (keys.length < 2 ? keys.join('') : `${keys.slice(0, -1).join(', ')} and ${keys[keys.length - 1]}`);
|
|
67
|
+
export async function readStdinJson(limit, keys) {
|
|
68
|
+
const read = await readStdinLine(limit);
|
|
69
|
+
if (!read.ok)
|
|
70
|
+
return read;
|
|
71
|
+
const shape = `standard input must be one JSON object with exactly the keys ${listed(keys)}, each a string, on one line`;
|
|
72
|
+
let value;
|
|
73
|
+
try {
|
|
74
|
+
value = JSON.parse(read.line);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return { ok: false, problem: shape };
|
|
78
|
+
}
|
|
79
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
80
|
+
return { ok: false, problem: shape };
|
|
81
|
+
if (hasRepeatedKey(read.line))
|
|
82
|
+
return { ok: false, problem: 'the object names a key more than once' };
|
|
83
|
+
const object = value;
|
|
84
|
+
const own = Object.keys(object);
|
|
85
|
+
if (own.length !== keys.length || !keys.every((k) => own.includes(k)))
|
|
86
|
+
return { ok: false, problem: `the object must have exactly the keys ${listed(keys)}` };
|
|
87
|
+
if (!keys.every((k) => typeof object[k] === 'string'))
|
|
88
|
+
return { ok: false, problem: shape };
|
|
89
|
+
return { ok: true, values: Object.fromEntries(keys.map((k) => [k, object[k]])) };
|
|
90
|
+
}
|
|
91
|
+
export function mountCommand(name, configDir) {
|
|
92
|
+
const { say, complain } = cliOutput(name);
|
|
93
|
+
let r;
|
|
94
|
+
try {
|
|
95
|
+
r = mount({ name, configDir });
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
complain(`mount failed: ${e instanceof Error ? e.message : 'unknown error'}`);
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
if (!r.ok) {
|
|
102
|
+
complain(`mount refused (${r.reason}): ${r.message}`);
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
say(r.changed ? `mounted connector ${name} in ${r.file}; restart the engine to apply` : `connector ${name} is already mounted in ${r.file}`);
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
const PROTOCOL_VERSION = '2025-11-25';
|
|
109
|
+
const STATUS_TIMEOUT_MS = 10_000;
|
|
110
|
+
const KILL_GRACE_MS = 2_000;
|
|
111
|
+
const SHOWN_LINES = 5;
|
|
112
|
+
const RECEIPT_PREFIX = 'sema-connector: receipt ';
|
|
113
|
+
function ownManifest() {
|
|
114
|
+
const url = new URL('../package.json', import.meta.url);
|
|
115
|
+
const manifest = JSON.parse(readFileSync(url, 'utf8'));
|
|
116
|
+
return { version: manifest.version, launcher: join(dirname(fileURLToPath(url)), manifest.bin['sema-connector'] ?? '') };
|
|
117
|
+
}
|
|
118
|
+
function lineReader(stream) {
|
|
119
|
+
const lines = [];
|
|
120
|
+
const waiters = [];
|
|
121
|
+
let rest = '';
|
|
122
|
+
let ended = false;
|
|
123
|
+
stream.setEncoding('utf8');
|
|
124
|
+
stream.on('data', (chunk) => {
|
|
125
|
+
rest += chunk;
|
|
126
|
+
let at;
|
|
127
|
+
while ((at = rest.indexOf('\n')) !== -1) {
|
|
128
|
+
const line = rest.slice(0, at);
|
|
129
|
+
rest = rest.slice(at + 1);
|
|
130
|
+
const waiter = waiters.shift();
|
|
131
|
+
if (waiter)
|
|
132
|
+
waiter(line);
|
|
133
|
+
else
|
|
134
|
+
lines.push(line);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
stream.on('close', () => {
|
|
138
|
+
ended = true;
|
|
139
|
+
for (const waiter of waiters.splice(0))
|
|
140
|
+
waiter(null);
|
|
141
|
+
});
|
|
142
|
+
return () => {
|
|
143
|
+
const line = lines.shift();
|
|
144
|
+
if (line !== undefined)
|
|
145
|
+
return Promise.resolve(line);
|
|
146
|
+
if (ended)
|
|
147
|
+
return Promise.resolve(null);
|
|
148
|
+
return new Promise((resolve) => waiters.push(resolve));
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
export async function statusCommand(name, configDir) {
|
|
152
|
+
const { program, say, complain } = cliOutput(name);
|
|
153
|
+
const own = ownManifest();
|
|
154
|
+
const child = spawn(process.execPath, [own.launcher, 'run', name, '--mode', 'standalone', '--transport', 'stdio', '--config-dir', configDir], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
155
|
+
const exited = new Promise((resolve) => child.on('exit', (code) => resolve(code)));
|
|
156
|
+
const errors = [];
|
|
157
|
+
child.stderr.on('data', (d) => errors.push(d));
|
|
158
|
+
child.stdin.on('error', () => undefined);
|
|
159
|
+
const nextLine = lineReader(child.stdout);
|
|
160
|
+
let timedOut = false;
|
|
161
|
+
const timer = setTimeout(() => {
|
|
162
|
+
timedOut = true;
|
|
163
|
+
child.stdout.destroy();
|
|
164
|
+
child.stderr.destroy();
|
|
165
|
+
child.kill('SIGTERM');
|
|
166
|
+
setTimeout(() => child.kill('SIGKILL'), KILL_GRACE_MS).unref();
|
|
167
|
+
}, STATUS_TIMEOUT_MS);
|
|
168
|
+
const write = (message) => void child.stdin.write(JSON.stringify({ jsonrpc: '2.0', ...message }) + '\n');
|
|
169
|
+
const reply = async (id, method, params) => {
|
|
170
|
+
write({ id, method, ...(params === undefined ? {} : { params }) });
|
|
171
|
+
for (;;) {
|
|
172
|
+
const line = await nextLine();
|
|
173
|
+
if (line === null)
|
|
174
|
+
return null;
|
|
175
|
+
try {
|
|
176
|
+
const m = JSON.parse(line);
|
|
177
|
+
if (m?.id === id)
|
|
178
|
+
return m;
|
|
179
|
+
}
|
|
180
|
+
catch { }
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
let found = null;
|
|
184
|
+
let problem = null;
|
|
185
|
+
try {
|
|
186
|
+
const init = await reply(1, 'initialize', { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: program, version: own.version } });
|
|
187
|
+
if (init?.result === undefined)
|
|
188
|
+
problem = 'the launcher did not complete initialize';
|
|
189
|
+
else {
|
|
190
|
+
write({ method: 'notifications/initialized' });
|
|
191
|
+
const list = await reply(2, 'resources/list');
|
|
192
|
+
const entries = list?.result?.resources;
|
|
193
|
+
const uri = Array.isArray(entries) && entries.length === 1 ? entries[0]?.uri : undefined;
|
|
194
|
+
if (typeof uri !== 'string')
|
|
195
|
+
problem = 'the launcher did not list exactly one resource, its status';
|
|
196
|
+
else {
|
|
197
|
+
const read = await reply(3, 'resources/read', { uri });
|
|
198
|
+
const text = read?.result?.contents?.[0]?.text;
|
|
199
|
+
if (typeof text !== 'string')
|
|
200
|
+
problem = `the status could not be read${typeof read?.error?.message === 'string' ? `: ${read.error.message}` : ''}`;
|
|
201
|
+
else
|
|
202
|
+
found = JSON.parse(text);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
problem = 'the launcher answered with something that is not JSON';
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
child.stdin.end();
|
|
211
|
+
}
|
|
212
|
+
const code = await exited;
|
|
213
|
+
clearTimeout(timer);
|
|
214
|
+
if (found !== null && !timedOut) {
|
|
215
|
+
say(JSON.stringify(found));
|
|
216
|
+
return 0;
|
|
217
|
+
}
|
|
218
|
+
complain(timedOut ? `the launcher did not answer within ${STATUS_TIMEOUT_MS} ms` : (problem ?? `the launcher exited with code ${String(code)}`));
|
|
219
|
+
const said = Buffer.concat(errors).toString('utf8').split('\n').filter((l) => l.trim() !== '' && !l.startsWith(RECEIPT_PREFIX));
|
|
220
|
+
for (const line of said.slice(-SHOWN_LINES))
|
|
221
|
+
complain(`the launcher said: ${line}`);
|
|
222
|
+
return 1;
|
|
223
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -6,3 +6,7 @@ export { ASSERTION_HEADER, hostedResolver, standaloneResolver, verifyAssertion }
|
|
|
6
6
|
export type { AnonymousPrincipal, AssertionPrincipal, AssertionStep, Principal, PrincipalResolver, PrincipalResult, RequestContext, VerifyOptions, VerifyResult, } from './principal.js';
|
|
7
7
|
export { mount } from './mount.js';
|
|
8
8
|
export type { MountOptions, MountRefusal, MountResult } from './mount.js';
|
|
9
|
+
export { cliOutput, connectorCli, mountCommand, readStdinJson, readStdinLine, statusCommand } from './cli-kit.js';
|
|
10
|
+
export type { CliOutput, ConnectorCommands, StdinJson, StdinLine } from './cli-kit.js';
|
|
11
|
+
export { isLoopbackHttpOrHttps } from './jwks.js';
|
|
12
|
+
export { writeAll } from './write-all.js';
|
package/dist/index.js
CHANGED
|
@@ -2,3 +2,6 @@ export { resolveConfigDir } from './config-dir.js';
|
|
|
2
2
|
export { openFileSecrets } from './secrets.js';
|
|
3
3
|
export { ASSERTION_HEADER, hostedResolver, standaloneResolver, verifyAssertion } from './principal.js';
|
|
4
4
|
export { mount } from './mount.js';
|
|
5
|
+
export { cliOutput, connectorCli, mountCommand, readStdinJson, readStdinLine, statusCommand } from './cli-kit.js';
|
|
6
|
+
export { isLoopbackHttpOrHttps } from './jwks.js';
|
|
7
|
+
export { writeAll } from './write-all.js';
|
package/dist/jwks.d.ts
CHANGED
|
@@ -14,5 +14,6 @@ export type KeySet = {
|
|
|
14
14
|
readonly ok: false;
|
|
15
15
|
readonly problem: string;
|
|
16
16
|
};
|
|
17
|
+
export declare function isLoopbackHttpOrHttps(url: URL): boolean;
|
|
17
18
|
export declare function keySetSource(env: Env, issuer: string): KeySetSource | string;
|
|
18
19
|
export declare function obtainKeySet(source: KeySetSource): Promise<KeySet>;
|
package/dist/jwks.js
CHANGED
|
@@ -5,6 +5,9 @@ const INLINE = 'CONNECTOR_ASSERTION_JWKS';
|
|
|
5
5
|
const OVERRIDE = 'CONNECTOR_ASSERTION_JWKS_URL';
|
|
6
6
|
const WELL_KNOWN = '/.well-known/jwks.json';
|
|
7
7
|
const LOOPBACK_HOSTS = ['127.0.0.1', 'localhost'];
|
|
8
|
+
export function isLoopbackHttpOrHttps(url) {
|
|
9
|
+
return url.protocol === 'https:' || (url.protocol === 'http:' && LOOPBACK_HOSTS.includes(url.hostname));
|
|
10
|
+
}
|
|
8
11
|
const FETCH_LIMIT_BYTES = 1024 * 1024;
|
|
9
12
|
const FETCH_TIMEOUT_MS = 10_000;
|
|
10
13
|
export function keySetSource(env, issuer) {
|
|
@@ -24,7 +27,7 @@ export function keySetSource(env, issuer) {
|
|
|
24
27
|
}
|
|
25
28
|
if (url.username !== '' || url.password !== '')
|
|
26
29
|
return `${from} carries credentials; a key set URL never does`;
|
|
27
|
-
if (
|
|
30
|
+
if (isLoopbackHttpOrHttps(url))
|
|
28
31
|
return { kind: 'url', from, url };
|
|
29
32
|
return `${from} must be an https URL, or an http URL on 127.0.0.1 or localhost`;
|
|
30
33
|
}
|
package/dist/launcher.js
CHANGED
|
@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { readFileSync } from 'node:fs';
|
|
4
4
|
import { createRequire } from 'node:module';
|
|
5
5
|
import { isIPv4, isIPv6 } from 'node:net';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
6
7
|
import { dirname, join } from 'node:path';
|
|
7
8
|
import { absoluteDir, given } from './config-dir.js';
|
|
8
9
|
import { CONNECTOR_NAME } from './connector-name.js';
|
|
@@ -10,7 +11,7 @@ import { serveHttp } from './http.js';
|
|
|
10
11
|
import { keySetSource, obtainKeySet } from './jwks.js';
|
|
11
12
|
import { hostedResolver, standaloneResolver } from './principal.js';
|
|
12
13
|
import { Scrubber, secretMatcher } from './redact.js';
|
|
13
|
-
import { copyForLaunch,
|
|
14
|
+
import { NO_HANDLES, copyForLaunch, handleDirectory } from './secrets.js';
|
|
14
15
|
import { INSPECT_LIMIT_BYTES, Session } from './session.js';
|
|
15
16
|
import { mintStatus } from './status.js';
|
|
16
17
|
const EXIT = { ok: 0, guard: 2, notInstalled: 4, connectorFailed: 5 };
|
|
@@ -24,6 +25,7 @@ const PIECE_BYTES = 1024;
|
|
|
24
25
|
const USAGE = 'usage: sema-connector run <name> --mode standalone|hosted --transport stdio|http [--listen <ip>:<port>] [--config-dir <absolute path>]';
|
|
25
26
|
const FLAGS = ['--mode', '--transport', '--listen', '--config-dir'];
|
|
26
27
|
const CHILD_ENV = ['HOME', 'LOGNAME', 'USER', 'PATH', 'SHELL', 'TERM', 'LANG', 'LC_ALL', 'TZ'];
|
|
28
|
+
const CONNECTOR_DIR = 'CONNECTOR_DIR';
|
|
27
29
|
function guard(argv, env) {
|
|
28
30
|
if (argv[0] !== 'run')
|
|
29
31
|
return 'expected the command "run"';
|
|
@@ -369,17 +371,19 @@ function pump(src, dst, framing, also = [], passing, giveUp) {
|
|
|
369
371
|
src.on('close', finish);
|
|
370
372
|
});
|
|
371
373
|
}
|
|
372
|
-
function childEnvironment(env, secrets) {
|
|
374
|
+
function childEnvironment(env, connectorDir, secrets) {
|
|
373
375
|
const out = {};
|
|
374
376
|
for (const name of CHILD_ENV) {
|
|
375
377
|
const value = env[name];
|
|
376
378
|
if (value !== undefined)
|
|
377
379
|
out[name] = value;
|
|
378
380
|
}
|
|
381
|
+
out[CONNECTOR_DIR] = connectorDir;
|
|
379
382
|
for (const [name, path] of secrets.files)
|
|
380
383
|
out[`${name}_FILE`] = path;
|
|
381
384
|
return out;
|
|
382
385
|
}
|
|
386
|
+
const hostedConnectorDir = (name) => join(tmpdir(), `sema-connector-${randomUUID()}`, 'connectors', name);
|
|
383
387
|
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
388
|
const abnormal = (o) => o.code !== 0 || o.stalled === true;
|
|
385
389
|
function startConnector(binPath, childEnv, session, scrubber, toUpstream, upstreamOut, log, stderrAfterExitMs) {
|
|
@@ -491,15 +495,14 @@ async function runStdio(packageName, binPath, childEnv, session, scrubber, stdou
|
|
|
491
495
|
return EXIT.connectorFailed;
|
|
492
496
|
}
|
|
493
497
|
async function runHosted(plan, keys, packageName, binPath, env, log) {
|
|
494
|
-
const childEnv = childEnvironment(env, NO_SECRETS);
|
|
495
498
|
const start = (deliver) => {
|
|
496
499
|
const session = new Session({
|
|
497
500
|
connector: plan.name,
|
|
498
|
-
status: (tools) => mintStatus({ handles:
|
|
501
|
+
status: (tools) => mintStatus({ handles: NO_HANDLES.handles, nowMs: Date.now(), tools }),
|
|
499
502
|
ownIdPrefix: `sema-connector/${randomUUID()}`,
|
|
500
|
-
withheld: secretMatcher(
|
|
503
|
+
withheld: secretMatcher(NO_HANDLES.values),
|
|
501
504
|
});
|
|
502
|
-
const run = startConnector(binPath,
|
|
505
|
+
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
506
|
return {
|
|
504
507
|
send: (raw, principal) => run.fromUpstream(raw, principal),
|
|
505
508
|
busy: () => run.toChild.behind(),
|
|
@@ -524,7 +527,6 @@ async function runHosted(plan, keys, packageName, binPath, env, log) {
|
|
|
524
527
|
setTimeout(abandon, TEARDOWN_STEP_MS);
|
|
525
528
|
return EXIT.ok;
|
|
526
529
|
}
|
|
527
|
-
const NO_SECRETS = { files: new Map(), values: [], remove: () => undefined };
|
|
528
530
|
export async function main(argv, env) {
|
|
529
531
|
const stdout = new Out(process.stdout);
|
|
530
532
|
const log = new Log(new Out(process.stderr));
|
|
@@ -550,20 +552,18 @@ export async function main(argv, env) {
|
|
|
550
552
|
}
|
|
551
553
|
return finish(await runHosted(plan, keys.keys, resolved.packageName, resolved.binPath, env, log));
|
|
552
554
|
}
|
|
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
555
|
let secrets;
|
|
560
556
|
try {
|
|
561
|
-
secrets = copyForLaunch(plan.configDir, plan.name
|
|
557
|
+
secrets = copyForLaunch(plan.configDir, plan.name);
|
|
562
558
|
}
|
|
563
559
|
catch {
|
|
564
560
|
log.line(`sema-connector: ${INTERNAL}: connector ${plan.name}: its secrets cannot be read for this launch\n`);
|
|
565
561
|
return finish(EXIT.guard);
|
|
566
562
|
}
|
|
563
|
+
if (!secrets.ok) {
|
|
564
|
+
log.line(`sema-connector: ${INTERNAL}: ${secrets.problem}\n`);
|
|
565
|
+
return finish(EXIT.guard);
|
|
566
|
+
}
|
|
567
567
|
const resolved = resolveConnector(plan.name);
|
|
568
568
|
if ('reason' in resolved) {
|
|
569
569
|
secrets.remove();
|
|
@@ -572,12 +572,12 @@ export async function main(argv, env) {
|
|
|
572
572
|
}
|
|
573
573
|
const session = new Session({
|
|
574
574
|
connector: plan.name,
|
|
575
|
-
status: (tools) => mintStatus({ handles, nowMs: Date.now(), tools }),
|
|
575
|
+
status: (tools) => mintStatus({ handles: secrets.handles, nowMs: Date.now(), tools }),
|
|
576
576
|
ownIdPrefix: `sema-connector/${randomUUID()}`,
|
|
577
577
|
withheld: secretMatcher(secrets.values),
|
|
578
578
|
});
|
|
579
579
|
try {
|
|
580
|
-
return finish(await runStdio(resolved.packageName, resolved.binPath, childEnvironment(env, secrets), session, new Scrubber(secrets.values), stdout, log));
|
|
580
|
+
return finish(await runStdio(resolved.packageName, resolved.binPath, childEnvironment(env, handleDirectory(plan.configDir, plan.name), secrets), session, new Scrubber(secrets.values), stdout, log));
|
|
581
581
|
}
|
|
582
582
|
finally {
|
|
583
583
|
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;
|
package/dist/mount.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { closeSync, fchmodSync,
|
|
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 } from './write-all.js';
|
|
7
9
|
const RESERVED_NAMES = ['sema-connector'];
|
|
8
10
|
export function standaloneEntry(name, configDir) {
|
|
9
11
|
return { name, transport: { kind: 'stdio', command: 'sema-connector', args: ['run', name, '--mode', 'standalone', '--transport', 'stdio', '--config-dir', configDir] } };
|
|
@@ -69,32 +71,10 @@ function plan(before, entry) {
|
|
|
69
71
|
return { text };
|
|
70
72
|
}
|
|
71
73
|
const LOCK_FILE = '.mcp.json.lock';
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
}
|
|
74
|
+
const failure = (e) => {
|
|
75
|
+
const code = e?.code;
|
|
76
|
+
return typeof code === 'string' ? code : e instanceof Error ? e.message : 'unknown error';
|
|
77
|
+
};
|
|
98
78
|
const readOrNull = (file) => {
|
|
99
79
|
try {
|
|
100
80
|
return readFileSync(file, 'utf8');
|
|
@@ -117,8 +97,14 @@ export function mount(options) {
|
|
|
117
97
|
return refuse('config_dir', 'configDir must be the configuration directory as an absolute path; mount reads no environment variable and has no default');
|
|
118
98
|
const dir = join(configDir, 'config.d');
|
|
119
99
|
const file = join(dir, 'mcp.json');
|
|
120
|
-
|
|
121
|
-
|
|
100
|
+
let unlock;
|
|
101
|
+
try {
|
|
102
|
+
mkdirSync(dir, { recursive: true });
|
|
103
|
+
unlock = takeLockFile(join(dir, LOCK_FILE));
|
|
104
|
+
}
|
|
105
|
+
catch (e) {
|
|
106
|
+
return refuse('write_failed', `config.d or its lock could not be written (${failure(e)}); config.d/mcp.json was left as it was`);
|
|
107
|
+
}
|
|
122
108
|
if (unlock === null)
|
|
123
109
|
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
110
|
try {
|
|
@@ -155,8 +141,7 @@ function write(file, entry, refuse) {
|
|
|
155
141
|
try {
|
|
156
142
|
if (mode !== null)
|
|
157
143
|
fchmodSync(fd, mode);
|
|
158
|
-
|
|
159
|
-
fsyncSync(fd);
|
|
144
|
+
writeAll(fd, text);
|
|
160
145
|
}
|
|
161
146
|
finally {
|
|
162
147
|
closeSync(fd);
|
|
@@ -169,7 +154,7 @@ function write(file, entry, refuse) {
|
|
|
169
154
|
}
|
|
170
155
|
catch (e) {
|
|
171
156
|
rmSync(tmp, { force: true });
|
|
172
|
-
|
|
157
|
+
return refuse('write_failed', `config.d/mcp.json could not be written (${failure(e)}); it was left as it was and nothing else was written`);
|
|
173
158
|
}
|
|
174
159
|
return { ok: true, changed: true, file };
|
|
175
160
|
}
|
package/dist/scope.js
CHANGED
package/dist/secrets.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export interface SecretHandle {
|
|
|
2
2
|
readonly connector: string;
|
|
3
3
|
readonly name: string;
|
|
4
4
|
readonly source: 'file';
|
|
5
|
+
readonly secret: boolean;
|
|
5
6
|
readonly expiresAt?: string;
|
|
6
7
|
}
|
|
7
8
|
export type SecretListing = {
|
|
@@ -13,21 +14,35 @@ export type SecretListing = {
|
|
|
13
14
|
};
|
|
14
15
|
export interface PutOptions {
|
|
15
16
|
readonly expiresAt?: string;
|
|
17
|
+
readonly secret?: boolean;
|
|
16
18
|
}
|
|
17
19
|
export interface FileSecrets {
|
|
20
|
+
readonly directory: string;
|
|
18
21
|
list(): SecretListing;
|
|
19
22
|
put(name: string, value: string, options?: PutOptions): SecretHandle;
|
|
23
|
+
withLock<T>(fn: () => T): T;
|
|
20
24
|
}
|
|
25
|
+
export declare const isSecret: (handle: SecretHandle) => boolean;
|
|
21
26
|
export declare const HANDLE_NAME: RegExp;
|
|
27
|
+
export declare const LOCK_FILE = ".auth.lock";
|
|
28
|
+
export declare const LOCK_WAIT_MS = 5000;
|
|
22
29
|
export declare function handleDirectory(configDir: string, connector: string): string;
|
|
23
30
|
export declare function handleFilePath(configDir: string, handle: SecretHandle): string;
|
|
24
31
|
export declare function readSecretValue(configDir: string, handle: SecretHandle): Buffer;
|
|
25
32
|
export interface LaunchSecrets {
|
|
33
|
+
readonly handles: readonly SecretHandle[];
|
|
26
34
|
readonly files: ReadonlyMap<string, string>;
|
|
27
35
|
readonly values: readonly string[];
|
|
28
36
|
remove(): void;
|
|
29
37
|
}
|
|
30
|
-
export
|
|
38
|
+
export type LaunchCopy = ({
|
|
39
|
+
readonly ok: true;
|
|
40
|
+
} & LaunchSecrets) | {
|
|
41
|
+
readonly ok: false;
|
|
42
|
+
readonly problem: string;
|
|
43
|
+
};
|
|
44
|
+
export declare const NO_HANDLES: LaunchSecrets;
|
|
45
|
+
export declare function copyForLaunch(configDir: string, connector: string): LaunchCopy;
|
|
31
46
|
export declare function openFileSecrets(options: {
|
|
32
47
|
readonly configDir: string;
|
|
33
48
|
readonly connector: string;
|
package/dist/secrets.js
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import { closeSync, constants, fchmodSync,
|
|
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
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
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(
|
|
36
|
-
|
|
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
|
-
|
|
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
|
|
93
|
-
if (!names.includes(
|
|
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
|
|
98
|
-
if (
|
|
99
|
-
return refuse(
|
|
100
|
-
let
|
|
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
|
-
|
|
182
|
+
meta = readMeta(readFileSync(join(dir, metaFile), 'utf8'));
|
|
103
183
|
}
|
|
104
184
|
catch {
|
|
105
|
-
|
|
185
|
+
meta = null;
|
|
106
186
|
}
|
|
107
|
-
if (
|
|
108
|
-
return refuse(`
|
|
109
|
-
handles.push({ connector, name, source: 'file',
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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/status.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ConnectorMintedStatus } from '@sema-agent/connectors-contract';
|
|
2
|
-
import type
|
|
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
|
|
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 @@
|
|
|
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,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sema-agent/connectors",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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",
|