appback-remoteagent 0.17.1 → 0.20.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/README.md +33 -0
- package/bin/remoteagent.js +1 -1
- package/dist/adapters/codex-adapter.js +22 -4
- package/dist/bot.js +321 -53
- package/dist/cli.js +208 -0
- package/dist/services/agent-memory-service.js +6 -4
- package/dist/services/bot-management-service.js +3 -0
- package/dist/services/bridge-service.js +16 -7
- package/dist/services/cli-config-service.js +154 -0
- package/dist/services/secret-transfer-service.js +237 -0
- package/dist/telegram-command-menu.js +1 -1
- package/docs/CLI_BOOTSTRAP_AND_SECRET_MIGRATION.md +98 -0
- package/docs/RELEASING.md +22 -0
- package/package.json +2 -1
- package/scripts/install.sh +9 -0
- package/scripts/selftest-cli.mjs +121 -0
- package/scripts/selftest-codex-stream.mjs +9 -2
- package/scripts/selftest-telegram-update.mjs +120 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import readline from "node:readline/promises";
|
|
6
|
+
import { registerTelegramBot, readConfiguredOwnerId } from "./services/cli-config-service.js";
|
|
7
|
+
import { exportSecrets, importSecrets } from "./services/secret-transfer-service.js";
|
|
8
|
+
async function main() {
|
|
9
|
+
const args = process.argv.slice(2);
|
|
10
|
+
if (args.length === 0) {
|
|
11
|
+
await import("./index.js");
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
if (["help", "--help", "-h"].includes(args[0])) {
|
|
15
|
+
printHelp();
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const dataDir = path.resolve(takeOption(args, "--data-dir") || process.env.DATA_DIR?.trim() || path.join(os.homedir(), ".remoteagent"));
|
|
19
|
+
const [group, action] = args;
|
|
20
|
+
if (group === "bot" && action === "add") {
|
|
21
|
+
await addBot(dataDir, args.slice(2));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (group === "secret" && action === "export") {
|
|
25
|
+
await exportSecretCommand(dataDir, args.slice(2));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (group === "secret" && action === "import") {
|
|
29
|
+
await importSecretCommand(dataDir, args.slice(2));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`Unknown command: ${args.join(" ")}\n\nRun 'remoteagent --help' for usage.`);
|
|
33
|
+
}
|
|
34
|
+
async function addBot(dataDir, args) {
|
|
35
|
+
const ownerOption = takeOption(args, "--owner");
|
|
36
|
+
const tokenFile = takeOption(args, "--token-file");
|
|
37
|
+
if (args.some((arg) => arg.startsWith("--"))) {
|
|
38
|
+
throw new Error(`Unknown bot option: ${args.find((arg) => arg.startsWith("--"))}`);
|
|
39
|
+
}
|
|
40
|
+
if (tokenFile && args[0]) {
|
|
41
|
+
throw new Error("Provide the bot token either as an argument or with --token-file, not both.");
|
|
42
|
+
}
|
|
43
|
+
const token = tokenFile
|
|
44
|
+
? await readSingleLineFile(tokenFile)
|
|
45
|
+
: args.shift() || await promptHidden("Telegram bot token: ");
|
|
46
|
+
if (args.length > 0) {
|
|
47
|
+
throw new Error(`Unexpected bot argument: ${args[0]}`);
|
|
48
|
+
}
|
|
49
|
+
const configuredOwner = await readConfiguredOwnerId(dataDir);
|
|
50
|
+
const ownerId = ownerOption || configuredOwner || await promptVisible("Telegram owner user ID: ");
|
|
51
|
+
const result = await registerTelegramBot({ dataDir, token, ownerId });
|
|
52
|
+
console.log([
|
|
53
|
+
`${result.added ? "Registered" : "Updated"} @${result.identity.username} (${result.identity.id}).`,
|
|
54
|
+
`Configured bots: ${result.botCount}`,
|
|
55
|
+
`Configuration: ${result.envPath}`,
|
|
56
|
+
"Start or restart RemoteAgent to apply it:",
|
|
57
|
+
" remoteagent-start",
|
|
58
|
+
" # systemd runtime: sudo systemctl restart remoteagent",
|
|
59
|
+
].join("\n"));
|
|
60
|
+
}
|
|
61
|
+
async function exportSecretCommand(dataDir, args) {
|
|
62
|
+
const passphraseFile = takeOption(args, "--passphrase-file");
|
|
63
|
+
if (args.some((arg) => arg.startsWith("--"))) {
|
|
64
|
+
throw new Error(`Unknown secret export option: ${args.find((arg) => arg.startsWith("--"))}`);
|
|
65
|
+
}
|
|
66
|
+
const outputPath = args.shift() || path.resolve(`remoteagent-secrets-${formatDate(new Date())}.ra-secrets`);
|
|
67
|
+
if (args.length > 0) {
|
|
68
|
+
throw new Error(`Unexpected secret export argument: ${args[0]}`);
|
|
69
|
+
}
|
|
70
|
+
const passphrase = passphraseFile
|
|
71
|
+
? await readSingleLineFile(passphraseFile)
|
|
72
|
+
: await promptConfirmedPassphrase();
|
|
73
|
+
const result = await exportSecrets(dataDir, outputPath, passphrase);
|
|
74
|
+
console.log([
|
|
75
|
+
`Exported ${result.count} secret(s) to ${result.outputPath}.`,
|
|
76
|
+
"The bundle is encrypted. Transfer it together with neither the passphrase nor the source secret store.",
|
|
77
|
+
].join("\n"));
|
|
78
|
+
}
|
|
79
|
+
async function importSecretCommand(dataDir, args) {
|
|
80
|
+
const passphraseFile = takeOption(args, "--passphrase-file");
|
|
81
|
+
const replace = takeFlag(args, "--replace");
|
|
82
|
+
if (args.some((arg) => arg.startsWith("--"))) {
|
|
83
|
+
throw new Error(`Unknown secret import option: ${args.find((arg) => arg.startsWith("--"))}`);
|
|
84
|
+
}
|
|
85
|
+
const inputPath = args.shift();
|
|
86
|
+
if (!inputPath) {
|
|
87
|
+
throw new Error("Usage: remoteagent secret import <file> [--replace]");
|
|
88
|
+
}
|
|
89
|
+
if (args.length > 0) {
|
|
90
|
+
throw new Error(`Unexpected secret import argument: ${args[0]}`);
|
|
91
|
+
}
|
|
92
|
+
const passphrase = passphraseFile
|
|
93
|
+
? await readSingleLineFile(passphraseFile)
|
|
94
|
+
: await promptHidden("Secret bundle passphrase: ");
|
|
95
|
+
const result = await importSecrets(dataDir, inputPath, passphrase, replace);
|
|
96
|
+
console.log([
|
|
97
|
+
`Imported ${result.imported} secret(s); overwritten=${result.overwritten}; total=${result.total}.`,
|
|
98
|
+
result.backupPath ? `Previous secret store backup: ${result.backupPath}` : "No previous secret store required a backup.",
|
|
99
|
+
"Secret values were not printed.",
|
|
100
|
+
].join("\n"));
|
|
101
|
+
}
|
|
102
|
+
function takeOption(args, name) {
|
|
103
|
+
const index = args.indexOf(name);
|
|
104
|
+
if (index < 0) {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
const value = args[index + 1];
|
|
108
|
+
if (!value || value.startsWith("--")) {
|
|
109
|
+
throw new Error(`${name} requires a value.`);
|
|
110
|
+
}
|
|
111
|
+
args.splice(index, 2);
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
function takeFlag(args, name) {
|
|
115
|
+
const index = args.indexOf(name);
|
|
116
|
+
if (index < 0) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
args.splice(index, 1);
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
async function promptVisible(question) {
|
|
123
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
124
|
+
throw new Error(`${question.trim()} is required as a command option in non-interactive mode.`);
|
|
125
|
+
}
|
|
126
|
+
const terminal = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
127
|
+
try {
|
|
128
|
+
return (await terminal.question(question)).trim();
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
terminal.close();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async function promptHidden(question) {
|
|
135
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY || !process.stdin.setRawMode) {
|
|
136
|
+
throw new Error("Interactive hidden input requires a TTY. Use the corresponding --*-file option instead.");
|
|
137
|
+
}
|
|
138
|
+
process.stdout.write(question);
|
|
139
|
+
process.stdin.setRawMode(true);
|
|
140
|
+
process.stdin.resume();
|
|
141
|
+
process.stdin.setEncoding("utf8");
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
let value = "";
|
|
144
|
+
const cleanup = () => {
|
|
145
|
+
process.stdin.off("data", onData);
|
|
146
|
+
process.stdin.setRawMode(false);
|
|
147
|
+
process.stdin.pause();
|
|
148
|
+
process.stdout.write("\n");
|
|
149
|
+
};
|
|
150
|
+
const onData = (chunk) => {
|
|
151
|
+
for (const char of chunk) {
|
|
152
|
+
if (char === "\u0003") {
|
|
153
|
+
cleanup();
|
|
154
|
+
reject(new Error("Cancelled."));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (char === "\r" || char === "\n") {
|
|
158
|
+
cleanup();
|
|
159
|
+
resolve(value);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (char === "\u007f" || char === "\b") {
|
|
163
|
+
value = value.slice(0, -1);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
value += char;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
process.stdin.on("data", onData);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
async function promptConfirmedPassphrase() {
|
|
173
|
+
const first = await promptHidden("Secret bundle passphrase: ");
|
|
174
|
+
const second = await promptHidden("Confirm passphrase: ");
|
|
175
|
+
if (first !== second) {
|
|
176
|
+
throw new Error("Passphrases did not match.");
|
|
177
|
+
}
|
|
178
|
+
return first;
|
|
179
|
+
}
|
|
180
|
+
async function readSingleLineFile(filePath) {
|
|
181
|
+
const value = await fs.readFile(path.resolve(filePath), "utf8");
|
|
182
|
+
return value.replace(/[\r\n]+$/, "");
|
|
183
|
+
}
|
|
184
|
+
function formatDate(date) {
|
|
185
|
+
return date.toISOString().slice(0, 10).replace(/-/g, "");
|
|
186
|
+
}
|
|
187
|
+
function printHelp() {
|
|
188
|
+
console.log(`RemoteAgent CLI
|
|
189
|
+
|
|
190
|
+
Usage:
|
|
191
|
+
remoteagent Start the foreground runtime
|
|
192
|
+
remoteagent bot add [token] [--owner <telegram-user-id>]
|
|
193
|
+
remoteagent bot add --token-file <file> --owner <telegram-user-id>
|
|
194
|
+
remoteagent secret export [file] [--passphrase-file <file>]
|
|
195
|
+
remoteagent secret import <file> [--replace] [--passphrase-file <file>]
|
|
196
|
+
|
|
197
|
+
Global option:
|
|
198
|
+
--data-dir <path> Default: ~/.remoteagent
|
|
199
|
+
|
|
200
|
+
Security:
|
|
201
|
+
Omit tokens and passphrases for hidden interactive prompts. File options are
|
|
202
|
+
intended for automation and keep sensitive values out of shell history.`);
|
|
203
|
+
}
|
|
204
|
+
main().catch((error) => {
|
|
205
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
206
|
+
console.error(`RemoteAgent CLI error: ${message}`);
|
|
207
|
+
process.exitCode = 1;
|
|
208
|
+
});
|
|
@@ -267,8 +267,7 @@ export class AgentMemoryService {
|
|
|
267
267
|
return existed;
|
|
268
268
|
}
|
|
269
269
|
async getMacro(aliasOrIndex) {
|
|
270
|
-
const macros =
|
|
271
|
-
.sort((left, right) => left.alias.localeCompare(right.alias, "ko"));
|
|
270
|
+
const macros = await this.getMacros();
|
|
272
271
|
const trimmed = aliasOrIndex.trim();
|
|
273
272
|
if (/^[0-9]+$/.test(trimmed)) {
|
|
274
273
|
const index = Number.parseInt(trimmed, 10);
|
|
@@ -277,9 +276,12 @@ export class AgentMemoryService {
|
|
|
277
276
|
const normalizedAlias = this.normalizeMacroAlias(trimmed);
|
|
278
277
|
return macros.find((macro) => macro.alias === normalizedAlias);
|
|
279
278
|
}
|
|
280
|
-
async
|
|
281
|
-
|
|
279
|
+
async getMacros() {
|
|
280
|
+
return Object.values(await this.readMacros())
|
|
282
281
|
.sort((left, right) => left.alias.localeCompare(right.alias, "ko"));
|
|
282
|
+
}
|
|
283
|
+
async listMacros() {
|
|
284
|
+
const macros = await this.getMacros();
|
|
283
285
|
if (macros.length === 0) {
|
|
284
286
|
return "No macros are stored.";
|
|
285
287
|
}
|
|
@@ -31,6 +31,9 @@ export class BotManagementService {
|
|
|
31
31
|
}
|
|
32
32
|
return this.formatBots(bots, await this.pollingState.list());
|
|
33
33
|
}
|
|
34
|
+
async listBotChoices() {
|
|
35
|
+
return (await this.listConfiguredBots()).map(({ id, username }) => ({ id, username }));
|
|
36
|
+
}
|
|
34
37
|
async formatCurrentBotSummary(currentBotId) {
|
|
35
38
|
const env = await this.readEnvConfig();
|
|
36
39
|
const bots = this.zipBots(env.tokens, env.usernames);
|
|
@@ -181,15 +181,12 @@ export class BridgeService {
|
|
|
181
181
|
}, chatSession.session.workspace);
|
|
182
182
|
}
|
|
183
183
|
async formatModelSelection(botId, chatId) {
|
|
184
|
-
const
|
|
185
|
-
const provider =
|
|
186
|
-
this.ensurePaired(chatSession, provider);
|
|
187
|
-
const providerSession = chatSession.session[provider];
|
|
188
|
-
const presets = MODEL_PRESETS[provider] ?? [];
|
|
184
|
+
const selection = await this.getModelSelection(botId, chatId);
|
|
185
|
+
const { provider, currentModel, presets } = selection;
|
|
189
186
|
const lines = [
|
|
190
|
-
`session: ${
|
|
187
|
+
`session: ${selection.sessionPublicId}`,
|
|
191
188
|
`mode: ${provider}`,
|
|
192
|
-
`currentModel: ${
|
|
189
|
+
`currentModel: ${currentModel}`,
|
|
193
190
|
"availablePresets:",
|
|
194
191
|
...presets.map((item, index) => ` ${index + 1}. ${item}`),
|
|
195
192
|
"",
|
|
@@ -200,6 +197,18 @@ export class BridgeService {
|
|
|
200
197
|
}
|
|
201
198
|
return lines.join("\n");
|
|
202
199
|
}
|
|
200
|
+
async getModelSelection(botId, chatId) {
|
|
201
|
+
const chatSession = await this.requireChat(botId, chatId);
|
|
202
|
+
const provider = chatSession.session.mode;
|
|
203
|
+
this.ensurePaired(chatSession, provider);
|
|
204
|
+
const providerSession = chatSession.session[provider];
|
|
205
|
+
return {
|
|
206
|
+
sessionPublicId: chatSession.session.publicId,
|
|
207
|
+
provider,
|
|
208
|
+
currentModel: providerSession.model ?? this.defaultModelFor(provider),
|
|
209
|
+
presets: [...(MODEL_PRESETS[provider] ?? [])],
|
|
210
|
+
};
|
|
211
|
+
}
|
|
203
212
|
async status(botId, chatId) {
|
|
204
213
|
return this.store.getChatSession(botId, chatId);
|
|
205
214
|
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export async function registerTelegramBot(options) {
|
|
4
|
+
const token = options.token.trim();
|
|
5
|
+
const ownerId = options.ownerId.trim();
|
|
6
|
+
assertBotToken(token);
|
|
7
|
+
assertOwnerId(ownerId);
|
|
8
|
+
const identity = options.identity ?? await fetchTelegramBotIdentity(token);
|
|
9
|
+
const envPath = path.join(options.dataDir, ".env");
|
|
10
|
+
const original = await fs.readFile(envPath, "utf8").catch((error) => {
|
|
11
|
+
if (error.code === "ENOENT") {
|
|
12
|
+
return "";
|
|
13
|
+
}
|
|
14
|
+
throw error;
|
|
15
|
+
});
|
|
16
|
+
const values = parseEnv(original);
|
|
17
|
+
const configuredTokens = parseCsv(values.get("TELEGRAM_BOT_TOKENS") || values.get("TELEGRAM_BOT_TOKEN") || "");
|
|
18
|
+
const configuredUsernames = parseCsv(values.get("TELEGRAM_BOT_USERNAMES") || "");
|
|
19
|
+
const validIndexes = configuredTokens
|
|
20
|
+
.map((configuredToken, index) => isBotToken(configuredToken) ? index : -1)
|
|
21
|
+
.filter((index) => index >= 0);
|
|
22
|
+
const tokens = validIndexes.map((index) => configuredTokens[index]);
|
|
23
|
+
const usernames = validIndexes.map((index) => configuredUsernames[index] || "");
|
|
24
|
+
const existingIndex = tokens.indexOf(token);
|
|
25
|
+
const usernameIndex = usernames.findIndex((value) => value.toLowerCase() === identity.username.toLowerCase());
|
|
26
|
+
if (usernameIndex >= 0 && existingIndex < 0) {
|
|
27
|
+
throw new Error(`Telegram bot @${identity.username} is already configured with another token.`);
|
|
28
|
+
}
|
|
29
|
+
const added = existingIndex < 0;
|
|
30
|
+
if (added) {
|
|
31
|
+
tokens.push(token);
|
|
32
|
+
usernames.push(identity.username);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
while (usernames.length < tokens.length) {
|
|
36
|
+
usernames.push("");
|
|
37
|
+
}
|
|
38
|
+
usernames[existingIndex] = identity.username;
|
|
39
|
+
}
|
|
40
|
+
const next = upsertEnv(original, {
|
|
41
|
+
TELEGRAM_BOT_TOKEN: tokens[0],
|
|
42
|
+
TELEGRAM_BOT_TOKENS: tokens.join(","),
|
|
43
|
+
TELEGRAM_BOT_USERNAMES: usernames.join(","),
|
|
44
|
+
TELEGRAM_OWNER_ID: ownerId,
|
|
45
|
+
});
|
|
46
|
+
await atomicWrite(envPath, next, 0o600);
|
|
47
|
+
return {
|
|
48
|
+
identity,
|
|
49
|
+
envPath,
|
|
50
|
+
added,
|
|
51
|
+
botCount: tokens.length,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export async function readConfiguredOwnerId(dataDir) {
|
|
55
|
+
const envPath = path.join(dataDir, ".env");
|
|
56
|
+
const text = await fs.readFile(envPath, "utf8").catch((error) => {
|
|
57
|
+
if (error.code === "ENOENT") {
|
|
58
|
+
return "";
|
|
59
|
+
}
|
|
60
|
+
throw error;
|
|
61
|
+
});
|
|
62
|
+
return parseEnv(text).get("TELEGRAM_OWNER_ID")?.trim() || undefined;
|
|
63
|
+
}
|
|
64
|
+
export async function fetchTelegramBotIdentity(token) {
|
|
65
|
+
assertBotToken(token);
|
|
66
|
+
let response;
|
|
67
|
+
try {
|
|
68
|
+
response = await fetch(`https://api.telegram.org/bot${token}/getMe`, {
|
|
69
|
+
signal: AbortSignal.timeout(20_000),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
throw new Error("Telegram getMe request failed. Check this machine's network and DNS, then retry.");
|
|
74
|
+
}
|
|
75
|
+
let payload;
|
|
76
|
+
try {
|
|
77
|
+
payload = await response.json();
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
throw new Error(`Telegram getMe returned an invalid response (HTTP ${response.status}).`);
|
|
81
|
+
}
|
|
82
|
+
if (!response.ok || !payload.ok || !payload.result?.id || !payload.result.username) {
|
|
83
|
+
throw new Error(payload.description || `Telegram rejected the supplied bot token (HTTP ${response.status}).`);
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
id: payload.result.id,
|
|
87
|
+
username: payload.result.username,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function assertBotToken(token) {
|
|
91
|
+
if (!isBotToken(token)) {
|
|
92
|
+
throw new Error("Invalid Telegram bot token format.");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function isBotToken(token) {
|
|
96
|
+
return /^\d+:[A-Za-z0-9_-]{20,}$/.test(token);
|
|
97
|
+
}
|
|
98
|
+
function assertOwnerId(ownerId) {
|
|
99
|
+
if (!/^\d+$/.test(ownerId)) {
|
|
100
|
+
throw new Error("Telegram owner ID must contain digits only.");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function parseCsv(value) {
|
|
104
|
+
return value
|
|
105
|
+
.split(/[\r\n,]+/)
|
|
106
|
+
.map((item) => item.trim())
|
|
107
|
+
.filter(Boolean);
|
|
108
|
+
}
|
|
109
|
+
function parseEnv(text) {
|
|
110
|
+
const result = new Map();
|
|
111
|
+
for (const line of text.split(/\r?\n/)) {
|
|
112
|
+
const match = /^([A-Z0-9_]+)=(.*)$/.exec(line);
|
|
113
|
+
if (match) {
|
|
114
|
+
result.set(match[1], match[2]);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
function upsertEnv(text, replacements) {
|
|
120
|
+
const remaining = new Map(Object.entries(replacements));
|
|
121
|
+
const lines = text.split(/\r?\n/);
|
|
122
|
+
const output = [];
|
|
123
|
+
for (const line of lines) {
|
|
124
|
+
const match = /^([A-Z0-9_]+)=/.exec(line);
|
|
125
|
+
const key = match?.[1];
|
|
126
|
+
if (key && remaining.has(key)) {
|
|
127
|
+
output.push(`${key}=${remaining.get(key)}`);
|
|
128
|
+
remaining.delete(key);
|
|
129
|
+
}
|
|
130
|
+
else if (line || output.length > 0) {
|
|
131
|
+
output.push(line);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
while (output.at(-1) === "") {
|
|
135
|
+
output.pop();
|
|
136
|
+
}
|
|
137
|
+
for (const [key, value] of remaining) {
|
|
138
|
+
output.push(`${key}=${value}`);
|
|
139
|
+
}
|
|
140
|
+
return `${output.join("\n")}\n`;
|
|
141
|
+
}
|
|
142
|
+
async function atomicWrite(filePath, content, mode) {
|
|
143
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
144
|
+
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
145
|
+
try {
|
|
146
|
+
await fs.writeFile(tempPath, content, { encoding: "utf8", mode });
|
|
147
|
+
await fs.chmod(tempPath, mode).catch(() => undefined);
|
|
148
|
+
await fs.rename(tempPath, filePath);
|
|
149
|
+
await fs.chmod(filePath, mode).catch(() => undefined);
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
await fs.rm(tempPath, { force: true }).catch(() => undefined);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes, scrypt } from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { gunzipSync, gzipSync } from "node:zlib";
|
|
5
|
+
const BUNDLE_FORMAT = "remoteagent-secret-bundle";
|
|
6
|
+
const BUNDLE_VERSION = 1;
|
|
7
|
+
const AAD = Buffer.from(`${BUNDLE_FORMAT}:v${BUNDLE_VERSION}`, "utf8");
|
|
8
|
+
export async function exportSecrets(dataDir, outputPath, passphrase, options = {}) {
|
|
9
|
+
assertPassphrase(passphrase);
|
|
10
|
+
const secretsPath = path.join(dataDir, "managed", "secrets.json");
|
|
11
|
+
const storedSecrets = await readSecrets(secretsPath, false);
|
|
12
|
+
const secrets = selectSecrets(storedSecrets, options);
|
|
13
|
+
const payload = {
|
|
14
|
+
exportedAt: new Date().toISOString(),
|
|
15
|
+
secrets,
|
|
16
|
+
};
|
|
17
|
+
const salt = randomBytes(16);
|
|
18
|
+
const iv = randomBytes(12);
|
|
19
|
+
const key = await deriveKey(passphrase, salt);
|
|
20
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
21
|
+
cipher.setAAD(AAD);
|
|
22
|
+
const compressedPayload = gzipSync(Buffer.from(JSON.stringify(payload), "utf8"));
|
|
23
|
+
const encrypted = Buffer.concat([
|
|
24
|
+
cipher.update(compressedPayload),
|
|
25
|
+
cipher.final(),
|
|
26
|
+
]);
|
|
27
|
+
const bundle = {
|
|
28
|
+
format: BUNDLE_FORMAT,
|
|
29
|
+
version: BUNDLE_VERSION,
|
|
30
|
+
compression: "gzip",
|
|
31
|
+
kdf: {
|
|
32
|
+
name: "scrypt",
|
|
33
|
+
salt: salt.toString("base64"),
|
|
34
|
+
},
|
|
35
|
+
cipher: {
|
|
36
|
+
name: "aes-256-gcm",
|
|
37
|
+
iv: iv.toString("base64"),
|
|
38
|
+
tag: cipher.getAuthTag().toString("base64"),
|
|
39
|
+
},
|
|
40
|
+
data: encrypted.toString("base64"),
|
|
41
|
+
};
|
|
42
|
+
const resolvedOutputPath = path.resolve(outputPath);
|
|
43
|
+
await atomicWrite(resolvedOutputPath, `${JSON.stringify(bundle, null, 2)}\n`, 0o600);
|
|
44
|
+
return {
|
|
45
|
+
outputPath: resolvedOutputPath,
|
|
46
|
+
count: Object.keys(secrets).length,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function selectSecrets(secrets, options) {
|
|
50
|
+
const excluded = new Set((options.excludeKeys ?? []).map((key) => key.trim().toUpperCase()).filter(Boolean));
|
|
51
|
+
const requested = (options.includeKeys ?? []).map((key) => key.trim().toUpperCase()).filter(Boolean);
|
|
52
|
+
const selected = {};
|
|
53
|
+
if (requested.length > 0) {
|
|
54
|
+
const missing = requested.filter((key) => !secrets[key]);
|
|
55
|
+
if (missing.length > 0) {
|
|
56
|
+
throw new Error(`Secret key was not found: ${missing.join(", ")}`);
|
|
57
|
+
}
|
|
58
|
+
for (const key of requested) {
|
|
59
|
+
if (!excluded.has(key)) {
|
|
60
|
+
selected[key] = secrets[key];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
for (const [key, record] of Object.entries(secrets)) {
|
|
66
|
+
if (!excluded.has(key)) {
|
|
67
|
+
selected[key] = record;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (Object.keys(selected).length === 0) {
|
|
72
|
+
throw new Error("No Secret values remain to export.");
|
|
73
|
+
}
|
|
74
|
+
return selected;
|
|
75
|
+
}
|
|
76
|
+
export async function importSecrets(dataDir, inputPath, passphrase, replace = false) {
|
|
77
|
+
assertPassphrase(passphrase);
|
|
78
|
+
const resolvedInputPath = path.resolve(inputPath);
|
|
79
|
+
const bundle = parseBundle(await fs.readFile(resolvedInputPath, "utf8"));
|
|
80
|
+
const salt = decodeBase64(bundle.kdf.salt, "salt");
|
|
81
|
+
const iv = decodeBase64(bundle.cipher.iv, "iv");
|
|
82
|
+
const tag = decodeBase64(bundle.cipher.tag, "authentication tag");
|
|
83
|
+
const encrypted = decodeBase64(bundle.data, "encrypted data");
|
|
84
|
+
const key = await deriveKey(passphrase, salt);
|
|
85
|
+
let plaintext;
|
|
86
|
+
try {
|
|
87
|
+
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
88
|
+
decipher.setAAD(AAD);
|
|
89
|
+
decipher.setAuthTag(tag);
|
|
90
|
+
plaintext = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
throw new Error("Secret bundle could not be decrypted. Check the passphrase and file integrity.");
|
|
94
|
+
}
|
|
95
|
+
let decodedPayload = plaintext;
|
|
96
|
+
if (bundle.compression === "gzip") {
|
|
97
|
+
try {
|
|
98
|
+
decodedPayload = gunzipSync(plaintext);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
throw new Error("Secret bundle compressed payload is invalid.");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const payload = parsePayload(decodedPayload.toString("utf8"));
|
|
105
|
+
const secretsPath = path.join(dataDir, "managed", "secrets.json");
|
|
106
|
+
const existing = await readSecrets(secretsPath, true);
|
|
107
|
+
const incomingKeys = Object.keys(payload.secrets);
|
|
108
|
+
const overwritten = incomingKeys.filter((keyName) => Boolean(existing[keyName])).length;
|
|
109
|
+
const merged = replace ? payload.secrets : { ...existing, ...payload.secrets };
|
|
110
|
+
let backupPath;
|
|
111
|
+
if (Object.keys(existing).length > 0) {
|
|
112
|
+
backupPath = `${secretsPath}.backup-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
113
|
+
await fs.mkdir(path.dirname(backupPath), { recursive: true });
|
|
114
|
+
await fs.copyFile(secretsPath, backupPath);
|
|
115
|
+
await fs.chmod(backupPath, 0o600).catch(() => undefined);
|
|
116
|
+
}
|
|
117
|
+
await atomicWrite(secretsPath, `${JSON.stringify(merged, null, 2)}\n`, 0o600);
|
|
118
|
+
return {
|
|
119
|
+
imported: incomingKeys.length,
|
|
120
|
+
overwritten,
|
|
121
|
+
total: Object.keys(merged).length,
|
|
122
|
+
backupPath,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function parseBundle(text) {
|
|
126
|
+
let value;
|
|
127
|
+
try {
|
|
128
|
+
value = JSON.parse(text);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
throw new Error("Secret bundle is not valid JSON.");
|
|
132
|
+
}
|
|
133
|
+
const bundle = value;
|
|
134
|
+
if (bundle.format !== BUNDLE_FORMAT
|
|
135
|
+
|| bundle.version !== BUNDLE_VERSION
|
|
136
|
+
|| (bundle.compression !== undefined && bundle.compression !== "gzip")
|
|
137
|
+
|| bundle.kdf?.name !== "scrypt"
|
|
138
|
+
|| bundle.cipher?.name !== "aes-256-gcm"
|
|
139
|
+
|| typeof bundle.kdf.salt !== "string"
|
|
140
|
+
|| typeof bundle.cipher.iv !== "string"
|
|
141
|
+
|| typeof bundle.cipher.tag !== "string"
|
|
142
|
+
|| typeof bundle.data !== "string") {
|
|
143
|
+
throw new Error("Unsupported or malformed RemoteAgent secret bundle.");
|
|
144
|
+
}
|
|
145
|
+
return bundle;
|
|
146
|
+
}
|
|
147
|
+
function parsePayload(text) {
|
|
148
|
+
let value;
|
|
149
|
+
try {
|
|
150
|
+
value = JSON.parse(text);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
throw new Error("Decrypted secret payload is invalid.");
|
|
154
|
+
}
|
|
155
|
+
const payload = value;
|
|
156
|
+
if (!payload.secrets || typeof payload.secrets !== "object" || Array.isArray(payload.secrets)) {
|
|
157
|
+
throw new Error("Decrypted secret payload has no valid secret records.");
|
|
158
|
+
}
|
|
159
|
+
validateSecrets(payload.secrets);
|
|
160
|
+
return payload;
|
|
161
|
+
}
|
|
162
|
+
async function readSecrets(filePath, missingIsEmpty) {
|
|
163
|
+
let text;
|
|
164
|
+
try {
|
|
165
|
+
text = await fs.readFile(filePath, "utf8");
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
if (error.code === "ENOENT") {
|
|
169
|
+
if (missingIsEmpty) {
|
|
170
|
+
return {};
|
|
171
|
+
}
|
|
172
|
+
throw new Error(`No RemoteAgent secrets were found at ${filePath}.`);
|
|
173
|
+
}
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
let secrets;
|
|
177
|
+
try {
|
|
178
|
+
secrets = JSON.parse(text);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
throw new Error(`RemoteAgent secret store is invalid JSON: ${filePath}`);
|
|
182
|
+
}
|
|
183
|
+
if (!secrets || typeof secrets !== "object" || Array.isArray(secrets)) {
|
|
184
|
+
throw new Error(`RemoteAgent secret store is malformed: ${filePath}`);
|
|
185
|
+
}
|
|
186
|
+
validateSecrets(secrets);
|
|
187
|
+
return secrets;
|
|
188
|
+
}
|
|
189
|
+
function validateSecrets(secrets) {
|
|
190
|
+
for (const [key, record] of Object.entries(secrets)) {
|
|
191
|
+
if (!/^[A-Z0-9_.-]{1,80}$/.test(key)
|
|
192
|
+
|| !record
|
|
193
|
+
|| typeof record !== "object"
|
|
194
|
+
|| record.key !== key
|
|
195
|
+
|| typeof record.value !== "string"
|
|
196
|
+
|| typeof record.createdAt !== "string"
|
|
197
|
+
|| typeof record.updatedAt !== "string") {
|
|
198
|
+
throw new Error(`Secret record is malformed: ${key}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function assertPassphrase(passphrase) {
|
|
203
|
+
if (passphrase.length < 8) {
|
|
204
|
+
throw new Error("Secret bundle passphrase must be at least 8 characters.");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function decodeBase64(value, label) {
|
|
208
|
+
const decoded = Buffer.from(value, "base64");
|
|
209
|
+
if (decoded.length === 0) {
|
|
210
|
+
throw new Error(`Secret bundle ${label} is empty or invalid.`);
|
|
211
|
+
}
|
|
212
|
+
return decoded;
|
|
213
|
+
}
|
|
214
|
+
function deriveKey(passphrase, salt) {
|
|
215
|
+
return new Promise((resolve, reject) => {
|
|
216
|
+
scrypt(passphrase, salt, 32, (error, key) => {
|
|
217
|
+
if (error) {
|
|
218
|
+
reject(error);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
resolve(key);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
async function atomicWrite(filePath, content, mode) {
|
|
226
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
227
|
+
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
228
|
+
try {
|
|
229
|
+
await fs.writeFile(tempPath, content, { encoding: "utf8", mode });
|
|
230
|
+
await fs.chmod(tempPath, mode).catch(() => undefined);
|
|
231
|
+
await fs.rename(tempPath, filePath);
|
|
232
|
+
await fs.chmod(filePath, mode).catch(() => undefined);
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
await fs.rm(tempPath, { force: true }).catch(() => undefined);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -11,7 +11,7 @@ export const TELEGRAM_COMMAND_MENU = [
|
|
|
11
11
|
{ command: "attach", description: "Attach an existing provider session" },
|
|
12
12
|
{ command: "state", description: "Show or edit session state notes" },
|
|
13
13
|
{ command: "option", description: "Show or change runtime options" },
|
|
14
|
-
{ command: "secret", description: "Store or manage hidden
|
|
14
|
+
{ command: "secret", description: "Store, export, or manage hidden secrets" },
|
|
15
15
|
{ command: "docs", description: "Pin or find session documents" },
|
|
16
16
|
{ command: "macro", description: "Save or run reusable instructions" },
|
|
17
17
|
{ command: "model", description: "Show or change provider model" },
|