Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ All contributions start with a GitHub Issue.

- Check for existing reports first.
- Create a new bug report with: - Clear, numbered reproduction steps - Expected vs actual result - Tumble Code version (required); API provider/model if relevant
<!-- - **Security issues**: Report privately via [security advisories](https://github.com/krzychdre/tumble-code/security/advisories/new). -->
<!-- - **Security issues**: Report privately via [security advisories](https://github.com/krzychdre/tumble-code/security/advisories/new). -->

## Development & Submission Process

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Tumble Code is a community-maintained fork of [Roo Code](https://github.com/RooC
- [Tiếng Việt](locales/vi/README.md)
- [简体中文](locales/zh-CN/README.md)
- [繁體中文](locales/zh-TW/README.md)
</details>
</details>

---

Expand Down
344 changes: 172 additions & 172 deletions ai_plans/2026-05-14_17-04_error_while_saving_file.md

Large diffs are not rendered by default.

171 changes: 171 additions & 0 deletions ai_plans/2026-05-16_fix-self-hosted-auth-404.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
# Fix self-hosted Clerk sign-in returning a session id the client cannot use

**Date:** 2026-05-16
**Branch:** `feature/self-hosted-cloud-backend`
**Scope:** `self-hosted-cloudapi/` only — no extension/TypeScript changes.

---

## Symptom

After a successful Authentik login, the VS Code extension receives the ticket,
calls `POST /v1/client/sign_ins`, then calls
`POST /v1/client/sessions/{sess_id}/tokens` to mint its first JWT — and the
self-hosted API responds:

```
INFO: 127.0.0.1:59596 - "POST /v1/client/sessions/sess_b4b99562f7cc4c34976f32420/tokens HTTP/1.1" 404 Not Found
```

Recorded in [spotted-errors/self-hosted-cloud.md](../spotted-errors/self-hosted-cloud.md).
This breaks the entire sign-in flow: the extension never gets a session JWT, so
no authenticated requests can succeed.

## Root cause

The current sign-in handler issues a Bearer token that does **not** belong to
the session id it returns in the body:

```python
# self-hosted-cloudapi/src/routers/auth.py:53-67
session = await validate_ticket(db, ticket) # returns session_A (from ticket)
...
_, raw_token = await create_session_and_token(db, session.user_id) # creates session_B + token_B
body, auth_header_value = format_sign_in_response(session.id, raw_token)
# ^ session_A.id ^ token_B (belongs to session_B)
```

The client stores `created_session_id = session_A.id` and the
`Authorization: Bearer token_B` from the response header, then calls
`POST /v1/client/sessions/{session_A.id}/tokens` with `Bearer token_B`.
On the server:

```python
# self-hosted-cloudapi/src/routers/auth.py:92-103
session = await validate_client_token(db, raw_token) # token_B → session_B
if session.id != session_id: # session_B.id != session_A.id
raise HTTPException(404, "Session not found")
```

→ **404**.

Secondary observation: [browser.py:272](../self-hosted-cloudapi/src/routers/browser.py#L272)
already calls `create_session_and_token(db, user.id)` during the OAuth callback,
producing a third client token that is hashed into the DB and never returned
to anyone (the raw form is gone the moment the function returns). That token is
unrecoverable dead weight.

## Architectural intent (from the plan)

[ai_plans/self-hosted-cloud-api-architecture.md §3.1](self-hosted-cloud-api-architecture.md):
the **ticket** is the single thread that ties browser-side auth to
extension-side auth. The session is created at OAuth callback, the ticket maps
to it, and at `/v1/client/sign_ins` time the server returns:

- body: `created_session_id` = **the session the ticket points to**
- header: `Authorization: Bearer <clientToken bound to that same session>`

We need exactly one session per sign-in, and the client token must belong to it.

## Fix

Split token issuance from session creation, and bind the sign-in token to the
ticket's session.

### Code changes

1. **`src/services/auth_service.py` — add `create_client_token`**, refactor
`create_session_and_token` to compose it:

```python
async def create_client_token(db, session_id) -> tuple[ClientToken, str]:
raw_token = secrets.token_urlsafe(48)
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
ct = ClientToken(session_id=session_id, token_hash=token_hash)
db.add(ct); await db.flush()
return ct, raw_token

async def create_session_and_token(db, user_id):
session = Session(user_id=user_id); db.add(session); await db.flush()
_, raw_token = await create_client_token(db, session.id)
return session, raw_token
```

2. **`src/routers/auth.py:53-67` — bind token to ticket's session**, drop the
stray session:

```python
session = await validate_ticket(db, ticket)
if session is None:
raise HTTPException(401, "Invalid or expired ticket")
_, raw_token = await create_client_token(db, session.id)
body, auth_header_value = format_sign_in_response(session.id, raw_token)
```

3. **`src/routers/browser.py:272` — stop creating an unused client token at
callback**. The OAuth callback only needs to persist the session so the
ticket can point to it; the client token will be created at `sign_ins` time.
Replace `create_session_and_token(db, user.id)` with a new
`create_session(db, user.id) -> Session` helper. (Tiny refactor; keeps the
ticket model unchanged.)

### No DB migration needed

Schema is unchanged. Only the lifecycle of `client_tokens` rows shifts: one row
per successful sign-in instead of two (one stillborn at callback + one at
sign-in).

## Test plan

Add `tests/test_sign_in_flow.py` — the first integration test that exercises
the post-OAuth path end-to-end against the in-memory SQLite from `conftest.py`.
Needs a `get_db` dependency override so `TestClient` shares the fixture's
session.

Cases:

1. **Happy path** (the regression test for this bug):

- Seed a `User`, a `Session`, and a `Ticket(code=…, session_id=session.id)`.
- `POST /v1/client/sign_ins` with `strategy=ticket&ticket=<code>`.
- Assert `response.json()["response"]["created_session_id"] == session.id`.
- Capture `Authorization` header → `raw_token`.
- `POST /v1/client/sessions/{session.id}/tokens` with
`Authorization: Bearer <raw_token>` and `_is_native=1`.
- Assert **200** (this is the line that was 404).
- Assert `response.json()["jwt"]` is a non-empty string that decodes with
the configured JWT verifier and has `r.u == user.id`.

2. **Token-not-bound-to-session guard**:

- Sign in for `session_A`, capture `token_A`.
- Manually create a second `Session` (`session_B`) for the same user.
- `POST /v1/client/sessions/{session_B.id}/tokens` with `Bearer token_A`.
- Assert 404 — the existing cross-session guard must still hold.

3. **Ticket single-use**:

- Sign in once with a ticket; assert 200.
- Sign in again with the same ticket; assert 401 (`validate_ticket` flips
`used = True`).

4. **`/v1/me` after sign-in** (proves the chain works end-to-end):
- After case 1, `GET /v1/me` with `Authorization: Bearer <raw_token>`.
- Assert 200 and `response.json()["response"]["id"] == user.id`.

Keep the existing `test_auth.py` unit tests as-is; they cover the 401 paths.

## Risk and rollback

- Behavior change is strictly additive for happy path (404 → 200) and
removes one unused DB row per sign-in.
- If browser-side flow regresses (unlikely — the callback no longer needs the
raw token), revert the single callback hunk; the auth.py + auth_service.py
changes are independent and safe to keep.

## Out of scope

- Anthropic streaming SSE conversion ([anthropic.py:114-125](../self-hosted-cloudapi/src/proxy/providers/anthropic.py#L114-L125)).
- Authentik groups → `org_id` mapping ([browser.py:278](../self-hosted-cloudapi/src/routers/browser.py#L278)).
- Marketplace org filtering, Google/xAI providers, alembic-as-source-of-truth.
Tracked separately.
21 changes: 8 additions & 13 deletions ai_plans/2026-05-25_ui-live-elapsed-duration.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,14 @@ No doubled durations. No font drift.
renders the duration when `endTs > startTs`; while running, callers pass
`endTs = undefined` (or the next-message `ts` which is `undefined` for the
latest message).
- Callsites:
- `ReasoningBlock.tsx:48` — thinking block; `endTs` is the next message's
`ts`, `undefined` while still streaming.
- `ChatRow.tsx:1084-1087` — api_req_started; an explicit
`isApiRequestInProgress` boolean drives `endTs = isApiRequestInProgress ?
undefined : nextMessageTs`.
- `UpdateTodoListToolBlock.tsx:166, 189` — block-level start + duration on
the "Todo List Updated" header.
- `TodoChangeDisplay.tsx:70` — block-level start + duration on the
"todos updated" header.
- `TodoChangeDisplay.tsx:88` — **per-item** start-only badge for completed
todos. This row passes only `startTs` (no `endTs` concept), and **must
not start ticking live** when we add live behavior to the others.
- Callsites: - `ReasoningBlock.tsx:48` — thinking block; `endTs` is the next message's
`ts`, `undefined` while still streaming. - `ChatRow.tsx:1084-1087` — api_req_started; an explicit
`isApiRequestInProgress` boolean drives `endTs = isApiRequestInProgress ?
undefined : nextMessageTs`. - `UpdateTodoListToolBlock.tsx:166, 189` — block-level start + duration on
the "Todo List Updated" header. - `TodoChangeDisplay.tsx:70` — block-level start + duration on the
"todos updated" header. - `TodoChangeDisplay.tsx:88` — **per-item** start-only badge for completed
todos. This row passes only `startTs` (no `endTs` concept), and **must
not start ticking live** when we add live behavior to the others.
- `formatDuration` (`webview-ui/src/utils/format.ts:63-74`) is the canonical
duration formatter (`1.2s` / `3m 04s`). Reusing it preserves visual parity
between live and final values.
Expand Down
11 changes: 4 additions & 7 deletions ai_plans/2026-06-02_code-index-auto-retry-connection.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,11 @@ There is **no automatic recovery** from the `Error` state. Evidence:
batch error bubbles up, the orchestrator catches it, sets `Error`, and the whole
process is done — no outer/connection-level retry exists.

4. How connection failures look in the final `Error` message:
- Embedder validation: exactly `t("embeddings:validation.connectionFailed")`
(via `validation-helpers.ts:174-184` matching `ECONNREFUSED`/`ENOTFOUND`/
`ETIMEDOUT`/`AbortError`/`HTTP 0:`/`No response`).
- Scan failure: `Failed during initial scan: Indexing failed: Failed to create
4. How connection failures look in the final `Error` message: - Embedder validation: exactly `t("embeddings:validation.connectionFailed")`
(via `validation-helpers.ts:174-184` matching `ECONNREFUSED`/`ENOTFOUND`/
`ETIMEDOUT`/`AbortError`/`HTTP 0:`/`No response`). - Scan failure: `Failed during initial scan: Indexing failed: Failed to create
embeddings after 3 attempts: <raw>` where `<raw>` carries the network signature
(OpenAI SDK `Connection error.`, `fetch failed`, `ECONNREFUSED`, …).
- Qdrant down: `...qdrantConnectionFailed...`.
(OpenAI SDK `Connection error.`, `fetch failed`, `ECONNREFUSED`, …). - Qdrant down: `...qdrantConnectionFailed...`.

## Fix

Expand Down
74 changes: 74 additions & 0 deletions ai_plans/2026-06-03_merge-main-into-self-hosted-cloud-backend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Merge `main` into `feature/self-hosted-cloud-backend`

**Date:** 2026-06-03
**Branch:** `feature/self-hosted-cloud-backend`
**Merging in:** `main` @ `27d0c8e75` (71 commits ahead of merge base `adea58c12`)

## Situation

`main` had advanced 71 commits — the **Tumble rebrand** + the **Zoo PR port wave**.
The feature branch (8 commits) is mostly the **self-hosted cloud backend** (a standalone
`self-hosted-cloudapi/` Python app + extension-side cloud auth/config wiring).

The collision: as part of the rebrand, `main` **deliberately removed the entire "roo"
cloud router provider** (documented in `ai_plans/2026-05-26_22-35_remove-roo-router-provider.md`)
— handler, fetcher, schema, types enum, settings UI, welcome screen, CLI onboarding,
image-gen, and i18n. The feature branch had extended that same provider for the
self-hosted backend.

16 files had textual conflicts; `main` also cleanly deleted roo references in ~20
non-conflicting files (auto-staged), so a naive "keep ours" would have left the tree
non-compiling.

## Decision (user)

- **Adopt `main` fully, including the router-provider removal.**
- **Keep only the self-hosted `CloudService` auth/config** (self-hosted URL overrides +
Clerk auto-detect in `packages/cloud`). Drop the built-in roo proxy _provider_.
- **Brand kept strings as Tumble** (`tumblecode.dev`).

This is close to `main`'s own direction, so the resolution is "take theirs almost
everywhere; preserve the self-hosted auth layer in `packages/cloud` + its extension wiring."

## Resolutions

| File | Resolution |
| --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `packages/cloud/src/config.ts` | Keep our self-hosted auth layer (runtime overrides, Clerk auto-detect, provider-URL knob); rebrand the 3 `PRODUCTION_*` URLs to `*.tumblecode.dev`. |
| `packages/cloud/src/__tests__/config.spec.ts` | Rebrand expected URLs to Tumble. |
| `src/api/providers/roo.ts`, `__tests__/roo.spec.ts`, `webviewMessageHandler.rooBalance.spec.ts` | `git rm` (router provider gone). |
| `src/api/providers/fetchers/modelCache.ts`, `src/core/webview/webviewMessageHandler.ts`, `src/extension/api.ts` | Take `main` (router cases removed; verified no self-hosted-only content of ours). |
| `src/extension.ts` | Drop the roo-models-cache block in `authStateChangedHandler` (match `main`); drop now-unused `getRooCodeProviderUrl` import. **Preserve** the `syncCloudUrls()` / `registerCloudUrlsSubscription()` wiring (non-conflicting, kept). |
| 7 `*.spec.ts` cloud mocks | Keep both getters (`getRooCodeApiUrl` + retained `getRooCodeProviderUrl`), `localhost:8080` values. |
| `.gitignore` | Trivial blank-line conflict — drop. |

## Non-conflicting breakage fixed (the subtle part)

- **`src/package.json`**: our 3 cloud settings auto-merged under the legacy
`roo-cline.*` prefix while `main` moved all schema keys to `tumble-code.*`
(`Package.name`). Renamed `cloudApiUrl`/`cloudProviderUrl`/`clerkBaseUrl` to
`tumble-code.*` so they are actually read.
- **`src/__tests__/extension.spec.ts`**: merged `extension.ts` now runs
`syncCloudUrls()` at activation, which `main`'s `vscode` mock didn't anticipate:
- `getConfiguration().get` returned `[]` for all keys → `.trim()` threw. Made it
key-aware (returns `undefined` for the string cloud-URL settings).
- Added `setRooCodeApiUrl`/`setRooCodeProviderUrl`/`setClerkBaseUrl` to the
`@roo-code/cloud` mock (called by `syncCloudUrls`).
- Added `workspace.onDidChangeConfiguration` mock (used by
`registerCloudUrlsSubscription`).
- NLS (`src/package.nls.json`): rebranded the 3 cloud-setting description examples.

## Verification

- `pnpm --filter @roo-code/cloud check-types` — clean.
- `pnpm --filter tumble-code check-types` — clean.
- `packages/cloud` config spec — 20/20.
- `extension.spec.ts` — 2/2 (the activation tests that exercise `syncCloudUrls`).
- The 6 previously-conflicted `ClineProvider.*` specs — 60/60.
- `git grep` for dangling `getRooModels`/`RooHandler`/`rooDefaultModelId`/`provider: "roo"`
in non-test source — none.

## Not yet done

- Merge **not committed** (awaiting user).
- Full `pnpm test` / `pnpm lint` across all packages not run (only affected specs).
Loading
Loading