telecodex 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -1
- package/dist/bot/auth.js +62 -13
- package/dist/bot/commandSupport.js +20 -7
- package/dist/bot/createBot.js +0 -1
- package/dist/bot/handlers/operationalHandlers.js +52 -0
- package/dist/bot/handlers/sessionConfigHandlers.js +27 -6
- package/dist/cli.js +0 -0
- package/dist/runtime/bindingCodes.js +5 -0
- package/dist/runtime/bootstrap.js +15 -10
- package/dist/store/sessions.js +108 -3
- package/dist/telegram/renderer.js +40 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -73,6 +73,30 @@ npm run dev
|
|
|
73
73
|
For a production-style local install during development, `npm link` exposes the
|
|
74
74
|
same `telecodex` command globally from the current checkout.
|
|
75
75
|
|
|
76
|
+
## Automated npm release
|
|
77
|
+
|
|
78
|
+
This repository is set up for npm trusted publishing from GitHub Actions.
|
|
79
|
+
|
|
80
|
+
1. On npm, open the `telecodex` package settings and configure a trusted publisher:
|
|
81
|
+
- Organization or user: `jiangege`
|
|
82
|
+
- Repository: `telecodex`
|
|
83
|
+
- Workflow filename: `publish.yml`
|
|
84
|
+
2. Bump the version locally:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
npm version patch
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
3. Push the branch and tag:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
git push origin main --follow-tags
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Pushing a `v*` tag runs `.github/workflows/publish.yml`, which installs dependencies,
|
|
97
|
+
runs `npm run check`, runs `npm test`, and publishes the package to npm when the tag
|
|
98
|
+
matches the version in `package.json`.
|
|
99
|
+
|
|
76
100
|
## First launch
|
|
77
101
|
|
|
78
102
|
On first launch, `telecodex`:
|
|
@@ -83,6 +107,8 @@ On first launch, `telecodex`:
|
|
|
83
107
|
4. Generates a one-time bootstrap code if no Telegram admin is bound yet.
|
|
84
108
|
5. Waits for that code in a private chat. The first successful sender becomes the permanent admin for this bot instance.
|
|
85
109
|
|
|
110
|
+
Bootstrap codes are time-limited and attempt-limited. When a code expires or is exhausted, start telecodex again locally to issue a fresh one.
|
|
111
|
+
|
|
86
112
|
There are no required environment variables in the normal startup path.
|
|
87
113
|
|
|
88
114
|
Optional security override:
|
|
@@ -116,6 +142,9 @@ Optional security override:
|
|
|
116
142
|
|
|
117
143
|
- `/start` or `/help` - show the current usage model.
|
|
118
144
|
- `/status` - in private chat shows global state; in a project topic shows project/thread runtime state.
|
|
145
|
+
- `/admin` - in private chat, show admin binding and handoff status.
|
|
146
|
+
- `/admin rebind` - in private chat, issue a time-limited handoff code for transferring control to another Telegram account.
|
|
147
|
+
- `/admin cancel` - in private chat, cancel a pending admin handoff code.
|
|
119
148
|
- `/project` - show the current supergroup's project binding.
|
|
120
149
|
- `/project bind <absolute-path>` - bind the current supergroup to a project root.
|
|
121
150
|
- `/project unbind` - remove the current supergroup's project binding.
|
|
@@ -134,7 +163,7 @@ Optional security override:
|
|
|
134
163
|
- `/web default|disabled|cached|live` - set Codex SDK web search mode.
|
|
135
164
|
- `/network on|off` - set workspace network access for Codex SDK runs.
|
|
136
165
|
- `/gitcheck skip|enforce` - control Codex SDK git repository checks.
|
|
137
|
-
- `/adddir list|add <absolute-path>|drop <index>|clear` - manage Codex SDK additional directories.
|
|
166
|
+
- `/adddir list|add <path-inside-project>|add-external <absolute-path>|drop <index>|clear` - manage Codex SDK additional directories. `add` stays inside the project root; `add-external` is the explicit escape hatch.
|
|
138
167
|
- `/schema show|set <JSON object>|clear` - manage Codex SDK output schema for the current topic.
|
|
139
168
|
- `/codexconfig show|set <JSON object>|clear` - manage global non-auth Codex SDK config overrides for future runs.
|
|
140
169
|
- Image messages in a topic - download the Telegram image locally and send it as SDK `local_image` input.
|
|
@@ -144,6 +173,7 @@ Optional security override:
|
|
|
144
173
|
- Long polling is managed by `@grammyjs/runner`.
|
|
145
174
|
- Streaming updates are throttled before editing Telegram messages.
|
|
146
175
|
- Final answers are rendered from Markdown to Telegram-safe HTML.
|
|
176
|
+
- Project-scoped path checks resolve symlinks before enforcing the root boundary, so topic cwd changes cannot escape the bound project through symlink paths.
|
|
147
177
|
- Because the SDK run is in-process, a telecodex restart cannot resume a partially streamed Telegram turn; the topic is reset and the user is asked to resend.
|
|
148
178
|
- Authentication and provider switching remain owned by the local Codex installation; telecodex does not manage API keys or login state.
|
|
149
179
|
- Interactive terminal stdin bridging and native Codex approval UI are intentionally not part of the Telegram contract. For unattended remote work, use the topic's sandbox/approval preset deliberately.
|
package/dist/bot/auth.js
CHANGED
|
@@ -15,11 +15,30 @@ export function authMiddleware(input) {
|
|
|
15
15
|
return;
|
|
16
16
|
}
|
|
17
17
|
const authorizedUserId = input.store.getAuthorizedUserId();
|
|
18
|
+
const binding = input.store.getBindingCodeState();
|
|
19
|
+
const messageText = ctx.message?.text?.trim();
|
|
18
20
|
if (authorizedUserId != null) {
|
|
19
21
|
if (authorizedUserId === userId) {
|
|
20
22
|
await next();
|
|
21
23
|
return;
|
|
22
24
|
}
|
|
25
|
+
if (ctx.chat?.type === "private" && binding?.mode === "rebind" && messageText) {
|
|
26
|
+
const handled = await handleBindingCodeMessage({
|
|
27
|
+
ctx,
|
|
28
|
+
userId,
|
|
29
|
+
text: messageText,
|
|
30
|
+
binding,
|
|
31
|
+
store: input.store,
|
|
32
|
+
success: async () => {
|
|
33
|
+
input.store.rebindAuthorizedUserId(userId);
|
|
34
|
+
await ctx.reply("Admin handoff succeeded. This Telegram account is now authorized to use telecodex.");
|
|
35
|
+
},
|
|
36
|
+
mismatchLabel: "Admin handoff code did not match.",
|
|
37
|
+
exhaustedLabel: "Admin handoff code exhausted its attempt limit and was invalidated. Issue a new one from the currently authorized account.",
|
|
38
|
+
});
|
|
39
|
+
if (handled)
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
23
42
|
input.logger?.warn("telegram update denied because user is not authorized", {
|
|
24
43
|
chatId: ctx.chat?.id ?? null,
|
|
25
44
|
chatType: ctx.chat?.type ?? null,
|
|
@@ -30,29 +49,59 @@ export function authMiddleware(input) {
|
|
|
30
49
|
await deny(ctx, "Unauthorized.");
|
|
31
50
|
return;
|
|
32
51
|
}
|
|
33
|
-
if (!
|
|
34
|
-
await deny(ctx, "
|
|
52
|
+
if (!binding || binding.mode !== "bootstrap") {
|
|
53
|
+
await deny(ctx, "This bot is not initialized yet, or the binding code expired. Restart telecodex locally to issue a new binding code.");
|
|
35
54
|
return;
|
|
36
55
|
}
|
|
37
56
|
if (ctx.chat?.type !== "private") {
|
|
38
57
|
await deny(ctx, "Send the admin bootstrap code to the bot in a private chat first.");
|
|
39
58
|
return;
|
|
40
59
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
60
|
+
if (messageText) {
|
|
61
|
+
const handled = await handleBindingCodeMessage({
|
|
62
|
+
ctx,
|
|
63
|
+
userId,
|
|
64
|
+
text: messageText,
|
|
65
|
+
binding,
|
|
66
|
+
store: input.store,
|
|
67
|
+
success: async () => {
|
|
68
|
+
const claimedUserId = input.store.claimAuthorizedUserId(userId);
|
|
69
|
+
if (claimedUserId === userId) {
|
|
70
|
+
input.onAdminBound?.(userId);
|
|
71
|
+
await ctx.reply("Admin binding succeeded. Only this Telegram account can use this bot from now on.");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
await deny(ctx, "An admin account has already claimed this bot.");
|
|
75
|
+
},
|
|
76
|
+
mismatchLabel: "Binding code did not match.",
|
|
77
|
+
exhaustedLabel: "Binding code exhausted its attempt limit and was invalidated. Restart telecodex locally to issue a new one.",
|
|
78
|
+
});
|
|
79
|
+
if (handled)
|
|
80
|
+
return;
|
|
52
81
|
}
|
|
53
82
|
await ctx.reply("This bot is not initialized yet. Send the binding code shown in the startup logs to complete the one-time admin binding.");
|
|
54
83
|
};
|
|
55
84
|
}
|
|
85
|
+
async function handleBindingCodeMessage(input) {
|
|
86
|
+
if (input.text === input.binding.code) {
|
|
87
|
+
await input.success();
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
if (input.text.startsWith("/")) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
const attempt = input.store.recordBindingCodeFailure();
|
|
94
|
+
if (!attempt) {
|
|
95
|
+
await input.ctx.reply("The binding code is no longer active. Issue a new one and try again.");
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
if (attempt.exhausted) {
|
|
99
|
+
await input.ctx.reply(input.exhaustedLabel);
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
await input.ctx.reply(`${input.mismatchLabel}\nRemaining attempts: ${attempt.remaining}`);
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
56
105
|
async function deny(ctx, text) {
|
|
57
106
|
if (ctx.callbackQuery) {
|
|
58
107
|
await ctx.answerCallbackQuery({ text, show_alert: false });
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { statSync } from "node:fs";
|
|
1
|
+
import { realpathSync, statSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { APPROVAL_POLICIES, MODE_PRESETS, REASONING_EFFORTS, SANDBOX_MODES, } from "../config.js";
|
|
4
4
|
import { makeSessionKey } from "../store/sessions.js";
|
|
@@ -56,6 +56,7 @@ export function formatHelpText(ctx, projects) {
|
|
|
56
56
|
"/queue drop <id>",
|
|
57
57
|
"/queue clear",
|
|
58
58
|
"/stop",
|
|
59
|
+
"/admin",
|
|
59
60
|
"",
|
|
60
61
|
formatPrivateProjectSummary(projects),
|
|
61
62
|
].join("\n");
|
|
@@ -97,15 +98,17 @@ export function formatHelpText(ctx, projects) {
|
|
|
97
98
|
"/web default|disabled|cached|live",
|
|
98
99
|
"/network on|off",
|
|
99
100
|
"/gitcheck skip|enforce",
|
|
100
|
-
"/adddir list|add|drop|clear",
|
|
101
|
+
"/adddir list|add|add-external|drop|clear",
|
|
101
102
|
"/schema show|set|clear",
|
|
102
103
|
"/codexconfig show|set|clear",
|
|
103
104
|
].join("\n");
|
|
104
105
|
}
|
|
105
106
|
export function formatPrivateStatus(store, projects) {
|
|
107
|
+
const binding = store.getBindingCodeState();
|
|
106
108
|
return [
|
|
107
109
|
"telecodex admin",
|
|
108
110
|
`authorized telegram user id: ${store.getAuthorizedUserId() ?? "not bound"}`,
|
|
111
|
+
binding?.mode === "rebind" ? `pending handoff: active until ${binding.expiresAt}` : "pending handoff: none",
|
|
109
112
|
"",
|
|
110
113
|
formatPrivateProjectSummary(projects),
|
|
111
114
|
].join("\n");
|
|
@@ -199,11 +202,12 @@ export function resolveExistingDirectory(input) {
|
|
|
199
202
|
if (!stat.isDirectory()) {
|
|
200
203
|
throw new Error(`Not a directory: ${resolved}`);
|
|
201
204
|
}
|
|
202
|
-
return resolved;
|
|
205
|
+
return canonicalizeBoundaryPath(resolved);
|
|
203
206
|
}
|
|
204
207
|
export function assertProjectScopedPath(input, projectRoot) {
|
|
205
|
-
const resolved =
|
|
206
|
-
|
|
208
|
+
const resolved = resolveExistingDirectory(input);
|
|
209
|
+
const canonicalRoot = resolveExistingDirectory(projectRoot);
|
|
210
|
+
if (!isPathWithinRoot(resolved, canonicalRoot)) {
|
|
207
211
|
throw new Error(["Path must stay within the project root.", `project root: ${projectRoot}`, `input: ${resolved}`].join("\n"));
|
|
208
212
|
}
|
|
209
213
|
return resolved;
|
|
@@ -233,7 +237,16 @@ export function hasTopicContext(ctx) {
|
|
|
233
237
|
return ctx.message?.message_thread_id != null || ctx.callbackQuery?.message?.message_thread_id != null;
|
|
234
238
|
}
|
|
235
239
|
export function isPathWithinRoot(candidate, root) {
|
|
236
|
-
const resolvedCandidate =
|
|
237
|
-
const resolvedRoot =
|
|
240
|
+
const resolvedCandidate = canonicalizeBoundaryPath(candidate);
|
|
241
|
+
const resolvedRoot = canonicalizeBoundaryPath(root);
|
|
238
242
|
return resolvedCandidate === resolvedRoot || resolvedCandidate.startsWith(`${resolvedRoot}${path.sep}`);
|
|
239
243
|
}
|
|
244
|
+
function canonicalizeBoundaryPath(input) {
|
|
245
|
+
const resolved = path.resolve(input);
|
|
246
|
+
try {
|
|
247
|
+
return realpathSync.native(resolved);
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return resolved;
|
|
251
|
+
}
|
|
252
|
+
}
|
package/dist/bot/createBot.js
CHANGED
|
@@ -8,7 +8,6 @@ export function wireBot(input) {
|
|
|
8
8
|
const { bot, config, store, projects, codex, bootstrapCode, logger, onAdminBound } = input;
|
|
9
9
|
const buffers = new MessageBuffer(bot, config.updateIntervalMs, logger?.child("message-buffer"));
|
|
10
10
|
bot.use(authMiddleware({
|
|
11
|
-
bootstrapCode,
|
|
12
11
|
store,
|
|
13
12
|
...(logger ? { logger: logger.child("auth") } : {}),
|
|
14
13
|
...(onAdminBound ? { onAdminBound } : {}),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { presetFromProfile } from "../../config.js";
|
|
2
|
+
import { generateBindingCode } from "../../runtime/bindingCodes.js";
|
|
2
3
|
import { formatSessionRuntimeStatus } from "../../runtime/sessionRuntime.js";
|
|
3
4
|
import { refreshSessionIfActiveTurnIsStale } from "../inputService.js";
|
|
4
5
|
import { contextLogFields, formatHelpText, formatPrivateStatus, formatProjectStatus, formatReasoningEffort, getProjectForContext, getScopedSession, hasTopicContext, isPrivateChat, parseSubcommand, } from "../commandSupport.js";
|
|
@@ -8,6 +9,57 @@ export function registerOperationalHandlers(deps) {
|
|
|
8
9
|
bot.command(["start", "help"], async (ctx) => {
|
|
9
10
|
await ctx.reply(formatHelpText(ctx, projects));
|
|
10
11
|
});
|
|
12
|
+
bot.command("admin", async (ctx) => {
|
|
13
|
+
if (!isPrivateChat(ctx)) {
|
|
14
|
+
await ctx.reply("Use /admin in the bot private chat.");
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const authorizedUserId = store.getAuthorizedUserId();
|
|
18
|
+
if (authorizedUserId == null) {
|
|
19
|
+
await ctx.reply("Admin binding is not completed yet.");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const { command } = parseSubcommand(ctx.match.trim());
|
|
23
|
+
const binding = store.getBindingCodeState();
|
|
24
|
+
if (!command || command === "status") {
|
|
25
|
+
await ctx.reply([
|
|
26
|
+
"Admin status",
|
|
27
|
+
`authorized telegram user id: ${authorizedUserId}`,
|
|
28
|
+
binding?.mode === "rebind"
|
|
29
|
+
? `pending handoff: active until ${formatIsoTimestamp(binding.expiresAt)} (${binding.maxAttempts - binding.attempts} attempts remaining)`
|
|
30
|
+
: "pending handoff: none",
|
|
31
|
+
"Usage: /admin | /admin rebind | /admin cancel",
|
|
32
|
+
].join("\n"));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (command === "rebind") {
|
|
36
|
+
const next = store.issueBindingCode({
|
|
37
|
+
code: generateBindingCode("rebind"),
|
|
38
|
+
mode: "rebind",
|
|
39
|
+
issuedByUserId: authorizedUserId,
|
|
40
|
+
});
|
|
41
|
+
await ctx.reply([
|
|
42
|
+
"Admin handoff code created.",
|
|
43
|
+
`expires at: ${formatIsoTimestamp(next.expiresAt)}`,
|
|
44
|
+
`max failed attempts: ${next.maxAttempts}`,
|
|
45
|
+
"",
|
|
46
|
+
next.code,
|
|
47
|
+
"",
|
|
48
|
+
"Send this code from the target Telegram account in this bot's private chat to transfer control.",
|
|
49
|
+
].join("\n"));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (command === "cancel") {
|
|
53
|
+
if (binding?.mode !== "rebind") {
|
|
54
|
+
await ctx.reply("No pending admin handoff.");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
store.clearBindingCode();
|
|
58
|
+
await ctx.reply("Cancelled the pending admin handoff.");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
await ctx.reply("Usage: /admin | /admin rebind | /admin cancel");
|
|
62
|
+
});
|
|
11
63
|
bot.command("status", async (ctx) => {
|
|
12
64
|
if (isPrivateChat(ctx)) {
|
|
13
65
|
await ctx.reply(formatPrivateStatus(store, projects));
|
|
@@ -177,28 +177,29 @@ export function registerSessionConfigHandlers(deps) {
|
|
|
177
177
|
await ctx.reply(`Set git repo check: ${value}`);
|
|
178
178
|
});
|
|
179
179
|
bot.command("adddir", async (ctx) => {
|
|
180
|
+
const project = getProjectForContext(ctx, projects);
|
|
180
181
|
const session = getScopedSession(ctx, store, projects, config);
|
|
181
|
-
if (!session)
|
|
182
|
+
if (!project || !session)
|
|
182
183
|
return;
|
|
183
184
|
const [command, ...rest] = ctx.match.trim().split(/\s+/).filter(Boolean);
|
|
184
185
|
const args = rest.join(" ");
|
|
185
186
|
if (!command || command === "list") {
|
|
186
187
|
await ctx.reply(session.additionalDirectories.length === 0
|
|
187
|
-
? "additional directories: none\nUsage: /adddir add <absolute-path> | /adddir drop <index> | /adddir clear"
|
|
188
|
+
? "additional directories: none\nUsage: /adddir add <path-inside-project> | /adddir add-external <absolute-path> | /adddir drop <index> | /adddir clear"
|
|
188
189
|
: [
|
|
189
190
|
"additional directories:",
|
|
190
191
|
...session.additionalDirectories.map((directory, index) => `${index + 1}. ${directory}`),
|
|
191
|
-
"Usage: /adddir add <absolute-path> | /adddir drop <index> | /adddir clear",
|
|
192
|
+
"Usage: /adddir add <path-inside-project> | /adddir add-external <absolute-path> | /adddir drop <index> | /adddir clear",
|
|
192
193
|
].join("\n"));
|
|
193
194
|
return;
|
|
194
195
|
}
|
|
195
196
|
if (command === "add") {
|
|
196
197
|
if (!args) {
|
|
197
|
-
await ctx.reply("Usage: /adddir add <
|
|
198
|
+
await ctx.reply("Usage: /adddir add <path-inside-project>");
|
|
198
199
|
return;
|
|
199
200
|
}
|
|
200
201
|
try {
|
|
201
|
-
const directory =
|
|
202
|
+
const directory = assertProjectScopedPath(args, project.cwd);
|
|
202
203
|
const next = [...session.additionalDirectories.filter((entry) => entry !== directory), directory];
|
|
203
204
|
store.setAdditionalDirectories(session.sessionKey, next);
|
|
204
205
|
await ctx.reply(`Added additional directory:\n${directory}`);
|
|
@@ -208,6 +209,26 @@ export function registerSessionConfigHandlers(deps) {
|
|
|
208
209
|
}
|
|
209
210
|
return;
|
|
210
211
|
}
|
|
212
|
+
if (command === "add-external") {
|
|
213
|
+
if (!args) {
|
|
214
|
+
await ctx.reply("Usage: /adddir add-external <absolute-path>");
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
const directory = resolveExistingDirectory(args);
|
|
219
|
+
const next = [...session.additionalDirectories.filter((entry) => entry !== directory), directory];
|
|
220
|
+
store.setAdditionalDirectories(session.sessionKey, next);
|
|
221
|
+
await ctx.reply([
|
|
222
|
+
"Added external additional directory outside the project root.",
|
|
223
|
+
directory,
|
|
224
|
+
"Codex can now read files there during future runs.",
|
|
225
|
+
].join("\n"));
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
await ctx.reply(error instanceof Error ? error.message : String(error));
|
|
229
|
+
}
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
211
232
|
if (command === "drop") {
|
|
212
233
|
const index = Number(args);
|
|
213
234
|
if (!Number.isInteger(index) || index <= 0 || index > session.additionalDirectories.length) {
|
|
@@ -224,7 +245,7 @@ export function registerSessionConfigHandlers(deps) {
|
|
|
224
245
|
await ctx.reply("Cleared additional directories.");
|
|
225
246
|
return;
|
|
226
247
|
}
|
|
227
|
-
await ctx.reply("Usage: /adddir list | /adddir add <absolute-path> | /adddir drop <index> | /adddir clear");
|
|
248
|
+
await ctx.reply("Usage: /adddir list | /adddir add <path-inside-project> | /adddir add-external <absolute-path> | /adddir drop <index> | /adddir clear");
|
|
228
249
|
});
|
|
229
250
|
bot.command("schema", async (ctx) => {
|
|
230
251
|
const session = getScopedSession(ctx, store, projects, config);
|
package/dist/cli.js
CHANGED
|
File without changes
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { cancel, confirm, intro, isCancel, note, password, spinner, text, } from "@clack/prompts";
|
|
2
2
|
import clipboard from "clipboardy";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
|
-
import { randomBytes } from "node:crypto";
|
|
5
4
|
import { existsSync } from "node:fs";
|
|
6
5
|
import path from "node:path";
|
|
7
6
|
import { Bot, GrammyError, HttpError } from "grammy";
|
|
8
7
|
import { buildConfig } from "../config.js";
|
|
9
8
|
import { openDatabase } from "../store/db.js";
|
|
10
9
|
import { ProjectStore } from "../store/projects.js";
|
|
11
|
-
import { SessionStore } from "../store/sessions.js";
|
|
10
|
+
import { BINDING_CODE_MAX_ATTEMPTS, SessionStore } from "../store/sessions.js";
|
|
12
11
|
import { getStateDbPath } from "./appPaths.js";
|
|
12
|
+
import { generateBindingCode } from "./bindingCodes.js";
|
|
13
13
|
import { PLAINTEXT_TOKEN_FALLBACK_ENV, SecretStore, } from "./secrets.js";
|
|
14
14
|
const MAC_CODEX_BIN = "/Applications/Codex.app/Contents/Resources/codex";
|
|
15
15
|
export async function bootstrapRuntime() {
|
|
@@ -35,19 +35,27 @@ export async function bootstrapRuntime() {
|
|
|
35
35
|
});
|
|
36
36
|
let bootstrapCode = null;
|
|
37
37
|
if (store.getAuthorizedUserId() == null) {
|
|
38
|
-
|
|
39
|
-
if (!
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
let binding = store.getBindingCodeState();
|
|
39
|
+
if (!binding || binding.mode !== "bootstrap") {
|
|
40
|
+
binding = store.issueBindingCode({
|
|
41
|
+
code: generateBindingCode("bootstrap"),
|
|
42
|
+
mode: "bootstrap",
|
|
43
|
+
});
|
|
42
44
|
}
|
|
45
|
+
bootstrapCode = binding.code;
|
|
46
|
+
}
|
|
47
|
+
else if (store.getBindingCodeState()?.mode === "bootstrap") {
|
|
48
|
+
store.clearBindingCode();
|
|
43
49
|
}
|
|
44
50
|
if (bootstrapCode) {
|
|
51
|
+
const binding = store.getBindingCodeState();
|
|
45
52
|
const copied = await copyBootstrapCode(bootstrapCode);
|
|
46
53
|
note([
|
|
47
54
|
`Bot: ${botUsername ? `@${botUsername}` : "unknown"}`,
|
|
48
55
|
`Workspace: ${config.defaultCwd}`,
|
|
49
56
|
copied ? "Binding code copied to the clipboard." : "Failed to copy the binding code. Copy it manually.",
|
|
50
|
-
|
|
57
|
+
`Binding code expires at: ${binding?.expiresAt ?? "unknown"}`,
|
|
58
|
+
`Max failed attempts: ${binding?.maxAttempts ?? BINDING_CODE_MAX_ATTEMPTS}`,
|
|
51
59
|
"",
|
|
52
60
|
bootstrapCode,
|
|
53
61
|
].join("\n"), "Admin Binding");
|
|
@@ -208,6 +216,3 @@ function exitCancelled() {
|
|
|
208
216
|
cancel("Cancelled");
|
|
209
217
|
process.exit(0);
|
|
210
218
|
}
|
|
211
|
-
function generateBootstrapCode() {
|
|
212
|
-
return `bind-${randomBytes(6).toString("base64url")}`;
|
|
213
|
-
}
|
package/dist/store/sessions.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { DEFAULT_SESSION_PROFILE, isSessionApprovalPolicy, isSessionReasoningEffort, isSessionSandboxMode, isSessionWebSearchMode, } from "../config.js";
|
|
2
|
+
export const BINDING_CODE_TTL_MS = 15 * 60 * 1000;
|
|
3
|
+
export const BINDING_CODE_MAX_ATTEMPTS = 5;
|
|
2
4
|
export class SessionStore {
|
|
3
5
|
db;
|
|
4
6
|
constructor(db) {
|
|
@@ -26,13 +28,96 @@ export class SessionStore {
|
|
|
26
28
|
return Number.isSafeInteger(userId) ? userId : null;
|
|
27
29
|
}
|
|
28
30
|
getBootstrapCode() {
|
|
29
|
-
|
|
31
|
+
const binding = this.getBindingCodeState();
|
|
32
|
+
return binding?.mode === "bootstrap" ? binding.code : null;
|
|
30
33
|
}
|
|
31
34
|
setBootstrapCode(code) {
|
|
32
|
-
this.
|
|
35
|
+
this.issueBindingCode({
|
|
36
|
+
code,
|
|
37
|
+
mode: "bootstrap",
|
|
38
|
+
});
|
|
33
39
|
}
|
|
34
40
|
clearBootstrapCode() {
|
|
41
|
+
this.clearBindingCode();
|
|
42
|
+
}
|
|
43
|
+
getBindingCodeState(now = new Date()) {
|
|
44
|
+
const code = this.getAppState("bootstrap_code");
|
|
45
|
+
const createdAt = this.getAppState("binding_code_created_at");
|
|
46
|
+
const expiresAt = this.getAppState("binding_code_expires_at");
|
|
47
|
+
if (!code || !createdAt || !expiresAt)
|
|
48
|
+
return null;
|
|
49
|
+
const expiresAtMs = Date.parse(expiresAt);
|
|
50
|
+
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= now.getTime()) {
|
|
51
|
+
this.clearBindingCode();
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const attempts = normalizeNonNegativeInteger(this.getAppState("binding_code_attempts"));
|
|
55
|
+
if (attempts >= BINDING_CODE_MAX_ATTEMPTS) {
|
|
56
|
+
this.clearBindingCode();
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
code,
|
|
61
|
+
mode: normalizeBindingCodeMode(this.getAppState("binding_code_mode")),
|
|
62
|
+
createdAt,
|
|
63
|
+
expiresAt,
|
|
64
|
+
attempts,
|
|
65
|
+
maxAttempts: BINDING_CODE_MAX_ATTEMPTS,
|
|
66
|
+
issuedByUserId: normalizeOptionalUserId(this.getAppState("binding_code_issued_by_user_id")),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
issueBindingCode(input) {
|
|
70
|
+
const now = input.now ?? new Date();
|
|
71
|
+
const createdAt = now.toISOString();
|
|
72
|
+
const expiresAt = new Date(now.getTime() + (input.ttlMs ?? BINDING_CODE_TTL_MS)).toISOString();
|
|
73
|
+
this.setAppState("bootstrap_code", input.code);
|
|
74
|
+
this.setAppState("binding_code_mode", input.mode);
|
|
75
|
+
this.setAppState("binding_code_created_at", createdAt);
|
|
76
|
+
this.setAppState("binding_code_expires_at", expiresAt);
|
|
77
|
+
this.setAppState("binding_code_attempts", "0");
|
|
78
|
+
if (input.issuedByUserId == null) {
|
|
79
|
+
this.deleteAppState("binding_code_issued_by_user_id");
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
this.setAppState("binding_code_issued_by_user_id", String(input.issuedByUserId));
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
code: input.code,
|
|
86
|
+
mode: input.mode,
|
|
87
|
+
createdAt,
|
|
88
|
+
expiresAt,
|
|
89
|
+
attempts: 0,
|
|
90
|
+
maxAttempts: BINDING_CODE_MAX_ATTEMPTS,
|
|
91
|
+
issuedByUserId: input.issuedByUserId ?? null,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
recordBindingCodeFailure(now = new Date()) {
|
|
95
|
+
const state = this.getBindingCodeState(now);
|
|
96
|
+
if (!state)
|
|
97
|
+
return null;
|
|
98
|
+
const attempts = state.attempts + 1;
|
|
99
|
+
if (attempts >= state.maxAttempts) {
|
|
100
|
+
this.clearBindingCode();
|
|
101
|
+
return {
|
|
102
|
+
attempts,
|
|
103
|
+
remaining: 0,
|
|
104
|
+
exhausted: true,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
this.setAppState("binding_code_attempts", String(attempts));
|
|
108
|
+
return {
|
|
109
|
+
attempts,
|
|
110
|
+
remaining: state.maxAttempts - attempts,
|
|
111
|
+
exhausted: false,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
clearBindingCode() {
|
|
35
115
|
this.deleteAppState("bootstrap_code");
|
|
116
|
+
this.deleteAppState("binding_code_mode");
|
|
117
|
+
this.deleteAppState("binding_code_created_at");
|
|
118
|
+
this.deleteAppState("binding_code_expires_at");
|
|
119
|
+
this.deleteAppState("binding_code_attempts");
|
|
120
|
+
this.deleteAppState("binding_code_issued_by_user_id");
|
|
36
121
|
}
|
|
37
122
|
claimAuthorizedUserId(userId) {
|
|
38
123
|
const existing = this.getAuthorizedUserId();
|
|
@@ -42,11 +127,16 @@ export class SessionStore {
|
|
|
42
127
|
const current = this.getAuthorizedUserId();
|
|
43
128
|
if (current == null)
|
|
44
129
|
throw new Error("Failed to persist authorized Telegram user id");
|
|
45
|
-
this.
|
|
130
|
+
this.clearBindingCode();
|
|
46
131
|
return current;
|
|
47
132
|
}
|
|
133
|
+
rebindAuthorizedUserId(userId) {
|
|
134
|
+
this.setAppState("authorized_user_id", String(userId));
|
|
135
|
+
this.clearBindingCode();
|
|
136
|
+
}
|
|
48
137
|
clearAuthorizedUserId() {
|
|
49
138
|
this.deleteAppState("authorized_user_id");
|
|
139
|
+
this.clearBindingCode();
|
|
50
140
|
}
|
|
51
141
|
getOrCreate(input) {
|
|
52
142
|
const existing = this.get(input.sessionKey);
|
|
@@ -285,6 +375,21 @@ function normalizeRuntimeStatus(value, activeTurnId) {
|
|
|
285
375
|
return activeTurnId ? "running" : "idle";
|
|
286
376
|
}
|
|
287
377
|
}
|
|
378
|
+
function normalizeBindingCodeMode(value) {
|
|
379
|
+
return value === "rebind" ? "rebind" : "bootstrap";
|
|
380
|
+
}
|
|
381
|
+
function normalizeNonNegativeInteger(value) {
|
|
382
|
+
if (!value)
|
|
383
|
+
return 0;
|
|
384
|
+
const parsed = Number(value);
|
|
385
|
+
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : 0;
|
|
386
|
+
}
|
|
387
|
+
function normalizeOptionalUserId(value) {
|
|
388
|
+
if (value == null)
|
|
389
|
+
return null;
|
|
390
|
+
const parsed = Number(value);
|
|
391
|
+
return Number.isSafeInteger(parsed) ? parsed : null;
|
|
392
|
+
}
|
|
288
393
|
function parseStoredCodexInput(inputJson, fallbackText) {
|
|
289
394
|
if (!inputJson)
|
|
290
395
|
return fallbackText;
|
|
@@ -11,6 +11,11 @@ export function escapeHtml(value) {
|
|
|
11
11
|
.replaceAll("<", "<")
|
|
12
12
|
.replaceAll(">", ">");
|
|
13
13
|
}
|
|
14
|
+
function escapeHtmlAttribute(value) {
|
|
15
|
+
return escapeHtml(value)
|
|
16
|
+
.replaceAll('"', """)
|
|
17
|
+
.replaceAll("'", "'");
|
|
18
|
+
}
|
|
14
19
|
export function renderMarkdownForTelegram(markdown) {
|
|
15
20
|
const tokens = md.parse(markdown, {});
|
|
16
21
|
const html = renderTokens(tokens).replace(/\n{3,}/g, "\n\n").trim();
|
|
@@ -95,6 +100,7 @@ function renderTokens(tokens) {
|
|
|
95
100
|
}
|
|
96
101
|
function renderInline(tokens) {
|
|
97
102
|
let out = "";
|
|
103
|
+
const linkStack = [];
|
|
98
104
|
for (const token of tokens) {
|
|
99
105
|
switch (token.type) {
|
|
100
106
|
case "text":
|
|
@@ -122,12 +128,18 @@ function renderInline(tokens) {
|
|
|
122
128
|
out += "</s>";
|
|
123
129
|
break;
|
|
124
130
|
case "link_open": {
|
|
125
|
-
const href = token.attrGet("href");
|
|
126
|
-
|
|
131
|
+
const href = sanitizeTelegramHref(token.attrGet("href"));
|
|
132
|
+
const opened = Boolean(href);
|
|
133
|
+
linkStack.push(opened);
|
|
134
|
+
if (href) {
|
|
135
|
+
out += `<a href="${escapeHtmlAttribute(href)}">`;
|
|
136
|
+
}
|
|
127
137
|
break;
|
|
128
138
|
}
|
|
129
139
|
case "link_close":
|
|
130
|
-
|
|
140
|
+
if (linkStack.pop()) {
|
|
141
|
+
out += "</a>";
|
|
142
|
+
}
|
|
131
143
|
break;
|
|
132
144
|
case "softbreak":
|
|
133
145
|
case "hardbreak":
|
|
@@ -144,3 +156,28 @@ function renderInline(tokens) {
|
|
|
144
156
|
}
|
|
145
157
|
return out;
|
|
146
158
|
}
|
|
159
|
+
function sanitizeTelegramHref(value) {
|
|
160
|
+
if (!value)
|
|
161
|
+
return null;
|
|
162
|
+
const href = value.trim();
|
|
163
|
+
if (!href)
|
|
164
|
+
return null;
|
|
165
|
+
try {
|
|
166
|
+
const url = new URL(href);
|
|
167
|
+
return isAllowedTelegramProtocol(url.protocol) ? url.toString() : null;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function isAllowedTelegramProtocol(protocol) {
|
|
174
|
+
switch (protocol) {
|
|
175
|
+
case "http:":
|
|
176
|
+
case "https:":
|
|
177
|
+
case "mailto:":
|
|
178
|
+
case "tg:":
|
|
179
|
+
return true;
|
|
180
|
+
default:
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|