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 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 (!input.bootstrapCode) {
34
- await deny(ctx, "Authentication is not configured for this bot yet.");
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
- const messageText = ctx.message?.text?.trim();
42
- if (messageText === input.bootstrapCode) {
43
- const claimedUserId = input.store.claimAuthorizedUserId(userId);
44
- if (claimedUserId === userId) {
45
- input.onAdminBound?.(userId);
46
- await ctx.reply("Admin binding succeeded. Only this Telegram account can use this bot from now on.");
47
- }
48
- else {
49
- await deny(ctx, "An admin account has already claimed this bot.");
50
- }
51
- return;
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 = path.resolve(input.trim());
206
- if (!isPathWithinRoot(resolved, projectRoot)) {
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 = path.resolve(candidate);
237
- const resolvedRoot = path.resolve(root);
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
+ }
@@ -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 <absolute-path>");
198
+ await ctx.reply("Usage: /adddir add <path-inside-project>");
198
199
  return;
199
200
  }
200
201
  try {
201
- const directory = resolveExistingDirectory(args);
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
@@ -0,0 +1,5 @@
1
+ import { randomBytes } from "node:crypto";
2
+ export function generateBindingCode(mode) {
3
+ const prefix = mode === "rebind" ? "rebind" : "bind";
4
+ return `${prefix}-${randomBytes(6).toString("base64url")}`;
5
+ }
@@ -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
- bootstrapCode = store.getBootstrapCode();
39
- if (!bootstrapCode) {
40
- bootstrapCode = generateBootstrapCode();
41
- store.setBootstrapCode(bootstrapCode);
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
- "This binding code stays valid until an admin account successfully claims it.",
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
- }
@@ -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
- return this.getAppState("bootstrap_code");
31
+ const binding = this.getBindingCodeState();
32
+ return binding?.mode === "bootstrap" ? binding.code : null;
30
33
  }
31
34
  setBootstrapCode(code) {
32
- this.setAppState("bootstrap_code", code);
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.clearBootstrapCode();
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("<", "&lt;")
12
12
  .replaceAll(">", "&gt;");
13
13
  }
14
+ function escapeHtmlAttribute(value) {
15
+ return escapeHtml(value)
16
+ .replaceAll('"', "&quot;")
17
+ .replaceAll("'", "&#39;");
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
- out += href ? `<a href="${escapeHtml(href)}">` : "";
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
- out += "</a>";
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "telecodex",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Telegram bridge for local Codex.",
5
5
  "license": "MIT",
6
6
  "type": "module",