@sema-agent/connectors 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,16 +1,18 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { createHash, randomBytes } from 'node:crypto';
3
- import { chmodSync, closeSync, constants, fchmodSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync } from 'node:fs';
3
+ import { chmodSync, closeSync, constants, createReadStream, fchmodSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, renameSync, rmSync } from 'node:fs';
4
4
  import { get as httpsGet } from 'node:https';
5
5
  import { constants as osConstants } from 'node:os';
6
6
  import { dirname, isAbsolute, join, relative, sep } from 'node:path';
7
+ import { Transform } from 'node:stream';
8
+ import { pipeline } from 'node:stream/promises';
7
9
  import { fileURLToPath } from 'node:url';
8
10
  import { cliOutput, listed } from './cli-kit.js';
9
11
  import { given } from './config-dir.js';
10
12
  import { CONNECTOR_NAME } from './connector-name.js';
11
- import { entryOf } from './release-archive.js';
13
+ import { archiveEntry, formatOf, treeListing, treeProblem, unpackTree } from './release-archive.js';
12
14
  import { HANDLE_NAME, openFileSecrets } from './secrets.js';
13
- import { writeAll } from './write-all.js';
15
+ import { writeAll, writeAllStream } from './write-all.js';
14
16
  const CONNECTOR_DIR = 'CONNECTOR_DIR';
15
17
  const BIN_DIR = 'bin';
16
18
  const DIRECTORY_MODE = 0o700;
@@ -22,12 +24,16 @@ const REDIRECT_LIMIT = 5;
22
24
  const SIGNALS = ['SIGTERM', 'SIGINT', 'SIGHUP'];
23
25
  const VERSION = /^[0-9]+\.[0-9]+\.[0-9]+$/;
24
26
  const PLATFORM = /^[a-z0-9]+-[a-z0-9]+$/;
27
+ const EVERY_PLATFORM = '*';
25
28
  const FILE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
29
+ const SEGMENT = /^[A-Za-z0-9@][A-Za-z0-9@._-]*$/;
26
30
  const SHA256 = /^[0-9a-f]{64}$/;
27
31
  const platformKey = () => `${process.platform}-${process.arch}`;
28
32
  const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
29
33
  const keysAre = (v, keys) => Object.keys(v).sort().join('\0') === [...keys].sort().join('\0');
30
- const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
34
+ const isPath = (v) => typeof v === 'string' && v.split('/').every((s) => SEGMENT.test(s));
35
+ const isUnpack = (v) => isObject(v) && ((keysAre(v, ['entry']) && typeof v.entry === 'string' && FILE.test(v.entry)) || (keysAre(v, ['directory', 'to']) && typeof v.directory === 'string' && FILE.test(v.directory) && isPath(v.to)));
36
+ const topOf = (u) => ('entry' in u ? u.entry : u.to.split('/')[0]);
31
37
  const below = (dir, file) => {
32
38
  if (!isAbsolute(file))
33
39
  return false;
@@ -60,7 +66,88 @@ function regularFile(path) {
60
66
  closeSync(fd);
61
67
  }
62
68
  }
63
- function fetchHttps(url, redirects) {
69
+ function sha256Of(path) {
70
+ let fd;
71
+ try {
72
+ fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ try {
78
+ if (!fstatSync(fd).isFile())
79
+ return null;
80
+ const hash = createHash('sha256');
81
+ const piece = Buffer.alloc(1024 * 1024);
82
+ for (let n; (n = readSync(fd, piece, 0, piece.length, null)) > 0;)
83
+ hash.update(piece.subarray(0, n));
84
+ return hash.digest('hex');
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ finally {
90
+ closeSync(fd);
91
+ }
92
+ }
93
+ function treeDiffers(root, listed) {
94
+ const seen = new Set();
95
+ const visit = (rel) => {
96
+ let names;
97
+ try {
98
+ names = readdirSync(rel === '' ? root : join(root, rel));
99
+ }
100
+ catch {
101
+ return rel === '' ? '.' : rel;
102
+ }
103
+ for (const name of names) {
104
+ const path = rel === '' ? name : `${rel}/${name}`;
105
+ const want = listed.get(path);
106
+ if (want === undefined)
107
+ return path;
108
+ seen.add(path);
109
+ let st;
110
+ try {
111
+ st = lstatSync(join(root, path));
112
+ }
113
+ catch {
114
+ return path;
115
+ }
116
+ const kind = st.isSymbolicLink() ? 'link' : st.isDirectory() ? 'directory' : st.isFile() ? 'file' : 'other';
117
+ if (kind !== want.kind)
118
+ return path;
119
+ if (kind === 'file' && (st.size !== want.size || (st.mode & 0o777) !== want.mode))
120
+ return path;
121
+ if (kind === 'link' && readlinkSync(join(root, path)) !== want.link)
122
+ return path;
123
+ if (kind === 'directory') {
124
+ const deeper = visit(path);
125
+ if (deeper !== null)
126
+ return deeper;
127
+ }
128
+ }
129
+ return null;
130
+ };
131
+ const first = visit('');
132
+ if (first !== null)
133
+ return first;
134
+ for (const path of listed.keys())
135
+ if (!seen.has(path))
136
+ return path;
137
+ return null;
138
+ }
139
+ class Hashed extends Transform {
140
+ hash = createHash('sha256');
141
+ size = 0;
142
+ _transform(chunk, _encoding, done) {
143
+ this.size += chunk.length;
144
+ if (this.size > DOWNLOAD_LIMIT)
145
+ return done(new Error(`the archive is larger than ${DOWNLOAD_LIMIT} bytes`));
146
+ this.hash.update(chunk);
147
+ done(null, chunk);
148
+ }
149
+ }
150
+ function fetchHttps(url, redirects, dest) {
64
151
  return new Promise((resolve) => {
65
152
  let settled = false;
66
153
  const done = (r) => {
@@ -85,45 +172,67 @@ function fetchHttps(url, redirects) {
85
172
  return done({ ok: false, problem: `the download was redirected more than ${REDIRECT_LIMIT} times` });
86
173
  if (next.protocol !== 'https:' || next.username !== '' || next.password !== '')
87
174
  return done({ ok: false, problem: 'the download was redirected away from https' });
88
- return void fetchHttps(next, redirects - 1).then(done);
175
+ return void fetchHttps(next, redirects - 1, dest).then(done);
89
176
  }
90
177
  if (status !== 200) {
91
178
  res.resume();
92
179
  return done({ ok: false, problem: `the download answered HTTP ${status}` });
93
180
  }
94
- const parts = [];
95
- let size = 0;
96
- res.on('data', (d) => {
97
- size += d.length;
98
- if (size > DOWNLOAD_LIMIT) {
99
- req.destroy();
100
- done({ ok: false, problem: `the archive is larger than ${DOWNLOAD_LIMIT} bytes` });
101
- }
102
- else
103
- parts.push(d);
181
+ const hashed = new Hashed();
182
+ let out;
183
+ try {
184
+ out = openSync(dest, 'wx', 0o600);
185
+ }
186
+ catch (e) {
187
+ res.resume();
188
+ return done({ ok: false, problem: `the download could not be written (${e.code ?? 'unknown error'})` });
189
+ }
190
+ pipeline(res, hashed, writeAllStream(out)).then(() => {
191
+ closeSync(out);
192
+ done({ ok: true, sha256: hashed.hash.digest('hex') });
193
+ }, (e) => {
194
+ closeSync(out);
195
+ req.destroy();
196
+ done({ ok: false, problem: hashed.size > DOWNLOAD_LIMIT ? `the archive is larger than ${DOWNLOAD_LIMIT} bytes` : `the download broke off (${e.message})` });
104
197
  });
105
- res.on('end', () => done({ ok: true, bytes: Buffer.concat(parts) }));
106
- res.on('error', () => done({ ok: false, problem: 'the download broke off' }));
107
198
  });
108
199
  req.setTimeout(DOWNLOAD_IDLE_MS, () => req.destroy(new Error(`no data for ${DOWNLOAD_IDLE_MS / 1000} s`)));
109
200
  req.on('error', (e) => done({ ok: false, problem: `the download failed (${e.message})` }));
110
201
  });
111
202
  }
112
- async function fetchArchive(url) {
203
+ async function fetchArchive(url, dest) {
113
204
  if (url.protocol === 'https:')
114
- return fetchHttps(url, REDIRECT_LIMIT);
205
+ return fetchHttps(url, REDIRECT_LIMIT, dest);
115
206
  const path = fileURLToPath(url);
207
+ let fd;
116
208
  try {
117
- const st = statSync(path);
209
+ fd = openSync(path, 'r');
210
+ }
211
+ catch {
212
+ return { ok: false, problem: `${path} cannot be read` };
213
+ }
214
+ try {
215
+ const st = fstatSync(fd);
118
216
  if (!st.isFile())
119
217
  return { ok: false, problem: `${path} is not a file` };
120
218
  if (st.size > DOWNLOAD_LIMIT)
121
219
  return { ok: false, problem: `the archive is larger than ${DOWNLOAD_LIMIT} bytes` };
122
- return { ok: true, bytes: readFileSync(path) };
220
+ const hashed = new Hashed();
221
+ const out = openSync(dest, 'wx', 0o600);
222
+ try {
223
+ await pipeline(createReadStream('', { fd, autoClose: false }), hashed, writeAllStream(out));
224
+ }
225
+ finally {
226
+ closeSync(out);
227
+ }
228
+ return { ok: true, sha256: hashed.hash.digest('hex') };
123
229
  }
124
230
  catch {
125
231
  return { ok: false, problem: `${path} cannot be read` };
126
232
  }
233
+ finally {
234
+ closeSync(fd);
235
+ }
127
236
  }
128
237
  export function pinnedUpstream(options) {
129
238
  const { connector, upstream, lockFile } = options ?? {};
@@ -136,12 +245,16 @@ export function pinnedUpstream(options) {
136
245
  const handles = options.handles;
137
246
  if (!Array.isArray(handles) || !handles.every((h) => typeof h === 'string' && HANDLE_NAME.test(h)) || new Set(handles).size !== handles.length)
138
247
  throw new Error('handles must list handle names, each once');
248
+ const optionalHandles = options.optionalHandles ?? [];
249
+ if (!Array.isArray(optionalHandles) || !optionalHandles.every((h) => typeof h === 'string' && HANDLE_NAME.test(h)))
250
+ throw new Error('optionalHandles must list handle names');
251
+ const allHandles = [...handles, ...optionalHandles];
252
+ if (new Set(allHandles).size !== allHandles.length)
253
+ throw new Error('handles and optionalHandles must list handle names, each once');
139
254
  const { program, say, complain } = cliOutput(connector);
140
- const fileVars = handles.map((h) => `${h}_FILE`);
141
- const binaryOf = (connectorDir) => join(connectorDir, BIN_DIR, upstream);
255
+ const fileVars = allHandles.map((h) => `${h}_FILE`);
142
256
  function pinnedBuild() {
143
257
  const refuse = (problem) => ({ ok: false, problem: `binaries.lock.json ${problem}` });
144
- const isBuild = (v) => isObject(v) && keysAre(v, ['file', 'sha256']) && typeof v.file === 'string' && FILE.test(v.file) && v.file !== upstream && typeof v.sha256 === 'string' && SHA256.test(v.sha256);
145
258
  let doc;
146
259
  try {
147
260
  doc = JSON.parse(readFileSync(lockFile, 'utf8'));
@@ -152,25 +265,48 @@ export function pinnedUpstream(options) {
152
265
  if (!isObject(doc) || !keysAre(doc, [upstream]))
153
266
  return refuse(`must hold exactly one entry, "${upstream}"`);
154
267
  const entry = doc[upstream];
155
- if (!isObject(entry) || !keysAre(entry, ['version', 'source', 'platforms']))
156
- return refuse('must give exactly version, source and platforms');
157
- const { version, source, platforms } = entry;
268
+ if (!isObject(entry) || !keysAre(entry, ['version', 'archives', 'program']))
269
+ return refuse('must give exactly version, archives and program');
270
+ const { version, archives, program: prog } = entry;
158
271
  if (typeof version !== 'string' || !VERSION.test(version))
159
272
  return refuse('must give the version as <major>.<minor>.<patch>');
160
- if (typeof source !== 'string' || !source.includes('{file}'))
161
- return refuse('must give the source as a URL template naming {file}');
162
- if (!isObject(platforms) || !Object.entries(platforms).every(([k, v]) => PLATFORM.test(k) && isBuild(v)))
163
- return refuse('must map each platform to {"file": <archive name>, "sha256": <64 hex digits>}');
273
+ const isArchive = (v) => isObject(v) &&
274
+ keysAre(v, ['platform', 'file', 'source', 'sha256', 'unpack']) &&
275
+ typeof v.platform === 'string' &&
276
+ (v.platform === EVERY_PLATFORM || PLATFORM.test(v.platform)) &&
277
+ typeof v.file === 'string' &&
278
+ FILE.test(v.file) &&
279
+ formatOf(v.file) !== null &&
280
+ typeof v.source === 'string' &&
281
+ typeof v.sha256 === 'string' &&
282
+ SHA256.test(v.sha256) &&
283
+ isUnpack(v.unpack);
284
+ if (!Array.isArray(archives) || archives.length === 0 || !archives.every(isArchive))
285
+ return refuse('must list archives, each {"platform": <platform> or "*", "file": <name ending in .tar.gz, .tgz or .zip>, "source": <URL template>, "sha256": <64 hex digits>, "unpack": {"entry": <name>} or {"directory": <name>, "to": <path>}}');
286
+ if (!isObject(prog) || !((keysAre(prog, ['binary']) && isPath(prog.binary)) || (keysAre(prog, ['node']) && isPath(prog.node))))
287
+ return refuse('must give the program as {"binary": <path>} or {"node": <path>}');
164
288
  const key = platformKey();
165
- const build = Object.hasOwn(platforms, key) ? platforms[key] : undefined;
166
- if (!isBuild(build))
289
+ const all = archives.map((a) => ({ ...a, format: formatOf(a.file) }));
290
+ if (!all.some((a) => a.platform === key))
167
291
  return refuse(`pins no ${upstream} build for ${key}`);
168
- return { ok: true, lock: { version, source, platforms: platforms }, build };
292
+ const mine = all.filter((a) => a.platform === key || a.platform === EVERY_PLATFORM);
293
+ const tops = mine.map((a) => topOf(a.unpack));
294
+ const places = [...mine.map((a) => a.file), ...mine.filter((a) => 'entry' in a.unpack).map((a) => a.unpack.entry)];
295
+ const trees = mine.flatMap((a) => ('to' in a.unpack ? [a.unpack.to] : []));
296
+ const overlap = trees.some((t, i) => trees.some((u, j) => i !== j && (t === u || t.startsWith(`${u}/`)))) || places.some((p, i) => places.indexOf(p) !== i) || mine.some((a) => trees.some((t) => t.split('/')[0] === a.file)) || mine.some((a) => 'entry' in a.unpack && trees.some((t) => t.split('/')[0] === topOf(a.unpack)));
297
+ if (overlap || tops.length !== mine.length)
298
+ return refuse(`gives two archives for ${key} the same place in the bin directory`);
299
+ const kind = 'binary' in prog ? 'binary' : 'node';
300
+ const path = (kind === 'binary' ? prog.binary : prog.node);
301
+ const provides = mine.filter((a) => ('entry' in a.unpack ? a.unpack.entry === path : path.startsWith(`${a.unpack.to}/`)));
302
+ if (provides.length !== 1)
303
+ return refuse(`does not unpack the program ${path} from exactly one archive for ${key}`);
304
+ return { ok: true, build: { version, archives: mine, program: { kind, path } } };
169
305
  }
170
- function sourceOf(lock, build) {
306
+ function sourceOf(build, archive) {
171
307
  let url;
172
308
  try {
173
- url = new URL(lock.source.replaceAll('{version}', lock.version).replaceAll('{file}', build.file));
309
+ url = new URL(archive.source.replaceAll('{version}', build.version).replaceAll('{file}', archive.file));
174
310
  }
175
311
  catch {
176
312
  return { ok: false, problem: 'the source in binaries.lock.json is not a URL' };
@@ -181,6 +317,11 @@ export function pinnedUpstream(options) {
181
317
  return { ok: true, url };
182
318
  return { ok: false, problem: 'the source in binaries.lock.json must be an https or file URL (an https URL without credentials)' };
183
319
  }
320
+ function programSource(build) {
321
+ const { path } = build.program;
322
+ const archive = build.archives.find((a) => ('entry' in a.unpack ? a.unpack.entry === path : path.startsWith(`${a.unpack.to}/`)));
323
+ return { archive, name: 'entry' in archive.unpack ? archive.unpack.entry : `${archive.unpack.directory}/${path.slice(archive.unpack.to.length + 1)}` };
324
+ }
184
325
  function installedProblem(connectorDir, build) {
185
326
  const dir = join(connectorDir, BIN_DIR);
186
327
  let st;
@@ -192,21 +333,52 @@ export function pinnedUpstream(options) {
192
333
  }
193
334
  if (!st.isDirectory() || (st.mode & 0o777) !== DIRECTORY_MODE)
194
335
  return 'the bin directory of the connector is not a 0700 directory';
195
- const archive = regularFile(join(dir, build.file));
196
- if (archive === null)
197
- return `${upstream} is not installed (the pinned archive is not there)`;
198
- if (sha256(archive) !== build.sha256)
199
- return 'the archive there is not the one binaries.lock.json pins';
200
- const entry = entryOf(archive, upstream);
201
- if (!entry.ok)
202
- return `the pinned archive ${entry.problem}`;
203
- const binary = binaryOf(connectorDir);
204
- const bytes = regularFile(binary);
205
- if (bytes === null || !bytes.equals(entry.bytes))
206
- return `${upstream} there is not the entry of the pinned archive`;
207
- if ((lstatSync(binary).mode & 0o777) !== BINARY_MODE)
208
- return `${upstream} there is not mode 0755`;
209
- return null;
336
+ const sameAs = (archive, name, file, executable) => {
337
+ const entry = archiveEntry(join(dir, archive.file), archive.format, name);
338
+ if (!entry.ok)
339
+ return `the pinned archive ${entry.problem}`;
340
+ const path = join(dir, file);
341
+ const bytes = regularFile(path);
342
+ if (bytes === null || !bytes.equals(entry.bytes))
343
+ return `${file} there is not the entry of the pinned archive`;
344
+ if (executable && (lstatSync(path).mode & 0o777) !== BINARY_MODE)
345
+ return `${file} there is not mode 0755`;
346
+ return null;
347
+ };
348
+ for (const archive of build.archives) {
349
+ const hash = sha256Of(join(dir, archive.file));
350
+ if (hash === null)
351
+ return `${upstream} is not installed (the pinned archive is not there)`;
352
+ if (hash !== archive.sha256)
353
+ return 'the archive there is not the one binaries.lock.json pins';
354
+ if ('entry' in archive.unpack) {
355
+ const problem = sameAs(archive, archive.unpack.entry, archive.unpack.entry, true);
356
+ if (problem !== null)
357
+ return problem;
358
+ continue;
359
+ }
360
+ let tree;
361
+ try {
362
+ tree = lstatSync(join(dir, archive.unpack.to));
363
+ }
364
+ catch {
365
+ tree = null;
366
+ }
367
+ if (tree === null || !tree.isDirectory())
368
+ return `${upstream} is not installed (${archive.unpack.to} is not there)`;
369
+ const listing = treeListing(join(dir, archive.file), archive.format, archive.unpack.directory);
370
+ if (!listing.ok)
371
+ return `the pinned archive ${listing.problem}`;
372
+ const differs = treeDiffers(join(dir, archive.unpack.to), listing.paths);
373
+ if (differs !== null)
374
+ return `${archive.unpack.to}/${differs} there is not as the pinned archive gives it`;
375
+ }
376
+ const { archive, name } = programSource(build);
377
+ return 'entry' in archive.unpack ? null : sameAs(archive, name, build.program.path, build.program.kind === 'binary');
378
+ }
379
+ function commit(tmp, path, mode) {
380
+ chmodSync(tmp, mode);
381
+ renameSync(tmp, path);
210
382
  }
211
383
  function writeAtomically(path, bytes, mode) {
212
384
  const tmp = join(dirname(path), `.${upstream}.${randomBytes(6).toString('hex')}.tmp`);
@@ -226,13 +398,52 @@ export function pinnedUpstream(options) {
226
398
  throw e;
227
399
  }
228
400
  }
401
+ async function placeTree(binDir, archive, from) {
402
+ const target = join(binDir, archive.unpack.to);
403
+ const parent = dirname(target);
404
+ mkdirSync(parent, { recursive: true, mode: 0o755 });
405
+ for (let p = parent; p !== binDir; p = dirname(p))
406
+ if (!lstatSync(p).isDirectory())
407
+ throw new Error(`${relative(binDir, p)} in the bin directory is not a directory`);
408
+ const tmp = mkdtempSync(join(binDir, `.${upstream}.unpack-`));
409
+ const old = join(binDir, `.${upstream}.old-${randomBytes(6).toString('hex')}`);
410
+ try {
411
+ const problem = await unpackTree(from, archive.format, archive.unpack.directory, tmp);
412
+ if (problem !== null)
413
+ throw new Error(`the pinned archive ${archive.file} ${problem}`);
414
+ chmodSync(tmp, 0o755);
415
+ let had = true;
416
+ try {
417
+ renameSync(target, old);
418
+ }
419
+ catch (e) {
420
+ if (e.code !== 'ENOENT')
421
+ throw e;
422
+ had = false;
423
+ }
424
+ try {
425
+ renameSync(tmp, target);
426
+ }
427
+ catch (e) {
428
+ if (had)
429
+ renameSync(old, target);
430
+ throw e;
431
+ }
432
+ if (had)
433
+ rmSync(old, { recursive: true, force: true });
434
+ }
435
+ catch (e) {
436
+ rmSync(tmp, { recursive: true, force: true });
437
+ throw e;
438
+ }
439
+ }
229
440
  async function install(configDir) {
230
441
  const pinned = pinnedBuild();
231
442
  if (!pinned.ok) {
232
443
  complain(`${pinned.problem}; nothing was installed`);
233
444
  return 1;
234
445
  }
235
- const { lock, build } = pinned;
446
+ const { build } = pinned;
236
447
  const secrets = openFileSecrets({ configDir, connector });
237
448
  const before = secrets.list();
238
449
  if (!before.ok) {
@@ -242,29 +453,23 @@ export function pinnedUpstream(options) {
242
453
  const connectorDir = secrets.directory;
243
454
  const binDir = join(connectorDir, BIN_DIR);
244
455
  if (installedProblem(connectorDir, build) === null) {
245
- say(`${upstream} ${lock.version} is already installed in ${binDir}; nothing changed`);
456
+ say(`${upstream} ${build.version} is already installed in ${binDir}; nothing changed`);
246
457
  return 0;
247
458
  }
248
- const source = sourceOf(lock, build);
249
- if (!source.ok) {
250
- complain(`${source.problem}; nothing was installed`);
251
- return 1;
252
- }
253
- const fetched = await fetchArchive(source.url);
254
- if (!fetched.ok) {
255
- complain(`${build.file} could not be fetched: ${fetched.problem}; nothing was installed`);
256
- return 1;
257
- }
258
- const actual = sha256(fetched.bytes);
259
- if (actual !== build.sha256) {
260
- complain(`the fetched ${build.file} does not match binaries.lock.json (sha256 ${actual}, pinned ${build.sha256}); nothing was installed`);
261
- return 1;
262
- }
263
- const entry = entryOf(fetched.bytes, upstream);
264
- if (!entry.ok) {
265
- complain(`the pinned archive ${build.file} ${entry.problem}; nothing was installed`);
266
- return 1;
459
+ const sources = [];
460
+ for (const archive of build.archives) {
461
+ const source = sourceOf(build, archive);
462
+ if (!source.ok) {
463
+ complain(`${source.problem}; nothing was installed`);
464
+ return 1;
465
+ }
466
+ sources.push(source.url);
267
467
  }
468
+ const temps = [];
469
+ const dropTemps = () => {
470
+ for (const t of temps)
471
+ rmSync(t, { force: true });
472
+ };
268
473
  try {
269
474
  mkdirSync(binDir, { recursive: true, mode: DIRECTORY_MODE });
270
475
  const after = secrets.list();
@@ -274,24 +479,74 @@ export function pinnedUpstream(options) {
274
479
  if (!st.isDirectory())
275
480
  throw new Error('the bin directory of the connector is not a directory');
276
481
  chmodSync(binDir, DIRECTORY_MODE);
277
- writeAtomically(join(binDir, build.file), fetched.bytes, ARCHIVE_MODE);
278
- writeAtomically(binaryOf(connectorDir), entry.bytes, BINARY_MODE);
279
482
  }
280
483
  catch (e) {
281
484
  complain(`${upstream} was not installed: ${e instanceof Error ? e.message : 'unknown error'}`);
282
485
  return 1;
283
486
  }
487
+ for (const [i, archive] of build.archives.entries()) {
488
+ const tmp = join(binDir, `.${upstream}.${randomBytes(6).toString('hex')}.download`);
489
+ temps.push(tmp);
490
+ const fetched = await fetchArchive(sources[i], tmp);
491
+ if (!fetched.ok) {
492
+ dropTemps();
493
+ complain(`${archive.file} could not be fetched: ${fetched.problem}; nothing was installed`);
494
+ return 1;
495
+ }
496
+ if (fetched.sha256 !== archive.sha256) {
497
+ dropTemps();
498
+ complain(`the fetched ${archive.file} does not match binaries.lock.json (sha256 ${fetched.sha256}, pinned ${archive.sha256}); nothing was installed`);
499
+ return 1;
500
+ }
501
+ const problem = 'entry' in archive.unpack ? (() => {
502
+ const e = archiveEntry(tmp, archive.format, archive.unpack.entry);
503
+ return e.ok ? null : e.problem;
504
+ })() : treeProblem(tmp, archive.format, archive.unpack.directory);
505
+ if (problem !== null) {
506
+ dropTemps();
507
+ complain(`the pinned archive ${archive.file} ${problem}; nothing was installed`);
508
+ return 1;
509
+ }
510
+ }
511
+ {
512
+ const { archive, name } = programSource(build);
513
+ const entry = archiveEntry(temps[build.archives.indexOf(archive)], archive.format, name);
514
+ if (!entry.ok) {
515
+ dropTemps();
516
+ complain(`the pinned archive ${archive.file} ${entry.problem}; nothing was installed`);
517
+ return 1;
518
+ }
519
+ }
520
+ try {
521
+ for (const [i, archive] of build.archives.entries()) {
522
+ const tmp = temps[i];
523
+ if ('entry' in archive.unpack) {
524
+ const e = archiveEntry(tmp, archive.format, archive.unpack.entry);
525
+ if (!e.ok)
526
+ throw new Error(`the pinned archive ${archive.file} ${e.problem}`);
527
+ writeAtomically(join(binDir, archive.unpack.entry), e.bytes, BINARY_MODE);
528
+ }
529
+ else
530
+ await placeTree(binDir, archive, tmp);
531
+ commit(tmp, join(binDir, archive.file), ARCHIVE_MODE);
532
+ }
533
+ }
534
+ catch (e) {
535
+ dropTemps();
536
+ complain(`${upstream} was not installed: ${e instanceof Error ? e.message : 'unknown error'}`);
537
+ return 1;
538
+ }
284
539
  const problem = installedProblem(connectorDir, build);
285
540
  if (problem !== null) {
286
541
  complain(`${upstream} was written but does not read back as the pinned build: ${problem}`);
287
542
  return 1;
288
543
  }
289
- say(`installed ${upstream} ${lock.version} (${build.file}, sha256 ${build.sha256}) in ${binDir}`);
544
+ say(`installed ${upstream} ${build.version} (${build.archives.map((a) => `${a.file}, sha256 ${a.sha256}`).join('; ')}) in ${binDir}`);
290
545
  return 0;
291
546
  }
292
- function run(binary, args, env) {
547
+ function run(command, args, env) {
293
548
  return new Promise((resolve) => {
294
- const child = spawn(binary, [...args], { stdio: 'inherit', env });
549
+ const child = spawn(command, [...args], { stdio: 'inherit', env });
295
550
  const forwarders = SIGNALS.map((signal) => [signal, () => void child.kill(signal)]);
296
551
  for (const [signal, forward] of forwarders)
297
552
  process.on(signal, forward);
@@ -320,11 +575,14 @@ export function pinnedUpstream(options) {
320
575
  const connectorDir = env[CONNECTOR_DIR];
321
576
  if (!given(connectorDir) || !isAbsolute(connectorDir))
322
577
  return refuse(`${CONNECTOR_DIR} does not name the connector's directory: start the connector with sema-connector run ${connector}`);
323
- const paths = fileVars.map((v) => env[v]);
324
- if (!paths.every(given))
578
+ const requiredPaths = handles.map((h) => env[`${h}_FILE`]);
579
+ if (!requiredPaths.every(given))
325
580
  return refuse(`connector ${connector} has no ${listed(handles)} ${handles.length === 1 ? 'handle' : 'handles'} to start ${upstream} with: store ${handles.length === 1 ? 'it' : 'them'} with ${program} auth`);
326
- if (!paths.every((p) => below(connectorDir, p) && isRegularFile(p)))
327
- return refuse(`${listed(fileVars)} must name files in the connector's directory (${CONNECTOR_DIR}), as the launcher hands ${handles.length === 1 ? 'it' : 'them'}: start the connector with sema-connector run ${connector}`);
581
+ const presentHandles = [...handles, ...optionalHandles.filter((h) => given(env[`${h}_FILE`]))];
582
+ const presentVars = presentHandles.map((h) => `${h}_FILE`);
583
+ const presentPaths = presentVars.map((v) => env[v]);
584
+ if (!presentPaths.every((p) => given(p) && below(connectorDir, p) && isRegularFile(p)))
585
+ return refuse(`${listed(presentVars)} must name files in the connector's directory (${CONNECTOR_DIR}), as the launcher hands ${presentHandles.length === 1 ? 'it' : 'them'}: start the connector with sema-connector run ${connector}`);
328
586
  const pinned = pinnedBuild();
329
587
  if (!pinned.ok)
330
588
  return refuse(pinned.problem);
@@ -335,12 +593,15 @@ export function pinnedUpstream(options) {
335
593
  for (const [k, v] of Object.entries(env))
336
594
  if (v !== undefined && k !== CONNECTOR_DIR && !fileVars.includes(k))
337
595
  own[k] = v;
338
- const binary = binaryOf(connectorDir);
596
+ const binDir = join(connectorDir, BIN_DIR);
597
+ const path = join(binDir, pinned.build.program.path);
598
+ const [command, first] = pinned.build.program.kind === 'node' ? [process.execPath, [path]] : [path, []];
339
599
  return {
340
600
  ok: true,
341
601
  connectorDir,
342
- files: Object.fromEntries(handles.map((h, i) => [h, paths[i]])),
343
- run: (args, extra) => run(binary, args, { ...own, ...extra }),
602
+ binDir,
603
+ files: Object.fromEntries(presentHandles.map((h, i) => [h, presentPaths[i]])),
604
+ run: (args, extra) => run(command, [...first, ...args], { ...own, ...extra }),
344
605
  };
345
606
  }
346
607
  return { install, start };
@@ -41,7 +41,7 @@ export type PrincipalResult = {
41
41
  export interface PrincipalResolver {
42
42
  resolve(request: RequestContext): PrincipalResult;
43
43
  }
44
- export declare function standaloneResolver(): PrincipalResolver;
44
+ export declare function standaloneResolver(policy?: readonly string[] | null): PrincipalResolver;
45
45
  export declare function hostedResolver(options: {
46
46
  readonly issuer: string;
47
47
  readonly audience: string;
package/dist/principal.js CHANGED
@@ -95,8 +95,11 @@ export function verifyAssertion(compactJws, options) {
95
95
  return { ok: true, principal: { kind: 'assertion', sub, space, scope: scope.split(' ') } };
96
96
  }
97
97
  const ANONYMOUS = Object.freeze({ kind: 'anonymous', scope: Object.freeze(['tool:*']) });
98
- export function standaloneResolver() {
99
- return { resolve: () => ({ ok: true, principal: ANONYMOUS }) };
98
+ export function standaloneResolver(policy) {
99
+ if (policy === null || policy === undefined)
100
+ return { resolve: () => ({ ok: true, principal: ANONYMOUS }) };
101
+ const principal = Object.freeze({ kind: 'anonymous', scope: Object.freeze(policy.map((name) => `tool:${name}`)) });
102
+ return { resolve: () => ({ ok: true, principal }) };
100
103
  }
101
104
  export function hostedResolver(options) {
102
105
  if (typeof options?.issuer !== 'string' || options.issuer === '' || typeof options.audience !== 'string' || options.audience === '') {
@@ -1,3 +1,5 @@
1
+ export declare const UNPACKED_LIMIT: number;
2
+ export type ArchiveFormat = 'tar.gz' | 'zip';
1
3
  export type Entry = {
2
4
  readonly ok: true;
3
5
  readonly bytes: Buffer;
@@ -5,4 +7,24 @@ export type Entry = {
5
7
  readonly ok: false;
6
8
  readonly problem: string;
7
9
  };
10
+ type Problem = string | null;
11
+ export declare const formatOf: (file: string) => ArchiveFormat | null;
8
12
  export declare function entryOf(archive: Buffer, wanted: string): Entry;
13
+ export declare function archiveEntry(path: string, format: ArchiveFormat, wanted: string): Entry;
14
+ export declare function unpackTree(path: string, format: ArchiveFormat, top: string, dest: string): Promise<Problem>;
15
+ export declare function treeProblem(path: string, format: ArchiveFormat, top: string): Problem;
16
+ export interface Listed {
17
+ readonly kind: 'file' | 'directory' | 'link';
18
+ readonly mode: number;
19
+ readonly size: number;
20
+ readonly link?: string;
21
+ }
22
+ export type Listing = {
23
+ readonly ok: true;
24
+ readonly paths: ReadonlyMap<string, Listed>;
25
+ } | {
26
+ readonly ok: false;
27
+ readonly problem: string;
28
+ };
29
+ export declare function treeListing(path: string, format: ArchiveFormat, top: string): Listing;
30
+ export {};