@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.
- package/CHANGELOG.md +24 -0
- package/README.md +3 -3
- package/README.zh-CN.md +3 -3
- package/THIRD_PARTY_NOTICE.md +1 -0
- package/dist/server/cli.js +1365 -308
- package/dist/web/assets/index-BgQn9NWS.js +586 -0
- package/dist/web/assets/index-DeyQgcnD.css +1 -0
- package/dist/web/index.html +2 -2
- package/licenses/LOBE-ICONS-MIT.txt +23 -0
- package/package.json +2 -1
- package/skills/dev-team/SKILL.md +64 -0
- package/dist/web/assets/index-Ds2S6THj.js +0 -536
- package/dist/web/assets/index-TrYYVs4D.css +0 -1
package/dist/server/cli.js
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/server/cli.ts
|
|
4
|
-
import { existsSync as
|
|
4
|
+
import { existsSync as existsSync5 } from "node:fs";
|
|
5
5
|
import { createInterface } from "node:readline/promises";
|
|
6
|
-
import { dirname as
|
|
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
|
|
15
|
-
import { HTTPException as
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
setCookie(c,
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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 = `${
|
|
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
|
|
502
|
+
let name3;
|
|
500
503
|
try {
|
|
501
|
-
|
|
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"),
|
|
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:
|
|
541
|
-
if (!/\.html?$/i.test(
|
|
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
|
|
848
|
+
const app = new Hono12();
|
|
792
849
|
app.use("*", headers);
|
|
793
850
|
app.onError(handleError);
|
|
794
|
-
const api = new
|
|
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
|
|
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
|
|
816
|
-
throw new
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
833
|
-
import { chmodSync, mkdirSync } from "node:fs";
|
|
834
|
-
import { homedir } from "node:os";
|
|
835
|
-
import { dirname as dirname2, join as
|
|
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
|
|
961
|
-
for (let suffix = 2; taken.has(
|
|
962
|
-
taken.add(
|
|
963
|
-
if (
|
|
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 >
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
1580
|
-
if (!
|
|
1581
|
-
rest = rest.slice(
|
|
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,
|
|
1655
|
-
this.chats.assertAccess(actorId, threadId,
|
|
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,
|
|
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:
|
|
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
|
|
1810
|
-
return { unread: 0, mentions: 0, readSequence:
|
|
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
|
|
1816
|
-
return { unread, mentions, readSequence:
|
|
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,
|
|
1823
|
-
if (!this.canAccess(actorId, chatId,
|
|
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,
|
|
2272
|
+
createAttachment(actorId, chatId, name3, mimeType, data) {
|
|
2075
2273
|
this.assertAccess(actorId, chatId, true);
|
|
2076
|
-
const file = { id: randomUUID5(), name:
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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
|
|
2397
|
-
for (let n = 2; taken.get({ name:
|
|
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(
|
|
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,
|
|
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 ||
|
|
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
|
|
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:
|
|
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
|
-
|
|
2581
|
-
this.path =
|
|
2945
|
+
mkdirSync2(root, { recursive: true, mode: 448 });
|
|
2946
|
+
this.path = join3(root, DATABASE_FILENAME);
|
|
2582
2947
|
this.db = new DatabaseSync(this.path);
|
|
2583
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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(
|
|
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 =
|
|
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 =
|
|
2654
|
-
|
|
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(
|
|
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:
|
|
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
|
|
2691
|
-
if (
|
|
2692
|
-
if (this.nameTaken(
|
|
2693
|
-
return
|
|
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(
|
|
2697
|
-
const clean = this.botName(
|
|
2698
|
-
const id =
|
|
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
|
|
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(
|
|
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 =
|
|
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,
|
|
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(
|
|
2783
|
-
if (this.db.prepare("SELECT 1 FROM provider_connections WHERE owner_id=? AND kind='custom' AND provider_name=? AND id<>?").get(this.userMemberId(),
|
|
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
|
|
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(
|
|
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,
|
|
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 [
|
|
2832
|
-
if (value2 === null) headers2.delete(
|
|
2833
|
-
else if (typeof value2 === "string") headers2.set(
|
|
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,
|
|
2839
|
-
literalHeaders[
|
|
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.
|
|
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.
|
|
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
|
|
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 =
|
|
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
|
|
3472
|
-
import { mkdir as
|
|
3473
|
-
import { join as
|
|
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
|
|
3485
|
-
if (!
|
|
3486
|
-
const next = text3[at2 + 1 +
|
|
3487
|
-
return !(next && WORD.test(next) && WORD.test(
|
|
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
|
|
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
|
|
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,
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
3800
|
-
if (/[\n\r]/.test(
|
|
3801
|
-
return
|
|
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
|
|
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 [
|
|
4399
|
-
sections[
|
|
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:
|
|
4966
|
+
const { name: name3, description, parameters, ...specific } = tool;
|
|
4408
4967
|
if (Object.keys(specific).length) toolProtocol[index] = value(specific);
|
|
4409
|
-
return { name:
|
|
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
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
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,
|
|
5227
|
+
let planes, bits2;
|
|
4579
5228
|
if (header === 12) {
|
|
4580
5229
|
planes = u16le(bytes, 22);
|
|
4581
|
-
|
|
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
|
-
|
|
5234
|
+
bits2 = u16le(bytes, 28);
|
|
4586
5235
|
} else return false;
|
|
4587
|
-
return planes === 1 && [1, 4, 8, 16, 24, 32].includes(
|
|
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),
|
|
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 }) =>
|
|
4612
|
-
|
|
4613
|
-
|
|
4614
|
-
|
|
4615
|
-
|
|
4616
|
-
|
|
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:
|
|
4622
|
-
createEditToolDefinition(cwd, { operations: { readFile: readFile3, writeFile:
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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(
|
|
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((
|
|
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,
|
|
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))
|
|
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(
|
|
5663
|
+
steer(xml2) {
|
|
4865
5664
|
if (!this.promptActive || this.session.isIdle) return false;
|
|
4866
|
-
void this.session.steer(
|
|
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(
|
|
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(
|
|
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,
|
|
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
|
|
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
|
|
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:${
|
|
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(
|
|
5873
|
+
const dir = await mkdtemp(join6(tmpdir(), "jarvy-computer-"));
|
|
5075
5874
|
try {
|
|
5076
|
-
await writeFile2(
|
|
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 [
|
|
5099
|
-
if (
|
|
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(
|
|
5902
|
+
async function containerState(name3) {
|
|
5104
5903
|
try {
|
|
5105
|
-
return await docker(["inspect", "--format", "{{.State.Running}}",
|
|
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(
|
|
5135
|
-
await docker(["start",
|
|
5933
|
+
async function startContainer(name3) {
|
|
5934
|
+
await docker(["start", name3], 6e4);
|
|
5136
5935
|
}
|
|
5137
|
-
async function homeAtWorkspace(
|
|
5138
|
-
const home = await docker(["exec",
|
|
5139
|
-
if (home !== COMPUTER_CWD) await docker(["exec", "-u", "root",
|
|
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(
|
|
5142
|
-
await docker(["stop", "--time", "5",
|
|
5940
|
+
async function stopContainer(name3) {
|
|
5941
|
+
await docker(["stop", "--time", "5", name3], 6e4);
|
|
5143
5942
|
}
|
|
5144
|
-
async function removeContainer(
|
|
5943
|
+
async function removeContainer(name3) {
|
|
5145
5944
|
try {
|
|
5146
|
-
await docker(["rm", "--force",
|
|
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 =
|
|
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
|
|
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:
|
|
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
|
|
5318
|
-
import { dirname as dirname4, join as
|
|
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 =
|
|
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(
|
|
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:
|
|
5436
|
-
daemonDir:
|
|
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,
|
|
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
|
|
5525
|
-
this.log(`removing ${
|
|
5526
|
-
await removeContainer(
|
|
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
|
|
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
|
-
|
|
6359
|
+
mkdirSync4(dirname5(path), { recursive: true, mode: 448 });
|
|
5561
6360
|
this.db = new DatabaseSync2(path);
|
|
5562
|
-
|
|
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:
|
|
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
|
|
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 ?
|
|
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
|
|
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
|
|
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(
|
|
6686
|
+
async writeTempFile(name3, content2, signal) {
|
|
5888
6687
|
await this.prepare();
|
|
5889
|
-
const path =
|
|
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
|
-
|
|
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(
|
|
6759
|
+
async writeTempFile(name3, content2, signal) {
|
|
5909
6760
|
const env = this.current();
|
|
5910
|
-
if (env.kind === "host") return this.local.writeTempFile(
|
|
5911
|
-
const path = `${env.cwd}/temp/${
|
|
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(
|
|
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(
|
|
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(
|
|
5922
|
-
target2 = `${env.cwd}/temp/files/${stem}-${id.slice(0, 8)}${
|
|
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(
|
|
5948
|
-
const cleaned =
|
|
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
|
|
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
|
|
6054
|
-
return workspace.writeTempFile(
|
|
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 = (
|
|
6091
|
-
var
|
|
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,
|
|
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,
|
|
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((
|
|
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(
|
|
6505
|
-
switch (
|
|
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,
|
|
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(([
|
|
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
|
|
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:
|
|
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,
|
|
6678
|
-
return this.agent.call(
|
|
6679
|
-
switch (
|
|
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((
|
|
6767
|
-
const subscribers = this.scope.chats.channels.subscribers(this.scope.botId, chatId).map((member) => member.name).filter((
|
|
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
|
-
|
|
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
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/\r/g, " ");
|
|
6807
7699
|
}
|
|
6808
|
-
function attribute(
|
|
6809
|
-
if (!value2.trim()) throw new Error(`Missing ${
|
|
6810
|
-
return ` ${
|
|
7700
|
+
function attribute(name3, value2) {
|
|
7701
|
+
if (!value2.trim()) throw new Error(`Missing ${name3}`);
|
|
7702
|
+
return ` ${name3}="${escape(value2).replace(/"/g, """).replace(/\n/g, " ").replace(/\t/g, "	")}"`;
|
|
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 = (
|
|
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,
|
|
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(
|
|
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(
|
|
7488
|
-
const bot = this.store.listBots().find((record3) => same2(
|
|
7489
|
-
if (!bot) throw new ChatError(404, `No Bot named ${
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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.
|
|
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
|
|
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:
|
|
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
|
|
7965
|
-
import { join as
|
|
7966
|
-
var pidPath = (dir) =>
|
|
7967
|
-
var logPath = (dir) =>
|
|
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
|
-
|
|
9031
|
+
writeFileSync3(pidPath(dir), JSON.stringify(record3), { mode: 384 });
|
|
7971
9032
|
}
|
|
7972
9033
|
function removePidFile(dir) {
|
|
7973
|
-
if (readRecord(dir)?.pid === process.pid)
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
8110
|
-
if (host
|
|
8111
|
-
|
|
8112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
8187
|
-
if (!
|
|
8188
|
-
const server = createJarvyServer(app, webRoot);
|
|
8189
|
-
closeHttp = () =>
|
|
8190
|
-
|
|
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
|
-
|
|
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.");
|