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.
@@ -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/docs/RELEASING.md CHANGED
@@ -220,3 +220,25 @@ npm run selftest:telegram
220
220
  npm run release:publish
221
221
  npm run release:deploy -- 0.17.1 30
222
222
  ```
223
+
224
+ ## Release 0.18.0
225
+
226
+ Date: 2026-08-03
227
+
228
+ Changes:
229
+
230
+ - Telegram list and option responses provide inline command controls.
231
+ - Queue notices provide inline remove-latest and remove-by-id controls in one message.
232
+ - Codex progress streaming accepts both normalized CLI `item.completed` events and raw `event_msg`/`response_item` agent-message events.
233
+ - The Codex stream self-test covers every supported progress event shape and keeps final results out of the progress callback.
234
+
235
+ Validated:
236
+
237
+ ```bash
238
+ npm run check
239
+ npm run build
240
+ npm run selftest:codex-stream
241
+ npm run selftest:telegram
242
+ npm run release:publish
243
+ npm run release:deploy -- 0.18.0 30
244
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appback-remoteagent",
3
- "version": "0.17.1",
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
+ }
@@ -23,7 +23,9 @@ printf '%s\\n' '{"type":"thread.started","thread_id":"stream-thread"}'
23
23
  printf '%s' '{"type":"item.completed","item":{"type":"agent_message","text":"REPORT:progress\\nphase one"}}'
24
24
  printf '\\n'
25
25
  sleep 0.05
26
- printf '%s\\n' '{"type":"item.completed","item":{"type":"agent_message","text":"REPORT:progress\\nphase two"}}'
26
+ printf '%s\\n' '{"type":"event_msg","payload":{"type":"agent_message","message":"REPORT:progress\\nphase two"}}'
27
+ sleep 0.05
28
+ printf '%s\\n' '{"type":"response_item","payload":{"type":"message","content":[{"type":"output_text","text":"REPORT:progress\\nphase three"}]}}'
27
29
  sleep 0.05
28
30
  printf '%s\\n' '{"type":"item.completed","item":{"type":"agent_message","text":"REPORT:result\\nfinished"}}'
29
31
  printf '%s\\n' 'REPORT:result' 'finished' > "$output"
@@ -50,7 +52,12 @@ const responsePromise = adapter.send({
50
52
  const response = await responsePromise;
51
53
  settled = true;
52
54
 
53
- if (progress.length !== 2 || !progress[0]?.includes("phase one") || !progress[1]?.includes("phase two")) {
55
+ if (
56
+ progress.length !== 3
57
+ || !progress[0]?.includes("phase one")
58
+ || !progress[1]?.includes("phase two")
59
+ || !progress[2]?.includes("phase three")
60
+ ) {
54
61
  throw new Error(`Unexpected streamed progress: ${JSON.stringify(progress)}`);
55
62
  }
56
63
  if (progress.some((item) => item.includes("REPORT:result"))) {
@@ -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
  ]);
@@ -254,6 +260,14 @@ async function click(data) {
254
260
  await injectedBot.handleUpdates([callbackUpdate(data)]);
255
261
  }
256
262
 
263
+ function findInlineButton(call, label) {
264
+ if (!call?.reply_markup) {
265
+ return undefined;
266
+ }
267
+ const markup = JSON.parse(call.reply_markup);
268
+ return markup.inline_keyboard?.flat().find((button) => button.text === label);
269
+ }
270
+
257
271
  async function readTelegramCalls() {
258
272
  return (await fs.readFile(telegramCalls, "utf8"))
259
273
  .trim()
@@ -287,6 +301,9 @@ await send("/start codex");
287
301
  await send("/option retry 6");
288
302
  await send("/option timeout 600");
289
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");
290
307
  await send("같은 값을 봐야하는데 로직문제네? 확인해줘\\n이미 수정되어 있을 수 있어.\\n나한테 수정했다고 보고했었거든");
291
308
  await send("/state");
292
309
 
@@ -310,6 +327,32 @@ if (!/^TELEGRAM_UNTAGGED_INTENT_RETRIES=4$/m.test(envText)) {
310
327
  throw new Error(`Option command did not persist untagged intent retry limit to .env: ${envText}`);
311
328
  }
312
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
+
313
356
  const sessionWorkspace = session.workspace;
314
357
  await fs.mkdir(path.join(sessionWorkspace, "node_modules", "left-pad"), { recursive: true });
315
358
  await fs.mkdir(path.join(sessionWorkspace, "src"), { recursive: true });
@@ -440,6 +483,83 @@ if (calls.some((call) => /미완료 TODO|\/task|새 작업으로 접수/.test(ca
440
483
  throw new Error(`Task gate language leaked to Telegram replies. Calls: ${JSON.stringify(calls, null, 2)}`);
441
484
  }
442
485
 
486
+ await send("/new");
487
+ await send("/list");
488
+ const sessionListCall = await waitForTelegramCall((call) => call.text.includes("Sessions (2/2)"));
489
+ const firstSessionButton = findInlineButton(sessionListCall, `S001 · ${path.basename(session.workspace)}`);
490
+ if (!firstSessionButton?.callback_data?.startsWith("remoteagent:action:")) {
491
+ throw new Error(`Session switch button is missing: ${sessionListCall.reply_markup}`);
492
+ }
493
+ await click(firstSessionButton.callback_data);
494
+ await waitForTelegramCall((call) => call.text.includes("Switched this chat to session S001."));
495
+
496
+ await send("/model");
497
+ const modelListCall = await waitForTelegramCall((call) => call.text.includes("availablePresets:"));
498
+ const modelButton = findInlineButton(modelListCall, "gpt-5.6-terra");
499
+ if (!modelButton?.callback_data) {
500
+ throw new Error(`Model selection button is missing: ${modelListCall.reply_markup}`);
501
+ }
502
+ await click(modelButton.callback_data);
503
+ await waitForTelegramCall((call) => call.text.includes("Set codex model to gpt-5.6-terra."));
504
+ const modelState = JSON.parse(await fs.readFile(path.join(dataDir, "state.json"), "utf8"));
505
+ if (modelState.sessions[session.sessionId]?.codex?.model !== "gpt-5.6-terra") {
506
+ throw new Error("Model button did not update the bound session model");
507
+ }
508
+
509
+ await send("/option");
510
+ const optionListCall = await waitForTelegramCall((call) => call.text.startsWith("Runtime options"));
511
+ const timeoutButton = findInlineButton(optionListCall, "Timeout");
512
+ if (!timeoutButton?.callback_data) {
513
+ throw new Error(`Runtime option button is missing: ${optionListCall.reply_markup}`);
514
+ }
515
+ await click(timeoutButton.callback_data);
516
+ await waitForTelegramCall((call) => call.text.includes("Current provider execution timeout: 600s"));
517
+
518
+ await send("/sandbox");
519
+ const sandboxListCall = await waitForTelegramCall((call) => call.text.startsWith("Codex sandbox"));
520
+ const readOnlyButton = findInlineButton(sandboxListCall, "read-only");
521
+ const dangerButton = findInlineButton(sandboxListCall, "danger-full-access");
522
+ if (!readOnlyButton?.callback_data || !dangerButton?.callback_data) {
523
+ throw new Error(`Sandbox selection buttons are missing: ${sandboxListCall.reply_markup}`);
524
+ }
525
+ await click(readOnlyButton.callback_data);
526
+ await waitForTelegramCall((call) => call.text.includes("Set Codex sandbox to read-only."));
527
+ await click(dangerButton.callback_data);
528
+ const sandboxConfirmCall = await waitForTelegramCall((call) => call.text.includes("Confirm Codex sandbox change"));
529
+ if (!findInlineButton(sandboxConfirmCall, "Confirm danger-full-access")?.callback_data) {
530
+ throw new Error(`Danger sandbox confirmation button is missing: ${sandboxConfirmCall.reply_markup}`);
531
+ }
532
+
533
+ await send("/macro set button-test inspect the callback path");
534
+ await send("/batch start");
535
+ await send("/macro");
536
+ const macroListCall = await waitForTelegramCall((call) => call.text.includes("Macros (1)"));
537
+ const macroButton = findInlineButton(macroListCall, "button-test");
538
+ if (!macroButton?.callback_data) {
539
+ throw new Error(`Macro execution button is missing: ${macroListCall.reply_markup}`);
540
+ }
541
+ await click(macroButton.callback_data);
542
+ await send("/batch send");
543
+ await waitForTelegramCall((call) => call.text.includes("mock provider completed"));
544
+
545
+ await fs.appendFile(path.join(dataDir, ".env"), [
546
+ "TELEGRAM_BOT_TOKENS=000000:test-token",
547
+ "TELEGRAM_BOT_USERNAMES=remoteagent_test_bot",
548
+ "",
549
+ ].join("\n"), "utf8");
550
+ await send("/bots");
551
+ const botsCall = await waitForTelegramCall((call) => call.text.includes("Configured bots (1)"));
552
+ const botLink = findInlineButton(botsCall, "@bot_0");
553
+ const refreshButton = findInlineButton(botsCall, "Refresh");
554
+ if (botLink?.url !== "https://t.me/bot_0" || !refreshButton?.callback_data) {
555
+ throw new Error(`Bot link or refresh button is missing: ${botsCall.reply_markup}`);
556
+ }
557
+ await click(refreshButton.callback_data);
558
+ const refreshedBotsCalls = (await readTelegramCalls()).filter((call) => call.text.includes("Configured bots (1)"));
559
+ if (refreshedBotsCalls.length < 2) {
560
+ throw new Error("Bot refresh callback did not render the bot list again");
561
+ }
562
+
443
563
  providerMode = "timeout";
444
564
  await send("/batch start");
445
565
  await send("timeout regression test");