appback-remoteagent 0.20.1 → 0.21.1
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 +1 -1
- package/dist/adapters/runtime-env.js +10 -0
- package/dist/cli.js +22 -16
- package/dist/index.js +6 -2
- package/dist/services/cli-config-service.js +80 -0
- package/dist/services/provider-setup-service.js +2 -4
- package/docs/CLI_BOOTSTRAP_AND_SECRET_MIGRATION.md +6 -2
- package/package.json +1 -1
- package/scripts/selftest-cli.mjs +52 -2
package/README.md
CHANGED
|
@@ -325,7 +325,7 @@ remoteagent bot add
|
|
|
325
325
|
remoteagent-start
|
|
326
326
|
```
|
|
327
327
|
|
|
328
|
-
The command validates the BotFather token
|
|
328
|
+
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
329
|
|
|
330
330
|
To move installation-wide `/secret` values to another PC, export and import a password-encrypted bundle:
|
|
331
331
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import process from "node:process";
|
|
3
4
|
import { fileURLToPath } from "node:url";
|
|
4
5
|
const CHILD_ENV_BLOCKED_PREFIXES = ["TELEGRAM_"];
|
|
5
6
|
export function buildProviderEnv(extraEnv) {
|
|
@@ -15,11 +16,20 @@ export function buildProviderEnv(extraEnv) {
|
|
|
15
16
|
env.DATA_DIR = dataDir;
|
|
16
17
|
env.REMOTEAGENT_DATA_DIR = dataDir;
|
|
17
18
|
env.REMOTEAGENT_SECRET_BIN = resolveSecretHelperPath();
|
|
19
|
+
env.PATH = buildRuntimePath(env.PATH);
|
|
18
20
|
if (extraEnv) {
|
|
19
21
|
Object.assign(env, extraEnv);
|
|
20
22
|
}
|
|
21
23
|
return env;
|
|
22
24
|
}
|
|
25
|
+
export function buildRuntimePath(existingPath = process.env.PATH, nodeExecutable = process.execPath, homeDir = os.homedir()) {
|
|
26
|
+
const entries = [
|
|
27
|
+
path.dirname(nodeExecutable),
|
|
28
|
+
path.join(homeDir, ".local", "bin"),
|
|
29
|
+
...(existingPath ?? "").split(path.delimiter),
|
|
30
|
+
].filter(Boolean);
|
|
31
|
+
return [...new Set(entries)].join(path.delimiter);
|
|
32
|
+
}
|
|
23
33
|
function resolveSecretHelperPath() {
|
|
24
34
|
const adapterDir = path.dirname(fileURLToPath(import.meta.url));
|
|
25
35
|
return path.resolve(adapterDir, "..", "secret-helper.js");
|
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
2
3
|
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import process from "node:process";
|
|
5
|
-
import
|
|
6
|
-
import { registerTelegramBot, readConfiguredOwnerId } from "./services/cli-config-service.js";
|
|
6
|
+
import { fetchTelegramBotIdentity, registerTelegramBot, 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);
|
|
@@ -46,9 +46,27 @@ async function addBot(dataDir, args) {
|
|
|
46
46
|
if (args.length > 0) {
|
|
47
47
|
throw new Error(`Unexpected bot argument: ${args[0]}`);
|
|
48
48
|
}
|
|
49
|
+
const identity = await fetchTelegramBotIdentity(token);
|
|
49
50
|
const configuredOwner = await readConfiguredOwnerId(dataDir);
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
let ownerId = ownerOption || configuredOwner;
|
|
52
|
+
if (!ownerId) {
|
|
53
|
+
const startPayload = `ra_${randomBytes(8).toString("hex")}`;
|
|
54
|
+
console.log([
|
|
55
|
+
`Validated @${identity.username} (${identity.id}).`,
|
|
56
|
+
"",
|
|
57
|
+
"Open this Telegram link within 3 minutes to confirm the owner:",
|
|
58
|
+
` https://t.me/${identity.username}?start=${startPayload}`,
|
|
59
|
+
"",
|
|
60
|
+
"Or send this exact command to the bot:",
|
|
61
|
+
` /start ${startPayload}`,
|
|
62
|
+
"",
|
|
63
|
+
"Waiting for owner confirmation...",
|
|
64
|
+
].join("\n"));
|
|
65
|
+
const owner = await waitForTelegramOwner(token, startPayload);
|
|
66
|
+
ownerId = owner.id;
|
|
67
|
+
console.log(`Detected Telegram owner: ${owner.displayName}${owner.username ? ` (@${owner.username})` : ""} (${owner.id})`);
|
|
68
|
+
}
|
|
69
|
+
const result = await registerTelegramBot({ dataDir, token, ownerId, identity });
|
|
52
70
|
console.log([
|
|
53
71
|
`${result.added ? "Registered" : "Updated"} @${result.identity.username} (${result.identity.id}).`,
|
|
54
72
|
`Configured bots: ${result.botCount}`,
|
|
@@ -119,18 +137,6 @@ function takeFlag(args, name) {
|
|
|
119
137
|
args.splice(index, 1);
|
|
120
138
|
return true;
|
|
121
139
|
}
|
|
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
140
|
async function promptHidden(question) {
|
|
135
141
|
if (!process.stdin.isTTY || !process.stdout.isTTY || !process.stdin.setRawMode) {
|
|
136
142
|
throw new Error("Interactive hidden input requires a TTY. Use the corresponding --*-file option instead.");
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { BotPollingStateService } from "./services/bot-polling-state-service.js"
|
|
|
19
19
|
import { ProviderRecoveryService } from "./services/provider-recovery-service.js";
|
|
20
20
|
import { computePolicyPollIntervalMs, computeRecentMessageRanks } from "./services/polling-policy.js";
|
|
21
21
|
import { terminateAllSpawnedExecutions } from "./adapters/windows-shell.js";
|
|
22
|
+
import { buildProviderEnv } from "./adapters/runtime-env.js";
|
|
22
23
|
import { setTelegramCommandMenu } from "./telegram-command-menu.js";
|
|
23
24
|
import { buildBotInfoFromIdentity, buildFallbackBotInfo } from "./telegram-bot-identity.js";
|
|
24
25
|
const execFileAsync = promisify(execFile);
|
|
@@ -528,9 +529,12 @@ function commandExists(command) {
|
|
|
528
529
|
return fs.existsSync(trimmed);
|
|
529
530
|
}
|
|
530
531
|
if (process.platform === "win32") {
|
|
531
|
-
return spawnSync("where", [trimmed], { stdio: "ignore" }).status === 0;
|
|
532
|
+
return spawnSync("where", [trimmed], { stdio: "ignore", env: buildProviderEnv() }).status === 0;
|
|
532
533
|
}
|
|
533
|
-
return spawnSync("sh", ["-lc", 'command -v "$0" >/dev/null 2>&1', trimmed], {
|
|
534
|
+
return spawnSync("sh", ["-lc", 'command -v "$0" >/dev/null 2>&1', trimmed], {
|
|
535
|
+
stdio: "ignore",
|
|
536
|
+
env: buildProviderEnv(),
|
|
537
|
+
}).status === 0;
|
|
534
538
|
}
|
|
535
539
|
async function acquireProcessLock(dataDir) {
|
|
536
540
|
await fsp.mkdir(dataDir, { recursive: true });
|
|
@@ -101,6 +101,86 @@ export async function fetchTelegramBotIdentity(token) {
|
|
|
101
101
|
username: payload.result.username,
|
|
102
102
|
};
|
|
103
103
|
}
|
|
104
|
+
export async function waitForTelegramOwner(token, startPayload, timeoutMs = 180_000) {
|
|
105
|
+
assertBotToken(token);
|
|
106
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(startPayload)) {
|
|
107
|
+
throw new Error("Telegram start payload must use 1-64 URL-safe characters.");
|
|
108
|
+
}
|
|
109
|
+
const startedAt = Date.now();
|
|
110
|
+
let offset = await nextTelegramUpdateOffset(token);
|
|
111
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
112
|
+
const remainingMs = timeoutMs - (Date.now() - startedAt);
|
|
113
|
+
const pollSeconds = Math.max(1, Math.min(15, Math.floor(remainingMs / 1000)));
|
|
114
|
+
const updates = await getTelegramUpdates(token, offset, pollSeconds);
|
|
115
|
+
for (const update of updates) {
|
|
116
|
+
if (typeof update.update_id === "number") {
|
|
117
|
+
offset = Math.max(offset, update.update_id + 1);
|
|
118
|
+
}
|
|
119
|
+
const message = update.message;
|
|
120
|
+
const sender = message?.from;
|
|
121
|
+
if (message?.chat?.type !== "private"
|
|
122
|
+
|| sender?.is_bot
|
|
123
|
+
|| typeof sender?.id !== "number"
|
|
124
|
+
|| message.text?.trim() !== `/start ${startPayload}`) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
id: String(sender.id),
|
|
129
|
+
username: sender.username,
|
|
130
|
+
displayName: [sender.first_name, sender.last_name].filter(Boolean).join(" ") || sender.username || String(sender.id),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
throw new Error("Timed out waiting for the Telegram owner confirmation. Run the command again and use the new /start link.");
|
|
135
|
+
}
|
|
136
|
+
async function nextTelegramUpdateOffset(token) {
|
|
137
|
+
const updates = await getTelegramUpdates(token, undefined, 0);
|
|
138
|
+
return updates.reduce((next, update) => typeof update.update_id === "number" ? Math.max(next, update.update_id + 1) : next, 0);
|
|
139
|
+
}
|
|
140
|
+
async function getTelegramUpdates(token, offset, timeoutSeconds) {
|
|
141
|
+
const args = [
|
|
142
|
+
"-4",
|
|
143
|
+
"-sS",
|
|
144
|
+
"--get",
|
|
145
|
+
"--connect-timeout",
|
|
146
|
+
"10",
|
|
147
|
+
"--max-time",
|
|
148
|
+
String(Math.max(20, timeoutSeconds + 10)),
|
|
149
|
+
"--data-urlencode",
|
|
150
|
+
`timeout=${timeoutSeconds}`,
|
|
151
|
+
"--data-urlencode",
|
|
152
|
+
"limit=100",
|
|
153
|
+
"--data-urlencode",
|
|
154
|
+
'allowed_updates=["message"]',
|
|
155
|
+
];
|
|
156
|
+
if (offset !== undefined) {
|
|
157
|
+
args.push("--data-urlencode", `offset=${offset}`);
|
|
158
|
+
}
|
|
159
|
+
args.push(`https://api.telegram.org/bot${token}/getUpdates`);
|
|
160
|
+
let stdout;
|
|
161
|
+
try {
|
|
162
|
+
const result = await execFileAsync("curl", args);
|
|
163
|
+
stdout = result.stdout;
|
|
164
|
+
if (result.stderr?.trim()) {
|
|
165
|
+
console.error(`curl stderr for Telegram getUpdates: ${result.stderr.trim()}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
const detail = error instanceof Error ? error.message.replace(token, "[redacted]") : String(error);
|
|
170
|
+
throw new Error(`Telegram getUpdates request failed over IPv4: ${detail}`);
|
|
171
|
+
}
|
|
172
|
+
let payload;
|
|
173
|
+
try {
|
|
174
|
+
payload = JSON.parse(stdout);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
throw new Error("Telegram getUpdates returned an invalid response.");
|
|
178
|
+
}
|
|
179
|
+
if (!payload.ok || !Array.isArray(payload.result)) {
|
|
180
|
+
throw new Error(payload.description || "Telegram rejected the getUpdates request.");
|
|
181
|
+
}
|
|
182
|
+
return payload.result;
|
|
183
|
+
}
|
|
104
184
|
function assertBotToken(token) {
|
|
105
185
|
if (!isBotToken(token)) {
|
|
106
186
|
throw new Error("Invalid Telegram bot token format.");
|
|
@@ -3,6 +3,7 @@ import os from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import process from "node:process";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
|
+
import { buildProviderEnv } from "../adapters/runtime-env.js";
|
|
6
7
|
export class ProviderSetupService {
|
|
7
8
|
timeoutMs;
|
|
8
9
|
isProviderAvailable;
|
|
@@ -224,10 +225,7 @@ export class ProviderSetupService {
|
|
|
224
225
|
return new Promise((resolve, reject) => {
|
|
225
226
|
const child = spawn(launcher.file, launcher.args, {
|
|
226
227
|
cwd: process.cwd(),
|
|
227
|
-
env:
|
|
228
|
-
...process.env,
|
|
229
|
-
...extraEnv,
|
|
230
|
-
},
|
|
228
|
+
env: buildProviderEnv(extraEnv),
|
|
231
229
|
});
|
|
232
230
|
let stdout = "";
|
|
233
231
|
let stderr = "";
|
|
@@ -11,9 +11,11 @@ remoteagent bot add
|
|
|
11
11
|
remoteagent-start
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
`remoteagent bot add` asks for the BotFather token without echoing it and
|
|
14
|
+
`remoteagent bot add` asks for the BotFather token without echoing it and validates the token with Telegram `getMe`. On the first registration it then prints a one-time Telegram `/start` link and waits up to three minutes. Open that link from the Telegram account that will own the installation; RemoteAgent detects and stores that account's numeric user ID automatically. No manual owner ID lookup or entry is required.
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
The confirmation update is consumed by the setup command. After `remoteagent-start`, send `/start` to the bot normally to begin using RemoteAgent.
|
|
17
|
+
|
|
18
|
+
For unattended automation, keep sensitive values out of shell history by using a permission-restricted token file and pass the already verified owner ID explicitly:
|
|
17
19
|
|
|
18
20
|
```bash
|
|
19
21
|
chmod 600 /secure/path/telegram-token
|
|
@@ -30,6 +32,8 @@ remoteagent-start
|
|
|
30
32
|
sudo systemctl restart remoteagent
|
|
31
33
|
```
|
|
32
34
|
|
|
35
|
+
Provider install, login, and execution commands automatically prepend the directory of the Node executable running RemoteAgent and `~/.local/bin` to their child-process `PATH`. This keeps `/install codex`, `/install claude`, and `/login` working when RemoteAgent runs under systemd with Node installed through nvm, even if the service itself was started with a minimal system PATH.
|
|
36
|
+
|
|
33
37
|
## Secret migration
|
|
34
38
|
|
|
35
39
|
RemoteAgent `/secret` values belong to the installation, not to an individual agent session. They are stored under `~/.remoteagent/managed/secrets.json`.
|
package/package.json
CHANGED
package/scripts/selftest-cli.mjs
CHANGED
|
@@ -4,7 +4,12 @@ import assert from "node:assert/strict";
|
|
|
4
4
|
import fs from "node:fs/promises";
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import path from "node:path";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
fetchTelegramBotIdentity,
|
|
9
|
+
registerTelegramBot,
|
|
10
|
+
waitForTelegramOwner,
|
|
11
|
+
} from "../dist/services/cli-config-service.js";
|
|
12
|
+
import { buildProviderEnv, buildRuntimePath } from "../dist/adapters/runtime-env.js";
|
|
8
13
|
import { exportSecrets, importSecrets } from "../dist/services/secret-transfer-service.js";
|
|
9
14
|
|
|
10
15
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "remoteagent-cli-selftest-"));
|
|
@@ -15,12 +20,44 @@ const selectedBundlePath = path.join(root, "selected-transfer.ra-secrets");
|
|
|
15
20
|
const passphrase = "correct-horse-battery-staple";
|
|
16
21
|
|
|
17
22
|
try {
|
|
23
|
+
const expectedNodeBin = path.join(root, "nvm", "bin");
|
|
24
|
+
const expectedHome = path.join(root, "home");
|
|
25
|
+
const runtimePath = buildRuntimePath(
|
|
26
|
+
["/usr/local/bin", "/usr/bin", "/usr/bin"].join(path.delimiter),
|
|
27
|
+
path.join(expectedNodeBin, "node"),
|
|
28
|
+
expectedHome,
|
|
29
|
+
).split(path.delimiter);
|
|
30
|
+
assert.deepEqual(runtimePath, [
|
|
31
|
+
expectedNodeBin,
|
|
32
|
+
path.join(expectedHome, ".local", "bin"),
|
|
33
|
+
"/usr/local/bin",
|
|
34
|
+
"/usr/bin",
|
|
35
|
+
]);
|
|
36
|
+
const providerEnv = buildProviderEnv();
|
|
37
|
+
assert.equal(providerEnv.PATH?.split(path.delimiter)[0], path.dirname(process.execPath));
|
|
38
|
+
assert.ok(providerEnv.PATH?.split(path.delimiter).includes(path.join(os.homedir(), ".local", "bin")));
|
|
39
|
+
|
|
18
40
|
const binDir = path.join(root, "bin");
|
|
19
41
|
const curlArgsPath = path.join(root, "curl-args.txt");
|
|
42
|
+
const curlUpdateCallsPath = path.join(root, "curl-update-calls.txt");
|
|
20
43
|
await fs.mkdir(binDir, { recursive: true });
|
|
21
44
|
await fs.writeFile(path.join(binDir, "curl"), `#!/usr/bin/env bash
|
|
22
45
|
printf '%s\\n' "$@" > ${JSON.stringify(curlArgsPath)}
|
|
23
|
-
printf '
|
|
46
|
+
if printf '%s\\n' "$@" | grep -q '/getUpdates'; then
|
|
47
|
+
count=0
|
|
48
|
+
if [ -f ${JSON.stringify(curlUpdateCallsPath)} ]; then
|
|
49
|
+
count="$(cat ${JSON.stringify(curlUpdateCallsPath)})"
|
|
50
|
+
fi
|
|
51
|
+
count=$((count + 1))
|
|
52
|
+
printf '%s' "$count" > ${JSON.stringify(curlUpdateCallsPath)}
|
|
53
|
+
if [ "$count" -eq 1 ]; then
|
|
54
|
+
printf '{"ok":true,"result":[{"update_id":41,"message":{"text":"/start stale_payload","chat":{"id":777,"type":"private"},"from":{"id":777,"is_bot":false,"username":"stale"}}}]}'
|
|
55
|
+
else
|
|
56
|
+
printf '{"ok":true,"result":[{"update_id":42,"message":{"text":"/start ra_selftest","chat":{"id":8202993989,"type":"private"},"from":{"id":8202993989,"is_bot":false,"username":"roy","first_name":"Roy"}}}]}'
|
|
57
|
+
fi
|
|
58
|
+
else
|
|
59
|
+
printf '{"ok":true,"result":{"id":100000,"username":"bootstrap_test_bot"}}'
|
|
60
|
+
fi
|
|
24
61
|
`, { mode: 0o755 });
|
|
25
62
|
const originalPath = process.env.PATH;
|
|
26
63
|
process.env.PATH = `${binDir}:${originalPath ?? ""}`;
|
|
@@ -29,6 +66,19 @@ printf '{"ok":true,"result":{"id":100000,"username":"bootstrap_test_bot"}}'
|
|
|
29
66
|
const curlArgs = await fs.readFile(curlArgsPath, "utf8");
|
|
30
67
|
assert.match(curlArgs, /^-4$/m);
|
|
31
68
|
assert.match(curlArgs, /\/getMe$/m);
|
|
69
|
+
const detectedOwner = await waitForTelegramOwner(
|
|
70
|
+
"100000:abcdefghijklmnopqrstuvwxyz_123456",
|
|
71
|
+
"ra_selftest",
|
|
72
|
+
2_000,
|
|
73
|
+
);
|
|
74
|
+
assert.deepEqual(detectedOwner, {
|
|
75
|
+
id: "8202993989",
|
|
76
|
+
username: "roy",
|
|
77
|
+
displayName: "Roy",
|
|
78
|
+
});
|
|
79
|
+
const ownerCurlArgs = await fs.readFile(curlArgsPath, "utf8");
|
|
80
|
+
assert.match(ownerCurlArgs, /^-4$/m);
|
|
81
|
+
assert.match(ownerCurlArgs, /^offset=42$/m);
|
|
32
82
|
process.env.PATH = originalPath;
|
|
33
83
|
|
|
34
84
|
await fs.mkdir(sourceDataDir, { recursive: true });
|