appback-remoteagent 0.23.4 → 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 +11 -7
- package/dist/adapters/codex-adapter.js +14 -2
- package/dist/bot.js +40 -182
- package/dist/services/login-service.js +129 -0
- package/dist/services/provider-setup-service.js +4 -4
- package/dist/telegram-command-menu.js +1 -1
- package/docs/LOGIN.md +62 -0
- package/docs/RESPONSE_ROUTING.md +20 -0
- package/package.json +1 -1
- package/scripts/release-deploy.sh +30 -2
- package/scripts/selftest-codex-stream.mjs +7 -0
- package/scripts/selftest-login.mjs +52 -0
- package/scripts/selftest-telegram-update.mjs +68 -25
package/README.md
CHANGED
|
@@ -111,7 +111,7 @@ Current command surface implemented in `src/bot.ts`:
|
|
|
111
111
|
| `/sandbox [codex <mode>]` | Lists Codex sandbox choices or changes the current session sandbox |
|
|
112
112
|
| `/option retry <count>` | Sets the automatic continuation turn limit and persists it to `~/.remoteagent/.env` |
|
|
113
113
|
| `/option timeout <seconds>` | Sets the provider execution timeout and persists it to `~/.remoteagent/.env` |
|
|
114
|
-
| `/option intent <count>` |
|
|
114
|
+
| `/option intent <count>` | Retired compatibility command; explains that untagged replies are delivered without content-based retries. |
|
|
115
115
|
| `/state` | Shows the session ledger that is injected as provider context |
|
|
116
116
|
| `/state clear` | Clears the current session ledger without deleting the session |
|
|
117
117
|
| `/state note <text>` | Adds an operator note to the session ledger |
|
|
@@ -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
|
|
126
|
-
| `/login claude
|
|
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
|
-
-
|
|
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
|
-
- `
|
|
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";
|
|
@@ -32,7 +33,7 @@ const HELP_TEXT = [
|
|
|
32
33
|
"/stop",
|
|
33
34
|
"/sandbox codex <read-only|workspace-write|danger-full-access>",
|
|
34
35
|
"/status",
|
|
35
|
-
"/option [retry <count>|timeout <seconds>|
|
|
36
|
+
"/option [retry <count>|timeout <seconds>|reasoning <low|medium|high|xhigh|max>|command-menu <on|off|refresh>]",
|
|
36
37
|
"/state [clear|note <text>]",
|
|
37
38
|
"/artifacts list|cleanup <days>",
|
|
38
39
|
"/cleanup",
|
|
@@ -47,8 +48,7 @@ const HELP_TEXT = [
|
|
|
47
48
|
"/bot remove <username|id>",
|
|
48
49
|
"/bot reload",
|
|
49
50
|
"/install codex|claude",
|
|
50
|
-
"/login
|
|
51
|
-
"/login claude [token]",
|
|
51
|
+
"/login - choose GitHub, Codex or Claude",
|
|
52
52
|
"/reset",
|
|
53
53
|
"/! <command>",
|
|
54
54
|
"/!cmd <command>",
|
|
@@ -649,7 +649,6 @@ ${bridge.formatStatus(mapping)}`);
|
|
|
649
649
|
actionButton(ctx, "Timeout", { kind: "option.show", option: "timeout" }),
|
|
650
650
|
],
|
|
651
651
|
[
|
|
652
|
-
actionButton(ctx, "Intent", { kind: "option.show", option: "intent" }),
|
|
653
652
|
actionButton(ctx, "Reasoning", { kind: "option.show", option: "reasoning" }),
|
|
654
653
|
actionButton(ctx, "Command menu", { kind: "option.show", option: "command-menu" }),
|
|
655
654
|
],
|
|
@@ -657,9 +656,11 @@ ${bridge.formatStatus(mapping)}`);
|
|
|
657
656
|
return;
|
|
658
657
|
}
|
|
659
658
|
if (option !== "retry" && option !== "timeout" && option !== "intent" && option !== "reasoning" && option !== "command-menu") {
|
|
660
|
-
await reply(ctx,
|
|
661
|
-
|
|
662
|
-
|
|
659
|
+
await reply(ctx, formatRuntimeOptions());
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
if (option === "intent") {
|
|
663
|
+
await reply(ctx, "The intent option has been retired. Untagged replies are delivered without content-based retries.");
|
|
663
664
|
return;
|
|
664
665
|
}
|
|
665
666
|
if (!value) {
|
|
@@ -708,9 +709,7 @@ ${bridge.formatStatus(mapping)}`);
|
|
|
708
709
|
if (!Number.isInteger(parsed) || parsed < 0 || String(parsed) !== value.trim()) {
|
|
709
710
|
await reply(ctx, option === "retry"
|
|
710
711
|
? "Invalid retry count. Use `0` or a positive integer, for example `/option retry 6`."
|
|
711
|
-
: option
|
|
712
|
-
? "Invalid intent retry count. Use `0` or a positive integer, for example `/option intent 4`."
|
|
713
|
-
: "Invalid timeout. Use seconds as a positive integer, for example `/option timeout 600`.", {
|
|
712
|
+
: "Invalid timeout. Use seconds as a positive integer, for example `/option timeout 600`.", {
|
|
714
713
|
parse_mode: "Markdown",
|
|
715
714
|
});
|
|
716
715
|
return;
|
|
@@ -722,13 +721,6 @@ ${bridge.formatStatus(mapping)}`);
|
|
|
722
721
|
await reply(ctx, `Set automatic continuation retry limit to ${formatRetryLimit(parsed)}.\n\nSaved: TELEGRAM_AUTO_PROGRESS_MAX_TURNS=${parsed}`);
|
|
723
722
|
return;
|
|
724
723
|
}
|
|
725
|
-
if (option === "intent") {
|
|
726
|
-
config.telegramUntaggedIntentRetries = parsed;
|
|
727
|
-
await upsertInstalledEnvValue("TELEGRAM_UNTAGGED_INTENT_RETRIES", String(parsed));
|
|
728
|
-
await bridge.logSystem(botId, chatId, `Runtime option TELEGRAM_UNTAGGED_INTENT_RETRIES set to ${parsed}.`);
|
|
729
|
-
await reply(ctx, `Set untagged intent retry limit to ${formatRetryLimit(parsed)}.\n\nSaved: TELEGRAM_UNTAGGED_INTENT_RETRIES=${parsed}`);
|
|
730
|
-
return;
|
|
731
|
-
}
|
|
732
724
|
if (parsed < 10) {
|
|
733
725
|
await reply(ctx, "Invalid timeout. Use at least 10 seconds, for example `/option timeout 600`.", {
|
|
734
726
|
parse_mode: "Markdown",
|
|
@@ -1042,29 +1034,38 @@ ${bridge.formatStatus(mapping)}`);
|
|
|
1042
1034
|
return { chunks: flattenChunks([result.output], 3900) };
|
|
1043
1035
|
});
|
|
1044
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
|
+
};
|
|
1045
1047
|
bot.command("login", async (ctx) => {
|
|
1046
1048
|
await ensureOwnerControlAccess(ctx);
|
|
1047
1049
|
const { args, rest } = parseCommand(ctx.message?.text, 1);
|
|
1048
1050
|
const provider = args[0]?.toLowerCase();
|
|
1049
|
-
if (provider
|
|
1050
|
-
await
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
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
|
+
]));
|
|
1055
1057
|
return;
|
|
1056
1058
|
}
|
|
1057
|
-
if (provider
|
|
1058
|
-
await reply(ctx,
|
|
1059
|
+
if (provider === "claude" && rest?.trim()) {
|
|
1060
|
+
await reply(ctx, await setupService.finishClaudeLogin(rest));
|
|
1059
1061
|
return;
|
|
1060
1062
|
}
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
});
|
|
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);
|
|
1068
1069
|
});
|
|
1069
1070
|
const setChatSandbox = async (ctx, sandboxMode) => {
|
|
1070
1071
|
if (!ctx.chat) {
|
|
@@ -1145,6 +1146,10 @@ ${bridge.formatStatus(mapping)}`);
|
|
|
1145
1146
|
text: action.kind === "macro.run" ? "Macro selected." : "Applying...",
|
|
1146
1147
|
});
|
|
1147
1148
|
try {
|
|
1149
|
+
if (action.kind === "login.start") {
|
|
1150
|
+
await startLogin(ctx, action.target, action.force);
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1148
1153
|
if (action.kind === "session.switch") {
|
|
1149
1154
|
await reply(ctx, await switchChatSession(ctx, action.selector));
|
|
1150
1155
|
return;
|
|
@@ -1639,11 +1644,8 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
|
|
|
1639
1644
|
const emptyResponseRetries = config.telegramEmptyResponseRetries;
|
|
1640
1645
|
const retryableErrorRetries = config.telegramRetryableErrorRetries;
|
|
1641
1646
|
const retryableErrorDelayMs = config.telegramRetryableErrorDelayMs;
|
|
1642
|
-
const untaggedIntentRetries = config.telegramUntaggedIntentRetries;
|
|
1643
1647
|
let emptyResponseRetryCount = 0;
|
|
1644
1648
|
let retryableErrorCount = 0;
|
|
1645
|
-
let untaggedIntentRetryCount = 0;
|
|
1646
|
-
let missingEvidenceRetryCount = 0;
|
|
1647
1649
|
let deliveredProgressCount = 0;
|
|
1648
1650
|
let providerCompleted = false;
|
|
1649
1651
|
const streamedProgressKeys = new Set();
|
|
@@ -1705,23 +1707,12 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
|
|
|
1705
1707
|
emptyResponseRetryCount = 0;
|
|
1706
1708
|
retryableErrorCount = 0;
|
|
1707
1709
|
if (parsed.kind === "progress") {
|
|
1708
|
-
untaggedIntentRetryCount = 0;
|
|
1709
|
-
missingEvidenceRetryCount = 0;
|
|
1710
1710
|
const key = progressDeliveryKey(parsed.chunks);
|
|
1711
1711
|
if (!streamedProgressKeys.has(key)) {
|
|
1712
1712
|
streamedProgressKeys.add(key);
|
|
1713
1713
|
deliveredProgressCount += 1;
|
|
1714
1714
|
if (currentSession) {
|
|
1715
|
-
|
|
1716
|
-
if (progress.repeated) {
|
|
1717
|
-
const repeatedMessage = [
|
|
1718
|
-
"Repeated progress detected. The same work pattern has appeared 3 or more times.",
|
|
1719
|
-
"Automatic continuation stopped so the task can be inspected instead of looping.",
|
|
1720
|
-
].join("\n");
|
|
1721
|
-
await bridge.logSystem(botId, chatId, repeatedMessage);
|
|
1722
|
-
autoContinue.clear(botId, chatId, sessionId);
|
|
1723
|
-
return [repeatedMessage];
|
|
1724
|
-
}
|
|
1715
|
+
await memoryService.recordProgress(currentSession.session, parsed.chunks.join("\n"));
|
|
1725
1716
|
}
|
|
1726
1717
|
await ensureStillBound(`${turnLabel} progress delivery`);
|
|
1727
1718
|
await helpers.reportProgress(parsed.chunks);
|
|
@@ -1736,26 +1727,6 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
|
|
|
1736
1727
|
continue;
|
|
1737
1728
|
}
|
|
1738
1729
|
if (parsed.kind === "result") {
|
|
1739
|
-
untaggedIntentRetryCount = 0;
|
|
1740
|
-
const resultText = parsed.chunks.join("\n");
|
|
1741
|
-
const evidenceIssue = classifyMissingResultEvidence(resultText);
|
|
1742
|
-
if (evidenceIssue && missingEvidenceRetryCount < 1) {
|
|
1743
|
-
missingEvidenceRetryCount += 1;
|
|
1744
|
-
const retryMessage = `${turnLabel} returned a result without required evidence: ${evidenceIssue}`;
|
|
1745
|
-
await bridge.logSystem(botId, chatId, retryMessage);
|
|
1746
|
-
prompt = appendManagedContext(formatMissingEvidenceRetryPrompt(resultText, evidenceIssue), managedContext);
|
|
1747
|
-
continue;
|
|
1748
|
-
}
|
|
1749
|
-
if (evidenceIssue) {
|
|
1750
|
-
const blockedMessage = [
|
|
1751
|
-
"Provider reported a completed result without concrete evidence after a retry.",
|
|
1752
|
-
`Reason: ${evidenceIssue}`,
|
|
1753
|
-
"Automatic continuation stopped so the work is not accepted on an unsupported claim.",
|
|
1754
|
-
].join("\n");
|
|
1755
|
-
await bridge.logSystem(botId, chatId, blockedMessage);
|
|
1756
|
-
autoContinue.clear(botId, chatId, sessionId);
|
|
1757
|
-
return [blockedMessage];
|
|
1758
|
-
}
|
|
1759
1730
|
await ensureStillBound(`${turnLabel} final delivery`);
|
|
1760
1731
|
if (currentSession) {
|
|
1761
1732
|
await memoryService.completeTask(currentSession.session, parsed.chunks.join("\n"));
|
|
@@ -1765,19 +1736,10 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
|
|
|
1765
1736
|
return parsed.chunks;
|
|
1766
1737
|
}
|
|
1767
1738
|
if (parsed.kind === "blocked") {
|
|
1768
|
-
untaggedIntentRetryCount = 0;
|
|
1769
|
-
missingEvidenceRetryCount = 0;
|
|
1770
1739
|
await ensureStillBound(`${turnLabel} final delivery`);
|
|
1771
1740
|
autoContinue.clear(botId, chatId, sessionId);
|
|
1772
1741
|
return parsed.chunks;
|
|
1773
1742
|
}
|
|
1774
|
-
if (looksLikeUntaggedIntentOnlyResponse(parsed.chunks.join("\n")) && untaggedIntentRetryCount < untaggedIntentRetries) {
|
|
1775
|
-
untaggedIntentRetryCount += 1;
|
|
1776
|
-
const retryMessage = `${turnLabel} returned an untagged intent-only response; asking provider to do concrete work before replying.`;
|
|
1777
|
-
await bridge.logSystem(botId, chatId, retryMessage);
|
|
1778
|
-
prompt = appendManagedContext(formatUntaggedIntentRetryPrompt(parsed.chunks.join("\n")), managedContext);
|
|
1779
|
-
continue;
|
|
1780
|
-
}
|
|
1781
1743
|
await bridge.logSystem(botId, chatId, `${turnLabel} returned an untagged response; treating it as final output.`);
|
|
1782
1744
|
autoContinue.clear(botId, chatId, sessionId);
|
|
1783
1745
|
return parsed.chunks;
|
|
@@ -1966,11 +1928,8 @@ function parseReportResponses(formattedBlocks, transform) {
|
|
|
1966
1928
|
const header = lines.slice(0, reportLineIndex).join("\n").trim();
|
|
1967
1929
|
const reportLine = lines[reportLineIndex].trim();
|
|
1968
1930
|
const match = /^REPORT:(progress|result|blocked)$/i.exec(reportLine);
|
|
1969
|
-
|
|
1931
|
+
const kind = match?.[1]?.toLowerCase() ?? "unknown";
|
|
1970
1932
|
const body = lines.slice(reportLineIndex + 1).join("\n").trim();
|
|
1971
|
-
if ((kind === "progress" || kind === "result") && looksLikeBlockedBody(body)) {
|
|
1972
|
-
kind = "blocked";
|
|
1973
|
-
}
|
|
1974
1933
|
return {
|
|
1975
1934
|
kind,
|
|
1976
1935
|
text: body ? [header, body].filter(Boolean).join("\n") : "",
|
|
@@ -2066,107 +2025,9 @@ function escapeTelegramHtml(value) {
|
|
|
2066
2025
|
function escapeTelegramHtmlAttribute(value) {
|
|
2067
2026
|
return escapeTelegramHtml(value).replace(/"/g, """);
|
|
2068
2027
|
}
|
|
2069
|
-
function looksLikeUntaggedIntentOnlyResponse(text) {
|
|
2070
|
-
const normalized = text.trim();
|
|
2071
|
-
if (!normalized) {
|
|
2072
|
-
return false;
|
|
2073
|
-
}
|
|
2074
|
-
const hasConcreteEvidence = [
|
|
2075
|
-
/REPORT:/i,
|
|
2076
|
-
/(완료|통과|실패|확인 결과|검증 결과|원인|근거|수정했습니다|배포했습니다|커밋|푸시)/,
|
|
2077
|
-
/\b(git status|git diff|npm run|node --check|docker|journalctl|grep|rg)\b/i,
|
|
2078
|
-
/`[^`]+`/,
|
|
2079
|
-
/:\d{1,5}\b/,
|
|
2080
|
-
].some((pattern) => pattern.test(normalized));
|
|
2081
|
-
if (hasConcreteEvidence) {
|
|
2082
|
-
return false;
|
|
2083
|
-
}
|
|
2084
|
-
return [
|
|
2085
|
-
/(하겠습니다|진행하겠습니다|확인하겠습니다|수정하겠습니다|검증하겠습니다|대조하겠습니다|보겠습니다)/,
|
|
2086
|
-
/(진행해서|확인해서|수정해서|검증해서).*(하겠습니다|진행하겠습니다)/,
|
|
2087
|
-
/\b(I will|I'll|I am going to|going to|will continue|will check|will verify)\b/i,
|
|
2088
|
-
].some((pattern) => pattern.test(normalized));
|
|
2089
|
-
}
|
|
2090
|
-
function formatUntaggedIntentRetryPrompt(lastResponse) {
|
|
2091
|
-
return [
|
|
2092
|
-
"The previous response did not follow the REPORT protocol and only stated intent without concrete evidence.",
|
|
2093
|
-
"Do not repeat the plan or say what you will do.",
|
|
2094
|
-
"Do concrete work now before replying again.",
|
|
2095
|
-
"Reply with exactly one first line: REPORT:progress, REPORT:result, or REPORT:blocked.",
|
|
2096
|
-
"If you cannot continue, use REPORT:blocked and state the exact blocker.",
|
|
2097
|
-
"",
|
|
2098
|
-
"Previous invalid response:",
|
|
2099
|
-
lastResponse.trim(),
|
|
2100
|
-
].join("\n");
|
|
2101
|
-
}
|
|
2102
|
-
function classifyMissingResultEvidence(text) {
|
|
2103
|
-
const normalized = text.trim();
|
|
2104
|
-
if (!normalized) {
|
|
2105
|
-
return undefined;
|
|
2106
|
-
}
|
|
2107
|
-
if (!looksLikeCompletedWorkClaim(normalized)) {
|
|
2108
|
-
return undefined;
|
|
2109
|
-
}
|
|
2110
|
-
if (hasConcreteResultEvidence(normalized)) {
|
|
2111
|
-
return undefined;
|
|
2112
|
-
}
|
|
2113
|
-
return "REPORT:result claims completed work but does not include concrete evidence.";
|
|
2114
|
-
}
|
|
2115
|
-
function looksLikeCompletedWorkClaim(text) {
|
|
2116
|
-
return [
|
|
2117
|
-
/(수정|반영|배포|커밋|푸시|전송|생성|삭제|추가|적용|구현|저장|업데이트|등록|제거|정리|마이그레이션|검증|테스트|빌드).{0,24}(완료|했습니다|됐습니다|성공|통과)/,
|
|
2118
|
-
/(완료했습니다|완료됐습니다|끝났습니다|처리했습니다)/,
|
|
2119
|
-
/\b(fixed|implemented|deployed|committed|pushed|sent|created|deleted|updated|added|removed|migrated|verified|passed|completed|built)\b/i,
|
|
2120
|
-
].some((pattern) => pattern.test(text));
|
|
2121
|
-
}
|
|
2122
|
-
function hasConcreteResultEvidence(text) {
|
|
2123
|
-
return [
|
|
2124
|
-
/```/,
|
|
2125
|
-
/`[^`]+`/,
|
|
2126
|
-
/\b[0-9a-f]{7,40}\b/i,
|
|
2127
|
-
/sha256:[0-9a-f]{20,}/i,
|
|
2128
|
-
/\b(HTTP\s+\d{3}|exit\s+\d+|active|passed|failed)\b/i,
|
|
2129
|
-
/\b(npm run|git status|git diff|node --check|docker|journalctl|curl|psql|grep|rg|bash)\b/i,
|
|
2130
|
-
/\/[A-Za-z0-9._/-]{3,}/,
|
|
2131
|
-
/\b[A-Za-z0-9._/-]+\.(?:js|ts|tsx|jsx|sql|md|json|yml|yaml|sh|py|css|html|txt|log)\b/,
|
|
2132
|
-
/:\d{1,5}\b/,
|
|
2133
|
-
/(근거|검증|변경 파일|커밋|푸시|배포|로그|명령|출력|파일|라인|경로|상태)\s*:/,
|
|
2134
|
-
].some((pattern) => pattern.test(text));
|
|
2135
|
-
}
|
|
2136
|
-
function formatMissingEvidenceRetryPrompt(lastResponse, issue) {
|
|
2137
|
-
return [
|
|
2138
|
-
"The previous REPORT:result was not accepted by RemoteAgent.",
|
|
2139
|
-
issue,
|
|
2140
|
-
"RemoteAgent does not inspect code or decide whether the work is correct.",
|
|
2141
|
-
"You, the provider, must either provide concrete evidence for the completed work or change the reply to REPORT:progress or REPORT:blocked.",
|
|
2142
|
-
"Do not repeat a bare completion claim.",
|
|
2143
|
-
"Reply with exactly one first line: REPORT:progress, REPORT:result, or REPORT:blocked.",
|
|
2144
|
-
"",
|
|
2145
|
-
"Accepted evidence examples: file paths, line references, commands and outputs, log paths, commit IDs, image digests, deployment status, or explicit verification output.",
|
|
2146
|
-
"",
|
|
2147
|
-
"Previous unsupported result:",
|
|
2148
|
-
lastResponse.trim(),
|
|
2149
|
-
].join("\n");
|
|
2150
|
-
}
|
|
2151
2028
|
function isEmptyResponseError(message) {
|
|
2152
2029
|
return /empty response|failed without any output|without stdout\/stderr/i.test(message);
|
|
2153
2030
|
}
|
|
2154
|
-
function looksLikeBlockedBody(text) {
|
|
2155
|
-
if (!text.trim()) {
|
|
2156
|
-
return false;
|
|
2157
|
-
}
|
|
2158
|
-
const blockedPatterns = [
|
|
2159
|
-
/\b(sudo|usermod|setfacl|chmod|chown|relogin|re-login|new login session)\b/i,
|
|
2160
|
-
/\b(waiting on|need you to|you need to|please run|please do|manual step|admin step|external fix)\b/i,
|
|
2161
|
-
/\b(permission denied|permission change|ssh access|api key|login required|authentication required)\b/i,
|
|
2162
|
-
/적용되면.*(다시|이어서|계속)/i,
|
|
2163
|
-
/해주시면.*(다시|이어서|계속)/i,
|
|
2164
|
-
/권한.*(필요|없)/i,
|
|
2165
|
-
/로그인 세션.*필요/i,
|
|
2166
|
-
/관리자.*조치/i,
|
|
2167
|
-
];
|
|
2168
|
-
return blockedPatterns.some((pattern) => pattern.test(text));
|
|
2169
|
-
}
|
|
2170
2031
|
function classifyRetryableProviderIssue(message, retryAfterMs) {
|
|
2171
2032
|
if (/selected model is at capacity/i.test(message)) {
|
|
2172
2033
|
return { kind: "capacity", retryAfterMs };
|
|
@@ -2531,19 +2392,16 @@ function formatRuntimeOptions() {
|
|
|
2531
2392
|
"Runtime options",
|
|
2532
2393
|
`- retry: ${formatRetryLimit(config.telegramAutoProgressMaxTurns)} (TELEGRAM_AUTO_PROGRESS_MAX_TURNS)`,
|
|
2533
2394
|
`- timeout: ${formatTimeoutSeconds(config.commandTimeoutMs)} (COMMAND_TIMEOUT_MS)`,
|
|
2534
|
-
`- intent: ${formatRetryLimit(config.telegramUntaggedIntentRetries)} (TELEGRAM_UNTAGGED_INTENT_RETRIES)`,
|
|
2535
2395
|
`- reasoning: ${getAstraReasoning()} (Astra, server-wide, CODEX_REASONING_EFFORT)`,
|
|
2536
2396
|
`- command-menu: ${config.telegramCommandMenuEnabled ? "on" : "off"} (TELEGRAM_COMMAND_MENU_ENABLED)`,
|
|
2537
2397
|
"",
|
|
2538
2398
|
"Usage:",
|
|
2539
2399
|
"/option retry <count>",
|
|
2540
2400
|
"/option timeout <seconds>",
|
|
2541
|
-
"/option intent <count>",
|
|
2542
2401
|
"/option reasoning <low|medium|high|xhigh|max>",
|
|
2543
2402
|
"/option command-menu <on|off|refresh>",
|
|
2544
2403
|
"",
|
|
2545
2404
|
"`retry 0` disables the automatic continuation limit.",
|
|
2546
|
-
"`intent 0` disables untagged intent-only response retries.",
|
|
2547
2405
|
"`command-menu refresh` reapplies Telegram slash-command autocomplete without changing the saved option.",
|
|
2548
2406
|
].join("\n");
|
|
2549
2407
|
}
|
|
@@ -2555,7 +2413,7 @@ function formatRuntimeOptionDetail(option) {
|
|
|
2555
2413
|
return `Current automatic continuation retry limit: ${formatRetryLimit(config.telegramAutoProgressMaxTurns)}\n\nUsage: \`/option retry <count>\``;
|
|
2556
2414
|
}
|
|
2557
2415
|
if (option === "intent") {
|
|
2558
|
-
return
|
|
2416
|
+
return "The intent option has been retired. Untagged replies are delivered without content-based retries.";
|
|
2559
2417
|
}
|
|
2560
2418
|
if (option === "command-menu") {
|
|
2561
2419
|
return `Current Telegram command menu: ${config.telegramCommandMenuEnabled ? "on" : "off"}\n\nUsage: \`/option command-menu on\`, \`/option command-menu off\`, or \`/option command-menu refresh\``;
|
|
@@ -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
|
|
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.
|
|
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
|
|
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.
|
|
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: "
|
|
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.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Provider response routing
|
|
2
|
+
|
|
3
|
+
RemoteAgent uses the explicit first-line REPORT marker to route provider replies.
|
|
4
|
+
|
|
5
|
+
- `REPORT:progress`: forward progress and continue within the configured automatic continuation limit.
|
|
6
|
+
- `REPORT:result`: forward the final reply and end the turn.
|
|
7
|
+
- `REPORT:blocked`: forward the reply and end the turn.
|
|
8
|
+
- Missing marker: forward the reply as final output.
|
|
9
|
+
|
|
10
|
+
The response body is not used to decide whether work happened, whether evidence is adequate, or whether the provider needs permission. Evidence remains a provider reporting instruction, not a delivery gate. Recording a final reply does not certify project completion.
|
|
11
|
+
|
|
12
|
+
Repeated progress text is deduplicated for delivery. It does not override the explicit status. The configured continuation limit, user stop requests, and session binding checks control further executions.
|
|
13
|
+
|
|
14
|
+
The retired `/option intent` command returns a compatibility notice. Existing `TELEGRAM_UNTAGGED_INTENT_RETRIES` values no longer trigger executions.
|
|
15
|
+
|
|
16
|
+
## Verification
|
|
17
|
+
|
|
18
|
+
Run `npm run selftest:telegram` to exercise S091 requirement corrections and negations, final replies without evidence, untagged replies, explicit progress and blocked states, queued instruction removal, timeout handling, and stopping an active progress turn.
|
|
19
|
+
|
|
20
|
+
The S091 incident was caused by body regexes matching both "정리했습니다" and "완료 보고가 아닙니다" as completion claims. Those classifiers and their corrective retry prompts have been removed rather than expanded with word exceptions.
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|
|
@@ -109,6 +109,8 @@ if (persistedBotIdentity.username !== "appbackadmin_bot") {
|
|
|
109
109
|
|
|
110
110
|
const providerCalls = [];
|
|
111
111
|
let providerMode = "success";
|
|
112
|
+
let literalResponse = "";
|
|
113
|
+
let explicitResponses = [];
|
|
112
114
|
let untaggedIntentCalls = 0;
|
|
113
115
|
let missingEvidenceCalls = 0;
|
|
114
116
|
let streamingFinalProgressCalls = 0;
|
|
@@ -136,6 +138,9 @@ const provider = {
|
|
|
136
138
|
output: "REPORT:result\nusage fallback completed with evidence: `fallback-test.log`",
|
|
137
139
|
};
|
|
138
140
|
}
|
|
141
|
+
if (providerMode === "literal-result") {
|
|
142
|
+
return {provider: "codex", sessionId: request.sessionId || "mock-thread", cwd: request.cwd, output: literalResponse};
|
|
143
|
+
}
|
|
139
144
|
if (providerMode === "untagged-intent") {
|
|
140
145
|
untaggedIntentCalls += 1;
|
|
141
146
|
return {
|
|
@@ -160,7 +165,10 @@ const provider = {
|
|
|
160
165
|
: "REPORT:result\n수정 완료했습니다.\n\n근거:\n- 변경 파일: `src/example.ts`\n- 검증: `npm run check` 통과",
|
|
161
166
|
};
|
|
162
167
|
}
|
|
163
|
-
if (providerMode === "
|
|
168
|
+
if (providerMode === "explicit-status") {
|
|
169
|
+
return {provider: "codex", sessionId: request.sessionId || "mock-thread", cwd: request.cwd, output: explicitResponses.shift() ?? "REPORT:result\nunexpected extra call"};
|
|
170
|
+
}
|
|
171
|
+
if (providerMode === "queue-hold" || providerMode === "stop-hold") {
|
|
164
172
|
queueHoldStartedResolve?.();
|
|
165
173
|
await queueHoldReleasePromise;
|
|
166
174
|
return {
|
|
@@ -168,7 +176,7 @@ const provider = {
|
|
|
168
176
|
sessionId: request.sessionId || "mock-thread",
|
|
169
177
|
publicSessionId: request.publicSessionId,
|
|
170
178
|
cwd: request.cwd,
|
|
171
|
-
output: "REPORT:result\nactive queue test completed",
|
|
179
|
+
output: providerMode === "stop-hold" ? "REPORT:progress\nstop regression progress" : "REPORT:result\nactive queue test completed",
|
|
172
180
|
};
|
|
173
181
|
}
|
|
174
182
|
if (providerMode === "streaming-progress") {
|
|
@@ -359,8 +367,8 @@ if (!/^TELEGRAM_AUTO_PROGRESS_MAX_TURNS=6$/m.test(envText)) {
|
|
|
359
367
|
if (!/^COMMAND_TIMEOUT_MS=600000$/m.test(envText)) {
|
|
360
368
|
throw new Error(`Option command did not persist command timeout to .env: ${envText}`);
|
|
361
369
|
}
|
|
362
|
-
if (
|
|
363
|
-
throw new Error(
|
|
370
|
+
if (/^TELEGRAM_UNTAGGED_INTENT_RETRIES=/m.test(envText)) {
|
|
371
|
+
throw new Error("Retired intent option should not persist configuration");
|
|
364
372
|
}
|
|
365
373
|
|
|
366
374
|
const importedSecretDataDir = path.join(tmp, "imported-secret-data");
|
|
@@ -509,7 +517,7 @@ if (!calls.some((call) => call.method === "sendMessage" && /Set automatic contin
|
|
|
509
517
|
if (!calls.some((call) => call.method === "sendMessage" && /Set provider execution timeout to 600s/.test(call.text))) {
|
|
510
518
|
throw new Error(`Did not see option timeout acknowledgement. Calls: ${JSON.stringify(calls, null, 2)}`);
|
|
511
519
|
}
|
|
512
|
-
if (!calls.some((call) => call.method === "sendMessage" && /
|
|
520
|
+
if (!calls.some((call) => call.method === "sendMessage" && /intent option has been retired/.test(call.text))) {
|
|
513
521
|
throw new Error(`Did not see option intent acknowledgement. Calls: ${JSON.stringify(calls, null, 2)}`);
|
|
514
522
|
}
|
|
515
523
|
if (!calls.some((call) => call.method === "sendMessage" && /Workspace cleanup finished for S001/.test(call.text))) {
|
|
@@ -519,6 +527,14 @@ if (calls.some((call) => /미완료 TODO|\/task|새 작업으로 접수/.test(ca
|
|
|
519
527
|
throw new Error(`Task gate language leaked to Telegram replies. Calls: ${JSON.stringify(calls, null, 2)}`);
|
|
520
528
|
}
|
|
521
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
|
+
|
|
522
538
|
await send("/new");
|
|
523
539
|
await send("/list");
|
|
524
540
|
const sessionListCall = await waitForTelegramCall((call) => call.text.includes("Sessions (2/2)"));
|
|
@@ -725,14 +741,8 @@ const untaggedCalls = (await fs.readFile(telegramCalls, "utf8"))
|
|
|
725
741
|
text: Buffer.from(textB64, "base64").toString("utf8"),
|
|
726
742
|
};
|
|
727
743
|
});
|
|
728
|
-
if (untaggedIntentCalls !==
|
|
729
|
-
throw new Error(
|
|
730
|
-
}
|
|
731
|
-
if (!untaggedCalls.some((call) => /untagged intent recovered/.test(call.text))) {
|
|
732
|
-
throw new Error(`Did not see recovered result after untagged intent retry. Calls: ${JSON.stringify(untaggedCalls, null, 2)}`);
|
|
733
|
-
}
|
|
734
|
-
if (untaggedCalls.some((call) => call.method === "sendMessage" && /^계속 진행해서 확인하겠습니다\.$/.test(call.text.trim()))) {
|
|
735
|
-
throw new Error(`Untagged intent-only response leaked as final Telegram message. Calls: ${JSON.stringify(untaggedCalls, null, 2)}`);
|
|
744
|
+
if (untaggedIntentCalls !== 1 || !untaggedCalls.some(call => call.text.includes("계속 진행해서 확인하겠습니다."))) {
|
|
745
|
+
throw new Error("Untagged response was not delivered in one execution");
|
|
736
746
|
}
|
|
737
747
|
|
|
738
748
|
providerMode = "missing-evidence";
|
|
@@ -752,17 +762,23 @@ const evidenceCalls = (await fs.readFile(telegramCalls, "utf8"))
|
|
|
752
762
|
text: Buffer.from(textB64, "base64").toString("utf8"),
|
|
753
763
|
};
|
|
754
764
|
});
|
|
755
|
-
if (missingEvidenceCalls !==
|
|
756
|
-
throw new Error(
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
765
|
+
if (missingEvidenceCalls !== 1 || !evidenceCalls.some(call => call.text.includes("수정 완료했습니다."))) {
|
|
766
|
+
throw new Error("Final response was not delivered in one execution");
|
|
767
|
+
}
|
|
768
|
+
for (const body of [
|
|
769
|
+
"플레이스미션을 누락했고, 표시 방식도 잘못 정리했습니다.\n‘더보기 목록’이 아니라 기존 넘기기 UI를 재사용하는 요구로 정정합니다.",
|
|
770
|
+
"앞선 답변은 요구사항 정정이며, 코드 수정·검증·병합 완료 보고가 아닙니다. 이번 정정으로 변경한 파일이나 커밋은 없습니다.",
|
|
771
|
+
"sudo 권한이 필요하지 않습니다. API key 변경도 없습니다.",
|
|
772
|
+
]) {
|
|
773
|
+
providerMode = "literal-result";
|
|
774
|
+
literalResponse = "REPORT:result\n" + body;
|
|
775
|
+
const countBefore = providerCalls.length;
|
|
776
|
+
await send("/batch start");
|
|
777
|
+
await send("S091 regression");
|
|
778
|
+
await send("/batch send");
|
|
779
|
+
if (providerCalls.length !== countBefore + 1) throw new Error("Result triggered extra provider execution");
|
|
780
|
+
const delivered = await readTelegramCalls();
|
|
781
|
+
if (!delivered.some(call => call.text.includes(body))) throw new Error("Result body not delivered");
|
|
766
782
|
}
|
|
767
783
|
|
|
768
784
|
providerMode = "streaming-progress";
|
|
@@ -859,6 +875,33 @@ if (providerCalls.length !== queueProviderCallsBefore + 1) {
|
|
|
859
875
|
throw new Error(`Removed queued instructions reached the provider: ${providerCalls.length - queueProviderCallsBefore} calls`);
|
|
860
876
|
}
|
|
861
877
|
|
|
878
|
+
providerMode = "explicit-status";
|
|
879
|
+
explicitResponses = ["REPORT:progress\nsudo 권한 변경은 필요 없습니다. 다음 단계를 진행합니다.", "REPORT:result\nexplicit continuation finished"];
|
|
880
|
+
const explicitBefore = providerCalls.length;
|
|
881
|
+
await send("/batch start");
|
|
882
|
+
await send("explicit status regression");
|
|
883
|
+
await send("/batch send");
|
|
884
|
+
if (providerCalls.length !== explicitBefore + 2) throw new Error("Explicit progress was overridden by body words");
|
|
885
|
+
explicitResponses = ["REPORT:blocked\n명시적으로 중단합니다."];
|
|
886
|
+
const blockedBefore = providerCalls.length;
|
|
887
|
+
await send("/batch start");
|
|
888
|
+
await send("explicit blocked regression");
|
|
889
|
+
await send("/batch send");
|
|
890
|
+
if (providerCalls.length !== blockedBefore + 1) throw new Error("Explicit blocked response retried");
|
|
891
|
+
|
|
892
|
+
providerMode = "stop-hold";
|
|
893
|
+
queueHoldStartedPromise = new Promise(resolve => { queueHoldStartedResolve = resolve; });
|
|
894
|
+
queueHoldReleasePromise = new Promise(resolve => { queueHoldReleaseResolve = resolve; });
|
|
895
|
+
const stopBefore = providerCalls.length;
|
|
896
|
+
await send("/batch start");
|
|
897
|
+
await send("stop regression");
|
|
898
|
+
const stoppedRun = send("/batch send");
|
|
899
|
+
await queueHoldStartedPromise;
|
|
900
|
+
await send("/stop");
|
|
901
|
+
queueHoldReleaseResolve();
|
|
902
|
+
await stoppedRun;
|
|
903
|
+
if (providerCalls.length !== stopBefore + 1) throw new Error("Stop allowed automatic continuation");
|
|
904
|
+
|
|
862
905
|
console.log(JSON.stringify({
|
|
863
906
|
ok: true,
|
|
864
907
|
dataDir,
|
|
@@ -867,7 +910,7 @@ console.log(JSON.stringify({
|
|
|
867
910
|
recoveredTodoItems: recoveredActive.length,
|
|
868
911
|
retryOption: 6,
|
|
869
912
|
timeoutOptionMs: 600000,
|
|
870
|
-
|
|
913
|
+
intentOptionRetired: true,
|
|
871
914
|
providerCalls: providerCalls.length,
|
|
872
915
|
untaggedIntentCalls,
|
|
873
916
|
missingEvidenceCalls,
|