appback-remoteagent 0.18.0 → 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 CHANGED
@@ -318,6 +318,34 @@ remoteagent-start
318
318
 
319
319
  `remoteagent-install` seeds provider install/login hook paths into `~/.remoteagent/.env` automatically, so `/install codex` and `/install claude` work on a fresh machine without manual hook wiring.
320
320
 
321
+ Register the first Telegram bot without editing `.env` manually:
322
+
323
+ ```bash
324
+ remoteagent bot add
325
+ remoteagent-start
326
+ ```
327
+
328
+ The command validates the BotFather token, stores the bot configuration, and configures the numeric Telegram owner user ID. Tokens are entered through a hidden prompt by default.
329
+
330
+ To move installation-wide `/secret` values to another PC, export and import a password-encrypted bundle:
331
+
332
+ ```bash
333
+ # Old PC
334
+ remoteagent secret export ~/remoteagent-secrets.ra-secrets
335
+
336
+ # New PC
337
+ remoteagent secret import ~/remoteagent-secrets.ra-secrets
338
+ ```
339
+
340
+ See [CLI bootstrap and secret migration](./docs/CLI_BOOTSTRAP_AND_SECRET_MIGRATION.md) for the complete interactive and automation-safe commands.
341
+
342
+ Selected Secret keys can also be returned to the current private Telegram chat as a compressed, encrypted bundle without exposing values to the provider:
343
+
344
+ ```text
345
+ /secret set REMOTEAGENT_TRANSFER_PASSPHRASE a-long-private-passphrase
346
+ /secret export REMOTEAGENT_TRANSFER_PASSPHRASE KEY_ONE KEY_TWO
347
+ ```
348
+
321
349
  For one-line installs on a fresh machine:
322
350
 
323
351
  ```bash
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env node
2
- import "../dist/index.js";
2
+ import "../dist/cli.js";
package/dist/bot.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import fsSync from "node:fs";
3
3
  import fs from "node:fs/promises";
4
+ import os from "node:os";
4
5
  import path from "node:path";
5
6
  import { randomUUID } from "node:crypto";
6
7
  import { promisify } from "node:util";
@@ -10,6 +11,7 @@ import { ProviderSetupService } from "./services/provider-setup-service.js";
10
11
  import { RemoteShellService } from "./services/remote-shell-service.js";
11
12
  import { AgentMemoryService } from "./services/agent-memory-service.js";
12
13
  import { WorkspaceCleanupService } from "./services/workspace-cleanup-service.js";
14
+ import { exportSecrets } from "./services/secret-transfer-service.js";
13
15
  import { deleteTelegramCommandMenu, setTelegramCommandMenu } from "./telegram-command-menu.js";
14
16
  const execFileAsync = promisify(execFile);
15
17
  const HELP_TEXT = [
@@ -32,7 +34,7 @@ const HELP_TEXT = [
32
34
  "/state [clear|note <text>]",
33
35
  "/artifacts list|cleanup <days>",
34
36
  "/cleanup",
35
- "/secret set|list|remove",
37
+ "/secret set|list|remove|export",
36
38
  "/docs pin|find|list|remove|reinforce",
37
39
  "/macro set|list|remove|<alias|number>",
38
40
  "/매크로 set|list|remove|<alias|number>",
@@ -782,9 +784,46 @@ ${bridge.formatStatus(mapping)}`);
782
784
  return;
783
785
  }
784
786
  await memoryService.setSecret(key, rest.trim());
787
+ if (ctx.chat && ctx.message?.message_id) {
788
+ await deleteTelegramMessage(token, ctx.chat.id, ctx.message.message_id).catch((error) => {
789
+ console.warn(`[secret] failed to delete source message for ${key}: ${error instanceof Error ? error.message : String(error)}`);
790
+ });
791
+ }
785
792
  await reply(ctx, `Stored secret key ${key}. Value is hidden from agents and chat output.`);
786
793
  return;
787
794
  }
795
+ if (action === "export") {
796
+ if (!key || !ctx.chat) {
797
+ await reply(ctx, formatSecretHelp(), { parse_mode: "Markdown" });
798
+ return;
799
+ }
800
+ const passphrase = await memoryService.getSecret(key);
801
+ if (!passphrase) {
802
+ await reply(ctx, `Secret key was not found: ${key}`);
803
+ return;
804
+ }
805
+ const selectedKeys = rest
806
+ ?.split(/\s+/)
807
+ .map((value) => value.trim().toUpperCase())
808
+ .filter(Boolean);
809
+ const mapping = await bridge.status(getBotId(), String(ctx.chat.id)).catch(() => undefined);
810
+ const exportDir = await fs.mkdtemp(path.join(os.tmpdir(), "remoteagent-secret-export-"));
811
+ const exportPath = path.join(exportDir, `remoteagent-secrets-${mapping?.session.publicId ?? "install"}-${new Date().toISOString().slice(0, 10)}.ra-secrets`);
812
+ try {
813
+ const result = await exportSecrets(config.dataDir, exportPath, passphrase, {
814
+ includeKeys: selectedKeys,
815
+ excludeKeys: [key],
816
+ });
817
+ await sendTelegramDocument(token, ctx.chat.id, {
818
+ path: result.outputPath,
819
+ caption: `Encrypted RemoteAgent Secret bundle (${result.count} key(s)). Secret values are not shown.`,
820
+ });
821
+ }
822
+ finally {
823
+ await fs.rm(exportDir, { recursive: true, force: true }).catch(() => undefined);
824
+ }
825
+ return;
826
+ }
788
827
  if (action === "remove") {
789
828
  if (!key) {
790
829
  await reply(ctx, formatSecretHelp(), { parse_mode: "Markdown" });
@@ -2411,12 +2450,15 @@ function formatSecretHelp() {
2411
2450
  "/secret set KEY value",
2412
2451
  "/secret list",
2413
2452
  "/secret remove KEY",
2453
+ "/secret export PASSPHRASE_KEY [KEY ...]",
2414
2454
  "```",
2415
2455
  "",
2416
2456
  "Example:",
2417
2457
  "```text",
2418
2458
  "/secret set GIFTISHOW_AUTH_KEY REAL...",
2419
2459
  "/secret set GIFTISHOW_TOKEN_KEY xNC...",
2460
+ "/secret set REMOTEAGENT_TRANSFER_PASSPHRASE a-long-private-passphrase",
2461
+ "/secret export REMOTEAGENT_TRANSFER_PASSPHRASE GIFTISHOW_AUTH_KEY GIFTISHOW_TOKEN_KEY",
2420
2462
  "```",
2421
2463
  "",
2422
2464
  "Then tell the agent:",
@@ -3009,6 +3051,25 @@ async function sendTelegramDocument(botToken, chatId, document) {
3009
3051
  }
3010
3052
  return payload.result;
3011
3053
  }
3054
+ async function deleteTelegramMessage(botToken, chatId, messageId) {
3055
+ const { stdout, stderr } = await execFileAsync("curl", [
3056
+ "-sS",
3057
+ "--max-time",
3058
+ "20",
3059
+ "-d",
3060
+ `chat_id=${chatId}`,
3061
+ "-d",
3062
+ `message_id=${messageId}`,
3063
+ `https://api.telegram.org/bot${botToken}/deleteMessage`,
3064
+ ]);
3065
+ if (stderr?.trim()) {
3066
+ console.error(`curl stderr for deleteMessage: ${stderr.trim()}`);
3067
+ }
3068
+ const payload = JSON.parse(stdout);
3069
+ if (!payload.ok) {
3070
+ throw new Error(payload.description || "Telegram API deleteMessage failed.");
3071
+ }
3072
+ }
3012
3073
  async function sendTelegramMessage(botToken, chatId, text, extra) {
3013
3074
  const startedAt = Date.now();
3014
3075
  try {
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
+ });
@@ -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 secret values" },
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" },
@@ -0,0 +1,98 @@
1
+ # CLI bootstrap and secret migration
2
+
3
+ ## First Telegram bot
4
+
5
+ Install RemoteAgent, seed its runtime configuration, and register the first Telegram bot:
6
+
7
+ ```bash
8
+ npm install -g appback-remoteagent
9
+ remoteagent-install
10
+ remoteagent bot add
11
+ remoteagent-start
12
+ ```
13
+
14
+ `remoteagent bot add` asks for the BotFather token without echoing it and then asks for the numeric Telegram owner user ID. It validates the token with Telegram `getMe` before writing `~/.remoteagent/.env`.
15
+
16
+ For automation, keep sensitive values out of shell history by using a permission-restricted token file:
17
+
18
+ ```bash
19
+ chmod 600 /secure/path/telegram-token
20
+ remoteagent bot add --token-file /secure/path/telegram-token --owner 123456789
21
+ ```
22
+
23
+ After adding or updating a bot, apply the configuration with the runtime command appropriate to the installation:
24
+
25
+ ```bash
26
+ remoteagent-start
27
+ ```
28
+
29
+ ```bash
30
+ sudo systemctl restart remoteagent
31
+ ```
32
+
33
+ ## Secret migration
34
+
35
+ RemoteAgent `/secret` values belong to the installation, not to an individual agent session. They are stored under `~/.remoteagent/managed/secrets.json`.
36
+
37
+ Export them on the old PC as a password-encrypted bundle:
38
+
39
+ ```bash
40
+ remoteagent secret export ~/remoteagent-secrets.ra-secrets
41
+ ```
42
+
43
+ The command asks twice for a bundle passphrase. The resulting file uses scrypt key derivation and AES-256-GCM authenticated encryption; secret values are never printed.
44
+
45
+ Transfer the encrypted file to the new PC, install RemoteAgent, and import it:
46
+
47
+ ```bash
48
+ remoteagent-install
49
+ remoteagent secret import ~/remoteagent-secrets.ra-secrets
50
+ ```
51
+
52
+ Import merges the bundle into the new PC's installation-wide Secret store. Imported keys replace keys with the same name; unrelated existing keys remain. Before overwriting an existing store, RemoteAgent creates a permission-restricted timestamped backup beside `secrets.json`.
53
+
54
+ Use `--replace` only when the imported bundle must become the entire Secret store:
55
+
56
+ ```bash
57
+ remoteagent secret import ~/remoteagent-secrets.ra-secrets --replace
58
+ ```
59
+
60
+ For non-interactive automation, provide a permission-restricted passphrase file:
61
+
62
+ ```bash
63
+ chmod 600 /secure/path/transfer-passphrase
64
+ remoteagent secret export ~/remoteagent-secrets.ra-secrets --passphrase-file /secure/path/transfer-passphrase
65
+ remoteagent secret import ~/remoteagent-secrets.ra-secrets --passphrase-file /secure/path/transfer-passphrase
66
+ ```
67
+
68
+ The Telegram bot token and `TELEGRAM_OWNER_ID` are runtime configuration, not `/secret` values. Register the bot separately with `remoteagent bot add` on the new PC.
69
+
70
+ ## Encrypted delivery through Telegram
71
+
72
+ RemoteAgent can send selected Secret values back to the current private chat without exposing their values to the provider or Telegram message text. First store a transfer passphrase that you know:
73
+
74
+ ```text
75
+ /secret set REMOTEAGENT_TRANSFER_PASSPHRASE a-long-private-passphrase
76
+ ```
77
+
78
+ Then export only the required keys:
79
+
80
+ ```text
81
+ /secret export REMOTEAGENT_TRANSFER_PASSPHRASE APPBACK_RELEASE_STORE_PASSWORD APPBACK_RELEASE_KEY_PASSWORD APPBACK_RELEASE_KEYSTORE_BASE64 GOOGLE_PLAY_SERVICE_ACCOUNT_JSON
82
+ ```
83
+
84
+ RemoteAgent performs these steps itself:
85
+
86
+ 1. Reads the passphrase and selected Secret values without sending them to Codex or Claude.
87
+ 2. Excludes the passphrase key from the bundle.
88
+ 3. Compresses the payload with gzip and encrypts it with AES-256-GCM.
89
+ 4. Sends the `.ra-secrets` file to the same Telegram chat.
90
+ 5. Removes the temporary server-side bundle after delivery.
91
+
92
+ Import the received file on the destination PC with the CLI:
93
+
94
+ ```bash
95
+ remoteagent secret import ~/Downloads/remoteagent-secrets-S001-20260811.ra-secrets
96
+ ```
97
+
98
+ The `/secret set` source message is deleted from the private chat after successful storage when Telegram permits deletion, and local RemoteAgent logs redact its value. Telegram bot chats are not end-to-end encrypted, so the local CLI export remains the strongest option for especially sensitive credentials.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appback-remoteagent",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Personal installable session server for continuing local AI work across PC and Telegram",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,6 +20,7 @@
20
20
  "check": "tsc --noEmit -p tsconfig.json",
21
21
  "selftest:telegram": "npm run build && node scripts/selftest-telegram-update.mjs",
22
22
  "selftest:codex-stream": "npm run build && node scripts/selftest-codex-stream.mjs",
23
+ "selftest:cli": "npm run build && node scripts/selftest-cli.mjs",
23
24
  "prepare": "npm run build",
24
25
  "prepublishOnly": "node scripts/prepublish-guard.mjs",
25
26
  "release:version": "bash scripts/release-version.sh",
@@ -45,6 +45,13 @@ fi
45
45
 
46
46
  mkdir -p "$DATA_DIR" "$DATA_DIR/logs"
47
47
 
48
+ NODE_BIN_PATH="$(command -v node || true)"
49
+ if [ -z "$NODE_BIN_PATH" ]; then
50
+ echo "node is required to install RemoteAgent." >&2
51
+ exit 1
52
+ fi
53
+ NODE_BIN_DIR="$(cd -P "$(dirname "$NODE_BIN_PATH")" && pwd)"
54
+
48
55
  if [ ! -f "$ENV_FILE" ]; then
49
56
  cp "$ROOT_DIR/.env.example" "$ENV_FILE"
50
57
  echo "Created $ENV_FILE"
@@ -84,6 +91,8 @@ upsert_env "BOT_RESTART_HELPER_PATH" "$ROOT_DIR/scripts/restart-after-bot-op.sh"
84
91
 
85
92
  cat > "$DATA_DIR/start-remoteagent.sh" <<EOF
86
93
  #!/usr/bin/env bash
94
+ export PATH="$NODE_BIN_DIR:\$PATH"
95
+ export NODE_BIN="$NODE_BIN_PATH"
87
96
  DATA_DIR="$DATA_DIR" "$ROOT_DIR/scripts/start.sh"
88
97
  EOF
89
98
  chmod +x "$DATA_DIR/start-remoteagent.sh"
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+
3
+ import assert from "node:assert/strict";
4
+ import fs from "node:fs/promises";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { registerTelegramBot } from "../dist/services/cli-config-service.js";
8
+ import { exportSecrets, importSecrets } from "../dist/services/secret-transfer-service.js";
9
+
10
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "remoteagent-cli-selftest-"));
11
+ const sourceDataDir = path.join(root, "source");
12
+ const targetDataDir = path.join(root, "target");
13
+ const bundlePath = path.join(root, "transfer.ra-secrets");
14
+ const selectedBundlePath = path.join(root, "selected-transfer.ra-secrets");
15
+ const passphrase = "correct-horse-battery-staple";
16
+
17
+ try {
18
+ await fs.mkdir(sourceDataDir, { recursive: true });
19
+ await fs.writeFile(path.join(sourceDataDir, ".env"), [
20
+ "TELEGRAM_BOT_TOKEN=your-telegram-bot-token",
21
+ "TELEGRAM_BOT_TOKENS=",
22
+ "TELEGRAM_OWNER_ID=",
23
+ "DEFAULT_MODE=codex",
24
+ "",
25
+ ].join("\n"), { mode: 0o600 });
26
+
27
+ const first = await registerTelegramBot({
28
+ dataDir: sourceDataDir,
29
+ token: "100001:abcdefghijklmnopqrstuvwxyz_123456",
30
+ ownerId: "8202993989",
31
+ identity: { id: 100001, username: "first_remoteagent_bot" },
32
+ });
33
+ assert.equal(first.added, true);
34
+ assert.equal(first.botCount, 1);
35
+
36
+ const second = await registerTelegramBot({
37
+ dataDir: sourceDataDir,
38
+ token: "100002:abcdefghijklmnopqrstuvwxyz_654321",
39
+ ownerId: "8202993989",
40
+ identity: { id: 100002, username: "second_remoteagent_bot" },
41
+ });
42
+ assert.equal(second.botCount, 2);
43
+ const envText = await fs.readFile(path.join(sourceDataDir, ".env"), "utf8");
44
+ assert.match(envText, /DEFAULT_MODE=codex/);
45
+ assert.match(envText, /TELEGRAM_OWNER_ID=8202993989/);
46
+ assert.doesNotMatch(envText, /your-telegram-bot-token/);
47
+ assert.match(envText, /TELEGRAM_BOT_USERNAMES=first_remoteagent_bot,second_remoteagent_bot/);
48
+
49
+ const sourceSecrets = {
50
+ API_TOKEN: {
51
+ key: "API_TOKEN",
52
+ value: "plain-value-must-not-appear-in-bundle",
53
+ createdAt: "2026-01-01T00:00:00.000Z",
54
+ updatedAt: "2026-01-01T00:00:00.000Z",
55
+ },
56
+ DB_PASSWORD: {
57
+ key: "DB_PASSWORD",
58
+ value: "another-private-value",
59
+ createdAt: "2026-01-02T00:00:00.000Z",
60
+ updatedAt: "2026-01-02T00:00:00.000Z",
61
+ },
62
+ };
63
+ await fs.mkdir(path.join(sourceDataDir, "managed"), { recursive: true });
64
+ await fs.writeFile(
65
+ path.join(sourceDataDir, "managed", "secrets.json"),
66
+ `${JSON.stringify(sourceSecrets, null, 2)}\n`,
67
+ { mode: 0o600 },
68
+ );
69
+
70
+ const exported = await exportSecrets(sourceDataDir, bundlePath, passphrase);
71
+ assert.equal(exported.count, 2);
72
+ const bundleText = await fs.readFile(bundlePath, "utf8");
73
+ assert.doesNotMatch(bundleText, /plain-value-must-not-appear-in-bundle/);
74
+ assert.doesNotMatch(bundleText, /another-private-value/);
75
+ assert.match(bundleText, /remoteagent-secret-bundle/);
76
+ assert.match(bundleText, /"compression": "gzip"/);
77
+
78
+ const selectedExport = await exportSecrets(sourceDataDir, selectedBundlePath, passphrase, {
79
+ includeKeys: ["API_TOKEN"],
80
+ });
81
+ assert.equal(selectedExport.count, 1);
82
+ const selectedDataDir = path.join(root, "selected-target");
83
+ await importSecrets(selectedDataDir, selectedBundlePath, passphrase);
84
+ const selectedSecrets = JSON.parse(await fs.readFile(path.join(selectedDataDir, "managed", "secrets.json"), "utf8"));
85
+ assert.deepEqual(Object.keys(selectedSecrets), ["API_TOKEN"]);
86
+
87
+ await fs.mkdir(path.join(targetDataDir, "managed"), { recursive: true });
88
+ await fs.writeFile(path.join(targetDataDir, "managed", "secrets.json"), JSON.stringify({
89
+ API_TOKEN: {
90
+ key: "API_TOKEN",
91
+ value: "old-value",
92
+ createdAt: "2025-01-01T00:00:00.000Z",
93
+ updatedAt: "2025-01-01T00:00:00.000Z",
94
+ },
95
+ KEEP_ME: {
96
+ key: "KEEP_ME",
97
+ value: "kept-value",
98
+ createdAt: "2025-01-01T00:00:00.000Z",
99
+ updatedAt: "2025-01-01T00:00:00.000Z",
100
+ },
101
+ }, null, 2), { mode: 0o600 });
102
+
103
+ const imported = await importSecrets(targetDataDir, bundlePath, passphrase);
104
+ assert.equal(imported.imported, 2);
105
+ assert.equal(imported.overwritten, 1);
106
+ assert.equal(imported.total, 3);
107
+ assert.ok(imported.backupPath);
108
+ const importedSecrets = JSON.parse(await fs.readFile(path.join(targetDataDir, "managed", "secrets.json"), "utf8"));
109
+ assert.equal(importedSecrets.API_TOKEN.value, sourceSecrets.API_TOKEN.value);
110
+ assert.equal(importedSecrets.DB_PASSWORD.value, sourceSecrets.DB_PASSWORD.value);
111
+ assert.equal(importedSecrets.KEEP_ME.value, "kept-value");
112
+
113
+ await assert.rejects(
114
+ importSecrets(path.join(root, "wrong-passphrase"), bundlePath, "wrong-passphrase"),
115
+ /could not be decrypted/,
116
+ );
117
+
118
+ console.log("RemoteAgent CLI self-test passed.");
119
+ } finally {
120
+ await fs.rm(root, { recursive: true, force: true });
121
+ }
@@ -11,6 +11,7 @@ const workspace = path.join(tmp, "workspace");
11
11
  const workspaceRoot = path.join(tmp, "workspaces");
12
12
  const binDir = path.join(tmp, "bin");
13
13
  const telegramCalls = path.join(tmp, "telegram-calls.jsonl");
14
+ const capturedDocument = path.join(tmp, "captured-document.ra-secrets");
14
15
 
15
16
  await fs.mkdir(workspace, { recursive: true });
16
17
  await fs.mkdir(workspaceRoot, { recursive: true });
@@ -22,6 +23,7 @@ method="unknown"
22
23
  text=""
23
24
  chat_id=""
24
25
  reply_markup=""
26
+ document_path=""
25
27
  for arg in "$@"; do
26
28
  case "$arg" in
27
29
  https://api.telegram.org/bot*/sendMessage) method="sendMessage" ;;
@@ -32,6 +34,7 @@ for arg in "$@"; do
32
34
  chat_id=*) chat_id="\${arg#chat_id=}" ;;
33
35
  text=*) text="\${arg#text=}" ;;
34
36
  reply_markup=*) reply_markup="\${arg#reply_markup=}" ;;
37
+ document=@*) document_path="\${arg#document=@}" ;;
35
38
  esac
36
39
  done
37
40
  text_b64="$(printf '%s' "$text" | base64 -w 0)"
@@ -45,6 +48,7 @@ case "$method" in
45
48
  printf '{"ok":true,"result":true}'
46
49
  ;;
47
50
  sendDocument)
51
+ cp "$document_path" ${JSON.stringify(capturedDocument)}
48
52
  printf '{"ok":true,"result":{"message_id":1002,"document":{"file_id":"fake"}}}'
49
53
  ;;
50
54
  *)
@@ -72,6 +76,7 @@ const [
72
76
  { BotManagementService },
73
77
  { FileStore },
74
78
  { AgentMemoryService },
79
+ { importSecrets },
75
80
  { WorkspaceCleanupService },
76
81
  { buildFallbackBotInfo },
77
82
  ] = await Promise.all([
@@ -80,6 +85,7 @@ const [
80
85
  import(path.join(root, "dist", "services", "bot-management-service.js")),
81
86
  import(path.join(root, "dist", "store", "file-store.js")),
82
87
  import(path.join(root, "dist", "services", "agent-memory-service.js")),
88
+ import(path.join(root, "dist", "services", "secret-transfer-service.js")),
83
89
  import(path.join(root, "dist", "services", "workspace-cleanup-service.js")),
84
90
  import(path.join(root, "dist", "telegram-bot-identity.js")),
85
91
  ]);
@@ -295,6 +301,9 @@ await send("/start codex");
295
301
  await send("/option retry 6");
296
302
  await send("/option timeout 600");
297
303
  await send("/option intent 4");
304
+ await send("/secret set REMOTEAGENT_TRANSFER_PASSPHRASE correct-horse-battery-staple");
305
+ await send("/secret set API_TOKEN telegram-secret-export-value");
306
+ await send("/secret export REMOTEAGENT_TRANSFER_PASSPHRASE API_TOKEN");
298
307
  await send("같은 값을 봐야하는데 로직문제네? 확인해줘\\n이미 수정되어 있을 수 있어.\\n나한테 수정했다고 보고했었거든");
299
308
  await send("/state");
300
309
 
@@ -318,6 +327,32 @@ if (!/^TELEGRAM_UNTAGGED_INTENT_RETRIES=4$/m.test(envText)) {
318
327
  throw new Error(`Option command did not persist untagged intent retry limit to .env: ${envText}`);
319
328
  }
320
329
 
330
+ const importedSecretDataDir = path.join(tmp, "imported-secret-data");
331
+ const importedSecretResult = await importSecrets(
332
+ importedSecretDataDir,
333
+ capturedDocument,
334
+ "correct-horse-battery-staple",
335
+ );
336
+ if (importedSecretResult.imported !== 1) {
337
+ throw new Error(`Expected one Telegram-exported Secret, got ${importedSecretResult.imported}`);
338
+ }
339
+ const importedSecretStore = JSON.parse(
340
+ await fs.readFile(path.join(importedSecretDataDir, "managed", "secrets.json"), "utf8"),
341
+ );
342
+ if (importedSecretStore.API_TOKEN?.value !== "telegram-secret-export-value") {
343
+ throw new Error("Telegram Secret export did not preserve the selected Secret value");
344
+ }
345
+ if (importedSecretStore.REMOTEAGENT_TRANSFER_PASSPHRASE) {
346
+ throw new Error("Telegram Secret export included its transfer passphrase key");
347
+ }
348
+ const secretTelegramCalls = await readTelegramCalls();
349
+ if (secretTelegramCalls.filter((call) => call.method === "deleteMessage").length < 2) {
350
+ throw new Error("Secret source messages were not deleted after storage");
351
+ }
352
+ if (!secretTelegramCalls.some((call) => call.method === "sendDocument")) {
353
+ throw new Error("Encrypted Secret bundle was not sent as a Telegram document");
354
+ }
355
+
321
356
  const sessionWorkspace = session.workspace;
322
357
  await fs.mkdir(path.join(sessionWorkspace, "node_modules", "left-pad"), { recursive: true });
323
358
  await fs.mkdir(path.join(sessionWorkspace, "src"), { recursive: true });