@goodfish/jarvy 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,18 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/server/cli.ts
4
- import { existsSync as existsSync2 } from "node:fs";
4
+ import { existsSync as existsSync5 } from "node:fs";
5
5
  import { createInterface } from "node:readline/promises";
6
- import { dirname as dirname8, join as join11 } from "node:path";
7
- import { fileURLToPath } from "node:url";
6
+ import { dirname as dirname9, join as join14 } from "node:path";
7
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
8
8
 
9
9
  // src/server/api/server.ts
10
10
  import { createServer } from "node:http";
11
+ import { createServer as createSecureServer } from "node:https";
12
+ import { createServer as createListener } from "node:net";
13
+ import { networkInterfaces } from "node:os";
11
14
  import { getRequestListener } from "@hono/node-server";
12
15
 
13
16
  // src/server/api/app.ts
14
- import { Hono as Hono10 } from "hono";
15
- import { HTTPException as HTTPException10 } from "hono/http-exception";
17
+ import { Hono as Hono12 } from "hono";
18
+ import { HTTPException as HTTPException12 } from "hono/http-exception";
16
19
  import { serveStatic } from "@hono/node-server/serve-static";
17
20
 
18
21
  // src/server/api/middleware/security.ts
@@ -153,22 +156,22 @@ function textField(input, key, fallback) {
153
156
  }
154
157
 
155
158
  // src/server/api/routes/account.ts
156
- function sessionCookie(c, name2, value2, maxAge) {
157
- const secure = !!c.env.incoming.socket.encrypted;
158
- setCookie(c, name2, value2, { httpOnly: true, sameSite: "Strict", path: "/", maxAge, secure });
159
+ var encrypted = (c) => !!c.env.incoming.socket.encrypted;
160
+ function sessionCookie(c, name3, value2, maxAge) {
161
+ setCookie(c, name3, value2, { httpOnly: true, sameSite: "Strict", path: "/", maxAge, secure: encrypted(c) });
159
162
  }
160
163
  function installAuthentication(api, account) {
161
- const COOKIE = `jarvy_session_${account.instance}`;
164
+ const BASE = `jarvy_session_${account.instance}`, cookie = (c) => encrypted(c) ? `${BASE}_tls` : BASE;
162
165
  api.post("/login", jsonBody, async (c) => {
163
166
  const input = await readJson(c);
164
167
  const result = account.login(input.username, input.password, c.env.incoming.socket.remoteAddress ?? "unknown");
165
168
  if (result.type === "limited") throw new HTTPException4(429, { message: "Too many login attempts; try again later" });
166
169
  if (result.type === "invalid") throw new HTTPException4(401, { message: "Invalid username or password" });
167
- sessionCookie(c, COOKIE, result.token, 7 * 24 * 60 * 60);
170
+ sessionCookie(c, cookie(c), result.token, 7 * 24 * 60 * 60);
168
171
  return c.json({ username: result.username });
169
172
  });
170
173
  api.use("*", async (c, next) => {
171
- const token = getCookie(c, COOKIE);
174
+ const token = getCookie(c, cookie(c));
172
175
  if (!account.authenticated(token)) throw new HTTPException4(401, { message: "Login required" });
173
176
  c.set("sessionToken", token);
174
177
  c.set("actorId", account.identity().id);
@@ -177,7 +180,7 @@ function installAuthentication(api, account) {
177
180
  api.get("/me", (c) => c.json({ username: account.identity().username }));
178
181
  api.post("/logout", (c) => {
179
182
  account.logout(c.get("sessionToken"));
180
- sessionCookie(c, COOKIE, "", 0);
183
+ sessionCookie(c, cookie(c), "", 0);
181
184
  return c.json({ ok: true });
182
185
  });
183
186
  }
@@ -198,7 +201,7 @@ function botStatusView(status) {
198
201
  errorCode: status.error?.code ?? null,
199
202
  error: status.error?.message ?? null,
200
203
  ...status.error?.params ? { errorParams: status.error.params } : {},
201
- ...status.context ? { context: { tokens: status.context.tokens, contextWindow: status.context.limit, percent: status.context.tokens === null ? null : status.context.tokens / status.context.limit * 100 } } : {}
204
+ ...status.context ? { context: { tokens: status.context.tokens, contextWindow: status.context.limit, percent: status.context.tokens === null ? null : status.context.tokens / status.context.limit * 100, stale: status.context.stale } } : {}
202
205
  };
203
206
  }
204
207
 
@@ -228,12 +231,12 @@ function clipMarked(content2, length) {
228
231
  function replyExcerpt(reply) {
229
232
  return reply.content ? previewQuotes(reply.content, reply.quotes).slice(0, 200) : "(attachment)";
230
233
  }
231
- function expandQuotes(content2, quotes, name2) {
234
+ function expandQuotes(content2, quotes, name3) {
232
235
  if (!quotes.length) return content2;
233
236
  return content2.replace(/[ \t]*\[\[q(\d+)\]\][ \t]*/g, (mark, n, offset, whole) => {
234
237
  const quote = quotes[Number(n) - 1];
235
238
  if (!quote) return mark;
236
- const head = `${name2(quote.senderId)}${quote.messageId ? ` (${quote.messageId})` : ""}: `;
239
+ const head = `${name3(quote.senderId)}${quote.messageId ? ` (${quote.messageId})` : ""}: `;
237
240
  const block = `${head}${clipQuote(quote.text).trim()}`.split("\n").map((line) => `> ${line}`).join("\n");
238
241
  const end = offset + mark.length;
239
242
  return `${offset > 0 && whole[offset - 1] !== "\n" ? "\n" : ""}${block}${end < whole.length && whole[end] !== "\n" ? "\n" : ""}`;
@@ -496,16 +499,16 @@ function chatRoutes({ chats, bots }) {
496
499
  return c.json(messageView(message));
497
500
  });
498
501
  routes.post("/:id/attachments", attachmentBody, async (c) => {
499
- let name2;
502
+ let name3;
500
503
  try {
501
- name2 = decodeURIComponent(c.req.header("x-file-name") ?? "");
504
+ name3 = decodeURIComponent(c.req.header("x-file-name") ?? "");
502
505
  } catch {
503
506
  throw new HTTPException8(400, { message: "Invalid filename" });
504
507
  }
505
508
  const data = Buffer.from(await c.req.arrayBuffer());
506
509
  if (!data.length) throw new HTTPException8(400, { message: "Empty attachment" });
507
510
  const mime = (c.req.header("content-type") ?? "application/octet-stream").slice(0, 100);
508
- return c.json(chats.upload(c.get("actorId"), c.req.param("id"), name2, mime, data), 201);
511
+ return c.json(chats.upload(c.get("actorId"), c.req.param("id"), name3, mime, data), 201);
509
512
  });
510
513
  routes.delete("/:id", async (c) => {
511
514
  const id = c.req.param("id");
@@ -537,8 +540,8 @@ function attachmentRoutes(chats) {
537
540
  routes.get("/:id/run", (c) => {
538
541
  const attachment = chats.download(c.get("actorId"), c.req.param("id"));
539
542
  if (!attachment) throw new HTTPException8(404, { message: "Attachment not found" });
540
- const { name: name2, mimeType } = attachment.file;
541
- if (!/\.html?$/i.test(name2) && mimeType !== "text/html") throw new HTTPException8(415, { message: "Only HTML attachments can run" });
543
+ const { name: name3, mimeType } = attachment.file;
544
+ if (!/\.html?$/i.test(name3) && mimeType !== "text/html") throw new HTTPException8(415, { message: "Only HTML attachments can run" });
542
545
  c.set("sandboxedDocument", true);
543
546
  return c.body(new Uint8Array(attachment.data), 200, { "Content-Type": "text/html; charset=utf-8", "Content-Length": String(attachment.data.length) });
544
547
  });
@@ -786,13 +789,68 @@ data: ${JSON.stringify(data)}
786
789
  return routes;
787
790
  }
788
791
 
792
+ // src/server/api/routes/connection.ts
793
+ import { Hono as Hono10 } from "hono";
794
+ import { HTTPException as HTTPException10 } from "hono/http-exception";
795
+ function rootCertificateRoute(connection) {
796
+ const routes = new Hono10();
797
+ routes.get("/connection/root.crt", (c) => {
798
+ const der = connection.rootCertificate();
799
+ if (!der) throw new HTTPException10(404, { message: "Jarvy serves HTTPS only when it listens beyond this machine" });
800
+ return c.body(new Uint8Array(der), 200, { "Content-Type": "application/x-x509-ca-cert", "Content-Disposition": 'attachment; filename="jarvy-root.crt"' });
801
+ });
802
+ return routes;
803
+ }
804
+ function connectionRoutes(connection) {
805
+ const routes = new Hono10();
806
+ routes.get("/connection", (c) => c.json(connection.info()));
807
+ routes.put("/connection", jsonBody, async (c) => {
808
+ const input = await readJson(c);
809
+ if (typeof input.allowHttp !== "boolean") throw new HTTPException10(400, { message: "allowHttp must be true or false" });
810
+ return c.json(connection.setAllowHttp(input.allowHttp));
811
+ });
812
+ return routes;
813
+ }
814
+
815
+ // src/server/api/routes/secrets.ts
816
+ import { Hono as Hono11 } from "hono";
817
+ import { HTTPException as HTTPException11 } from "hono/http-exception";
818
+ function secretRoutes(secrets) {
819
+ const routes = new Hono11();
820
+ routes.get("/", (c) => c.json(secrets.list()));
821
+ routes.post("/", jsonBody, async (c) => {
822
+ const input = await readJson(c);
823
+ return c.json(secrets.create({ name: input.name, description: input.description, value: input.value }), 201);
824
+ });
825
+ routes.put("/:id", jsonBody, async (c) => {
826
+ const input = await readJson(c);
827
+ return c.json(secrets.update(c.req.param("id"), { description: input.description, value: input.value }));
828
+ });
829
+ routes.delete("/:id", (c) => {
830
+ secrets.delete(c.req.param("id"));
831
+ return c.json({ ok: true });
832
+ });
833
+ routes.put("/:id/grants/:botId", jsonBody, async (c) => {
834
+ const input = await readJson(c);
835
+ if (typeof input.granted !== "boolean") throw new HTTPException11(400, { message: "granted must be true or false" });
836
+ return c.json(secrets.grant(c.req.param("id"), c.req.param("botId"), input.granted));
837
+ });
838
+ routes.get("/:id/uses", (c) => c.json({ uses: secrets.uses(c.req.param("id")) }));
839
+ routes.post("/requests/:chatId/:messageId", jsonBody, async (c) => {
840
+ const input = await readJson(c);
841
+ return c.json(messageView(secrets.fulfil(c.get("actorId"), c.req.param("chatId"), c.req.param("messageId"), { value: input.value, scope: input.scope })));
842
+ });
843
+ return routes;
844
+ }
845
+
789
846
  // src/server/api/app.ts
790
847
  function createHttpApp(services, webRoot) {
791
- const app = new Hono10();
848
+ const app = new Hono12();
792
849
  app.use("*", headers);
793
850
  app.onError(handleError);
794
- const api = new Hono10();
851
+ const api = new Hono12();
795
852
  api.use("*", apiSecurity);
853
+ api.route("/", rootCertificateRoute(services.connection));
796
854
  installAuthentication(api, services.account);
797
855
  api.route("/bots", botRoutes(services.bots));
798
856
  api.route("/", channelRoutes(services));
@@ -801,8 +859,10 @@ function createHttpApp(services, webRoot) {
801
859
  api.route("/", modelRoutes(services.models));
802
860
  api.route("/", eventRoutes(services));
803
861
  api.route("/", liveRoutes(services));
862
+ api.route("/", connectionRoutes(services.connection));
863
+ api.route("/secrets", secretRoutes(services.secrets));
804
864
  api.all("*", () => {
805
- throw new HTTPException10(404, { message: "API route not found" });
865
+ throw new HTTPException12(404, { message: "API route not found" });
806
866
  });
807
867
  app.route("/api", api);
808
868
  app.get("*", serveStatic({ root: webRoot, onFound: (path, c) => {
@@ -812,16 +872,72 @@ function createHttpApp(services, webRoot) {
812
872
  c.header("Cache-Control", "no-store");
813
873
  } }));
814
874
  app.all("*", (c) => {
815
- if (c.req.method !== "GET" && c.req.method !== "HEAD") throw new HTTPException10(405, { message: "Method not allowed" });
816
- throw new HTTPException10(404, { message: "Web assets missing; run npm run build" });
875
+ if (c.req.method !== "GET" && c.req.method !== "HEAD") throw new HTTPException12(405, { message: "Method not allowed" });
876
+ throw new HTTPException12(404, { message: "Web assets missing; run npm run build" });
817
877
  });
818
878
  return app;
819
879
  }
820
880
 
821
881
  // src/server/api/server.ts
822
- function createJarvyServer(services, webRoot) {
882
+ var own = { at: 0, addresses: /* @__PURE__ */ new Set() };
883
+ function fromThisMachine(address = "") {
884
+ const plain2 = address.replace(/^::ffff:/, "");
885
+ if (/^127\./.test(plain2) || plain2 === "::1") return true;
886
+ if (Date.now() - own.at > 6e4) own = { at: Date.now(), addresses: new Set(Object.values(networkInterfaces()).flatMap((list) => (list ?? []).map((item) => item.address))) };
887
+ return own.addresses.has(plain2);
888
+ }
889
+ function createJarvyServer(services, webRoot, tls) {
823
890
  const app = createHttpApp(services, webRoot);
824
- return createServer(getRequestListener((request, bindings) => app.fetch(request, bindings), { overrideGlobalObjects: false }));
891
+ const handle = getRequestListener((request, bindings) => app.fetch(request, bindings), { overrideGlobalObjects: false });
892
+ const plain2 = createServer((request, response) => {
893
+ if (tls && !fromThisMachine(request.socket.remoteAddress) && !services.connection.allowHttp()) {
894
+ response.writeHead(308, { Location: `https://${request.headers.host ?? ""}${request.url ?? "/"}`, "Cache-Control": "no-store" }).end();
895
+ return;
896
+ }
897
+ void handle(request, response);
898
+ });
899
+ if (!tls) return {
900
+ listen: (port, host) => new Promise((resolve5, reject) => {
901
+ plain2.once("error", reject);
902
+ plain2.listen(port, host, () => {
903
+ plain2.off("error", reject);
904
+ resolve5();
905
+ });
906
+ }),
907
+ close: () => {
908
+ plain2.close();
909
+ plain2.closeAllConnections();
910
+ }
911
+ };
912
+ const secure = createSecureServer({ key: tls.key, cert: tls.cert }, (request, response) => void handle(request, response));
913
+ const front = createListener({ pauseOnConnect: true }, (socket) => {
914
+ socket.on("error", () => socket.destroy());
915
+ socket.setTimeout(6e4, () => socket.destroy());
916
+ socket.once("readable", () => {
917
+ const head = socket.read(1);
918
+ if (!head) {
919
+ socket.destroy();
920
+ return;
921
+ }
922
+ socket.setTimeout(0);
923
+ socket.unshift(head);
924
+ (head[0] === 22 ? secure : plain2).emit("connection", socket);
925
+ });
926
+ });
927
+ return {
928
+ listen: (port, host) => new Promise((resolve5, reject) => {
929
+ front.once("error", reject);
930
+ front.listen(port, host, () => {
931
+ front.off("error", reject);
932
+ resolve5();
933
+ });
934
+ }),
935
+ close: () => {
936
+ front.close();
937
+ plain2.closeAllConnections();
938
+ secure.closeAllConnections();
939
+ }
940
+ };
825
941
  }
826
942
 
827
943
  // src/server/shared/models.ts
@@ -829,10 +945,10 @@ var BOT_COLORS = ["gray", "blue", "green", "amber", "rose", "violet"];
829
945
  var AGENT_BOT_ID = "00000000-0000-4000-8000-000000000001";
830
946
 
831
947
  // src/server/infra/storage/store.ts
832
- import { createHash, randomBytes, randomInt as randomInt3, randomUUID as randomUUID8, scryptSync, timingSafeEqual } from "node:crypto";
833
- import { chmodSync, mkdirSync } from "node:fs";
834
- import { homedir } from "node:os";
835
- import { dirname as dirname2, join as join2, resolve } from "node:path";
948
+ import { createHash, randomBytes as randomBytes3, randomInt as randomInt3, randomUUID as randomUUID9, scryptSync, timingSafeEqual } from "node:crypto";
949
+ import { chmodSync as chmodSync2, mkdirSync as mkdirSync2 } from "node:fs";
950
+ import { homedir as homedir2 } from "node:os";
951
+ import { dirname as dirname2, join as join3, resolve } from "node:path";
836
952
  import { DatabaseSync } from "node:sqlite";
837
953
 
838
954
  // src/server/infra/storage/migrations/composition-schema.ts
@@ -957,10 +1073,10 @@ function migrateChannels(db) {
957
1073
  const rename3 = db.prepare("UPDATE bots SET name=? WHERE id=?");
958
1074
  for (const bot of db.prepare("SELECT id,name FROM bots ORDER BY created_at,id").all()) {
959
1075
  const base = bot.name.replace(/@/g, "").trim() || "Bot";
960
- let name2 = base;
961
- for (let suffix = 2; taken.has(name2.toLowerCase()); suffix++) name2 = `${base} ${suffix}`;
962
- taken.add(name2.toLowerCase());
963
- if (name2 !== bot.name) rename3.run(name2, bot.id);
1076
+ let name3 = base;
1077
+ for (let suffix = 2; taken.has(name3.toLowerCase()); suffix++) name3 = `${base} ${suffix}`;
1078
+ taken.add(name3.toLowerCase());
1079
+ if (name3 !== bot.name) rename3.run(name3, bot.id);
964
1080
  }
965
1081
  db.exec("PRAGMA user_version=12; COMMIT;");
966
1082
  } catch (error) {
@@ -1166,6 +1282,86 @@ function migrateEnvironments(db) {
1166
1282
  }
1167
1283
  }
1168
1284
 
1285
+ // src/server/infra/vault/cipher.ts
1286
+ import { createCipheriv, createDecipheriv, createHmac, randomBytes } from "node:crypto";
1287
+ var PREFIX = "enc:v1:";
1288
+ var Cipher = class _Cipher {
1289
+ constructor(load, log = console.log) {
1290
+ this.load = load;
1291
+ this.log = log;
1292
+ }
1293
+ loaded = null;
1294
+ get master() {
1295
+ if (!this.loaded) {
1296
+ this.loaded = this.load();
1297
+ if (this.loaded.created) this.log(`[vault] made a new master key at ${this.loaded.location}; back it up with the data directory`);
1298
+ }
1299
+ return this.loaded;
1300
+ }
1301
+ location() {
1302
+ return this.master.location;
1303
+ }
1304
+ static sealed(text3) {
1305
+ return text3.startsWith(PREFIX);
1306
+ }
1307
+ seal(plain2, context2) {
1308
+ const iv = randomBytes(12), cipher = createCipheriv("aes-256-gcm", this.master.key, iv);
1309
+ cipher.setAAD(Buffer.from(context2));
1310
+ const body = Buffer.concat([cipher.update(plain2, "utf8"), cipher.final()]);
1311
+ return PREFIX + Buffer.concat([iv, body, cipher.getAuthTag()]).toString("base64");
1312
+ }
1313
+ /** The plain text, or null when it does not open under this key (another installation's key, or damaged). */
1314
+ open(sealed, context2) {
1315
+ if (!_Cipher.sealed(sealed)) return null;
1316
+ try {
1317
+ const raw = Buffer.from(sealed.slice(PREFIX.length), "base64");
1318
+ const decipher = createDecipheriv("aes-256-gcm", this.master.key, raw.subarray(0, 12));
1319
+ decipher.setAAD(Buffer.from(context2));
1320
+ decipher.setAuthTag(raw.subarray(raw.length - 16));
1321
+ return Buffer.concat([decipher.update(raw.subarray(12, raw.length - 16)), decipher.final()]).toString("utf8");
1322
+ } catch {
1323
+ return null;
1324
+ }
1325
+ }
1326
+ /** A short keyed fingerprint for people and logs: it tells values apart without letting anyone test guesses. */
1327
+ fingerprint(plain2) {
1328
+ return createHmac("sha256", this.master.key).update(plain2).digest("hex").slice(0, 8);
1329
+ }
1330
+ };
1331
+
1332
+ // src/server/infra/storage/migrations/secret-schema.ts
1333
+ function migrateSecrets(db, cipher) {
1334
+ const version = db.prepare("PRAGMA user_version").get().user_version;
1335
+ if (version >= 21) return;
1336
+ db.exec("BEGIN IMMEDIATE");
1337
+ try {
1338
+ db.exec(`
1339
+ CREATE TABLE secrets(id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES members(id), name TEXT NOT NULL,
1340
+ kind TEXT NOT NULL CHECK(kind IN ('value','ssh_key')), bot_id TEXT REFERENCES bots(id),
1341
+ description TEXT NOT NULL DEFAULT '', value TEXT NOT NULL, fingerprint TEXT NOT NULL,
1342
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
1343
+ CREATE UNIQUE INDEX secrets_name ON secrets(owner_id, name) WHERE kind='value';
1344
+ CREATE UNIQUE INDEX secrets_bot_key ON secrets(bot_id) WHERE kind='ssh_key';
1345
+ CREATE TABLE secret_grants(secret_id TEXT NOT NULL REFERENCES secrets(id) ON DELETE CASCADE,
1346
+ bot_id TEXT NOT NULL REFERENCES bots(id), scope TEXT NOT NULL CHECK(scope IN ('always','once')), run_id TEXT,
1347
+ granted_by TEXT NOT NULL REFERENCES members(id), granted_at INTEGER NOT NULL, PRIMARY KEY(secret_id, bot_id));
1348
+ CREATE TABLE secret_uses(id INTEGER PRIMARY KEY AUTOINCREMENT, secret_id TEXT REFERENCES secrets(id) ON DELETE SET NULL,
1349
+ secret_name TEXT NOT NULL, bot_id TEXT NOT NULL REFERENCES bots(id), at INTEGER NOT NULL,
1350
+ how TEXT NOT NULL CHECK(how IN ('command','ssh_sign')), environment_id TEXT, history_id INTEGER, tool_call_id TEXT,
1351
+ detail TEXT NOT NULL DEFAULT '');
1352
+ CREATE INDEX secret_uses_secret ON secret_uses(secret_id, at);
1353
+ `);
1354
+ const rows2 = db.prepare("SELECT connection_id AS id, credential_json AS json FROM provider_credentials").all();
1355
+ const update = db.prepare("UPDATE provider_credentials SET credential_json=? WHERE connection_id=?");
1356
+ for (const row of rows2) if (!Cipher.sealed(row.json)) update.run(cipher.seal(row.json, `credential:${row.id}`), row.id);
1357
+ db.exec("PRAGMA user_version=21");
1358
+ db.exec("COMMIT");
1359
+ } catch (error) {
1360
+ db.exec("ROLLBACK");
1361
+ throw error;
1362
+ }
1363
+ }
1364
+
1169
1365
  // src/server/infra/storage/migrations/schema.ts
1170
1366
  import { randomUUID as randomUUID2 } from "node:crypto";
1171
1367
 
@@ -1286,9 +1482,9 @@ function migrateRuntime(db) {
1286
1482
  }
1287
1483
 
1288
1484
  // src/server/infra/storage/migrations/schema.ts
1289
- function migrateDatabase(db) {
1485
+ function migrateDatabase(db, cipher) {
1290
1486
  const version = db.prepare("PRAGMA user_version").get().user_version;
1291
- if (version > 20) throw new Error(`Unsupported database version: ${version}`);
1487
+ if (version > 21) throw new Error(`Unsupported database version: ${version}`);
1292
1488
  migratePrototype(db);
1293
1489
  if (version >= 5) {
1294
1490
  migrateRuntime(db);
@@ -1306,6 +1502,7 @@ function migrateDatabase(db) {
1306
1502
  migrateTitles(db);
1307
1503
  migrateAgent(db);
1308
1504
  migrateEnvironments(db);
1505
+ migrateSecrets(db, cipher);
1309
1506
  return;
1310
1507
  }
1311
1508
  db.exec("PRAGMA foreign_keys=OFF; BEGIN IMMEDIATE");
@@ -1381,6 +1578,7 @@ function migrateDatabase(db) {
1381
1578
  migrateTitles(db);
1382
1579
  migrateAgent(db);
1383
1580
  migrateEnvironments(db);
1581
+ migrateSecrets(db, cipher);
1384
1582
  }
1385
1583
 
1386
1584
  // src/server/infra/storage/chat-repository.ts
@@ -1431,10 +1629,10 @@ var ChannelRepository = class {
1431
1629
  section(ownerId, id) {
1432
1630
  return this.db.prepare(`SELECT ${SECTION_COLUMNS} FROM sections WHERE id=? AND owner_id=?`).get(id, ownerId) ?? null;
1433
1631
  }
1434
- createSection(ownerId, name2) {
1632
+ createSection(ownerId, name3) {
1435
1633
  const id = randomUUID3(), now = Date.now();
1436
1634
  const position = this.db.prepare("SELECT COALESCE(MAX(position),0)+1 AS next FROM sections WHERE owner_id=?").get(ownerId).next;
1437
- this.db.prepare("INSERT INTO sections(id,owner_id,name,position,created_at,updated_at) VALUES(?,?,?,?,?,?)").run(id, ownerId, name2, position, now, now);
1635
+ this.db.prepare("INSERT INTO sections(id,owner_id,name,position,created_at,updated_at) VALUES(?,?,?,?,?,?)").run(id, ownerId, name3, position, now, now);
1438
1636
  return this.section(ownerId, id);
1439
1637
  }
1440
1638
  updateSection(ownerId, id, change) {
@@ -1519,13 +1717,13 @@ var ChannelRepository = class {
1519
1717
  this.chats.assertAccess(memberId, chatId);
1520
1718
  this.db.prepare(`INSERT INTO chat_states(chat_id,member_id,read_sequence) VALUES(:chat,:member,MIN(:sequence,COALESCE((SELECT MAX(sequence) FROM messages WHERE chat_id=:chat),0)))
1521
1719
  ON CONFLICT(chat_id,member_id) DO UPDATE SET read_sequence=MAX(read_sequence,excluded.read_sequence)`).run({ chat: chatId, member: memberId, sequence });
1522
- const read = this.chats.unread(memberId, chatId).readSequence;
1720
+ const read3 = this.chats.unread(memberId, chatId).readSequence;
1523
1721
  if (this.chats.member(memberId)?.kind === "user") {
1524
1722
  this.db.prepare(`UPDATE inbox SET state='told',told_at=? WHERE member_id=? AND kind='message' AND state='pending'
1525
- AND chat_id=? AND message_id IN (SELECT id FROM messages WHERE chat_id=? AND sequence<=?)`).run(Date.now(), memberId, chatId, chatId, read);
1723
+ AND chat_id=? AND message_id IN (SELECT id FROM messages WHERE chat_id=? AND sequence<=?)`).run(Date.now(), memberId, chatId, chatId, read3);
1526
1724
  this.chats.interactions.seenIn(memberId, chatId);
1527
1725
  }
1528
- return read;
1726
+ return read3;
1529
1727
  }
1530
1728
  /** Subscribing: every new message in this channel or thread is delivered to the member,
1531
1729
  * @ or not. Subscribing to a thread also follows it; unsubscribing keeps following. */
@@ -1576,9 +1774,9 @@ function threadTitle(content2, names, file = "") {
1576
1774
  let rest = line;
1577
1775
  for (; ; ) {
1578
1776
  rest = rest.trimStart();
1579
- const name2 = rest.startsWith("@") ? sorted.find((item) => rest.toLowerCase().startsWith(`@${item.toLowerCase()}`)) : void 0;
1580
- if (!name2) break;
1581
- rest = rest.slice(name2.length + 1);
1777
+ const name3 = rest.startsWith("@") ? sorted.find((item) => rest.toLowerCase().startsWith(`@${item.toLowerCase()}`)) : void 0;
1778
+ if (!name3) break;
1779
+ rest = rest.slice(name3.length + 1);
1582
1780
  }
1583
1781
  return (rest.trim() || line || file).slice(0, 60);
1584
1782
  }
@@ -1651,8 +1849,8 @@ var ThreadRepository = class {
1651
1849
  return this.db.prepare(`SELECT c.id,c.title,c.status,(SELECT count(*) FROM messages WHERE chat_id=c.id) AS replies,
1652
1850
  (SELECT MAX(created_at) FROM messages WHERE chat_id=c.id) AS lastReplyAt FROM chats c WHERE c.root_message_id=?`).get(rootMessageId) ?? null;
1653
1851
  }
1654
- assertThread(actorId, threadId, write = false) {
1655
- this.chats.assertAccess(actorId, threadId, write);
1852
+ assertThread(actorId, threadId, write2 = false) {
1853
+ this.chats.assertAccess(actorId, threadId, write2);
1656
1854
  if (!this.db.prepare("SELECT 1 FROM chats WHERE id=? AND type='thread'").get(threadId)) throw new ChatError(404, "Thread not found");
1657
1855
  }
1658
1856
  };
@@ -1795,10 +1993,10 @@ var ChatRepository = class {
1795
1993
  }
1796
1994
  /** A thread has no member list of its own: reading and writing follow its parent channel (A37). The owner may
1797
1995
  * read (never write) a DM between two of its Bots. */
1798
- canAccess(actorId, chatId, write = false) {
1996
+ canAccess(actorId, chatId, write2 = false) {
1799
1997
  return !!this.db.prepare(`SELECT 1 FROM chats c JOIN chat_members cm ON cm.chat_id=COALESCE(c.parent_id,c.id)
1800
1998
  WHERE c.id=:chat AND cm.member_id=:actor AND cm.can_read=1 AND (:write=0 OR cm.can_write=1)
1801
- UNION ALL SELECT 1 FROM chats c WHERE c.id=:chat AND c.type='dm' AND c.owner_id=:actor AND :write=0`).get({ chat: chatId, actor: actorId, write: write ? 1 : 0 });
1999
+ UNION ALL SELECT 1 FROM chats c WHERE c.id=:chat AND c.type='dm' AND c.owner_id=:actor AND :write=0`).get({ chat: chatId, actor: actorId, write: write2 ? 1 : 0 });
1802
2000
  }
1803
2001
  /** Unread count, unread @-mentions of the member (every unread message in a DM) and read position.
1804
2002
  * A thread counts only for its followers. */
@@ -1806,21 +2004,21 @@ var ChatRepository = class {
1806
2004
  const params = { chat: chatId, member: memberId };
1807
2005
  const thread = this.db.prepare("SELECT type FROM chats WHERE id=?").get(chatId)?.type === "thread";
1808
2006
  if (thread && !this.threads.isFollowing(memberId, chatId)) {
1809
- const read2 = this.db.prepare("SELECT read_sequence AS n FROM chat_states WHERE chat_id=? AND member_id=?").get(chatId, memberId);
1810
- return { unread: 0, mentions: 0, readSequence: read2?.n ?? 0 };
2007
+ const read4 = this.db.prepare("SELECT read_sequence AS n FROM chat_states WHERE chat_id=? AND member_id=?").get(chatId, memberId);
2008
+ return { unread: 0, mentions: 0, readSequence: read4?.n ?? 0 };
1811
2009
  }
1812
2010
  const unread = this.db.prepare(`SELECT count(*) AS n ${UNREAD}`).get(params).n;
1813
2011
  const mentions = this.db.prepare(`SELECT count(*) AS n ${UNREAD} AND ((SELECT type FROM chats WHERE id=:chat)='dm'
1814
2012
  OR EXISTS(SELECT 1 FROM message_mentions mm WHERE mm.message_id=m.id AND mm.member_id=:member))`).get(params).n;
1815
- const read = this.db.prepare("SELECT read_sequence AS n FROM chat_states WHERE chat_id=? AND member_id=?").get(chatId, memberId);
1816
- return { unread, mentions, readSequence: read?.n ?? 0 };
2013
+ const read3 = this.db.prepare("SELECT read_sequence AS n FROM chat_states WHERE chat_id=? AND member_id=?").get(chatId, memberId);
2014
+ return { unread, mentions, readSequence: read3?.n ?? 0 };
1817
2015
  }
1818
2016
  note(chatId) {
1819
2017
  const row = this.db.prepare("SELECT text,updated_by AS updatedBy,updated_at AS updatedAt,version FROM chat_notes WHERE chat_id=?").get(chatId);
1820
2018
  return row ? { ...row, updatedBy: this.member(row.updatedBy) } : null;
1821
2019
  }
1822
- assertAccess(actorId, chatId, write = false) {
1823
- if (!this.canAccess(actorId, chatId, write)) throw new ChatError(404, "Chat not found");
2020
+ assertAccess(actorId, chatId, write2 = false) {
2021
+ if (!this.canAccess(actorId, chatId, write2)) throw new ChatError(404, "Chat not found");
1824
2022
  }
1825
2023
  getChat(actorId, id) {
1826
2024
  if (!this.canAccess(actorId, id)) return null;
@@ -2071,10 +2269,10 @@ var ChatRepository = class {
2071
2269
  expirePendingQuestions() {
2072
2270
  this.db.prepare(`UPDATE messages SET question=json_set(question,'$.state','ignored','$.settledAt',?) WHERE json_extract(question,'$.state')='pending'`).run(Date.now());
2073
2271
  }
2074
- createAttachment(actorId, chatId, name2, mimeType, data) {
2272
+ createAttachment(actorId, chatId, name3, mimeType, data) {
2075
2273
  this.assertAccess(actorId, chatId, true);
2076
- const file = { id: randomUUID5(), name: name2, mimeType, size: data.length };
2077
- this.db.prepare("INSERT INTO attachments(id,chat_id,message_id,uploaded_by,name,mime_type,size,data,created_at) VALUES(?,?,NULL,?,?,?,?,?,?)").run(file.id, chatId, actorId, name2, mimeType, data.length, data, Date.now());
2274
+ const file = { id: randomUUID5(), name: name3, mimeType, size: data.length };
2275
+ this.db.prepare("INSERT INTO attachments(id,chat_id,message_id,uploaded_by,name,mime_type,size,data,created_at) VALUES(?,?,NULL,?,?,?,?,?,?)").run(file.id, chatId, actorId, name3, mimeType, data.length, data, Date.now());
2078
2276
  return file;
2079
2277
  }
2080
2278
  attachment(actorId, id) {
@@ -2350,21 +2548,34 @@ function credentialValue(value2) {
2350
2548
  throw new RuntimeFault("invalid_credential", "The credential format isn't valid.");
2351
2549
  }
2352
2550
  var CredentialRepository = class {
2353
- constructor(db) {
2551
+ constructor(db, cipher) {
2354
2552
  this.db = db;
2553
+ this.cipher = cipher;
2355
2554
  }
2356
- read(ownerId, connectionId) {
2555
+ /** The text to store for a credential. */
2556
+ stored(connectionId, value2) {
2557
+ return this.cipher.seal(JSON.stringify(credentialValue(value2)), `credential:${connectionId}`);
2558
+ }
2559
+ /** Like `read`, but a value that doesn't open under this installation's key comes back as null, so it can be replaced. */
2560
+ current(ownerId, connectionId) {
2357
2561
  const row = this.db.prepare(`SELECT p.credential_json AS json FROM provider_credentials p JOIN provider_connections c ON c.id=p.connection_id
2358
2562
  WHERE c.id=? AND c.owner_id=?`).get(connectionId, ownerId);
2359
- return row ? { value: credentialValue(JSON.parse(row.json)), revision: row.json } : null;
2563
+ if (!row) return null;
2564
+ const plain2 = Cipher.sealed(row.json) ? this.cipher.open(row.json, `credential:${connectionId}`) : row.json;
2565
+ return { value: plain2 === null ? null : credentialValue(JSON.parse(plain2)), revision: row.json };
2566
+ }
2567
+ read(ownerId, connectionId) {
2568
+ const row = this.current(ownerId, connectionId);
2569
+ if (row && !row.value) throw new RuntimeFault("credential_unreadable", "The saved credential can't be decrypted with this installation's master key; enter it again in Settings.");
2570
+ return row;
2360
2571
  }
2361
2572
  replace(ownerId, connectionId, prior, value2) {
2362
- const next = JSON.stringify(credentialValue(value2));
2573
+ const next = this.stored(connectionId, value2);
2363
2574
  this.db.exec("BEGIN IMMEDIATE");
2364
2575
  try {
2365
2576
  if (!this.db.prepare("SELECT 1 FROM provider_connections WHERE id=? AND owner_id=?").get(connectionId, ownerId))
2366
2577
  throw new RuntimeFault("connection_missing", "Connection not found", 404);
2367
- const current = this.read(ownerId, connectionId);
2578
+ const current = this.current(ownerId, connectionId);
2368
2579
  if ((current?.revision ?? null) !== prior) throw new RuntimeFault("credential_changed", "The credential changed. Please try again.");
2369
2580
  this.db.prepare(`INSERT INTO provider_credentials(connection_id,type,credential_json,updated_at) VALUES(?,?,?,?)
2370
2581
  ON CONFLICT(connection_id) DO UPDATE SET type=excluded.type,credential_json=excluded.credential_json,updated_at=excluded.updated_at`).run(connectionId, value2.type, next, Date.now());
@@ -2393,15 +2604,15 @@ var AgentRepository = class {
2393
2604
  if (row && row.deletedAt === null) return;
2394
2605
  const taken = this.db.prepare(`SELECT 1 FROM account WHERE lower(username)=lower(:name)
2395
2606
  UNION ALL SELECT 1 FROM bots WHERE lower(name)=lower(:name) AND deleted_at IS NULL LIMIT 1`);
2396
- let name2 = "Jarvy";
2397
- for (let n = 2; taken.get({ name: name2 }); n++) name2 = `Jarvy ${n}`;
2607
+ let name3 = "Jarvy";
2608
+ for (let n = 2; taken.get({ name: name3 }); n++) name3 = `Jarvy ${n}`;
2398
2609
  this.db.exec("BEGIN IMMEDIATE");
2399
2610
  try {
2400
2611
  if (row) this.db.prepare(`UPDATE bots SET deleted_at=NULL,name=?,description='',system_prompt='',connection_id=NULL,model_id='',thinking_level='auto'
2401
- WHERE id=?`).run(name2, AGENT_BOT_ID);
2612
+ WHERE id=?`).run(name3, AGENT_BOT_ID);
2402
2613
  else {
2403
2614
  this.db.prepare("INSERT OR IGNORE INTO members VALUES(?,'bot')").run(AGENT_BOT_ID);
2404
- this.db.prepare("INSERT INTO bots(id,name,description,system_prompt,model_id,created_at,owner_id,thinking_level,color) VALUES(?,?,'','','',?,?,'auto',?)").run(AGENT_BOT_ID, name2, Date.now(), this.ownerId(), BOT_COLORS[randomInt2(BOT_COLORS.length)]);
2615
+ this.db.prepare("INSERT INTO bots(id,name,description,system_prompt,model_id,created_at,owner_id,thinking_level,color) VALUES(?,?,'','','',?,?,'auto',?)").run(AGENT_BOT_ID, name3, Date.now(), this.ownerId(), BOT_COLORS[randomInt2(BOT_COLORS.length)]);
2405
2616
  }
2406
2617
  this.db.prepare("DELETE FROM settings WHERE key='agent_onboarded'").run();
2407
2618
  this.db.exec("COMMIT");
@@ -2504,8 +2715,158 @@ var EnvironmentRepository = class {
2504
2715
  }
2505
2716
  };
2506
2717
 
2718
+ // src/server/infra/storage/settings-repository.ts
2719
+ var SettingsRepository = class {
2720
+ constructor(db) {
2721
+ this.db = db;
2722
+ }
2723
+ get(key) {
2724
+ const row = this.db.prepare("SELECT value FROM settings WHERE key=?").get(key);
2725
+ return row?.value ?? null;
2726
+ }
2727
+ set(key, value2) {
2728
+ if (value2 === null) this.db.prepare("DELETE FROM settings WHERE key=?").run(key);
2729
+ else this.db.prepare("INSERT INTO settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(key, value2);
2730
+ }
2731
+ };
2732
+
2733
+ // src/server/infra/storage/secret-repository.ts
2734
+ import { randomUUID as randomUUID8 } from "node:crypto";
2735
+
2736
+ // src/server/shared/secret.ts
2737
+ var SECRET_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
2738
+
2739
+ // src/server/infra/storage/secret-repository.ts
2740
+ var COLUMNS2 = `s.id, s.name, s.kind, s.bot_id AS botId, s.description, s.value, s.fingerprint, s.created_at AS createdAt, s.updated_at AS updatedAt,
2741
+ (SELECT count(*) FROM secret_uses u WHERE u.secret_id=s.id) AS uses, (SELECT max(at) FROM secret_uses u WHERE u.secret_id=s.id) AS lastUsedAt`;
2742
+ var MAX_VALUE = 16 * 1024;
2743
+ var SecretRepository = class {
2744
+ constructor(db, cipher, ownerId) {
2745
+ this.db = db;
2746
+ this.cipher = cipher;
2747
+ this.ownerId = ownerId;
2748
+ }
2749
+ toRecord(row) {
2750
+ const grants = this.db.prepare(`SELECT bot_id AS botId, scope, granted_at AS grantedAt FROM secret_grants g WHERE secret_id=?
2751
+ AND (scope='always' OR EXISTS(SELECT 1 FROM bot_runs r WHERE r.id=g.run_id AND r.ended_at IS NULL)) ORDER BY granted_at`).all(row.id).map((grant) => ({ ...grant }));
2752
+ const { value: value2, ...rest } = row;
2753
+ return { ...rest, readable: this.cipher.open(value2, `secret:${row.id}`) !== null, grants };
2754
+ }
2755
+ list() {
2756
+ this.purge();
2757
+ return this.db.prepare(`SELECT ${COLUMNS2} FROM secrets s WHERE s.owner_id=? ORDER BY s.kind, lower(s.name)`).all(this.ownerId()).map((row) => this.toRecord(row));
2758
+ }
2759
+ get(id) {
2760
+ const row = this.db.prepare(`SELECT ${COLUMNS2} FROM secrets s WHERE s.id=? AND s.owner_id=?`).get(id, this.ownerId());
2761
+ return row ? this.toRecord(row) : null;
2762
+ }
2763
+ checkValue(value2) {
2764
+ if (typeof value2 !== "string" || !value2) throw new ValidationError("A secret needs a value", "secret_value_missing");
2765
+ if (value2.length > MAX_VALUE) throw new ValidationError("The value is too long", "secret_value_too_long");
2766
+ return value2;
2767
+ }
2768
+ checkDescription(value2) {
2769
+ if (value2 === void 0 || value2 === null) return "";
2770
+ if (typeof value2 !== "string" || value2.length > 200 || /[\n\r]/.test(value2)) throw new ValidationError("The description must be one line of up to 200 characters", "secret_description_invalid");
2771
+ return value2.trim();
2772
+ }
2773
+ create(input) {
2774
+ if (typeof input.name !== "string" || !SECRET_NAME.test(input.name)) throw new ValidationError("Names use capital letters, digits and underscores, and start with a letter", "secret_name_invalid");
2775
+ const description = this.checkDescription(input.description), value2 = this.checkValue(input.value);
2776
+ if (this.db.prepare("SELECT 1 FROM secrets WHERE owner_id=? AND kind='value' AND name=?").get(this.ownerId(), input.name)) throw new ConflictError("Another secret has this name", "secret_name_taken");
2777
+ const id = randomUUID8(), now = Date.now();
2778
+ this.db.prepare("INSERT INTO secrets(id,owner_id,name,kind,bot_id,description,value,fingerprint,created_at,updated_at) VALUES(?,?,?,'value',NULL,?,?,?,?,?)").run(id, this.ownerId(), input.name, description, this.cipher.seal(value2, `secret:${id}`), this.cipher.fingerprint(value2), now, now);
2779
+ return this.get(id);
2780
+ }
2781
+ update(id, input) {
2782
+ if (!this.get(id)) return null;
2783
+ if (input.description !== void 0) this.db.prepare("UPDATE secrets SET description=?, updated_at=? WHERE id=?").run(this.checkDescription(input.description), Date.now(), id);
2784
+ if (input.value !== void 0) {
2785
+ const value2 = this.checkValue(input.value);
2786
+ this.db.prepare("UPDATE secrets SET value=?, fingerprint=?, updated_at=? WHERE id=?").run(this.cipher.seal(value2, `secret:${id}`), this.cipher.fingerprint(value2), Date.now(), id);
2787
+ }
2788
+ return this.get(id);
2789
+ }
2790
+ /** The grants go with it; its uses stay, under its name. */
2791
+ delete(id) {
2792
+ return Number(this.db.prepare("DELETE FROM secrets WHERE id=? AND owner_id=?").run(id, this.ownerId()).changes) > 0;
2793
+ }
2794
+ grant(id, botId, scope, runId) {
2795
+ this.db.prepare(`INSERT INTO secret_grants(secret_id,bot_id,scope,run_id,granted_by,granted_at) VALUES(?,?,?,?,?,?)
2796
+ ON CONFLICT(secret_id,bot_id) DO UPDATE SET scope=excluded.scope, run_id=excluded.run_id, granted_at=excluded.granted_at`).run(id, botId, scope, runId, this.ownerId(), Date.now());
2797
+ }
2798
+ revoke(id, botId) {
2799
+ return Number(this.db.prepare("DELETE FROM secret_grants WHERE secret_id=? AND bot_id=?").run(id, botId).changes) > 0;
2800
+ }
2801
+ uses(id, limit = 100) {
2802
+ return this.db.prepare(`SELECT id, secret_name AS secretName, bot_id AS botId, at, how, environment_id AS environmentId, history_id AS historyId,
2803
+ tool_call_id AS toolCallId, detail FROM secret_uses WHERE secret_id=? ORDER BY at DESC, id DESC LIMIT ?`).all(id, limit).map((use) => ({ ...use }));
2804
+ }
2805
+ /** Every value the owner keeps that opens, for masking. */
2806
+ values() {
2807
+ return this.db.prepare("SELECT id, name, value FROM secrets WHERE owner_id=? AND kind='value'").all(this.ownerId()).map((row) => ({ name: row.name, value: this.cipher.open(row.value, `secret:${row.id}`) })).filter((row) => row.value !== null);
2808
+ }
2809
+ /** The secrets a Bot may use: granted until revoked, or for the run that is still going. */
2810
+ grantedTo(botId) {
2811
+ return this.db.prepare(`SELECT s.id, s.name, s.description, g.scope FROM secrets s JOIN secret_grants g ON g.secret_id=s.id
2812
+ WHERE g.bot_id=? AND s.owner_id=? AND s.kind='value' AND (g.scope='always' OR EXISTS(SELECT 1 FROM bot_runs r WHERE r.id=g.run_id AND r.ended_at IS NULL))
2813
+ ORDER BY s.name`).all(botId, this.ownerId()).map((row) => ({ ...row }));
2814
+ }
2815
+ /** The Bot's run in progress, which a "this time only" grant lasts for. */
2816
+ currentRun(botId) {
2817
+ return this.db.prepare("SELECT id FROM bot_runs WHERE bot_id=? AND ended_at IS NULL ORDER BY created_at DESC LIMIT 1").get(botId)?.id ?? null;
2818
+ }
2819
+ byName(name3) {
2820
+ const row = this.db.prepare("SELECT id FROM secrets WHERE owner_id=? AND kind='value' AND name=?").get(this.ownerId(), name3);
2821
+ return row ? this.get(row.id) : null;
2822
+ }
2823
+ /** "This time only" grants whose run has ended. */
2824
+ purge() {
2825
+ this.db.prepare("DELETE FROM secret_grants WHERE scope='once' AND NOT EXISTS(SELECT 1 FROM bot_runs r WHERE r.id=secret_grants.run_id AND r.ended_at IS NULL)").run();
2826
+ }
2827
+ record(use) {
2828
+ this.db.prepare("INSERT INTO secret_uses(secret_id,secret_name,bot_id,at,how,environment_id,tool_call_id,detail) VALUES(?,?,?,?,?,?,?,?)").run(use.secretId, use.name, use.botId, Date.now(), use.how, use.environmentId, use.toolCallId, use.detail.slice(0, 4e3));
2829
+ }
2830
+ /** The plain value, for a command that uses it; null when it does not open. */
2831
+ value(id) {
2832
+ const row = this.db.prepare("SELECT value FROM secrets WHERE id=?").get(id);
2833
+ return row ? this.cipher.open(row.value, `secret:${id}`) : null;
2834
+ }
2835
+ };
2836
+
2837
+ // src/server/infra/vault/master-key.ts
2838
+ import { randomBytes as randomBytes2 } from "node:crypto";
2839
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2840
+ import { homedir } from "node:os";
2841
+ import { join as join2 } from "node:path";
2842
+ function keyDirectory() {
2843
+ const base = process.env.XDG_CONFIG_HOME || (process.platform === "win32" ? process.env.APPDATA ?? join2(homedir(), "AppData", "Roaming") : join2(homedir(), ".config"));
2844
+ return join2(base, "jarvy");
2845
+ }
2846
+ function read(location) {
2847
+ const key = Buffer.from(readFileSync(location, "utf8").trim(), "base64");
2848
+ if (key.length !== 32) throw new Error(`The master key at ${location} is damaged`);
2849
+ return { key, location, created: false };
2850
+ }
2851
+ function masterKey(installationId) {
2852
+ if (!/^[A-Za-z0-9-]+$/.test(installationId)) throw new Error("Invalid installation id");
2853
+ const dir = keyDirectory(), location = join2(dir, `${installationId}.key`);
2854
+ if (existsSync(location)) return read(location);
2855
+ mkdirSync(dir, { recursive: true, mode: 448 });
2856
+ const key = randomBytes2(32);
2857
+ try {
2858
+ writeFileSync(location, `${key.toString("base64")}
2859
+ `, { mode: 384, flag: "wx" });
2860
+ } catch (error) {
2861
+ if (error.code === "EEXIST") return read(location);
2862
+ throw error;
2863
+ }
2864
+ chmodSync(location, 384);
2865
+ return { key, location, created: true };
2866
+ }
2867
+
2507
2868
  // src/server/infra/storage/store.ts
2508
- var defaultDataDir = () => process.env.JARVY_DATA_DIR || join2(homedir(), ".jarvy");
2869
+ var defaultDataDir = () => process.env.JARVY_DATA_DIR || join3(homedir2(), ".jarvy");
2509
2870
  var DATABASE_FILENAME = "jarvy.db";
2510
2871
  var SESSION_LIFETIME_MS = 7 * 24 * 60 * 60 * 1e3;
2511
2872
  function passwordHash(password, salt) {
@@ -2533,7 +2894,7 @@ function normalizeModel(value2) {
2533
2894
  if (!value2 || typeof value2 !== "object" || Array.isArray(value2)) throw new ValidationError("Invalid model");
2534
2895
  const input = value2;
2535
2896
  const id = requireText(input.id, "Model ID", 200);
2536
- const name2 = input.name === void 0 ? id : optionalText(input.name, "Model name", 200) || id;
2897
+ const name3 = input.name === void 0 ? id : optionalText(input.name, "Model name", 200) || id;
2537
2898
  if (input.reasoning !== void 0 && typeof input.reasoning !== "boolean") throw new ValidationError("Invalid reasoning capability");
2538
2899
  const capabilities2 = input.input === void 0 ? ["text"] : input.input;
2539
2900
  if (!Array.isArray(capabilities2) || !capabilities2.length || !capabilities2.includes("text") || capabilities2.some((item) => item !== "text" && item !== "image")) throw new ValidationError("Invalid input capabilities");
@@ -2554,7 +2915,7 @@ function normalizeModel(value2) {
2554
2915
  const { thinkingFormat: _format, ...compat } = rawCompat;
2555
2916
  return {
2556
2917
  id,
2557
- name: name2,
2918
+ name: name3,
2558
2919
  ...input.api ? { api: input.api } : {},
2559
2920
  headerNames: Object.keys(input.headers ?? {}),
2560
2921
  reasoning: input.reasoning === true,
@@ -2575,24 +2936,31 @@ var Store = class {
2575
2936
  credentials;
2576
2937
  agent;
2577
2938
  environments;
2939
+ settings;
2940
+ secrets;
2941
+ /** Seals secrets and model credentials under the master key (A52). */
2942
+ cipher;
2578
2943
  constructor(dataDir = defaultDataDir()) {
2579
2944
  const root = resolve(dataDir);
2580
- mkdirSync(root, { recursive: true, mode: 448 });
2581
- this.path = join2(root, DATABASE_FILENAME);
2945
+ mkdirSync2(root, { recursive: true, mode: 448 });
2946
+ this.path = join3(root, DATABASE_FILENAME);
2582
2947
  this.db = new DatabaseSync(this.path);
2583
- chmodSync(this.path, 384);
2948
+ chmodSync2(this.path, 384);
2584
2949
  this.db.exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;");
2950
+ this.cipher = new Cipher(() => masterKey(this.installationDeviceId()));
2585
2951
  try {
2586
- migrateDatabase(this.db);
2952
+ migrateDatabase(this.db, this.cipher);
2587
2953
  } catch (error) {
2588
2954
  this.db.close();
2589
2955
  throw error;
2590
2956
  }
2591
2957
  this.chats = new ChatRepository(this.db);
2592
2958
  this.runtime = new RuntimeRepository(this.db);
2593
- this.credentials = new CredentialRepository(this.db);
2959
+ this.credentials = new CredentialRepository(this.db, this.cipher);
2594
2960
  this.agent = new AgentRepository(this.db, () => this.userMemberId());
2595
2961
  this.environments = new EnvironmentRepository(this.db, () => this.userMemberId());
2962
+ this.settings = new SettingsRepository(this.db);
2963
+ this.secrets = new SecretRepository(this.db, this.cipher, () => this.userMemberId());
2596
2964
  this.agent.ensure();
2597
2965
  }
2598
2966
  close() {
@@ -2602,7 +2970,7 @@ var Store = class {
2602
2970
  installationDeviceId() {
2603
2971
  const row = this.db.prepare("SELECT device_id FROM installation WHERE id=1").get();
2604
2972
  if (row) return row.device_id;
2605
- const id = randomUUID8();
2973
+ const id = randomUUID9();
2606
2974
  this.db.prepare("INSERT INTO installation(id,device_id) VALUES(1,?)").run(id);
2607
2975
  return id;
2608
2976
  }
@@ -2617,14 +2985,14 @@ var Store = class {
2617
2985
  return this.db.prepare("SELECT username FROM account WHERE id=1").get()?.username ?? null;
2618
2986
  }
2619
2987
  initialize(username, password) {
2620
- const name2 = requireText(username, "Username", 64);
2988
+ const name3 = requireText(username, "Username", 64);
2621
2989
  if (typeof password !== "string" || password.length < 8 || password.length > 256) throw new ValidationError("Password must be 8-256 characters");
2622
- const salt = randomBytes(16).toString("hex");
2990
+ const salt = randomBytes3(16).toString("hex");
2623
2991
  const digest = passwordHash(password, salt);
2624
2992
  this.db.exec("BEGIN IMMEDIATE");
2625
2993
  try {
2626
2994
  if (this.isInitialized()) throw new ConflictError("Account is already initialized");
2627
- this.db.prepare("INSERT INTO account(id,username,password_salt,password_hash,member_id) VALUES(1,?,?,?,?)").run(name2, salt, digest, this.userMemberId());
2995
+ this.db.prepare("INSERT INTO account(id,username,password_salt,password_hash,member_id) VALUES(1,?,?,?,?)").run(name3, salt, digest, this.userMemberId());
2628
2996
  this.db.exec("COMMIT");
2629
2997
  } catch (error) {
2630
2998
  this.db.exec("ROLLBACK");
@@ -2637,7 +3005,7 @@ var Store = class {
2637
3005
  const candidate = Buffer.from(passwordHash(password, row.password_salt), "hex");
2638
3006
  const expected = Buffer.from(row.password_hash, "hex");
2639
3007
  if (!timingSafeEqual(candidate, expected) || username !== row.username) return null;
2640
- const token = randomBytes(32).toString("base64url");
3008
+ const token = randomBytes3(32).toString("base64url");
2641
3009
  this.db.prepare("DELETE FROM auth_sessions WHERE expires_at<?").run(Date.now());
2642
3010
  this.db.prepare("INSERT INTO auth_sessions VALUES (?,?)").run(tokenHash(token), Date.now() + SESSION_LIFETIME_MS);
2643
3011
  return token;
@@ -2650,8 +3018,8 @@ var Store = class {
2650
3018
  if (token) this.db.prepare("DELETE FROM auth_sessions WHERE token_hash=?").run(tokenHash(token));
2651
3019
  }
2652
3020
  botFromRow(row) {
2653
- const workspacePath = join2(dirname2(this.path), "bots", row.id, "workspace");
2654
- mkdirSync(join2(workspacePath, "temp"), { recursive: true, mode: 448 });
3021
+ const workspacePath = join3(dirname2(this.path), "bots", row.id, "workspace");
3022
+ mkdirSync2(join3(workspacePath, "temp"), { recursive: true, mode: 448 });
2655
3023
  return { ...row, workspacePath };
2656
3024
  }
2657
3025
  listBots() {
@@ -2663,9 +3031,9 @@ var Store = class {
2663
3031
  return row ? this.botFromRow(row) : null;
2664
3032
  }
2665
3033
  /** Member names are unique across people and Bots, ignoring case, so "@name" in a message is unambiguous (A37). */
2666
- nameTaken(name2, exceptBotId) {
3034
+ nameTaken(name3, exceptBotId) {
2667
3035
  return !!this.db.prepare(`SELECT 1 FROM account WHERE lower(username)=lower(:name)
2668
- UNION ALL SELECT 1 FROM bots WHERE lower(name)=lower(:name) AND id<>:except AND deleted_at IS NULL LIMIT 1`).get({ name: name2, except: exceptBotId ?? "" });
3036
+ UNION ALL SELECT 1 FROM bots WHERE lower(name)=lower(:name) AND id<>:except AND deleted_at IS NULL LIMIT 1`).get({ name: name3, except: exceptBotId ?? "" });
2669
3037
  }
2670
3038
  /** Deletes a Bot: its DMs (with anyone), channel memberships and states, queued deliveries,
2671
3039
  * notices and session row go; the member row stays, marked deleted, so messages it left in channels keep their
@@ -2679,6 +3047,7 @@ var Store = class {
2679
3047
  for (const table of ["chat_members", "chat_states", "inbox"]) this.db.prepare(`DELETE FROM ${table} WHERE member_id=?`).run(id);
2680
3048
  this.db.prepare("DELETE FROM bot_sessions WHERE bot_id=?").run(id);
2681
3049
  this.db.prepare("DELETE FROM environment_grants WHERE bot_id=?").run(id);
3050
+ this.db.prepare("DELETE FROM secret_grants WHERE bot_id=?").run(id);
2682
3051
  this.db.exec("COMMIT");
2683
3052
  } catch (error) {
2684
3053
  this.db.exec("ROLLBACK");
@@ -2687,15 +3056,15 @@ var Store = class {
2687
3056
  return true;
2688
3057
  }
2689
3058
  botName(value2, exceptBotId) {
2690
- const name2 = requireText(value2, "Bot name", 80);
2691
- if (name2.includes("@") || /[\n\r]/.test(name2)) throw new ValidationError("Bot name cannot contain @ or line breaks");
2692
- if (this.nameTaken(name2, exceptBotId)) throw new ConflictError("Another member already has this name", "name_taken");
2693
- return name2;
3059
+ const name3 = requireText(value2, "Bot name", 80);
3060
+ if (name3.includes("@") || /[\n\r]/.test(name3)) throw new ValidationError("Bot name cannot contain @ or line breaks");
3061
+ if (this.nameTaken(name3, exceptBotId)) throw new ConflictError("Another member already has this name", "name_taken");
3062
+ return name3;
2694
3063
  }
2695
3064
  /** The caller names the Bot (A45: the interface picks a free default in its own language). */
2696
- createBot(name2) {
2697
- const clean = this.botName(name2);
2698
- const id = randomUUID8(), createdAt = Date.now();
3065
+ createBot(name3) {
3066
+ const clean = this.botName(name3);
3067
+ const id = randomUUID9(), createdAt = Date.now();
2699
3068
  this.db.exec("BEGIN IMMEDIATE");
2700
3069
  try {
2701
3070
  this.db.prepare("INSERT INTO members VALUES(?,'bot')").run(id);
@@ -2709,10 +3078,10 @@ var Store = class {
2709
3078
  }
2710
3079
  updateBot(id, input) {
2711
3080
  if (!this.getBot(id)) return null;
2712
- const name2 = this.botName(input.name, id);
3081
+ const name3 = this.botName(input.name, id);
2713
3082
  const description = optionalText(input.description, "Description", 1e3);
2714
3083
  const systemPrompt = optionalText(input.systemPrompt, "Persona", 16e3);
2715
- if (!this.db.prepare("UPDATE bots SET name=?,description=?,system_prompt=? WHERE id=?").run(name2, description, systemPrompt, id).changes) return null;
3084
+ if (!this.db.prepare("UPDATE bots SET name=?,description=?,system_prompt=? WHERE id=?").run(name3, description, systemPrompt, id).changes) return null;
2716
3085
  return this.getBot(id);
2717
3086
  }
2718
3087
  validateSelection(id, input) {
@@ -2767,11 +3136,11 @@ var Store = class {
2767
3136
  }
2768
3137
  const key = input.apiKey === void 0 ? null : requireText(input.apiKey, "API Key", 8192);
2769
3138
  if (key && authType !== "api_key") throw new ValidationError("Invalid credential type");
2770
- const id = randomUUID8(), now = Date.now();
3139
+ const id = randomUUID9(), now = Date.now();
2771
3140
  this.db.exec("BEGIN IMMEDIATE");
2772
3141
  try {
2773
3142
  this.db.prepare("INSERT INTO provider_connections(id,provider_name,display_name,kind,auth_type,base_url,api,created_at,updated_at,owner_id) VALUES(?,?,?,?,?,?,?,?,?,?)").run(id, providerName, displayName, kind, authType, baseUrl, api, now, now, this.userMemberId());
2774
- if (key) this.db.prepare("INSERT INTO provider_credentials(connection_id,type,credential_json,updated_at) VALUES(?,'api_key',?,?)").run(id, JSON.stringify({ type: "api_key", key }), now);
3143
+ if (key) this.db.prepare("INSERT INTO provider_credentials(connection_id,type,credential_json,updated_at) VALUES(?,'api_key',?,?)").run(id, this.credentials.stored(id, { type: "api_key", key }), now);
2775
3144
  this.db.exec("COMMIT");
2776
3145
  } catch (error) {
2777
3146
  this.db.exec("ROLLBACK");
@@ -2779,8 +3148,8 @@ var Store = class {
2779
3148
  }
2780
3149
  return this.getModelConnection(id);
2781
3150
  }
2782
- checkCustomName(name2, except = "") {
2783
- if (this.db.prepare("SELECT 1 FROM provider_connections WHERE owner_id=? AND kind='custom' AND provider_name=? AND id<>?").get(this.userMemberId(), name2, except)) throw new ValidationError("A provider with this name already exists", "provider_name_taken");
3151
+ checkCustomName(name3, except = "") {
3152
+ if (this.db.prepare("SELECT 1 FROM provider_connections WHERE owner_id=? AND kind='custom' AND provider_name=? AND id<>?").get(this.userMemberId(), name3, except)) throw new ValidationError("A provider with this name already exists", "provider_name_taken");
2784
3153
  }
2785
3154
  updateModelConnection(id, input) {
2786
3155
  const current = this.getModelConnection(id);
@@ -2788,7 +3157,7 @@ var Store = class {
2788
3157
  const providerName = current.kind === "custom" && input.providerName !== void 0 ? requireText(input.providerName, "Provider name", 80).toLowerCase() : current.providerName;
2789
3158
  if (!/^[a-z0-9][a-z0-9._-]*$/.test(providerName)) throw new ValidationError("Invalid provider name");
2790
3159
  if (current.kind === "custom") this.checkCustomName(providerName, id);
2791
- const name2 = requireText(input.displayName, "Connection name", 100);
3160
+ const name3 = requireText(input.displayName, "Connection name", 100);
2792
3161
  const baseUrl = optionalText(input.baseUrl, "Base URL", 2048);
2793
3162
  if (baseUrl) {
2794
3163
  const url = new URL(baseUrl);
@@ -2796,7 +3165,7 @@ var Store = class {
2796
3165
  }
2797
3166
  const api = requireText(input.api, "API", 80);
2798
3167
  if (!MODEL_APIS.has(api)) throw new ValidationError("Unsupported API protocol");
2799
- this.db.prepare("UPDATE provider_connections SET display_name=?,base_url=?,api=?,updated_at=?,provider_name=? WHERE id=?").run(name2, baseUrl, api, Date.now(), providerName, id);
3168
+ this.db.prepare("UPDATE provider_connections SET display_name=?,base_url=?,api=?,updated_at=?,provider_name=? WHERE id=?").run(name3, baseUrl, api, Date.now(), providerName, id);
2800
3169
  return this.getModelConnection(id);
2801
3170
  }
2802
3171
  deleteModelConnection(id) {
@@ -2812,7 +3181,7 @@ var Store = class {
2812
3181
  else if (input.apiKey !== void 0) {
2813
3182
  const key = requireText(input.apiKey, "API Key", 8192);
2814
3183
  this.db.prepare(`INSERT INTO provider_credentials(connection_id,type,credential_json,updated_at) VALUES(?,'api_key',?,?)
2815
- ON CONFLICT(connection_id) DO UPDATE SET type='api_key',credential_json=excluded.credential_json,updated_at=excluded.updated_at`).run(id, JSON.stringify({ type: "api_key", key }), Date.now());
3184
+ ON CONFLICT(connection_id) DO UPDATE SET type='api_key',credential_json=excluded.credential_json,updated_at=excluded.updated_at`).run(id, this.credentials.stored(id, { type: "api_key", key }), Date.now());
2816
3185
  }
2817
3186
  return this.getModelConnection(id);
2818
3187
  }
@@ -2828,15 +3197,15 @@ var Store = class {
2828
3197
  const updates = raw.headerUpdates;
2829
3198
  if (updates !== void 0) {
2830
3199
  if (!updates || typeof updates !== "object" || Array.isArray(updates)) throw new ValidationError("Invalid headers");
2831
- for (const [name2, value2] of Object.entries(updates)) {
2832
- if (value2 === null) headers2.delete(name2);
2833
- else if (typeof value2 === "string") headers2.set(name2, value2);
3200
+ for (const [name3, value2] of Object.entries(updates)) {
3201
+ if (value2 === null) headers2.delete(name3);
3202
+ else if (typeof value2 === "string") headers2.set(name3, value2);
2834
3203
  else throw new ValidationError("Invalid header value");
2835
3204
  }
2836
3205
  }
2837
3206
  const literalHeaders = {};
2838
- headers2.forEach((value2, name2) => {
2839
- literalHeaders[name2] = value2;
3207
+ headers2.forEach((value2, name3) => {
3208
+ literalHeaders[name3] = value2;
2840
3209
  });
2841
3210
  const stored = { ...model, headers: literalHeaders };
2842
3211
  this.db.exec("BEGIN IMMEDIATE");
@@ -2869,6 +3238,186 @@ var Store = class {
2869
3238
  }
2870
3239
  };
2871
3240
 
3241
+ // src/server/infra/tls/certificates.ts
3242
+ import { X509Certificate as X509Certificate2 } from "node:crypto";
3243
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
3244
+ import { hostname, networkInterfaces as networkInterfaces2 } from "node:os";
3245
+ import { join as join4 } from "node:path";
3246
+
3247
+ // src/server/infra/tls/x509.ts
3248
+ import { createHash as createHash2, createPrivateKey, generateKeyPairSync, randomBytes as randomBytes4, sign, X509Certificate } from "node:crypto";
3249
+ import { isIPv4, isIPv6 } from "node:net";
3250
+ var tlv = (tag, content2) => {
3251
+ const n = content2.length;
3252
+ const length = n < 128 ? Buffer.from([n]) : (() => {
3253
+ const bytes = [];
3254
+ for (let v = n; v > 0; v >>= 8) bytes.unshift(v & 255);
3255
+ return Buffer.from([128 | bytes.length, ...bytes]);
3256
+ })();
3257
+ return Buffer.concat([Buffer.from([tag]), length, content2]);
3258
+ };
3259
+ var seq = (...items) => tlv(48, Buffer.concat(items));
3260
+ var set = (...items) => tlv(49, Buffer.concat(items));
3261
+ var integer2 = (bytes) => tlv(2, bytes[0] & 128 ? Buffer.concat([Buffer.from([0]), bytes]) : bytes);
3262
+ var bits = (bytes, unused = 0) => tlv(3, Buffer.concat([Buffer.from([unused]), bytes]));
3263
+ var octets = (bytes) => tlv(4, bytes);
3264
+ var utf8 = (text3) => tlv(12, Buffer.from(text3, "utf8"));
3265
+ var TRUE = tlv(1, Buffer.from([255]));
3266
+ function oid(text3) {
3267
+ const [a, b, ...rest] = text3.split(".").map(Number);
3268
+ const out = [a * 40 + b];
3269
+ for (const value2 of rest) {
3270
+ const chunk = [value2 & 127];
3271
+ for (let v = value2 >>> 7; v > 0; v >>>= 7) chunk.unshift(128 | v & 127);
3272
+ out.push(...chunk);
3273
+ }
3274
+ return tlv(6, Buffer.from(out));
3275
+ }
3276
+ function time(date) {
3277
+ const iso3 = date.toISOString().replace(/[-:T]/g, "").slice(0, 14) + "Z";
3278
+ return date.getUTCFullYear() < 2050 ? tlv(23, Buffer.from(iso3.slice(2))) : tlv(24, Buffer.from(iso3));
3279
+ }
3280
+ var ECDSA_SHA256 = seq(oid("1.2.840.10045.4.3.2"));
3281
+ var name = (common) => seq(set(seq(oid("2.5.4.10"), utf8("Jarvy"))), set(seq(oid("2.5.4.3"), utf8(common))));
3282
+ var extension = (id, value2, critical = false) => seq(oid(id), ...critical ? [TRUE] : [], octets(value2));
3283
+ var keyId = (key) => createHash2("sha1").update(key.export({ type: "spki", format: "der" })).digest();
3284
+ function ipBytes(address) {
3285
+ if (isIPv4(address)) return Buffer.from(address.split(".").map(Number));
3286
+ if (!isIPv6(address) || address.includes(".")) return null;
3287
+ const [head, tail] = address.split("::");
3288
+ const groups = (part) => part ? part.split(":") : [];
3289
+ const left = groups(head), right = groups(tail);
3290
+ const all = tail === void 0 ? left : [...left, ...Array(8 - left.length - right.length).fill("0"), ...right];
3291
+ return all.length === 8 ? Buffer.concat(all.map((group) => {
3292
+ const b = Buffer.alloc(2);
3293
+ b.writeUInt16BE(parseInt(group, 16));
3294
+ return b;
3295
+ })) : null;
3296
+ }
3297
+ var altNames = (names) => seq(...names.map((entry) => {
3298
+ const ip = ipBytes(entry);
3299
+ return ip ? tlv(135, ip) : tlv(130, Buffer.from(entry, "ascii"));
3300
+ }));
3301
+ function issue(subject, signer, fields) {
3302
+ const serial = randomBytes4(16);
3303
+ serial[0] &= 127;
3304
+ serial[0] |= 64;
3305
+ const notBefore = new Date(Date.now() - 24 * 36e5), notAfter = new Date(Date.now() + fields.days * 24 * 36e5);
3306
+ const tbs = seq(
3307
+ tlv(160, integer2(Buffer.from([2]))),
3308
+ integer2(serial),
3309
+ ECDSA_SHA256,
3310
+ name(fields.issuer),
3311
+ seq(time(notBefore), time(notAfter)),
3312
+ name(fields.subject),
3313
+ subject.export({ type: "spki", format: "der" }),
3314
+ tlv(163, seq(...fields.extensions))
3315
+ );
3316
+ const der = seq(tbs, ECDSA_SHA256, bits(sign("sha256", tbs, signer)));
3317
+ return `-----BEGIN CERTIFICATE-----
3318
+ ${der.toString("base64").replace(/.{64}/g, "$&\n").replace(/\n?$/, "\n")}-----END CERTIFICATE-----
3319
+ `;
3320
+ }
3321
+ var pair = () => generateKeyPairSync("ec", { namedCurve: "prime256v1" });
3322
+ var pem = (key) => key.export({ type: "pkcs8", format: "pem" });
3323
+ function makeRoot(label) {
3324
+ const { publicKey, privateKey } = pair(), subject = `Jarvy local root (${label})`;
3325
+ const certPem = issue(publicKey, privateKey, { subject, issuer: subject, days: 3650, extensions: [
3326
+ extension("2.5.29.19", seq(TRUE, integer2(Buffer.from([0]))), true),
3327
+ extension("2.5.29.15", bits(Buffer.from([6]), 1), true),
3328
+ extension("2.5.29.14", octets(keyId(publicKey)))
3329
+ ] });
3330
+ return { keyPem: pem(privateKey), certPem };
3331
+ }
3332
+ function makeServer(root, names) {
3333
+ const rootCert = new X509Certificate(root.certPem), rootKey = rootCert.publicKey;
3334
+ const { publicKey, privateKey } = pair();
3335
+ const issuer = rootCert.subject.split("\n").find((line) => line.startsWith("CN=")).slice(3);
3336
+ const certPem = issue(publicKey, createPrivateKey(root.keyPem), { subject: names[0], issuer, days: 397, extensions: [
3337
+ extension("2.5.29.19", seq(), true),
3338
+ extension("2.5.29.15", bits(Buffer.from([128]), 7), true),
3339
+ extension("2.5.29.37", seq(oid("1.3.6.1.5.5.7.3.1"))),
3340
+ extension("2.5.29.17", altNames(names)),
3341
+ extension("2.5.29.14", octets(keyId(publicKey))),
3342
+ extension("2.5.29.35", seq(tlv(128, keyId(rootKey))))
3343
+ ] });
3344
+ const check = new X509Certificate(certPem);
3345
+ if (!check.checkIssued(rootCert) || !check.verify(rootKey)) throw new Error("The server certificate does not verify against Jarvy's root");
3346
+ return { keyPem: pem(privateKey), certPem };
3347
+ }
3348
+
3349
+ // src/server/infra/tls/certificates.ts
3350
+ var RENEW_MS = 30 * 24 * 36e5;
3351
+ var isLoopbackHost = (host) => host === "localhost" || host === "::1" || /^127\./.test(host);
3352
+ var VIRTUAL = /^(docker|br-|veth|virbr|vmnet|vboxnet|vhs|lxc|lxd|cni|flannel|cali|tun|utun|meta|clash)/i;
3353
+ var TUNNEL = /^198\.1[89]\./;
3354
+ var rank = (name3, family) => (/^(en|eth|wl|bond)/.test(name3) ? 0 : 2) + (family === "IPv4" ? 0 : 1);
3355
+ var reachable = () => Object.entries(networkInterfaces2()).filter(([name3]) => !VIRTUAL.test(name3)).flatMap(([name3, list]) => (list ?? []).filter((item) => !item.internal && !TUNNEL.test(item.address) && (item.family === "IPv4" || !item.address.startsWith("fe80"))).map((item) => ({ address: item.address, order: rank(name3, item.family) }))).sort((a, b) => a.order - b.order).map((item) => item.address);
3356
+ function lanAddresses(host) {
3357
+ if (host !== "0.0.0.0" && host !== "::") return isLoopbackHost(host) ? [] : [host];
3358
+ return reachable();
3359
+ }
3360
+ function coveredNames(host) {
3361
+ const machine = hostname().toLowerCase(), label = /^[a-z0-9-]+$/.test(machine) ? [machine, `${machine}.local`] : [];
3362
+ return [.../* @__PURE__ */ new Set([...label, "localhost", "127.0.0.1", "::1", ...reachable(), ...host === "0.0.0.0" || host === "::" ? [] : [host]])];
3363
+ }
3364
+ var canonical = (entry) => ipBytes(entry)?.toString("hex") ?? entry.toLowerCase();
3365
+ function read2(dir, file) {
3366
+ const path = join4(dir, file);
3367
+ return existsSync2(path) ? readFileSync2(path, "utf8") : null;
3368
+ }
3369
+ function write(dir, file, text3, secret) {
3370
+ writeFileSync2(join4(dir, file), text3, { mode: secret ? 384 : 420 });
3371
+ }
3372
+ function readKey(dir, name3, cipher, log) {
3373
+ const plain2 = read2(dir, `${name3}.pem`);
3374
+ if (plain2) {
3375
+ writeKey(dir, name3, plain2, cipher);
3376
+ rmSync(join4(dir, `${name3}.pem`));
3377
+ return plain2;
3378
+ }
3379
+ const sealed = read2(dir, `${name3}.sealed`);
3380
+ if (!sealed) return null;
3381
+ const opened = cipher.open(sealed.trim(), `tls:${name3}`);
3382
+ if (opened === null) log(`[tls] ${name3} doesn't open with this installation's master key; making a new one`);
3383
+ return opened;
3384
+ }
3385
+ function writeKey(dir, name3, pem2, cipher) {
3386
+ write(dir, `${name3}.sealed`, `${cipher.seal(pem2, `tls:${name3}`)}
3387
+ `, true);
3388
+ }
3389
+ function certificates(dataDir, host, cipher, log = console.log) {
3390
+ const dir = join4(dataDir, "tls");
3391
+ mkdirSync3(dir, { recursive: true, mode: 448 });
3392
+ let root = null;
3393
+ const rootKey = readKey(dir, "root-key", cipher, log), rootCert = read2(dir, "root.pem");
3394
+ if (rootKey && rootCert && new X509Certificate2(rootCert).validToDate.getTime() - Date.now() > RENEW_MS) root = { keyPem: rootKey, certPem: rootCert };
3395
+ const names = coveredNames(host);
3396
+ let server = null;
3397
+ if (root) {
3398
+ const key = readKey(dir, "server-key", cipher, log), cert = read2(dir, "server.pem");
3399
+ if (key && cert) {
3400
+ const parsed = new X509Certificate2(cert);
3401
+ const covers = new Set((parsed.subjectAltName ?? "").split(", ").map((entry) => canonical(entry.replace(/^(DNS|IP Address):/, ""))));
3402
+ const fresh = parsed.validToDate.getTime() - Date.now() > RENEW_MS && parsed.checkIssued(new X509Certificate2(root.certPem));
3403
+ if (fresh && names.every((entry) => covers.has(canonical(entry)))) server = { keyPem: key, certPem: cert };
3404
+ }
3405
+ } else {
3406
+ root = makeRoot(hostname());
3407
+ writeKey(dir, "root-key", root.keyPem, cipher);
3408
+ write(dir, "root.pem", root.certPem, false);
3409
+ log("[tls] made a new root certificate; devices need to trust it once");
3410
+ }
3411
+ if (!server) {
3412
+ server = makeServer(root, names);
3413
+ writeKey(dir, "server-key", server.keyPem, cipher);
3414
+ write(dir, "server.pem", server.certPem, false);
3415
+ log(`[tls] signed a server certificate for ${names.join(", ")}`);
3416
+ }
3417
+ const parsedRoot = new X509Certificate2(root.certPem);
3418
+ return { key: server.keyPem, cert: server.certPem, rootDer: parsedRoot.raw, fingerprint: parsedRoot.fingerprint256, names };
3419
+ }
3420
+
2872
3421
  // src/server/service/account/service.ts
2873
3422
  var AccountService = class {
2874
3423
  /** `instance` tells apart Jarvy data directories served from the same host (see the session cookie). */
@@ -3019,16 +3568,16 @@ var ConnectionCredentials = class {
3019
3568
  }
3020
3569
  async list(options) {
3021
3570
  options?.signal?.throwIfAborted();
3022
- const row = this.repository.read(this.ownerId, this.connectionId);
3023
- return row ? [{ providerId: this.providerName, type: row.value.type }] : [];
3571
+ const row = this.repository.current(this.ownerId, this.connectionId);
3572
+ return row?.value ? [{ providerId: this.providerName, type: row.value.type }] : [];
3024
3573
  }
3025
3574
  async modify(providerId, fn, options) {
3026
3575
  if (providerId !== this.providerName) throw new RuntimeFault("credential_scope", "The credential belongs to another connection.");
3027
3576
  return this.coordinator.exclusive(this.connectionId, async () => {
3028
- const prior = this.repository.read(this.ownerId, this.connectionId);
3029
- const next = await fn(prior?.value);
3577
+ const prior = this.repository.current(this.ownerId, this.connectionId);
3578
+ const next = await fn(prior?.value ?? void 0);
3030
3579
  if (next) this.repository.replace(this.ownerId, this.connectionId, prior?.revision ?? null, credentialValue(next));
3031
- return next ?? prior?.value;
3580
+ return next ?? prior?.value ?? void 0;
3032
3581
  }, options?.signal);
3033
3582
  }
3034
3583
  async delete(providerId, options) {
@@ -3335,7 +3884,7 @@ var ModelService = class {
3335
3884
  };
3336
3885
 
3337
3886
  // src/server/infra/pi/auth.ts
3338
- import { randomUUID as randomUUID9 } from "node:crypto";
3887
+ import { randomUUID as randomUUID10 } from "node:crypto";
3339
3888
  import { ModelRuntime as ModelRuntime2 } from "@earendil-works/pi-coding-agent";
3340
3889
  var CredentialLogins = class {
3341
3890
  constructor(store, credentials, changed) {
@@ -3383,7 +3932,7 @@ var CredentialLogins = class {
3383
3932
  prompt(flow, prompt) {
3384
3933
  const signal = prompt.signal ? AbortSignal.any([flow.controller.signal, prompt.signal]) : flow.controller.signal;
3385
3934
  signal.throwIfAborted();
3386
- const id = randomUUID9();
3935
+ const id = randomUUID10();
3387
3936
  flow.state.prompt = {
3388
3937
  id,
3389
3938
  type: prompt.type,
@@ -3468,9 +4017,9 @@ var User = class extends Member {
3468
4017
  };
3469
4018
 
3470
4019
  // src/server/application.ts
3471
- import { createHash as createHash6 } from "node:crypto";
3472
- import { mkdir as mkdir5, realpath } from "node:fs/promises";
3473
- import { join as join9, resolve as resolve3 } from "node:path";
4020
+ import { createHash as createHash7 } from "node:crypto";
4021
+ import { mkdir as mkdir6, realpath } from "node:fs/promises";
4022
+ import { join as join12, resolve as resolve3 } from "node:path";
3474
4023
  import lockfile from "proper-lockfile";
3475
4024
 
3476
4025
  // src/server/service/chat/mentions.ts
@@ -3481,10 +4030,10 @@ function findMentions(source2, candidates2) {
3481
4030
  for (let at2 = lower.indexOf("@"); at2 >= 0; at2 = lower.indexOf("@", at2 + 1)) {
3482
4031
  if (at2 > 0 && WORD.test(text3[at2 - 1])) continue;
3483
4032
  const match = sorted.find((candidate) => {
3484
- const name2 = candidate.name.toLowerCase();
3485
- if (!name2 || !lower.startsWith(name2, at2 + 1)) return false;
3486
- const next = text3[at2 + 1 + name2.length];
3487
- return !(next && WORD.test(next) && WORD.test(name2.at(-1)));
4033
+ const name3 = candidate.name.toLowerCase();
4034
+ if (!name3 || !lower.startsWith(name3, at2 + 1)) return false;
4035
+ const next = text3[at2 + 1 + name3.length];
4036
+ return !(next && WORD.test(next) && WORD.test(name3.at(-1)));
3488
4037
  });
3489
4038
  if (match && !found.includes(match)) found.push(match);
3490
4039
  }
@@ -3524,7 +4073,7 @@ var POLICY = {
3524
4073
  onboard: { setup: "interrupt" },
3525
4074
  // What the agent Bot changed about a Bot rides along with that Bot's next batch, without waking it. The owner moving
3526
4075
  // a Bot to another work environment stops its running turn like an @, so it doesn't go on in the old place (A50); a
3527
- // Bot not running, and a change of access alone, hear of it with their next batch instead (fish m1012-m1015).
4076
+ // Bot not running, and a change of access alone, hear of it with their next batch instead.
3528
4077
  change: { agent: "digest", owner: "interrupt" }
3529
4078
  };
3530
4079
  function urgencyOf(kind, reason) {
@@ -3668,7 +4217,7 @@ var ChatMembers = class {
3668
4217
  };
3669
4218
 
3670
4219
  // src/server/service/chat/messages.ts
3671
- import { createHash as createHash2 } from "node:crypto";
4220
+ import { createHash as createHash3 } from "node:crypto";
3672
4221
  var ChatMessages = class {
3673
4222
  constructor(id, actor, repository) {
3674
4223
  this.id = id;
@@ -3676,13 +4225,13 @@ var ChatMessages = class {
3676
4225
  this.repository = repository;
3677
4226
  }
3678
4227
  async append(sender, message, deliveries, mentions = []) {
3679
- const payload = message.files === void 0 ? [this.id, message.content, message.attachmentIds] : [this.id, message.content, message.files.map((file) => [file.name, createHash2("sha256").update(file.data).digest("hex")])];
4228
+ const payload = message.files === void 0 ? [this.id, message.content, message.attachmentIds] : [this.id, message.content, message.files.map((file) => [file.name, createHash3("sha256").update(file.data).digest("hex")])];
3680
4229
  if (message.source) payload.push(message.source);
3681
4230
  if (message.question) payload.push(message.question);
3682
4231
  if (message.alsoToChannel) payload.push("alsoToChannel");
3683
4232
  if (message.replyTo) payload.push({ replyTo: message.replyTo });
3684
4233
  if (message.quotes?.length) payload.push({ quotes: message.quotes });
3685
- const hash = createHash2("sha256").update(JSON.stringify(payload)).digest("hex");
4234
+ const hash = createHash3("sha256").update(JSON.stringify(payload)).digest("hex");
3686
4235
  return this.repository.append(sender.id, this.id, message, hash, message.files, deliveries, mentions);
3687
4236
  }
3688
4237
  async read(id) {
@@ -3720,9 +4269,15 @@ function questionSpec(input) {
3720
4269
  ...input.detail === void 0 ? {} : { detail: input.detail.trim() },
3721
4270
  options: parsed,
3722
4271
  multiple: input.multiple === true,
3723
- allowCustom: input.allowCustom === true
4272
+ allowCustom: input.allowCustom === true,
4273
+ ...input.secret === void 0 ? {} : { secret: secretAsk(input.secret) }
3724
4274
  };
3725
4275
  }
4276
+ function secretAsk(value2) {
4277
+ const ask = value2;
4278
+ if (!ask || typeof ask !== "object" || typeof ask.name !== "string" || !/^[A-Z][A-Z0-9_]{0,63}$/.test(ask.name) || !text(ask.purpose, 300) || typeof ask.exists !== "boolean" || typeof ask.needsValue !== "boolean") throw new ChatError(400, "Invalid secret request");
4279
+ return { name: ask.name, purpose: ask.purpose.trim(), exists: ask.exists, needsValue: ask.needsValue };
4280
+ }
3726
4281
  function answerOf(question, input) {
3727
4282
  const selected = input.selected ?? [], custom = input.custom === void 0 || input.custom === null || input.custom === "" ? null : input.custom;
3728
4283
  if (!Array.isArray(selected) || new Set(selected).size !== selected.length || !selected.every((index) => Number.isInteger(index) && index >= 0 && index < question.options.length)) throw new ChatError(400, "Invalid selection");
@@ -3748,7 +4303,7 @@ var ChatQuestions = class {
3748
4303
  if (!question) return Promise.reject(new ChatError(409, "Not a question"));
3749
4304
  if (question.state === "answered") return Promise.resolve(resultOf(question, question.answer));
3750
4305
  if (question.state === "dismissed") return Promise.resolve({ status: "dismissed" });
3751
- if (question.state === "ignored") return Promise.reject(new ChatError(409, "The question has expired", "question_expired"));
4306
+ if (question.state === "ignored") return Promise.reject(new ChatError(409, "The question is no longer waiting for an answer", "question_expired"));
3752
4307
  return new Promise((resolve5, reject) => {
3753
4308
  const stop = () => {
3754
4309
  this.waiting.delete(message.id);
@@ -3766,6 +4321,10 @@ var ChatQuestions = class {
3766
4321
  });
3767
4322
  });
3768
4323
  }
4324
+ /** Whether the asking tool call still waits for this card. */
4325
+ waitingFor(messageId) {
4326
+ return this.waiting.has(messageId);
4327
+ }
3769
4328
  respond(actorId, chatId, messageId, input) {
3770
4329
  const message = this.repository.message(actorId, messageId);
3771
4330
  if (!message || message.chatId !== chatId || !message.question) throw new ChatError(404, "Question not found");
@@ -3775,7 +4334,7 @@ var ChatQuestions = class {
3775
4334
  if (question.state !== "pending") throw new ChatError(409, "The question is already settled", "question_closed");
3776
4335
  if (!waiter) {
3777
4336
  if (this.repository.settleQuestion(messageId, "ignored", null)) this.changed(chatId);
3778
- throw new ChatError(409, "The question has expired", "question_expired");
4337
+ throw new ChatError(409, "The question is no longer waiting for an answer", "question_expired");
3779
4338
  }
3780
4339
  const dismissed = input.action === "dismiss";
3781
4340
  if (!dismissed && input.action !== "answer") throw new ChatError(400, "Invalid question action");
@@ -3796,9 +4355,9 @@ function text2(value2, label, max, required = false) {
3796
4355
  }
3797
4356
  var optional = (value2, label, max) => value2 === void 0 ? void 0 : text2(value2, label, max);
3798
4357
  var nameOf = (value2, label) => {
3799
- const name2 = text2(value2, label, 80, true);
3800
- if (/[\n\r]/.test(name2)) throw new ChatError(400, `Invalid ${label}`);
3801
- return name2;
4358
+ const name3 = text2(value2, label, 80, true);
4359
+ if (/[\n\r]/.test(name3)) throw new ChatError(400, `Invalid ${label}`);
4360
+ return name3;
3802
4361
  };
3803
4362
  var ChatChannels = class {
3804
4363
  constructor(repository, saved, sectionsChanged, delivered = () => {
@@ -4330,7 +4889,7 @@ function activityPage(messages, history, limit) {
4330
4889
  }
4331
4890
 
4332
4891
  // src/server/service/bot/service.ts
4333
- import { dirname as dirname7, join as join8 } from "node:path";
4892
+ import { dirname as dirname8, join as join11 } from "node:path";
4334
4893
  import { rm as rm3 } from "node:fs/promises";
4335
4894
 
4336
4895
  // src/server/infra/pi/history.ts
@@ -4395,8 +4954,8 @@ var PiHistoryAdapter = class {
4395
4954
  if (m.role === "system") {
4396
4955
  if (m.sections) {
4397
4956
  const sections = {};
4398
- for (const [name2, text3] of Object.entries(m.sections)) {
4399
- sections[name2] = text3 === null ? null : message.content.length;
4957
+ for (const [name3, text3] of Object.entries(m.sections)) {
4958
+ sections[name3] = text3 === null ? null : message.content.length;
4400
4959
  if (text3 !== null) message.content.push({ type: "text", text: text3 });
4401
4960
  }
4402
4961
  pi.sections = sections;
@@ -4404,9 +4963,9 @@ var PiHistoryAdapter = class {
4404
4963
  if (m.toolsAdded) {
4405
4964
  const toolProtocol = {};
4406
4965
  message.tools = m.toolsAdded.map((tool, index) => {
4407
- const { name: name2, description, parameters, ...specific } = tool;
4966
+ const { name: name3, description, parameters, ...specific } = tool;
4408
4967
  if (Object.keys(specific).length) toolProtocol[index] = value(specific);
4409
- return { name: name2, description, parameters: value(parameters) };
4968
+ return { name: name3, description, parameters: value(parameters) };
4410
4969
  });
4411
4970
  if (Object.keys(toolProtocol).length) pi.tools = toolProtocol;
4412
4971
  }
@@ -4446,11 +5005,30 @@ var PiHistoryAdapter = class {
4446
5005
  };
4447
5006
 
4448
5007
  // src/server/infra/pi/session.ts
4449
- import { existsSync, readFileSync } from "node:fs";
5008
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
4450
5009
  import { mkdir as mkdir2 } from "node:fs/promises";
4451
- import { dirname as dirname3, join as join3, resolve as resolve2, sep } from "node:path";
5010
+ import { dirname as dirname3, join as join5, resolve as resolve2, sep } from "node:path";
4452
5011
  import { createAgentSession, createCodemodeExtension, DefaultResourceLoader, ModelRuntime as ModelRuntime3, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
4453
5012
 
5013
+ // src/server/infra/pi/context.ts
5014
+ var reported = (usage) => usage ? usage.totalTokens || (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0) : 0;
5015
+ function reportedContext(branch) {
5016
+ let stale = false;
5017
+ for (let i = branch.length - 1; i >= 0; i--) {
5018
+ const entry = branch[i];
5019
+ if (entry.type === "compaction") return { tokens: null, stale: false };
5020
+ if (entry.type === "context_edit") {
5021
+ stale = true;
5022
+ continue;
5023
+ }
5024
+ const message = entry.type === "message" ? entry.message : void 0;
5025
+ if (message?.role !== "assistant" || message.stopReason === "error" || message.stopReason === "aborted") continue;
5026
+ const tokens = reported(message.usage);
5027
+ if (tokens > 0) return { tokens, stale };
5028
+ }
5029
+ return { tokens: null, stale: false };
5030
+ }
5031
+
4454
5032
  // src/server/infra/pi/http.ts
4455
5033
  import { EventEmitter } from "node:events";
4456
5034
  import * as undici from "undici";
@@ -4545,6 +5123,77 @@ import { readFile as readLocal, rm } from "node:fs/promises";
4545
5123
  import { basename } from "node:path";
4546
5124
  import { createBashToolDefinition, createEditToolDefinition, createReadToolDefinition, createWriteToolDefinition } from "@earendil-works/pi-coding-agent";
4547
5125
 
5126
+ // src/server/shared/secret-mask.ts
5127
+ import { StringDecoder } from "node:string_decoder";
5128
+ var placeholder = (name3) => `{{secret:${name3}}}`;
5129
+ var MIN_MASKED = 4;
5130
+ var MIN_FRAGMENT = 8;
5131
+ var xml = (text3) => text3.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
5132
+ function base64Fragments(value2) {
5133
+ const bytes = Buffer.from(value2, "utf8"), found = [];
5134
+ for (const offset of [0, 1, 2]) {
5135
+ const encoded = Buffer.concat([Buffer.alloc(offset), bytes]).toString("base64");
5136
+ const fragment = encoded.slice(Math.ceil(offset * 8 / 6), Math.floor((offset + bytes.length) * 8 / 6));
5137
+ if (fragment.length >= MIN_FRAGMENT) found.push(fragment, fragment.replaceAll("+", "-").replaceAll("/", "_"));
5138
+ }
5139
+ return found;
5140
+ }
5141
+ function forms(value2) {
5142
+ return [value2, JSON.stringify(value2).slice(1, -1), encodeURIComponent(value2), xml(value2), ...base64Fragments(value2)];
5143
+ }
5144
+ var Masker = class {
5145
+ /** Longest form first, so a form inside another is not replaced first. */
5146
+ pairs;
5147
+ /** The longest form: streamed output holds back one less than this. */
5148
+ longest;
5149
+ constructor(secrets) {
5150
+ const pairs = /* @__PURE__ */ new Map();
5151
+ for (const { name: name3, value: value2 } of secrets) if (value2.length >= MIN_MASKED) {
5152
+ for (const form of forms(value2)) if (!pairs.has(form)) pairs.set(form, name3);
5153
+ }
5154
+ this.pairs = [...pairs].map(([form, name3]) => ({ form, name: name3 })).sort((a, b) => b.form.length - a.form.length);
5155
+ this.longest = this.pairs[0]?.form.length ?? 0;
5156
+ }
5157
+ get empty() {
5158
+ return this.pairs.length === 0;
5159
+ }
5160
+ mask(text3) {
5161
+ let result = text3;
5162
+ for (const { form, name: name3 } of this.pairs) if (result.includes(form)) result = result.split(form).join(placeholder(name3));
5163
+ return result;
5164
+ }
5165
+ /** Every string inside a JSON-like value. */
5166
+ maskDeep(value2) {
5167
+ if (this.empty) return value2;
5168
+ if (typeof value2 === "string") return this.mask(value2);
5169
+ if (Array.isArray(value2)) return value2.map((item) => this.maskDeep(item));
5170
+ if (value2 && typeof value2 === "object" && Object.getPrototypeOf(value2) === Object.prototype)
5171
+ return Object.fromEntries(Object.entries(value2).map(([key, item]) => [key, this.maskDeep(item)]));
5172
+ return value2;
5173
+ }
5174
+ };
5175
+ var StreamMask = class {
5176
+ constructor(masker, emit) {
5177
+ this.masker = masker;
5178
+ this.emit = emit;
5179
+ }
5180
+ pending = "";
5181
+ decoder = new StringDecoder("utf8");
5182
+ push(chunk) {
5183
+ this.pending = this.masker.mask(this.pending + this.decoder.write(chunk));
5184
+ const keep = Math.max(0, this.masker.longest - 1), cut = Math.max(0, this.pending.length - keep);
5185
+ if (cut > 0) {
5186
+ this.emit(Buffer.from(this.pending.slice(0, cut)));
5187
+ this.pending = this.pending.slice(cut);
5188
+ }
5189
+ }
5190
+ end() {
5191
+ const rest = this.masker.mask(this.pending + this.decoder.end());
5192
+ this.pending = "";
5193
+ if (rest) this.emit(Buffer.from(rest));
5194
+ }
5195
+ };
5196
+
4548
5197
  // src/server/infra/pi/image-kind.ts
4549
5198
  var IMAGE_SNIFF_BYTES = 4100;
4550
5199
  function imageKind(bytes) {
@@ -4575,16 +5224,16 @@ function isBmp(bytes) {
4575
5224
  if (size !== 0 && size < 26) return false;
4576
5225
  if (pixels < 14 + header) return false;
4577
5226
  if (size !== 0 && pixels >= size) return false;
4578
- let planes, bits;
5227
+ let planes, bits2;
4579
5228
  if (header === 12) {
4580
5229
  planes = u16le(bytes, 22);
4581
- bits = u16le(bytes, 24);
5230
+ bits2 = u16le(bytes, 24);
4582
5231
  } else if (header >= 40 && header <= 124) {
4583
5232
  if (bytes.length < 30) return false;
4584
5233
  planes = u16le(bytes, 26);
4585
- bits = u16le(bytes, 28);
5234
+ bits2 = u16le(bytes, 28);
4586
5235
  } else return false;
4587
- return planes === 1 && [1, 4, 8, 16, 24, 32].includes(bits);
5236
+ return planes === 1 && [1, 4, 8, 16, 24, 32].includes(bits2);
4588
5237
  }
4589
5238
  var at = (bytes, offset) => bytes[offset] ?? 0;
4590
5239
  var u16le = (bytes, offset) => at(bytes, offset) + (at(bytes, offset + 1) << 8);
@@ -4597,29 +5246,150 @@ function ascii(bytes, offset, text3) {
4597
5246
  return true;
4598
5247
  }
4599
5248
 
5249
+ // src/server/infra/pi/secret-placeholders.ts
5250
+ var PLACEHOLDER = /\{\{secret:([A-Za-z0-9_]{1,64})\}\}/y;
5251
+ var secretVariable = (name3) => `JARVY_SECRET_${name3}`;
5252
+ function rewritePlaceholders(command) {
5253
+ if (!command.includes("{{secret:")) return { command, names: [] };
5254
+ let out = "", i = 0, state = "plain";
5255
+ const names = /* @__PURE__ */ new Set(), heredocs = [];
5256
+ const word = (i2) => i2 === 0 || /[\s;&|()<>]/.test(command[i2 - 1]);
5257
+ while (i < command.length) {
5258
+ PLACEHOLDER.lastIndex = i;
5259
+ const match = PLACEHOLDER.exec(command);
5260
+ if (match) {
5261
+ const name3 = match[1], variable = `\${${secretVariable(name3)}}`;
5262
+ if (!/^[A-Z][A-Z0-9_]*$/.test(name3)) return { error: `"${name3}" isn't a secret name: names are capital letters, digits and underscores, starting with a letter` };
5263
+ names.add(name3);
5264
+ out += state === "plain" ? `"${variable}"` : state === "double" ? variable : state === "single" ? `'"${variable}"'` : `'"${variable}"$'`;
5265
+ i += match[0].length;
5266
+ continue;
5267
+ }
5268
+ const char = command[i];
5269
+ if (state === "single") {
5270
+ out += char;
5271
+ i++;
5272
+ if (char === "'") state = "plain";
5273
+ continue;
5274
+ }
5275
+ if (state === "ansi") {
5276
+ if (char === "\\" && i + 1 < command.length) {
5277
+ out += command.slice(i, i + 2);
5278
+ i += 2;
5279
+ continue;
5280
+ }
5281
+ out += char;
5282
+ i++;
5283
+ if (char === "'") state = "plain";
5284
+ continue;
5285
+ }
5286
+ if (char === "\\" && i + 1 < command.length) {
5287
+ out += command.slice(i, i + 2);
5288
+ i += 2;
5289
+ continue;
5290
+ }
5291
+ if (state === "double") {
5292
+ out += char;
5293
+ i++;
5294
+ if (char === '"') state = "plain";
5295
+ continue;
5296
+ }
5297
+ if (char === "'") {
5298
+ state = "single";
5299
+ out += char;
5300
+ i++;
5301
+ continue;
5302
+ }
5303
+ if (char === '"') {
5304
+ state = "double";
5305
+ out += char;
5306
+ i++;
5307
+ continue;
5308
+ }
5309
+ if (char === "$" && command[i + 1] === "'") {
5310
+ state = "ansi";
5311
+ out += "$'";
5312
+ i += 2;
5313
+ continue;
5314
+ }
5315
+ if (char === "#" && word(i)) {
5316
+ const end = command.indexOf("\n", i);
5317
+ const stop = end < 0 ? command.length : end;
5318
+ out += command.slice(i, stop);
5319
+ i = stop;
5320
+ continue;
5321
+ }
5322
+ if (char === "<" && command.startsWith("<<", i) && command[i + 2] !== "<") {
5323
+ const found = /^<<(-?)[ \t]*((?:'[^'\n]*'|"[^"\n]*"|\\?[^\s;&|()<>'"]+)+)/.exec(command.slice(i));
5324
+ if (found) {
5325
+ const raw = found[2];
5326
+ heredocs.push({ delimiter: raw.replace(/['"\\]/g, ""), quoted: /['"\\]/.test(raw), tabs: found[1] === "-" });
5327
+ out += found[0];
5328
+ i += found[0].length;
5329
+ continue;
5330
+ }
5331
+ }
5332
+ if (char === "\n" && heredocs.length) {
5333
+ out += char;
5334
+ i++;
5335
+ for (const doc of heredocs.splice(0)) {
5336
+ while (i < command.length) {
5337
+ const end = command.indexOf("\n", i), stop = end < 0 ? command.length : end;
5338
+ const line = command.slice(i, stop), next = end < 0 ? command.length : end + 1;
5339
+ if ((doc.tabs ? line.replace(/^\t+/, "") : line) === doc.delimiter) {
5340
+ out += command.slice(i, next);
5341
+ i = next;
5342
+ break;
5343
+ }
5344
+ if (doc.quoted && /\{\{secret:[A-Za-z0-9_]{1,64}\}\}/.test(line)) return { error: `A placeholder in a quoted heredoc (<<'${doc.delimiter}') stays literal. Use an unquoted heredoc, or pass the value another way. To write the placeholder itself into a file, use write or edit, which keep it as written.` };
5345
+ out += line.replace(/\{\{secret:([A-Za-z0-9_]{1,64})\}\}/g, (_, name3) => {
5346
+ names.add(name3);
5347
+ return `\${${secretVariable(name3)}}`;
5348
+ }) + command.slice(stop, next);
5349
+ i = next;
5350
+ }
5351
+ }
5352
+ continue;
5353
+ }
5354
+ out += char;
5355
+ i++;
5356
+ }
5357
+ return { command: out, names: [...names] };
5358
+ }
5359
+
4600
5360
  // src/server/infra/pi/environment-tools.ts
4601
- function environmentTools(lookup) {
5361
+ function environmentTools(lookup, secrets) {
4602
5362
  const pinned = new AsyncLocalStorage();
4603
- const current = () => pinned.getStore() ?? lookup();
5363
+ const current = () => pinned.getStore()?.env ?? lookup();
4604
5364
  const access = async (path) => {
4605
5365
  if (!await current().exists(path)) throw Object.assign(new Error(`ENOENT: no such file or directory, access '${path}'`), { code: "ENOENT" });
4606
5366
  };
4607
- const readFile3 = (path) => current().readFile(path), writeFile4 = (path, content2) => current().writeFile(path, content2);
5367
+ const readFile3 = (path) => current().readFile(path), writeFile5 = (path, content2) => current().writeFile(path, content2);
4608
5368
  const cwd = current().cwd;
4609
5369
  const definitions2 = [
4610
5370
  relocated(createBashToolDefinition(cwd, { operations: {
4611
- exec: (command, dir, { onData, signal, timeout, env }) => current().exec(command, {
4612
- cwd: dir,
4613
- onData,
4614
- ...signal ? { signal } : {},
4615
- ...timeout ? { timeout } : {},
4616
- ...env ? { env: defined(env) } : {}
4617
- })
5371
+ exec: async (command, dir, { onData, signal, timeout, env }) => {
5372
+ const where2 = current(), prepared = withSecrets(command, secrets);
5373
+ if (prepared.names.length) secrets.record(prepared.names, { environmentId: where2.id, toolCallId: pinned.getStore()?.toolCallId ?? null, detail: command });
5374
+ const masker = secrets?.masker(), stream = masker && !masker.empty ? new StreamMask(masker, onData) : null;
5375
+ try {
5376
+ return await where2.exec(prepared.command, {
5377
+ cwd: dir,
5378
+ onData: stream ? (chunk) => stream.push(chunk) : onData,
5379
+ ...signal ? { signal } : {},
5380
+ ...timeout ? { timeout } : {},
5381
+ ...env ? { env: defined(env) } : {},
5382
+ ...prepared.values ? { secrets: prepared.values } : {}
5383
+ });
5384
+ } finally {
5385
+ stream?.end();
5386
+ }
5387
+ }
4618
5388
  } }), current),
4619
5389
  // Pictures go to the model as pictures; the kind comes from a file's first bytes, as Pi's local read does.
4620
5390
  createReadToolDefinition(cwd, { operations: { readFile: readFile3, access, detectImageMimeType: async (path) => imageKind(await current().head(path, IMAGE_SNIFF_BYTES)) } }),
4621
- createWriteToolDefinition(cwd, { operations: { writeFile: writeFile4, mkdir: (path) => current().mkdir(path) } }),
4622
- createEditToolDefinition(cwd, { operations: { readFile: readFile3, writeFile: writeFile4, access } })
5391
+ createWriteToolDefinition(cwd, { operations: { writeFile: writeFile5, mkdir: (path) => current().mkdir(path) } }),
5392
+ createEditToolDefinition(cwd, { operations: { readFile: readFile3, writeFile: writeFile5, access } })
4623
5393
  ];
4624
5394
  return definitions2.map((definition) => inside(definition, lookup, pinned));
4625
5395
  }
@@ -4628,9 +5398,17 @@ function inside(definition, lookup, pinned) {
4628
5398
  return { ...definition, execute: ((...args) => {
4629
5399
  const env = lookup(), ctx = args[4];
4630
5400
  if (ctx && typeof ctx === "object") args[4] = new Proxy(ctx, { get: (target2, key) => key === "cwd" ? env.cwd : Reflect.get(target2, key, target2) });
4631
- return pinned.run(env, () => execute(...args));
5401
+ return pinned.run({ env, toolCallId: typeof args[0] === "string" ? args[0] : null }, () => execute(...args));
4632
5402
  }) };
4633
5403
  }
5404
+ function withSecrets(command, secrets) {
5405
+ const rewritten = rewritePlaceholders(command);
5406
+ if ("error" in rewritten) throw new Error(rewritten.error);
5407
+ if (!rewritten.names.length) return { command, names: [] };
5408
+ const resolved = secrets?.resolve(rewritten.names) ?? { refused: rewritten.names };
5409
+ if ("refused" in resolved) throw new Error(`You have no secret named ${resolved.refused.join(", ")}. secrets.list in the chat gateway shows the ones you may use. If you need another, use secrets.request; never ask for a value in the chat.`);
5410
+ return { command: rewritten.command, names: rewritten.names, values: Object.fromEntries(Object.entries(resolved.values).map(([name3, value2]) => [secretVariable(name3), value2])) };
5411
+ }
4634
5412
  function relocated(definition, current) {
4635
5413
  const execute = definition.execute.bind(definition);
4636
5414
  return { ...definition, execute: (async (...args) => {
@@ -4690,7 +5468,9 @@ var PiSession = class _PiSession {
4690
5468
  translator = new LiveTranslator();
4691
5469
  get context() {
4692
5470
  const usage = this.session.getContextUsage();
4693
- return usage ? { tokens: usage.tokens, contextWindow: usage.contextWindow, percent: usage.percent } : void 0;
5471
+ if (!usage) return void 0;
5472
+ const { tokens, stale } = reportedContext(this.session.sessionManager.getBranch());
5473
+ return { tokens, contextWindow: usage.contextWindow, percent: tokens === null ? null : tokens / usage.contextWindow * 100, stale };
4694
5474
  }
4695
5475
  get thinkingLevel() {
4696
5476
  return this.session.thinkingLevel;
@@ -4703,18 +5483,18 @@ var PiSession = class _PiSession {
4703
5483
  }
4704
5484
  static async create(options) {
4705
5485
  const { bot, store, reference, selection } = options;
4706
- const agentDir = join3(dirname3(bot.workspacePath), "agent", "pi"), sessionsDir = join3(agentDir, "sessions");
5486
+ const agentDir = join5(dirname3(bot.workspacePath), "agent", "pi"), sessionsDir = join5(agentDir, "sessions");
4707
5487
  await mkdir2(sessionsDir, { recursive: true, mode: 448 });
4708
5488
  const expected = resolve2(sessionsDir) + sep;
4709
5489
  if (!resolve2(reference.file).startsWith(expected) || dirname3(resolve2(reference.file)) !== resolve2(sessionsDir))
4710
5490
  throw new RuntimeFault("session_path", "The session file path isn't valid.");
4711
- const exists = existsSync(reference.file);
5491
+ const exists = existsSync3(reference.file);
4712
5492
  if (!exists && (reference.persisted || store.runtime.runs(bot.id).some((run) => run.state === "unknown")))
4713
5493
  throw new RuntimeFault("session_missing", "The session file is missing; check the run records before recovering.");
4714
5494
  if (exists) {
4715
5495
  let header;
4716
5496
  try {
4717
- header = JSON.parse(readFileSync(reference.file, "utf8").split("\n")[0]);
5497
+ header = JSON.parse(readFileSync3(reference.file, "utf8").split("\n")[0]);
4718
5498
  } catch {
4719
5499
  throw new RuntimeFault("session_corrupt", "The session file can't be read.");
4720
5500
  }
@@ -4756,7 +5536,7 @@ var PiSession = class _PiSession {
4756
5536
  parameters: Type.Unsafe(tool.parameters),
4757
5537
  executionMode: "sequential",
4758
5538
  execute: async (toolCallId, args, signal) => {
4759
- const output = await tool.execute(args, { toolCallId, signal });
5539
+ const output = await tool.execute(options.secrets ? options.secrets.masker().maskDeep(args) : args, { toolCallId, signal });
4760
5540
  if (isToolMedia(output)) return { content: [
4761
5541
  { type: "text", text: output.text },
4762
5542
  ...output.images.map((image) => ({ type: "image", data: image.data, mimeType: image.mimeType }))
@@ -4765,6 +5545,20 @@ var PiSession = class _PiSession {
4765
5545
  }
4766
5546
  });
4767
5547
  api.on("before_agent_start", () => ({ systemPrompt: options.systemPrompt() }));
5548
+ api.on("tool_result", (event) => {
5549
+ const masker = options.secrets?.masker();
5550
+ if (!masker || masker.empty) return void 0;
5551
+ let changed = false;
5552
+ const content2 = event.content.map((part) => {
5553
+ if (part.type !== "text") return part;
5554
+ const text3 = masker.mask(part.text);
5555
+ if (text3 === part.text) return part;
5556
+ changed = true;
5557
+ return { ...part, text: text3 };
5558
+ });
5559
+ if (!changed) return void 0;
5560
+ return { content: content2, ...event.structuredContent !== void 0 ? { structuredContent: masker.maskDeep(event.structuredContent) } : {} };
5561
+ });
4768
5562
  }]
4769
5563
  });
4770
5564
  await resourceLoader.reload();
@@ -4778,11 +5572,11 @@ var PiSession = class _PiSession {
4778
5572
  model,
4779
5573
  thinkingLevel: resolveThinking(model, selection.thinkingLevel),
4780
5574
  tools,
4781
- customTools: environmentTools(options.environment)
5575
+ customTools: environmentTools(options.environment, options.secrets)
4782
5576
  });
4783
5577
  adapter.session = result.session;
4784
5578
  adapter.settings = settingsManager;
4785
- if (result.extensionsResult.errors.length || !tools.every((name2) => adapter.session.getActiveToolNames().includes(name2))) {
5579
+ if (result.extensionsResult.errors.length || !tools.every((name3) => adapter.session.getActiveToolNames().includes(name3))) {
4786
5580
  adapter.session.dispose();
4787
5581
  throw new RuntimeFault("tools_missing", "Couldn't load the run's tools.");
4788
5582
  }
@@ -4801,10 +5595,14 @@ var PiSession = class _PiSession {
4801
5595
  }
4802
5596
  });
4803
5597
  }
4804
- store.runtime.recordSession(bot.id, adapter.sessionId, existsSync(adapter.sessionFile));
5598
+ store.runtime.recordSession(bot.id, adapter.sessionId, existsSync3(adapter.sessionFile));
4805
5599
  adapter.signature = JSON.stringify([{ connectionId: selection.connectionId, modelId: selection.modelId }, connection.providerName, connection.api, connection.baseUrl, configured2, store.modelHeaders(connection.id, configured2.id)]);
4806
5600
  return adapter;
4807
5601
  }
5602
+ /** A batch never carries a secret's value, even one a person pasted into the chat. */
5603
+ masked(xml2) {
5604
+ return this.options.secrets?.masker().mask(xml2) ?? xml2;
5605
+ }
4808
5606
  onEvent(event) {
4809
5607
  if (event.type === "agent_start") {
4810
5608
  this.started = true;
@@ -4838,7 +5636,8 @@ var PiSession = class _PiSession {
4838
5636
  }
4839
5637
  );
4840
5638
  if (event.type === "message_end" && event.message.role === "assistant") this.result = this.classify(event.message);
4841
- if (this.options.live) for (const live of this.translator.translate(event)) this.options.live(live);
5639
+ if (this.options.live) for (const live of this.translator.translate(event))
5640
+ this.options.live(live.type === "tool_end" && this.options.secrets ? { ...live, output: this.options.secrets.masker().mask(live.output) } : live);
4842
5641
  this.maybeAbort();
4843
5642
  }
4844
5643
  /** The last assistant message decides the prompt (a later retry success replaces an earlier error).
@@ -4861,9 +5660,9 @@ var PiSession = class _PiSession {
4861
5660
  }
4862
5661
  /** A notice rides along with the running turn. Pi keeps queued steering messages across an abort, so a
4863
5662
  * notice steered just before a new message interrupts the turn is delivered at the start of the next one. */
4864
- steer(xml) {
5663
+ steer(xml2) {
4865
5664
  if (!this.promptActive || this.session.isIdle) return false;
4866
- void this.session.steer(xml, void 0, { source: "extension" }).catch((error) => {
5665
+ void this.session.steer(this.masked(xml2), void 0, { source: "extension" }).catch((error) => {
4867
5666
  console.error("[pi] steer failed", { botId: this.options.bot.id, error: error instanceof Error ? error.message : String(error) });
4868
5667
  });
4869
5668
  return true;
@@ -4872,7 +5671,7 @@ var PiSession = class _PiSession {
4872
5671
  this.wantsAbort = true;
4873
5672
  this.maybeAbort();
4874
5673
  }
4875
- async prompt(xml) {
5674
+ async prompt(xml2) {
4876
5675
  this.promptActive = true;
4877
5676
  this.started = false;
4878
5677
  this.settled = false;
@@ -4886,13 +5685,13 @@ var PiSession = class _PiSession {
4886
5685
  let outcome = "failed";
4887
5686
  try {
4888
5687
  try {
4889
- await this.session.prompt(xml, { expandPromptTemplates: false });
5688
+ await this.session.prompt(this.masked(xml2), { expandPromptTemplates: false });
4890
5689
  } finally {
4891
5690
  await this.session.waitForIdle();
4892
5691
  if (this.started && !this.settled) await settled;
4893
5692
  this.promptActive = false;
4894
5693
  await this.abortTask;
4895
- this.options.store.runtime.recordSession(this.options.bot.id, this.sessionId, existsSync(this.sessionFile));
5694
+ this.options.store.runtime.recordSession(this.options.bot.id, this.sessionId, existsSync3(this.sessionFile));
4896
5695
  }
4897
5696
  if (this.abortFailure) throw this.abortFailure;
4898
5697
  outcome = this.result.outcome;
@@ -4937,11 +5736,11 @@ var PiSession = class _PiSession {
4937
5736
 
4938
5737
  // src/server/infra/env/docker.ts
4939
5738
  import { execFile, spawn } from "node:child_process";
4940
- import { createHash as createHash3 } from "node:crypto";
5739
+ import { createHash as createHash4 } from "node:crypto";
4941
5740
  import { createWriteStream } from "node:fs";
4942
5741
  import { mkdir as mkdir3, mkdtemp, readFile as readFile2, rename as rename2, rm as rm2, writeFile as writeFile2 } from "node:fs/promises";
4943
5742
  import { tmpdir } from "node:os";
4944
- import { join as join4 } from "node:path";
5743
+ import { join as join6 } from "node:path";
4945
5744
  import { Connection, RemoteExecutionEnv, packagedDaemon } from "@earendil-works/pi-env";
4946
5745
 
4947
5746
  // src/server/infra/env/computer-image.ts
@@ -4975,7 +5774,7 @@ function overDurable(id, kind, env, options) {
4975
5774
  async exec(command, exec) {
4976
5775
  const result = await env.exec(command, {
4977
5776
  cwd: exec.cwd,
4978
- ...options.passEnv && exec.env ? { env: exec.env, inheritEnv: false } : { inheritEnv: true },
5777
+ ...options.passEnv && exec.env ? { env: { ...exec.env, ...exec.secrets }, inheritEnv: false } : { ...exec.secrets ? { env: exec.secrets } : {}, inheritEnv: true },
4979
5778
  ...exec.timeout ? { timeout: exec.timeout } : {},
4980
5779
  onOutput: (text3) => exec.onData(Buffer.from(text3))
4981
5780
  }, context(exec.signal));
@@ -5047,7 +5846,7 @@ async function dockerAvailable() {
5047
5846
  }
5048
5847
  }
5049
5848
  function computerImage(uid, gid) {
5050
- return `jarvy-computer:${createHash3("sha256").update(`${COMPUTER_DOCKERFILE}
5849
+ return `jarvy-computer:${createHash4("sha256").update(`${COMPUTER_DOCKERFILE}
5051
5850
  ${uid}:${gid}`).digest("hex").slice(0, 12)}`;
5052
5851
  }
5053
5852
  async function pruneImages(keep) {
@@ -5071,9 +5870,9 @@ async function imageExists(tag) {
5071
5870
  }
5072
5871
  }
5073
5872
  async function buildImage(tag, uid, gid, logFile) {
5074
- const dir = await mkdtemp(join4(tmpdir(), "jarvy-computer-"));
5873
+ const dir = await mkdtemp(join6(tmpdir(), "jarvy-computer-"));
5075
5874
  try {
5076
- await writeFile2(join4(dir, "Dockerfile"), COMPUTER_DOCKERFILE);
5875
+ await writeFile2(join6(dir, "Dockerfile"), COMPUTER_DOCKERFILE);
5077
5876
  const log = createWriteStream(logFile, { flags: "w", mode: 384 });
5078
5877
  const code = await new Promise((resolve5, reject) => {
5079
5878
  const child = spawn(
@@ -5095,14 +5894,14 @@ async function containerStates(label) {
5095
5894
  const out = await docker(["ps", "-a", "--filter", `label=${label}`, "--format", "{{.Names}} {{.State}}"]);
5096
5895
  const states = /* @__PURE__ */ new Map();
5097
5896
  for (const line of out.split("\n").filter(Boolean)) {
5098
- const [name2, state] = line.split(" ");
5099
- if (name2) states.set(name2, state === "running" ? "running" : "stopped");
5897
+ const [name3, state] = line.split(" ");
5898
+ if (name3) states.set(name3, state === "running" ? "running" : "stopped");
5100
5899
  }
5101
5900
  return states;
5102
5901
  }
5103
- async function containerState(name2) {
5902
+ async function containerState(name3) {
5104
5903
  try {
5105
- return await docker(["inspect", "--format", "{{.State.Running}}", name2]) === "true" ? "running" : "stopped";
5904
+ return await docker(["inspect", "--format", "{{.State.Running}}", name3]) === "true" ? "running" : "stopped";
5106
5905
  } catch (error) {
5107
5906
  if (/no such (object|container)/i.test(error.message)) return "missing";
5108
5907
  throw error;
@@ -5131,19 +5930,19 @@ async function runContainer(spec) {
5131
5930
  "infinity"
5132
5931
  ], 12e4);
5133
5932
  }
5134
- async function startContainer(name2) {
5135
- await docker(["start", name2], 6e4);
5933
+ async function startContainer(name3) {
5934
+ await docker(["start", name3], 6e4);
5136
5935
  }
5137
- async function homeAtWorkspace(name2) {
5138
- const home = await docker(["exec", name2, "sh", "-c", "getent passwd bot | cut -d: -f6"], 3e4);
5139
- if (home !== COMPUTER_CWD) await docker(["exec", "-u", "root", name2, "usermod", "-d", COMPUTER_CWD, "bot"], 3e4);
5936
+ async function homeAtWorkspace(name3) {
5937
+ const home = await docker(["exec", name3, "sh", "-c", "getent passwd bot | cut -d: -f6"], 3e4);
5938
+ if (home !== COMPUTER_CWD) await docker(["exec", "-u", "root", name3, "usermod", "-d", COMPUTER_CWD, "bot"], 3e4);
5140
5939
  }
5141
- async function stopContainer(name2) {
5142
- await docker(["stop", "--time", "5", name2], 6e4);
5940
+ async function stopContainer(name3) {
5941
+ await docker(["stop", "--time", "5", name3], 6e4);
5143
5942
  }
5144
- async function removeContainer(name2) {
5943
+ async function removeContainer(name3) {
5145
5944
  try {
5146
- await docker(["rm", "--force", name2], 6e4);
5945
+ await docker(["rm", "--force", name3], 6e4);
5147
5946
  } catch (error) {
5148
5947
  if (!/no such (object|container)/i.test(error.message)) throw error;
5149
5948
  }
@@ -5151,7 +5950,7 @@ async function removeContainer(name2) {
5151
5950
  async function syncDaemon(dir) {
5152
5951
  const arch = process.arch === "arm64" ? "arm64" : "x64";
5153
5952
  const wanted = await readFile2(packagedDaemon({ platform: "linux", arch }));
5154
- const target2 = join4(dir, "pi-env");
5953
+ const target2 = join6(dir, "pi-env");
5155
5954
  const current = await readFile2(target2).catch(() => null);
5156
5955
  if (current?.equals(wanted)) return;
5157
5956
  await mkdir3(dir, { recursive: true, mode: 448 });
@@ -5213,9 +6012,9 @@ var BotEnvironments = class {
5213
6012
  }
5214
6013
  /** A Bot's environments as other Bots see them in bots.list. */
5215
6014
  summary(botId) {
5216
- const name2 = (record3) => record3.kind === "host" ? "owner's computer" : record3.botId === botId ? "own sandbox" : "another Bot's sandbox";
6015
+ const name3 = (record3) => record3.kind === "host" ? "owner's computer" : record3.botId === botId ? "own sandbox" : "another Bot's sandbox";
5217
6016
  const { home, current } = this.store.environments.placement(botId);
5218
- return { default: name2(home), current: name2(current), usable: this.usable(botId).map(name2) };
6017
+ return { default: name3(home), current: name3(current), usable: this.usable(botId).map(name3) };
5219
6018
  }
5220
6019
  /** The Bot's default environment, which its instructions describe. */
5221
6020
  home(botId, workspacePath) {
@@ -5314,14 +6113,14 @@ var BotEnvironments = class {
5314
6113
  };
5315
6114
 
5316
6115
  // src/server/service/bot/computers.ts
5317
- import { createHash as createHash4 } from "node:crypto";
5318
- import { dirname as dirname4, join as join5 } from "node:path";
6116
+ import { createHash as createHash5 } from "node:crypto";
6117
+ import { dirname as dirname4, join as join7 } from "node:path";
5319
6118
  var SWEEP_MS = 6e4;
5320
6119
  var Computers = class {
5321
6120
  constructor(deps) {
5322
6121
  this.deps = deps;
5323
6122
  this.dataDir = dirname4(deps.store.path);
5324
- this.tag = createHash4("sha256").update(this.dataDir).digest("hex").slice(0, 12);
6123
+ this.tag = createHash5("sha256").update(this.dataDir).digest("hex").slice(0, 12);
5325
6124
  }
5326
6125
  docker = null;
5327
6126
  probing = null;
@@ -5346,7 +6145,7 @@ var Computers = class {
5346
6145
  this.docker = await dockerAvailable();
5347
6146
  if (!this.docker) return;
5348
6147
  try {
5349
- await syncDaemon(join5(this.dataDir, "computers", "bin"));
6148
+ await syncDaemon(join7(this.dataDir, "computers", "bin"));
5350
6149
  } catch (error) {
5351
6150
  this.docker = false;
5352
6151
  this.log(`computers disabled: ${error.message}`);
@@ -5432,8 +6231,8 @@ var Computers = class {
5432
6231
  if (state === "missing") await runContainer({
5433
6232
  name: container,
5434
6233
  image,
5435
- workspace: join5(this.dataDir, "bots", botId, "workspace"),
5436
- daemonDir: join5(this.dataDir, "computers", "bin"),
6234
+ workspace: join7(this.dataDir, "bots", botId, "workspace"),
6235
+ daemonDir: join7(this.dataDir, "computers", "bin"),
5437
6236
  uid: this.uid,
5438
6237
  gid: this.gid,
5439
6238
  labels: { "jarvy.data": this.tag, "jarvy.bot": botId }
@@ -5492,7 +6291,7 @@ var Computers = class {
5492
6291
  const tag = computerImage(this.uid, this.gid);
5493
6292
  if (!await imageExists(tag)) {
5494
6293
  this.log(`building ${tag}`);
5495
- await buildImage(tag, this.uid, this.gid, join5(this.dataDir, "computers", "build.log"));
6294
+ await buildImage(tag, this.uid, this.gid, join7(this.dataDir, "computers", "build.log"));
5496
6295
  void pruneImages(tag).then((removed) => {
5497
6296
  if (removed.length) this.log(`removed ${removed.join(", ")}`);
5498
6297
  }).catch(() => {
@@ -5521,9 +6320,9 @@ var Computers = class {
5521
6320
  const states = await containerStates(`jarvy.data=${this.tag}`);
5522
6321
  const computers = this.deps.store.environments.computers();
5523
6322
  const owned = new Set(computers.map((computer) => computer.spec.container));
5524
- for (const name2 of states.keys()) if (!owned.has(name2)) {
5525
- this.log(`removing ${name2}, which no Bot has`);
5526
- await removeContainer(name2).catch((error) => this.log(`removing ${name2} failed: ${error.message}`));
6323
+ for (const name3 of states.keys()) if (!owned.has(name3)) {
6324
+ this.log(`removing ${name3}, which no Bot has`);
6325
+ await removeContainer(name3).catch((error) => this.log(`removing ${name3} failed: ${error.message}`));
5527
6326
  }
5528
6327
  for (const computer of computers) {
5529
6328
  const botId = computer.botId;
@@ -5544,7 +6343,7 @@ var Computers = class {
5544
6343
 
5545
6344
  // src/server/infra/storage/history-repository.ts
5546
6345
  import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
5547
- import { chmodSync as chmodSync2, mkdirSync as mkdirSync2 } from "node:fs";
6346
+ import { chmodSync as chmodSync3, mkdirSync as mkdirSync4 } from "node:fs";
5548
6347
  import { dirname as dirname5 } from "node:path";
5549
6348
  var json = (value2) => value2 == null ? null : JSON.stringify(value2);
5550
6349
  var CALL = "EXISTS (SELECT 1 FROM json_each(history.message, '$.content') b WHERE json_extract(b.value, '$.type')=? AND json_extract(b.value, '$.tool_call_id')=?)";
@@ -5557,9 +6356,9 @@ var SUMMARY = `SELECT id,timestamp,role,model,
5557
6356
  var HistoryRepository = class {
5558
6357
  db;
5559
6358
  constructor(path) {
5560
- mkdirSync2(dirname5(path), { recursive: true, mode: 448 });
6359
+ mkdirSync4(dirname5(path), { recursive: true, mode: 448 });
5561
6360
  this.db = new DatabaseSync2(path);
5562
- chmodSync2(path, 384);
6361
+ chmodSync3(path, 384);
5563
6362
  const version = this.db.prepare("PRAGMA user_version").get().user_version;
5564
6363
  if (version === 0) this.db.exec(`BEGIN; CREATE TABLE history (
5565
6364
  id INTEGER PRIMARY KEY, timestamp INTEGER NOT NULL, role TEXT NOT NULL,
@@ -5606,7 +6405,7 @@ var HistoryRepository = class {
5606
6405
  return rows2.map(({ priced, cost, ...row }) => ({ ...row, cost: priced ? cost : null }));
5607
6406
  }
5608
6407
  decodeSummary(row) {
5609
- const tools = JSON.parse(row.tools).map(({ kind, name: name2, error }) => error ? { kind, name: name2, error: true } : { kind, name: name2 });
6408
+ const tools = JSON.parse(row.tools).map(({ kind, name: name3, error }) => error ? { kind, name: name3, error: true } : { kind, name: name3 });
5610
6409
  return { ...row, model: row.model === null ? null : JSON.parse(row.model), preview: row.preview.replace(/\s+/g, " ").trim(), tools };
5611
6410
  }
5612
6411
  read(id) {
@@ -5686,7 +6485,7 @@ var Memory = class {
5686
6485
  };
5687
6486
 
5688
6487
  // src/server/service/bot/bot.ts
5689
- import { createHash as createHash5 } from "node:crypto";
6488
+ import { createHash as createHash6 } from "node:crypto";
5690
6489
  var Bot = class extends Member {
5691
6490
  kind = "bot";
5692
6491
  identity;
@@ -5777,7 +6576,7 @@ var Bot = class extends Member {
5777
6576
  ...message.question ? { question: message.question } : {},
5778
6577
  ...message.alsoToChannel ? { alsoToChannel: true } : {},
5779
6578
  ...message.replyTo ? { replyTo: message.replyTo } : {},
5780
- clientMessageId: source2 ? createHash5("sha256").update(JSON.stringify([this.id, source2.historyId, source2.toolCallId])).digest("hex") : void 0
6579
+ clientMessageId: source2 ? createHash6("sha256").update(JSON.stringify([this.id, source2.historyId, source2.toolCallId])).digest("hex") : void 0
5781
6580
  });
5782
6581
  }
5783
6582
  /** 由 Bot 协调配置与运行实例;配置在应用成功后提交。执行中切换从下一次模型请求生效(A28)。 */
@@ -5873,53 +6672,105 @@ import { posix } from "node:path";
5873
6672
 
5874
6673
  // src/server/infra/files/workspace.ts
5875
6674
  import { mkdir as mkdir4, writeFile as writeFile3 } from "node:fs/promises";
5876
- import { join as join6 } from "node:path";
6675
+ import { join as join8 } from "node:path";
5877
6676
  var LocalWorkspace = class {
5878
6677
  constructor(cwd) {
5879
6678
  this.cwd = cwd;
5880
6679
  }
5881
6680
  get tempDir() {
5882
- return join6(this.cwd, "temp");
6681
+ return join8(this.cwd, "temp");
5883
6682
  }
5884
6683
  async prepare() {
5885
6684
  await mkdir4(this.tempDir, { recursive: true, mode: 448 });
5886
6685
  }
5887
- async writeTempFile(name2, content2, signal) {
6686
+ async writeTempFile(name3, content2, signal) {
5888
6687
  await this.prepare();
5889
- const path = join6(this.tempDir, name2);
6688
+ const path = join8(this.tempDir, name3);
5890
6689
  await writeFile3(path, content2, { encoding: "utf8", flag: "wx", mode: 384, signal });
5891
6690
  return path;
5892
6691
  }
5893
6692
  };
5894
6693
 
6694
+ // src/server/infra/files/skills.ts
6695
+ import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync4 } from "node:fs";
6696
+ import { mkdir as mkdir5, writeFile as writeFile4 } from "node:fs/promises";
6697
+ import { dirname as dirname6, join as join9 } from "node:path";
6698
+ import { fileURLToPath } from "node:url";
6699
+
6700
+ // src/server/shared/skill.ts
6701
+ var skillPath = (name3) => `skills/${name3}/SKILL.md`;
6702
+
6703
+ // src/server/infra/files/skills.ts
6704
+ function builtinSkills() {
6705
+ const dir = skillsDir();
6706
+ if (!dir) return [];
6707
+ const skills = [];
6708
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
6709
+ if (!entry.isDirectory()) continue;
6710
+ const file = join9(dir, entry.name, "SKILL.md");
6711
+ if (!existsSync4(file)) continue;
6712
+ const skill = parseSkill(entry.name, readFileSync4(file, "utf8"));
6713
+ if (skill) skills.push(skill);
6714
+ else console.warn(`Jarvy: skipped the skill in ${entry.name}: SKILL.md needs a name matching its folder and a description`);
6715
+ }
6716
+ return skills.sort((a, b) => a.name.localeCompare(b.name));
6717
+ }
6718
+ async function installSkills(workspace, skills) {
6719
+ for (const skill of skills) {
6720
+ const file = join9(workspace, skillPath(skill.name));
6721
+ await mkdir5(dirname6(file), { recursive: true });
6722
+ await writeFile4(file, skill.content, "utf8");
6723
+ }
6724
+ }
6725
+ function skillsDir() {
6726
+ let dir = dirname6(fileURLToPath(import.meta.url));
6727
+ for (let depth = 0; depth < 6; depth++) {
6728
+ if (existsSync4(join9(dir, "package.json"))) return existsSync4(join9(dir, "skills")) ? join9(dir, "skills") : null;
6729
+ const parent = dirname6(dir);
6730
+ if (parent === dir) break;
6731
+ dir = parent;
6732
+ }
6733
+ return null;
6734
+ }
6735
+ function parseSkill(folder, content2) {
6736
+ const head = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(content2);
6737
+ if (!head) return null;
6738
+ const field2 = (key) => new RegExp(`^${key}:\\s*(.+)$`, "m").exec(head[1])?.[1]?.trim() ?? "";
6739
+ const name3 = field2("name"), description = field2("description");
6740
+ if (name3 !== folder || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name3) || !description) return null;
6741
+ return { name: name3, description, content: content2 };
6742
+ }
6743
+
5895
6744
  // src/server/service/bot/workspace.ts
5896
6745
  var MAX_FILE = 10 * 1024 * 1024;
5897
6746
  var Workspace = class {
5898
- constructor(cwd, current, home) {
6747
+ constructor(cwd, current, home, skills = []) {
5899
6748
  this.current = current;
5900
6749
  this.home = home;
6750
+ this.skills = skills;
5901
6751
  this.local = new LocalWorkspace(cwd);
5902
6752
  }
5903
6753
  local;
5904
- prepare() {
5905
- return this.local.prepare();
6754
+ async prepare() {
6755
+ await this.local.prepare();
6756
+ await installSkills(this.local.cwd, this.skills);
5906
6757
  }
5907
6758
  /** A file in the temporary directory of the environment the Bot works in, by the path it sees there. */
5908
- async writeTempFile(name2, content2, signal) {
6759
+ async writeTempFile(name3, content2, signal) {
5909
6760
  const env = this.current();
5910
- if (env.kind === "host") return this.local.writeTempFile(name2, content2, signal);
5911
- const path = `${env.cwd}/temp/${name2}`;
6761
+ if (env.kind === "host") return this.local.writeTempFile(name3, content2, signal);
6762
+ const path = `${env.cwd}/temp/${name3}`;
5912
6763
  await env.writeFile(path, content2);
5913
6764
  return path;
5914
6765
  }
5915
6766
  /** Puts a chat file into the Bot's environment: at `path`, or in temp/files/ under its own name (with part of its
5916
6767
  * id when another file has that name). Returns where it went. */
5917
- async put(name2, id, data, path) {
6768
+ async put(name3, id, data, path) {
5918
6769
  const env = this.current();
5919
- let target2 = path ? posix.resolve(env.cwd, path) : `${env.cwd}/temp/files/${plain(name2)}`;
6770
+ let target2 = path ? posix.resolve(env.cwd, path) : `${env.cwd}/temp/files/${plain(name3)}`;
5920
6771
  if (!path && await env.exists(target2)) {
5921
- const dot = plain(name2).lastIndexOf("."), stem = dot > 0 ? plain(name2).slice(0, dot) : plain(name2), extension = dot > 0 ? plain(name2).slice(dot) : "";
5922
- target2 = `${env.cwd}/temp/files/${stem}-${id.slice(0, 8)}${extension}`;
6772
+ const dot = plain(name3).lastIndexOf("."), stem = dot > 0 ? plain(name3).slice(0, dot) : plain(name3), extension2 = dot > 0 ? plain(name3).slice(dot) : "";
6773
+ target2 = `${env.cwd}/temp/files/${stem}-${id.slice(0, 8)}${extension2}`;
5923
6774
  }
5924
6775
  await env.mkdir(posix.dirname(target2));
5925
6776
  await env.writeFile(target2, data);
@@ -5944,8 +6795,8 @@ var Workspace = class {
5944
6795
  return { type: "local", cwd: this.local.cwd, tempDir: this.local.tempDir };
5945
6796
  }
5946
6797
  };
5947
- function plain(name2) {
5948
- const cleaned = name2.replace(/[/\\\0]/g, "_").trim();
6798
+ function plain(name3) {
6799
+ const cleaned = name3.replace(/[/\\\0]/g, "_").trim();
5949
6800
  return cleaned && cleaned !== "." && cleaned !== ".." ? cleaned : "file";
5950
6801
  }
5951
6802
 
@@ -5970,7 +6821,7 @@ import { Type as Type5 } from "typebox";
5970
6821
  import { Value } from "typebox/value";
5971
6822
 
5972
6823
  // src/server/service/bot/history-read.ts
5973
- import { randomUUID as randomUUID10 } from "node:crypto";
6824
+ import { randomUUID as randomUUID11 } from "node:crypto";
5974
6825
  import { Type as Type2 } from "typebox";
5975
6826
 
5976
6827
  // src/server/service/bot/history-format.ts
@@ -6050,8 +6901,8 @@ async function readHistory(memory, workspace, input, signal) {
6050
6901
  const rendered = formatHistory(result, format);
6051
6902
  signal?.throwIfAborted();
6052
6903
  if (!input.to_file) return rendered;
6053
- const name2 = `history-${randomUUID10()}.${format === "json" ? "json" : "md"}`;
6054
- return workspace.writeTempFile(name2, rendered, signal);
6904
+ const name3 = `history-${randomUUID11()}.${format === "json" ? "json" : "md"}`;
6905
+ return workspace.writeTempFile(name3, rendered, signal);
6055
6906
  }
6056
6907
 
6057
6908
  // src/server/service/bot/file-view.ts
@@ -6087,8 +6938,8 @@ function asText(data) {
6087
6938
  }
6088
6939
 
6089
6940
  // src/server/service/bot/chat-views.ts
6090
- var iso = (time) => new Date(time).toISOString();
6091
- var name = (chats) => (memberId) => chats.member(memberId)?.name ?? memberId;
6941
+ var iso = (time2) => new Date(time2).toISOString();
6942
+ var name2 = (chats) => (memberId) => chats.member(memberId)?.name ?? memberId;
6092
6943
  function chatRows(chats, botId) {
6093
6944
  const sections = new Map(chats.channels.sections(botId).map((section) => [section.id, section.name]));
6094
6945
  const row = (chat) => ({
@@ -6122,7 +6973,7 @@ function chatDetail(chats, botId, chat) {
6122
6973
  parent: chat.parentId,
6123
6974
  status: chat.status,
6124
6975
  following: chat.following,
6125
- root: root ? { id: root.id, from: root.sender.name, content: expandQuotes(root.content, root.quotes, name(chats)), at: iso(root.createdAt) } : null
6976
+ root: root ? { id: root.id, from: root.sender.name, content: expandQuotes(root.content, root.quotes, name2(chats)), at: iso(root.createdAt) } : null
6126
6977
  } : {},
6127
6978
  members,
6128
6979
  notice: chat.note ? { text: chat.note.text, version: chat.note.version, by: chat.note.updatedBy.name, at: iso(chat.note.updatedAt) } : null,
@@ -6152,7 +7003,7 @@ function messageRow(chats, botId, message, attachments, chat) {
6152
7003
  ...chat ? { chat: { id: chat.id, type: chat.type, title: chat.title } } : {},
6153
7004
  from: message.sender.name,
6154
7005
  at: iso(message.createdAt),
6155
- text: expandQuotes(message.content, message.quotes, name(chats)),
7006
+ text: expandQuotes(message.content, message.quotes, name2(chats)),
6156
7007
  ...attachments.length ? { attachments } : {},
6157
7008
  ...message.mentions.length ? { mentions: message.mentions.map((id) => chats.member(id)?.name ?? id) } : {},
6158
7009
  ...message.thread ? { thread: { id: message.thread.id, title: message.thread.title, status: message.thread.status, replies: message.thread.replies } } : {},
@@ -6191,7 +7042,7 @@ var ChatTools = class {
6191
7042
  /** Members by name (case-insensitive, a leading @ allowed); unknown names are an error that lists them. */
6192
7043
  members(names) {
6193
7044
  const directory = this.chats.channels.directory(this.bot);
6194
- const found = names.map((name2) => ({ name: name2, member: directory.find((member) => same(name2, member.name)) }));
7045
+ const found = names.map((name3) => ({ name: name3, member: directory.find((member) => same(name3, member.name)) }));
6195
7046
  const missing = found.filter((item) => !item.member).map((item) => item.name);
6196
7047
  if (missing.length) throw new ChatError(404, `No member named: ${missing.join(", ")} (see members.list)`);
6197
7048
  return found.map((item) => item.member);
@@ -6416,6 +7267,15 @@ var definitions = {
6416
7267
  description: "Move your file and command tools (bash, read, write, edit) to another work environment from environments.list, like cd one level up: you stay there, across turns, until you switch back. The result has your directory there and what you can do there. Commands already running finish where they started.",
6417
7268
  schema: Type3.Object({ id: Type3.String({ minLength: 1, maxLength: 80, description: "The environment's id from environments.list" }) }, { additionalProperties: false })
6418
7269
  },
7270
+ "secrets.request": {
7271
+ description: 'Ask your owner for a secret you need (a token, password or key) on a card in a chat with them, and wait for the answer, as messages.ask does. They enter the value on the card, which never shows it in the chat, and let you use it until they revoke it or only until this run ends, or decline. It returns {status:"granted", placeholder, until} or {status:"declined"}; if you may already use it, it returns at once. Then write the placeholder in bash.',
7272
+ schema: Type3.Object({
7273
+ chatId: Type3.String(),
7274
+ name: Type3.String({ pattern: "^[A-Z][A-Z0-9_]{0,63}$", description: "Capital letters, digits and underscores, such as GITHUB_TOKEN" }),
7275
+ purpose: Type3.String({ minLength: 1, maxLength: 300, description: "What you need it for, in your owner's language; shown on the card" })
7276
+ }, { additionalProperties: false })
7277
+ },
7278
+ "secrets.list": { description: "List the secrets (tokens, passwords) your owner lets you use: each name, its placeholder and what it is for, never the value. To use one, write its placeholder, such as {{secret:GITHUB_TOKEN}}, in a bash command: it is filled in for that command only and shown masked in the output.", schema: None },
6419
7279
  "files.view": {
6420
7280
  description: "Look at a file from a message without putting it anywhere: a picture (PNG, JPEG, GIF or WebP, up to 4 MB) is shown to you, and a text file up to 64 KB comes back as text. Anything else needs files.get.",
6421
7281
  schema: Type3.Object({ id: FileId }, { additionalProperties: false })
@@ -6425,7 +7285,7 @@ var definitions = {
6425
7285
  schema: Type3.Object({ messageId: MessageId, emoji: Type3.String({ minLength: 1, maxLength: 32, description: "A single emoji, such as \u{1F44D}" }), on: Type3.Optional(Type3.Boolean()) }, { additionalProperties: false })
6426
7286
  },
6427
7287
  "messages.ask": {
6428
- description: `Post a multiple-choice card in a chat you are in and wait for the person's answer. It returns {status:"answered", selected:[{label,value}], custom}, or {status:"dismissed"} if they closed the question without answering. Use it when someone needs to choose or confirm (a plan, a preference, whether to go on with something risky); ask open questions with messages.send. options: 1\u20136; multiple allows several choices; allowCustom lets them write their own answer (custom). If, while you wait, the person sends a new message, cancels this turn or stops the Bot, the call is interrupted and the new message arrives in the next batch. Only people can answer, so it can't be used in chats without one (such as a DM between Bots). Never use it to ask for passwords or keys.`,
7288
+ description: `Post a multiple-choice card in a chat you are in and wait for the person's answer. It returns {status:"answered", selected:[{label,value}], custom}, or {status:"dismissed"} if they closed the question without answering. If the chat had messages you hadn't read, before the card or while it waited, it also gives their count (unread) and a note on where to read them. Use it when someone needs to choose or confirm (a plan, a preference, whether to go on with something risky); ask open questions with messages.send. options: 1\u20136; multiple allows several choices; allowCustom lets them write their own answer (custom). If, while you wait, the person sends a new message, cancels this turn or stops the Bot, the call is interrupted and the new message arrives in the next batch. Only people can answer, so it can't be used in chats without one (such as a DM between Bots). Never use it to ask for passwords, tokens or keys: use secrets.request.`,
6429
7289
  schema: Type3.Object({
6430
7290
  chatId: Type3.String(),
6431
7291
  question: Type3.String({ minLength: 1, maxLength: 500 }),
@@ -6436,7 +7296,7 @@ var definitions = {
6436
7296
  }, { additionalProperties: false })
6437
7297
  },
6438
7298
  "messages.send": {
6439
- description: "Send a message, with files from your environment if you like (path there, absolute or relative to your directory, and the name people see). Give chatId or to, not both: to is a member's name and sends them a DM (a person or a Bot; the first time creates the DM). Only messages sent successfully are visible in the chat. In channels and threads, only the members whose @name is in the text and the members subscribed there receive it; DMs need no @. The result lists who was @-mentioned (notified), who got it through a subscription (subscribers), and the names that aren't in the channel and weren't told. thread: the ID of a message in the channel, to reply in that message's thread (created if it has none; chatId is the channel). replyTo: the ID of a message in the same chat, to reply to it as a quote; this tells its author, like an @. When replying in a thread, alsoToChannel=true also posts a copy to the channel.",
7299
+ description: "Send a message, with files from your environment if you like (path there, absolute or relative to your directory, and the name people see). Give chatId or to, not both: to is a member's name and sends them a DM (a person or a Bot; the first time creates the DM). Only messages sent successfully are visible in the chat. In channels and threads, only the members whose @name is in the text and the members subscribed there receive it; DMs need no @. The result lists who was @-mentioned (notified), who got it through a subscription (subscribers), and the names that aren't in the channel and weren't told. If the chat had messages you hadn't read, it also gives their count (unread) and a note on where to read them: sending marks them read, so read from the after it names. thread: the ID of a message in the channel, to reply in that message's thread (created if it has none; chatId is the channel). replyTo: the ID of a message in the same chat, to reply to it as a quote; this tells its author, like an @. When replying in a thread, alsoToChannel=true also posts a copy to the channel.",
6440
7300
  schema: Type3.Object({
6441
7301
  chatId: Type3.Optional(Type3.String()),
6442
7302
  to: Type3.Optional(Type3.String({ minLength: 1, maxLength: 80 })),
@@ -6501,8 +7361,8 @@ var AgentTools = class {
6501
7361
  if (chat.type !== "channel") throw new ChatError(400, "Not a channel");
6502
7362
  return chat;
6503
7363
  }
6504
- async call(name2, args, ask) {
6505
- switch (name2) {
7364
+ async call(name3, args, ask) {
7365
+ switch (name3) {
6506
7366
  case "models.list":
6507
7367
  return this.ops.models();
6508
7368
  case "bots.create": {
@@ -6518,7 +7378,7 @@ var AgentTools = class {
6518
7378
  case "bots.start":
6519
7379
  case "bots.stop": {
6520
7380
  const bot = this.ops.bot(args.bot);
6521
- return { name: bot.name, status: await this.ops.run(bot, name2 === "bots.start" ? "start" : "stop") };
7381
+ return { name: bot.name, status: await this.ops.run(bot, name3 === "bots.start" ? "start" : "stop") };
6522
7382
  }
6523
7383
  case "bots.usage": {
6524
7384
  const input = args, bot = this.ops.bot(input.bot);
@@ -6651,6 +7511,17 @@ var Tools = class {
6651
7511
  if (!signal?.aborted) this.scope.asking?.(false);
6652
7512
  }
6653
7513
  }
7514
+ /** Where the Bot had read up to in a chat, and how many messages it hadn't, before it writes there (A53). Sending
7515
+ * moves its read position past them, so this is taken first. A thread counts only itself. */
7516
+ readState(chatId) {
7517
+ const chat = this.scope.chats.get(this.scope.botId, chatId);
7518
+ return { unread: chat?.unread ?? 0, after: chat?.readSequence ?? 0 };
7519
+ }
7520
+ /** Others' messages it hadn't read (delivered ones arrive in its batches anyway), and where to read them from. */
7521
+ unreadHint(count, after2) {
7522
+ const one = count === 1;
7523
+ return count ? { unread: count, note: `${count} message${one ? "" : "s"} here you hadn't read. Read ${one ? "it" : "them"} with messages.read (chatId, after=${after2}), which may also bring back messages you already have, and follow up if ${one ? "it changes" : "they change"} where things stand.` } : {};
7524
+ }
6654
7525
  bindSend(send) {
6655
7526
  this.send = send;
6656
7527
  }
@@ -6664,19 +7535,19 @@ var Tools = class {
6664
7535
  signal?.throwIfAborted();
6665
7536
  if (raw.action === "list") {
6666
7537
  if (raw.tool !== void 0 || raw.args !== void 0) throw new ChatError(400, "list does not accept tool or args");
6667
- return Object.entries(this.catalog).map(([name3, tool]) => ({ name: name3, description: tool.description }));
7538
+ return Object.entries(this.catalog).map(([name4, tool]) => ({ name: name4, description: tool.description }));
6668
7539
  }
6669
7540
  if (!raw.tool || !Object.hasOwn(this.catalog, raw.tool)) throw new ChatError(400, "Unknown chat tool; use list first");
6670
- const name2 = raw.tool, definition = this.catalog[name2];
7541
+ const name3 = raw.tool, definition = this.catalog[name3];
6671
7542
  if (raw.action === "describe") {
6672
7543
  if (raw.args !== void 0) throw new ChatError(400, "describe does not accept args");
6673
- return { name: name2, description: definition.description, parameters: definition.schema };
7544
+ return { name: name3, description: definition.description, parameters: definition.schema };
6674
7545
  }
6675
7546
  const args = raw.args;
6676
7547
  if (!args || !Value.Check(definition.schema, args)) throw new ChatError(400, "Invalid tool arguments; use describe for the parameter schema");
6677
- if (this.agent && Object.hasOwn(agentDefinitions, name2))
6678
- return this.agent.call(name2, args, (chatId, question) => this.ask(chatId, question, toolCallId, signal));
6679
- switch (name2) {
7548
+ if (this.agent && Object.hasOwn(agentDefinitions, name3))
7549
+ return this.agent.call(name3, args, (chatId, question) => this.ask(chatId, question, toolCallId, signal));
7550
+ switch (name3) {
6680
7551
  case "history.read":
6681
7552
  return readHistory(this.scope.memory, this.scope.workspace, args, signal);
6682
7553
  case "chats.list":
@@ -6696,6 +7567,23 @@ var Tools = class {
6696
7567
  return this.scope.places.list();
6697
7568
  case "environments.switch":
6698
7569
  return this.scope.places.switch(args.id);
7570
+ case "secrets.list":
7571
+ return this.scope.secrets.list();
7572
+ case "secrets.request": {
7573
+ const parsed = args;
7574
+ if (!this.tools.chat(parsed.chatId).memberIds.some((id) => this.scope.chats.member(id)?.kind === "user")) throw new ChatError(400, "No person in this chat to answer; ask in a chat with your owner");
7575
+ const prepared = this.scope.secrets.prepare(parsed.name, parsed.purpose.trim());
7576
+ if ("granted" in prepared) return { status: "granted", name: parsed.name, placeholder: placeholder(parsed.name), note: "You may already use it" };
7577
+ const result = await this.ask(parsed.chatId, prepared.question, toolCallId, signal);
7578
+ if (result.status !== "answered") return { messageId: result.messageId, status: "declined", note: "Your owner declined. Don't ask for the value in the chat; go on without it, or ask what they would like instead." };
7579
+ return {
7580
+ messageId: result.messageId,
7581
+ status: "granted",
7582
+ name: parsed.name,
7583
+ placeholder: placeholder(parsed.name),
7584
+ until: result.selected[0]?.value === "once" ? "this run ends" : "your owner revokes it"
7585
+ };
7586
+ }
6699
7587
  case "chats.create":
6700
7588
  return this.tools.create(args);
6701
7589
  case "chats.update":
@@ -6752,6 +7640,7 @@ var Tools = class {
6752
7640
  if (!root || root.chatId !== chatId) throw new ChatError(404, "Message not found in this channel");
6753
7641
  chatId = this.scope.chats.threads.open(this.scope.botId, parsed.thread).id;
6754
7642
  }
7643
+ const before = this.readState(chatId);
6755
7644
  const sent = await this.send(chatId, {
6756
7645
  content: parsed.message,
6757
7646
  attachments: parsed.attachments,
@@ -6761,17 +7650,18 @@ var Tools = class {
6761
7650
  signal
6762
7651
  });
6763
7652
  const chat = this.scope.chats.get(this.scope.botId, chatId);
6764
- if (!chat || chat.type === "dm") return { messageId: sent.id, chatId: sent.chatId, sequence: sent.sequence };
7653
+ if (!chat || chat.type === "dm") return { messageId: sent.id, chatId: sent.chatId, sequence: sent.sequence, ...this.unreadHint(before.unread, before.after) };
6765
7654
  const report = this.scope.chats.channels.mentionReport(this.scope.botId, chatId, parsed.message);
6766
- const notified = sent.mentions.map((id) => this.scope.chats.member(id)?.name).filter((name3) => !!name3);
6767
- const subscribers = this.scope.chats.channels.subscribers(this.scope.botId, chatId).map((member) => member.name).filter((name3) => name3 !== this.scope.chats.member(this.scope.botId)?.name && !notified.includes(name3));
7655
+ const notified = sent.mentions.map((id) => this.scope.chats.member(id)?.name).filter((name4) => !!name4);
7656
+ const subscribers = this.scope.chats.channels.subscribers(this.scope.botId, chatId).map((member) => member.name).filter((name4) => name4 !== this.scope.chats.member(this.scope.botId)?.name && !notified.includes(name4));
6768
7657
  return {
6769
7658
  messageId: sent.id,
6770
7659
  chatId: sent.chatId,
6771
7660
  sequence: sent.sequence,
6772
7661
  notified,
6773
7662
  ...subscribers.length ? { subscribers } : {},
6774
- ...report.notInChannel.length ? { notInChannel: report.notInChannel } : {}
7663
+ ...report.notInChannel.length ? { notInChannel: report.notInChannel } : {},
7664
+ ...this.unreadHint(before.unread, before.after)
6775
7665
  };
6776
7666
  }
6777
7667
  case "messages.ask": {
@@ -6784,7 +7674,9 @@ var Tools = class {
6784
7674
  multiple: parsed.multiple === true,
6785
7675
  allowCustom: parsed.allowCustom === true
6786
7676
  };
6787
- return this.ask(parsed.chatId, question, toolCallId, signal);
7677
+ const chatId = this.tools.chat(parsed.chatId).id, before = this.readState(chatId);
7678
+ const answer = await this.ask(chatId, question, toolCallId, signal);
7679
+ return { ...answer, ...this.unreadHint(before.unread + this.readState(chatId).unread, before.after) };
6788
7680
  }
6789
7681
  }
6790
7682
  }
@@ -6793,7 +7685,7 @@ var Tools = class {
6793
7685
  };
6794
7686
 
6795
7687
  // src/server/service/bot/inbox.ts
6796
- import { join as join7, dirname as dirname6 } from "node:path";
7688
+ import { join as join10, dirname as dirname7 } from "node:path";
6797
7689
 
6798
7690
  // src/server/shared/agent.ts
6799
7691
  var WELCOME_LANGUAGES = { en: "English", zh: "Chinese" };
@@ -6805,9 +7697,9 @@ function escape(value2) {
6805
7697
  if (INVALID_XML.test(value2)) throw new Error("Invalid XML character");
6806
7698
  return value2.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\r/g, "&#13;");
6807
7699
  }
6808
- function attribute(name2, value2) {
6809
- if (!value2.trim()) throw new Error(`Missing ${name2}`);
6810
- return ` ${name2}="${escape(value2).replace(/"/g, "&quot;").replace(/\n/g, "&#10;").replace(/\t/g, "&#9;")}"`;
7700
+ function attribute(name3, value2) {
7701
+ if (!value2.trim()) throw new Error(`Missing ${name3}`);
7702
+ return ` ${name3}="${escape(value2).replace(/"/g, "&quot;").replace(/\n/g, "&#10;").replace(/\t/g, "&#9;")}"`;
6811
7703
  }
6812
7704
  function remember(map, id, definition) {
6813
7705
  const old = map.get(id);
@@ -6815,7 +7707,7 @@ function remember(map, id, definition) {
6815
7707
  map.set(id, definition);
6816
7708
  }
6817
7709
  var environmentLine = (place) => place ? `<environment${attribute("name", place.name)}${attribute("directory", place.directory)}/>` : "";
6818
- var iso2 = (time) => new Date(time).toISOString().replace(".000Z", "Z");
7710
+ var iso2 = (time2) => new Date(time2).toISOString().replace(".000Z", "Z");
6819
7711
  var welcome = (owner, language) => `${owner} has just set up Jarvy and picked your model. You are their agent Bot. In this DM, welcome them in ${WELCOME_LANGUAGES[language]}, the language of their interface: say in a few sentences what Jarvy is and how you can help, such as building a team of Bots for their work and running Jarvy for them. Then ask what they want to get done: a software project, writing, research or something else.`;
6820
7712
  function encodeNoticeBatch(botId, notices, environment) {
6821
7713
  if (!notices.length) throw new Error("Empty notice batch");
@@ -6885,7 +7777,7 @@ var BotInbox = class {
6885
7777
  }
6886
7778
  reference(workspace) {
6887
7779
  if (this.repository.hasUncertainModel(this.botId)) throw new RuntimeFault("model_switch_uncertain", "The model switch didn't finish. Pick the model again.");
6888
- return this.repository.ensureSession(this.botId, join7(dirname6(workspace), "agent", "pi", "sessions", `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.jsonl`));
7780
+ return this.repository.ensureSession(this.botId, join10(dirname7(workspace), "agent", "pi", "sessions", `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.jsonl`));
6889
7781
  }
6890
7782
  /** XML for the claimed messages plus the tail (other deliveries not told yet, unread); `notices` are told once
6891
7783
  * the batch is submitted. */
@@ -6957,7 +7849,7 @@ var AgentRuntime = class {
6957
7849
  }
6958
7850
  update(patch) {
6959
7851
  const context2 = this.session?.context;
6960
- this.status = { ...this.status, ...patch, context: context2 ? { tokens: context2.tokens, limit: context2.contextWindow } : null };
7852
+ this.status = { ...this.status, ...patch, context: context2 ? { tokens: context2.tokens, limit: context2.contextWindow, stale: context2.stale } : null };
6961
7853
  this.setup?.onStatus(this.status);
6962
7854
  }
6963
7855
  activity(phase) {
@@ -7058,7 +7950,7 @@ var AgentRuntime = class {
7058
7950
  repository.releaseUnsubmitted(batch.run.id);
7059
7951
  continue;
7060
7952
  }
7061
- const { xml, notices } = prepared;
7953
+ const { xml: xml2, notices } = prepared;
7062
7954
  if (this.stopping || this.pauses || version !== this.inputVersion) {
7063
7955
  repository.releaseUnsubmitted(batch.run.id);
7064
7956
  continue;
@@ -7067,7 +7959,7 @@ var AgentRuntime = class {
7067
7959
  submitted = true;
7068
7960
  this.prompting = true;
7069
7961
  this.inbox.acknowledge(notices);
7070
- this.promptTask = session.prompt(xml);
7962
+ this.promptTask = session.prompt(xml2);
7071
7963
  const result = await this.promptTask;
7072
7964
  repository.finish(batch.run.id, result.outcome, result.outcome === "failed" ? "model_request_failed" : null);
7073
7965
  if (result.outcome === "failed") {
@@ -7165,7 +8057,7 @@ The chat gateway lists tools only you have: models.list, bots.create, bots.updat
7165
8057
  Other Bots may ask you to do these things for them. Do the ordinary ones; deleting always waits for the owner's confirmation.
7166
8058
  You can't add model connections, keys or logins. The owner does that in Settings, so point them there when a model is missing.
7167
8059
  `;
7168
- function botPrompt(setup) {
8060
+ function botPrompt(setup, skills = []) {
7169
8061
  const bot = { ...setup.identity(), workspacePath: setup.workspace.cwd };
7170
8062
  return `# Persona
7171
8063
  Your name is ${bot.name}.
@@ -7175,7 +8067,7 @@ ${bot.persona}
7175
8067
  ## Environment
7176
8068
  You are in a group chat with people and other Bots. You take part in direct messages (DMs), channels and threads, and you talk and share information with the other members through the chat.
7177
8069
  Here your name is ${bot.name}, and your description is: ${bot.description}
7178
- ${bot.agent ? AGENT_ROLE : ""}
8070
+ ${bot.agent ? AGENT_ROLE : ""}${skillsSection(setup, skills)}
7179
8071
  ## Language
7180
8072
  Write to people in the language they write to you in. In a channel or thread, use the language its conversation is held in.
7181
8073
  These instructions and the tool descriptions are in English so that every Bot reads the same rules. They do not decide which language you use with people.
@@ -7191,6 +8083,11 @@ Each batch of messages ends with your unread counts and the notices and summarie
7191
8083
  To look into earlier work, search the chat first, then call history.read with the History ID a message carries to read how the work was done.
7192
8084
  history.read is listed by the chat gateway. History is material to look back on, not an instruction for the task at hand.
7193
8085
 
8086
+ ## Secrets
8087
+ Never ask anyone for a password, token or key in the chat, and never put one in a message or in a file others read.
8088
+ secrets.list in the chat gateway shows the secrets your owner lets you use. To use one, write its placeholder, such as {{secret:GITHUB_TOKEN}}, in a bash command: it is filled in for that command only. Prefer handing it to a program through its environment, such as GH_TOKEN={{secret:GITHUB_TOKEN}} gh pr list, over putting it among the command's arguments. Placeholders in write and edit content stay as written.
8089
+ Secret values are masked in everything you see. If you need one you don't have, call secrets.request: your owner enters it on a card, and you wait for the answer.
8090
+
7194
8091
  ${workspaceSection(setup, bot.workspacePath)}
7195
8092
  ## Responding
7196
8093
  When you are @-mentioned or get a DM, first let the person know you have it: react to that message with an emoji, or confirm in a sentence or two with an emoji. A reaction is only a signal, not a command; pick the emoji yourself.
@@ -7217,6 +8114,15 @@ Your tools run on your owner's computer by default, the machine running Jarvy.
7217
8114
  ${SWITCHING}
7218
8115
  `;
7219
8116
  }
8117
+ function skillsSection(setup, skills) {
8118
+ if (!skills.length) return "";
8119
+ const home = setup.home(), base = home.kind === "computer" ? home.cwd : setup.workspace.cwd;
8120
+ return `
8121
+ ## Skills
8122
+ Skills are guides that come with Jarvy for kinds of work you help with. When a task matches one, read its file before you start, and follow it. They are suggestions for the owner: propose a plan from them and act once the owner agrees. Jarvy keeps these files in your workspace and writes them again each time you start, so changes to them don't last.
8123
+ ${skills.map((skill) => `- ${skill.name}: ${skill.description} File: ${base}/${skillPath(skill.name)}`).join("\n")}
8124
+ `;
8125
+ }
7220
8126
  var SWITCHING = "Each batch of messages ends with the environment you are in now. environments.list shows the ones you may use, and environments.switch moves your tools to another, like cd, until you switch back.";
7221
8127
 
7222
8128
  // src/server/shared/live.ts
@@ -7484,9 +8390,9 @@ var AgentOps = class {
7484
8390
  }
7485
8391
  agentId = AGENT_BOT_ID;
7486
8392
  /** A Bot by name, ignoring case and a leading @. */
7487
- bot(name2) {
7488
- const bot = this.store.listBots().find((record3) => same2(name2, record3.name));
7489
- if (!bot) throw new ChatError(404, `No Bot named ${name2} (see bots.list)`);
8393
+ bot(name3) {
8394
+ const bot = this.store.listBots().find((record3) => same2(name3, record3.name));
8395
+ if (!bot) throw new ChatError(404, `No Bot named ${name3} (see bots.list)`);
7490
8396
  return bot;
7491
8397
  }
7492
8398
  /** Connected models and their thinking levels; never credentials. */
@@ -7604,6 +8510,8 @@ var BotService = class {
7604
8510
  started = false;
7605
8511
  /** What only the agent Bot does (A48). */
7606
8512
  agent;
8513
+ /** The skills that ship with Jarvy, for the agent Bot (A55). */
8514
+ skills = builtinSkills();
7607
8515
  listChanged() {
7608
8516
  this.deps.listChanged?.();
7609
8517
  }
@@ -7613,7 +8521,7 @@ var BotService = class {
7613
8521
  if (!record3) throw new RuntimeFault("bot_missing", "Bot not found", 404);
7614
8522
  let bot = this.bots.get(id);
7615
8523
  if (bot) return bot;
7616
- const { store, chats, files, credentials } = this.deps, live = this.live(id);
8524
+ const { store, chats, files, credentials } = this.deps, live = this.live(id), skills = id === AGENT_BOT_ID ? this.skills : [];
7617
8525
  const here = () => {
7618
8526
  const place = this.environments.here(id, record3.workspacePath);
7619
8527
  return { name: place.name, directory: place.directory };
@@ -7625,17 +8533,18 @@ var BotService = class {
7625
8533
  reference,
7626
8534
  credentials,
7627
8535
  tools: setup.tools,
7628
- systemPrompt: () => botPrompt(setup),
8536
+ systemPrompt: () => botPrompt(setup, skills),
7629
8537
  onHistory: setup.onHistory,
7630
8538
  activity,
7631
8539
  live: (event) => live.event(event),
7632
- environment: () => this.environments.current(id, record3.workspacePath)
8540
+ environment: () => this.environments.current(id, record3.workspacePath),
8541
+ secrets: this.deps.secrets.forBot(id)
7633
8542
  }));
7634
8543
  const workspace = new Workspace(record3.workspacePath, () => this.environments.current(id, record3.workspacePath), () => {
7635
8544
  const place = this.environments.home(id, record3.workspacePath);
7636
8545
  return { kind: place.kind === "sandbox" ? "computer" : "host", cwd: place.directory };
7637
- });
7638
- const memory = new Memory(join8(dirname7(record3.workspacePath), "history.db"));
8546
+ }, skills);
8547
+ const memory = new Memory(join11(dirname8(record3.workspacePath), "history.db"));
7639
8548
  bot = new Bot(id, {
7640
8549
  identity: new Identity(id, store),
7641
8550
  model: new Model(id, store, resolveThinking),
@@ -7654,6 +8563,7 @@ var BotService = class {
7654
8563
  switch: (environmentId) => this.environments.switch(id, environmentId, record3.workspacePath),
7655
8564
  summary: (botId) => this.environments.summary(botId)
7656
8565
  },
8566
+ secrets: { list: () => this.deps.secrets.listFor(id), prepare: (name3, purpose) => this.deps.secrets.prepareRequest(id, name3, purpose) },
7657
8567
  ...id === AGENT_BOT_ID ? { agent: this.agent } : {}
7658
8568
  }),
7659
8569
  agentRuntime: runtime,
@@ -7708,10 +8618,10 @@ var BotService = class {
7708
8618
  return this.deps.store.listBots().map((record3) => this.details(record3));
7709
8619
  }
7710
8620
  /** A new Bot works in its sandbox when this machine has Docker, unless asked for the owner's computer (A50). */
7711
- create(name2, environment) {
8621
+ create(name3, environment) {
7712
8622
  const kind = environment ?? (this.computers.available ? "computer" : "host");
7713
8623
  if (kind === "computer" && !this.computers.available) throw new RuntimeFault("docker_unavailable", "Docker is not available on this machine.", 409);
7714
- const record3 = this.deps.store.createBot(name2);
8624
+ const record3 = this.deps.store.createBot(name3);
7715
8625
  this.environments.setHome(record3.id, kind, record3.workspacePath);
7716
8626
  return this.details(record3);
7717
8627
  }
@@ -7731,7 +8641,7 @@ var BotService = class {
7731
8641
  }
7732
8642
  /** Tells a Bot what its owner changed about where it works (A41, A50). A move interrupts a running turn, as an
7733
8643
  * @-mention does, so the Bot doesn't go on in the old place; a Bot not running, or a change of access alone, is
7734
- * told with its next batch (fish m1012–m1015). */
8644
+ * told with its next batch. */
7735
8645
  tellOwner(id, changes, move) {
7736
8646
  if (!this.record(id).connectionId) return;
7737
8647
  const owner = this.deps.store.userMemberId(), chatId = this.deps.chats.openDirect(owner, id).id;
@@ -7854,7 +8764,7 @@ var BotService = class {
7854
8764
  this.lives.delete(id);
7855
8765
  await this.environments.release(id);
7856
8766
  if (!this.deps.store.deleteBot(id)) throw new RuntimeFault("bot_missing", "Bot not found", 404);
7857
- if (!keepFiles) await rm3(dirname7(workspacePath), { recursive: true, force: true });
8767
+ if (!keepFiles) await rm3(dirname8(workspacePath), { recursive: true, force: true });
7858
8768
  }
7859
8769
  async close() {
7860
8770
  if (this.closed) return;
@@ -7866,6 +8776,152 @@ var BotService = class {
7866
8776
  };
7867
8777
  var where = (moved) => `${moved.to.name}, in ${moved.to.directory}`;
7868
8778
 
8779
+ // src/server/service/connection.ts
8780
+ var ALLOW_HTTP = "allow_http";
8781
+ var ConnectionService = class {
8782
+ constructor(store, dataDir) {
8783
+ this.store = store;
8784
+ this.dataDir = dataDir;
8785
+ }
8786
+ material = null;
8787
+ listening = null;
8788
+ /** Called once before listening: the certificate to serve, or null when Jarvy stays on this machine. */
8789
+ prepare(host, port) {
8790
+ this.listening = { host, port };
8791
+ this.material = isLoopbackHost(host) ? null : certificates(this.dataDir, host, this.store.cipher);
8792
+ return this.material;
8793
+ }
8794
+ allowHttp() {
8795
+ return this.store.settings.get(ALLOW_HTTP) === "1";
8796
+ }
8797
+ setAllowHttp(allow) {
8798
+ this.store.settings.set(ALLOW_HTTP, allow ? "1" : null);
8799
+ return this.info();
8800
+ }
8801
+ info() {
8802
+ const listening = this.listening, material = this.material;
8803
+ const addresses = listening && material ? lanAddresses(listening.host).map((address) => `https://${address.includes(":") ? `[${address}]` : address}:${listening.port}`) : [];
8804
+ return { https: !!material, allowHttp: this.allowHttp(), addresses, fingerprint: material?.fingerprint ?? null };
8805
+ }
8806
+ /** The root certificate (DER) for devices to install, when serving HTTPS. */
8807
+ rootCertificate() {
8808
+ return this.material?.rootDer ?? null;
8809
+ }
8810
+ };
8811
+
8812
+ // src/server/service/secrets.ts
8813
+ var SCOPES = ["always", "once"];
8814
+ var SecretService = class {
8815
+ constructor(store, chats) {
8816
+ this.store = store;
8817
+ this.chats = chats;
8818
+ }
8819
+ /** Built from every value on first need, again after any change. */
8820
+ cached = null;
8821
+ list() {
8822
+ return { secrets: this.store.secrets.list(), keyLocation: this.store.cipher.location() };
8823
+ }
8824
+ create(input) {
8825
+ const made = this.store.secrets.create(input);
8826
+ this.cached = null;
8827
+ return made;
8828
+ }
8829
+ update(id, input) {
8830
+ const updated = this.store.secrets.update(id, input) ?? this.missing();
8831
+ this.cached = null;
8832
+ return updated;
8833
+ }
8834
+ delete(id) {
8835
+ if (!this.store.secrets.delete(id)) this.missing();
8836
+ this.cached = null;
8837
+ }
8838
+ /** Grants until revoked, from Settings; a card's "this time only" comes with the request card. */
8839
+ grant(id, botId, granted) {
8840
+ const secret = this.store.secrets.get(id) ?? this.missing();
8841
+ if (secret.kind !== "value") throw new RuntimeFault("secret_missing", "No secret with this id", 404);
8842
+ if (!this.store.getBot(botId)) throw new RuntimeFault("bot_missing", "Bot not found", 404);
8843
+ if (granted) this.store.secrets.grant(id, botId, "always", null);
8844
+ else this.store.secrets.revoke(id, botId);
8845
+ return this.store.secrets.get(id);
8846
+ }
8847
+ uses(id) {
8848
+ if (!this.store.secrets.get(id)) this.missing();
8849
+ return this.store.secrets.uses(id);
8850
+ }
8851
+ masker() {
8852
+ return this.cached ??= new Masker(this.store.secrets.values());
8853
+ }
8854
+ /** secrets.list: what the Bot may use, never a value. */
8855
+ listFor(botId) {
8856
+ const secrets = this.store.secrets.grantedTo(botId).map(({ name: name3, description, scope }) => ({
8857
+ name: name3,
8858
+ placeholder: placeholder(name3),
8859
+ ...description ? { description } : {},
8860
+ ...scope === "once" ? { until: "this run ends" } : {}
8861
+ }));
8862
+ return { secrets, ...secrets.length ? {} : { note: "None yet. If you need one, use secrets.request; never ask for a value in the chat." } };
8863
+ }
8864
+ /** secrets.request: nothing to ask when the Bot may already use it; else the card to post. */
8865
+ prepareRequest(botId, name3, purpose) {
8866
+ if (!SECRET_NAME.test(name3)) throw new ChatError(400, "Secret names are capital letters, digits and underscores, starting with a letter, such as GITHUB_TOKEN");
8867
+ const existing = this.store.secrets.byName(name3), readable = !!existing?.readable;
8868
+ if (readable && this.store.secrets.grantedTo(botId).some((secret) => secret.name === name3)) return { granted: true };
8869
+ return { question: {
8870
+ question: purpose,
8871
+ options: [{ label: "Keep allowing", value: "always" }, { label: "This time only", value: "once" }],
8872
+ multiple: false,
8873
+ allowCustom: false,
8874
+ secret: { name: name3, purpose, exists: !!existing, needsValue: !readable }
8875
+ } };
8876
+ }
8877
+ /** The owner's answer on a request card: saves the value when one is given (a new secret, or a new value for it),
8878
+ * grants it to the Bot that asked, then settles the card. The value never enters the message. */
8879
+ fulfil(actorId, chatId, messageId, input) {
8880
+ const message = this.chats.message(actorId, messageId);
8881
+ const ask = message?.question?.secret;
8882
+ if (!message || message.chatId !== chatId || !ask || message.question?.state !== "pending") throw new ChatError(409, "The question is already settled", "question_closed");
8883
+ if (!this.chats.questions.waitingFor(messageId)) throw new ChatError(409, "The question has expired", "question_expired");
8884
+ const scope = SCOPES.find((item) => item === input.scope);
8885
+ if (!scope) throw new ChatError(400, "scope must be always or once");
8886
+ const botId = message.sender.id, value2 = input.value === void 0 || input.value === "" ? void 0 : input.value;
8887
+ let secret = this.store.secrets.byName(ask.name);
8888
+ if (!secret) secret = this.store.secrets.create({ name: ask.name, description: ask.purpose.replace(/\s+/g, " ").slice(0, 200), value: value2 ?? "" });
8889
+ else if (value2 !== void 0) secret = this.store.secrets.update(secret.id, { value: value2 });
8890
+ else if (!secret.readable) throw new ChatError(400, "A secret needs a value", "secret_value_missing");
8891
+ this.cached = null;
8892
+ this.store.secrets.grant(secret.id, botId, scope, scope === "once" ? this.store.secrets.currentRun(botId) : null);
8893
+ return this.chats.questions.respond(actorId, chatId, messageId, { action: "answer", selected: [SCOPES.indexOf(scope)] });
8894
+ }
8895
+ /** One Bot's access, for its runs. */
8896
+ forBot(botId) {
8897
+ return {
8898
+ resolve: (names) => {
8899
+ const granted = new Map(this.store.secrets.grantedTo(botId).map((secret) => [secret.name, secret.id]));
8900
+ const refused = names.filter((name3) => !granted.has(name3));
8901
+ if (refused.length) return { refused };
8902
+ const values = {};
8903
+ for (const name3 of names) {
8904
+ const value2 = this.store.secrets.value(granted.get(name3));
8905
+ if (value2 === null) return { refused: [name3] };
8906
+ values[name3] = value2;
8907
+ }
8908
+ return { values };
8909
+ },
8910
+ record: (names, use) => {
8911
+ const granted = new Map(this.store.secrets.grantedTo(botId).map((secret) => [secret.name, secret.id]));
8912
+ for (const name3 of names) {
8913
+ const secretId = granted.get(name3);
8914
+ if (secretId) this.store.secrets.record({ secretId, name: name3, botId, how: "command", ...use });
8915
+ }
8916
+ },
8917
+ masker: () => this.masker()
8918
+ };
8919
+ }
8920
+ missing() {
8921
+ throw new RuntimeFault("secret_missing", "No secret with this id", 404);
8922
+ }
8923
+ };
8924
+
7869
8925
  // src/server/application.ts
7870
8926
  var Application = class _Application {
7871
8927
  constructor(store, dataDir, release, factory) {
@@ -7888,17 +8944,20 @@ var Application = class _Application {
7888
8944
  );
7889
8945
  this.files = new ChatFiles(this.chats);
7890
8946
  store.runtime.recover();
8947
+ this.secrets = new SecretService(store, this.chats);
7891
8948
  this.bots = new BotService({
7892
8949
  store,
7893
8950
  chats: this.chats,
7894
8951
  files: this.files,
7895
8952
  credentials: this.credentials,
8953
+ secrets: this.secrets,
7896
8954
  changed: (id) => this.events.publish({ kind: "bot", id, status: this.bots.status(id) }),
7897
8955
  listChanged: () => this.events.publish({ kind: "bots" }),
7898
8956
  factory
7899
8957
  });
7900
8958
  this.auth = new CredentialLogins(store, this.credentials, (id) => this.bots.connectionChanged(id));
7901
- this.account = new AccountService(store, createHash6("sha256").update(dataDir).digest("hex").slice(0, 10));
8959
+ this.connection = new ConnectionService(store, dataDir);
8960
+ this.account = new AccountService(store, createHash7("sha256").update(dataDir).digest("hex").slice(0, 10));
7902
8961
  this.models = new ModelService(store, this.credentials, this.auth, (id) => this.bots.connectionChanged(id), new ModelCatalog(dataDir));
7903
8962
  }
7904
8963
  events = new Changes();
@@ -7910,9 +8969,11 @@ var Application = class _Application {
7910
8969
  auth;
7911
8970
  account;
7912
8971
  models;
8972
+ connection;
8973
+ secrets;
7913
8974
  closing;
7914
8975
  static async open(dataDir, options = {}) {
7915
- await mkdir5(resolve3(dataDir), { recursive: true, mode: 448 });
8976
+ await mkdir6(resolve3(dataDir), { recursive: true, mode: 448 });
7916
8977
  const root = await realpath(resolve3(dataDir));
7917
8978
  let app;
7918
8979
  let release;
@@ -7921,7 +8982,7 @@ var Application = class _Application {
7921
8982
  retries: 0,
7922
8983
  stale: 3e4,
7923
8984
  update: 1e4,
7924
- lockfilePath: join9(root, ".runtime.lock"),
8985
+ lockfilePath: join12(root, ".runtime.lock"),
7925
8986
  onCompromised: () => {
7926
8987
  console.error("[runtime] data lock compromised");
7927
8988
  options.onCompromised?.();
@@ -7961,27 +9022,27 @@ var Application = class _Application {
7961
9022
 
7962
9023
  // src/server/daemon.ts
7963
9024
  import { spawn as spawn2 } from "node:child_process";
7964
- import { closeSync, mkdirSync as mkdirSync3, openSync, readFileSync as readFileSync2, rmSync, writeFileSync } from "node:fs";
7965
- import { join as join10, resolve as resolve4 } from "node:path";
7966
- var pidPath = (dir) => join10(resolve4(dir), "jarvy.pid");
7967
- var logPath = (dir) => join10(resolve4(dir), "jarvy.log");
9025
+ import { closeSync, mkdirSync as mkdirSync5, openSync, readFileSync as readFileSync5, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
9026
+ import { join as join13, resolve as resolve4 } from "node:path";
9027
+ var pidPath = (dir) => join13(resolve4(dir), "jarvy.pid");
9028
+ var logPath = (dir) => join13(resolve4(dir), "jarvy.log");
7968
9029
  function writePidFile(dir, url) {
7969
9030
  const record3 = { pid: process.pid, startToken: startToken(process.pid), url, startedAt: Date.now() };
7970
- writeFileSync(pidPath(dir), JSON.stringify(record3), { mode: 384 });
9031
+ writeFileSync3(pidPath(dir), JSON.stringify(record3), { mode: 384 });
7971
9032
  }
7972
9033
  function removePidFile(dir) {
7973
- if (readRecord(dir)?.pid === process.pid) rmSync(pidPath(dir), { force: true });
9034
+ if (readRecord(dir)?.pid === process.pid) rmSync2(pidPath(dir), { force: true });
7974
9035
  }
7975
9036
  function readRecord(dir) {
7976
9037
  try {
7977
- return JSON.parse(readFileSync2(pidPath(dir), "utf8"));
9038
+ return JSON.parse(readFileSync5(pidPath(dir), "utf8"));
7978
9039
  } catch {
7979
9040
  return null;
7980
9041
  }
7981
9042
  }
7982
9043
  function startToken(pid) {
7983
9044
  try {
7984
- return readFileSync2(`/proc/${pid}/stat`, "utf8").split(") ").pop().split(" ")[19] ?? "";
9045
+ return readFileSync5(`/proc/${pid}/stat`, "utf8").split(") ").pop().split(" ")[19] ?? "";
7985
9046
  } catch {
7986
9047
  return "";
7987
9048
  }
@@ -7999,13 +9060,13 @@ function runningServer(dir) {
7999
9060
  if (!record3) return null;
8000
9061
  const token = alive(record3.pid) ? startToken(record3.pid) : null;
8001
9062
  if (token === null || token && record3.startToken && token !== record3.startToken) {
8002
- rmSync(pidPath(dir), { force: true });
9063
+ rmSync2(pidPath(dir), { force: true });
8003
9064
  return null;
8004
9065
  }
8005
9066
  return record3;
8006
9067
  }
8007
9068
  function startBackground(script, runArgs, dir) {
8008
- mkdirSync3(resolve4(dir), { recursive: true, mode: 448 });
9069
+ mkdirSync5(resolve4(dir), { recursive: true, mode: 448 });
8009
9070
  const log = openSync(logPath(dir), "a", 384);
8010
9071
  const child = spawn2(process.execPath, [...process.execArgv, script, "run", ...runArgs], { detached: true, stdio: ["ignore", log, log, "ipc"] });
8011
9072
  closeSync(log);
@@ -8024,7 +9085,7 @@ function startBackground(script, runArgs, dir) {
8024
9085
  }
8025
9086
  function lastLogLine(dir) {
8026
9087
  try {
8027
- return readFileSync2(logPath(dir), "utf8").slice(-4e3).trim().split("\n").reverse().find((line) => line.startsWith("Jarvy: "))?.slice(7) ?? null;
9088
+ return readFileSync5(logPath(dir), "utf8").slice(-4e3).trim().split("\n").reverse().find((line) => line.startsWith("Jarvy: "))?.slice(7) ?? null;
8028
9089
  } catch {
8029
9090
  return null;
8030
9091
  }
@@ -8040,7 +9101,7 @@ async function stopServer(dir, waitMs = 3e4) {
8040
9101
  process.kill(record3.pid, "SIGTERM");
8041
9102
  for (const deadline = Date.now() + waitMs; Date.now() < deadline; ) {
8042
9103
  if (!alive(record3.pid)) {
8043
- rmSync(pidPath(dir), { force: true });
9104
+ rmSync2(pidPath(dir), { force: true });
8044
9105
  return "stopped";
8045
9106
  }
8046
9107
  await new Promise((done) => setTimeout(done, 200));
@@ -8106,19 +9167,21 @@ async function initialize(store) {
8106
9167
  store.initialize(username, password);
8107
9168
  console.log(`Account ${username} initialized.`);
8108
9169
  }
8109
- function bindWarning(host) {
8110
- if (host === "0.0.0.0" || host === "::") return "WARNING: public bind over plain HTTP exposes passwords and API keys on untrusted networks. Use a trusted LAN or TLS reverse proxy.";
8111
- if (host !== "127.0.0.1" && host !== "localhost" && host !== "::1") return "WARNING: plain HTTP does not encrypt passwords or API keys. Use TLS for remote access.";
8112
- return null;
9170
+ function reachNotes(host, port, allowHttp) {
9171
+ if (isLoopbackHost(host)) return { info: [], warning: null };
9172
+ const info = lanAddresses(host).map((address) => `On your network: https://${address.includes(":") ? `[${address}]` : address}:${port}`);
9173
+ info.push("To trust Jarvy on other devices, install its certificate from Settings \u2192 Network.");
9174
+ return { info, warning: allowHttp ? "WARNING: HTTP access is allowed in Settings \u2192 Network, so passwords and conversations reach other devices unencrypted." : null };
8113
9175
  }
8114
9176
  function listenOptions(args) {
8115
9177
  const host = option(args, "--host", "127.0.0.1");
8116
9178
  const port = Number(option(args, "--port", "8080"));
8117
9179
  if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Port must be between 1 and 65535");
8118
- return { host, port, url: `http://${host === "0.0.0.0" || host === "::" ? "localhost" : host}:${port}` };
9180
+ const wildcard = host === "0.0.0.0" || host === "::";
9181
+ return { host, port, url: wildcard || isLoopbackHost(host) ? `http://${wildcard ? "localhost" : host}:${port}` : `https://${host}:${port}` };
8119
9182
  }
8120
9183
  async function startDetached(args, dir) {
8121
- const { host } = listenOptions(args);
9184
+ const { host, port } = listenOptions(args);
8122
9185
  const running = runningServer(dir);
8123
9186
  const offCommand = `jarvy off${dir === defaultDataDir() ? "" : ` --data-dir ${dir}`}`;
8124
9187
  if (running) {
@@ -8126,17 +9189,20 @@ async function startDetached(args, dir) {
8126
9189
  return;
8127
9190
  }
8128
9191
  const app = await Application.open(dir);
9192
+ let allowHttp = false;
8129
9193
  try {
8130
9194
  if (!app.store.isInitialized()) await initialize(app.store);
9195
+ allowHttp = app.connection.allowHttp();
8131
9196
  } finally {
8132
9197
  await app.close();
8133
9198
  }
8134
9199
  const started = await startBackground(process.argv[1], args.slice(1), dir);
8135
9200
  console.log(`Jarvy running in background at ${started.url} (pid ${started.pid}, listening on ${host})`);
9201
+ const notes = reachNotes(host, port, allowHttp);
9202
+ for (const line of notes.info) console.log(line);
8136
9203
  console.log(`Log: ${logPath(dir)}
8137
9204
  Stop with: ${offCommand}`);
8138
- const warning = bindWarning(host);
8139
- if (warning) console.warn(warning);
9205
+ if (notes.warning) console.warn(notes.warning);
8140
9206
  }
8141
9207
  async function stopDetached(dir) {
8142
9208
  const result = await stopServer(dir);
@@ -8183,33 +9249,24 @@ async function main() {
8183
9249
  try {
8184
9250
  if (!store.isInitialized()) await initialize(store);
8185
9251
  const { host, port, url } = listenOptions(args);
8186
- const webRoot = join11(dirname8(fileURLToPath(import.meta.url)), "../web");
8187
- if (!existsSync2(join11(webRoot, "index.html"))) throw new Error("Web build missing. Run npm run build first");
8188
- const server = createJarvyServer(app, webRoot);
8189
- closeHttp = () => {
8190
- server.close();
8191
- server.closeAllConnections();
8192
- };
8193
- await new Promise((resolve5, reject) => {
8194
- server.once("error", reject);
8195
- server.listen(port, host, () => {
8196
- server.off("error", reject);
8197
- resolve5();
8198
- });
8199
- });
9252
+ const webRoot = join14(dirname9(fileURLToPath2(import.meta.url)), "../web");
9253
+ if (!existsSync5(join14(webRoot, "index.html"))) throw new Error("Web build missing. Run npm run build first");
9254
+ const server = createJarvyServer(app, webRoot, app.connection.prepare(host, port));
9255
+ closeHttp = () => server.close();
9256
+ await server.listen(port, host);
8200
9257
  app.start();
8201
9258
  writePidFile(dir, url);
8202
9259
  console.log(`Jarvy running at ${url} (listening on ${host}, pid ${process.pid})`);
9260
+ const notes = reachNotes(host, port, app.connection.allowHttp());
9261
+ for (const line of notes.info) console.log(line);
8203
9262
  console.log(`Data: ${store.path}`);
8204
- const warning = bindWarning(host);
8205
- if (warning) console.warn(warning);
9263
+ if (notes.warning) console.warn(notes.warning);
8206
9264
  signalReady(url);
8207
9265
  let shuttingDown = false;
8208
9266
  const shutdown = () => {
8209
9267
  if (shuttingDown) return;
8210
9268
  shuttingDown = true;
8211
9269
  server.close();
8212
- server.closeAllConnections();
8213
9270
  void app.close().then(() => {
8214
9271
  removePidFile(dir);
8215
9272
  console.log("Jarvy stopped.");