appback-remoteagent 0.23.5 → 0.23.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -122,8 +122,8 @@ Current command surface implemented in `src/bot.ts`:
122
122
  | `/bot remove <username\|id>` | Removes a configured Telegram bot, restarts the runtime, and confirms the result after restart |
123
123
  | `/bot reload` | Restarts the runtime and confirms the result after restart |
124
124
  | `/install codex\|claude` | Runs the configured provider install or update command for the bot owner |
125
- | `/login codex` | Starts the Codex device-auth login flow and returns a browser URL when available |
126
- | `/login claude [token]` | Starts or finishes the configured Claude Code login flow for the bot owner |
125
+ | `/login` | Owner-only GitHub / Codex / Claude selection buttons; returns authentication URL and any device code, then reports completion |
126
+ | `/login github\|codex\|claude` | Starts the selected login directly; `git` is an alias for `github` |
127
127
  | `/reset` | Clears the current chat binding |
128
128
  | `/batch start` | Starts manual batching of multiple text messages |
129
129
  | `/batch send` | Sends the collected batch |
@@ -186,7 +186,7 @@ Current Claude behavior:
186
186
  - fresh pairing from Telegram
187
187
  - attach to existing `session_id`
188
188
  - continue the same Claude Code session across turns
189
- - optional owner-only install/login flow through `/install claude` and `/login claude [token]`
189
+ - owner-only installation through `/install claude` and browser authentication through `/login` Claude
190
190
 
191
191
  ### 5. Telegram attachments
192
192
 
@@ -302,8 +302,7 @@ Recommended Linux hooks in this repo:
302
302
  - `CLAUDE_COMMAND`
303
303
  - `CLAUDE_PERMISSION_MODE`
304
304
  - `CLAUDE_INSTALL_COMMAND`
305
- - `CLAUDE_LOGIN_START_COMMAND`
306
- - `CLAUDE_LOGIN_FINISH_COMMAND`
305
+ - `CLAUDE_LOGIN_FINISH_COMMAND` (legacy `/login claude <token>` compatibility only)
307
306
  - `REMOTEAGENT_SERVICE_NAME`
308
307
  - `BOT_RESTART_HELPER_PATH`
309
308
  - `LOCAL_UI_ENABLED`
@@ -387,15 +386,20 @@ Then open Telegram and start with one of these common flows. `/start` without a
387
386
  /start claude
388
387
  /install codex
389
388
  /install claude
389
+ /login
390
+ /login github
390
391
  /login codex
391
392
  /login claude
392
- /login claude <token>
393
393
  /attach codex <thread_id>
394
394
  /attach claude <session_id>
395
395
  ```
396
396
 
397
397
  Once a chat is bound, ordinary text messages continue the active session. Supported attachments can also be sent directly as normal Telegram messages.
398
398
 
399
+ `/login` authenticates the server's RemoteAgent OS account without creating a new
400
+ session or changing its model. Already authenticated accounts offer a **Log in
401
+ again** button. See [Login guide](docs/LOGIN.md) for prerequisites and expiry.
402
+
399
403
  ## Architecture and operations
400
404
 
401
405
  High-level architecture: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
@@ -194,16 +194,28 @@ export class CodexAdapter {
194
194
  formatProcessError(stdout, stderr, timedOut = false, code) {
195
195
  const structured = this.extractStructuredError(stdout, stderr);
196
196
  if (structured) {
197
- return structured;
197
+ return this.addUpgradeGuidance(structured);
198
198
  }
199
199
  const text = this.extractPlainTextError(stdout, stderr);
200
200
  if (text) {
201
- return text;
201
+ return this.addUpgradeGuidance(text);
202
202
  }
203
203
  return timedOut
204
204
  ? this.formatTimeoutError()
205
205
  : `Codex process exited with code ${code ?? "unknown"} without stdout/stderr.`;
206
206
  }
207
+ addUpgradeGuidance(message) {
208
+ if (!/requires a newer version of Codex/i.test(message)) {
209
+ return message;
210
+ }
211
+ return [
212
+ message,
213
+ "",
214
+ "현재 Codex 버전이 선택한 모델을 지원하지 않습니다.",
215
+ "Telegram에서 /install codex 를 실행하면 최신 버전으로 업데이트됩니다.",
216
+ "업데이트가 완료되면 같은 세션에서 요청을 다시 보내 주세요.",
217
+ ].join("\n");
218
+ }
207
219
  extractStructuredError(stdout, stderr) {
208
220
  const messages = [];
209
221
  for (const line of stdout.split(/\r?\n/)) {
package/dist/bot.js CHANGED
@@ -9,6 +9,7 @@ import { promisify } from "node:util";
9
9
  import { Bot, GrammyError, HttpError } from "grammy";
10
10
  import { config } from "./config.js";
11
11
  import { ProviderSetupService } from "./services/provider-setup-service.js";
12
+ import { LoginService } from "./services/login-service.js";
12
13
  import { RemoteShellService } from "./services/remote-shell-service.js";
13
14
  import { AgentMemoryService } from "./services/agent-memory-service.js";
14
15
  import { WorkspaceCleanupService } from "./services/workspace-cleanup-service.js";
@@ -47,8 +48,7 @@ const HELP_TEXT = [
47
48
  "/bot remove <username|id>",
48
49
  "/bot reload",
49
50
  "/install codex|claude",
50
- "/login codex",
51
- "/login claude [token]",
51
+ "/login - choose GitHub, Codex or Claude",
52
52
  "/reset",
53
53
  "/! <command>",
54
54
  "/!cmd <command>",
@@ -1034,29 +1034,38 @@ ${bridge.formatStatus(mapping)}`);
1034
1034
  return { chunks: flattenChunks([result.output], 3900) };
1035
1035
  });
1036
1036
  });
1037
+ const loginService = new LoginService(15 * 60_000, { codex: config.codexBin, claude: config.claudeBin });
1038
+ const startLogin = async (ctx, target, force = false) => {
1039
+ await ensureOwnerControlAccess(ctx);
1040
+ if (!ctx.chat)
1041
+ throw new Error("Telegram chat context is missing.");
1042
+ const result = await loginService.start(target, force, async (text) => { await reply(ctx, text); });
1043
+ await reply(ctx, result.text, result.alreadyLoggedIn ? keyboardOptions([[
1044
+ actionButton(ctx, "Log in again", { kind: "login.start", target, force: true }),
1045
+ ]]) : undefined);
1046
+ };
1037
1047
  bot.command("login", async (ctx) => {
1038
1048
  await ensureOwnerControlAccess(ctx);
1039
1049
  const { args, rest } = parseCommand(ctx.message?.text, 1);
1040
1050
  const provider = args[0]?.toLowerCase();
1041
- if (provider === "codex") {
1042
- await runWithPendingAnimation(token, ctx.chat.id, async () => {
1043
- const output = await setupService.startCodexLogin();
1044
- await bridge.rememberDefaultStartMode("codex");
1045
- return { chunks: flattenChunks([output], 3900) };
1046
- });
1051
+ if (!provider) {
1052
+ await reply(ctx, "Choose an account to authenticate on this server. Authentication belongs to the OS account, not an individual session.", keyboardOptions([
1053
+ [actionButton(ctx, "GitHub", { kind: "login.start", target: "github" })],
1054
+ [actionButton(ctx, "Codex", { kind: "login.start", target: "codex" })],
1055
+ [actionButton(ctx, "Claude", { kind: "login.start", target: "claude" })],
1056
+ ]));
1047
1057
  return;
1048
1058
  }
1049
- if (provider !== "claude") {
1050
- await reply(ctx, "Usage: `/login codex` or `/login claude` or `/login claude <token>`", { parse_mode: "Markdown" });
1059
+ if (provider === "claude" && rest?.trim()) {
1060
+ await reply(ctx, await setupService.finishClaudeLogin(rest));
1051
1061
  return;
1052
1062
  }
1053
- await runWithPendingAnimation(token, ctx.chat.id, async () => {
1054
- const output = rest?.trim()
1055
- ? await setupService.finishClaudeLogin(rest)
1056
- : await setupService.startClaudeLogin();
1057
- await bridge.rememberDefaultStartMode("claude");
1058
- return { chunks: flattenChunks([output], 3900) };
1059
- });
1063
+ const target = provider === "git" ? "github" : provider;
1064
+ if (target !== "github" && target !== "codex" && target !== "claude") {
1065
+ await reply(ctx, "Use /login to choose GitHub, Codex or Claude. Direct commands: /login github, /login codex, /login claude. /login git is a GitHub alias.");
1066
+ return;
1067
+ }
1068
+ await startLogin(ctx, target);
1060
1069
  });
1061
1070
  const setChatSandbox = async (ctx, sandboxMode) => {
1062
1071
  if (!ctx.chat) {
@@ -1137,6 +1146,10 @@ ${bridge.formatStatus(mapping)}`);
1137
1146
  text: action.kind === "macro.run" ? "Macro selected." : "Applying...",
1138
1147
  });
1139
1148
  try {
1149
+ if (action.kind === "login.start") {
1150
+ await startLogin(ctx, action.target, action.force);
1151
+ return;
1152
+ }
1140
1153
  if (action.kind === "session.switch") {
1141
1154
  await reply(ctx, await switchChatSession(ctx, action.selector));
1142
1155
  return;
@@ -0,0 +1,129 @@
1
+ import { spawn } from "node:child_process";
2
+ import os from "node:os";
3
+ import { buildProviderEnv } from "../adapters/runtime-env.js";
4
+ const commands = {
5
+ github: { bin: "gh", status: ["auth", "status", "--hostname", "github.com"], login: ["auth", "login", "--hostname", "github.com", "--git-protocol", "https", "--web"] },
6
+ codex: { bin: "codex", status: ["login", "status"], login: ["login", "--device-auth"] },
7
+ claude: { bin: "claude", status: ["auth", "status"], login: ["auth", "login"] },
8
+ };
9
+ // Shared across Telegram bots: authentication belongs to the OS account, not a chat.
10
+ const active = new Set();
11
+ export function loginHints(raw) {
12
+ const text = raw.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
13
+ const urls = [...new Set(text.match(/https?:\/\/[^\s<>"\x1b]+/g) ?? [])];
14
+ if (!urls.length)
15
+ return undefined;
16
+ const code = text.match(/(?:one[- ]time|device) code[^\n]*?\b([A-Z0-9]{4}-[A-Z0-9]{4,5})\b/i)?.[1]
17
+ ?? text.match(/\b([A-Z0-9]{4}-[A-Z0-9]{4,5})\b/)?.[1];
18
+ return [...urls, ...(code ? [`One-time code: ${code}`] : [])].join("\n");
19
+ }
20
+ export class LoginService {
21
+ lifetimeMs;
22
+ binaries;
23
+ constructor(lifetimeMs = 15 * 60_000, binaries = {}) {
24
+ this.lifetimeMs = lifetimeMs;
25
+ this.binaries = binaries;
26
+ }
27
+ unavailable(target) {
28
+ const guidance = target === "github" ? "Install GitHub CLI (gh) on this server."
29
+ : `Run /install ${target} first.`;
30
+ return `${commands[target].bin} could not start. ${guidance} If installed, check execution permissions.`;
31
+ }
32
+ async status(target) {
33
+ const command = commands[target];
34
+ return new Promise((resolve, reject) => {
35
+ const child = spawn(this.binaries[target] ?? command.bin, command.status, { cwd: os.homedir(), env: buildProviderEnv({}) });
36
+ let output = "";
37
+ const timer = setTimeout(() => child.kill("SIGKILL"), 10_000);
38
+ child.stdout.on("data", chunk => { output = (output + chunk).slice(-16_384); });
39
+ child.stderr.resume();
40
+ child.stdin.end();
41
+ child.on("error", () => { clearTimeout(timer); reject(new Error(this.unavailable(target))); });
42
+ child.on("close", code => {
43
+ clearTimeout(timer);
44
+ if (target === "claude") {
45
+ try {
46
+ resolve(code === 0 && JSON.parse(output).loggedIn === true);
47
+ }
48
+ catch {
49
+ resolve(false);
50
+ }
51
+ }
52
+ else
53
+ resolve(code === 0);
54
+ });
55
+ });
56
+ }
57
+ async start(target, force, notify) {
58
+ if (active.has(target))
59
+ return { alreadyLoggedIn: false, text: `${target} login is already in progress on this machine.` };
60
+ active.add(target);
61
+ try {
62
+ if (!force && await this.status(target)) {
63
+ active.delete(target);
64
+ return { alreadyLoggedIn: true, text: `${target} is already authenticated for OS account ${os.userInfo().username}.` };
65
+ }
66
+ }
67
+ catch (error) {
68
+ active.delete(target);
69
+ throw error;
70
+ }
71
+ const command = commands[target];
72
+ return new Promise(resolve => {
73
+ const child = spawn(this.binaries[target] ?? command.bin, command.login, {
74
+ cwd: os.homedir(), env: buildProviderEnv({ BROWSER: "echo", GH_BROWSER: "echo" }),
75
+ });
76
+ const stopOnExit = () => { child.kill("SIGKILL"); };
77
+ process.once("exit", stopOnExit);
78
+ let raw = "";
79
+ let delivered = "";
80
+ let settled = false;
81
+ let expired = false;
82
+ let notifications = Promise.resolve();
83
+ const send = (text) => { notifications = notifications.then(() => notify(text)).catch(() => undefined); };
84
+ const finishStart = (text) => {
85
+ if (settled)
86
+ return;
87
+ settled = true;
88
+ clearTimeout(initialTimer);
89
+ resolve({ alreadyLoggedIn: false, text });
90
+ };
91
+ const initialTimer = setTimeout(() => finishStart(`${target} login is waiting for an authentication URL. Completion or expiry will be reported here.`), 20_000);
92
+ const expiryTimer = setTimeout(() => { expired = true; child.kill("SIGKILL"); }, this.lifetimeMs);
93
+ const consume = (chunk) => {
94
+ raw = (raw + chunk.toString()).slice(-32_768);
95
+ const hints = loginHints(raw);
96
+ if (hints && hints !== delivered) {
97
+ delivered = hints;
98
+ const message = `${target} login (${os.userInfo().username}@${os.hostname()})\n${hints}\nComplete authentication in your browser. Existing sessions remain unchanged.`;
99
+ if (settled)
100
+ send(message);
101
+ else
102
+ finishStart(message);
103
+ }
104
+ };
105
+ child.stdout.on("data", consume);
106
+ child.stderr.on("data", consume);
107
+ child.stdin.on("error", () => undefined);
108
+ child.stdin.end("\n");
109
+ child.on("error", () => finishStart(this.unavailable(target)));
110
+ child.on("close", async (code) => {
111
+ process.removeListener("exit", stopOnExit);
112
+ clearTimeout(expiryTimer);
113
+ try {
114
+ const authenticated = !expired && code === 0 && await this.status(target).catch(() => false);
115
+ const message = authenticated ? `${target} login completed and authentication verified.`
116
+ : expired ? `${target} login expired after ${Math.round(this.lifetimeMs / 60_000)} minutes. Run /login to retry.`
117
+ : `${target} login did not complete (exit=${code}). Authentication was not confirmed. Run /login to retry.`;
118
+ if (settled)
119
+ send(message);
120
+ else
121
+ finishStart(message);
122
+ }
123
+ finally {
124
+ active.delete(target);
125
+ }
126
+ });
127
+ });
128
+ }
129
+ }
@@ -141,13 +141,13 @@ export class ProviderSetupService {
141
141
  if (/not logged in/i.test(statusText)) {
142
142
  return [
143
143
  "Codex is installed but not logged in yet.",
144
- "Next step: run `/login codex` in this chat.",
144
+ "Next step: run /login and select Codex.",
145
145
  "If you prefer machine-side auth, you can use `codex login --device-auth` and complete the login in your browser.",
146
146
  ].join("\n");
147
147
  }
148
148
  }
149
149
  catch {
150
- return "Codex is installed. If this machine is not authenticated yet, run `/login codex` or use `codex login --device-auth` on the machine.";
150
+ return "Codex is installed. To authenticate this server account, run /login and select Codex.";
151
151
  }
152
152
  }
153
153
  if (provider === "claude") {
@@ -157,12 +157,12 @@ export class ProviderSetupService {
157
157
  if (/not logged in|loggedIn:\s*false/i.test(statusText)) {
158
158
  return [
159
159
  "Claude Code is installed but not logged in yet.",
160
- "Next step: run `/login claude` or complete the configured Claude login flow on this machine.",
160
+ "Next step: run /login and select Claude.",
161
161
  ].join("\n");
162
162
  }
163
163
  }
164
164
  catch {
165
- return "Claude Code is installed. If this machine is not authenticated yet, run `/login claude` or complete the configured login flow.";
165
+ return "Claude Code is installed. To authenticate this server account, run /login and select Claude.";
166
166
  }
167
167
  }
168
168
  return undefined;
@@ -24,7 +24,7 @@ export const TELEGRAM_COMMAND_MENU = [
24
24
  { command: "bots", description: "List configured Telegram bots" },
25
25
  { command: "bot", description: "Manage Telegram bots" },
26
26
  { command: "install", description: "Install or update Codex or Claude" },
27
- { command: "login", description: "Run provider login flow" },
27
+ { command: "login", description: "Choose GitHub, Codex or Claude login" },
28
28
  { command: "reset", description: "Clear this chat binding" },
29
29
  { command: "help", description: "Show command help" },
30
30
  ];
package/docs/LOGIN.md ADDED
@@ -0,0 +1,62 @@
1
+ # Server Account Login
2
+
3
+ Send `/login` in Telegram and select GitHub, Codex, or Claude.
4
+ `/help` lists this entry once. Telegram's slash-command menu offers `/login`;
5
+ provider names are chosen with buttons, not subcommand autocomplete.
6
+ The login applies to the RemoteAgent OS account on that server. Existing
7
+ Telegram sessions and their selected models remain unchanged.
8
+
9
+ Direct commands:
10
+
11
+ ```text
12
+ /login github
13
+ /login git
14
+ /login codex
15
+ /login claude
16
+ ```
17
+
18
+ GitHub uses `gh auth login --hostname github.com --git-protocol https --web`.
19
+ Codex uses `codex login --device-auth`.
20
+ Claude uses `claude auth login`.
21
+ Install the selected CLI on the server first. For providers, use
22
+ `/install codex` or `/install claude`; GitHub requires the GitHub CLI (`gh`).
23
+
24
+ An authenticated account gets a "Log in again" button. A new flow reports
25
+ URLs and one-time device codes emitted by the CLI, then checks authentication
26
+ after a successful process exit. Complete the browser flow from your PC.
27
+ One login per provider can run across all bots in the same RemoteAgent process.
28
+ Flows expire after 15 minutes. Restarting RemoteAgent interrupts the flow;
29
+ start `/login` again after a restart. Telegram delivery failure is not an
30
+ authentication failure: `/login` checks the account again.
31
+
32
+ Raw CLI output is not forwarded. Only authentication URLs, device codes, and
33
+ status messages are delivered. The legacy `/login claude <token>` command
34
+ continues to use its configured finish hook. The button flow uses the native
35
+ Claude CLI, not the old 15-second start hook.
36
+
37
+ Local regression tests:
38
+
39
+ ```sh
40
+ npm run build
41
+ node scripts/selftest-login.mjs
42
+ npm run selftest:telegram
43
+ ```
44
+
45
+ CLI references: https://cli.github.com/manual/gh_auth_login and
46
+ https://code.claude.com/docs/en/cli-reference.
47
+
48
+ ## Deploy to Server 50
49
+
50
+ After committing changes, bump the version with `npm run release:version -- patch`,
51
+ commit the version files, and push. Publish and deploy the exact version:
52
+
53
+ ```sh
54
+ npm run release:publish
55
+ npm run release:deploy -- 0.23.6 50
56
+ ```
57
+
58
+ The 50 target uses root SSH to run npm and RemoteAgent as `daone`. It checks
59
+ for active work, stops the runtime, updates the npm package, and restarts it
60
+ from the account home directory. Existing configuration, secrets, and sessions
61
+ remain in place. The existing npm launchers do not require installer regeneration.
62
+ The historical `all` target remains 30/40/26; select 50 explicitly.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appback-remoteagent",
3
- "version": "0.23.5",
3
+ "version": "0.23.6",
4
4
  "description": "Personal installable session server for continuing local AI work across PC and Telegram",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -2,7 +2,7 @@
2
2
  set -euo pipefail
3
3
 
4
4
  usage() {
5
- echo "Usage: npm run release:deploy -- <version> <30|40|26|all>" >&2
5
+ echo "Usage: npm run release:deploy -- <version> <30|40|26|50|all>" >&2
6
6
  echo "Example: npm run release:deploy -- 0.15.5 all" >&2
7
7
  }
8
8
 
@@ -20,7 +20,7 @@ if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
20
20
  fi
21
21
 
22
22
  case "$TARGET" in
23
- 30|40|26|all)
23
+ 30|40|26|50|all)
24
24
  ;;
25
25
  *)
26
26
  usage
@@ -192,6 +192,31 @@ fi
192
192
  REMOTE
193
193
  }
194
194
 
195
+ deploy_50() {
196
+ ssh root@192.168.33.50 "runuser -u daone -- env VERSION=$VERSION PATH=/home/daone/.nvm/versions/node/v22.23.2/bin:/usr/local/bin:/usr/bin:/bin bash -s" <<'REMOTE'
197
+ set -euo pipefail
198
+ cd "$HOME"
199
+ node --input-type=module - <<'NODE'
200
+ import fs from 'node:fs';
201
+ const state = JSON.parse(fs.readFileSync(`${process.env.HOME}/.remoteagent/bot-polling-state.json`, 'utf8'));
202
+ if (Object.values(state.bots || {}).some(bot => bot.runningSessionIds?.length)) {
203
+ throw new Error('Active provider work; deployment aborted.');
204
+ }
205
+ NODE
206
+ remoteagent-stop
207
+ trap 'remoteagent-start' EXIT
208
+ npm install -g "appback-remoteagent@$VERSION"
209
+ test "$(node -p 'require(process.env.HOME + "/.nvm/versions/node/v22.23.2/lib/node_modules/appback-remoteagent/package.json").version')" = "$VERSION"
210
+ # Existing npm installation keeps the same launcher and configuration paths.
211
+ remoteagent-start
212
+ trap - EXIT
213
+ sleep 5
214
+ kill -0 "$(cat "$HOME/.remoteagent/remoteagent.pid")"
215
+ npm list -g appback-remoteagent --depth=0
216
+ tail -n 12 "$HOME/.remoteagent/logs/agent.log"
217
+ REMOTE
218
+ }
219
+
195
220
  case "$TARGET" in
196
221
  30)
197
222
  deploy_30
@@ -202,6 +227,9 @@ case "$TARGET" in
202
227
  26)
203
228
  deploy_26
204
229
  ;;
230
+ 50)
231
+ deploy_50
232
+ ;;
205
233
  all)
206
234
  deploy_30
207
235
  deploy_40
@@ -34,6 +34,13 @@ await fs.chmod(fakeCodex, 0o755);
34
34
 
35
35
  const { CodexAdapter } = await import(path.join(root, "dist", "adapters", "codex-adapter.js"));
36
36
  const adapter = new CodexAdapter(fakeCodex, 5000, "read-only");
37
+ const upgradeError = "The 'gpt-6-astra' model requires a newer version of Codex. Please upgrade to the latest app or CLI and try again.";
38
+ for (const raw of [upgradeError, JSON.stringify({type: "error", status: 400, error: {type: "invalid_request_error", message: upgradeError}})]) {
39
+ const formatted = adapter.formatProcessError(raw, "", false, 1);
40
+ if (!formatted.includes(upgradeError) || !formatted.includes("/install codex")) throw new Error("Missing Codex upgrade guidance");
41
+ }
42
+ const unrelatedError = "This request was blocked by our safety systems. Reason: Potentially unintended activity.";
43
+ if (adapter.formatProcessError(unrelatedError, "", false, 1) !== unrelatedError) throw new Error("Unrelated error was changed");
37
44
  for (const method of ["buildExecArgs", "buildResumeArgs"]) {
38
45
  for (const reasoningEffort of ["low", "medium", "high", "xhigh", "max"]) {
39
46
  const args = adapter[method]({model: "gpt-6-astra", reasoningEffort, cwd: tmp, sessionId: "stream-thread"}, path.join(tmp, "output"), "read-only");
@@ -0,0 +1,52 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { LoginService, loginHints } from '../dist/services/login-service.js';
6
+
7
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ra-login-test-'));
8
+ const bin = path.join(dir, 'fake-cli');
9
+ const state = path.join(dir, 'authenticated');
10
+ const notices = [];
11
+ const waitFor = async predicate => {
12
+ for (let i = 0; i < 100; i++) {
13
+ if (predicate()) return;
14
+ await new Promise(resolve => setTimeout(resolve, 50));
15
+ }
16
+ throw new Error('Timed out waiting for test condition');
17
+ };
18
+ try {
19
+ await fs.writeFile(bin, `#!${process.execPath}
20
+ const fs = require('node:fs');
21
+ if (process.argv.includes('status')) {
22
+ console.log(JSON.stringify({loggedIn:fs.existsSync(${JSON.stringify(state)})}));
23
+ process.exit(fs.existsSync(${JSON.stringify(state)}) ? 0 : 1);
24
+ }
25
+ console.log('First copy your one-time code: ABCD-EFGH');
26
+ console.log('https://github.com/login/device');
27
+ console.log('ACCESS_TOKEN_MUST_NOT_LEAK');
28
+ setTimeout(() => {fs.writeFileSync(${JSON.stringify(state)}, 'ok'); process.exit(0);}, 400);
29
+ `, { mode: 0o700 });
30
+ const binaries = { github: bin, codex: bin, claude: bin };
31
+ const service = new LoginService(4000, binaries);
32
+ const notify = async text => { notices.push(text); };
33
+ const first = service.start('github', false, notify);
34
+ assert.match((await new LoginService(4000, binaries).start('github', false, notify)).text, /already in progress/);
35
+ assert.match((await first).text, /ABCD-EFGH/);
36
+ await waitFor(() => notices.some(text => text.includes('verified')));
37
+ assert.equal((await service.start('github', false, notify)).alreadyLoggedIn, true);
38
+ notices.length = 0;
39
+ assert.match((await service.start('github', true, notify)).text, /login\/device/);
40
+ await waitFor(() => notices.some(text => text.includes('verified')));
41
+ assert.ok(notices.every(text => !text.includes('ACCESS_TOKEN_MUST_NOT_LEAK')));
42
+ await fs.unlink(state);
43
+ notices.length = 0;
44
+ await new LoginService(100, binaries).start('claude', false, notify);
45
+ await waitFor(() => notices.some(text => text.includes('expired')));
46
+ await assert.rejects(new LoginService(100, { codex: path.join(dir, 'missing') }).start('codex', false, notify), /could not start/);
47
+ assert.equal(loginHints('secret token only'), undefined);
48
+ assert.match(loginHints('https://auth.openai.com/codex/device\nABCD-EFGHI'), /ABCD-EFGHI/);
49
+ console.log('PASS login: URL/code, status, cross-bot lock, reauthentication, completion, expiry, missing CLI, output filtering');
50
+ } finally {
51
+ await fs.rm(dir, { recursive: true, force: true });
52
+ }
@@ -527,6 +527,14 @@ if (calls.some((call) => /미완료 TODO|\/task|새 작업으로 접수/.test(ca
527
527
  throw new Error(`Task gate language leaked to Telegram replies. Calls: ${JSON.stringify(calls, null, 2)}`);
528
528
  }
529
529
 
530
+ await send("/login");
531
+ const loginMenu = await waitForTelegramCall(call => call.text.includes("Choose an account to authenticate"));
532
+ for (const label of ["GitHub", "Codex", "Claude"]) {
533
+ if (!findInlineButton(loginMenu, label)?.callback_data?.startsWith("remoteagent:action:")) {
534
+ throw new Error(`Missing login button: ${label}`);
535
+ }
536
+ }
537
+
530
538
  await send("/new");
531
539
  await send("/list");
532
540
  const sessionListCall = await waitForTelegramCall((call) => call.text.includes("Sessions (2/2)"));