@goodfish/jarvy 0.8.0 → 0.9.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/dist/server/cli.js +289 -112
- package/dist/web/assets/index-8mJR5pGx.js +604 -0
- package/dist/web/assets/index-eWqQZ8ur.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-Cl_aFSP0.js +0 -594
- package/dist/web/assets/index-CxbCVrx3.css +0 -1
package/dist/server/cli.js
CHANGED
|
@@ -301,7 +301,8 @@ function messageView(message) {
|
|
|
301
301
|
echoOf: message.echoOf,
|
|
302
302
|
replyTo: message.replyTo,
|
|
303
303
|
reactions: message.reactions,
|
|
304
|
-
quotes: message.quotes
|
|
304
|
+
quotes: message.quotes,
|
|
305
|
+
folded: message.folded
|
|
305
306
|
};
|
|
306
307
|
}
|
|
307
308
|
|
|
@@ -680,6 +681,7 @@ function channelRoutes({ chats }) {
|
|
|
680
681
|
const input = await readJson(c);
|
|
681
682
|
return c.json(messageView(chats.react(c.get("actorId"), c.req.param("id"), input.emoji, input.on ?? true)));
|
|
682
683
|
});
|
|
684
|
+
routes.put("/messages/:id/fold", jsonBody, async (c) => c.json(messageView(chats.fold(c.get("actorId"), c.req.param("id"), (await readJson(c)).folded))));
|
|
683
685
|
routes.put("/chats/:id/mute", jsonBody, async (c) => c.json(chat(c.get("actorId"), chats.mute(c.get("actorId"), c.req.param("id"), (await readJson(c)).muted).id)));
|
|
684
686
|
routes.put("/chats/:id/pin", jsonBody, async (c) => c.json(chat(c.get("actorId"), chats.pin(c.get("actorId"), c.req.param("id"), (await readJson(c)).pinned).id)));
|
|
685
687
|
routes.get("/members/:id/dms", (c) => c.json({ chats: chats.botDirects(c.get("actorId"), c.req.param("id")).map((item) => chatView(item, (id) => chats.member(id))) }));
|
|
@@ -857,6 +859,7 @@ function environmentRoutes(bots) {
|
|
|
857
859
|
const input = await readJson(c);
|
|
858
860
|
return c.json(await machines.scan({ host: input.host, port: input.port, user: input.user }));
|
|
859
861
|
});
|
|
862
|
+
routes.post("/keys", async (c) => c.json(await machines.prepareKey(), 201));
|
|
860
863
|
routes.post("/", jsonBody, async (c) => {
|
|
861
864
|
const input = await readJson(c);
|
|
862
865
|
return c.json(await machines.add({
|
|
@@ -866,9 +869,14 @@ function environmentRoutes(bots) {
|
|
|
866
869
|
user: input.user,
|
|
867
870
|
directory: input.directory,
|
|
868
871
|
password: input.password,
|
|
872
|
+
...input.keyId !== void 0 ? { keyId: input.keyId } : {},
|
|
869
873
|
hostKeys: hostKeys(input.hostKeys)
|
|
870
874
|
}), 201);
|
|
871
875
|
});
|
|
876
|
+
routes.put("/:id/password", jsonBody, async (c) => {
|
|
877
|
+
const input = await readJson(c);
|
|
878
|
+
return c.json(await machines.setPassword(c.req.param("id"), input.password));
|
|
879
|
+
});
|
|
872
880
|
routes.put("/:id", jsonBody, async (c) => {
|
|
873
881
|
const input = await readJson(c);
|
|
874
882
|
return c.json(machines.update(c.req.param("id"), { name: input.name, directory: input.directory }));
|
|
@@ -890,7 +898,12 @@ function environmentRoutes(bots) {
|
|
|
890
898
|
});
|
|
891
899
|
routes.post("/requests/:chatId/:messageId", jsonBody, async (c) => {
|
|
892
900
|
const input = await readJson(c);
|
|
893
|
-
return c.json(messageView(await machines.fulfil(
|
|
901
|
+
return c.json(messageView(await machines.fulfil(
|
|
902
|
+
c.get("actorId"),
|
|
903
|
+
c.req.param("chatId"),
|
|
904
|
+
c.req.param("messageId"),
|
|
905
|
+
{ password: input.password, ...input.keyId !== void 0 ? { keyId: input.keyId } : {} }
|
|
906
|
+
)));
|
|
894
907
|
});
|
|
895
908
|
return routes;
|
|
896
909
|
}
|
|
@@ -1513,6 +1526,34 @@ function migrateTasks(db) {
|
|
|
1513
1526
|
}
|
|
1514
1527
|
}
|
|
1515
1528
|
|
|
1529
|
+
// src/server/infra/storage/migrations/ssh-login-schema.ts
|
|
1530
|
+
function migrateSshLogin(db) {
|
|
1531
|
+
const version = db.prepare("PRAGMA user_version").get().user_version;
|
|
1532
|
+
if (version >= 24) return;
|
|
1533
|
+
db.exec("BEGIN IMMEDIATE");
|
|
1534
|
+
try {
|
|
1535
|
+
db.exec("ALTER TABLE environments ADD COLUMN password TEXT; PRAGMA user_version=24");
|
|
1536
|
+
db.exec("COMMIT");
|
|
1537
|
+
} catch (error) {
|
|
1538
|
+
db.exec("ROLLBACK");
|
|
1539
|
+
throw error;
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
// src/server/infra/storage/migrations/fold-schema.ts
|
|
1544
|
+
function migrateFolds(db) {
|
|
1545
|
+
const version = db.prepare("PRAGMA user_version").get().user_version;
|
|
1546
|
+
if (version >= 25) return;
|
|
1547
|
+
db.exec("BEGIN IMMEDIATE");
|
|
1548
|
+
try {
|
|
1549
|
+
db.exec("ALTER TABLE messages ADD COLUMN folded_at INTEGER; PRAGMA user_version=25;");
|
|
1550
|
+
db.exec("COMMIT");
|
|
1551
|
+
} catch (error) {
|
|
1552
|
+
db.exec("ROLLBACK");
|
|
1553
|
+
throw error;
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1516
1557
|
// src/server/infra/storage/migrations/schema.ts
|
|
1517
1558
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1518
1559
|
|
|
@@ -1635,7 +1676,7 @@ function migrateRuntime(db) {
|
|
|
1635
1676
|
// src/server/infra/storage/migrations/schema.ts
|
|
1636
1677
|
function migrateDatabase(db, cipher) {
|
|
1637
1678
|
const version = db.prepare("PRAGMA user_version").get().user_version;
|
|
1638
|
-
if (version >
|
|
1679
|
+
if (version > 25) throw new Error(`Unsupported database version: ${version}`);
|
|
1639
1680
|
migratePrototype(db);
|
|
1640
1681
|
if (version >= 5) {
|
|
1641
1682
|
migrateRuntime(db);
|
|
@@ -1656,6 +1697,8 @@ function migrateDatabase(db, cipher) {
|
|
|
1656
1697
|
migrateSecrets(db, cipher);
|
|
1657
1698
|
migrateSsh(db);
|
|
1658
1699
|
migrateTasks(db);
|
|
1700
|
+
migrateSshLogin(db);
|
|
1701
|
+
migrateFolds(db);
|
|
1659
1702
|
return;
|
|
1660
1703
|
}
|
|
1661
1704
|
db.exec("PRAGMA foreign_keys=OFF; BEGIN IMMEDIATE");
|
|
@@ -1734,6 +1777,8 @@ function migrateDatabase(db, cipher) {
|
|
|
1734
1777
|
migrateSecrets(db, cipher);
|
|
1735
1778
|
migrateSsh(db);
|
|
1736
1779
|
migrateTasks(db);
|
|
1780
|
+
migrateSshLogin(db);
|
|
1781
|
+
migrateFolds(db);
|
|
1737
1782
|
}
|
|
1738
1783
|
|
|
1739
1784
|
// src/server/infra/storage/chat-repository.ts
|
|
@@ -2095,6 +2140,10 @@ var InteractionRepository = class {
|
|
|
2095
2140
|
return this.db.prepare(`SELECT DISTINCT member_id AS id FROM chat_states WHERE muted=1 AND chat_id IN
|
|
2096
2141
|
(SELECT id FROM chats WHERE id=? UNION SELECT parent_id FROM chats WHERE id=? AND parent_id IS NOT NULL)`).all(chatId, chatId).map((row) => row.id);
|
|
2097
2142
|
}
|
|
2143
|
+
/** Folds a message to one line in the interface, or unfolds it (A60); its content and everything else stay. */
|
|
2144
|
+
setFolded(messageId, folded) {
|
|
2145
|
+
this.db.prepare("UPDATE messages SET folded_at=? WHERE id=?").run(folded ? Date.now() : null, messageId);
|
|
2146
|
+
}
|
|
2098
2147
|
setPinned(memberId, chatId, pinned) {
|
|
2099
2148
|
this.chats.assertAccess(memberId, chatId);
|
|
2100
2149
|
this.db.prepare(`INSERT INTO chat_states(chat_id,member_id,pinned) VALUES(?,?,?)
|
|
@@ -2117,7 +2166,7 @@ var QuoteRepository = class {
|
|
|
2117
2166
|
};
|
|
2118
2167
|
|
|
2119
2168
|
// src/server/infra/storage/chat-repository.ts
|
|
2120
|
-
var MESSAGE_COLUMNS = "id,chat_id AS chatId,sequence,sender_id AS senderId,content,created_at AS createdAt,history_id AS historyId,tool_call_id AS toolCallId,question,echo_of AS echoOf,reply_to AS replyTo";
|
|
2169
|
+
var MESSAGE_COLUMNS = "id,chat_id AS chatId,sequence,sender_id AS senderId,content,created_at AS createdAt,history_id AS historyId,tool_call_id AS toolCallId,question,echo_of AS echoOf,reply_to AS replyTo,folded_at AS foldedAt";
|
|
2121
2170
|
var CHAT_COLUMNS = `c.id,c.type,c.title,c.owner_id AS ownerId,c.created_at AS createdAt,c.updated_at AS updatedAt,
|
|
2122
2171
|
c.section_id AS sectionId,c.topic,c.parent_id AS parentId,c.root_message_id AS rootMessageId,c.status,
|
|
2123
2172
|
(SELECT content FROM messages WHERE chat_id=c.id ORDER BY sequence DESC LIMIT 1) AS lastMessage,
|
|
@@ -2272,12 +2321,12 @@ var ChatRepository = class {
|
|
|
2272
2321
|
return rows2.map((row) => this.messageFromRow(row));
|
|
2273
2322
|
}
|
|
2274
2323
|
/** Search every chat the member can read: its DMs and channels and their threads (newest first). `within` keeps
|
|
2275
|
-
* one chat, a channel with its threads. */
|
|
2276
|
-
searchAll(actorId, query, limit, within) {
|
|
2277
|
-
const scope = within ? "AND (c.id=? OR c.parent_id=?)" : "";
|
|
2324
|
+
* one chat, a channel with its threads; `sender` keeps one member's messages. */
|
|
2325
|
+
searchAll(actorId, query, limit, within, sender) {
|
|
2326
|
+
const scope = (within ? "AND (c.id=? OR c.parent_id=?) " : "") + (sender ? "AND m.sender_id=?" : "");
|
|
2278
2327
|
const rows2 = this.db.prepare(`SELECT ${MESSAGE_COLUMNS.split(",").map((column) => `m.${column}`).join(",")} FROM messages m JOIN chats c ON c.id=m.chat_id
|
|
2279
2328
|
JOIN chat_members cm ON cm.chat_id=COALESCE(c.parent_id,c.id) AND cm.member_id=? AND cm.can_read=1
|
|
2280
|
-
WHERE instr(lower(m.content),lower(?))>0 ${scope} ORDER BY m.position DESC LIMIT ?`).all(actorId, query, ...within ? [within, within] : [], limit);
|
|
2329
|
+
WHERE instr(lower(m.content),lower(?))>0 ${scope} ORDER BY m.position DESC LIMIT ?`).all(actorId, query, ...within ? [within, within] : [], ...sender ? [sender] : [], limit);
|
|
2281
2330
|
return rows2.map((row) => this.messageFromRow(row));
|
|
2282
2331
|
}
|
|
2283
2332
|
/** Channels and threads the member can read whose title, topic or notice / summary contains the query (newest first). */
|
|
@@ -2296,9 +2345,10 @@ var ChatRepository = class {
|
|
|
2296
2345
|
const row = this.db.prepare(`SELECT ${MESSAGE_COLUMNS} FROM messages WHERE id=?`).get(id);
|
|
2297
2346
|
return row && this.canAccess(actorId, row.chatId) ? this.messageFromRow(row) : null;
|
|
2298
2347
|
}
|
|
2299
|
-
search(actorId, chatId, query, limit) {
|
|
2348
|
+
search(actorId, chatId, query, limit, sender) {
|
|
2300
2349
|
this.assertAccess(actorId, chatId);
|
|
2301
|
-
const rows2 = this.db.prepare(`SELECT ${MESSAGE_COLUMNS} FROM messages WHERE chat_id=? AND instr(lower(content),lower(?))>0
|
|
2350
|
+
const rows2 = this.db.prepare(`SELECT ${MESSAGE_COLUMNS} FROM messages WHERE chat_id=? AND instr(lower(content),lower(?))>0 ${sender ? "AND sender_id=?" : ""}
|
|
2351
|
+
ORDER BY sequence DESC LIMIT ?`).all(chatId, query, ...sender ? [sender] : [], limit);
|
|
2302
2352
|
return rows2.map((row) => this.messageFromRow(row));
|
|
2303
2353
|
}
|
|
2304
2354
|
sentActivity(actorId, senderId, before, limit) {
|
|
@@ -2329,7 +2379,8 @@ var ChatRepository = class {
|
|
|
2329
2379
|
echoOf: row.echoOf === null ? null : { id: row.echoOf, title: this.db.prepare("SELECT title FROM chats WHERE id=?").get(row.echoOf).title },
|
|
2330
2380
|
replyTo: row.replyTo === null ? null : this.quote(row.replyTo),
|
|
2331
2381
|
reactions: this.interactions.reactions(row.id),
|
|
2332
|
-
quotes: this.quotes.of(row.id)
|
|
2382
|
+
quotes: this.quotes.of(row.id),
|
|
2383
|
+
folded: row.foldedAt !== null
|
|
2333
2384
|
};
|
|
2334
2385
|
}
|
|
2335
2386
|
/** What a reply shows of the message it answers: who wrote it and the start of its content, with its quotes ("" is attachments only). */
|
|
@@ -2391,14 +2442,15 @@ var ChatRepository = class {
|
|
|
2391
2442
|
toolCallId: draft.source?.toolCallId ?? null,
|
|
2392
2443
|
question: null,
|
|
2393
2444
|
echoOf: chatId,
|
|
2394
|
-
replyTo: null
|
|
2445
|
+
replyTo: null,
|
|
2446
|
+
foldedAt: null
|
|
2395
2447
|
});
|
|
2396
2448
|
}
|
|
2397
2449
|
}
|
|
2398
2450
|
const file = this.db.prepare("SELECT name FROM attachments WHERE message_id=? ORDER BY created_at,rowid LIMIT 1").get(id);
|
|
2399
2451
|
const title = draft.content ? previewQuotes(draft.content, draft.quotes ?? []).slice(0, 46).replace(/\s+/g, " ") : file?.name.slice(0, 46) ?? "";
|
|
2400
2452
|
this.db.prepare("UPDATE chats SET title=CASE WHEN title='' AND ?=1 THEN ? ELSE title END,updated_at=? WHERE id=?").run(sequence, title, createdAt, chatId);
|
|
2401
|
-
const message = this.messageFromRow({ id, chatId, sequence, senderId: actorId, content: draft.content, createdAt, historyId: draft.source?.historyId ?? null, toolCallId: draft.source?.toolCallId ?? null, question, echoOf: null, replyTo: draft.replyTo ?? null });
|
|
2453
|
+
const message = this.messageFromRow({ id, chatId, sequence, senderId: actorId, content: draft.content, createdAt, historyId: draft.source?.historyId ?? null, toolCallId: draft.source?.toolCallId ?? null, question, echoOf: null, replyTo: draft.replyTo ?? null, foldedAt: null });
|
|
2402
2454
|
this.db.exec("COMMIT");
|
|
2403
2455
|
return { message, duplicate: false, ...echo ? { echo } : {} };
|
|
2404
2456
|
} catch (error) {
|
|
@@ -2884,7 +2936,7 @@ var EnvironmentRepository = class {
|
|
|
2884
2936
|
}
|
|
2885
2937
|
addSsh(input, id = randomUUID7()) {
|
|
2886
2938
|
const { host, port, user, home, platform } = input;
|
|
2887
|
-
this.db.prepare("INSERT INTO environments(id,owner_id,kind,name,connection,directory,key,host_keys,created_at) VALUES(?,?,'ssh'
|
|
2939
|
+
this.db.prepare("INSERT INTO environments(id,owner_id,kind,name,connection,directory,key,host_keys,password,created_at) VALUES(?,?,'ssh',?,?,?,?,?,?,?)").run(
|
|
2888
2940
|
id,
|
|
2889
2941
|
this.ownerId(),
|
|
2890
2942
|
input.name,
|
|
@@ -2892,20 +2944,34 @@ var EnvironmentRepository = class {
|
|
|
2892
2944
|
input.directory,
|
|
2893
2945
|
this.cipher.seal(input.privateKey, `environment:${id}`),
|
|
2894
2946
|
JSON.stringify(input.hostKeys),
|
|
2947
|
+
input.password ? this.cipher.seal(input.password, `environment-password:${id}`) : null,
|
|
2895
2948
|
Date.now()
|
|
2896
2949
|
);
|
|
2897
2950
|
return this.get(id);
|
|
2898
2951
|
}
|
|
2952
|
+
/** `password`: a new one for a machine reached with its password; null goes back to the key (A59). */
|
|
2899
2953
|
updateSsh(id, input) {
|
|
2954
|
+
if (input.password !== void 0) this.db.prepare("UPDATE environments SET password=? WHERE id=? AND kind='ssh'").run(input.password === null ? null : this.cipher.seal(input.password, `environment-password:${id}`), id);
|
|
2900
2955
|
if (input.name !== void 0) this.db.prepare("UPDATE environments SET name=? WHERE id=? AND kind='ssh'").run(input.name, id);
|
|
2901
2956
|
if (input.directory !== void 0) this.db.prepare("UPDATE environments SET directory=? WHERE id=? AND kind='ssh'").run(input.directory, id);
|
|
2902
2957
|
if (input.hostKeys !== void 0) this.db.prepare("UPDATE environments SET host_keys=? WHERE id=? AND kind='ssh'").run(JSON.stringify(input.hostKeys), id);
|
|
2903
2958
|
}
|
|
2904
|
-
/** The machine's private key (null when it doesn't open under this installation's key)
|
|
2959
|
+
/** The machine's private key (null when it doesn't open under this installation's key), its password when it is
|
|
2960
|
+
* reached with one (A59; `passwordUnreadable` when it is kept but doesn't open) and the accepted host keys. */
|
|
2905
2961
|
sshSecrets(id) {
|
|
2906
|
-
const row = this.db.prepare("SELECT key, host_keys AS hostKeys FROM environments WHERE id=? AND kind='ssh' AND deleted_at IS NULL").get(id);
|
|
2962
|
+
const row = this.db.prepare("SELECT key, password, host_keys AS hostKeys FROM environments WHERE id=? AND kind='ssh' AND deleted_at IS NULL").get(id);
|
|
2907
2963
|
if (!row) return null;
|
|
2908
|
-
|
|
2964
|
+
const password = row.password ? this.cipher.open(row.password, `environment-password:${id}`) : null;
|
|
2965
|
+
return {
|
|
2966
|
+
privateKey: row.key ? this.cipher.open(row.key, `environment:${id}`) : null,
|
|
2967
|
+
password,
|
|
2968
|
+
passwordUnreadable: !!row.password && password === null,
|
|
2969
|
+
hostKeys: row.hostKeys ? JSON.parse(row.hostKeys) : { keys: [], fingerprints: [] }
|
|
2970
|
+
};
|
|
2971
|
+
}
|
|
2972
|
+
/** Whether a machine is reached with its password rather than the key (A59). */
|
|
2973
|
+
usesPassword(id) {
|
|
2974
|
+
return !!this.db.prepare("SELECT password FROM environments WHERE id=? AND kind='ssh'").get(id)?.password;
|
|
2909
2975
|
}
|
|
2910
2976
|
/** The Bots that may use an environment. */
|
|
2911
2977
|
grantees(environmentId) {
|
|
@@ -2918,7 +2984,7 @@ var EnvironmentRepository = class {
|
|
|
2918
2984
|
const moved = this.db.prepare("SELECT id FROM bots WHERE current_environment_id=?").all(id).map((row) => row.id);
|
|
2919
2985
|
this.db.prepare("UPDATE bots SET current_environment_id=home_environment_id WHERE current_environment_id=?").run(id);
|
|
2920
2986
|
this.db.prepare("DELETE FROM environment_grants WHERE environment_id=?").run(id);
|
|
2921
|
-
this.db.prepare("UPDATE environments SET deleted_at=?, key=NULL WHERE id=? AND kind='ssh'").run(Date.now(), id);
|
|
2987
|
+
this.db.prepare("UPDATE environments SET deleted_at=?, key=NULL, password=NULL WHERE id=? AND kind='ssh'").run(Date.now(), id);
|
|
2922
2988
|
this.db.exec("COMMIT");
|
|
2923
2989
|
return moved;
|
|
2924
2990
|
} catch (error) {
|
|
@@ -5102,6 +5168,15 @@ var ChatService = class {
|
|
|
5102
5168
|
if (added) this.dispatcher.dispatch(deliveries);
|
|
5103
5169
|
return this.repository.message(actorId, messageId);
|
|
5104
5170
|
}
|
|
5171
|
+
/** Folds a message to one line in the owner's interface, or unfolds it (A60). Nobody is told: a Bot reads it as before. */
|
|
5172
|
+
fold(actorId, messageId, folded) {
|
|
5173
|
+
if (typeof folded !== "boolean") throw new ChatError(400, "Invalid folded state");
|
|
5174
|
+
const message = this.repository.message(actorId, messageId);
|
|
5175
|
+
if (!message) throw new ChatError(404, "Message not found");
|
|
5176
|
+
this.repository.interactions.setFolded(messageId, folded);
|
|
5177
|
+
this.notifySaved(message.chatId);
|
|
5178
|
+
return this.repository.message(actorId, messageId);
|
|
5179
|
+
}
|
|
5105
5180
|
mute(actorId, chatId, muted) {
|
|
5106
5181
|
if (typeof muted !== "boolean") throw new ChatError(400, "Invalid muted");
|
|
5107
5182
|
this.repository.interactions.setMuted(actorId, chatId, muted);
|
|
@@ -5126,10 +5201,11 @@ var ChatService = class {
|
|
|
5126
5201
|
unreadMessages(actorId, chatId, limit) {
|
|
5127
5202
|
return this.repository.unreadMessages(actorId, chatId, limit);
|
|
5128
5203
|
}
|
|
5129
|
-
|
|
5204
|
+
/** `sender`: only that member's messages (a Bot looking for what it sent itself). */
|
|
5205
|
+
searchAll(actorId, query, limit = 30, within, sender) {
|
|
5130
5206
|
if (!query.trim() || query.length > 500 || !Number.isInteger(limit) || limit < 1 || limit > 100) throw new ChatError(400, "Invalid search query or limit");
|
|
5131
5207
|
if (within) this.repository.assertAccess(actorId, within);
|
|
5132
|
-
return this.repository.searchAll(actorId, query.trim(), limit, within);
|
|
5208
|
+
return this.repository.searchAll(actorId, query.trim(), limit, within, sender);
|
|
5133
5209
|
}
|
|
5134
5210
|
/** The search box: channels and threads by title, topic or notice / summary, and messages by text, with the chat
|
|
5135
5211
|
* each message is in. `within` searches one chat (a channel with its threads) and returns no chats. */
|
|
@@ -5200,9 +5276,9 @@ var ChatService = class {
|
|
|
5200
5276
|
message(actorId, id) {
|
|
5201
5277
|
return this.repository.message(actorId, id);
|
|
5202
5278
|
}
|
|
5203
|
-
search(actorId, chatId, query, limit = 30) {
|
|
5279
|
+
search(actorId, chatId, query, limit = 30, sender) {
|
|
5204
5280
|
if (!query.trim() || query.length > 500 || !Number.isInteger(limit) || limit < 1 || limit > 100) throw new ChatError(400, "Invalid search query or limit");
|
|
5205
|
-
return this.repository.search(actorId, chatId, query, limit);
|
|
5281
|
+
return this.repository.search(actorId, chatId, query, limit, sender);
|
|
5206
5282
|
}
|
|
5207
5283
|
notifySaved(chatId) {
|
|
5208
5284
|
try {
|
|
@@ -5891,7 +5967,7 @@ function environmentTools(lookup, secrets) {
|
|
|
5891
5967
|
const access = async (path) => {
|
|
5892
5968
|
if (!await current().exists(path)) throw Object.assign(new Error(`ENOENT: no such file or directory, access '${path}'`), { code: "ENOENT" });
|
|
5893
5969
|
};
|
|
5894
|
-
const readFile4 = (path) => current().readFile(path),
|
|
5970
|
+
const readFile4 = (path) => current().readFile(path), writeFile5 = (path, content2) => current().writeFile(path, content2);
|
|
5895
5971
|
const cwd = current().cwd;
|
|
5896
5972
|
const definitions2 = [
|
|
5897
5973
|
relocated(createBashToolDefinition(cwd, { operations: {
|
|
@@ -5915,8 +5991,8 @@ function environmentTools(lookup, secrets) {
|
|
|
5915
5991
|
} }), current),
|
|
5916
5992
|
// Pictures go to the model as pictures; the kind comes from a file's first bytes, as Pi's local read does.
|
|
5917
5993
|
createReadToolDefinition(cwd, { operations: { readFile: readFile4, access, detectImageMimeType: async (path) => imageKind(await current().head(path, IMAGE_SNIFF_BYTES)) } }),
|
|
5918
|
-
createWriteToolDefinition(cwd, { operations: { writeFile:
|
|
5919
|
-
createEditToolDefinition(cwd, { operations: { readFile: readFile4, writeFile:
|
|
5994
|
+
createWriteToolDefinition(cwd, { operations: { writeFile: writeFile5, mkdir: (path) => current().mkdir(path) } }),
|
|
5995
|
+
createEditToolDefinition(cwd, { operations: { readFile: readFile4, writeFile: writeFile5, access } })
|
|
5920
5996
|
];
|
|
5921
5997
|
return definitions2.map((definition) => inside(definition, lookup, pinned));
|
|
5922
5998
|
}
|
|
@@ -6917,7 +6993,7 @@ import { dirname as dirname5, join as join9, posix } from "node:path";
|
|
|
6917
6993
|
// src/server/infra/env/ssh.ts
|
|
6918
6994
|
import { spawn as spawn2 } from "node:child_process";
|
|
6919
6995
|
import { mkdirSync as mkdirSync4, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
|
|
6920
|
-
import { mkdir as mkdir4, mkdtemp as mkdtemp2, readFile as readFile3, rm as rm3
|
|
6996
|
+
import { mkdir as mkdir4, mkdtemp as mkdtemp2, readFile as readFile3, rm as rm3 } from "node:fs/promises";
|
|
6921
6997
|
import { join as join8 } from "node:path";
|
|
6922
6998
|
import { HostKeyChangedError, HostKeyUnknownError, RemoteExecutionEnv as RemoteExecutionEnv2, detectPlatform, scanHostKey, sshArguments, sshConnection } from "@earendil-works/pi-env";
|
|
6923
6999
|
var SshFault = class extends Error {
|
|
@@ -6950,26 +7026,31 @@ async function generateKey(runtime, comment) {
|
|
|
6950
7026
|
await rm3(directory, { recursive: true, force: true });
|
|
6951
7027
|
}
|
|
6952
7028
|
}
|
|
7029
|
+
function publicKeyOf(privateKey, comment) {
|
|
7030
|
+
try {
|
|
7031
|
+
const body = Buffer.from(privateKey.replace(/-----[^-]+-----/g, "").replace(/\s+/g, ""), "base64"), magic = "openssh-key-v1\0";
|
|
7032
|
+
if (body.subarray(0, magic.length).toString("latin1") !== magic) return null;
|
|
7033
|
+
let at2 = magic.length;
|
|
7034
|
+
const field2 = () => {
|
|
7035
|
+
const size = body.readUInt32BE(at2);
|
|
7036
|
+
const value2 = body.subarray(at2 + 4, at2 + 4 + size);
|
|
7037
|
+
at2 += 4 + size;
|
|
7038
|
+
return value2;
|
|
7039
|
+
};
|
|
7040
|
+
field2();
|
|
7041
|
+
field2();
|
|
7042
|
+
field2();
|
|
7043
|
+
if (body.readUInt32BE(at2) !== 1) return null;
|
|
7044
|
+
at2 += 4;
|
|
7045
|
+
const blob = field2(), type = blob.subarray(4, 4 + blob.readUInt32BE(0)).toString("latin1");
|
|
7046
|
+
return `${type} ${blob.toString("base64")} ${comment}`;
|
|
7047
|
+
} catch {
|
|
7048
|
+
return null;
|
|
7049
|
+
}
|
|
7050
|
+
}
|
|
6953
7051
|
async function installKey(address2, runtime, hostKeys2, password, publicKey) {
|
|
6954
|
-
|
|
6955
|
-
|
|
6956
|
-
await writeFile3(askpass, `#!/bin/sh
|
|
6957
|
-
printf '%s\\n' "$JARVY_SSH_PASSWORD"
|
|
6958
|
-
`, { mode: 448 });
|
|
6959
|
-
const { SSH_AUTH_SOCK: _agent, ...env } = process.env;
|
|
6960
|
-
const args = [
|
|
6961
|
-
"-o",
|
|
6962
|
-
"BatchMode=no",
|
|
6963
|
-
"-o",
|
|
6964
|
-
"PreferredAuthentications=keyboard-interactive,password",
|
|
6965
|
-
"-o",
|
|
6966
|
-
"PubkeyAuthentication=no",
|
|
6967
|
-
"-o",
|
|
6968
|
-
"NumberOfPasswordPrompts=1",
|
|
6969
|
-
"-o",
|
|
6970
|
-
"ConnectTimeout=15",
|
|
6971
|
-
...sshArguments({ ...target(address2, SCAN_ALIAS), ...files })
|
|
6972
|
-
];
|
|
7052
|
+
const access = { ...address2, runtime, alias: SCAN_ALIAS, hostKeys: hostKeys2, privateKey: "", password };
|
|
7053
|
+
await withFiles(access, async (files) => {
|
|
6973
7054
|
const script = [
|
|
6974
7055
|
"umask 077",
|
|
6975
7056
|
'mkdir -p "$HOME/.ssh"',
|
|
@@ -6978,29 +7059,23 @@ printf '%s\\n' "$JARVY_SSH_PASSWORD"
|
|
|
6978
7059
|
"IFS= read -r key",
|
|
6979
7060
|
`grep -qxF "$key" "$f" || printf '%s\\n' "$key" >> "$f"`
|
|
6980
7061
|
].join("\n");
|
|
6981
|
-
await runSsh(
|
|
6982
|
-
|
|
6983
|
-
args,
|
|
6984
|
-
`sh -c ${quote(script)}`,
|
|
6985
|
-
`${publicKey}
|
|
6986
|
-
`,
|
|
6987
|
-
{ ...env, SSH_ASKPASS: askpass, SSH_ASKPASS_REQUIRE: "force", DISPLAY: env.DISPLAY ?? "none", JARVY_SSH_PASSWORD: password }
|
|
6988
|
-
);
|
|
7062
|
+
await runSsh(access, files, `sh -c ${quote(script)}`, `${publicKey}
|
|
7063
|
+
`, "password");
|
|
6989
7064
|
});
|
|
6990
7065
|
}
|
|
6991
7066
|
async function probeHost(access) {
|
|
6992
|
-
return withFiles(access
|
|
7067
|
+
return withFiles(access, async (files) => {
|
|
6993
7068
|
try {
|
|
6994
7069
|
const remote = await detectPlatform({ ...target(access, access.alias), ...files });
|
|
6995
7070
|
if (remote.platform !== "linux" && remote.platform !== "darwin") throw new SshFault("ssh_unsupported", `Unsupported system: ${remote.platform}`);
|
|
6996
7071
|
return { platform: remote.platform, home: remote.home };
|
|
6997
7072
|
} catch (error) {
|
|
6998
|
-
throw classify(error, access);
|
|
7073
|
+
throw classify(error, access, access.password ? "password" : "key");
|
|
6999
7074
|
}
|
|
7000
7075
|
});
|
|
7001
7076
|
}
|
|
7002
7077
|
async function removeKey(access, comment) {
|
|
7003
|
-
await withFiles(access
|
|
7078
|
+
await withFiles(access, async (files) => {
|
|
7004
7079
|
const script = [
|
|
7005
7080
|
"umask 077",
|
|
7006
7081
|
'f="$HOME/.ssh/authorized_keys"',
|
|
@@ -7010,13 +7085,13 @@ async function removeKey(access, comment) {
|
|
|
7010
7085
|
'rm -f "$f.jarvy"',
|
|
7011
7086
|
"exit $s"
|
|
7012
7087
|
].join("\n");
|
|
7013
|
-
await runSsh(access,
|
|
7088
|
+
await runSsh(access, files, `sh -c ${quote(script)}`, "", access.password ? "password" : "key");
|
|
7014
7089
|
});
|
|
7015
7090
|
}
|
|
7016
7091
|
function sshEnvironment(id, access, directory, temp, onLog) {
|
|
7017
7092
|
mkdirSync4(access.runtime, { recursive: true, mode: 448 });
|
|
7018
7093
|
const folder = join8(access.runtime, `open-${id}-${process.pid}-${Date.now().toString(36)}`);
|
|
7019
|
-
const files = writeFilesSync(folder, access
|
|
7094
|
+
const files = writeFilesSync(folder, access);
|
|
7020
7095
|
const { connection } = sshConnection({ ...target(access, access.alias), ...files, ...onLog ? { onLog } : {} });
|
|
7021
7096
|
return overDurable(id, "ssh", new RemoteExecutionEnv2({ connection, id: `ssh:${id}`, cwd: directory }), {
|
|
7022
7097
|
passEnv: false,
|
|
@@ -7033,29 +7108,39 @@ function clearRuntime(runtime) {
|
|
|
7033
7108
|
function target(address2, alias2) {
|
|
7034
7109
|
return { host: address2.host, port: address2.port, user: address2.user, hostKeyAlias: alias2, configFile: "/dev/null" };
|
|
7035
7110
|
}
|
|
7036
|
-
async function withFiles(
|
|
7037
|
-
await mkdir4(runtime, { recursive: true, mode: 448 });
|
|
7038
|
-
const directory = await mkdtemp2(join8(runtime, "use-"));
|
|
7111
|
+
async function withFiles(access, work) {
|
|
7112
|
+
await mkdir4(access.runtime, { recursive: true, mode: 448 });
|
|
7113
|
+
const directory = await mkdtemp2(join8(access.runtime, "use-"));
|
|
7039
7114
|
try {
|
|
7040
|
-
return await work(writeFilesSync(directory,
|
|
7115
|
+
return await work(writeFilesSync(directory, access));
|
|
7041
7116
|
} finally {
|
|
7042
7117
|
await rm3(directory, { recursive: true, force: true });
|
|
7043
7118
|
}
|
|
7044
7119
|
}
|
|
7045
|
-
function writeFilesSync(directory,
|
|
7120
|
+
function writeFilesSync(directory, access) {
|
|
7046
7121
|
mkdirSync4(directory, { recursive: true, mode: 448 });
|
|
7047
7122
|
const knownHostsFile = join8(directory, "known_hosts");
|
|
7048
|
-
writeFileSync3(knownHostsFile,
|
|
7123
|
+
writeFileSync3(knownHostsFile, access.hostKeys.map((key) => `${access.alias} ${key}
|
|
7049
7124
|
`).join(""), { mode: 384 });
|
|
7050
|
-
if (
|
|
7125
|
+
if (access.password) {
|
|
7126
|
+
const secret = join8(directory, "password"), askpass = join8(directory, "askpass"), ssh = join8(directory, "ssh");
|
|
7127
|
+
writeFileSync3(secret, `${access.password}
|
|
7128
|
+
`, { mode: 384 });
|
|
7129
|
+
writeFileSync3(askpass, `#!/bin/sh
|
|
7130
|
+
exec cat ${quote(secret)}
|
|
7131
|
+
`, { mode: 448 });
|
|
7132
|
+
writeFileSync3(ssh, ["#!/bin/sh", `exec env -u SSH_AUTH_SOCK SSH_ASKPASS=${quote(askpass)} SSH_ASKPASS_REQUIRE=force DISPLAY=none ssh -o BatchMode=no -o PreferredAuthentications=keyboard-interactive,password -o PubkeyAuthentication=no -o NumberOfPasswordPrompts=1 -o ConnectTimeout=15 "$@"`, ""].join("\n"), { mode: 448 });
|
|
7133
|
+
return { knownHostsFile, ssh };
|
|
7134
|
+
}
|
|
7051
7135
|
const identityFile = join8(directory, "key");
|
|
7052
|
-
writeFileSync3(identityFile, privateKey.endsWith("\n") ? privateKey : `${privateKey}
|
|
7136
|
+
writeFileSync3(identityFile, access.privateKey.endsWith("\n") ? access.privateKey : `${access.privateKey}
|
|
7053
7137
|
`, { mode: 384 });
|
|
7054
7138
|
return { knownHostsFile, identityFile };
|
|
7055
7139
|
}
|
|
7056
|
-
function runSsh(
|
|
7057
|
-
|
|
7058
|
-
|
|
7140
|
+
function runSsh(access, files, command, stdin, how) {
|
|
7141
|
+
const { ssh, ...rest } = files;
|
|
7142
|
+
return run(ssh ?? "ssh", [...sshArguments({ ...target(access, access.alias), ...rest }), command], stdin).catch((error) => {
|
|
7143
|
+
throw classify(error, access, how);
|
|
7059
7144
|
});
|
|
7060
7145
|
}
|
|
7061
7146
|
function run(program, args, stdin = "", env) {
|
|
@@ -7079,13 +7164,20 @@ function run(program, args, stdin = "", env) {
|
|
|
7079
7164
|
child.stdin.end(stdin);
|
|
7080
7165
|
});
|
|
7081
7166
|
}
|
|
7082
|
-
function classify(error, address2) {
|
|
7167
|
+
function classify(error, address2, how = "key") {
|
|
7083
7168
|
if (error instanceof SshFault) return error;
|
|
7084
7169
|
const text4 = error instanceof Error ? error.message : String(error), where2 = `${address2.user}@${address2.host}:${address2.port}`;
|
|
7085
7170
|
if (error instanceof HostKeyChangedError || /REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed/.test(text4))
|
|
7086
7171
|
return new SshFault("ssh_host_key_changed", `The host key of ${where2} is not the one accepted`);
|
|
7087
7172
|
if (error instanceof HostKeyUnknownError) return new SshFault("ssh_host_key_changed", `The host key of ${where2} is not the one accepted`);
|
|
7088
|
-
|
|
7173
|
+
const denied = /Permission denied \(([^)]*)\)/.exec(text4);
|
|
7174
|
+
if (denied || /Permission denied|Too many authentication failures/.test(text4)) {
|
|
7175
|
+
const methods = denied ? denied[1].split(",").map((method) => method.trim()) : [];
|
|
7176
|
+
if (how === "password" && methods.length && !methods.some((method) => method === "password" || method === "keyboard-interactive"))
|
|
7177
|
+
return new SshFault("ssh_password_refused", `${where2} doesn't accept password logins`);
|
|
7178
|
+
if (how === "key") return new SshFault("ssh_key_refused", `${where2} didn't accept Jarvy's key`);
|
|
7179
|
+
return new SshFault("ssh_auth_failed", `${where2} refused the password`);
|
|
7180
|
+
}
|
|
7089
7181
|
if (/Connection refused|timed out|No route to host|Could not resolve|Network is unreachable|Connection closed|No host key received/i.test(text4))
|
|
7090
7182
|
return new SshFault("ssh_unreachable", `${where2} can't be reached: ${text4.split("\n").pop()}`);
|
|
7091
7183
|
return new SshFault("ssh_failed", text4.split("\n").slice(-3).join(" ").slice(0, 300) || `ssh ${where2} failed`);
|
|
@@ -7095,6 +7187,7 @@ var quote = (text4) => `'${text4.replaceAll("'", "'\\''")}'`;
|
|
|
7095
7187
|
// src/server/service/bot/machines.ts
|
|
7096
7188
|
var HOST = /^(?!-)[A-Za-z0-9._:-]{1,253}$/;
|
|
7097
7189
|
var USER = /^(?!-)[A-Za-z0-9._][A-Za-z0-9._-]{0,63}$/;
|
|
7190
|
+
var PREPARED_MS = 60 * 6e4;
|
|
7098
7191
|
var SshMachines = class {
|
|
7099
7192
|
/** `changed`: what Bots may use changed, so the owner's page reloads them. */
|
|
7100
7193
|
constructor(store, chats, changed = () => {
|
|
@@ -7106,6 +7199,7 @@ var SshMachines = class {
|
|
|
7106
7199
|
clearRuntime(this.runtime);
|
|
7107
7200
|
}
|
|
7108
7201
|
runtime;
|
|
7202
|
+
prepared = /* @__PURE__ */ new Map();
|
|
7109
7203
|
list() {
|
|
7110
7204
|
return this.store.environments.sshMachines().map((record3) => this.view(record3));
|
|
7111
7205
|
}
|
|
@@ -7116,30 +7210,68 @@ var SshMachines = class {
|
|
|
7116
7210
|
async scan(input) {
|
|
7117
7211
|
return this.wrap(() => scanHost(address(input), this.runtime));
|
|
7118
7212
|
}
|
|
7119
|
-
/**
|
|
7120
|
-
|
|
7213
|
+
/** A key for a machine the owner will add its public half to themselves; `keyId` then adds the machine. */
|
|
7214
|
+
async prepareKey() {
|
|
7215
|
+
const now = Date.now();
|
|
7216
|
+
for (const [keyId2, key2] of this.prepared) if (key2.until < now) this.prepared.delete(keyId2);
|
|
7217
|
+
const id = randomUUID12(), key = await this.wrap(() => generateKey(this.runtime, keyComment(id)));
|
|
7218
|
+
this.prepared.set(id, { id, ...key, until: now + PREPARED_MS });
|
|
7219
|
+
return { keyId: id, publicKey: key.publicKey };
|
|
7220
|
+
}
|
|
7221
|
+
/** Adds a machine and saves it once a login works; nothing is saved when a step fails. With `password`, Jarvy makes
|
|
7222
|
+
* a key and installs it with that password; when the machine then refuses the key, the password itself is kept
|
|
7223
|
+
* (sealed) and used to log in (A59), and the key stays installed for when keys are allowed. With `keyId`, the owner
|
|
7224
|
+
* has added that prepared key to the machine, and a login with it is checked. */
|
|
7121
7225
|
async add(input, grantTo) {
|
|
7122
7226
|
const where2 = address(input), name3 = this.freeName(text3(input.name, 40) || `${where2.user}@${where2.host}`);
|
|
7123
|
-
|
|
7227
|
+
const password = typeof input.password === "string" && input.password ? input.password : null;
|
|
7228
|
+
const prepared = typeof input.keyId === "string" ? this.prepared.get(input.keyId) : void 0;
|
|
7229
|
+
if (!password && input.keyId === void 0) throw new ChatError(400, "A password is needed to install the key", "ssh_password_missing");
|
|
7230
|
+
if (!password && (!prepared || prepared.until < Date.now())) throw new ChatError(409, "That key is no longer kept; show a new one and add it to the machine", "ssh_key_expired");
|
|
7124
7231
|
if (!input.hostKeys.keys.length) throw new ChatError(400, "The host keys are missing; scan the machine first");
|
|
7125
7232
|
directoryIn("/", input.directory);
|
|
7126
|
-
const id = randomUUID12(), comment = keyComment(id);
|
|
7233
|
+
const id = prepared && !password ? prepared.id : randomUUID12(), comment = keyComment(id);
|
|
7127
7234
|
const record3 = await this.wrap(async () => {
|
|
7128
|
-
const key =
|
|
7129
|
-
|
|
7235
|
+
const key = prepared && !password ? prepared : await generateKey(this.runtime, comment);
|
|
7236
|
+
const access = { ...where2, runtime: this.runtime, alias: alias(id), hostKeys: input.hostKeys.keys, privateKey: key.privateKey };
|
|
7237
|
+
const save = (remote, kept) => this.store.environments.addSsh({
|
|
7238
|
+
name: name3,
|
|
7239
|
+
...where2,
|
|
7240
|
+
...remote,
|
|
7241
|
+
directory: directoryIn(remote.home, input.directory),
|
|
7242
|
+
hostKeys: input.hostKeys,
|
|
7243
|
+
privateKey: key.privateKey,
|
|
7244
|
+
...kept ? { password: kept } : {}
|
|
7245
|
+
}, id);
|
|
7246
|
+
if (!password) return save(await probeHost(access));
|
|
7247
|
+
await installKey(where2, this.runtime, input.hostKeys.keys, password, key.publicKey);
|
|
7130
7248
|
try {
|
|
7131
|
-
|
|
7132
|
-
|
|
7249
|
+
try {
|
|
7250
|
+
return save(await probeHost(access));
|
|
7251
|
+
} catch (error) {
|
|
7252
|
+
if (!(error instanceof SshFault) || error.code !== "ssh_key_refused") throw error;
|
|
7253
|
+
return save(await probeHost({ ...access, password }), password);
|
|
7254
|
+
}
|
|
7133
7255
|
} catch (error) {
|
|
7134
|
-
await removeKey(access, comment).catch(() => {
|
|
7256
|
+
await removeKey(password ? { ...access, password } : access, comment).catch(() => {
|
|
7135
7257
|
});
|
|
7136
7258
|
throw error;
|
|
7137
7259
|
}
|
|
7138
7260
|
});
|
|
7261
|
+
if (prepared) this.prepared.delete(prepared.id);
|
|
7139
7262
|
if (grantTo) this.store.environments.grant(record3.id, grantTo.botId, grantTo.by);
|
|
7140
7263
|
this.changed();
|
|
7141
7264
|
return this.view(record3);
|
|
7142
7265
|
}
|
|
7266
|
+
/** A new password for a machine reached with its password, checked with a login before it is kept. */
|
|
7267
|
+
async setPassword(id, password) {
|
|
7268
|
+
const record3 = this.need(id);
|
|
7269
|
+
if (typeof password !== "string" || !password) throw new ChatError(400, "A password is needed", "ssh_password_missing");
|
|
7270
|
+
if (!this.store.environments.usesPassword(id)) throw new ChatError(409, "This machine is reached with Jarvy's key, not a password");
|
|
7271
|
+
await this.wrap(() => probeHost({ ...this.access(record3), password }));
|
|
7272
|
+
this.store.environments.updateSsh(id, { password });
|
|
7273
|
+
return this.get(id);
|
|
7274
|
+
}
|
|
7143
7275
|
update(id, input) {
|
|
7144
7276
|
const record3 = this.need(id);
|
|
7145
7277
|
const name3 = input.name === void 0 ? void 0 : text3(input.name, 40);
|
|
@@ -7152,11 +7284,23 @@ var SshMachines = class {
|
|
|
7152
7284
|
this.changed();
|
|
7153
7285
|
return this.get(id);
|
|
7154
7286
|
}
|
|
7155
|
-
/** Scans the machine again: `changed` when it no longer presents the keys the owner accepted.
|
|
7287
|
+
/** Scans the machine again: `changed` when it no longer presents the keys the owner accepted. A machine reached
|
|
7288
|
+
* with its password whose keys are unchanged is tried with Jarvy's key: once that works, the password is dropped
|
|
7289
|
+
* and the key is used from then on (A59). `machine` is how it stands after. */
|
|
7156
7290
|
async check(id) {
|
|
7157
7291
|
const record3 = this.need(id), accepted = this.store.environments.sshSecrets(id).hostKeys;
|
|
7158
|
-
const hostKeys2 = await this.scan(this.addressOf(record3));
|
|
7159
|
-
|
|
7292
|
+
const hostKeys2 = await this.scan(this.addressOf(record3)), changed = hostKeys2.keys.some((key) => !accepted.keys.includes(key));
|
|
7293
|
+
if (!changed && this.store.environments.usesPassword(id)) {
|
|
7294
|
+
const { password: _password, ...withKey } = this.access(record3);
|
|
7295
|
+
try {
|
|
7296
|
+
await probeHost(withKey);
|
|
7297
|
+
this.store.environments.updateSsh(id, { password: null });
|
|
7298
|
+
this.changed();
|
|
7299
|
+
} catch (error) {
|
|
7300
|
+
if (!(error instanceof SshFault) || error.code !== "ssh_key_refused") throw this.fault(error);
|
|
7301
|
+
}
|
|
7302
|
+
}
|
|
7303
|
+
return { hostKeys: hostKeys2, changed, machine: this.get(id) };
|
|
7160
7304
|
}
|
|
7161
7305
|
/** The owner accepts the keys the machine presents now (after a reinstall, say). */
|
|
7162
7306
|
async trust(id, fingerprints) {
|
|
@@ -7214,8 +7358,8 @@ var SshMachines = class {
|
|
|
7214
7358
|
};
|
|
7215
7359
|
return { question: { question: input.purpose, options: [{ label: "Add", value: "add" }], multiple: false, allowCustom: false, machine } };
|
|
7216
7360
|
}
|
|
7217
|
-
/** The owner's answer on a request card: adds the machine with the password (or grants the one
|
|
7218
|
-
* the Bot that asked, then settles the card. The password never enters the message. */
|
|
7361
|
+
/** The owner's answer on a request card: adds the machine with the password or a prepared key (or grants the one
|
|
7362
|
+
* already added) to the Bot that asked, then settles the card. The password never enters the message. */
|
|
7219
7363
|
async fulfil(actorId, chatId, messageId, input) {
|
|
7220
7364
|
const message = this.chats.message(actorId, messageId), ask = message?.question?.machine;
|
|
7221
7365
|
if (!message || message.chatId !== chatId || !ask || message.question?.state !== "pending") throw new ChatError(409, "The question is already settled", "question_closed");
|
|
@@ -7225,7 +7369,16 @@ var SshMachines = class {
|
|
|
7225
7369
|
if (existing) {
|
|
7226
7370
|
this.store.environments.grant(existing.id, botId, by);
|
|
7227
7371
|
this.changed();
|
|
7228
|
-
} else await this.add({
|
|
7372
|
+
} else await this.add({
|
|
7373
|
+
name: ask.name,
|
|
7374
|
+
host: ask.host,
|
|
7375
|
+
port: ask.port,
|
|
7376
|
+
user: ask.user,
|
|
7377
|
+
directory: ask.directory ?? void 0,
|
|
7378
|
+
password: input.password,
|
|
7379
|
+
...input.keyId !== void 0 ? { keyId: input.keyId } : {},
|
|
7380
|
+
hostKeys: ask.hostKeys
|
|
7381
|
+
}, { botId, by });
|
|
7229
7382
|
return this.chats.questions.respond(actorId, chatId, messageId, { action: "answer", selected: [0] });
|
|
7230
7383
|
}
|
|
7231
7384
|
/** The machine a request card added or granted, by its address. */
|
|
@@ -7245,7 +7398,9 @@ var SshMachines = class {
|
|
|
7245
7398
|
directory: record3.directory ?? spec.home ?? "",
|
|
7246
7399
|
platform: spec.platform ?? "",
|
|
7247
7400
|
fingerprints: secrets?.hostKeys.fingerprints ?? [],
|
|
7248
|
-
botIds: this.store.environments.grantees(record3.id)
|
|
7401
|
+
botIds: this.store.environments.grantees(record3.id),
|
|
7402
|
+
login: secrets?.password || secrets?.passwordUnreadable ? "password" : "key",
|
|
7403
|
+
publicKey: secrets?.privateKey ? publicKeyOf(secrets.privateKey, keyComment(record3.id)) : null
|
|
7249
7404
|
};
|
|
7250
7405
|
}
|
|
7251
7406
|
need(id) {
|
|
@@ -7258,8 +7413,15 @@ var SshMachines = class {
|
|
|
7258
7413
|
}
|
|
7259
7414
|
access(record3) {
|
|
7260
7415
|
const secrets = this.store.environments.sshSecrets(record3.id);
|
|
7261
|
-
if (!secrets?.privateKey) throw new ChatError(409, "This machine's key doesn't open under this installation's master key; delete the machine and add it again", "ssh_key_unreadable");
|
|
7262
|
-
return {
|
|
7416
|
+
if (!secrets?.privateKey || secrets.passwordUnreadable) throw new ChatError(409, "This machine's key doesn't open under this installation's master key; delete the machine and add it again", "ssh_key_unreadable");
|
|
7417
|
+
return {
|
|
7418
|
+
...this.addressOf(record3),
|
|
7419
|
+
runtime: this.runtime,
|
|
7420
|
+
alias: alias(record3.id),
|
|
7421
|
+
hostKeys: secrets.hostKeys.keys,
|
|
7422
|
+
privateKey: secrets.privateKey,
|
|
7423
|
+
...secrets.password ? { password: secrets.password } : {}
|
|
7424
|
+
};
|
|
7263
7425
|
}
|
|
7264
7426
|
freeName(name3) {
|
|
7265
7427
|
if (this.store.environments.sshMachines().some((record3) => record3.name === name3)) throw new ChatError(409, "Another machine has this name", "machine_name_taken");
|
|
@@ -7270,19 +7432,26 @@ var SshMachines = class {
|
|
|
7270
7432
|
try {
|
|
7271
7433
|
return await work();
|
|
7272
7434
|
} catch (error) {
|
|
7273
|
-
|
|
7274
|
-
|
|
7275
|
-
|
|
7276
|
-
|
|
7277
|
-
|
|
7278
|
-
|
|
7279
|
-
|
|
7280
|
-
|
|
7281
|
-
|
|
7282
|
-
|
|
7283
|
-
|
|
7284
|
-
|
|
7285
|
-
|
|
7435
|
+
throw this.fault(error);
|
|
7436
|
+
}
|
|
7437
|
+
}
|
|
7438
|
+
fault(error) {
|
|
7439
|
+
if (!(error instanceof SshFault)) return error;
|
|
7440
|
+
switch (error.code) {
|
|
7441
|
+
case "ssh_auth_failed":
|
|
7442
|
+
return new ChatError(403, error.message, "ssh_auth_failed");
|
|
7443
|
+
case "ssh_password_refused":
|
|
7444
|
+
return new ChatError(403, error.message, "ssh_password_refused");
|
|
7445
|
+
case "ssh_key_refused":
|
|
7446
|
+
return new ChatError(403, error.message, "ssh_key_refused");
|
|
7447
|
+
case "ssh_unreachable":
|
|
7448
|
+
return new ChatError(409, error.message, "ssh_unreachable");
|
|
7449
|
+
case "ssh_host_key_changed":
|
|
7450
|
+
return new ChatError(409, error.message, "ssh_host_key_changed");
|
|
7451
|
+
case "ssh_unsupported":
|
|
7452
|
+
return new ChatError(409, error.message, "ssh_unsupported");
|
|
7453
|
+
default:
|
|
7454
|
+
return new ChatError(409, error.message, "ssh_failed", { detail: error.message });
|
|
7286
7455
|
}
|
|
7287
7456
|
}
|
|
7288
7457
|
};
|
|
@@ -7634,7 +7803,7 @@ var Model = class {
|
|
|
7634
7803
|
import { posix as posix2 } from "node:path";
|
|
7635
7804
|
|
|
7636
7805
|
// src/server/infra/files/workspace.ts
|
|
7637
|
-
import { mkdir as mkdir5, writeFile as
|
|
7806
|
+
import { mkdir as mkdir5, writeFile as writeFile3 } from "node:fs/promises";
|
|
7638
7807
|
import { join as join10 } from "node:path";
|
|
7639
7808
|
var LocalWorkspace = class {
|
|
7640
7809
|
constructor(cwd) {
|
|
@@ -7649,14 +7818,14 @@ var LocalWorkspace = class {
|
|
|
7649
7818
|
async writeTempFile(name3, content2, signal) {
|
|
7650
7819
|
await this.prepare();
|
|
7651
7820
|
const path = join10(this.tempDir, name3);
|
|
7652
|
-
await
|
|
7821
|
+
await writeFile3(path, content2, { encoding: "utf8", flag: "wx", mode: 384, signal });
|
|
7653
7822
|
return path;
|
|
7654
7823
|
}
|
|
7655
7824
|
};
|
|
7656
7825
|
|
|
7657
7826
|
// src/server/infra/files/skills.ts
|
|
7658
7827
|
import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync4 } from "node:fs";
|
|
7659
|
-
import { mkdir as mkdir6, writeFile as
|
|
7828
|
+
import { mkdir as mkdir6, writeFile as writeFile4 } from "node:fs/promises";
|
|
7660
7829
|
import { dirname as dirname7, join as join11 } from "node:path";
|
|
7661
7830
|
import { fileURLToPath } from "node:url";
|
|
7662
7831
|
|
|
@@ -7682,7 +7851,7 @@ async function installSkills(workspace, skills) {
|
|
|
7682
7851
|
for (const skill of skills) {
|
|
7683
7852
|
const file = join11(workspace, skillPath(skill.name));
|
|
7684
7853
|
await mkdir6(dirname7(file), { recursive: true });
|
|
7685
|
-
await
|
|
7854
|
+
await writeFile4(file, skill.content, "utf8");
|
|
7686
7855
|
}
|
|
7687
7856
|
}
|
|
7688
7857
|
function skillsDir() {
|
|
@@ -8129,8 +8298,10 @@ var ChatTools = class {
|
|
|
8129
8298
|
signal?.throwIfAborted();
|
|
8130
8299
|
return messages.map((message) => messageRow(this.chats, this.bot, message, listing(message)));
|
|
8131
8300
|
}
|
|
8301
|
+
/** `mine`: only what this Bot sent; those messages carry the History ID of the turn that sent them (A21). */
|
|
8132
8302
|
search(input) {
|
|
8133
|
-
const
|
|
8303
|
+
const sender = input.mine ? this.bot : void 0;
|
|
8304
|
+
const found = input.chatId === void 0 ? this.chats.searchAll(this.bot, input.query, input.limit ?? 30, void 0, sender) : this.chats.search(this.bot, this.chat(input.chatId).id, input.query, input.limit ?? 30, sender);
|
|
8134
8305
|
const chats = /* @__PURE__ */ new Map();
|
|
8135
8306
|
const chatOf = (id) => {
|
|
8136
8307
|
if (!chats.has(id)) chats.set(id, this.chats.get(this.bot, id));
|
|
@@ -8219,8 +8390,13 @@ var definitions = {
|
|
|
8219
8390
|
}, { additionalProperties: false })
|
|
8220
8391
|
},
|
|
8221
8392
|
"messages.search": {
|
|
8222
|
-
description: "Search message text. With chatId, only that chat; without it, every chat you can see, and each result says which chat it is in. Results list their files like messages.read.",
|
|
8223
|
-
schema: Type3.Object({
|
|
8393
|
+
description: "Search message text. With chatId, only that chat; without it, every chat you can see, and each result says which chat it is in. Results list their files like messages.read. mine=true: only messages you sent; each carries the History ID of the turn that sent it.",
|
|
8394
|
+
schema: Type3.Object({
|
|
8395
|
+
chatId: Type3.Optional(Type3.String()),
|
|
8396
|
+
query: Type3.String({ minLength: 1, maxLength: 500 }),
|
|
8397
|
+
limit: Type3.Optional(Type3.Integer({ minimum: 1, maximum: 100 })),
|
|
8398
|
+
mine: Type3.Optional(Type3.Boolean({ description: "Only messages you sent" }))
|
|
8399
|
+
}, { additionalProperties: false })
|
|
8224
8400
|
},
|
|
8225
8401
|
"files.get": {
|
|
8226
8402
|
description: "Put a file from a message into your environment, to work on it with your tools; the result has its path there. It goes to temp/files/ under its own name unless you give a path.",
|
|
@@ -8232,7 +8408,7 @@ var definitions = {
|
|
|
8232
8408
|
schema: Type3.Object({ id: Type3.String({ minLength: 1, maxLength: 80, description: "The environment's id from environments.list" }) }, { additionalProperties: false })
|
|
8233
8409
|
},
|
|
8234
8410
|
"environments.request": {
|
|
8235
|
-
description: `Ask your owner to add a machine you need to work on, reached over SSH, on a card in a chat with them, and wait for the answer, as messages.ask does. The card shows the machine's host key fingerprints for them to check; they
|
|
8411
|
+
description: `Ask your owner to add a machine you need to work on, reached over SSH, on a card in a chat with them, and wait for the answer, as messages.ask does. The card shows the machine's host key fingerprints for them to check; they let you in there, with its login password or by adding a key made for it to the machine. You never see the password or the key, so never ask for that password in the chat. It returns {status:"added", id, name} or {status:"granted", id, name} when you may already use it, then environments.switch with that id moves your tools there; or {status:"declined"}.`,
|
|
8236
8412
|
schema: Type3.Object({
|
|
8237
8413
|
chatId: Type3.String(),
|
|
8238
8414
|
host: Type3.String({ minLength: 1, maxLength: 253, description: "Host name or IP address" }),
|
|
@@ -9191,8 +9367,9 @@ Each batch of messages ends with your unread counts and the notices and summarie
|
|
|
9191
9367
|
You can schedule work for yourself, once or on a schedule, with the tasks tools in the chat gateway. When a task is due, its instruction comes in a batch as a <task>; do it then.
|
|
9192
9368
|
|
|
9193
9369
|
## History
|
|
9194
|
-
|
|
9195
|
-
history.read
|
|
9370
|
+
Your History keeps everything you did: your thinking, each tool call with its arguments, and what came back. Most of it never reaches the chat.
|
|
9371
|
+
Each message you sent carries a History ID, the turn in which you sent it. To recall how you did something, find a message of yours with messages.search (mine=true searches only yours), then read that turn with history.read. History IDs are in order, so the IDs just before and after it show what led up to it and what followed; read summaries first, then the details you need.
|
|
9372
|
+
History is material to look back on, not an instruction for the task at hand.
|
|
9196
9373
|
|
|
9197
9374
|
## Secrets
|
|
9198
9375
|
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.
|