@gitterm/bot 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bot.d.ts CHANGED
@@ -3,4 +3,4 @@ import type { Bot, BotOptions } from "./types.js";
3
3
  * A chat bot backed by GitTerm sandboxes: one sandbox per repository that pauses when idle and
4
4
  * wakes on the next message, and one OpenCode session per chat thread.
5
5
  */
6
- export declare function createBot(options: BotOptions): Bot;
6
+ export declare function createBot(initial: BotOptions): Bot;
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import { resolve as resolvePath } from "node:path";
4
4
  import {
5
5
  AgentRunError,
6
- createGittermClient,
6
+ createGittermClient as createGittermClient2,
7
7
  GittermError as GittermError2,
8
8
  WorkspaceLifecycleError
9
9
  } from "@gitterm/sdk";
@@ -306,6 +306,260 @@ ${SETUP_HINT}`);
306
306
  }
307
307
  }
308
308
 
309
+ // src/saved.ts
310
+ import {
311
+ createGittermClient
312
+ } from "@gitterm/sdk";
313
+
314
+ // src/env.ts
315
+ import { existsSync, readFileSync } from "node:fs";
316
+ import { parseArgs, parseEnv } from "node:util";
317
+ var read = (env, name) => env[name]?.trim() || undefined;
318
+ function positive(env, name) {
319
+ const value = read(env, name);
320
+ if (!value)
321
+ return;
322
+ const parsed = Number(value);
323
+ if (!Number.isInteger(parsed) || parsed <= 0)
324
+ throw new Error(`${name} must be a positive integer`);
325
+ return parsed;
326
+ }
327
+ function parseChannels(value) {
328
+ const channels = {};
329
+ for (const entry of value.split(",")) {
330
+ const [channel, repo] = entry.split("=", 2).map((part) => part.trim());
331
+ if (!channel || !repo) {
332
+ throw new Error(`GITTERM_BOT_CHANNELS entries look like <channel id>=<repository url>`);
333
+ }
334
+ channels[channel] = repo;
335
+ }
336
+ return channels;
337
+ }
338
+ var list = (value) => value.split(",").map((entry) => entry.trim()).filter(Boolean);
339
+ function readSandboxEnv(path) {
340
+ if (!path)
341
+ return;
342
+ try {
343
+ const parsed = parseEnv(readFileSync(path, "utf8"));
344
+ return Object.fromEntries(Object.entries(parsed).filter((entry) => entry[1] !== undefined));
345
+ } catch (error) {
346
+ throw new Error(`Could not read the sandbox environment file ${path}`, { cause: error });
347
+ }
348
+ }
349
+ function readInstructionsFile(path) {
350
+ if (!path)
351
+ return;
352
+ try {
353
+ return readFileSync(path, "utf8").trim() || undefined;
354
+ } catch (error) {
355
+ throw new Error(`Could not read the instructions file ${path}`, { cause: error });
356
+ }
357
+ }
358
+ function botOptionsFromEnv(env = process.env) {
359
+ const connections = read(env, "GITTERM_BOT_CONNECTIONS");
360
+ const provider = read(env, "GITTERM_BOT_PROVIDER");
361
+ const channels = read(env, "GITTERM_BOT_CHANNELS");
362
+ const options = {};
363
+ const botId = read(env, "GITTERM_BOT_ID");
364
+ if (botId)
365
+ options.botId = botId;
366
+ const allowedChannels = read(env, "GITTERM_BOT_ALLOWED_CHANNELS");
367
+ if (allowedChannels)
368
+ options.allowedChannels = list(allowedChannels);
369
+ const approvers = read(env, "GITTERM_BOT_APPROVERS");
370
+ if (approvers)
371
+ options.approvers = list(approvers);
372
+ if (read(env, "GITTERM_BOT_ALLOW_ALWAYS") === "true")
373
+ options.allowAlways = true;
374
+ if (read(env, "GITTERM_BOT_SHARE_CHANNELS") === "true")
375
+ options.shareChannels = true;
376
+ const maxPendingRequests = positive(env, "GITTERM_BOT_MAX_PENDING");
377
+ if (maxPendingRequests)
378
+ options.maxPendingRequests = maxPendingRequests;
379
+ const maxPendingPerUser = positive(env, "GITTERM_BOT_MAX_PENDING_PER_USER");
380
+ if (maxPendingPerUser)
381
+ options.maxPendingPerUser = maxPendingPerUser;
382
+ const repo = read(env, "GITTERM_BOT_REPO");
383
+ if (repo)
384
+ options.repo = repo;
385
+ if (channels)
386
+ options.channels = parseChannels(channels);
387
+ if (connections)
388
+ options.connections = list(connections);
389
+ const repos = read(env, "GITTERM_BOT_REPOS");
390
+ if (repos)
391
+ options.repos = list(repos);
392
+ const allowedUsers = read(env, "GITTERM_BOT_ALLOWED_USERS");
393
+ if (allowedUsers)
394
+ options.allowedUsers = list(allowedUsers);
395
+ if (read(env, "GITTERM_BOT_ALLOW_GUESTS") === "true")
396
+ options.allowGuests = true;
397
+ const setup = read(env, "GITTERM_BOT_SETUP");
398
+ if (setup)
399
+ options.setup = [setup];
400
+ const sandboxEnv = readSandboxEnv(read(env, "GITTERM_BOT_SANDBOX_ENV_FILE"));
401
+ if (sandboxEnv)
402
+ options.env = sandboxEnv;
403
+ const model = read(env, "GITTERM_BOT_MODEL");
404
+ const credential = read(env, "GITTERM_BOT_MODEL_CREDENTIAL");
405
+ const apiKey = read(env, "GITTERM_BOT_MODEL_API_KEY");
406
+ if ((credential || apiKey) && !model) {
407
+ throw new Error("GITTERM_BOT_MODEL_CREDENTIAL and GITTERM_BOT_MODEL_API_KEY need GITTERM_BOT_MODEL");
408
+ }
409
+ if (model) {
410
+ options.model = {
411
+ id: model,
412
+ ...credential ? { credential } : {},
413
+ ...apiKey ? { apiKey } : {}
414
+ };
415
+ }
416
+ const instructions = [
417
+ read(env, "GITTERM_BOT_INSTRUCTIONS"),
418
+ readInstructionsFile(read(env, "GITTERM_BOT_INSTRUCTIONS_FILE"))
419
+ ].filter(Boolean).join(`
420
+
421
+ `);
422
+ if (instructions)
423
+ options.instructions = instructions;
424
+ const githubToken = read(env, "GITTERM_BOT_GITHUB_TOKEN");
425
+ if (provider || githubToken) {
426
+ options.workspace = {
427
+ ...provider ? { provider: { type: provider } } : {},
428
+ ...githubToken ? { repositoryCredentials: { token: githubToken } } : {}
429
+ };
430
+ }
431
+ const stateFile = read(env, "GITTERM_BOT_STATE_FILE");
432
+ if (stateFile)
433
+ options.stateFile = stateFile;
434
+ const runTimeoutMinutes = positive(env, "GITTERM_BOT_RUN_TIMEOUT_MINUTES");
435
+ if (runTimeoutMinutes)
436
+ options.runTimeoutMs = runTimeoutMinutes * 60000;
437
+ return options;
438
+ }
439
+ function cliOptions(argv = process.argv.slice(2)) {
440
+ if (existsSync(".env") && typeof process.loadEnvFile === "function")
441
+ process.loadEnvFile(".env");
442
+ const { values, positionals } = parseArgs({
443
+ args: argv,
444
+ allowPositionals: true,
445
+ options: {
446
+ repo: { type: "string" },
447
+ model: { type: "string" },
448
+ "instructions-file": { type: "string" },
449
+ help: { type: "boolean", short: "h" }
450
+ }
451
+ });
452
+ const flags = {
453
+ GITTERM_BOT_REPO: values.repo,
454
+ GITTERM_BOT_MODEL: values.model,
455
+ GITTERM_BOT_INSTRUCTIONS_FILE: values["instructions-file"]
456
+ };
457
+ const env = { ...process.env };
458
+ for (const [name, value] of Object.entries(flags))
459
+ if (value)
460
+ env[name] = value;
461
+ return { command: values.help ? "help" : positionals[0], options: botOptionsFromEnv(env) };
462
+ }
463
+ var BOT_ENV_HELP = ` Saved policy loads at startup; local settings cannot broaden its users, guests, or channels.
464
+
465
+ GITTERM_API_TOKEN The bot's GitTerm token (dashboard → Bots creates one)
466
+ GITTERM_SERVER_URL Self-hosted GitTerm API URL (default: hosted)
467
+ GITTERM_BOT_REPO Repository for every channel: https://github.com/acme/app[#branch]
468
+ GITTERM_BOT_REPOS More repositories people can name in a thread: url,url
469
+ GITTERM_BOT_CHANNELS Per-channel repositories: <channel id>=<url>,…
470
+ GITTERM_BOT_MODEL provider/model, e.g. anthropic/claude-sonnet-5-5
471
+ GITTERM_BOT_MODEL_CREDENTIAL Saved credential label (default: the provider's default)
472
+ GITTERM_BOT_MODEL_API_KEY Or a model API key for this bot only
473
+ GITTERM_BOT_GITHUB_TOKEN Your own GitHub token instead of a GitTerm GitHub connection
474
+ GITTERM_BOT_CONNECTIONS Tools besides GitHub, by name: Linear,Sentry
475
+ GITTERM_BOT_ALLOWED_USERS Only these user ids may use the bot: U012,U034
476
+ GITTERM_BOT_ID Names an unsaved bot's sandboxes (saved bots use their own id)
477
+ GITTERM_BOT_ALLOWED_CHANNELS Only these channel ids, whatever the repository routing
478
+ GITTERM_BOT_APPROVERS Permission approver ids (default: requester only)
479
+ GITTERM_BOT_ALLOW_ALWAYS true enables persistent approvals (default: disabled)
480
+ GITTERM_BOT_SHARE_CHANNELS true opts equally trusted channels into a shared sandbox
481
+ GITTERM_BOT_MAX_PENDING[_PER_USER] Queue bounds (defaults: 20 total, 3 per user)
482
+ GITTERM_BOT_ALLOW_GUESTS true lets guests and external people use the bot
483
+ GITTERM_BOT_INSTRUCTIONS[_FILE] What the agent should know and how to behave
484
+ GITTERM_BOT_SETUP Command run before the agent starts, e.g. "pnpm install"
485
+ GITTERM_BOT_SANDBOX_ENV_FILE dotenv file of variables for the sandbox (test credentials)
486
+ GITTERM_BOT_PROVIDER Compute provider for new sandboxes (railway, e2b, …)
487
+ GITTERM_BOT_STATE_FILE, GITTERM_BOT_RUN_TIMEOUT_MINUTES`;
488
+ var hasRepository = (options) => Boolean(options.repo || options.repos?.length || Object.keys(options.channels ?? {}).length);
489
+
490
+ // src/saved.ts
491
+ var isClient = (value) => typeof value === "object" && value !== null && ("runs" in value);
492
+ async function withSavedConfig(options, platform, log = console) {
493
+ const gitterm = isClient(options.gitterm) ? options.gitterm : createGittermClient(options.gitterm);
494
+ const base = { ...options, gitterm };
495
+ const saved = await gitterm.bots.self();
496
+ if (!saved)
497
+ return base;
498
+ if (saved.platform !== platform) {
499
+ throw new Error(`This GitTerm token belongs to the ${saved.platform} bot "${saved.name}"; run that bot with it`);
500
+ }
501
+ log.info(`Using the settings saved for "${saved.name}" in GitTerm.`);
502
+ return { ...applySavedConfig(base, saved, log), localOptions: base };
503
+ }
504
+ function applySavedConfig(options, saved, log = console) {
505
+ const merged = { ...options };
506
+ const localRepos = [
507
+ options.repo,
508
+ ...options.repos ?? [],
509
+ ...Object.values(options.channels ?? {})
510
+ ].filter((repo) => repo !== undefined);
511
+ if (localRepos.some((repo) => repoKey(parseRepo(repo)) !== repoKey(parseRepo(saved.repo)))) {
512
+ throw new Error("Local repository routing exceeds the saved bot policy. Change its repository in GitTerm first.");
513
+ }
514
+ const localModel = typeof options.model === "string" ? options.model : options.model?.id;
515
+ if (localModel && localModel !== saved.model)
516
+ throw new Error("Local model exceeds the saved bot policy. Change its model in GitTerm first.");
517
+ if (options.connections?.some((reference) => !saved.connections.includes(reference))) {
518
+ throw new Error("Local connections exceed the saved bot policy. Select them in GitTerm first.");
519
+ }
520
+ merged.botId = saved.id;
521
+ if (saved.channels.length) {
522
+ merged.allowedChannels = options.allowedChannels ? options.allowedChannels.filter((id) => saved.channels.includes(id)) : saved.channels;
523
+ }
524
+ if (!hasRepository(options)) {
525
+ if (saved.channels.length) {
526
+ merged.channels = Object.fromEntries(saved.channels.map((id) => [id, saved.repo]));
527
+ } else {
528
+ merged.repo = saved.repo;
529
+ }
530
+ }
531
+ merged.model ??= {
532
+ id: saved.model,
533
+ ...saved.credential ? { credential: saved.credential } : {}
534
+ };
535
+ merged.connections ??= saved.connections;
536
+ if (saved.provider && !options.workspace?.provider) {
537
+ merged.workspace = {
538
+ ...options.workspace,
539
+ provider: { type: saved.provider }
540
+ };
541
+ }
542
+ if (saved.allowedUsers.length) {
543
+ merged.allowedUsers = options.allowedUsers ? options.allowedUsers.filter((id) => saved.allowedUsers.includes(id)) : saved.allowedUsers;
544
+ }
545
+ merged.allowGuests = saved.allowGuests && (options.allowGuests ?? true);
546
+ if (saved.instructions)
547
+ merged.instructions ??= saved.instructions;
548
+ if (saved.setup)
549
+ merged.setup ??= [saved.setup];
550
+ if (saved.githubAccess === "token" && !merged.workspace?.repositoryCredentials) {
551
+ log.warn(`"${saved.name}" uses your own GitHub token, but GITTERM_BOT_GITHUB_TOKEN is not set. Public repositories still work.`);
552
+ }
553
+ log.info("Effective bot authorization", {
554
+ botId: saved.id,
555
+ source: "saved policy, intersected with local restrictions",
556
+ channels: merged.allowedChannels ?? "all invited channels",
557
+ users: merged.allowedUsers ?? "channel members",
558
+ allowGuests: merged.allowGuests
559
+ });
560
+ return merged;
561
+ }
562
+
309
563
  // src/state.ts
310
564
  import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
311
565
  import { dirname } from "node:path";
@@ -393,12 +647,14 @@ async function openStatusLine(adapter, thread, messageId) {
393
647
  const ms = Date.now() - startedAt;
394
648
  return ms >= TICK_MS ? ` (${minutes(ms)} min)` : "";
395
649
  };
396
- const render = () => {
650
+ let queue = Promise.resolve();
651
+ const send = (update) => queue = queue.then(update).catch(noop);
652
+ const render = () => send(() => {
397
653
  if (id)
398
- return adapter.edit(thread, id, `${current.message}${elapsed()}`).catch(noop);
654
+ return adapter.edit(thread, id, `${current.message}${elapsed()}`);
399
655
  const text = current.indicator && `${current.indicator}${elapsed()}`;
400
- return adapter.indicate?.(thread, text).catch(noop);
401
- };
656
+ return adapter.indicate?.(thread, text);
657
+ });
402
658
  const timer = setInterval(() => {
403
659
  if (id || current.indicator)
404
660
  render();
@@ -418,18 +674,15 @@ async function openStatusLine(adapter, thread, messageId) {
418
674
  async finish(text) {
419
675
  end();
420
676
  if (id) {
421
- await adapter.edit(thread, id, text).catch(noop);
677
+ await send(() => adapter.edit(thread, id, text));
422
678
  return;
423
679
  }
424
- await adapter.indicate?.(thread, "").catch(noop);
680
+ await send(() => adapter.indicate?.(thread, ""));
425
681
  await adapter.post(thread, text).catch(noop);
426
682
  },
427
683
  async remove() {
428
684
  end();
429
- if (id)
430
- await adapter.remove(thread, id).catch(noop);
431
- else
432
- await adapter.indicate?.(thread, "").catch(noop);
685
+ await send(() => id ? adapter.remove(thread, id) : adapter.indicate?.(thread, ""));
433
686
  },
434
687
  stop: end
435
688
  };
@@ -466,7 +719,7 @@ var threadKeyOf = (thread) => `${thread.channel}:${thread.thread}`;
466
719
  var noop2 = () => {
467
720
  return;
468
721
  };
469
- var isClient = (value) => typeof value === "object" && value !== null && ("runs" in value);
722
+ var isClient2 = (value) => typeof value === "object" && value !== null && ("runs" in value);
470
723
  function describeAnswer(answer) {
471
724
  if (answer.kind === "question")
472
725
  return answer.labels.join(", ");
@@ -511,25 +764,9 @@ function recentIds(limit) {
511
764
  return true;
512
765
  };
513
766
  }
514
- function createBot(options) {
515
- const { adapter } = options;
516
- const log = options.logger ?? console;
517
- const gitterm = isClient(options.gitterm) ? options.gitterm : createGittermClient(options.gitterm);
518
- const runTimeoutMs = options.runTimeoutMs ?? 60 * 60000;
519
- const inputTimeoutMs = options.inputTimeoutMs ?? 30 * 60000;
520
- for (const [name, value, maximum] of [
521
- ["runTimeoutMs", runTimeoutMs, 24 * 60 * 60000],
522
- ["inputTimeoutMs", inputTimeoutMs, 24 * 60 * 60000],
523
- ["maxPendingRequests", options.maxPendingRequests ?? 20, 1000],
524
- ["maxPendingPerUser", options.maxPendingPerUser ?? 3, 1000]
525
- ]) {
526
- if (!Number.isSafeInteger(value) || value <= 0 || value > maximum)
527
- throw new Error(`${name} must be a positive integer no greater than ${maximum}`);
528
- }
767
+ function settingsFor(options) {
529
768
  const defaultRepo = options.repo ? parseRepo(options.repo) : undefined;
530
769
  const extraRepos = (options.repos ?? []).map(parseRepo);
531
- const allowedUsers = options.allowedUsers ? new Set(options.allowedUsers) : undefined;
532
- const model = typeof options.model === "string" ? { id: options.model } : options.model;
533
770
  const channelRepos = new Map(Object.entries(options.channels ?? {}).map(([channel, repo]) => [channel, parseRepo(repo)]));
534
771
  const knownRepos = [
535
772
  ...new Map([defaultRepo, ...channelRepos.values(), ...extraRepos].filter((repo) => Boolean(repo)).map((repo) => [repoKey(repo), repo])).values()
@@ -537,6 +774,32 @@ function createBot(options) {
537
774
  if (knownRepos.length === 0) {
538
775
  throw new Error("Give the bot a `repo`, `repos` to choose from, or a repository per channel.");
539
776
  }
777
+ return {
778
+ options,
779
+ defaultRepo,
780
+ extraRepos,
781
+ channelRepos,
782
+ knownRepos,
783
+ allowedUsers: options.allowedUsers ? new Set(options.allowedUsers) : undefined,
784
+ model: typeof options.model === "string" ? { id: options.model } : options.model
785
+ };
786
+ }
787
+ function createBot(initial) {
788
+ const { adapter } = initial;
789
+ const log = initial.logger ?? console;
790
+ const gitterm = isClient2(initial.gitterm) ? initial.gitterm : createGittermClient2(initial.gitterm);
791
+ const runTimeoutMs = initial.runTimeoutMs ?? 60 * 60000;
792
+ const inputTimeoutMs = initial.inputTimeoutMs ?? 30 * 60000;
793
+ for (const [name, value, maximum] of [
794
+ ["runTimeoutMs", runTimeoutMs, 24 * 60 * 60000],
795
+ ["inputTimeoutMs", inputTimeoutMs, 24 * 60 * 60000],
796
+ ["maxPendingRequests", initial.maxPendingRequests ?? 20, 1000],
797
+ ["maxPendingPerUser", initial.maxPendingPerUser ?? 3, 1000]
798
+ ]) {
799
+ if (!Number.isSafeInteger(value) || value <= 0 || value > maximum)
800
+ throw new Error(`${name} must be a positive integer no greater than ${maximum}`);
801
+ }
802
+ let { options, defaultRepo, extraRepos, channelRepos, knownRepos, allowedUsers, model } = settingsFor(initial);
540
803
  const lock = createLocks();
541
804
  const fresh = recentIds(RECENT_MESSAGES);
542
805
  const active = new Map;
@@ -550,6 +813,7 @@ function createBot(options) {
550
813
  let running = false;
551
814
  let leaseTimer;
552
815
  let leaseId;
816
+ let savedAt;
553
817
  const tasks = new Set;
554
818
  const trackTask = (task) => {
555
819
  tasks.add(task);
@@ -1093,6 +1357,59 @@ function createBot(options) {
1093
1357
  }
1094
1358
  }
1095
1359
  }
1360
+ const workspaceManager = () => createWorkspaceManager({
1361
+ gitterm,
1362
+ platform: adapter.platform,
1363
+ scope,
1364
+ botId: options.botId,
1365
+ directRetentionMs: options.directRetentionMs,
1366
+ identityPolicy: {
1367
+ allowedUsers: options.allowedUsers,
1368
+ allowGuests: options.allowGuests,
1369
+ allowedChannels: options.allowedChannels,
1370
+ shareChannels: options.shareChannels
1371
+ },
1372
+ connections: options.connections ?? [],
1373
+ env: options.env ?? {},
1374
+ setup: options.setup ?? [],
1375
+ model,
1376
+ instructions: agentInstructions(adapter.displayName, options.instructions),
1377
+ overrides: options.workspace,
1378
+ log
1379
+ });
1380
+ async function reload() {
1381
+ const local = options.localOptions;
1382
+ if (!local)
1383
+ return;
1384
+ try {
1385
+ const saved = await gitterm.bots.self();
1386
+ if (!saved)
1387
+ return;
1388
+ savedAt = saved.updatedAt;
1389
+ if (saved.platform !== adapter.platform) {
1390
+ log.warn(`"${saved.name}" is now a ${saved.platform} bot; run it on that platform.`);
1391
+ return;
1392
+ }
1393
+ const next = settingsFor({
1394
+ ...applySavedConfig(local, saved, log),
1395
+ adapter,
1396
+ localOptions: local
1397
+ });
1398
+ await checkSetup({
1399
+ gitterm,
1400
+ repos: next.knownRepos,
1401
+ connections: next.options.connections ?? [],
1402
+ model: next.model,
1403
+ overrides: next.options.workspace,
1404
+ log
1405
+ });
1406
+ ({ options, defaultRepo, extraRepos, channelRepos, knownRepos, allowedUsers, model } = next);
1407
+ workspaces = workspaceManager();
1408
+ log.info(`Applied the settings saved for "${saved.name}". New sandboxes use them; reset replaces an existing one.`);
1409
+ } catch (error) {
1410
+ log.warn("Could not apply the newly saved settings; keeping the current ones. Save again in GitTerm to retry.", error);
1411
+ }
1412
+ }
1096
1413
  const bot = {
1097
1414
  async start() {
1098
1415
  if (running)
@@ -1106,6 +1423,7 @@ function createBot(options) {
1106
1423
  throw new Error("Load the saved configuration with withSavedConfig() before starting a saved bot");
1107
1424
  }
1108
1425
  if (savedIdentity) {
1426
+ savedAt = savedIdentity.updatedAt;
1109
1427
  const id = randomUUID();
1110
1428
  await claimLease(id);
1111
1429
  leaseId = id;
@@ -1115,7 +1433,11 @@ function createBot(options) {
1115
1433
  if (renewing || shutdown.signal.aborted)
1116
1434
  return;
1117
1435
  renewing = true;
1118
- gitterm.bots.acquireLease(id).then(() => failures = 0).catch((error) => {
1436
+ gitterm.bots.acquireLease(id).then(async (lease) => {
1437
+ failures = 0;
1438
+ if (lease.updatedAt && lease.updatedAt !== savedAt)
1439
+ await reload();
1440
+ }).catch((error) => {
1119
1441
  const lost = error instanceof GittermError2 && error.code === "CONFLICT";
1120
1442
  if (!lost && ++failures < 2)
1121
1443
  return;
@@ -1154,26 +1476,7 @@ function createBot(options) {
1154
1476
  accepts: servesChannel
1155
1477
  });
1156
1478
  scope = started.scope;
1157
- workspaces = createWorkspaceManager({
1158
- gitterm,
1159
- platform: adapter.platform,
1160
- scope,
1161
- botId: options.botId,
1162
- directRetentionMs: options.directRetentionMs,
1163
- identityPolicy: {
1164
- allowedUsers: options.allowedUsers,
1165
- allowGuests: options.allowGuests,
1166
- allowedChannels: options.allowedChannels,
1167
- shareChannels: options.shareChannels
1168
- },
1169
- connections: options.connections ?? [],
1170
- env: options.env ?? {},
1171
- setup: options.setup ?? [],
1172
- model,
1173
- instructions: agentInstructions(adapter.displayName, options.instructions),
1174
- overrides: options.workspace,
1175
- log
1176
- });
1479
+ workspaces = workspaceManager();
1177
1480
  await recover();
1178
1481
  markReady();
1179
1482
  log.info(`GitTerm ${adapter.displayName} bot is running for ${knownRepos.map(repoLabel).join(", ")}.`);
@@ -1236,256 +1539,6 @@ async function downloadImage(url, options) {
1236
1539
  reader.releaseLock();
1237
1540
  }
1238
1541
  }
1239
- // src/saved.ts
1240
- import {
1241
- createGittermClient as createGittermClient2
1242
- } from "@gitterm/sdk";
1243
-
1244
- // src/env.ts
1245
- import { existsSync, readFileSync } from "node:fs";
1246
- import { parseArgs, parseEnv } from "node:util";
1247
- var read = (env, name) => env[name]?.trim() || undefined;
1248
- function positive(env, name) {
1249
- const value = read(env, name);
1250
- if (!value)
1251
- return;
1252
- const parsed = Number(value);
1253
- if (!Number.isInteger(parsed) || parsed <= 0)
1254
- throw new Error(`${name} must be a positive integer`);
1255
- return parsed;
1256
- }
1257
- function parseChannels(value) {
1258
- const channels = {};
1259
- for (const entry of value.split(",")) {
1260
- const [channel, repo] = entry.split("=", 2).map((part) => part.trim());
1261
- if (!channel || !repo) {
1262
- throw new Error(`GITTERM_BOT_CHANNELS entries look like <channel id>=<repository url>`);
1263
- }
1264
- channels[channel] = repo;
1265
- }
1266
- return channels;
1267
- }
1268
- var list = (value) => value.split(",").map((entry) => entry.trim()).filter(Boolean);
1269
- function readSandboxEnv(path) {
1270
- if (!path)
1271
- return;
1272
- try {
1273
- const parsed = parseEnv(readFileSync(path, "utf8"));
1274
- return Object.fromEntries(Object.entries(parsed).filter((entry) => entry[1] !== undefined));
1275
- } catch (error) {
1276
- throw new Error(`Could not read the sandbox environment file ${path}`, { cause: error });
1277
- }
1278
- }
1279
- function readInstructionsFile(path) {
1280
- if (!path)
1281
- return;
1282
- try {
1283
- return readFileSync(path, "utf8").trim() || undefined;
1284
- } catch (error) {
1285
- throw new Error(`Could not read the instructions file ${path}`, { cause: error });
1286
- }
1287
- }
1288
- function botOptionsFromEnv(env = process.env) {
1289
- const connections = read(env, "GITTERM_BOT_CONNECTIONS");
1290
- const provider = read(env, "GITTERM_BOT_PROVIDER");
1291
- const channels = read(env, "GITTERM_BOT_CHANNELS");
1292
- const options = {};
1293
- const botId = read(env, "GITTERM_BOT_ID");
1294
- if (botId)
1295
- options.botId = botId;
1296
- const allowedChannels = read(env, "GITTERM_BOT_ALLOWED_CHANNELS");
1297
- if (allowedChannels)
1298
- options.allowedChannels = list(allowedChannels);
1299
- const approvers = read(env, "GITTERM_BOT_APPROVERS");
1300
- if (approvers)
1301
- options.approvers = list(approvers);
1302
- if (read(env, "GITTERM_BOT_ALLOW_ALWAYS") === "true")
1303
- options.allowAlways = true;
1304
- if (read(env, "GITTERM_BOT_SHARE_CHANNELS") === "true")
1305
- options.shareChannels = true;
1306
- const maxPendingRequests = positive(env, "GITTERM_BOT_MAX_PENDING");
1307
- if (maxPendingRequests)
1308
- options.maxPendingRequests = maxPendingRequests;
1309
- const maxPendingPerUser = positive(env, "GITTERM_BOT_MAX_PENDING_PER_USER");
1310
- if (maxPendingPerUser)
1311
- options.maxPendingPerUser = maxPendingPerUser;
1312
- const repo = read(env, "GITTERM_BOT_REPO");
1313
- if (repo)
1314
- options.repo = repo;
1315
- if (channels)
1316
- options.channels = parseChannels(channels);
1317
- if (connections)
1318
- options.connections = list(connections);
1319
- const repos = read(env, "GITTERM_BOT_REPOS");
1320
- if (repos)
1321
- options.repos = list(repos);
1322
- const allowedUsers = read(env, "GITTERM_BOT_ALLOWED_USERS");
1323
- if (allowedUsers)
1324
- options.allowedUsers = list(allowedUsers);
1325
- if (read(env, "GITTERM_BOT_ALLOW_GUESTS") === "true")
1326
- options.allowGuests = true;
1327
- const setup = read(env, "GITTERM_BOT_SETUP");
1328
- if (setup)
1329
- options.setup = [setup];
1330
- const sandboxEnv = readSandboxEnv(read(env, "GITTERM_BOT_SANDBOX_ENV_FILE"));
1331
- if (sandboxEnv)
1332
- options.env = sandboxEnv;
1333
- const model = read(env, "GITTERM_BOT_MODEL");
1334
- const credential = read(env, "GITTERM_BOT_MODEL_CREDENTIAL");
1335
- const apiKey = read(env, "GITTERM_BOT_MODEL_API_KEY");
1336
- if ((credential || apiKey) && !model) {
1337
- throw new Error("GITTERM_BOT_MODEL_CREDENTIAL and GITTERM_BOT_MODEL_API_KEY need GITTERM_BOT_MODEL");
1338
- }
1339
- if (model) {
1340
- options.model = {
1341
- id: model,
1342
- ...credential ? { credential } : {},
1343
- ...apiKey ? { apiKey } : {}
1344
- };
1345
- }
1346
- const instructions = [
1347
- read(env, "GITTERM_BOT_INSTRUCTIONS"),
1348
- readInstructionsFile(read(env, "GITTERM_BOT_INSTRUCTIONS_FILE"))
1349
- ].filter(Boolean).join(`
1350
-
1351
- `);
1352
- if (instructions)
1353
- options.instructions = instructions;
1354
- const githubToken = read(env, "GITTERM_BOT_GITHUB_TOKEN");
1355
- if (provider || githubToken) {
1356
- options.workspace = {
1357
- ...provider ? { provider: { type: provider } } : {},
1358
- ...githubToken ? { repositoryCredentials: { token: githubToken } } : {}
1359
- };
1360
- }
1361
- const stateFile = read(env, "GITTERM_BOT_STATE_FILE");
1362
- if (stateFile)
1363
- options.stateFile = stateFile;
1364
- const runTimeoutMinutes = positive(env, "GITTERM_BOT_RUN_TIMEOUT_MINUTES");
1365
- if (runTimeoutMinutes)
1366
- options.runTimeoutMs = runTimeoutMinutes * 60000;
1367
- return options;
1368
- }
1369
- function cliOptions(argv = process.argv.slice(2)) {
1370
- if (existsSync(".env") && typeof process.loadEnvFile === "function")
1371
- process.loadEnvFile(".env");
1372
- const { values, positionals } = parseArgs({
1373
- args: argv,
1374
- allowPositionals: true,
1375
- options: {
1376
- repo: { type: "string" },
1377
- model: { type: "string" },
1378
- "instructions-file": { type: "string" },
1379
- help: { type: "boolean", short: "h" }
1380
- }
1381
- });
1382
- const flags = {
1383
- GITTERM_BOT_REPO: values.repo,
1384
- GITTERM_BOT_MODEL: values.model,
1385
- GITTERM_BOT_INSTRUCTIONS_FILE: values["instructions-file"]
1386
- };
1387
- const env = { ...process.env };
1388
- for (const [name, value] of Object.entries(flags))
1389
- if (value)
1390
- env[name] = value;
1391
- return { command: values.help ? "help" : positionals[0], options: botOptionsFromEnv(env) };
1392
- }
1393
- var BOT_ENV_HELP = ` Saved policy loads at startup; local settings cannot broaden its users, guests, or channels.
1394
-
1395
- GITTERM_API_TOKEN The bot's GitTerm token (dashboard → Bots creates one)
1396
- GITTERM_SERVER_URL Self-hosted GitTerm API URL (default: hosted)
1397
- GITTERM_BOT_REPO Repository for every channel: https://github.com/acme/app[#branch]
1398
- GITTERM_BOT_REPOS More repositories people can name in a thread: url,url
1399
- GITTERM_BOT_CHANNELS Per-channel repositories: <channel id>=<url>,…
1400
- GITTERM_BOT_MODEL provider/model, e.g. anthropic/claude-sonnet-5-5
1401
- GITTERM_BOT_MODEL_CREDENTIAL Saved credential label (default: the provider's default)
1402
- GITTERM_BOT_MODEL_API_KEY Or a model API key for this bot only
1403
- GITTERM_BOT_GITHUB_TOKEN Your own GitHub token instead of a GitTerm GitHub connection
1404
- GITTERM_BOT_CONNECTIONS Tools besides GitHub, by name: Linear,Sentry
1405
- GITTERM_BOT_ALLOWED_USERS Only these user ids may use the bot: U012,U034
1406
- GITTERM_BOT_ID Names an unsaved bot's sandboxes (saved bots use their own id)
1407
- GITTERM_BOT_ALLOWED_CHANNELS Only these channel ids, whatever the repository routing
1408
- GITTERM_BOT_APPROVERS Permission approver ids (default: requester only)
1409
- GITTERM_BOT_ALLOW_ALWAYS true enables persistent approvals (default: disabled)
1410
- GITTERM_BOT_SHARE_CHANNELS true opts equally trusted channels into a shared sandbox
1411
- GITTERM_BOT_MAX_PENDING[_PER_USER] Queue bounds (defaults: 20 total, 3 per user)
1412
- GITTERM_BOT_ALLOW_GUESTS true lets guests and external people use the bot
1413
- GITTERM_BOT_INSTRUCTIONS[_FILE] What the agent should know and how to behave
1414
- GITTERM_BOT_SETUP Command run before the agent starts, e.g. "pnpm install"
1415
- GITTERM_BOT_SANDBOX_ENV_FILE dotenv file of variables for the sandbox (test credentials)
1416
- GITTERM_BOT_PROVIDER Compute provider for new sandboxes (railway, e2b, …)
1417
- GITTERM_BOT_STATE_FILE, GITTERM_BOT_RUN_TIMEOUT_MINUTES`;
1418
- var hasRepository = (options) => Boolean(options.repo || options.repos?.length || Object.keys(options.channels ?? {}).length);
1419
-
1420
- // src/saved.ts
1421
- var isClient2 = (value) => typeof value === "object" && value !== null && ("runs" in value);
1422
- async function withSavedConfig(options, platform, log = console) {
1423
- const gitterm = isClient2(options.gitterm) ? options.gitterm : createGittermClient2(options.gitterm);
1424
- const base = { ...options, gitterm };
1425
- const saved = await gitterm.bots.self();
1426
- if (!saved)
1427
- return base;
1428
- if (saved.platform !== platform) {
1429
- throw new Error(`This GitTerm token belongs to the ${saved.platform} bot "${saved.name}"; run that bot with it`);
1430
- }
1431
- const merged = { ...base };
1432
- const localRepos = [
1433
- options.repo,
1434
- ...options.repos ?? [],
1435
- ...Object.values(options.channels ?? {})
1436
- ].filter((repo) => repo !== undefined);
1437
- if (localRepos.some((repo) => repoKey(parseRepo(repo)) !== repoKey(parseRepo(saved.repo)))) {
1438
- throw new Error("Local repository routing exceeds the saved bot policy. Change its repository in GitTerm first.");
1439
- }
1440
- const localModel = typeof options.model === "string" ? options.model : options.model?.id;
1441
- if (localModel && localModel !== saved.model)
1442
- throw new Error("Local model exceeds the saved bot policy. Change its model in GitTerm first.");
1443
- if (options.connections?.some((reference) => !saved.connections.includes(reference))) {
1444
- throw new Error("Local connections exceed the saved bot policy. Select them in GitTerm first.");
1445
- }
1446
- merged.botId = saved.id;
1447
- if (saved.channels.length) {
1448
- merged.allowedChannels = options.allowedChannels ? options.allowedChannels.filter((id) => saved.channels.includes(id)) : saved.channels;
1449
- }
1450
- if (!hasRepository(options)) {
1451
- if (saved.channels.length) {
1452
- merged.channels = Object.fromEntries(saved.channels.map((id) => [id, saved.repo]));
1453
- } else {
1454
- merged.repo = saved.repo;
1455
- }
1456
- }
1457
- merged.model ??= {
1458
- id: saved.model,
1459
- ...saved.credential ? { credential: saved.credential } : {}
1460
- };
1461
- merged.connections ??= saved.connections;
1462
- if (saved.provider && !options.workspace?.provider) {
1463
- merged.workspace = {
1464
- ...options.workspace,
1465
- provider: { type: saved.provider }
1466
- };
1467
- }
1468
- if (saved.allowedUsers.length) {
1469
- merged.allowedUsers = options.allowedUsers ? options.allowedUsers.filter((id) => saved.allowedUsers.includes(id)) : saved.allowedUsers;
1470
- }
1471
- merged.allowGuests = saved.allowGuests && (options.allowGuests ?? true);
1472
- if (saved.instructions)
1473
- merged.instructions ??= saved.instructions;
1474
- if (saved.setup)
1475
- merged.setup ??= [saved.setup];
1476
- if (saved.githubAccess === "token" && !merged.workspace?.repositoryCredentials) {
1477
- log.warn(`"${saved.name}" uses your own GitHub token, but GITTERM_BOT_GITHUB_TOKEN is not set. Public repositories still work.`);
1478
- }
1479
- log.info(`Using the settings saved for "${saved.name}" in GitTerm.`);
1480
- log.info("Effective bot authorization", {
1481
- botId: saved.id,
1482
- source: "saved policy, intersected with local restrictions",
1483
- channels: merged.allowedChannels ?? "all invited channels",
1484
- users: merged.allowedUsers ?? "channel members",
1485
- allowGuests: merged.allowGuests
1486
- });
1487
- return merged;
1488
- }
1489
1542
  // src/markdown.ts
1490
1543
  var TABLE_SEPARATOR = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/;
1491
1544
  function renderTable(lines) {
package/dist/saved.d.ts CHANGED
@@ -4,7 +4,10 @@ type Options = Omit<BotOptions, "adapter">;
4
4
  /**
5
5
  * Adds the settings saved for this bot in the GitTerm dashboard (Bots), found by its API token.
6
6
  * Routing can be overridden locally; saved authorization is an upper bound. Without saved settings,
7
- * for example a token that isn't a bot's, the options come back unchanged.
7
+ * for example a token that isn't a bot's, the options come back unchanged. The options given are
8
+ * kept as `localOptions`, so the running bot can apply settings saved later the same way.
8
9
  */
9
10
  export declare function withSavedConfig(options: Options, platform: SavedBot["platform"], log?: BotLogger): Promise<Options>;
11
+ /** Saved settings on top of local options; throws when the local options exceed them. */
12
+ export declare function applySavedConfig(options: Options, saved: SavedBot, log?: BotLogger): Options;
10
13
  export {};
package/dist/types.d.ts CHANGED
@@ -208,6 +208,11 @@ export type BotOptions = {
208
208
  /** How long a question or permission prompt waits for an answer. Default 30 min. */
209
209
  inputTimeoutMs?: number;
210
210
  logger?: BotLogger;
211
+ /**
212
+ * Set by `withSavedConfig`: the options before saved settings were added. A running bot applies
213
+ * settings saved later in the dashboard to these, without a restart.
214
+ */
215
+ localOptions?: Omit<BotOptions, "adapter" | "localOptions">;
211
216
  };
212
217
  export type Bot = {
213
218
  start(): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gitterm/bot",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Chat-platform-agnostic engine for GitTerm agent bots: one sandbox per repository, one agent session per thread",
5
5
  "keywords": [
6
6
  "agent",
@@ -33,7 +33,7 @@
33
33
  "check-types": "tsc --noEmit"
34
34
  },
35
35
  "dependencies": {
36
- "@gitterm/sdk": "^0.10.0"
36
+ "@gitterm/sdk": "^0.11.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/bun": "^1.2.6",