@sema-agent/connectors 0.4.0 → 0.6.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,6 +1,16 @@
1
- import { gunzipSync } from 'node:zlib';
1
+ import { chmodSync, closeSync, createReadStream, fchmodSync, fstatSync, mkdirSync, openSync, readSync, symlinkSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { Transform } from 'node:stream';
4
+ import { pipeline } from 'node:stream/promises';
5
+ import { createInflateRaw, gunzipSync, inflateRawSync } from 'node:zlib';
6
+ import { writeAll, writeAllStream } from './write-all.js';
2
7
  const BLOCK = 512;
3
- const UNPACKED_LIMIT = 512 * 1024 * 1024;
8
+ export const UNPACKED_LIMIT = 1024 * 1024 * 1024;
9
+ const LINK_LIMIT = 4096;
10
+ const FILE_MODE = 0o644;
11
+ const EXEC_MODE = 0o755;
12
+ const DIR_MODE = 0o755;
13
+ export const formatOf = (file) => (/\.(?:tar\.gz|tgz)$/.test(file) ? 'tar.gz' : /\.zip$/.test(file) ? 'zip' : null);
4
14
  function text(h, at, length) {
5
15
  const raw = h.subarray(at, at + length);
6
16
  const end = raw.indexOf(0);
@@ -25,6 +35,8 @@ function pax(body) {
25
35
  const value = record.slice(eq + 1);
26
36
  if (key === 'path')
27
37
  out.path = value;
38
+ if (key === 'linkpath')
39
+ out.link = value;
28
40
  if (key === 'size') {
29
41
  if (!/^[0-9]+$/.test(value))
30
42
  return null;
@@ -34,7 +46,7 @@ function pax(body) {
34
46
  }
35
47
  return out;
36
48
  }
37
- export function entryOf(archive, wanted) {
49
+ function tarEntries(archive) {
38
50
  const refuse = (problem) => ({ ok: false, problem });
39
51
  let tar;
40
52
  try {
@@ -43,7 +55,7 @@ export function entryOf(archive, wanted) {
43
55
  catch {
44
56
  return refuse('is not a gzip stream within the size limit');
45
57
  }
46
- const found = [];
58
+ const entries = [];
47
59
  let next = {};
48
60
  for (let at = 0;;) {
49
61
  if (at === tar.length)
@@ -59,7 +71,7 @@ export function entryOf(archive, wanted) {
59
71
  if (octal(h, 148, 8) !== sum)
60
72
  return refuse('has a damaged header');
61
73
  const type = h[156] === 0 ? '0' : String.fromCharCode(h[156] ?? 0);
62
- const meta = type === 'x' || type === 'g' || type === 'L';
74
+ const meta = type === 'x' || type === 'g' || type === 'L' || type === 'K';
63
75
  const own = octal(h, 124, 12);
64
76
  const size = meta ? own : (next.size ?? own);
65
77
  if (size === null)
@@ -80,17 +92,457 @@ export function entryOf(archive, wanted) {
80
92
  next = { ...next, path: body.toString('utf8').replace(/\0+$/, '') };
81
93
  continue;
82
94
  }
95
+ if (type === 'K') {
96
+ next = { ...next, link: body.toString('utf8').replace(/\0+$/, '') };
97
+ continue;
98
+ }
83
99
  if (type === 'g')
84
100
  continue;
85
101
  const prefix = text(h, 257, 6).startsWith('ustar') ? text(h, 345, 155) : '';
86
102
  const name = next.path ?? (prefix === '' ? text(h, 0, 100) : `${prefix}/${text(h, 0, 100)}`);
103
+ const link = next.link ?? text(h, 157, 100);
87
104
  next = {};
88
- if ((type === '0' || type === '7') && name.replace(/^\.\//, '') === wanted)
89
- found.push(Buffer.from(body));
105
+ entries.push({ name, type: type === '7' ? '0' : type, mode: octal(h, 100, 8) ?? 0, body, link });
90
106
  }
107
+ return { ok: true, entries };
108
+ }
109
+ export function entryOf(archive, wanted) {
110
+ const read = tarEntries(archive);
111
+ if (!read.ok)
112
+ return read;
113
+ const found = read.entries.filter((e) => e.type === '0' && e.name.replace(/^\.\//, '') === wanted);
91
114
  if (found.length === 0)
92
- return refuse(`holds no ${wanted}`);
115
+ return { ok: false, problem: `holds no ${wanted}` };
93
116
  if (found.length > 1)
94
- return refuse(`holds ${wanted} more than once`);
95
- return { ok: true, bytes: found[0] };
117
+ return { ok: false, problem: `holds ${wanted} more than once` };
118
+ return { ok: true, bytes: Buffer.from(found[0].body) };
119
+ }
120
+ const EOCD = 0x06054b50;
121
+ const CENTRAL = 0x02014b50;
122
+ const LOCAL = 0x04034b50;
123
+ const ZIP64_LOCATOR = 0x07064b50;
124
+ const UNIX = 3;
125
+ const S_IFMT = 0o170000;
126
+ function readAt(fd, position, length) {
127
+ const b = Buffer.alloc(length);
128
+ let got = 0;
129
+ while (got < length) {
130
+ const n = readSync(fd, b, got, length - got, position + got);
131
+ if (n === 0)
132
+ break;
133
+ got += n;
134
+ }
135
+ return got === length ? b : b.subarray(0, got);
136
+ }
137
+ function zipEntries(fd) {
138
+ const refuse = (problem) => ({ ok: false, problem });
139
+ const size = fstatSync(fd).size;
140
+ if (size < 22)
141
+ return refuse('is not a zip archive');
142
+ const tailStart = Math.max(0, size - 22 - 65535);
143
+ const tail = readAt(fd, tailStart, size - tailStart);
144
+ const ends = [];
145
+ for (let i = tail.length - 22; i >= 0; i--)
146
+ if (tail.readUInt32LE(i) === EOCD && i + 22 + tail.readUInt16LE(i + 20) === tail.length)
147
+ ends.push(i);
148
+ if (ends.length !== 1)
149
+ return refuse(ends.length === 0 ? 'is not a zip archive' : 'has more than one end record');
150
+ const e = ends[0];
151
+ const eocdAt = tailStart + e;
152
+ if (tail.readUInt16LE(e + 4) !== 0 || tail.readUInt16LE(e + 6) !== 0)
153
+ return refuse('is a split archive');
154
+ const count = tail.readUInt16LE(e + 10);
155
+ const cdSize = tail.readUInt32LE(e + 12);
156
+ const cdAt = tail.readUInt32LE(e + 16);
157
+ if (tail.readUInt16LE(e + 8) !== count || count === 0xffff || cdSize === 0xffffffff || cdAt === 0xffffffff)
158
+ return refuse('is a ZIP64 archive, which is not taken');
159
+ if (eocdAt >= 20 && readAt(fd, eocdAt - 20, 4).readUInt32LE(0) === ZIP64_LOCATOR)
160
+ return refuse('is a ZIP64 archive, which is not taken');
161
+ if (cdAt + cdSize !== eocdAt)
162
+ return refuse('has a central directory that does not end where its end record starts');
163
+ const cd = readAt(fd, cdAt, cdSize);
164
+ const entries = [];
165
+ const utf8 = new TextDecoder('utf-8', { fatal: true });
166
+ let total = 0;
167
+ let at = 0;
168
+ for (let i = 0; i < count; i++) {
169
+ if (at + 46 > cd.length || cd.readUInt32LE(at) !== CENTRAL)
170
+ return refuse('has a damaged central directory');
171
+ const host = cd.readUInt8(at + 5);
172
+ const flags = cd.readUInt16LE(at + 8);
173
+ const method = cd.readUInt16LE(at + 10);
174
+ const crc = cd.readUInt32LE(at + 16);
175
+ const compressed = cd.readUInt32LE(at + 20);
176
+ const unpacked = cd.readUInt32LE(at + 24);
177
+ const nameLength = cd.readUInt16LE(at + 28);
178
+ const extraLength = cd.readUInt16LE(at + 30);
179
+ const commentLength = cd.readUInt16LE(at + 32);
180
+ const disk = cd.readUInt16LE(at + 34);
181
+ const external = cd.readUInt32LE(at + 38);
182
+ const localAt = cd.readUInt32LE(at + 42);
183
+ const raw = cd.subarray(at + 46, at + 46 + nameLength);
184
+ at += 46 + nameLength + extraLength + commentLength;
185
+ if (at > cd.length)
186
+ return refuse('has a damaged central directory');
187
+ if (compressed === 0xffffffff || unpacked === 0xffffffff || localAt === 0xffffffff || disk !== 0)
188
+ return refuse('is a ZIP64 archive, which is not taken');
189
+ if ((flags & 0x0001) !== 0 || (flags & 0x0040) !== 0 || (flags & 0x2000) !== 0)
190
+ return refuse('holds an encrypted entry');
191
+ if (method !== 0 && method !== 8)
192
+ return refuse('holds an entry compressed with a method other than stored or deflate');
193
+ if (method === 0 && compressed !== unpacked)
194
+ return refuse('holds a stored entry whose sizes disagree');
195
+ if ((flags & 0x0800) === 0 && raw.some((b) => b >= 0x80))
196
+ return refuse('holds a name that is not flagged as UTF-8');
197
+ let name;
198
+ try {
199
+ name = utf8.decode(raw);
200
+ }
201
+ catch {
202
+ return refuse('holds a name that is not UTF-8');
203
+ }
204
+ const unixMode = host === UNIX ? external >>> 16 : 0;
205
+ const type = unixMode & S_IFMT;
206
+ const slash = name.endsWith('/');
207
+ let kind;
208
+ if (type === 0o120000)
209
+ kind = 'link';
210
+ else if (type === 0o040000 || (type === 0 && slash))
211
+ kind = 'directory';
212
+ else if (type === 0o100000 || type === 0)
213
+ kind = 'file';
214
+ else
215
+ return refuse('holds an entry of a kind that is not taken (a device, a pipe or a socket)');
216
+ if ((kind === 'directory') !== slash)
217
+ return refuse('holds an entry whose kind and name disagree');
218
+ if (kind === 'link' && unpacked > LINK_LIMIT)
219
+ return refuse('holds a symbolic link with a target that is too long');
220
+ total += unpacked;
221
+ if (total > UNPACKED_LIMIT)
222
+ return refuse(`unpacks to more than ${UNPACKED_LIMIT} bytes`);
223
+ const local = readAt(fd, localAt, 30);
224
+ if (local.length !== 30 || local.readUInt32LE(0) !== LOCAL)
225
+ return refuse('has a damaged local header');
226
+ const localName = readAt(fd, localAt + 30, local.readUInt16LE(26));
227
+ if (!localName.equals(raw) || local.readUInt16LE(8) !== method || (local.readUInt16LE(6) & 0x0001) !== 0)
228
+ return refuse('has a local header that disagrees with its central directory');
229
+ const data = localAt + 30 + local.readUInt16LE(26) + local.readUInt16LE(28);
230
+ entries.push({ name, kind, mode: unixMode & 0o7777, method, crc, compressed, size: unpacked, local: localAt, data });
231
+ }
232
+ if (at !== cd.length)
233
+ return refuse('has a damaged central directory');
234
+ const byPlace = [...entries].sort((a, b) => a.local - b.local);
235
+ for (let i = 0; i < byPlace.length; i++) {
236
+ const x = byPlace[i];
237
+ const limit = i + 1 < byPlace.length ? byPlace[i + 1].local : cdAt;
238
+ if (x.local === limit || x.data + x.compressed > limit)
239
+ return refuse('holds entries whose data overlap');
240
+ }
241
+ return { ok: true, entries };
242
+ }
243
+ const CRC_TABLE = (() => {
244
+ const t = new Int32Array(256);
245
+ for (let n = 0; n < 256; n++) {
246
+ let c = n;
247
+ for (let k = 0; k < 8; k++)
248
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
249
+ t[n] = c;
250
+ }
251
+ return t;
252
+ })();
253
+ function crc32(crc, bytes) {
254
+ let c = ~crc;
255
+ for (let i = 0; i < bytes.length; i++)
256
+ c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
257
+ return ~c >>> 0;
258
+ }
259
+ function zipBytes(fd, e) {
260
+ const packed = readAt(fd, e.data, e.compressed);
261
+ if (packed.length !== e.compressed)
262
+ return null;
263
+ let bytes;
264
+ try {
265
+ bytes = e.method === 8 ? inflateRawSync(packed, { maxOutputLength: Math.max(e.size, 1) }) : packed;
266
+ }
267
+ catch {
268
+ return null;
269
+ }
270
+ return bytes.length === e.size && crc32(0, bytes) === e.crc ? bytes : null;
271
+ }
272
+ function withFile(path, fn) {
273
+ let fd;
274
+ try {
275
+ fd = openSync(path, 'r');
276
+ }
277
+ catch {
278
+ return null;
279
+ }
280
+ try {
281
+ return { value: fn(fd) };
282
+ }
283
+ finally {
284
+ closeSync(fd);
285
+ }
286
+ }
287
+ const wholeFile = (path) => withFile(path, (fd) => readAt(fd, 0, fstatSync(fd).size))?.value ?? null;
288
+ export function archiveEntry(path, format, wanted) {
289
+ if (format === 'tar.gz') {
290
+ const bytes = wholeFile(path);
291
+ return bytes === null ? { ok: false, problem: 'cannot be read' } : entryOf(bytes, wanted);
292
+ }
293
+ const r = withFile(path, (fd) => {
294
+ const read = zipEntries(fd);
295
+ if (!read.ok)
296
+ return read;
297
+ const found = read.entries.filter((e) => e.kind === 'file' && e.name === wanted);
298
+ if (found.length === 0)
299
+ return { ok: false, problem: `holds no ${wanted}` };
300
+ if (found.length > 1)
301
+ return { ok: false, problem: `holds ${wanted} more than once` };
302
+ const bytes = zipBytes(fd, found[0]);
303
+ return bytes === null ? { ok: false, problem: `holds a damaged ${wanted}` } : { ok: true, bytes };
304
+ });
305
+ return r?.value ?? { ok: false, problem: 'cannot be read' };
306
+ }
307
+ const SEGMENT_BAD = /[\x00-\x1f\x7f\\]/;
308
+ const segmentProblem = (s) => s === '' || s === '.' || SEGMENT_BAD.test(s);
309
+ function below(name, top) {
310
+ const bare = name.replace(/^\.\//, '');
311
+ if (bare.startsWith('/'))
312
+ return { problem: 'holds an entry with an absolute path' };
313
+ if (bare === top || bare === `${top}/`)
314
+ return { segments: [] };
315
+ if (!bare.startsWith(`${top}/`))
316
+ return { problem: `holds an entry outside ${top}/` };
317
+ const rest = bare.slice(top.length + 1).replace(/\/$/, '');
318
+ const segments = rest.split('/');
319
+ if (segments.some((s) => s === '..'))
320
+ return { problem: 'holds an entry whose path climbs out of its directory (..)' };
321
+ if (segments.some(segmentProblem))
322
+ return { problem: 'holds an entry whose path has an empty or "." segment, a backslash or a control character' };
323
+ return { segments };
324
+ }
325
+ const linkTargetTaken = (target) => target !== '' && !target.startsWith('/') && target.split('/').every((s) => s !== '..' && !segmentProblem(s));
326
+ function plannedProblem(planned) {
327
+ const kinds = new Map();
328
+ for (const p of planned) {
329
+ const key = p.segments.join('/');
330
+ if (kinds.has(key))
331
+ return 'holds a name more than once';
332
+ kinds.set(key, p.kind);
333
+ }
334
+ for (const p of planned) {
335
+ for (let i = 1; i < p.segments.length; i++) {
336
+ const parent = kinds.get(p.segments.slice(0, i).join('/'));
337
+ if (parent !== undefined && parent !== 'directory')
338
+ return `holds an entry below a ${parent === 'link' ? 'symbolic link' : 'file'} of the archive`;
339
+ }
340
+ }
341
+ return null;
342
+ }
343
+ async function writePlanned(planned, dest) {
344
+ const pathOf = (p) => join(dest, ...p.segments);
345
+ const directory = (path) => {
346
+ mkdirSync(path, { recursive: true, mode: DIR_MODE });
347
+ chmodSync(path, DIR_MODE);
348
+ };
349
+ for (const p of planned)
350
+ if (p.kind === 'directory')
351
+ directory(pathOf(p));
352
+ for (const p of planned) {
353
+ if (p.kind !== 'file')
354
+ continue;
355
+ mkdirSync(dirname(pathOf(p)), { recursive: true, mode: DIR_MODE });
356
+ const problem = await p.write(pathOf(p));
357
+ if (problem !== null)
358
+ return problem;
359
+ }
360
+ for (const p of planned) {
361
+ if (p.kind !== 'link')
362
+ continue;
363
+ mkdirSync(dirname(pathOf(p)), { recursive: true, mode: DIR_MODE });
364
+ symlinkSync(p.link, pathOf(p));
365
+ }
366
+ return null;
367
+ }
368
+ const modeOf = (mode) => ((mode & 0o111) !== 0 ? EXEC_MODE : FILE_MODE);
369
+ function writeBytes(target, bytes, mode) {
370
+ const fd = openSync(target, 'wx', mode);
371
+ try {
372
+ fchmodSync(fd, mode);
373
+ writeAll(fd, bytes);
374
+ }
375
+ finally {
376
+ closeSync(fd);
377
+ }
378
+ return null;
379
+ }
380
+ function planTar(archive, top) {
381
+ const read = tarEntries(archive);
382
+ if (!read.ok)
383
+ return { problem: read.problem };
384
+ const planned = [];
385
+ for (const e of read.entries) {
386
+ const where = below(e.name, top);
387
+ if ('problem' in where)
388
+ return where;
389
+ if (where.segments.length === 0)
390
+ continue;
391
+ if (e.type === '0') {
392
+ const mode = modeOf(e.mode);
393
+ planned.push({ segments: where.segments, kind: 'file', mode, size: e.body.length, write: (target) => writeBytes(target, e.body, mode) });
394
+ }
395
+ else if (e.type === '5')
396
+ planned.push({ segments: where.segments, kind: 'directory', mode: DIR_MODE, size: 0, write: () => null });
397
+ else if (e.type === '2') {
398
+ if (!linkTargetTaken(e.link))
399
+ return { problem: 'holds a symbolic link whose target is not a relative path below its own directory' };
400
+ planned.push({ segments: where.segments, kind: 'link', mode: 0, size: 0, link: e.link, write: () => null });
401
+ }
402
+ else if (e.type === '1')
403
+ return { problem: 'holds a hard link, which is not taken' };
404
+ else
405
+ return { problem: 'holds an entry of a kind that is not taken (a device, a pipe or a socket)' };
406
+ }
407
+ return { planned };
408
+ }
409
+ class Checked extends Transform {
410
+ limit;
411
+ size = 0;
412
+ crc = 0;
413
+ constructor(limit) {
414
+ super();
415
+ this.limit = limit;
416
+ }
417
+ _transform(chunk, _encoding, done) {
418
+ this.size += chunk.length;
419
+ if (this.size > this.limit)
420
+ return done(new Error('the entry is larger than the archive says'));
421
+ this.crc = crc32(this.crc, chunk);
422
+ done(null, chunk);
423
+ }
424
+ }
425
+ function planZip(fd, path, top) {
426
+ const read = zipEntries(fd);
427
+ if (!read.ok)
428
+ return { problem: read.problem };
429
+ const planned = [];
430
+ for (const e of read.entries) {
431
+ const where = below(e.name, top);
432
+ if ('problem' in where)
433
+ return where;
434
+ if (where.segments.length === 0)
435
+ continue;
436
+ if (e.kind === 'directory')
437
+ planned.push({ segments: where.segments, kind: 'directory', mode: DIR_MODE, size: 0, write: () => null });
438
+ else if (e.kind === 'link') {
439
+ const target = zipBytes(fd, e);
440
+ if (target === null)
441
+ return { problem: 'holds a damaged symbolic link' };
442
+ const link = target.toString('utf8');
443
+ if (!linkTargetTaken(link))
444
+ return { problem: 'holds a symbolic link whose target is not a relative path below its own directory' };
445
+ planned.push({ segments: where.segments, kind: 'link', mode: 0, size: 0, link, write: () => null });
446
+ }
447
+ else {
448
+ const mode = modeOf(e.mode);
449
+ planned.push({
450
+ segments: where.segments,
451
+ kind: 'file',
452
+ mode,
453
+ size: e.size,
454
+ write: async (target) => {
455
+ if (e.compressed === 0)
456
+ return e.size === 0 && e.crc === 0 ? writeBytes(target, Buffer.alloc(0), mode) : 'holds a damaged entry';
457
+ const checked = new Checked(e.size);
458
+ const source = createReadStream(path, { start: e.data, end: e.data + e.compressed - 1 });
459
+ let fd = null;
460
+ try {
461
+ fd = openSync(target, 'wx', mode);
462
+ fchmodSync(fd, mode);
463
+ if (e.method === 8)
464
+ await pipeline(source, createInflateRaw(), checked, writeAllStream(fd));
465
+ else
466
+ await pipeline(source, checked, writeAllStream(fd));
467
+ }
468
+ catch {
469
+ source.destroy();
470
+ return 'holds a damaged entry';
471
+ }
472
+ finally {
473
+ if (fd !== null)
474
+ closeSync(fd);
475
+ }
476
+ return checked.size === e.size && checked.crc === e.crc ? null : 'holds an entry whose size or CRC-32 does not match the archive';
477
+ },
478
+ });
479
+ }
480
+ }
481
+ return { planned };
482
+ }
483
+ export async function unpackTree(path, format, top, dest) {
484
+ if (format === 'tar.gz') {
485
+ const bytes = wholeFile(path);
486
+ if (bytes === null)
487
+ return 'cannot be read';
488
+ const plan = planTar(bytes, top);
489
+ if ('problem' in plan)
490
+ return plan.problem;
491
+ return plannedProblem(plan.planned) ?? (await writePlanned(plan.planned, dest));
492
+ }
493
+ let fd;
494
+ try {
495
+ fd = openSync(path, 'r');
496
+ }
497
+ catch {
498
+ return 'cannot be read';
499
+ }
500
+ try {
501
+ const plan = planZip(fd, path, top);
502
+ if ('problem' in plan)
503
+ return plan.problem;
504
+ return plannedProblem(plan.planned) ?? (await writePlanned(plan.planned, dest));
505
+ }
506
+ finally {
507
+ closeSync(fd);
508
+ }
509
+ }
510
+ export function treeProblem(path, format, top) {
511
+ if (format === 'tar.gz') {
512
+ const bytes = wholeFile(path);
513
+ if (bytes === null)
514
+ return 'cannot be read';
515
+ const plan = planTar(bytes, top);
516
+ return 'problem' in plan ? plan.problem : plannedProblem(plan.planned);
517
+ }
518
+ const r = withFile(path, (fd) => {
519
+ const plan = planZip(fd, path, top);
520
+ return 'problem' in plan ? plan.problem : plannedProblem(plan.planned);
521
+ });
522
+ return r === null ? 'cannot be read' : r.value;
523
+ }
524
+ export function treeListing(path, format, top) {
525
+ const listed = (plan) => {
526
+ if ('problem' in plan)
527
+ return { ok: false, problem: plan.problem };
528
+ const problem = plannedProblem(plan.planned);
529
+ if (problem !== null)
530
+ return { ok: false, problem };
531
+ const paths = new Map();
532
+ for (const p of plan.planned) {
533
+ for (let i = 1; i < p.segments.length; i++) {
534
+ const parent = p.segments.slice(0, i).join('/');
535
+ if (!paths.has(parent))
536
+ paths.set(parent, { kind: 'directory', mode: DIR_MODE, size: 0 });
537
+ }
538
+ paths.set(p.segments.join('/'), { kind: p.kind, mode: p.kind === 'link' ? 0 : p.mode, size: p.size, ...(p.link !== undefined ? { link: p.link } : {}) });
539
+ }
540
+ return { ok: true, paths };
541
+ };
542
+ if (format === 'tar.gz') {
543
+ const bytes = wholeFile(path);
544
+ return bytes === null ? { ok: false, problem: 'cannot be read' } : listed(planTar(bytes, top));
545
+ }
546
+ const r = withFile(path, (fd) => listed(planZip(fd, path, top)));
547
+ return r === null ? { ok: false, problem: 'cannot be read' } : r.value;
96
548
  }
package/dist/secrets.d.ts CHANGED
@@ -20,6 +20,7 @@ export interface FileSecrets {
20
20
  readonly directory: string;
21
21
  list(): SecretListing;
22
22
  put(name: string, value: string, options?: PutOptions): SecretHandle;
23
+ remove(name: string): boolean;
23
24
  withLock<T>(fn: () => T): T;
24
25
  }
25
26
  export declare const isSecret: (handle: SecretHandle) => boolean;
package/dist/secrets.js CHANGED
@@ -232,6 +232,8 @@ function putHandle(dir, connector, name, value, options) {
232
232
  throw new Error('a handle name is upper-case letters, digits and underscores, starting with a letter');
233
233
  if (typeof value !== 'string')
234
234
  throw new Error('a secret value is a string');
235
+ if (value === '')
236
+ throw new TypeError('a secret value is never the empty string (use remove to take the handle away)');
235
237
  if (options.expiresAt !== undefined && !validExpiry(options.expiresAt))
236
238
  throw new Error('expiresAt must be an RFC 3339 UTC time ending in Z');
237
239
  if (options.secret !== undefined && typeof options.secret !== 'boolean')
@@ -242,6 +244,37 @@ function putHandle(dir, connector, name, value, options) {
242
244
  throw new Error(r.problem);
243
245
  return r.value;
244
246
  }
247
+ function removeHandle(dir, connector, name) {
248
+ if (!HANDLE_NAME.test(name))
249
+ throw new Error('a handle name is upper-case letters, digits and underscores, starting with a letter');
250
+ try {
251
+ lstatSync(dir);
252
+ }
253
+ catch (e) {
254
+ if (e.code === 'ENOENT')
255
+ return false;
256
+ throw new Error(`connector ${connector}: the secrets directory cannot be read`);
257
+ }
258
+ const dirProblem = entryProblem(dir, 'directory', 'the secrets directory');
259
+ if (dirProblem !== null)
260
+ throw new Error(`connector ${connector}: ${dirProblem}`);
261
+ const r = locked(dir, connector, () => {
262
+ let existed = true;
263
+ try {
264
+ rmSync(join(dir, name));
265
+ }
266
+ catch (e) {
267
+ if (e.code !== 'ENOENT')
268
+ throw e;
269
+ existed = false;
270
+ }
271
+ rmSync(join(dir, name + META_SUFFIX), { force: true });
272
+ return existed;
273
+ });
274
+ if (!r.ok)
275
+ throw new Error(r.problem);
276
+ return r.value;
277
+ }
245
278
  export function openFileSecrets(options) {
246
279
  if (typeof options?.configDir !== 'string' || !isAbsolute(options.configDir))
247
280
  throw new Error('configDir must be an absolute path');
@@ -253,6 +286,7 @@ export function openFileSecrets(options) {
253
286
  directory: dir,
254
287
  list: () => listHandles(dir, connector),
255
288
  put: (name, value, putOptions = {}) => putHandle(dir, connector, name, value, putOptions),
289
+ remove: (name) => removeHandle(dir, connector, name),
256
290
  withLock: (fn) => {
257
291
  ensureDirectory(dir, connector);
258
292
  const r = locked(dir, connector, fn);
package/dist/session.js CHANGED
@@ -118,7 +118,7 @@ export class Session {
118
118
  switch (m.method) {
119
119
  case 'tools/call': {
120
120
  if (typeof name !== 'string' || !isGranted(grant, name))
121
- return [up(errorLine(id, INVALID_PARAMS, 'the requested tool is not in the granted scope', FORBIDDEN_SCOPE)), ...this.settle(call, 'refused', FORBIDDEN_SCOPE)];
121
+ return id === null ? [] : [up(errorLine(id, INVALID_PARAMS, 'the requested tool is not in the granted scope', FORBIDDEN_SCOPE)), ...this.settle(call, 'refused', FORBIDDEN_SCOPE)];
122
122
  if (this.options.parked === true)
123
123
  return id === null ? [] : [up(errorLine(id, INTERNAL_ERROR, noCredentials(this.options.connector), AUTH_REQUIRED)), ...this.settle(call, 'refused', AUTH_REQUIRED)];
124
124
  return this.forward(raw, id, grant, undefined, undefined, call);
@@ -1,2 +1,4 @@
1
+ import { Writable } from 'node:stream';
1
2
  export declare function writeAll(fd: number, data: string | Uint8Array): void;
3
+ export declare function writeAllStream(fd: number): Writable;
2
4
  export declare function writeFailure(e: unknown): string;
package/dist/write-all.js CHANGED
@@ -1,17 +1,47 @@
1
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;
2
+ import { Writable } from 'node:stream';
3
+ function writeAllAt(fd, bytes, position) {
4
4
  let written = 0;
5
5
  while (written < bytes.length) {
6
- const n = writeSync(fd, bytes, written, bytes.length - written, written);
6
+ const n = writeSync(fd, bytes, written, bytes.length - written, position + written);
7
7
  if (n <= 0)
8
8
  throw new Error(`a write stopped after ${written} of ${bytes.length} bytes`);
9
9
  written += n;
10
10
  }
11
+ return position + written;
12
+ }
13
+ function finished(fd, size) {
11
14
  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
+ const held = fstatSync(fd).size;
16
+ if (held !== size)
17
+ throw new Error(`the file holds ${held} bytes after a write of ${size}`);
18
+ }
19
+ export function writeAll(fd, data) {
20
+ const bytes = typeof data === 'string' ? Buffer.from(data, 'utf8') : data;
21
+ finished(fd, writeAllAt(fd, bytes, 0));
22
+ }
23
+ export function writeAllStream(fd) {
24
+ let position = 0;
25
+ return new Writable({
26
+ write(piece, _encoding, done) {
27
+ try {
28
+ position = writeAllAt(fd, piece, position);
29
+ done();
30
+ }
31
+ catch (e) {
32
+ done(e);
33
+ }
34
+ },
35
+ final(done) {
36
+ try {
37
+ finished(fd, position);
38
+ done();
39
+ }
40
+ catch (e) {
41
+ done(e);
42
+ }
43
+ },
44
+ });
15
45
  }
16
46
  export function writeFailure(e) {
17
47
  const code = e?.code;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/connectors",
3
- "version": "0.4.0",
3
+ "version": "0.6.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",