@akshar5/cohall 0.4.8 → 0.4.10

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/bin/cohall.js CHANGED
@@ -17,10 +17,11 @@ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
17
17
 
18
18
  // packages/protocol/src/index.ts
19
19
  import { Schema } from "effect";
20
- var version = "0.4.8", bounded = (maxLength) => Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(maxLength)), optionalText = (maxLength) => Schema.String.check(Schema.isMaxLength(maxLength)), boundedArray = (schema, maxLength) => Schema.Array(schema).check(Schema.isMaxLength(maxLength)), uuid = (name) => Schema.String.check(Schema.isUUID(4)).pipe(Schema.brand(name)), isoTimestamp = (name) => Schema.String.check(Schema.isPattern(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/, {
20
+ var version = "0.4.10", maxSocketPayloadBytes, bounded = (maxLength) => Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(maxLength)), optionalText = (maxLength) => Schema.String.check(Schema.isMaxLength(maxLength)), boundedArray = (schema, maxLength) => Schema.Array(schema).check(Schema.isMaxLength(maxLength)), uuid = (name) => Schema.String.check(Schema.isUUID(4)).pipe(Schema.brand(name)), isoTimestamp = (name) => Schema.String.check(Schema.isPattern(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/, {
21
21
  expected: "an ISO-8601 UTC timestamp"
22
22
  })).pipe(Schema.brand(name)), DeviceId, ThreadId, MessageId, TaskId, AuthSessionId, Timestamp, Platform, Provider, DeviceStatus, TaskStatus, TaskTraceEventKind, MessageRole, ConnectionRole, AuthSession, CreatePairingInput, PairingCredential, ExchangePairingInput, SessionCredential, PairingResult, Capability, Workspace, Device, Thread, Message, Task, TaskTraceEvent, TaskTrace, ThreadContext, CreateTaskInput, SocketEvent, ErrorResponse, terminalTaskStatuses, isTerminalTask = (task) => terminalTaskStatuses.has(task.status), now = () => Timestamp.make(new Date().toISOString()), makeDeviceId = () => DeviceId.make(crypto.randomUUID()), makeThreadId = () => ThreadId.make(crypto.randomUUID()), makeMessageId = () => MessageId.make(crypto.randomUUID()), makeTaskId = () => TaskId.make(crypto.randomUUID()), makeAuthSessionId = () => AuthSessionId.make(crypto.randomUUID()), decodeCreatePairingInput, decodeExchangePairingInput, decodeCreateTaskInput, decodeDevice, decodeSocketEvent;
23
23
  var init_src = __esm(() => {
24
+ maxSocketPayloadBytes = 1024 * 1024;
24
25
  DeviceId = uuid("DeviceId");
25
26
  ThreadId = uuid("ThreadId");
26
27
  MessageId = uuid("MessageId");
@@ -202,7 +203,7 @@ var init_src = __esm(() => {
202
203
 
203
204
  // apps/relay/src/config.ts
204
205
  import { Effect as Effect8, Schema as Schema9 } from "effect";
205
- import { access as access3, chmod as chmod4, mkdir as mkdir4, readFile as readFile4, writeFile as writeFile5 } from "node:fs/promises";
206
+ import { access as access4, chmod as chmod4, mkdir as mkdir4, readFile as readFile4, writeFile as writeFile5 } from "node:fs/promises";
206
207
  import { homedir as homedir4, platform as platform4 } from "node:os";
207
208
  import { join as join5, resolve as resolve2 } from "node:path";
208
209
  var RelayConfiguration, defaultDataDirectory = () => {
@@ -214,7 +215,7 @@ var RelayConfiguration, defaultDataDirectory = () => {
214
215
  }
215
216
  return join5(process.env.XDG_DATA_HOME ?? join5(homedir4(), ".local", "share"), "cohall");
216
217
  }, generatedToken = () => Array.from(crypto.getRandomValues(new Uint8Array(32)), (value) => value.toString(16).padStart(2, "0")).join(""), tokenFrom = async (path) => {
217
- if (await access3(path).then(() => true).catch(() => false)) {
218
+ if (await access4(path).then(() => true).catch(() => false)) {
218
219
  return (await readFile4(path, "utf8")).trim();
219
220
  }
220
221
  const token = generatedToken();
@@ -238,10 +239,11 @@ var init_config = __esm(() => {
238
239
  RelayConfiguration = Schema9.Struct({
239
240
  host: Schema9.NonEmptyString,
240
241
  port: Schema9.Int.check(Schema9.isBetween({ minimum: 1, maximum: 65535 })),
241
- token: Schema9.NonEmptyString,
242
+ token: Schema9.String.check(Schema9.isMinLength(32)),
242
243
  dataDirectory: Schema9.NonEmptyString,
243
244
  databasePath: Schema9.NonEmptyString,
244
- allowRemote: Schema9.Boolean
245
+ allowRemote: Schema9.Boolean,
246
+ historyTaskLimit: Schema9.Int.check(Schema9.isBetween({ minimum: 100, maximum: 1e5 }))
245
247
  });
246
248
  loadEnvironmentConfiguration = Effect8.tryPromise({
247
249
  try: async () => {
@@ -257,13 +259,18 @@ var init_config = __esm(() => {
257
259
  await chmod4(dataDirectory, 448);
258
260
  }
259
261
  const token = process.env.COHALL_TOKEN ?? await tokenFrom(join5(dataDirectory, "owner-token"));
262
+ if (token.length < 32) {
263
+ throw new Error("The Cohall relay owner token must be at least 32 characters");
264
+ }
265
+ const historyTaskLimit = Number(process.env.COHALL_HISTORY_TASK_LIMIT ?? "1000");
260
266
  return Schema9.decodeUnknownSync(RelayConfiguration)({
261
267
  host,
262
268
  port,
263
269
  token,
264
270
  dataDirectory,
265
271
  databasePath: join5(dataDirectory, "cohall.db"),
266
- allowRemote
272
+ allowRemote,
273
+ historyTaskLimit
267
274
  });
268
275
  },
269
276
  catch: (cause) => cause instanceof Error ? cause : new Error(String(cause))
@@ -277,12 +284,27 @@ class Hub {
277
284
  #principals = new Map;
278
285
  #devices = new Map;
279
286
  #deviceIds = new Map;
287
+ #sessions = new Map;
288
+ #ownerSockets = new Set;
280
289
  attach(socket, principal) {
290
+ if (principal.sessionId === undefined) {
291
+ if (this.#ownerSockets.size >= 16) {
292
+ return false;
293
+ }
294
+ this.#ownerSockets.add(socket);
295
+ } else {
296
+ const existing = this.#sessions.get(principal.sessionId);
297
+ if (existing !== undefined && existing.readyState === WebSocket2.OPEN) {
298
+ return false;
299
+ }
300
+ this.#sessions.set(principal.sessionId, socket);
301
+ }
281
302
  this.#principals.set(socket, principal);
282
- if (socket.data.authDeadline !== undefined) {
283
- clearTimeout(socket.data.authDeadline);
284
- socket.data.authDeadline = undefined;
303
+ if (socket.data.stageDeadline !== undefined) {
304
+ clearTimeout(socket.data.stageDeadline);
305
+ socket.data.stageDeadline = undefined;
285
306
  }
307
+ return true;
286
308
  }
287
309
  isAuthorized(socket) {
288
310
  const principal = this.#principals.get(socket);
@@ -299,19 +321,34 @@ class Hub {
299
321
  return this.#principals.get(socket)?.boundDeviceId;
300
322
  }
301
323
  registerDevice(deviceId, socket) {
324
+ if (this.#deviceIds.has(socket)) {
325
+ return false;
326
+ }
302
327
  const previous = this.#devices.get(deviceId);
303
328
  if (previous !== undefined && previous !== socket && previous.readyState === WebSocket2.OPEN) {
304
329
  return false;
305
330
  }
306
331
  this.#devices.set(deviceId, socket);
307
332
  this.#deviceIds.set(socket, deviceId);
333
+ if (socket.data.stageDeadline !== undefined) {
334
+ clearTimeout(socket.data.stageDeadline);
335
+ socket.data.stageDeadline = undefined;
336
+ }
308
337
  return true;
309
338
  }
339
+ pendingConnections() {
340
+ return this.#principals.size - this.#deviceIds.size;
341
+ }
310
342
  detach(socket) {
311
- if (socket.data.authDeadline !== undefined) {
312
- clearTimeout(socket.data.authDeadline);
343
+ if (socket.data.stageDeadline !== undefined) {
344
+ clearTimeout(socket.data.stageDeadline);
313
345
  }
346
+ const principal = this.#principals.get(socket);
314
347
  this.#principals.delete(socket);
348
+ this.#ownerSockets.delete(socket);
349
+ if (principal?.sessionId !== undefined && this.#sessions.get(principal.sessionId) === socket) {
350
+ this.#sessions.delete(principal.sessionId);
351
+ }
315
352
  const deviceId = this.#deviceIds.get(socket);
316
353
  this.#deviceIds.delete(socket);
317
354
  if (deviceId === undefined || this.#devices.get(deviceId) !== socket) {
@@ -527,7 +564,7 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
527
564
  events.push(TaskTraceEvent.make({ kind: task.status, at: task.completedAt, detail }));
528
565
  }
529
566
  return events;
530
- }, makeService = (db) => {
567
+ }, makeService = (db, retainedTerminalTasks = 1000) => {
531
568
  const recordTaskTraceEvent = (taskId, kind, detail, createdAt = now()) => {
532
569
  db.query(`INSERT INTO task_trace_events (task_id, kind, created_at, detail)
533
570
  VALUES (?, ?, ?, ?)`).run(taskId, kind, createdAt, detail);
@@ -535,6 +572,21 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
535
572
  SELECT id FROM task_trace_events WHERE task_id = ? ORDER BY id DESC LIMIT 1000
536
573
  )`).run(taskId, taskId);
537
574
  };
575
+ const pruneTerminalHistory = (preserveTaskId) => {
576
+ const exclusion = preserveTaskId === undefined ? "" : "AND id <> ?";
577
+ const offset = preserveTaskId === undefined ? retainedTerminalTasks : retainedTerminalTasks - 1;
578
+ const staleTasks = `SELECT id FROM tasks
579
+ WHERE status IN ('completed', 'failed', 'cancelled')
580
+ ${exclusion}
581
+ ORDER BY completed_at DESC, updated_at DESC, id DESC
582
+ LIMIT -1 OFFSET ?`;
583
+ const parameters = preserveTaskId === undefined ? [offset] : [preserveTaskId, offset];
584
+ db.query(`DELETE FROM messages WHERE task_id IN (${staleTasks})`).run(...parameters);
585
+ db.query(`DELETE FROM tasks WHERE id IN (${staleTasks})`).run(...parameters);
586
+ db.query(`DELETE FROM threads
587
+ WHERE NOT EXISTS (SELECT 1 FROM tasks WHERE tasks.thread_id = threads.id)
588
+ AND NOT EXISTS (SELECT 1 FROM messages WHERE messages.thread_id = threads.id)`).run();
589
+ };
538
590
  const queryTask = (taskId) => db.query("SELECT * FROM tasks WHERE id = ?").get(taskId);
539
591
  const getTask = Effect9.fn("RelayStore.getTask")(function* (taskId) {
540
592
  const row = yield* Effect9.try({
@@ -632,6 +684,7 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
632
684
  }
633
685
  db.query("DELETE FROM auth_pairings WHERE expires_at <= ? OR used_at IS NOT NULL").run(timestamp);
634
686
  db.query("UPDATE auth_sessions SET revoked_at = ? WHERE revoked_at IS NULL AND roles_json LIKE '%,%'").run(timestamp);
687
+ pruneTerminalHistory();
635
688
  })(),
636
689
  catch: operationError("RelayStore.recover")
637
690
  });
@@ -797,6 +850,9 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
797
850
  if (result.changes === 1 && event !== undefined) {
798
851
  recordTaskTraceEvent(taskId, event.kind, event.detail, timestamp);
799
852
  }
853
+ if (result.changes === 1 && update.status !== undefined && ["completed", "failed", "cancelled"].includes(update.status)) {
854
+ pruneTerminalHistory(taskId);
855
+ }
800
856
  })(),
801
857
  catch: operationError("RelayStore.transition")
802
858
  });
@@ -864,6 +920,7 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
864
920
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(makeMessageId(), current.threadId, role, status === "failed" ? "error" : "chat", deviceId, status === "completed" ? providerName(current.provider) : "Cohall", content, timestamp, taskId, deviceId);
865
921
  db.query("UPDATE threads SET updated_at = ? WHERE id = ?").run(timestamp, current.threadId);
866
922
  }
923
+ pruneTerminalHistory(taskId);
867
924
  })(),
868
925
  catch: operationError("RelayStore.terminal")
869
926
  });
@@ -1100,10 +1157,10 @@ var PersistenceError, Service2, operationError = (operation) => (cause) => new P
1100
1157
  }
1101
1158
  },
1102
1159
  catch: operationError("RelayStore.migrate")
1103
- }), layer2 = (path) => Layer2.effect(Service2, Effect9.acquireRelease(Effect9.try({
1160
+ }), layer2 = (path, retainedTerminalTasks = 1000) => Layer2.effect(Service2, Effect9.acquireRelease(Effect9.try({
1104
1161
  try: () => new Database(path),
1105
1162
  catch: operationError("RelayStore.open")
1106
- }).pipe(Effect9.tap(migrate)), (db) => Effect9.sync(() => db.close())).pipe(Effect9.map(makeService))), layerFromDatabase = (db) => Layer2.effect(Service2, migrate(db).pipe(Effect9.map(() => makeService(db))));
1163
+ }).pipe(Effect9.tap(migrate)), (db) => Effect9.sync(() => db.close())).pipe(Effect9.map((db) => makeService(db, retainedTerminalTasks)))), layerFromDatabase = (db) => Layer2.effect(Service2, migrate(db).pipe(Effect9.map(() => makeService(db))));
1107
1164
  var init_store = __esm(() => {
1108
1165
  init_src();
1109
1166
  init_database();
@@ -1122,7 +1179,7 @@ __export(exports_main, {
1122
1179
  });
1123
1180
  import { Effect as Effect10, ManagedRuntime, Schema as Schema11 } from "effect";
1124
1181
  import { createHash as createHash2, timingSafeEqual } from "node:crypto";
1125
- import { access as access4, chmod as chmod5 } from "node:fs/promises";
1182
+ import { access as access5, chmod as chmod5 } from "node:fs/promises";
1126
1183
  import { createServer } from "node:http";
1127
1184
  import { platform as platform5 } from "node:os";
1128
1185
  import { WebSocketServer } from "ws";
@@ -1258,13 +1315,13 @@ Content-Length: 0\r
1258
1315
  }), runRelay = async () => {
1259
1316
  const configuration = await Effect10.runPromise(loadEnvironmentConfiguration);
1260
1317
  process.umask(63);
1261
- const runtime = ManagedRuntime.make(exports_store.layer(configuration.databasePath));
1318
+ const runtime = ManagedRuntime.make(exports_store.layer(configuration.databasePath, configuration.historyTaskLimit));
1262
1319
  const run2 = (effect) => runtime.runPromise(effect);
1263
1320
  await run2(Effect10.gen(function* () {
1264
1321
  const store = yield* exports_store.Service;
1265
1322
  yield* store.recover();
1266
1323
  }));
1267
- if (platform5() !== "win32" && await access4(configuration.databasePath).then(() => true).catch(() => false)) {
1324
+ if (platform5() !== "win32" && await access5(configuration.databasePath).then(() => true).catch(() => false)) {
1268
1325
  await chmod5(configuration.databasePath, 384);
1269
1326
  }
1270
1327
  const hub = new Hub;
@@ -1346,11 +1403,24 @@ Content-Length: 0\r
1346
1403
  socket.close(4003, "Authentication failed");
1347
1404
  return;
1348
1405
  }
1349
- hub.attach(socket, {
1406
+ if (hub.pendingConnections() >= 64) {
1407
+ socket.close(4008, "Pending device connection limit reached");
1408
+ return;
1409
+ }
1410
+ const attached = hub.attach(socket, {
1350
1411
  ...principal === "owner" ? {} : { sessionId: principal.id },
1351
1412
  ...principal === "owner" || principal.deviceId === undefined ? {} : { boundDeviceId: principal.deviceId },
1352
1413
  ...principal === "owner" ? {} : { expiresAt: principal.expiresAt }
1353
1414
  });
1415
+ if (!attached) {
1416
+ socket.close(4008, "Credential connection limit reached");
1417
+ return;
1418
+ }
1419
+ socket.data.stageDeadline = setTimeout(() => {
1420
+ if (hub.deviceId(socket) === undefined) {
1421
+ socket.close(4003, "Device registration timeout");
1422
+ }
1423
+ }, 5000);
1354
1424
  socket.send(JSON.stringify(SocketEvent.make({ _tag: "Connected", serverVersion: version, connectedAt: now() })));
1355
1425
  return;
1356
1426
  }
@@ -1516,20 +1586,20 @@ Content-Length: 0\r
1516
1586
  };
1517
1587
  const websocketServer = new WebSocketServer({
1518
1588
  noServer: true,
1519
- maxPayload: 256 * 1024,
1589
+ maxPayload: maxSocketPayloadBytes,
1520
1590
  perMessageDeflate: false
1521
1591
  });
1522
1592
  websocketServer.on("connection", (rawSocket) => {
1523
1593
  const socket = rawSocket;
1524
1594
  socket.data = {
1525
1595
  processing: Promise.resolve(),
1526
- authDeadline: undefined,
1596
+ stageDeadline: undefined,
1527
1597
  preAuthFrameReceived: false,
1528
1598
  queuedMessages: 0,
1529
1599
  closed: false
1530
1600
  };
1531
1601
  connections += 1;
1532
- socket.data.authDeadline = setTimeout(() => {
1602
+ socket.data.stageDeadline = setTimeout(() => {
1533
1603
  if (!hub.isAuthorized(socket)) {
1534
1604
  socket.close(4003, "Authentication timeout");
1535
1605
  }
@@ -1539,6 +1609,9 @@ Content-Length: 0\r
1539
1609
  alive = true;
1540
1610
  });
1541
1611
  const heartbeat = setInterval(() => {
1612
+ if (!hub.isAuthorized(socket)) {
1613
+ return;
1614
+ }
1542
1615
  if (!alive) {
1543
1616
  socket.terminate();
1544
1617
  return;
@@ -1809,7 +1882,7 @@ import { accessSync, constants } from "node:fs";
1809
1882
  import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
1810
1883
  import { homedir, platform, tmpdir } from "node:os";
1811
1884
  import { delimiter, extname, isAbsolute, join } from "node:path";
1812
- import { spawn } from "node:child_process";
1885
+ import { execFile, spawn } from "node:child_process";
1813
1886
 
1814
1887
  class ProviderUnavailableError extends Schema3.TaggedErrorClass()("CohallProvider.Unavailable", { provider: Provider, message: Schema3.String }) {
1815
1888
  }
@@ -2116,6 +2189,50 @@ var parse = async (options, stdout, onFailure) => {
2116
2189
  }
2117
2190
  };
2118
2191
  var byteLength = (value) => new TextEncoder().encode(value).byteLength;
2192
+ var processExists = (pid) => {
2193
+ try {
2194
+ process.kill(-pid, 0);
2195
+ return true;
2196
+ } catch (cause) {
2197
+ if (typeof cause === "object" && cause !== null && "code" in cause && cause.code === "ESRCH") {
2198
+ return false;
2199
+ }
2200
+ throw cause;
2201
+ }
2202
+ };
2203
+ var waitForProcessGroup = async (pid, timeoutMs) => {
2204
+ const deadline = Date.now() + timeoutMs;
2205
+ while (processExists(pid)) {
2206
+ if (Date.now() >= deadline) {
2207
+ return false;
2208
+ }
2209
+ await new Promise((resolve) => setTimeout(resolve, 25));
2210
+ }
2211
+ return true;
2212
+ };
2213
+ var terminateWindowsTree = (pid) => new Promise((resolve) => {
2214
+ const systemRoot = process.env.SystemRoot ?? "C:\\Windows";
2215
+ execFile(join(systemRoot, "System32", "taskkill.exe"), ["/PID", String(pid), "/T", "/F"], { windowsHide: true }, () => resolve());
2216
+ });
2217
+ var terminateProcessTree = async (pid) => {
2218
+ if (platform() === "win32") {
2219
+ await terminateWindowsTree(pid);
2220
+ return;
2221
+ }
2222
+ try {
2223
+ process.kill(-pid, "SIGTERM");
2224
+ } catch (cause) {
2225
+ if (typeof cause === "object" && cause !== null && "code" in cause && cause.code === "ESRCH") {
2226
+ return;
2227
+ }
2228
+ throw cause;
2229
+ }
2230
+ if (await waitForProcessGroup(pid, 5000)) {
2231
+ return;
2232
+ }
2233
+ process.kill(-pid, "SIGKILL");
2234
+ await waitForProcessGroup(pid, 1000);
2235
+ };
2119
2236
  var run = (options) => Effect2.tryPromise({
2120
2237
  try: async (signal) => {
2121
2238
  const executable = findExecutable(executables[options.provider]);
@@ -2126,72 +2243,74 @@ var run = (options) => Effect2.tryPromise({
2126
2243
  });
2127
2244
  }
2128
2245
  const prepared = await prepareCommand(options);
2129
- const [, ...arguments_] = prepared.command;
2130
- const child = spawn(executable, arguments_, {
2131
- cwd: options.cwd,
2132
- env: {
2133
- ...providerEnvironment(),
2134
- COHALL_PROVIDER: options.provider,
2135
- COHALL_THREAD_ID: options.threadId
2136
- },
2137
- stdio: ["pipe", "pipe", "pipe"]
2138
- });
2139
- const exited = new Promise((resolve, reject) => {
2140
- child.once("error", reject);
2141
- child.once("exit", (code) => resolve(code ?? 1));
2142
- });
2143
- let killTimer;
2144
- const terminate = () => {
2145
- if (child.exitCode !== null) {
2146
- return;
2246
+ try {
2247
+ await options.beforeSpawn?.();
2248
+ const [, ...arguments_] = prepared.command;
2249
+ const child = spawn(executable, arguments_, {
2250
+ cwd: options.cwd,
2251
+ env: {
2252
+ ...providerEnvironment(),
2253
+ COHALL_PROVIDER: options.provider,
2254
+ COHALL_THREAD_ID: options.threadId
2255
+ },
2256
+ detached: platform() !== "win32",
2257
+ stdio: ["pipe", "pipe", "pipe"]
2258
+ });
2259
+ const exited = new Promise((resolve, reject) => {
2260
+ child.once("error", reject);
2261
+ child.once("exit", (code) => resolve(code ?? 1));
2262
+ });
2263
+ let termination;
2264
+ const terminate = () => {
2265
+ if (child.pid === undefined || termination !== undefined) {
2266
+ return;
2267
+ }
2268
+ termination = terminateProcessTree(child.pid);
2269
+ };
2270
+ signal.addEventListener("abort", terminate, { once: true });
2271
+ if (signal.aborted) {
2272
+ terminate();
2147
2273
  }
2148
- child.kill("SIGTERM");
2149
- killTimer ??= setTimeout(() => {
2274
+ child.stdin.end(prepared.input);
2275
+ try {
2276
+ const [result, stderr, exitCode] = await Promise.all([
2277
+ parse(options, child.stdout, terminate),
2278
+ captureText(child.stderr, 64 * 1024),
2279
+ exited
2280
+ ]);
2281
+ if (exitCode !== 0) {
2282
+ throw new ProviderRunError({
2283
+ provider: options.provider,
2284
+ message: failureMessage(executables[options.provider], stderr, exitCode),
2285
+ exitCode
2286
+ });
2287
+ }
2288
+ if (byteLength(result.result) > 131072) {
2289
+ throw new ProviderRunError({
2290
+ provider: options.provider,
2291
+ message: "Provider result exceeded 128 KiB"
2292
+ });
2293
+ }
2294
+ if (result.sessionId !== undefined && byteLength(result.sessionId) > 4096) {
2295
+ throw new ProviderRunError({
2296
+ provider: options.provider,
2297
+ message: "Provider session ID exceeded 4 KiB"
2298
+ });
2299
+ }
2300
+ return result;
2301
+ } finally {
2302
+ signal.removeEventListener("abort", terminate);
2150
2303
  if (child.exitCode === null) {
2151
- child.kill("SIGKILL");
2304
+ terminate();
2305
+ await exited.catch(() => {
2306
+ return;
2307
+ });
2152
2308
  }
2153
- }, 5000);
2154
- killTimer.unref();
2155
- };
2156
- signal.addEventListener("abort", terminate, { once: true });
2157
- child.stdin.end(prepared.input);
2158
- try {
2159
- const [result, stderr, exitCode] = await Promise.all([
2160
- parse(options, child.stdout, terminate),
2161
- captureText(child.stderr, 64 * 1024),
2162
- exited
2163
- ]);
2164
- if (exitCode !== 0) {
2165
- throw new ProviderRunError({
2166
- provider: options.provider,
2167
- message: failureMessage(executables[options.provider], stderr, exitCode),
2168
- exitCode
2169
- });
2170
- }
2171
- if (byteLength(result.result) > 131072) {
2172
- throw new ProviderRunError({
2173
- provider: options.provider,
2174
- message: "Provider result exceeded 128 KiB"
2175
- });
2176
- }
2177
- if (result.sessionId !== undefined && byteLength(result.sessionId) > 4096) {
2178
- throw new ProviderRunError({
2179
- provider: options.provider,
2180
- message: "Provider session ID exceeded 4 KiB"
2181
- });
2182
- }
2183
- return result;
2184
- } finally {
2185
- signal.removeEventListener("abort", terminate);
2186
- if (child.exitCode === null) {
2187
- terminate();
2188
- await exited.catch(() => {
2309
+ await termination?.catch(() => {
2189
2310
  return;
2190
2311
  });
2191
2312
  }
2192
- if (killTimer !== undefined) {
2193
- clearTimeout(killTimer);
2194
- }
2313
+ } finally {
2195
2314
  await prepared.cleanup();
2196
2315
  }
2197
2316
  },
@@ -2208,7 +2327,7 @@ var run = (options) => Effect2.tryPromise({
2208
2327
 
2209
2328
  // apps/device/src/cli.ts
2210
2329
  import { Effect as Effect5, Schema as Schema7 } from "effect";
2211
- import { access as access2, mkdir as mkdir3, readFile as readFile3, stat, writeFile as writeFile4 } from "node:fs/promises";
2330
+ import { access as access3, mkdir as mkdir3, readFile as readFile3, stat as stat2, writeFile as writeFile4 } from "node:fs/promises";
2212
2331
  import { homedir as homedir3 } from "node:os";
2213
2332
  import { dirname as dirname3, join as join4 } from "node:path";
2214
2333
 
@@ -2342,7 +2461,8 @@ cohall thread <thread-id>
2342
2461
  \`\`\`
2343
2462
 
2344
2463
  This returns a byte-bounded recent window of prompts, final responses, and task
2345
- states. Check \`truncated\`; older history remains on the relay when it is true.
2464
+ states. Check \`truncated\`; older entries may be outside the returned window or
2465
+ pruned by the relay's terminal-task retention limit.
2346
2466
 
2347
2467
  ## Input forms
2348
2468
 
@@ -2571,6 +2691,9 @@ var loadOwnerConfiguration = Effect3.tryPromise({
2571
2691
  if (token === undefined || token.length === 0) {
2572
2692
  throw new Error("No owner credential. Run this command on the relay host or set COHALL_TOKEN.");
2573
2693
  }
2694
+ if (token.length < 32) {
2695
+ throw new Error("The Cohall relay owner token must be at least 32 characters");
2696
+ }
2574
2697
  return ClientConfiguration.make({
2575
2698
  relayUrl: normalizeRelayUrl(process.env.COHALL_RELAY_URL ?? stored.relayUrl),
2576
2699
  token
@@ -2704,9 +2827,20 @@ var threadContext = Effect4.fn("Cohall.threadContext")(function* (client, thread
2704
2827
 
2705
2828
  // apps/device/src/upgrade.ts
2706
2829
  import { Schema as Schema6 } from "effect";
2707
- import { execFile } from "node:child_process";
2708
- import { chmod as chmod3, mkdir as mkdir2, readFile as readFile2, realpath as realpath2, rename as rename2, rm as rm2, writeFile as writeFile3 } from "node:fs/promises";
2709
- import { dirname as dirname2, join as join3 } from "node:path";
2830
+ import { execFile as execFile2 } from "node:child_process";
2831
+ import { constants as constants2 } from "node:fs";
2832
+ import {
2833
+ access as access2,
2834
+ chmod as chmod3,
2835
+ mkdir as mkdir2,
2836
+ readFile as readFile2,
2837
+ realpath as realpath2,
2838
+ rename as rename2,
2839
+ rm as rm2,
2840
+ stat,
2841
+ writeFile as writeFile3
2842
+ } from "node:fs/promises";
2843
+ import { delimiter as delimiter2, dirname as dirname2, extname as extname2, isAbsolute as isAbsolute2, join as join3 } from "node:path";
2710
2844
  import { platform as operatingSystem } from "node:os";
2711
2845
  var packageName = "@akshar5/cohall";
2712
2846
  var semanticVersion = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
@@ -2744,7 +2878,7 @@ var packageInstallation = (canonicalEntrypoint, entrypoint = canonicalEntrypoint
2744
2878
  if (path.includes("/.bun/install/global/node_modules/")) {
2745
2879
  return { manager: "bun", entrypoint };
2746
2880
  }
2747
- if (path.includes("/pnpm/global/") || path.includes("/.pnpm/")) {
2881
+ if (path.includes("/pnpm/global/")) {
2748
2882
  return { manager: "pnpm", entrypoint };
2749
2883
  }
2750
2884
  const nodeModulesParent = path.slice(0, packageIndex);
@@ -2770,6 +2904,80 @@ var packageInstallCommand = (installation, target) => {
2770
2904
  };
2771
2905
  }
2772
2906
  };
2907
+ var executableNames = (command) => {
2908
+ if (operatingSystem() !== "win32" || extname2(command).length > 0) {
2909
+ return [command];
2910
+ }
2911
+ return (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean).map((extension) => `${command}${extension.toLowerCase()}`);
2912
+ };
2913
+ var trustedExecutable = async (command) => {
2914
+ const candidates = isAbsolute2(command) ? [command] : (process.env.PATH ?? "").split(delimiter2).filter(Boolean).flatMap((directory) => executableNames(command).map((name) => join3(directory, name)));
2915
+ let selected;
2916
+ for (const path of candidates) {
2917
+ const available2 = await access2(path, operatingSystem() === "win32" ? constants2.F_OK : constants2.X_OK).then(() => true).catch(() => false);
2918
+ if (available2) {
2919
+ selected = path;
2920
+ break;
2921
+ }
2922
+ }
2923
+ if (selected === undefined) {
2924
+ throw new Error(`Could not find ${command} on PATH`);
2925
+ }
2926
+ const canonical = await realpath2(selected);
2927
+ if (operatingSystem() === "win32") {
2928
+ return canonical;
2929
+ }
2930
+ const uid = process.getuid?.();
2931
+ for (let path = canonical;; path = dirname2(path)) {
2932
+ const metadata = await stat(path);
2933
+ if ((metadata.mode & 18) !== 0) {
2934
+ throw new Error(`Refusing executable beneath group- or world-writable path ${path}`);
2935
+ }
2936
+ if (uid !== undefined && metadata.uid !== 0 && metadata.uid !== uid) {
2937
+ throw new Error(`Refusing executable owned by another user at ${path}`);
2938
+ }
2939
+ const parent = dirname2(path);
2940
+ if (parent === path) {
2941
+ break;
2942
+ }
2943
+ }
2944
+ return canonical;
2945
+ };
2946
+ var trustedServices = async (services, resolveExecutable) => {
2947
+ const resolved = new Map;
2948
+ const invocation = async (value) => {
2949
+ let command = resolved.get(value.command);
2950
+ if (command === undefined) {
2951
+ command = resolveExecutable(value.command);
2952
+ resolved.set(value.command, command);
2953
+ }
2954
+ return { ...value, command: await command };
2955
+ };
2956
+ const secured = [];
2957
+ for (const service of services) {
2958
+ let check;
2959
+ try {
2960
+ check = await invocation(service.check);
2961
+ } catch (cause) {
2962
+ if (cause instanceof Error && cause.message.startsWith("Could not find ")) {
2963
+ continue;
2964
+ }
2965
+ throw cause;
2966
+ }
2967
+ secured.push({
2968
+ ...service,
2969
+ check,
2970
+ ...service.entrypoint === undefined ? {} : {
2971
+ entrypoint: {
2972
+ ...service.entrypoint,
2973
+ inspect: await invocation(service.entrypoint.inspect)
2974
+ }
2975
+ },
2976
+ restart: await Promise.all(service.restart.map(invocation))
2977
+ });
2978
+ }
2979
+ return secured;
2980
+ };
2773
2981
  var serviceCandidates = (runtimePlatform, uid) => {
2774
2982
  if (runtimePlatform === "linux") {
2775
2983
  const systemd = (manager, unit, device) => {
@@ -2831,11 +3039,11 @@ var serviceCandidates = (runtimePlatform, uid) => {
2831
3039
  },
2832
3040
  restart: [
2833
3041
  {
2834
- command: "schtasks",
3042
+ command: "schtasks.exe",
2835
3043
  arguments: ["/End", "/TN", "Cohall Device"],
2836
3044
  allowFailure: true
2837
3045
  },
2838
- { command: "schtasks", arguments: ["/Run", "/TN", "Cohall Device"] }
3046
+ { command: "schtasks.exe", arguments: ["/Run", "/TN", "Cohall Device"] }
2839
3047
  ]
2840
3048
  }
2841
3049
  ];
@@ -2844,7 +3052,7 @@ var serviceCandidates = (runtimePlatform, uid) => {
2844
3052
  };
2845
3053
  var defaultRunner = {
2846
3054
  run: (command, arguments_, timeoutMs = 300000) => new Promise((resolve2) => {
2847
- execFile(command, [...arguments_], { encoding: "utf8", maxBuffer: 1024 * 1024, timeout: timeoutMs, windowsHide: true }, (error, stdout, stderr) => {
3055
+ execFile2(command, [...arguments_], { encoding: "utf8", maxBuffer: 1024 * 1024, timeout: timeoutMs, windowsHide: true }, (error, stdout, stderr) => {
2848
3056
  resolve2({
2849
3057
  exitCode: typeof error?.code === "number" ? error.code : error === null ? 0 : 1,
2850
3058
  stdout,
@@ -2953,9 +3161,10 @@ var restartServices = async (runner, services, statePath, state, preserveDeviceR
2953
3161
  var reportedRestartServices = (receipt) => receipt.restartingService === undefined || receipt.restartedServices.includes(receipt.restartingService) ? receipt.restartedServices : [...receipt.restartedServices, receipt.restartingService];
2954
3162
  var upgrade = async (options) => {
2955
3163
  const runner = options.runner ?? defaultRunner;
3164
+ const resolveExecutable = options.resolveExecutable ?? trustedExecutable;
2956
3165
  const runtimePlatform = options.platform ?? process.platform;
2957
3166
  const uid = options.uid ?? process.getuid?.();
2958
- const candidates = serviceCandidates(runtimePlatform, uid);
3167
+ const candidates = await trustedServices(serviceCandidates(runtimePlatform, uid), resolveExecutable);
2959
3168
  const statePath = options.statePath ?? restartReceiptPath();
2960
3169
  const previous = await readReceipt(statePath);
2961
3170
  if (previous?.version === options.currentVersion) {
@@ -3035,7 +3244,7 @@ var upgrade = async (options) => {
3035
3244
  dry_run: true
3036
3245
  };
3037
3246
  }
3038
- await checked(runner, install);
3247
+ await checked(runner, { ...install, command: await resolveExecutable(install.command) });
3039
3248
  const nextVersion = await installedVersion(entrypoint);
3040
3249
  if (target !== "latest" && nextVersion !== target) {
3041
3250
  throw new Error(`Installed Cohall ${nextVersion}, expected ${target}`);
@@ -3225,10 +3434,10 @@ var readInput = async (arguments_, name) => {
3225
3434
  throw new Error(`Use either --${name} or --${name}-file, not both`);
3226
3435
  }
3227
3436
  if (path !== undefined) {
3228
- if (!await access2(path).then(() => true).catch(() => false)) {
3437
+ if (!await access3(path).then(() => true).catch(() => false)) {
3229
3438
  throw new Error(`File does not exist: ${path}`);
3230
3439
  }
3231
- if ((await stat(path)).size > 131072) {
3440
+ if ((await stat2(path)).size > 131072) {
3232
3441
  throw new Error(`${name} file exceeds 128 KiB`);
3233
3442
  }
3234
3443
  return readFile3(path, "utf8");
@@ -3261,10 +3470,10 @@ var timeout = (arguments_) => {
3261
3470
  };
3262
3471
  var pairingToken = async (arguments_) => {
3263
3472
  const path = option(arguments_, "token-file");
3264
- if (path !== undefined && !await access2(path).then(() => true).catch(() => false)) {
3473
+ if (path !== undefined && !await access3(path).then(() => true).catch(() => false)) {
3265
3474
  throw new Error(`Pairing token file does not exist: ${path}`);
3266
3475
  }
3267
- if (path !== undefined && (await stat(path)).size > 512) {
3476
+ if (path !== undefined && (await stat2(path)).size > 512) {
3268
3477
  throw new Error("Pairing token file exceeds 512 bytes");
3269
3478
  }
3270
3479
  const token = (path === undefined ? await readStdin(512, "Pairing token") : await readFile3(path, "utf8")).trim();
@@ -3351,14 +3560,23 @@ var runCli = async (command, raw) => {
3351
3560
  const clientToken = paired.credentials.find((credential) => credential.session.role === "client")?.token;
3352
3561
  const deviceToken = paired.credentials.find((credential) => credential.session.role === "device")?.token;
3353
3562
  const deviceId = paired.credentials.find((credential) => credential.session.role === "device")?.session.deviceId;
3354
- if (!arguments_.options.has("client-only") && deviceToken === undefined) {
3563
+ const clientOnly = arguments_.options.has("client-only");
3564
+ if (clientOnly && clientToken === undefined) {
3565
+ throw new Error("The pairing credential did not include client access");
3566
+ }
3567
+ if (!clientOnly && deviceToken === undefined) {
3355
3568
  throw new Error("The pairing credential did not include device access");
3356
3569
  }
3570
+ const {
3571
+ clientToken: _retainedClientToken,
3572
+ deviceToken: _retainedDeviceToken,
3573
+ ...configurationDraft
3574
+ } = draft;
3357
3575
  const configuration2 = StoredConfiguration.make({
3358
- ...draft,
3359
- deviceId: deviceId ?? draft.deviceId,
3576
+ ...configurationDraft,
3577
+ deviceId: clientOnly ? configurationDraft.deviceId : deviceId ?? configurationDraft.deviceId,
3360
3578
  ...clientToken === undefined ? {} : { clientToken },
3361
- ...deviceToken === undefined ? {} : { deviceToken }
3579
+ ...clientOnly || deviceToken === undefined ? {} : { deviceToken }
3362
3580
  });
3363
3581
  await writeStoredConfiguration(configuration2);
3364
3582
  print({
@@ -3366,7 +3584,7 @@ var runCli = async (command, raw) => {
3366
3584
  config_path: configurationPath(),
3367
3585
  relay_url: configuration2.relayUrl,
3368
3586
  device_id: configuration2.deviceId,
3369
- roles: paired.credentials.map((credential) => credential.session.role),
3587
+ roles: clientOnly ? paired.credentials.map((credential) => credential.session.role).filter((role) => role === "client") : paired.credentials.map((credential) => credential.session.role),
3370
3588
  workspaces: configuration2.workspaces
3371
3589
  });
3372
3590
  return;
@@ -3618,11 +3836,11 @@ var runCli = async (command, raw) => {
3618
3836
  // apps/device/src/daemon.ts
3619
3837
  init_src();
3620
3838
  import { Effect as Effect6, Schedule as Schedule2, Schema as Schema8 } from "effect";
3621
- import { realpath as realpath3 } from "node:fs/promises";
3839
+ import { constants as constants3 } from "node:fs";
3840
+ import { open, realpath as realpath3, stat as stat3 } from "node:fs/promises";
3622
3841
  import { arch, hostname as hostname2, platform as platform3 } from "node:os";
3623
- import { basename, isAbsolute as isAbsolute2, relative } from "node:path";
3842
+ import { basename, isAbsolute as isAbsolute3, relative } from "node:path";
3624
3843
  import { WebSocket } from "ws";
3625
- var maxRelayPayloadBytes = 256 * 1024;
3626
3844
  var maxQueuedRelayMessages = 8;
3627
3845
 
3628
3846
  class DeviceConnectionError extends Schema8.TaggedErrorClass()("Device.ConnectionError", { message: Schema8.String }) {
@@ -3691,13 +3909,42 @@ var allowedWorkspace = async (configuration, requested) => {
3691
3909
  const candidate = await realpath3(selected);
3692
3910
  const allowed = configuration.workspaces.some((root) => {
3693
3911
  const child = relative(root, candidate);
3694
- return child === "" || !child.startsWith("..") && !isAbsolute2(child);
3912
+ return child === "" || !child.startsWith("..") && !isAbsolute3(child);
3695
3913
  });
3696
3914
  if (!allowed) {
3697
3915
  throw new Error(`Workspace ${candidate} is outside this device's configured workspace roots`);
3698
3916
  }
3699
3917
  return candidate;
3700
3918
  };
3919
+ var openAllowedWorkspace = async (configuration, requested) => {
3920
+ const path = await allowedWorkspace(configuration, requested);
3921
+ const operatingSystem2 = platform3();
3922
+ const flags = constants3.O_RDONLY | (operatingSystem2 === "win32" ? 0 : constants3.O_DIRECTORY | constants3.O_NOFOLLOW);
3923
+ const handle = await open(path, flags);
3924
+ const identity = await handle.stat();
3925
+ if (!identity.isDirectory()) {
3926
+ await handle.close();
3927
+ throw new Error(`Workspace ${path} is not a directory`);
3928
+ }
3929
+ const cwd = operatingSystem2 === "linux" ? `/proc/self/fd/${handle.fd}` : path;
3930
+ return {
3931
+ cwd,
3932
+ validate: async () => {
3933
+ const current = await handle.stat();
3934
+ if (!current.isDirectory() || current.dev !== identity.dev || current.ino !== identity.ino) {
3935
+ throw new Error(`Workspace ${path} changed before provider startup`);
3936
+ }
3937
+ if (operatingSystem2 !== "linux") {
3938
+ const currentPath = await realpath3(path);
3939
+ const currentPathIdentity = await stat3(currentPath);
3940
+ if (currentPathIdentity.dev !== identity.dev || currentPathIdentity.ino !== identity.ino) {
3941
+ throw new Error(`Workspace ${path} changed before provider startup`);
3942
+ }
3943
+ }
3944
+ },
3945
+ close: () => handle.close()
3946
+ };
3947
+ };
3701
3948
  var promptFor = (task, deviceName) => {
3702
3949
  const context = task.context === undefined ? "" : `
3703
3950
 
@@ -3746,8 +3993,8 @@ var execute = (configuration, state, task) => {
3746
3993
  state.tasks.set(task.id, controller);
3747
3994
  send(state, SocketEvent.make({ _tag: "TaskAccepted", taskId: task.id }));
3748
3995
  const workflow = Effect6.gen(function* () {
3749
- const cwd = yield* Effect6.tryPromise({
3750
- try: () => allowedWorkspace(configuration, task.workspace),
3996
+ const workspace = yield* Effect6.tryPromise({
3997
+ try: () => openAllowedWorkspace(configuration, task.workspace),
3751
3998
  catch: (cause) => new ProviderRunError({
3752
3999
  provider: task.provider,
3753
4000
  message: cause instanceof Error ? cause.message : String(cause)
@@ -3758,11 +4005,14 @@ var execute = (configuration, state, task) => {
3758
4005
  provider: task.provider,
3759
4006
  threadId: task.threadId,
3760
4007
  prompt: promptFor(task, configuration.name),
3761
- cwd,
4008
+ cwd: workspace.cwd,
4009
+ beforeSpawn: workspace.validate,
3762
4010
  ...sessionId === undefined ? {} : { sessionId },
3763
4011
  ...configuration.model === undefined ? {} : { model: configuration.model },
3764
4012
  ...configuration.sandbox === undefined ? {} : { sandbox: configuration.sandbox }
3765
- });
4013
+ }).pipe(Effect6.ensuring(Effect6.promise(() => workspace.close().catch(() => {
4014
+ return;
4015
+ }))));
3766
4016
  });
3767
4017
  Effect6.runPromise(workflow, { signal: controller.signal }).then((result) => {
3768
4018
  if (result.sessionId !== undefined) {
@@ -3823,7 +4073,7 @@ var cancel = (state, taskId) => {
3823
4073
  var connect = (configuration, state) => Effect6.tryPromise({
3824
4074
  try: (signal) => new Promise((complete) => {
3825
4075
  const socket = new WebSocket(socketUrl(configuration), {
3826
- maxPayload: maxRelayPayloadBytes,
4076
+ maxPayload: maxSocketPayloadBytes,
3827
4077
  perMessageDeflate: false
3828
4078
  });
3829
4079
  const heartbeat = setInterval(() => {
@@ -4014,12 +4264,12 @@ var main = async () => {
4014
4264
  console.log(version);
4015
4265
  return;
4016
4266
  }
4267
+ const commandArguments = process.argv.slice(3);
4268
+ if (commandArguments.length === 1 && ["--help", "-h", "help"].includes(commandArguments[0] ?? "")) {
4269
+ printHelp();
4270
+ return;
4271
+ }
4017
4272
  if (["device", "relay", "mcp"].includes(command) && process.argv.length > 3) {
4018
- const arguments_ = process.argv.slice(3);
4019
- if (arguments_.length === 1 && ["--help", "-h", "help"].includes(arguments_[0] ?? "")) {
4020
- printHelp();
4021
- return;
4022
- }
4023
4273
  throw new Error(`${command} does not accept arguments`);
4024
4274
  }
4025
4275
  if (command === "relay") {
@@ -4037,11 +4287,11 @@ var main = async () => {
4037
4287
  await runMcp(configuration);
4038
4288
  return;
4039
4289
  }
4040
- await runCli(command, process.argv.slice(3));
4290
+ await runCli(command, commandArguments);
4041
4291
  };
4042
4292
  await main().catch((cause) => {
4043
4293
  console.error(JSON.stringify({ error: cause instanceof Error ? cause.message : String(cause) }, null, 2));
4044
4294
  process.exitCode = 1;
4045
4295
  });
4046
4296
 
4047
- //# debugId=DD6CA056CEE6744B64756E2164756E21
4297
+ //# debugId=FEAADCA0BB5BB2AC64756E2164756E21