Skip to content

feat(telegram-remote): v0 operator gateway over Coordinator MCP (#681)#699

Draft
Yeachan-Heo wants to merge 2 commits into
devfrom
feat/telegram-remote
Draft

feat(telegram-remote): v0 operator gateway over Coordinator MCP (#681)#699
Yeachan-Heo wants to merge 2 commits into
devfrom
feat/telegram-remote

Conversation

@Yeachan-Heo

Copy link
Copy Markdown
Owner

Summary

Implements the v0 Telegram Remote for Gajae-Code sessions (issue #681), per the
contract fixed in docs/telegram-remote.md. Adds a new dependency-light companion
package @gajae-code/telegram-remote — a tiny, safe command + bounded-read operator
gateway over the Coordinator MCP for session lifecycle and observation. It is not
a core gjc mode, not a remote shell/RPC cockpit, and introduces no second
control protocol (it speaks MCP JSON-RPC to a gjc mcp-serve coordinator subprocess).

Opened as a draft for review of the v0 surface before wiring a live bot.

Command contract

/sessions, /observe <sessionId>, /start-session <presetId> [task], /stop <sessionId>,
/help. Everything else is rejected as unknown.

Safety properties (all enforced + tested)

  • Default deny. Only allowlisted Telegram user/chat ids may issue commands; unlisted
    senders get an identical, boring refusal (no hints, no preset enumeration).
  • Preset-only creation. Fixed workdir + fixed session command + optional fixed task
    template with a single length-capped, control-char-stripped {{task}} slot. No
    workdir/command/branch/repo/shell/raw-RPC ever comes from chat.
  • Fail-closed mutations. Coordinator runs with the smallest set (sessions, plus
    reports only when /stop is enabled); questions is never enabled; allow_mutation
    is passed only on the specific mutating call.
  • Redaction by construction. Typed, field-by-field projection only (sessionId, derived
    name, bounded status enum, branch, ISO-validated timestamps, bounded turn/lifecycle enum,
    short sanitized blocker). Never raw tmux tail/scrollback, transcripts, tool IO, diffs,
    file contents, env, system prompt, tokens/secrets, or absolute paths.
  • /stop confirmation + fail-closed offline. /stop <id> arms; /stop <id> confirm
    records the coordinator terminal cancelled status (not a process kill). Offline
    sessions are refused before any arm/report.

What's included

  • Contract types, command parser, preset resolver, redaction projection.
  • CoordinatorClient port + McpStdioCoordinatorClient (spawns gjc mcp-serve coordinator).
  • Gateway core (auth, dispatch, confirmation, failure-state → chat mapping).
  • Telegram Bot API long-poll transport, env config loader, runnable service entrypoint
    (gjc-telegram-remote), README, CHANGELOG, .env.example.
  • Cross-linked from docs/telegram-remote.md.

Verification

  • bun test in the package: 61 pass / 0 fail (default-deny, command parsing/unknown
    rejection, preset resolution + workdir/command/task injection rejection, task cap +
    control-char stripping, redaction allowlist incl. hostile timestamps, /stop
    confirmation + offline fail-closed, failure-state mapping, real-subprocess MCP JSON-RPC,
    and an end-to-end loop through a fake transport + fake coordinator).
  • tsgo typecheck and biome clean for the package.
  • A runnable examples/safety-smoke.ts asserts the adversarial invariants with no bot
    token/network and prints a deterministic success line.

Non-goals (v0)

No submit surface, no remote process teardown, no transcript/tail/secret dumping, no shell
or config editor, no answering of PC-side gates. See docs/telegram-remote.md for deferred
decisions.

Refs #681.

Yeachan-Heo added 2 commits June 15, 2026 20:18
Add @gajae-code/telegram-remote, a tiny, safe command + bounded-read
operator surface for gjc session lifecycle/observation from Telegram. It
maps a fixed five-command vocabulary onto Coordinator MCP tool calls and
introduces no second control protocol.

- Default-deny auth; identical boring refusal for unlisted senders.
- Preset-only creation (fixed workdir + session command + single
  length-capped, control-char-stripped {{task}} slot); no chat-derived
  workdir/command.
- Fail-closed mutations forced to the smallest set (sessions[,reports]);
  questions never enabled; allow_mutation only on the mutating call.
- Redaction by construction: typed field-by-field projection only; ISO
  timestamp validation; never raw tail/transcripts/tool IO/secrets/paths.
- /stop arms then requires '/stop <id> confirm', records coordinator
  cancelled status, and fails closed on offline sessions.
- MCP stdio coordinator client, Bot API long-poll transport, env config
  loader, runnable service entrypoint, and a safety smoke example.

Implements the v0 contract in docs/telegram-remote.md (cross-linked).
Refs #681.
…cks) (#681)

Add optional rich messaging as a presentation + alternate-entry layer over the
same Coordinator MCP surface — no second control protocol, no Telegram-side
event/notification system. Implements the ralplan consensus-approved plan.

- Reply contract evolves from Promise<string> to OutgoingReply
  (ChatReply | CallbackAnswerOnlyReply) over IncomingUpdate (text + callback).
- Inline keyboards: Observe / Stop / Refresh on /sessions and /observe;
  Confirm stop / Cancel on /stop. HTML formatting with render-boundary escaping.
- Callback queries reuse the same default-deny auth and the same CoordinatorClient
  -> Coordinator MCP calls as text commands. callback_data is an opaque
  gtr:v1:<token> (<=64 bytes, never the session id); the exact raw session id lives
  in TTL-bound, chat/user-bound, single-use server-side token metadata.
- Every callback is answered (answerCallbackQuery, finally-style, no reply-text
  string matching); unauthorized/expired/malformed/missing-chat/replayed/cancel
  callbacks are answer-only (no chat message, no backend call). Cancel revokes the
  paired stop_confirm token so Cancel-then-Confirm cannot mutate.
- setMyCommands menu (sessions/observe/stop/help/start; excludes hyphenated
  /start-session); /start onboarding; optional editMessageText (default off,
  safe fallback). New config knobs with safe defaults; plain-text mode preserved.
- Push notifications deferred: rich UI does not proactively notify; future push
  must reuse gjc_coordinator_watch_events, not a Telegram-side poller.
- Tests: telegram.test.ts (transport, injected fetch) + gateway/projection/config
  callback + redaction + exact-raw-id coverage; safety-smoke extended with callback
  adversarial invariants. README/CHANGELOG/.env.example/docs updated.

Refs #681.
@Yeachan-Heo

Copy link
Copy Markdown
Owner Author

Merge-gate red-team review — PR #699

Verdict: OWNER_CONFIRMATION_REQUIRED

No blocking security defect was found, but this PR introduces a brand-new, user-facing remote session-control surface over a public chat platform (Telegram), it is still a draft, and the green CI does not actually exercise this package. Those are owner-decision factors, so I am not declaring a flat MERGE_READY.

What I verified (holds up)

The core safety boundaries are correctly implemented and genuinely enforced coordinator-side, not just asserted:

  • Default-deny. Empty allowlist throws telegram_remote_no_allowlist at config load; unlisted senders get one identical boring refusal with no enumeration/hints (gateway.isAuthorized, messages.ts). Covered by tests.
  • Preset-only creation. Chat never supplies workdir/command/branch/repo/shell/raw-RPC; only a preset id + a length-capped, control-char-stripped {{task}} slot. The /start-session demo /etc/passwd test confirms the chat path is ignored and the preset workdir is bound.
  • Forced smallest mutation set. buildCoordinatorEnv always sets GJC_COORDINATOR_MCP_MUTATIONS to sessions or sessions,reports, and because the child env is { ...process.env, ...options.env }, the forced value overrides any wider parent env (and ?? ENABLE_MUTATION_CLASSES is short-circuited). questions is never enabled; allow_mutation: true is passed only on the two mutating calls. The coordinator MCP independently enforces this via requireCoordinatorMutation + assertCoordinatorWorkdir (coordinator-mcp/policy.ts, server.ts).
  • Redaction by construction. Field-by-field projection, never spread. I traced the one freeform-looking field, blockerSummary: it derives only from session-state reason, and every writeSessionState call in coordinator-mcp/server.ts writes a fixed marker (reported_failure, tmux_session_missing, tmux_delivery_unavailable, …) or null — never the agent's freeform summary (that goes to the turn record). It is additionally control-char-stripped and capped at 120. Hostile-timestamp and over-long-reason cases are tested.
  • Callback tokens. callback_data is only an opaque gtr:v1:<token> (≤64 bytes); raw id lives server-side, TTL-bound, chat/user-bound, single-use for mutating confirms, with cancel-revokes-sibling. Offline sessions fail closed before any arm/report.
  • Tests. bun test packages/telegram-remote/84 pass / 0 fail locally.

Why owner confirmation (not auto-merge)

  1. Still a draft (isDraft: true). Merging a draft is itself an owner call.
  2. No real CI coverage for this package. The only checks that ran are Affected path validation and gjc-state-gates — neither typechecks, lints, nor runs the 84 tests of packages/telegram-remote. "Green CI" here gives ~zero assurance about this new, security-sensitive package; I ran the suite locally to compensate, but the gate should be wired.
  3. Group-chat OR-auth blast radius. isAuthorized is OR(userId, chatId). Allowlisting a group chat id grants control to every current and future member of that group. This matches the contract ("approved users/chats"), but neither README.md nor .env.example warns about it (and .env.example shows a group-style -100… chat id). Please confirm the intended posture / add a warning.
  4. /stop semantics. Records the coordinator terminal cancelled status; it does not kill the tmux/session process. Documented and consistent with Roadmap: Telegram remote for Gajae-Code sessions #681's "request graceful stop", but it's a product expectation worth explicit sign-off.

Minor / non-blocking (no fix required to proceed)

  • Plain mode (ENABLE_RICH=false) id round-trip. renderSessionsList/renderSessionView show only the 48-char-capped session id; if a real session_id exceeds 48 chars, the operator can't type it back into /observe//stop. Rich mode (default on) is unaffected (button tokens carry the raw id).
  • taskTemplate.replace(TASK_SLOT, task) uses string replacement, so $-patterns in the task ($&, $`, $', $$) are interpreted. Harmless (authorized user, prompt text only) — a replacement function would be tidier.
  • Coordinator subprocess inherits full process.env (incl. the bot token) via { ...process.env, ...options.env }. Same trust domain, but broader than necessary.
  • Coupling note: the redaction guarantee for blockerSummary depends on coordinator session-state reason staying a bounded marker. A future coordinator change writing freeform text there would transmit up to 120 sanitized chars to chat — worth a comment/guard.

Net: high-quality, careful security design with no defect that lets an unauthorized party gain capability. The merge decision is yours given the draft status, the missing package CI gate, and the user-facing/security/product-contract items (3) and (4).


[repo owner's gaebal-gajae (clawdbot) 🦞]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant