appback-remoteagent 0.13.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/.env.example +39 -0
- package/LICENSE +21 -0
- package/README.md +371 -0
- package/bin/remoteagent.js +2 -0
- package/dist/adapters/claude-adapter.js +78 -0
- package/dist/adapters/codex-adapter.js +241 -0
- package/dist/adapters/provider-adapter.js +1 -0
- package/dist/adapters/shell-adapter.js +44 -0
- package/dist/adapters/windows-shell.js +111 -0
- package/dist/bot.js +2135 -0
- package/dist/config.js +170 -0
- package/dist/index.js +534 -0
- package/dist/secret-helper.js +24 -0
- package/dist/services/agent-memory-service.js +737 -0
- package/dist/services/bot-management-service.js +626 -0
- package/dist/services/bridge-service.js +807 -0
- package/dist/services/local-ui-service.js +533 -0
- package/dist/services/provider-setup-service.js +284 -0
- package/dist/services/remote-shell-service.js +97 -0
- package/dist/store/file-store.js +690 -0
- package/dist/telegram-fetch.js +85 -0
- package/dist/types.js +1 -0
- package/docs/ARCHITECTURE.md +170 -0
- package/docs/COKACDIR_NOTES.md +79 -0
- package/docs/ERROR_NORMALIZATION.md +46 -0
- package/docs/MINI_APP.md +112 -0
- package/docs/MVP.md +108 -0
- package/docs/OPERATIONS.md +181 -0
- package/docs/RELEASING.md +87 -0
- package/docs/SESSION_DIRECTORY_PLAN.md +506 -0
- package/package.json +47 -0
- package/scripts/bump-version.sh +23 -0
- package/scripts/finish-claude-login.sh +48 -0
- package/scripts/install-claude.sh +6 -0
- package/scripts/install-codex.sh +8 -0
- package/scripts/install.ps1 +51 -0
- package/scripts/install.sh +101 -0
- package/scripts/mock-adapter.sh +7 -0
- package/scripts/restart-after-bot-op.sh +118 -0
- package/scripts/selftest-telegram-update.mjs +359 -0
- package/scripts/start-claude-login.sh +4 -0
- package/scripts/start.ps1 +39 -0
- package/scripts/start.sh +54 -0
- package/scripts/stop.ps1 +40 -0
- package/scripts/stop.sh +39 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
export class ProviderSetupService {
|
|
7
|
+
timeoutMs;
|
|
8
|
+
isProviderAvailable;
|
|
9
|
+
installCommands;
|
|
10
|
+
claudeLoginStartCommand;
|
|
11
|
+
claudeLoginFinishCommand;
|
|
12
|
+
constructor(timeoutMs, isProviderAvailable, installCommands, claudeLoginStartCommand, claudeLoginFinishCommand) {
|
|
13
|
+
this.timeoutMs = timeoutMs;
|
|
14
|
+
this.isProviderAvailable = isProviderAvailable;
|
|
15
|
+
this.installCommands = installCommands;
|
|
16
|
+
this.claudeLoginStartCommand = claudeLoginStartCommand;
|
|
17
|
+
this.claudeLoginFinishCommand = claudeLoginFinishCommand;
|
|
18
|
+
}
|
|
19
|
+
async install(provider) {
|
|
20
|
+
const before = this.isProviderAvailable(provider);
|
|
21
|
+
const command = this.installCommands[provider]?.trim();
|
|
22
|
+
if (!command) {
|
|
23
|
+
if (before) {
|
|
24
|
+
return {
|
|
25
|
+
provider,
|
|
26
|
+
before,
|
|
27
|
+
after: true,
|
|
28
|
+
output: `${provider} is already installed on this machine, but no install or update command is configured.`,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
throw new Error(this.installGuidance(provider));
|
|
32
|
+
}
|
|
33
|
+
const result = await this.execute(command, {});
|
|
34
|
+
const after = this.isProviderAvailable(provider);
|
|
35
|
+
if (result.code !== 0) {
|
|
36
|
+
throw new Error(this.formatFailure(`${provider} ${before ? "update" : "install"} failed.`, result));
|
|
37
|
+
}
|
|
38
|
+
const authGuidance = await this.postInstallGuidance(provider, after);
|
|
39
|
+
return {
|
|
40
|
+
provider,
|
|
41
|
+
before,
|
|
42
|
+
after,
|
|
43
|
+
output: this.formatSuccess(`${provider} ${before ? "update" : "install"} finished.`, result, [
|
|
44
|
+
before
|
|
45
|
+
? (after ? `${provider} remains available after the update check.` : `${provider} update command finished, but the CLI is no longer detected.`)
|
|
46
|
+
: (after ? `${provider} is now available.` : `${provider} install command finished, but the CLI is still not detected.`),
|
|
47
|
+
authGuidance,
|
|
48
|
+
].filter(Boolean).join("\n\n")),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
async startCodexLogin() {
|
|
52
|
+
if (!this.isProviderAvailable("codex")) {
|
|
53
|
+
throw new Error("Codex is not installed yet. Run /install codex first.");
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const status = await this.execute("codex login status", {});
|
|
57
|
+
const statusText = [status.stdout, status.stderr].filter(Boolean).join("\n");
|
|
58
|
+
if (statusText && !/not logged in/i.test(statusText)) {
|
|
59
|
+
return this.formatSuccess("Codex is already logged in on this machine.", status);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Ignore status check failures and continue with device auth.
|
|
64
|
+
}
|
|
65
|
+
if (process.platform === "win32") {
|
|
66
|
+
return [
|
|
67
|
+
"Codex login requires local browser/device authentication on this machine.",
|
|
68
|
+
"Run `codex login` or `codex login --device-auth` on the machine.",
|
|
69
|
+
].join("\n");
|
|
70
|
+
}
|
|
71
|
+
const logPath = path.join(os.tmpdir(), `remoteagent-codex-login-${Date.now()}.log`);
|
|
72
|
+
await fs.writeFile(logPath, "", "utf8");
|
|
73
|
+
await this.launchDetached("codex login --device-auth", logPath);
|
|
74
|
+
const timeoutAt = Date.now() + 20_000;
|
|
75
|
+
let lastText = "";
|
|
76
|
+
while (Date.now() < timeoutAt) {
|
|
77
|
+
try {
|
|
78
|
+
lastText = await fs.readFile(logPath, "utf8");
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
lastText = "";
|
|
82
|
+
}
|
|
83
|
+
const cleaned = this.stripAnsi(lastText).trim();
|
|
84
|
+
const urls = this.extractUrls(cleaned);
|
|
85
|
+
const deviceCode = this.extractCodexDeviceCode(cleaned);
|
|
86
|
+
if (urls.length > 0) {
|
|
87
|
+
return [
|
|
88
|
+
"Codex login flow started.",
|
|
89
|
+
"Open this URL and finish the login flow:",
|
|
90
|
+
...urls,
|
|
91
|
+
deviceCode ? "" : undefined,
|
|
92
|
+
deviceCode ? `One-time code: ${deviceCode}` : undefined,
|
|
93
|
+
"",
|
|
94
|
+
"After the browser flow finishes, use `/start` in this chat.",
|
|
95
|
+
].filter((line) => typeof line === "string").join("\n");
|
|
96
|
+
}
|
|
97
|
+
if (/already logged in/i.test(cleaned)) {
|
|
98
|
+
return [
|
|
99
|
+
"Codex is already logged in on this machine.",
|
|
100
|
+
cleaned,
|
|
101
|
+
].filter(Boolean).join("\n\n");
|
|
102
|
+
}
|
|
103
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
104
|
+
}
|
|
105
|
+
if (lastText.trim()) {
|
|
106
|
+
const cleaned = this.stripAnsi(lastText).trim();
|
|
107
|
+
return this.formatSuccess("Codex login started, but no browser URL was captured yet.", { code: 0, stdout: cleaned, stderr: "" }, "If the login URL is not shown above, run `/login codex` again or use `codex login --device-auth` directly on the machine.");
|
|
108
|
+
}
|
|
109
|
+
throw new Error("Codex login start timed out before a browser URL was captured.");
|
|
110
|
+
}
|
|
111
|
+
async startClaudeLogin() {
|
|
112
|
+
if (!this.isProviderAvailable("claude")) {
|
|
113
|
+
throw new Error("Claude Code is not installed yet. Run /install claude first.");
|
|
114
|
+
}
|
|
115
|
+
const command = this.claudeLoginStartCommand?.trim();
|
|
116
|
+
if (!command) {
|
|
117
|
+
throw new Error([
|
|
118
|
+
"No Claude login start command is configured.",
|
|
119
|
+
"Set CLAUDE_LOGIN_START_COMMAND in ~/.remoteagent/.env, then run /login claude.",
|
|
120
|
+
].join("\n"));
|
|
121
|
+
}
|
|
122
|
+
const result = await this.execute(command, {});
|
|
123
|
+
if (result.code !== 0) {
|
|
124
|
+
throw new Error(this.formatFailure("Claude login start failed.", result));
|
|
125
|
+
}
|
|
126
|
+
const urls = this.extractUrls(`${result.stdout}\n${result.stderr}`);
|
|
127
|
+
const urlBlock = urls.length > 0
|
|
128
|
+
? ["Open this URL and finish the login flow:", ...urls].join("\n")
|
|
129
|
+
: undefined;
|
|
130
|
+
return this.formatSuccess("Claude login flow started.", result, urlBlock ? `${urlBlock}\n\nAfter login, send /login claude <token>.` : "After login, send /login claude <token>.");
|
|
131
|
+
}
|
|
132
|
+
async postInstallGuidance(provider, available) {
|
|
133
|
+
if (!available) {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
if (provider === "codex") {
|
|
137
|
+
try {
|
|
138
|
+
const result = await this.execute("codex login status", {});
|
|
139
|
+
const statusText = [result.stdout, result.stderr].filter(Boolean).join("\n");
|
|
140
|
+
if (/not logged in/i.test(statusText)) {
|
|
141
|
+
return [
|
|
142
|
+
"Codex is installed but not logged in yet.",
|
|
143
|
+
"Next step: run `/login codex` in this chat.",
|
|
144
|
+
"If you prefer machine-side auth, you can use `codex login --device-auth` and complete the login in your browser.",
|
|
145
|
+
].join("\n");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return "Codex is installed. If this machine is not authenticated yet, run `/login codex` or use `codex login --device-auth` on the machine.";
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (provider === "claude") {
|
|
153
|
+
try {
|
|
154
|
+
const result = await this.execute("claude auth status", {});
|
|
155
|
+
const statusText = [result.stdout, result.stderr].filter(Boolean).join("\n");
|
|
156
|
+
if (/not logged in|loggedIn:\s*false/i.test(statusText)) {
|
|
157
|
+
return [
|
|
158
|
+
"Claude Code is installed but not logged in yet.",
|
|
159
|
+
"Next step: run `/login claude` or complete the configured Claude login flow on this machine.",
|
|
160
|
+
].join("\n");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return "Claude Code is installed. If this machine is not authenticated yet, run `/login claude` or complete the configured login flow.";
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
async finishClaudeLogin(token) {
|
|
170
|
+
if (!this.isProviderAvailable("claude")) {
|
|
171
|
+
throw new Error("Claude Code is not installed yet. Run /install claude first.");
|
|
172
|
+
}
|
|
173
|
+
const trimmed = token.trim();
|
|
174
|
+
if (!trimmed) {
|
|
175
|
+
throw new Error("Usage: /login claude <token>");
|
|
176
|
+
}
|
|
177
|
+
const template = this.claudeLoginFinishCommand?.trim();
|
|
178
|
+
if (!template) {
|
|
179
|
+
throw new Error([
|
|
180
|
+
"No Claude login finish command is configured.",
|
|
181
|
+
"Set CLAUDE_LOGIN_FINISH_COMMAND in ~/.remoteagent/.env, then run /login claude <token>.",
|
|
182
|
+
].join("\n"));
|
|
183
|
+
}
|
|
184
|
+
const command = template.includes("{token}")
|
|
185
|
+
? template.replaceAll("{token}", this.shellEscape(trimmed))
|
|
186
|
+
: template;
|
|
187
|
+
const result = await this.execute(command, {
|
|
188
|
+
REMOTEAGENT_AUTH_TOKEN: trimmed,
|
|
189
|
+
CLAUDE_AUTH_TOKEN: trimmed,
|
|
190
|
+
});
|
|
191
|
+
if (result.code !== 0) {
|
|
192
|
+
throw new Error(this.formatFailure("Claude login failed.", result));
|
|
193
|
+
}
|
|
194
|
+
return this.formatSuccess("Claude login succeeded.", result);
|
|
195
|
+
}
|
|
196
|
+
stripAnsi(text) {
|
|
197
|
+
return text.replace(/\[[0-9;]*m/g, "");
|
|
198
|
+
}
|
|
199
|
+
extractUrls(text) {
|
|
200
|
+
const matches = text.match(/https?:\/\/[^\s)]+/g) ?? [];
|
|
201
|
+
return [...new Set(matches)];
|
|
202
|
+
}
|
|
203
|
+
extractCodexDeviceCode(text) {
|
|
204
|
+
const codeMatch = text.match(/Enter this one-time code(?:\s*\(expires in .*?\))?\s*([A-Z0-9]{4}-[A-Z0-9]{5})/is)
|
|
205
|
+
?? text.match(/([A-Z0-9]{4}-[A-Z0-9]{5})/);
|
|
206
|
+
return codeMatch?.[1];
|
|
207
|
+
}
|
|
208
|
+
installGuidance(provider) {
|
|
209
|
+
const commandName = provider === "codex" ? "CODEX_INSTALL_COMMAND" : "CLAUDE_INSTALL_COMMAND";
|
|
210
|
+
return [
|
|
211
|
+
`No install command is configured for ${provider}.`,
|
|
212
|
+
`Set ${commandName} in ~/.remoteagent/.env, then run /install ${provider}.`,
|
|
213
|
+
].join("\n");
|
|
214
|
+
}
|
|
215
|
+
async launchDetached(command, logPath) {
|
|
216
|
+
if (process.platform === "win32") {
|
|
217
|
+
throw new Error("Detached Codex login is not implemented on Windows.");
|
|
218
|
+
}
|
|
219
|
+
await fs.mkdir(path.dirname(logPath), { recursive: true });
|
|
220
|
+
await this.execute(`nohup ${command} > ${this.shellEscape(logPath)} 2>&1 </dev/null &`, {});
|
|
221
|
+
}
|
|
222
|
+
async execute(command, extraEnv) {
|
|
223
|
+
const launcher = this.resolveLauncher(command);
|
|
224
|
+
return new Promise((resolve, reject) => {
|
|
225
|
+
const child = spawn(launcher.file, launcher.args, {
|
|
226
|
+
cwd: process.cwd(),
|
|
227
|
+
env: {
|
|
228
|
+
...process.env,
|
|
229
|
+
...extraEnv,
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
let stdout = "";
|
|
233
|
+
let stderr = "";
|
|
234
|
+
const timer = setTimeout(() => {
|
|
235
|
+
child.kill("SIGTERM");
|
|
236
|
+
}, this.timeoutMs);
|
|
237
|
+
child.stdout.on("data", (chunk) => {
|
|
238
|
+
stdout += chunk.toString();
|
|
239
|
+
});
|
|
240
|
+
child.stderr.on("data", (chunk) => {
|
|
241
|
+
stderr += chunk.toString();
|
|
242
|
+
});
|
|
243
|
+
child.on("error", (error) => {
|
|
244
|
+
clearTimeout(timer);
|
|
245
|
+
reject(error);
|
|
246
|
+
});
|
|
247
|
+
child.on("close", (code) => {
|
|
248
|
+
clearTimeout(timer);
|
|
249
|
+
resolve({
|
|
250
|
+
code,
|
|
251
|
+
stdout: stdout.trim(),
|
|
252
|
+
stderr: stderr.trim(),
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
resolveLauncher(command) {
|
|
258
|
+
if (process.platform === "win32") {
|
|
259
|
+
return {
|
|
260
|
+
file: "powershell.exe",
|
|
261
|
+
args: [
|
|
262
|
+
"-NoProfile",
|
|
263
|
+
"-Command",
|
|
264
|
+
`[Console]::InputEncoding=[System.Text.Encoding]::UTF8; [Console]::OutputEncoding=[System.Text.Encoding]::UTF8; $OutputEncoding=[System.Text.Encoding]::UTF8; ${command}`,
|
|
265
|
+
],
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
file: "bash",
|
|
270
|
+
args: ["-lc", command],
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
shellEscape(value) {
|
|
274
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
275
|
+
}
|
|
276
|
+
formatFailure(prefix, result) {
|
|
277
|
+
const output = [result.stdout, result.stderr].filter(Boolean).join("\n\n").trim();
|
|
278
|
+
return output ? `${prefix}\n\n${output}` : prefix;
|
|
279
|
+
}
|
|
280
|
+
formatSuccess(prefix, result, suffix) {
|
|
281
|
+
const body = [result.stdout, result.stderr].filter(Boolean).join("\n\n").trim();
|
|
282
|
+
return [prefix, suffix, body].filter(Boolean).join("\n\n");
|
|
283
|
+
}
|
|
284
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
export class RemoteShellService {
|
|
4
|
+
timeoutMs;
|
|
5
|
+
maxBufferBytes;
|
|
6
|
+
constructor(timeoutMs, maxBufferBytes = 1024 * 1024) {
|
|
7
|
+
this.timeoutMs = timeoutMs;
|
|
8
|
+
this.maxBufferBytes = maxBufferBytes;
|
|
9
|
+
}
|
|
10
|
+
async execute(command, cwd, kind) {
|
|
11
|
+
const { file, args, shell } = this.resolveLauncher(command, kind);
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const child = spawn(file, args, {
|
|
14
|
+
cwd,
|
|
15
|
+
env: process.env,
|
|
16
|
+
});
|
|
17
|
+
let stdout = "";
|
|
18
|
+
let stderr = "";
|
|
19
|
+
let exceeded = false;
|
|
20
|
+
const timer = setTimeout(() => {
|
|
21
|
+
child.kill("SIGTERM");
|
|
22
|
+
}, this.timeoutMs);
|
|
23
|
+
child.stdout.on("data", (chunk) => {
|
|
24
|
+
if (exceeded) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
stdout += chunk.toString();
|
|
28
|
+
if (stdout.length + stderr.length > this.maxBufferBytes) {
|
|
29
|
+
exceeded = true;
|
|
30
|
+
child.kill("SIGTERM");
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
child.stderr.on("data", (chunk) => {
|
|
34
|
+
if (exceeded) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
stderr += chunk.toString();
|
|
38
|
+
if (stdout.length + stderr.length > this.maxBufferBytes) {
|
|
39
|
+
exceeded = true;
|
|
40
|
+
child.kill("SIGTERM");
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
child.on("error", (error) => {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
reject(error);
|
|
46
|
+
});
|
|
47
|
+
child.on("close", (code) => {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
if (exceeded) {
|
|
50
|
+
reject(new Error("Remote shell output exceeded the size limit."));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
resolve({
|
|
54
|
+
shell,
|
|
55
|
+
code,
|
|
56
|
+
stdout: stdout.trim(),
|
|
57
|
+
stderr: stderr.trim(),
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
resolveLauncher(command, kind) {
|
|
63
|
+
if (process.platform === "win32") {
|
|
64
|
+
if (kind === "cmd") {
|
|
65
|
+
return {
|
|
66
|
+
file: "cmd.exe",
|
|
67
|
+
args: ["/d", "/c", `chcp 65001>nul & ${command}`],
|
|
68
|
+
shell: "cmd",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (kind === "bash") {
|
|
72
|
+
return {
|
|
73
|
+
file: "bash",
|
|
74
|
+
args: ["-lc", command],
|
|
75
|
+
shell: "bash",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
file: "powershell.exe",
|
|
80
|
+
args: [
|
|
81
|
+
"-NoProfile",
|
|
82
|
+
"-Command",
|
|
83
|
+
`[Console]::InputEncoding=[System.Text.Encoding]::UTF8; [Console]::OutputEncoding=[System.Text.Encoding]::UTF8; $OutputEncoding=[System.Text.Encoding]::UTF8; ${command}`,
|
|
84
|
+
],
|
|
85
|
+
shell: "powershell",
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (kind === "cmd") {
|
|
89
|
+
throw new Error("cmd shell is only available on Windows.");
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
file: "bash",
|
|
93
|
+
args: ["-lc", command],
|
|
94
|
+
shell: "bash",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|