appback-remoteagent 0.22.2 → 0.23.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 +9 -0
- package/dist/bot.js +35 -1
- package/dist/cli.js +25 -1
- package/dist/config.js +4 -5
- package/dist/services/bot-management-service.js +3 -1
- package/dist/services/cli-config-service.js +49 -1
- package/docs/CLI_BOOTSTRAP_AND_SECRET_MIGRATION.md +16 -0
- package/docs/DATABASE_DR_READONLY_STANDBY.md +411 -0
- package/docs/OPERATIONS.md +7 -1
- package/docs/RELEASING.md +7 -2
- package/package.json +1 -1
- package/scripts/cleanup-aged-backup-entries.sh +80 -0
- package/scripts/release-deploy.sh +76 -8
- package/scripts/selftest-cli.mjs +51 -0
- package/scripts/selftest-telegram-update.mjs +45 -0
package/README.md
CHANGED
|
@@ -29,6 +29,8 @@ RemoteAgent is currently organized around six core capabilities.
|
|
|
29
29
|
| Telegram attachments | Telegram can send images, text, Markdown, PDF, Word documents, spreadsheet files, archives, and audio/voice inputs into the runtime | Supported |
|
|
30
30
|
| Telegram Mini App UI | A richer Telegram-native UI can sit on top of the same runtime and session model | Planned next |
|
|
31
31
|
|
|
32
|
+
Consecutive Telegram text updates received within the message batch window are treated as one user input. When their combined text exceeds 3,900 characters, RemoteAgent stores the complete UTF-8 text under `DATA_DIR/uploads/telegram`, indexes it as an artifact, and sends the provider one instruction containing the file path. This prevents Telegram-split long inputs from starting separate provider executions. Provider responses continue to use Telegram-safe message chunking.
|
|
33
|
+
|
|
32
34
|
## Product direction
|
|
33
35
|
|
|
34
36
|
RemoteAgent is a self-hosted personal runtime, not a hosted SaaS.
|
|
@@ -327,6 +329,13 @@ remoteagent-start
|
|
|
327
329
|
|
|
328
330
|
The command validates the BotFather token and shows a one-time Telegram `/start` link. Open that link from the account that will own the installation; RemoteAgent detects the sender's numeric user ID and stores it as the owner automatically. The token is entered through a hidden prompt, and the owner ID does not need to be looked up or typed manually.
|
|
329
331
|
|
|
332
|
+
Remove a configured bot by username or numeric Bot ID, then restart the runtime:
|
|
333
|
+
|
|
334
|
+
```bash
|
|
335
|
+
remoteagent bot remove @example_bot
|
|
336
|
+
sudo systemctl restart remoteagent
|
|
337
|
+
```
|
|
338
|
+
|
|
330
339
|
To move installation-wide `/secret` values to another PC, export and import a password-encrypted bundle:
|
|
331
340
|
|
|
332
341
|
```bash
|
package/dist/bot.js
CHANGED
|
@@ -102,6 +102,7 @@ const RECOGNIZED_COMMANDS = new Set([
|
|
|
102
102
|
]);
|
|
103
103
|
const TELEGRAM_STALE_UPDATE_GRACE_SECONDS = 10;
|
|
104
104
|
const TELEGRAM_PROCESS_STARTED_AT_SECONDS = Math.floor(Date.now() / 1000);
|
|
105
|
+
const TELEGRAM_LONG_TEXT_FILE_THRESHOLD = 3900;
|
|
105
106
|
const workLoopTails = new Map();
|
|
106
107
|
const workLoopGenerations = new Map();
|
|
107
108
|
const queuedWorkLoops = new Map();
|
|
@@ -233,10 +234,26 @@ export function createBot(token, bridge, botManagement, botInfo) {
|
|
|
233
234
|
claude: config.claudeInstallCommand,
|
|
234
235
|
}, config.claudeLoginStartCommand, config.claudeLoginFinishCommand);
|
|
235
236
|
const messageBatcher = new TelegramMessageBatcher(config.telegramMessageBatchMs, async (target, botId, chatId, text) => {
|
|
237
|
+
let request = text;
|
|
238
|
+
if (text.length > TELEGRAM_LONG_TEXT_FILE_THRESHOLD) {
|
|
239
|
+
const saved = await saveLongTelegramText(botId, chatId, text);
|
|
240
|
+
const mapping = await bridge.status(botId, chatId).catch(() => undefined);
|
|
241
|
+
await memoryService.recordArtifact({
|
|
242
|
+
session: mapping?.session,
|
|
243
|
+
botId,
|
|
244
|
+
chatId,
|
|
245
|
+
kind: "text",
|
|
246
|
+
filePath: saved.path,
|
|
247
|
+
fileName: saved.fileName,
|
|
248
|
+
mimeType: "text/plain",
|
|
249
|
+
});
|
|
250
|
+
request = formatLongTelegramTextPrompt(saved.path, text.length);
|
|
251
|
+
await bridge.logSystem(botId, chatId, `Telegram long text saved as UTF-8 attachment (${text.length} chars): ${saved.path}`);
|
|
252
|
+
}
|
|
236
253
|
await bridge.logSystem(botId, chatId, `Telegram text dispatch (${text.length} chars).`);
|
|
237
254
|
await runWithPendingAnimation(target.botToken, target.telegramChatId, async (helpers) => {
|
|
238
255
|
return {
|
|
239
|
-
chunks: await routeTelegramWorkLoop(bridge, botId, chatId,
|
|
256
|
+
chunks: await routeTelegramWorkLoop(bridge, botId, chatId, request, "Telegram text request", botManagement, helpers, autoContinue, memoryService),
|
|
240
257
|
};
|
|
241
258
|
});
|
|
242
259
|
});
|
|
@@ -2980,6 +2997,23 @@ function safePathSegment(value) {
|
|
|
2980
2997
|
const safe = value.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "");
|
|
2981
2998
|
return safe || "file";
|
|
2982
2999
|
}
|
|
3000
|
+
async function saveLongTelegramText(botId, chatId, text) {
|
|
3001
|
+
const directory = path.join(config.dataDir, "uploads", "telegram", safePathSegment(botId), safePathSegment(chatId));
|
|
3002
|
+
await fs.mkdir(directory, { recursive: true });
|
|
3003
|
+
const fileName = `${Date.now()}-telegram-long-message-${randomUUID()}.txt`;
|
|
3004
|
+
const outputPath = path.join(directory, fileName);
|
|
3005
|
+
await fs.writeFile(outputPath, text, { encoding: "utf8", mode: 0o600 });
|
|
3006
|
+
return { path: outputPath, fileName };
|
|
3007
|
+
}
|
|
3008
|
+
function formatLongTelegramTextPrompt(filePath, characterCount) {
|
|
3009
|
+
return [
|
|
3010
|
+
"The user sent a long Telegram text that RemoteAgent stored as a UTF-8 text file.",
|
|
3011
|
+
`File: ${filePath}`,
|
|
3012
|
+
`Character count: ${characterCount}`,
|
|
3013
|
+
"Read the entire file directly and treat its complete contents as the user's active instruction.",
|
|
3014
|
+
"Do not process only a preview and do not ask the user to resend the split messages.",
|
|
3015
|
+
].join("\n");
|
|
3016
|
+
}
|
|
2983
3017
|
async function normalizeTelegramDelivery(chunks) {
|
|
2984
3018
|
const documents = new Map();
|
|
2985
3019
|
const normalizedChunks = await Promise.all(chunks.map(async (chunk) => {
|
package/dist/cli.js
CHANGED
|
@@ -3,7 +3,7 @@ import { randomBytes } from "node:crypto";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import process from "node:process";
|
|
6
|
-
import { fetchTelegramBotIdentity, registerTelegramBot, readConfiguredOwnerId, waitForTelegramOwner, } from "./services/cli-config-service.js";
|
|
6
|
+
import { fetchTelegramBotIdentity, registerTelegramBot, removeTelegramBot, readConfiguredOwnerId, waitForTelegramOwner, } from "./services/cli-config-service.js";
|
|
7
7
|
import { exportSecrets, importSecrets } from "./services/secret-transfer-service.js";
|
|
8
8
|
async function main() {
|
|
9
9
|
const args = process.argv.slice(2);
|
|
@@ -21,6 +21,10 @@ async function main() {
|
|
|
21
21
|
await addBot(dataDir, args.slice(2));
|
|
22
22
|
return;
|
|
23
23
|
}
|
|
24
|
+
if (group === "bot" && action === "remove") {
|
|
25
|
+
await removeBot(dataDir, args.slice(2));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
24
28
|
if (group === "secret" && action === "export") {
|
|
25
29
|
await exportSecretCommand(dataDir, args.slice(2));
|
|
26
30
|
return;
|
|
@@ -76,6 +80,25 @@ async function addBot(dataDir, args) {
|
|
|
76
80
|
" # systemd runtime: sudo systemctl restart remoteagent",
|
|
77
81
|
].join("\n"));
|
|
78
82
|
}
|
|
83
|
+
async function removeBot(dataDir, args) {
|
|
84
|
+
if (args.some((arg) => arg.startsWith("--"))) {
|
|
85
|
+
throw new Error(`Unknown bot remove option: ${args.find((arg) => arg.startsWith("--"))}`);
|
|
86
|
+
}
|
|
87
|
+
const selector = args.shift();
|
|
88
|
+
if (!selector || args.length > 0) {
|
|
89
|
+
throw new Error("Usage: remoteagent bot remove <username|id>");
|
|
90
|
+
}
|
|
91
|
+
const result = await removeTelegramBot({ dataDir, selector });
|
|
92
|
+
const label = result.username ? `@${result.username}` : `bot ${result.id}`;
|
|
93
|
+
console.log([
|
|
94
|
+
`Removed ${label} (${result.id}).`,
|
|
95
|
+
`Configured bots: ${result.botCount}`,
|
|
96
|
+
`Configuration: ${result.envPath}`,
|
|
97
|
+
"Start or restart RemoteAgent to apply it:",
|
|
98
|
+
" remoteagent-start",
|
|
99
|
+
" # systemd runtime: sudo systemctl restart remoteagent",
|
|
100
|
+
].join("\n"));
|
|
101
|
+
}
|
|
79
102
|
async function exportSecretCommand(dataDir, args) {
|
|
80
103
|
const passphraseFile = takeOption(args, "--passphrase-file");
|
|
81
104
|
if (args.some((arg) => arg.startsWith("--"))) {
|
|
@@ -197,6 +220,7 @@ Usage:
|
|
|
197
220
|
remoteagent Start the foreground runtime
|
|
198
221
|
remoteagent bot add [token] [--owner <telegram-user-id>]
|
|
199
222
|
remoteagent bot add --token-file <file> --owner <telegram-user-id>
|
|
223
|
+
remoteagent bot remove <username|id>
|
|
200
224
|
remoteagent secret export [file] [--passphrase-file <file>]
|
|
201
225
|
remoteagent secret import <file> [--replace] [--passphrase-file <file>]
|
|
202
226
|
|
package/dist/config.js
CHANGED
|
@@ -33,14 +33,13 @@ function readTelegramBotTokens() {
|
|
|
33
33
|
return [readRequired("TELEGRAM_BOT_TOKEN")];
|
|
34
34
|
}
|
|
35
35
|
function readTelegramBotUsernames() {
|
|
36
|
-
const raw = process.env.TELEGRAM_BOT_USERNAMES
|
|
37
|
-
if (
|
|
36
|
+
const raw = process.env.TELEGRAM_BOT_USERNAMES;
|
|
37
|
+
if (raw === undefined || raw === "") {
|
|
38
38
|
return [];
|
|
39
39
|
}
|
|
40
40
|
return raw
|
|
41
|
-
.split(
|
|
42
|
-
.map((value) => value.trim())
|
|
43
|
-
.filter(Boolean);
|
|
41
|
+
.split(",")
|
|
42
|
+
.map((value) => value.trim());
|
|
44
43
|
}
|
|
45
44
|
function readOptional(name) {
|
|
46
45
|
const value = process.env[name]?.trim();
|
|
@@ -437,7 +437,9 @@ export class BotManagementService {
|
|
|
437
437
|
: singleTokenLine
|
|
438
438
|
? [singleTokenLine.slice("TELEGRAM_BOT_TOKEN=".length).trim()].filter(Boolean)
|
|
439
439
|
: [];
|
|
440
|
-
const configuredUsernames = usernameLine
|
|
440
|
+
const configuredUsernames = usernameLine
|
|
441
|
+
? usernameLine.slice("TELEGRAM_BOT_USERNAMES=".length).split(",").map((value) => value.trim())
|
|
442
|
+
: [];
|
|
441
443
|
const usernames = await this.normalizeUsernamesFromTelegram(tokens, configuredUsernames);
|
|
442
444
|
return {
|
|
443
445
|
lines,
|
|
@@ -18,7 +18,7 @@ export async function registerTelegramBot(options) {
|
|
|
18
18
|
});
|
|
19
19
|
const values = parseEnv(original);
|
|
20
20
|
const configuredTokens = parseCsv(values.get("TELEGRAM_BOT_TOKENS") || values.get("TELEGRAM_BOT_TOKEN") || "");
|
|
21
|
-
const configuredUsernames =
|
|
21
|
+
const configuredUsernames = parseCsvSlots(values.get("TELEGRAM_BOT_USERNAMES") || "");
|
|
22
22
|
const validIndexes = configuredTokens
|
|
23
23
|
.map((configuredToken, index) => isBotToken(configuredToken) ? index : -1)
|
|
24
24
|
.filter((index) => index >= 0);
|
|
@@ -54,6 +54,51 @@ export async function registerTelegramBot(options) {
|
|
|
54
54
|
botCount: tokens.length,
|
|
55
55
|
};
|
|
56
56
|
}
|
|
57
|
+
export async function removeTelegramBot(options) {
|
|
58
|
+
const selector = options.selector.trim().replace(/^@/, "").toLowerCase();
|
|
59
|
+
if (!selector) {
|
|
60
|
+
throw new Error("Usage: remoteagent bot remove <username|id>");
|
|
61
|
+
}
|
|
62
|
+
const envPath = path.join(options.dataDir, ".env");
|
|
63
|
+
const original = await fs.readFile(envPath, "utf8").catch((error) => {
|
|
64
|
+
if (error.code === "ENOENT") {
|
|
65
|
+
throw new Error(`RemoteAgent configuration was not found: ${envPath}`);
|
|
66
|
+
}
|
|
67
|
+
throw error;
|
|
68
|
+
});
|
|
69
|
+
const values = parseEnv(original);
|
|
70
|
+
const configuredTokens = parseCsv(values.get("TELEGRAM_BOT_TOKENS") || values.get("TELEGRAM_BOT_TOKEN") || "");
|
|
71
|
+
const configuredUsernames = parseCsvSlots(values.get("TELEGRAM_BOT_USERNAMES") || "");
|
|
72
|
+
const validIndexes = configuredTokens
|
|
73
|
+
.map((token, index) => isBotToken(token) ? index : -1)
|
|
74
|
+
.filter((index) => index >= 0);
|
|
75
|
+
const tokens = validIndexes.map((index) => configuredTokens[index]);
|
|
76
|
+
const usernames = validIndexes.map((index) => configuredUsernames[index] || "");
|
|
77
|
+
const targetIndex = tokens.findIndex((token, index) => token.slice(0, token.indexOf(":")) === selector
|
|
78
|
+
|| usernames[index]?.toLowerCase() === selector);
|
|
79
|
+
if (targetIndex < 0) {
|
|
80
|
+
throw new Error(`Telegram bot was not found: ${options.selector.trim()}`);
|
|
81
|
+
}
|
|
82
|
+
if (tokens.length <= 1) {
|
|
83
|
+
throw new Error("Cannot remove the last configured bot.");
|
|
84
|
+
}
|
|
85
|
+
const removedToken = tokens[targetIndex];
|
|
86
|
+
const removedUsername = usernames[targetIndex] || undefined;
|
|
87
|
+
const remainingTokens = tokens.filter((_, index) => index !== targetIndex);
|
|
88
|
+
const remainingUsernames = usernames.filter((_, index) => index !== targetIndex);
|
|
89
|
+
const next = upsertEnv(original, {
|
|
90
|
+
TELEGRAM_BOT_TOKEN: remainingTokens[0],
|
|
91
|
+
TELEGRAM_BOT_TOKENS: remainingTokens.join(","),
|
|
92
|
+
TELEGRAM_BOT_USERNAMES: remainingUsernames.join(","),
|
|
93
|
+
});
|
|
94
|
+
await atomicWrite(envPath, next, 0o600);
|
|
95
|
+
return {
|
|
96
|
+
id: Number(removedToken.slice(0, removedToken.indexOf(":"))),
|
|
97
|
+
username: removedUsername,
|
|
98
|
+
envPath,
|
|
99
|
+
botCount: remainingTokens.length,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
57
102
|
export async function readConfiguredOwnerId(dataDir) {
|
|
58
103
|
const envPath = path.join(dataDir, ".env");
|
|
59
104
|
const text = await fs.readFile(envPath, "utf8").catch((error) => {
|
|
@@ -200,6 +245,9 @@ function parseCsv(value) {
|
|
|
200
245
|
.map((item) => item.trim())
|
|
201
246
|
.filter(Boolean);
|
|
202
247
|
}
|
|
248
|
+
function parseCsvSlots(value) {
|
|
249
|
+
return value.split(",").map((item) => item.trim());
|
|
250
|
+
}
|
|
203
251
|
function parseEnv(text) {
|
|
204
252
|
const result = new Map();
|
|
205
253
|
for (const line of text.split(/\r?\n/)) {
|
|
@@ -22,6 +22,22 @@ chmod 600 /secure/path/telegram-token
|
|
|
22
22
|
remoteagent bot add --token-file /secure/path/telegram-token --owner 123456789
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
Remove a configured bot by username or numeric Bot ID, then restart the runtime:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
remoteagent bot remove @example_bot
|
|
29
|
+
sudo systemctl restart remoteagent
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
remoteagent bot remove 123456789
|
|
34
|
+
sudo systemctl restart remoteagent
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The command updates `TELEGRAM_BOT_TOKEN`, `TELEGRAM_BOT_TOKENS`, and
|
|
38
|
+
`TELEGRAM_BOT_USERNAMES` together. It does not print bot tokens and refuses to
|
|
39
|
+
remove the final configured bot.
|
|
40
|
+
|
|
25
41
|
After adding or updating a bot, apply the configuration with the runtime command appropriate to the installation:
|
|
26
42
|
|
|
27
43
|
```bash
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
# Database Backup and Read-Only Standby Plan
|
|
2
|
+
|
|
3
|
+
Last verified: 2026-08-27 (Asia/Seoul)
|
|
4
|
+
|
|
5
|
+
## Goal
|
|
6
|
+
|
|
7
|
+
- `.110` and `.111` remain the only writable database servers.
|
|
8
|
+
- `.40` keeps off-host backups and read-only PostgreSQL standby instances.
|
|
9
|
+
- During a primary outage, applications may read from `.40`, but writes fail closed.
|
|
10
|
+
- `.40` is never promoted. After the primary returns, replication resumes in the
|
|
11
|
+
original direction, so reverse synchronization is not required.
|
|
12
|
+
|
|
13
|
+
This is a read-only disaster-recovery design. It is not automatic multi-primary
|
|
14
|
+
HA.
|
|
15
|
+
|
|
16
|
+
## Verified Primary Layout
|
|
17
|
+
|
|
18
|
+
| Primary | Database | Container | PostgreSQL | Backup stanza |
|
|
19
|
+
|---|---|---|---|---|
|
|
20
|
+
| `192.168.33.110` | Damoa | `damoa-db` | 15.19 | `damoa` |
|
|
21
|
+
| `192.168.33.111` | Hub | `hub-db` | 15.19 | `hub111` |
|
|
22
|
+
| `192.168.33.111` | Title Clash | `tc-db` | 15.19 | `tc111` |
|
|
23
|
+
| `192.168.33.111` | Predict Clash | `pc-db` | 15.19 | `pc111` |
|
|
24
|
+
| `192.168.33.111` | Claw Clash | `cc-db` | 15.19 | `cc111` |
|
|
25
|
+
|
|
26
|
+
All five primaries were verified with:
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
wal_level=replica
|
|
30
|
+
max_wal_senders=10
|
|
31
|
+
max_replication_slots=10
|
|
32
|
+
hot_standby=on
|
|
33
|
+
archive_mode=on
|
|
34
|
+
archive_command=pgbackrest ... archive-push
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Current Backup Path
|
|
38
|
+
|
|
39
|
+
All stanzas use pgBackRest over SFTP and write to:
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
appback@192.168.33.40:/home/appback/backup/pgbackrest/repo
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Schedules:
|
|
46
|
+
|
|
47
|
+
| Primary | Full | Differential | Continuous WAL |
|
|
48
|
+
|---|---|---|---|
|
|
49
|
+
| `.110` Damoa | Sunday 18:00 UTC | Mon-Sat 18:00 UTC | yes |
|
|
50
|
+
| `.111` four DBs | Sunday 17:00 UTC | Mon-Sat 17:00 UTC | yes |
|
|
51
|
+
|
|
52
|
+
On 2026-08-27, all five stanzas had a successful 2026-08-23 full backup,
|
|
53
|
+
successful differential backups through 2026-08-26, and WAL files arriving on
|
|
54
|
+
2026-08-27. The repository contained only these active stanzas:
|
|
55
|
+
|
|
56
|
+
```text
|
|
57
|
+
damoa
|
|
58
|
+
hub111
|
|
59
|
+
tc111
|
|
60
|
+
pc111
|
|
61
|
+
cc111
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Old `.110` stanzas named `hub`, `tc`, `pc`, and `cc` were removed after the
|
|
65
|
+
services moved to `.111`.
|
|
66
|
+
|
|
67
|
+
## Server `.40` DR Conversion: 2026-08-27
|
|
68
|
+
|
|
69
|
+
The development workload previously sharing `.40` was moved before adding
|
|
70
|
+
standby databases. This keeps backup and recovery capacity isolated from test
|
|
71
|
+
builds and duplicate agent runtimes.
|
|
72
|
+
|
|
73
|
+
### Workloads moved to `.50`
|
|
74
|
+
|
|
75
|
+
- Damoa test deployment:
|
|
76
|
+
`/home/appback/deploy/damoa-test` on `.40` to
|
|
77
|
+
`/opt/appback/dev/damoa-test` on `.50`
|
|
78
|
+
- Damoa test ingress:
|
|
79
|
+
`dev.appback.app` is terminated by cloudflared on `.30` and now forwards to
|
|
80
|
+
`http://192.168.33.50:3213`
|
|
81
|
+
- Ten duplicate `appback-ai-agent` PM2 processes remain active on `.50`; their
|
|
82
|
+
`.40` copies were stopped
|
|
83
|
+
|
|
84
|
+
The `.50` firewall permits the Damoa test port only from `.30`. The public
|
|
85
|
+
endpoint and the internal `.30 -> .50` endpoint both returned HTTP 200 after
|
|
86
|
+
cutover.
|
|
87
|
+
|
|
88
|
+
### Cutover verification
|
|
89
|
+
|
|
90
|
+
The final source snapshot is retained at:
|
|
91
|
+
|
|
92
|
+
```text
|
|
93
|
+
/home/appback/backup/migrations/damoa-test-cutover-20260827T075116Z
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The copied snapshot is retained on `.50` at:
|
|
97
|
+
|
|
98
|
+
```text
|
|
99
|
+
/opt/appback/backups/migrations/damoa-test-cutover-20260827T075116Z
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The source and destination SHA-256 values matched for the database dump,
|
|
103
|
+
object-store archive, and source environment snapshot. After restore, all 83
|
|
104
|
+
public PostgreSQL table row counts produced an identical aggregate hash. All
|
|
105
|
+
non-MinIO-internal user objects also produced an identical file hash list.
|
|
106
|
+
|
|
107
|
+
The Damoa test edge uses Docker DNS re-resolution for both `damoa-api` and
|
|
108
|
+
`tc-minio`. Nginx configuration validation, API readiness, edge health, and the
|
|
109
|
+
public `dev.appback.app` response were verified after the final restore.
|
|
110
|
+
`route_snapshot_unavailable` refresh warnings appeared in both the old `.40`
|
|
111
|
+
API logs and the `.50` API logs, so they were not introduced by the migration.
|
|
112
|
+
|
|
113
|
+
### `.40` retained rollback state
|
|
114
|
+
|
|
115
|
+
The old Damoa test containers, volumes, deployment directory, and immutable
|
|
116
|
+
images remain on `.40`, but every `damoa-test-*` container is stopped. They are
|
|
117
|
+
rollback material and must not be started while `dev.appback.app` points to
|
|
118
|
+
`.50`.
|
|
119
|
+
|
|
120
|
+
Before stopping the duplicate PM2 agents, `.40` retained its previous PM2 dump
|
|
121
|
+
and crontab under:
|
|
122
|
+
|
|
123
|
+
```text
|
|
124
|
+
/home/appback/backup/dr-conversion-20260827T075410Z
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The `.40` PM2 reboot entry was removed and its daemon was stopped. Backup
|
|
128
|
+
retention cron jobs and the RemoteAgent service remain active. No deployment
|
|
129
|
+
directory, Docker volume, image, external-disk data, pgBackRest data, or
|
|
130
|
+
RemoteAgent workspace was deleted during this conversion.
|
|
131
|
+
|
|
132
|
+
Rollback order:
|
|
133
|
+
|
|
134
|
+
1. Stop the Damoa test stack on `.50`.
|
|
135
|
+
2. Restore the saved `.40` crontab only if the duplicate PM2 agents must also
|
|
136
|
+
return.
|
|
137
|
+
3. Start the `.40` Damoa test stack and verify its internal readiness.
|
|
138
|
+
4. Change the `.30` cloudflared route back to `192.168.33.40:3213`, validate the
|
|
139
|
+
configuration, restart cloudflared, and verify the public endpoint.
|
|
140
|
+
|
|
141
|
+
Do not run `.40` and `.50` as simultaneous writable copies of the Damoa test
|
|
142
|
+
database or object store.
|
|
143
|
+
|
|
144
|
+
## Accumulation Audit: 2026-08-27
|
|
145
|
+
|
|
146
|
+
The repository did not contain unknown or orphan pgBackRest stanzas. The active
|
|
147
|
+
repository directories were limited to `damoa`, `hub111`, `tc111`, `pc111`, and
|
|
148
|
+
`cc111`. The non-database backup sets were also small:
|
|
149
|
+
|
|
150
|
+
```text
|
|
151
|
+
appback-minio current + history: about 1.4 GB
|
|
152
|
+
damoa-media current + history: about 3.9 GB
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The following unresolved accumulation risks were found.
|
|
156
|
+
|
|
157
|
+
### Damoa WAL growth
|
|
158
|
+
|
|
159
|
+
The `damoa` archive occupied about 187 GB and was growing by approximately
|
|
160
|
+
37-60 GB per day. PostgreSQL reported about 909 GB of WAL generated since the
|
|
161
|
+
statistics reset on 2026-08-24. This was real WAL, not duplicate archive files.
|
|
162
|
+
|
|
163
|
+
The write workload repeatedly updates or replaces large portions of several
|
|
164
|
+
catalog tables. PostgreSQL was also configured with `max_wal_size=1GB`,
|
|
165
|
+
`checkpoint_timeout=5min`, `wal_compression=off`, and had 1,746 requested
|
|
166
|
+
checkpoints during the sampled period. Full-page images therefore account for
|
|
167
|
+
a significant part of the WAL volume.
|
|
168
|
+
|
|
169
|
+
The `.110` data directory also retained about 32.6 GB in `pg_wal` because
|
|
170
|
+
`wal_keep_size=32GB`, even though no replication slot or live standby existed.
|
|
171
|
+
|
|
172
|
+
### Stale bind-mounted pgBackRest configuration
|
|
173
|
+
|
|
174
|
+
The host path `/opt/appback/pgbackrest/config/pgbackrest.conf` had already been
|
|
175
|
+
replaced with the Damoa-only retention policy, but `damoa-db` still had the old
|
|
176
|
+
unlinked inode bind-mounted. The running container therefore continued to use:
|
|
177
|
+
|
|
178
|
+
```text
|
|
179
|
+
repo1-retention-full=4
|
|
180
|
+
repo1-retention-diff=14
|
|
181
|
+
archive-async=y
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
instead of the host file's intended `full=1`, `diff=6`, explicit archive
|
|
185
|
+
retention, and Damoa-only stanza. Replacing a bind-mounted file atomically does
|
|
186
|
+
not update the inode already mounted into a running container. The database
|
|
187
|
+
container must be recreated or the mounted inode must otherwise be updated and
|
|
188
|
+
verified before relying on the new policy.
|
|
189
|
+
|
|
190
|
+
At the observed WAL rate, the `.40` internal disk's approximately 119 GB free
|
|
191
|
+
space may be exhausted before the next weekly full backup. A successful new
|
|
192
|
+
full backup will not expire the old chain while the running container still
|
|
193
|
+
uses retention count 4.
|
|
194
|
+
|
|
195
|
+
### Backups without bounded retention
|
|
196
|
+
|
|
197
|
+
- `.40` `appback-minio/history` had seven daily change sets but no explicit
|
|
198
|
+
age/count cleanup in the backup script or user cron.
|
|
199
|
+
- `.111` retained about 18 GB of Damoa pre-migration dumps even though Damoa now
|
|
200
|
+
runs on `.110`.
|
|
201
|
+
- `.111` retained about 3.6 GB of deployment rollback dumps and about 995 MB of
|
|
202
|
+
legacy Title Clash originals without a general retention job.
|
|
203
|
+
- `.40` retained about 3.2 GB under `usb-enclosure-safety-copy`. Keep it until
|
|
204
|
+
the old 4 TB MinIO disk is mounted read-only and verified, then reassess it.
|
|
205
|
+
- `.40` RemoteAgent workspaces consumed about 21 GB. The two large workspaces
|
|
206
|
+
were still referenced by sessions, so they were not orphans and must not be
|
|
207
|
+
removed automatically.
|
|
208
|
+
|
|
209
|
+
### Required correction order
|
|
210
|
+
|
|
211
|
+
1. Make the running `damoa-db` consume the current pgBackRest configuration and
|
|
212
|
+
verify the effective settings from inside the container.
|
|
213
|
+
2. Run and verify a new Damoa full backup, then confirm expiration reclaimed the
|
|
214
|
+
previous backup chain and its WAL.
|
|
215
|
+
3. Add disk thresholds and projected-days-to-full monitoring for the `.40`
|
|
216
|
+
repository.
|
|
217
|
+
4. Reduce Damoa WAL at the source by reviewing the catalog synchronization
|
|
218
|
+
write pattern and PostgreSQL checkpoint/WAL settings.
|
|
219
|
+
5. Reduce `wal_keep_size` while no streaming standby exists; select a new value
|
|
220
|
+
as part of standby deployment rather than retaining an unused 32 GB.
|
|
221
|
+
6. Add explicit retention to MinIO history and deployment rollback dumps.
|
|
222
|
+
7. Remove `.111` Damoa migration dumps only after the `.110` restore path is
|
|
223
|
+
independently verified.
|
|
224
|
+
|
|
225
|
+
## Corrections Applied: 2026-08-27
|
|
226
|
+
|
|
227
|
+
The accumulation incident was corrected in the following order.
|
|
228
|
+
|
|
229
|
+
1. Recreated only `damoa-db` so its bind-mounted pgBackRest configuration uses
|
|
230
|
+
the current host file. The effective container configuration is now strict
|
|
231
|
+
SFTP host-key verification with SHA-256, `full=1`, `diff=6`, `archive=1`,
|
|
232
|
+
and synchronous archive submission.
|
|
233
|
+
2. Added all verified `.40` SSH host keys to the pinned `known_hosts` file and
|
|
234
|
+
proved a strict pgBackRest repository connection before running a backup.
|
|
235
|
+
3. Created and verified full backup `20260827-043735F`. Expiration removed the
|
|
236
|
+
superseded Damoa backup chain and its WAL. The `.40` pgBackRest repository
|
|
237
|
+
fell from about 229 GB to 18 GB, and root filesystem use fell from 74% to
|
|
238
|
+
26%.
|
|
239
|
+
4. Applied these reloadable Damoa PostgreSQL settings:
|
|
240
|
+
|
|
241
|
+
```text
|
|
242
|
+
wal_keep_size=1GB
|
|
243
|
+
wal_compression=pglz
|
|
244
|
+
max_wal_size=8GB
|
|
245
|
+
checkpoint_timeout=15min
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
A PostgreSQL checkpoint then reduced `.110` `pg_wal` from about 32.6 GB to
|
|
249
|
+
about 2 GB. No WAL file was deleted manually. The `.110` root filesystem is
|
|
250
|
+
now 13% used.
|
|
251
|
+
5. Repaired the isolated Damoa restore verifier and completed an actual
|
|
252
|
+
restore, WAL replay, read-only query, and shutdown test for the new full
|
|
253
|
+
backup. The verified database system ID was `7666642961956692002`.
|
|
254
|
+
6. Removed 18 GB of obsolete pre-migration Damoa dumps from `.111` after the
|
|
255
|
+
restore test passed. Also removed about 1.5 GB of deployment rollback
|
|
256
|
+
entries older than 35 days. The `.111` root filesystem fell from 64% to 57%
|
|
257
|
+
used.
|
|
258
|
+
7. Installed bounded 35-day cleanup jobs for `.111` deployment rollback
|
|
259
|
+
entries and `.40` Damoa media and Appback MinIO history. The shared cleanup
|
|
260
|
+
command is dry-run by default, only considers immediate children of an
|
|
261
|
+
explicitly supplied root, and requires `--apply` before deletion.
|
|
262
|
+
|
|
263
|
+
Continuous Damoa WAL archiving was observed advancing after the backup and
|
|
264
|
+
expiration. The active pgBackRest stanza set remains exactly `damoa`, `hub111`,
|
|
265
|
+
`tc111`, `pc111`, and `cc111`.
|
|
266
|
+
|
|
267
|
+
The database tuning mitigates storage growth but does not remove its source.
|
|
268
|
+
Damoa catalog synchronization still performs unusually high update/replace
|
|
269
|
+
volume across campaign route, media, coordinate, and source tables. That
|
|
270
|
+
application write amplification requires a separate code and query review.
|
|
271
|
+
|
|
272
|
+
The following data was intentionally retained:
|
|
273
|
+
|
|
274
|
+
- `.111` legacy Title Clash originals, about 995 MB, until ownership and
|
|
275
|
+
duplication are independently verified.
|
|
276
|
+
- `.40` `usb-enclosure-safety-copy`, about 3.2 GB, until the preserved 4 TB
|
|
277
|
+
MinIO disk is mounted read-only and compared.
|
|
278
|
+
- `.40` RemoteAgent workspaces referenced by active session state. They are not
|
|
279
|
+
orphan workspaces and must not be deleted by a backup cleanup job.
|
|
280
|
+
|
|
281
|
+
## Important Limitation
|
|
282
|
+
|
|
283
|
+
The pgBackRest repository is recovery material, not a queryable standby. A
|
|
284
|
+
PostgreSQL process cannot serve reads directly from the repository. Read-only
|
|
285
|
+
outage service requires five restored PostgreSQL instances on `.40` that keep
|
|
286
|
+
replaying WAL.
|
|
287
|
+
|
|
288
|
+
## Target Layout on `.40`
|
|
289
|
+
|
|
290
|
+
Use one PostgreSQL 15 standby per source database. Assign separate ports and
|
|
291
|
+
data directories. Keep the application-facing endpoints separate:
|
|
292
|
+
|
|
293
|
+
```text
|
|
294
|
+
write endpoint -> primary only (.110 or .111)
|
|
295
|
+
read endpoint -> primary normally, .40 standby during an outage
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Standby requirements:
|
|
299
|
+
|
|
300
|
+
- `hot_standby=on`
|
|
301
|
+
- recovery remains active
|
|
302
|
+
- no promotion trigger and no automatic failover manager
|
|
303
|
+
- application credentials on `.40` receive read-only privileges
|
|
304
|
+
- network rules allow application reads but block unintended administrative
|
|
305
|
+
writes
|
|
306
|
+
- monitoring checks replay delay, receive/replay LSN, last replay time, disk
|
|
307
|
+
space, and restore errors
|
|
308
|
+
|
|
309
|
+
Recommended replication method:
|
|
310
|
+
|
|
311
|
+
1. Bootstrap each standby from its pgBackRest backup.
|
|
312
|
+
2. Use asynchronous physical streaming replication from the primary.
|
|
313
|
+
3. Keep pgBackRest WAL restore configured as a gap-recovery fallback.
|
|
314
|
+
4. If streaming is interrupted, continue replaying archived WAL when available.
|
|
315
|
+
|
|
316
|
+
## Dual-Bay Allocation
|
|
317
|
+
|
|
318
|
+
The dual-bay enclosure currently attached to `.40` contains:
|
|
319
|
+
|
|
320
|
+
| Device | Size | Label | Current state |
|
|
321
|
+
|---|---:|---|---|
|
|
322
|
+
| Toshiba | 4 TB | `MINIO4T` | unmounted, preserves the previous MinIO data |
|
|
323
|
+
| WDC | 2 TB | `STORAGE2T` | unmounted |
|
|
324
|
+
|
|
325
|
+
Recommended allocation after data validation:
|
|
326
|
+
|
|
327
|
+
- 2 TB: PostgreSQL standby data directories for all five databases.
|
|
328
|
+
- 4 TB: pgBackRest repository and MinIO read-only replica data.
|
|
329
|
+
|
|
330
|
+
The five PostgreSQL datasets currently total well below 200 GB, so the 2 TB
|
|
331
|
+
disk has ample capacity. The two disks share one USB bridge and power source;
|
|
332
|
+
they are not independent backup copies. The writable primaries on `.110` and
|
|
333
|
+
`.111` remain the independent source copies.
|
|
334
|
+
|
|
335
|
+
Do not reformat or repurpose the 4 TB disk until its old MinIO data has been
|
|
336
|
+
mounted read-only, inventoried, and compared with the active `.110` MinIO.
|
|
337
|
+
|
|
338
|
+
## Implementation Order
|
|
339
|
+
|
|
340
|
+
1. Mount the 4 TB disk read-only on `.40` and verify the preserved MinIO data.
|
|
341
|
+
2. Mount and endurance-test the 2 TB disk, then create standby data paths.
|
|
342
|
+
3. Move the pgBackRest repository to the 4 TB disk with a verified maintenance
|
|
343
|
+
window, preserving the existing repository path with a bind mount.
|
|
344
|
+
4. Bootstrap one low-risk standby first, recommended `pc111`.
|
|
345
|
+
5. Verify read-only SQL, WAL replay, reconnect, restart, and primary recovery.
|
|
346
|
+
6. Repeat for `tc111`, `hub111`, `cc111`, then `damoa`.
|
|
347
|
+
7. Add separate read endpoints and prove that writes to `.40` fail.
|
|
348
|
+
8. Test a primary outage without promoting `.40`, then restore the primary and
|
|
349
|
+
verify replication resumes.
|
|
350
|
+
9. Add monitoring and a periodic restore/read test. A backup is not considered
|
|
351
|
+
verified solely because archive files exist.
|
|
352
|
+
|
|
353
|
+
## Service Continuity Boundary
|
|
354
|
+
|
|
355
|
+
A read-only database does not keep an application available if the application
|
|
356
|
+
server itself is down. In particular, a complete `.110` outage also removes the
|
|
357
|
+
Damoa API unless a read-only application instance exists on another host. The
|
|
358
|
+
same rule applies to services hosted on `.111`.
|
|
359
|
+
|
|
360
|
+
MinIO follows the same policy independently:
|
|
361
|
+
|
|
362
|
+
- writes go only to `.110`
|
|
363
|
+
- `.110` replicates one way to an independent `.40` MinIO
|
|
364
|
+
- `.40` uses read-only application credentials
|
|
365
|
+
- `.40` is not written to during a `.110` outage
|
|
366
|
+
|
|
367
|
+
## Container DNS Continuity
|
|
368
|
+
|
|
369
|
+
Recreating MinIO may assign it a different Docker network address. A healthy
|
|
370
|
+
MinIO container and a healthy edge container do not prove that the edge is
|
|
371
|
+
using the current address: an Nginx worker can retain the address resolved when
|
|
372
|
+
it started and continue returning 502 for uncached objects.
|
|
373
|
+
|
|
374
|
+
The `.110` edge configuration therefore uses Docker DNS `127.0.0.11` with
|
|
375
|
+
bounded re-resolution for both application upstreams:
|
|
376
|
+
|
|
377
|
+
```nginx
|
|
378
|
+
resolver 127.0.0.11 valid=10s ipv6=off;
|
|
379
|
+
|
|
380
|
+
upstream damoa_api_upstream {
|
|
381
|
+
zone damoa_api_upstream 64k;
|
|
382
|
+
server damoa-api:3100 resolve;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
upstream damoa_media_upstream {
|
|
386
|
+
zone damoa_media_upstream 64k;
|
|
387
|
+
server appback-minio:9000 resolve;
|
|
388
|
+
}
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
`/home/appback/deploy/damoa/media-edge.conf` is mounted read-only at
|
|
392
|
+
`/etc/nginx/conf.d/default.conf`. After recreating either MinIO or the Damoa
|
|
393
|
+
API, validation must request at least one known uncached media object through
|
|
394
|
+
the edge and confirm a 200 response. Container health checks alone are not an
|
|
395
|
+
acceptable continuity test.
|
|
396
|
+
|
|
397
|
+
## Validation Evidence Required
|
|
398
|
+
|
|
399
|
+
For every standby:
|
|
400
|
+
|
|
401
|
+
```text
|
|
402
|
+
pg_is_in_recovery() = true
|
|
403
|
+
transaction_read_only = on for application access
|
|
404
|
+
receive/replay LSN is advancing
|
|
405
|
+
replay delay is within the accepted limit
|
|
406
|
+
write test fails
|
|
407
|
+
read query succeeds
|
|
408
|
+
restart preserves recovery mode
|
|
409
|
+
primary outage read test succeeds
|
|
410
|
+
primary recovery resumes replication without reverse sync
|
|
411
|
+
```
|
package/docs/OPERATIONS.md
CHANGED
|
@@ -66,7 +66,7 @@ Current policy:
|
|
|
66
66
|
|
|
67
67
|
## Runtime model
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
Servers 30 and 40 run RemoteAgent as a `systemd` service.
|
|
70
70
|
|
|
71
71
|
- unit: `remoteagent.service`
|
|
72
72
|
- working directory: the installed `appback-remoteagent` package root
|
|
@@ -76,6 +76,12 @@ Server 30 runs RemoteAgent as a `systemd` service.
|
|
|
76
76
|
The service environment is loaded from:
|
|
77
77
|
|
|
78
78
|
- `/home/au2223/.remoteagent/.env`
|
|
79
|
+
- `/home/appback/.remoteagent/.env` on server 40
|
|
80
|
+
|
|
81
|
+
Server 40 uses the global npm package under Node.js `v22.23.2`. Its unit has
|
|
82
|
+
`Restart=always`, so a transient disk or process failure is recovered without
|
|
83
|
+
waiting for a manual Telegram health check. Deployment restarts this unit with
|
|
84
|
+
the `SUDO_APPBACK_33_40` RemoteAgent secret and never prints the secret value.
|
|
79
85
|
|
|
80
86
|
## Single-instance rule
|
|
81
87
|
|
package/docs/RELEASING.md
CHANGED
|
@@ -114,8 +114,10 @@ npm run release:deploy -- 0.15.5 all
|
|
|
114
114
|
The deploy script performs:
|
|
115
115
|
|
|
116
116
|
- npm registry version check for `appback-remoteagent@<version>`
|
|
117
|
+
- bounded retry when a target server's npm registry edge has not received the new version yet
|
|
118
|
+
- fail-fast validation for a broken or non-directory `~/.npm` cache path before remote installation
|
|
117
119
|
- server 30 npm install, install hook, systemd restart, version/log verification
|
|
118
|
-
- server 40 npm install, install hook,
|
|
120
|
+
- server 40 npm install, install hook, systemd restart using `SUDO_APPBACK_33_40`, version/log verification
|
|
119
121
|
- server 26 npm install, install hook, user-process restart, version/log verification
|
|
120
122
|
|
|
121
123
|
## 6. Verify
|
|
@@ -153,7 +155,10 @@ Server 40:
|
|
|
153
155
|
ssh appback@192.168.33.40 'bash -lc '"'"'
|
|
154
156
|
export PATH="$HOME/.local/bin:$HOME/.nvm/versions/node/v22.23.2/bin:$PATH"
|
|
155
157
|
npm list -g appback-remoteagent --depth=0
|
|
156
|
-
|
|
158
|
+
systemctl is-enabled remoteagent
|
|
159
|
+
systemctl is-active remoteagent
|
|
160
|
+
systemctl show remoteagent -p MainPID -p NRestarts
|
|
161
|
+
systemctl status remoteagent --no-pager -n 20
|
|
157
162
|
tail -80 ~/.remoteagent/logs/agent.log
|
|
158
163
|
'"'"''
|
|
159
164
|
```
|
package/package.json
CHANGED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
usage() {
|
|
5
|
+
cat <<'EOF'
|
|
6
|
+
Usage:
|
|
7
|
+
cleanup-aged-backup-entries.sh --root <absolute-directory> --days <count> [--apply]
|
|
8
|
+
|
|
9
|
+
Without --apply, matching entries are printed but not removed. Only immediate
|
|
10
|
+
children of --root are considered.
|
|
11
|
+
EOF
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
root=""
|
|
15
|
+
days=""
|
|
16
|
+
apply=false
|
|
17
|
+
|
|
18
|
+
while (($# > 0)); do
|
|
19
|
+
case "$1" in
|
|
20
|
+
--root)
|
|
21
|
+
[[ $# -ge 2 ]] || { usage >&2; exit 2; }
|
|
22
|
+
root="$2"
|
|
23
|
+
shift 2
|
|
24
|
+
;;
|
|
25
|
+
--days)
|
|
26
|
+
[[ $# -ge 2 ]] || { usage >&2; exit 2; }
|
|
27
|
+
days="$2"
|
|
28
|
+
shift 2
|
|
29
|
+
;;
|
|
30
|
+
--apply)
|
|
31
|
+
apply=true
|
|
32
|
+
shift
|
|
33
|
+
;;
|
|
34
|
+
-h|--help)
|
|
35
|
+
usage
|
|
36
|
+
exit 0
|
|
37
|
+
;;
|
|
38
|
+
*)
|
|
39
|
+
usage >&2
|
|
40
|
+
exit 2
|
|
41
|
+
;;
|
|
42
|
+
esac
|
|
43
|
+
done
|
|
44
|
+
|
|
45
|
+
[[ "$root" == /* && "$root" != "/" ]] || {
|
|
46
|
+
printf 'cleanup_refused reason=invalid_root root=%q\n' "$root" >&2
|
|
47
|
+
exit 2
|
|
48
|
+
}
|
|
49
|
+
[[ "$days" =~ ^[1-9][0-9]*$ ]] || {
|
|
50
|
+
printf 'cleanup_refused reason=invalid_days days=%q\n' "$days" >&2
|
|
51
|
+
exit 2
|
|
52
|
+
}
|
|
53
|
+
[[ -d "$root" && ! -L "$root" ]] || {
|
|
54
|
+
printf 'cleanup_refused reason=root_not_directory root=%q\n' "$root" >&2
|
|
55
|
+
exit 2
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
root="$(realpath -e -- "$root")"
|
|
59
|
+
removed=0
|
|
60
|
+
matched=0
|
|
61
|
+
|
|
62
|
+
while IFS= read -r -d '' candidate; do
|
|
63
|
+
candidate="$(realpath -m -- "$candidate")"
|
|
64
|
+
[[ "$(dirname -- "$candidate")" == "$root" && "$candidate" != "$root" ]] || {
|
|
65
|
+
printf 'cleanup_refused reason=candidate_outside_root candidate=%q\n' "$candidate" >&2
|
|
66
|
+
exit 1
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
matched=$((matched + 1))
|
|
70
|
+
if [[ "$apply" == true ]]; then
|
|
71
|
+
rm -rf --one-file-system -- "$candidate"
|
|
72
|
+
removed=$((removed + 1))
|
|
73
|
+
printf 'cleanup_removed path=%q\n' "$candidate"
|
|
74
|
+
else
|
|
75
|
+
printf 'cleanup_candidate path=%q\n' "$candidate"
|
|
76
|
+
fi
|
|
77
|
+
done < <(find "$root" -mindepth 1 -maxdepth 1 -mtime "+$days" -print0)
|
|
78
|
+
|
|
79
|
+
printf 'cleanup_complete root=%q days=%s apply=%s matched=%s removed=%s\n' \
|
|
80
|
+
"$root" "$days" "$apply" "$matched" "$removed"
|
|
@@ -39,6 +39,14 @@ deploy_30() {
|
|
|
39
39
|
ssh au2223@192.168.33.30 "VERSION=$VERSION bash -s" <<'REMOTE'
|
|
40
40
|
set -euo pipefail
|
|
41
41
|
export PATH="/home/au2223/.local/bin:/home/au2223/.nvm/versions/node/v22.22.0/bin:$PATH"
|
|
42
|
+
if [[ -L "$HOME/.npm" && ! -e "$HOME/.npm" ]]; then
|
|
43
|
+
echo "Broken npm cache symlink: $HOME/.npm -> $(readlink "$HOME/.npm")" >&2
|
|
44
|
+
exit 1
|
|
45
|
+
fi
|
|
46
|
+
if [[ -e "$HOME/.npm" && ! -d "$HOME/.npm" ]]; then
|
|
47
|
+
echo "npm cache path is not a directory: $HOME/.npm" >&2
|
|
48
|
+
exit 1
|
|
49
|
+
fi
|
|
42
50
|
node - <<'NODE'
|
|
43
51
|
const fs = require("fs");
|
|
44
52
|
const path = "/home/au2223/.remoteagent/bot-polling-state.json";
|
|
@@ -52,7 +60,17 @@ if (running.length > 0) {
|
|
|
52
60
|
process.exit(2);
|
|
53
61
|
}
|
|
54
62
|
NODE
|
|
55
|
-
|
|
63
|
+
for ATTEMPT in {1..12}; do
|
|
64
|
+
if npm install -g "appback-remoteagent@$VERSION"; then
|
|
65
|
+
break
|
|
66
|
+
fi
|
|
67
|
+
if [[ "$ATTEMPT" -eq 12 ]]; then
|
|
68
|
+
echo "Remote npm install failed after $ATTEMPT attempts." >&2
|
|
69
|
+
exit 1
|
|
70
|
+
fi
|
|
71
|
+
echo "Remote npm registry has not propagated yet; retrying in 5s ($ATTEMPT/12)."
|
|
72
|
+
sleep 5
|
|
73
|
+
done
|
|
56
74
|
remoteagent-install
|
|
57
75
|
sudo -n systemctl restart remoteagent
|
|
58
76
|
sleep 5
|
|
@@ -66,6 +84,14 @@ deploy_26() {
|
|
|
66
84
|
ssh ospadmin@192.168.33.26 "VERSION=$VERSION bash -s" <<'REMOTE'
|
|
67
85
|
set -euo pipefail
|
|
68
86
|
export PATH="$HOME/.local/bin:$PATH"
|
|
87
|
+
if [[ -L "$HOME/.npm" && ! -e "$HOME/.npm" ]]; then
|
|
88
|
+
echo "Broken npm cache symlink: $HOME/.npm -> $(readlink "$HOME/.npm")" >&2
|
|
89
|
+
exit 1
|
|
90
|
+
fi
|
|
91
|
+
if [[ -e "$HOME/.npm" && ! -d "$HOME/.npm" ]]; then
|
|
92
|
+
echo "npm cache path is not a directory: $HOME/.npm" >&2
|
|
93
|
+
exit 1
|
|
94
|
+
fi
|
|
69
95
|
node - <<'NODE'
|
|
70
96
|
const fs = require("fs");
|
|
71
97
|
const path = `${process.env.HOME}/.remoteagent/bot-polling-state.json`;
|
|
@@ -81,7 +107,17 @@ if (fs.existsSync(path)) {
|
|
|
81
107
|
}
|
|
82
108
|
}
|
|
83
109
|
NODE
|
|
84
|
-
|
|
110
|
+
for ATTEMPT in {1..12}; do
|
|
111
|
+
if npm install -g "appback-remoteagent@$VERSION"; then
|
|
112
|
+
break
|
|
113
|
+
fi
|
|
114
|
+
if [[ "$ATTEMPT" -eq 12 ]]; then
|
|
115
|
+
echo "Remote npm install failed after $ATTEMPT attempts." >&2
|
|
116
|
+
exit 1
|
|
117
|
+
fi
|
|
118
|
+
echo "Remote npm registry has not propagated yet; retrying in 5s ($ATTEMPT/12)."
|
|
119
|
+
sleep 5
|
|
120
|
+
done
|
|
85
121
|
remoteagent-install
|
|
86
122
|
~/.remoteagent/stop-remoteagent.sh || true
|
|
87
123
|
sleep 2
|
|
@@ -97,6 +133,14 @@ deploy_40() {
|
|
|
97
133
|
ssh appback@192.168.33.40 "VERSION=$VERSION bash -s" <<'REMOTE'
|
|
98
134
|
set -euo pipefail
|
|
99
135
|
export PATH="$HOME/.local/bin:$HOME/.nvm/versions/node/v22.23.2/bin:$PATH"
|
|
136
|
+
if [[ -L "$HOME/.npm" && ! -e "$HOME/.npm" ]]; then
|
|
137
|
+
echo "Broken npm cache symlink: $HOME/.npm -> $(readlink "$HOME/.npm")" >&2
|
|
138
|
+
exit 1
|
|
139
|
+
fi
|
|
140
|
+
if [[ -e "$HOME/.npm" && ! -d "$HOME/.npm" ]]; then
|
|
141
|
+
echo "npm cache path is not a directory: $HOME/.npm" >&2
|
|
142
|
+
exit 1
|
|
143
|
+
fi
|
|
100
144
|
node - <<'NODE'
|
|
101
145
|
const fs = require("fs");
|
|
102
146
|
const path = `${process.env.HOME}/.remoteagent/bot-polling-state.json`;
|
|
@@ -112,15 +156,39 @@ if (fs.existsSync(path)) {
|
|
|
112
156
|
}
|
|
113
157
|
}
|
|
114
158
|
NODE
|
|
115
|
-
|
|
159
|
+
for ATTEMPT in {1..12}; do
|
|
160
|
+
if npm install -g "appback-remoteagent@$VERSION"; then
|
|
161
|
+
break
|
|
162
|
+
fi
|
|
163
|
+
if [[ "$ATTEMPT" -eq 12 ]]; then
|
|
164
|
+
echo "Remote npm install failed after $ATTEMPT attempts." >&2
|
|
165
|
+
exit 1
|
|
166
|
+
fi
|
|
167
|
+
echo "Remote npm registry has not propagated yet; retrying in 5s ($ATTEMPT/12)."
|
|
168
|
+
sleep 5
|
|
169
|
+
done
|
|
116
170
|
remoteagent-install
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
171
|
+
if systemctl cat remoteagent >/dev/null 2>&1; then
|
|
172
|
+
HELPER="$HOME/.nvm/versions/node/v22.23.2/lib/node_modules/appback-remoteagent/dist/secret-helper.js"
|
|
173
|
+
SUDO_PASSWORD="$(node "$HELPER" get SUDO_APPBACK_33_40)"
|
|
174
|
+
printf '%s\n' "$SUDO_PASSWORD" | sudo -S -p '' systemctl restart remoteagent
|
|
175
|
+
unset SUDO_PASSWORD
|
|
176
|
+
sleep 7
|
|
177
|
+
systemctl is-active remoteagent
|
|
178
|
+
else
|
|
179
|
+
~/.remoteagent/stop-remoteagent.sh || true
|
|
180
|
+
sleep 2
|
|
181
|
+
~/.remoteagent/start-remoteagent.sh
|
|
182
|
+
sleep 5
|
|
183
|
+
fi
|
|
121
184
|
npm list -g appback-remoteagent --depth=0
|
|
122
185
|
pgrep -af 'appback-remoteagent/dist/index.js'
|
|
123
|
-
|
|
186
|
+
if systemctl cat remoteagent >/dev/null 2>&1; then
|
|
187
|
+
systemctl status remoteagent --no-pager -n 20
|
|
188
|
+
tail -80 ~/.remoteagent/logs/agent.log
|
|
189
|
+
else
|
|
190
|
+
tail -80 ~/.remoteagent/logs/agent.log
|
|
191
|
+
fi
|
|
124
192
|
REMOTE
|
|
125
193
|
}
|
|
126
194
|
|
package/scripts/selftest-cli.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import path from "node:path";
|
|
|
7
7
|
import {
|
|
8
8
|
fetchTelegramBotIdentity,
|
|
9
9
|
registerTelegramBot,
|
|
10
|
+
removeTelegramBot,
|
|
10
11
|
waitForTelegramOwner,
|
|
11
12
|
} from "../dist/services/cli-config-service.js";
|
|
12
13
|
import { buildProviderEnv, buildRuntimePath } from "../dist/adapters/runtime-env.js";
|
|
@@ -14,6 +15,7 @@ import { exportSecrets, importSecrets } from "../dist/services/secret-transfer-s
|
|
|
14
15
|
|
|
15
16
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "remoteagent-cli-selftest-"));
|
|
16
17
|
const sourceDataDir = path.join(root, "source");
|
|
18
|
+
const sparseUsernameDataDir = path.join(root, "sparse-usernames");
|
|
17
19
|
const targetDataDir = path.join(root, "target");
|
|
18
20
|
const bundlePath = path.join(root, "transfer.ra-secrets");
|
|
19
21
|
const selectedBundlePath = path.join(root, "selected-transfer.ra-secrets");
|
|
@@ -112,6 +114,55 @@ fi
|
|
|
112
114
|
assert.doesNotMatch(envText, /your-telegram-bot-token/);
|
|
113
115
|
assert.match(envText, /TELEGRAM_BOT_USERNAMES=first_remoteagent_bot,second_remoteagent_bot/);
|
|
114
116
|
|
|
117
|
+
const removedByUsername = await removeTelegramBot({
|
|
118
|
+
dataDir: sourceDataDir,
|
|
119
|
+
selector: "@second_remoteagent_bot",
|
|
120
|
+
});
|
|
121
|
+
assert.equal(removedByUsername.id, 100002);
|
|
122
|
+
assert.equal(removedByUsername.username, "second_remoteagent_bot");
|
|
123
|
+
assert.equal(removedByUsername.botCount, 1);
|
|
124
|
+
const envAfterUsernameRemoval = await fs.readFile(path.join(sourceDataDir, ".env"), "utf8");
|
|
125
|
+
assert.match(envAfterUsernameRemoval, /TELEGRAM_BOT_TOKEN=100001:abcdefghijklmnopqrstuvwxyz_123456/);
|
|
126
|
+
assert.match(envAfterUsernameRemoval, /TELEGRAM_BOT_TOKENS=100001:abcdefghijklmnopqrstuvwxyz_123456/);
|
|
127
|
+
assert.match(envAfterUsernameRemoval, /TELEGRAM_BOT_USERNAMES=first_remoteagent_bot/);
|
|
128
|
+
assert.doesNotMatch(envAfterUsernameRemoval, /second_remoteagent_bot/);
|
|
129
|
+
|
|
130
|
+
await assert.rejects(
|
|
131
|
+
removeTelegramBot({ dataDir: sourceDataDir, selector: "100001" }),
|
|
132
|
+
/Cannot remove the last configured bot/,
|
|
133
|
+
);
|
|
134
|
+
await assert.rejects(
|
|
135
|
+
removeTelegramBot({ dataDir: sourceDataDir, selector: "missing_bot" }),
|
|
136
|
+
/Telegram bot was not found/,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
await registerTelegramBot({
|
|
140
|
+
dataDir: sourceDataDir,
|
|
141
|
+
token: "100002:abcdefghijklmnopqrstuvwxyz_654321",
|
|
142
|
+
ownerId: "8202993989",
|
|
143
|
+
identity: { id: 100002, username: "second_remoteagent_bot" },
|
|
144
|
+
});
|
|
145
|
+
const removedById = await removeTelegramBot({ dataDir: sourceDataDir, selector: "100001" });
|
|
146
|
+
assert.equal(removedById.username, "first_remoteagent_bot");
|
|
147
|
+
assert.equal(removedById.botCount, 1);
|
|
148
|
+
|
|
149
|
+
await fs.mkdir(sparseUsernameDataDir, { recursive: true });
|
|
150
|
+
await fs.writeFile(path.join(sparseUsernameDataDir, ".env"), [
|
|
151
|
+
"TELEGRAM_BOT_TOKEN=100001:abcdefghijklmnopqrstuvwxyz_123456",
|
|
152
|
+
"TELEGRAM_BOT_TOKENS=100001:abcdefghijklmnopqrstuvwxyz_123456,100002:abcdefghijklmnopqrstuvwxyz_654321",
|
|
153
|
+
"TELEGRAM_BOT_USERNAMES=,second_remoteagent_bot",
|
|
154
|
+
"TELEGRAM_OWNER_ID=8202993989",
|
|
155
|
+
"",
|
|
156
|
+
].join("\n"), { mode: 0o600 });
|
|
157
|
+
const removedSparseUsername = await removeTelegramBot({
|
|
158
|
+
dataDir: sparseUsernameDataDir,
|
|
159
|
+
selector: "second_remoteagent_bot",
|
|
160
|
+
});
|
|
161
|
+
assert.equal(removedSparseUsername.id, 100002);
|
|
162
|
+
const sparseEnv = await fs.readFile(path.join(sparseUsernameDataDir, ".env"), "utf8");
|
|
163
|
+
assert.match(sparseEnv, /TELEGRAM_BOT_TOKENS=100001:abcdefghijklmnopqrstuvwxyz_123456/);
|
|
164
|
+
assert.doesNotMatch(sparseEnv, /100002:abcdefghijklmnopqrstuvwxyz_654321/);
|
|
165
|
+
|
|
115
166
|
const sourceSecrets = {
|
|
116
167
|
API_TOKEN: {
|
|
117
168
|
key: "API_TOKEN",
|
|
@@ -556,6 +556,50 @@ await click(macroButton.callback_data);
|
|
|
556
556
|
await send("/batch send");
|
|
557
557
|
await waitForTelegramCall((call) => call.text.includes("mock provider completed"));
|
|
558
558
|
|
|
559
|
+
const longTextProviderCallsBefore = providerCalls.length;
|
|
560
|
+
const longPartOne = `LONG_PART_ONE:${"a".repeat(2200)}`;
|
|
561
|
+
const longPartTwo = `LONG_PART_TWO:${"b".repeat(2200)}`;
|
|
562
|
+
providerMode = "success";
|
|
563
|
+
await send("/batch start");
|
|
564
|
+
await send(longPartOne);
|
|
565
|
+
await send(longPartTwo);
|
|
566
|
+
await send("/batch send");
|
|
567
|
+
await waitForTelegramCall((call) => call.text.includes("mock provider completed"));
|
|
568
|
+
|
|
569
|
+
const longTextProviderCalls = providerCalls.slice(longTextProviderCallsBefore);
|
|
570
|
+
if (longTextProviderCalls.length !== 1) {
|
|
571
|
+
throw new Error(`Split long Telegram input should make one provider call, got ${longTextProviderCalls.length}`);
|
|
572
|
+
}
|
|
573
|
+
const longTextProviderMessage = longTextProviderCalls[0]?.message ?? "";
|
|
574
|
+
if (!longTextProviderMessage.includes("stored as a UTF-8 text file")) {
|
|
575
|
+
throw new Error(`Long Telegram input was not replaced with a file prompt: ${longTextProviderMessage}`);
|
|
576
|
+
}
|
|
577
|
+
if (longTextProviderMessage.includes(longPartOne) || longTextProviderMessage.includes(longPartTwo)) {
|
|
578
|
+
throw new Error("Long Telegram input was copied into the provider prompt instead of being file-backed");
|
|
579
|
+
}
|
|
580
|
+
const longTextFile = longTextProviderMessage.match(/^File: (.+\.txt)$/m)?.[1];
|
|
581
|
+
if (!longTextFile) {
|
|
582
|
+
throw new Error(`Long Telegram input prompt did not include an absolute text file path: ${longTextProviderMessage}`);
|
|
583
|
+
}
|
|
584
|
+
const expectedLongTextDirectory = path.join(
|
|
585
|
+
dataDir,
|
|
586
|
+
"uploads",
|
|
587
|
+
"telegram",
|
|
588
|
+
"remoteagent_test_bot",
|
|
589
|
+
"111222333",
|
|
590
|
+
);
|
|
591
|
+
if (path.dirname(longTextFile) !== expectedLongTextDirectory) {
|
|
592
|
+
throw new Error(`Long Telegram input was stored outside the managed upload directory: ${longTextFile}`);
|
|
593
|
+
}
|
|
594
|
+
const storedLongText = await fs.readFile(longTextFile, "utf8");
|
|
595
|
+
if (storedLongText !== `${longPartOne}\n${longPartTwo}`) {
|
|
596
|
+
throw new Error("Stored Telegram text did not preserve all batched message parts in order");
|
|
597
|
+
}
|
|
598
|
+
const longTextMode = (await fs.stat(longTextFile)).mode & 0o777;
|
|
599
|
+
if (longTextMode !== 0o600) {
|
|
600
|
+
throw new Error(`Stored Telegram text permissions should be 0600, got ${longTextMode.toString(8)}`);
|
|
601
|
+
}
|
|
602
|
+
|
|
559
603
|
await fs.appendFile(path.join(dataDir, ".env"), [
|
|
560
604
|
"TELEGRAM_BOT_TOKENS=000000:test-token",
|
|
561
605
|
"TELEGRAM_BOT_USERNAMES=remoteagent_test_bot",
|
|
@@ -797,6 +841,7 @@ console.log(JSON.stringify({
|
|
|
797
841
|
queueRemoveLatest: secondQueueId,
|
|
798
842
|
timeoutFinalMessage: true,
|
|
799
843
|
usageLimitFallback: true,
|
|
844
|
+
longTelegramTextStoredAsFile: true,
|
|
800
845
|
telegramSendMessages: evidenceCalls.filter((call) => call.method === "sendMessage").length,
|
|
801
846
|
}, null, 2));
|
|
802
847
|
|