Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
edade54
feat(web): run auth and cloud tasks fully in the browser
gantoine Jul 8, 2026
8940145
feat(web): capability-gate local features for the cloud-only web host
gantoine Jul 8, 2026
45ac440
feat(web): bind the post-login task + shell services for the web host
gantoine Jul 8, 2026
1c66c3b
feat(web): bind team skills service for the web host
gantoine Jul 9, 2026
0131a58
feat(web): hide the local terminal on cloud-only hosts
gantoine Jul 9, 2026
3be9862
feat(web): wire cloud task creation + chat services for the web host
gantoine Jul 9, 2026
42fbe3c
feat(web): bind task-detail git + workspace context for the web host
gantoine Jul 9, 2026
e7bfae2
feat(web): render cloud task chat diffs and populate the sidebar
gantoine Jul 9, 2026
f81d13d
feat(web): fix task archiving and harden per-device storage
gantoine Jul 10, 2026
c7e7295
feat(web): browser notifications + hide desktop-only surfaces
gantoine Jul 10, 2026
83cbf22
feat(web): wire task title generation via the LLM gateway
gantoine Jul 10, 2026
ed43e64
feat(web): wire cloud task log history
gantoine Jul 10, 2026
8911255
feat(web): wire file attachments in cloud task chat
gantoine Jul 10, 2026
49550d0
feat(web): wire /skill commands in cloud task chat via team skills
gantoine Jul 10, 2026
325065e
fix(web): stub the slackIntegration router
gantoine Jul 10, 2026
2412f98
Merge branch 'main' into posthog-code/web-host-cloud-tasks-auth
gantoine Jul 10, 2026
59253dd
feat(web): hide in-app back/forward buttons on cloud-only hosts
gantoine Jul 10, 2026
46f58dd
feat(web): wire the Channels browser-tab strip
gantoine Jul 10, 2026
a6c1f9c
feat(web): wire posthog-js analytics + error tracking
gantoine Jul 10, 2026
93fb2f3
feat(web): wire drag-and-drop + file-picker attachments
gantoine Jul 10, 2026
f937c55
feat(web): wire posthog-js feature flags
gantoine Jul 10, 2026
6d42aa4
docs(web): correct skill dependency-expansion limitation
gantoine Jul 10, 2026
c524cb9
fix(web): stub the githubIntegration router
gantoine Jul 10, 2026
1d2787c
docs(web): mark feature flags as wired in the README
gantoine Jul 10, 2026
e5b20cb
feat(web): open the integration authorize URL for Slack/GitHub connect
gantoine Jul 10, 2026
5788e92
perf(web): split vendor libraries out of the entry chunk
gantoine Jul 10, 2026
7e739d9
docs(web): mark vendor code-splitting as done in the README
gantoine Jul 10, 2026
c8a83ae
feat(web): encrypt the refresh token at rest with a non-extractable key
gantoine Jul 10, 2026
2483a98
manual comment cleanup
gantoine Jul 11, 2026
1af57f6
Merge branch 'main' into posthog-code/web-host-cloud-tasks-auth
gantoine Jul 11, 2026
2c4a4c1
Merge branch 'main' into posthog-code/web-host-cloud-tasks-auth
gantoine Jul 13, 2026
ac83888
perf(web): lazy-load app routes + restructure README
gantoine Jul 13, 2026
ed8f06d
fix(web): bind inbox reportModelResolver + guard host capabilities
gantoine Jul 13, 2026
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
8 changes: 4 additions & 4 deletions apps/code/src/main/services/auth/port-adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,12 @@ import {

@injectable()
export class TokenCipherPortAdapter implements IAuthTokenCipher {
encrypt(plaintext: string): string {
return encrypt(plaintext);
encrypt(plaintext: string): Promise<string> {
return Promise.resolve(encrypt(plaintext));
}

decrypt(encrypted: string): string | null {
return decrypt(encrypted);
decrypt(encrypted: string): Promise<string | null> {
return Promise.resolve(decrypt(encrypted));
}
}

Expand Down
8 changes: 8 additions & 0 deletions apps/code/src/renderer/desktop-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ import {
import { SETUP_STORE } from "@posthog/core/setup/identifiers";
import { resolveService } from "@posthog/di/container";
import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger";
import {
HOST_CAPABILITIES,
type HostCapabilities,
} from "@posthog/platform/host-capabilities";
import {
type INotifications,
NOTIFICATIONS_SERVICE,
Expand Down Expand Up @@ -339,3 +343,7 @@ container
.inSingletonScope();

container.bind(SETUP_STORE).toConstantValue(setupStore);

container
.bind(HOST_CAPABILITIES)
.toConstantValue({ localWorkspaces: true } satisfies HostCapabilities);
5 changes: 5 additions & 0 deletions apps/code/src/renderer/di/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ import {
HOST_TRPC_CLIENT,
type HostTrpcClient,
} from "@posthog/host-router/client";
import {
HOST_CAPABILITIES,
type HostCapabilities,
} from "@posthog/platform/host-capabilities";
import {
type INotifications,
NOTIFICATIONS_SERVICE,
Expand Down Expand Up @@ -320,6 +324,7 @@ export interface RendererBindings {
[FEATURE_FLAGS]: FeatureFlags;
[AUTH_SIDE_EFFECTS]: IAuthSideEffects;
[SETUP_STORE]: ISetupStore;
[HOST_CAPABILITIES]: HostCapabilities;

// --- desktop-contributions.ts ---
[CONTRIBUTION]: Contribution;
Expand Down
7 changes: 7 additions & 0 deletions apps/code/src/renderer/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ import { Providers } from "@components/Providers";
import { DevToolbarHost } from "@features/dev-toolbar/DevToolbarHost";
import { preloadHighlighter } from "@pierre/diffs";
import { boot } from "@posthog/di/contribution";
import { assertHostCapabilities } from "@posthog/di/hostCapabilities";
import { ServiceProvider } from "@posthog/di/react";
import App from "@posthog/ui/shell/App";
import { logger } from "@posthog/ui/shell/logger";
import { initializePostHog } from "@posthog/ui/shell/posthogAnalyticsImpl";
import { REQUIRED_HOST_CAPABILITIES } from "@posthog/ui/shell/requiredHostCapabilities";
import { registerDesktopContributions } from "@renderer/desktop-contributions";
import { container } from "@renderer/di/container";
import "@renderer/desktop-services";
Expand Down Expand Up @@ -95,6 +97,11 @@ const root = ReactDOM.createRoot(rootElement);

try {
registerDesktopContributions();
// Fail loudly (into BootErrorScreen) if a capability the shared app resolves
// via service location is unbound, rather than deferring to the first
// navigation that needs it. The renderer container backs every useService, so
// all required tokens must resolve here. Shared with the web host.
assertHostCapabilities(container, REQUIRED_HOST_CAPABILITIES);
boot(container).catch((error: unknown) => {
bootLog.error("Renderer boot sequence failed", error);
// Replaces the mounted tree without running effect cleanup; acceptable
Expand Down
158 changes: 158 additions & 0 deletions apps/web/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
# @posthog/web

Browser host for PostHog Code. Boots the same `@posthog/ui` shell and
`@posthog/core` services as the desktop app, with web platform adapters —
no Electron, no local workspace-server. Scope today: auth + cloud tasks
(local workspaces, terminal, and local git need a workspace backend and are
out of scope for the web host's first iteration).

## Run

```bash
pnpm --filter @posthog/web dev # Vite on http://localhost:5273
```

No separate backend process: the host router slice runs in the browser
(`web-host-router.ts`), served over tRPC's `unstable_localLink`
(`web-trpc.ts`). `auth`, `cloudTask`, and `analytics` are the real routers
backed by in-browser `AuthService` / `CloudTaskService` (both are
host-agnostic core code); the rest are stubs that return benign empties.
Procedures outside the slice fail with NOT_FOUND at call time — that is the
to-do list for widening the web surface.

## Auth

`WebOAuthFlowService` (`web-oauth-flow.ts`) implements the core
`IAuthOAuthFlowService` with a browser PKCE flow: popup to
`{cloud}/oauth/authorize`, redirect back to `{origin}/callback`, code relayed
to the opener tab over a BroadcastChannel, token exchange via fetch. Session
persistence is localStorage (`web-auth-adapters.ts`); the refresh token is
encrypted at rest with AES-GCM under a **non-extractable** Web Crypto key held
in IndexedDB (`webAuthTokenCipher`). The key round-trips through structured
clone but its raw bytes are never exposed to JS, so a stolen localStorage dump
can't be decrypted offline — matching the bar the desktop host's machine-bound
cipher sets. A live XSS payload can still ask the key to decrypt while it runs
on the page (httpOnly cookies would need server-side sessions the cloud host
doesn't have). Tokens written by the earlier plaintext build fail to decrypt
and are cleared, forcing a clean re-auth.

## Hosting

Build a static bundle and serve it:

```bash
pnpm --filter @posthog/web build # Vite output: apps/web/dist
```

Serve `dist/` as a single-page app with a fallback to `index.html` for unknown
paths. The OAuth popup lands on the real path `/callback` (`OAUTH_CALLBACK_PATH`
in `web-oauth-flow.ts`, dispatched in `main.tsx`), which must load the SPA to
relay the code back to the opener tab. The app's own routes use hash history, so
`/callback` is the only real path that needs the fallback.

The build is code-split: vendor libraries into cacheable groups (`manualChunks`
in `vite.config.ts`) and each route's component into its own lazy chunk (the
TanStack Router plugin's `autoCodeSplitting`, mirroring `apps/code`), so a screen
downloads only when navigated to. All emitted assets are content-hashed — serve
`dist/assets/` with a long-lived immutable `Cache-Control` and only `index.html`
short-lived, so returning users re-download just the chunks that changed.

The steps below are one-time setup for a deployed origin. None block the app
from booting, but auth, attachments, and integrations each need one.

### Environment variables

All are Vite build-time vars (`import.meta.env.*`), baked in at build time.

| Var | Required | Purpose |
| --- | --- | --- |
| `VITE_POSTHOG_API_KEY` | Recommended | Real `phc_…` project key. Enables posthog-js analytics, error/rejection capture, session recording, and real feature flags. The guard in `main.tsx` requires the `phc_` prefix; without it posthog stays uninitialized and the tracker/analytics service no-op, leaving only the host-forced `SYNC_CLOUD_TASKS_FLAG` on (every other flag reads `false`). |
| `VITE_POSTHOG_API_HOST` | No | posthog-js ingestion host. Default `https://internal-c.posthog.com`. |
| `VITE_POSTHOG_UI_HOST` | No | posthog-js UI host. Default `https://us.i.posthog.com`. |
| `VITE_POSTHOG_ACCESS_TOKEN_OVERRIDE` | No | Dev/test only: a static access token that bypasses the OAuth flow (`AUTH_TOKEN_OVERRIDE`). Leave unset in production. |

Session recording turns on whenever posthog-js initializes (any build with a
real key); automatic unhandled-error/rejection/console capture is additionally
gated to non-dev (production) builds (`capture_exceptions` in
`posthogAnalyticsImpl.ts`).

### OAuth redirect URI registration — required for sign-in

The web host reuses the Code ("Array") OAuth application client ids
(`packages/shared/src/oauth.ts`). Each region stores its app's `redirect_uris`
as database rows (Django admin → OAuth applications), and they must include:

- `https://<web-origin>/callback` for the deployed host. `http` is rejected for
non-loopback hosts by `OAuthApplication` redirect validation, so the origin
must be HTTPS.
- `http://localhost/callback` (portless) for local dev — PostHog's authorize
view extends RFC 8252 §7.3 loopback port flexibility to `localhost`
(`posthog/api/oauth/views.py: validate_redirect_uri`), so the portless form
matches the Vite dev server on any port. If desktop dev builds can already
sign in to the region, check whether the registered localhost URI is portless
or pinned to `:8237`.

A CIMD client (the `raycast_metadata.py` / `wizard_metadata.py` pattern in
`posthog/api/oauth/`) is NOT suitable: CIMD registrations are capped to
unprivileged scopes, and Code requires `scope=*` like the desktop app.

### S3 artifact-bucket CORS — required for attachment uploads

Composer attachments upload straight from the browser via an S3 presigned POST
(`.../artifacts/prepare_upload/` returns the presigned post, then a `POST` to
`s3.<region>.amazonaws.com/<bucket>`). The bucket
(`posthog-cloud-prod-us-east-1-app-assets` for US) must return
`Access-Control-Allow-Origin` for the web origin or the browser blocks the
response — the POST itself returns `204` (it succeeds server-side) but `fetch`
rejects with a bare `NetworkError`. Desktop (Electron/Node `fetch`) is not
subject to CORS, so this is web-only.

Add the deployed web origin (and `http://localhost:5273` for dev) with the
`POST` method to the bucket's CORS config. Until then, attaching + preview work
but sending a task with an attachment fails at the upload step.

### `posthog_web` integration connect origin — required for Slack / GitHub connect

PostHog brokers the Slack/GitHub OAuth server-side and, on completion, redirects
to a target chosen by the `connect_from` value (`posthog_code` →
`posthog-code://…`, `posthog_mobile` → `posthog://…`) — a per-known-client
mapping, not an open redirect. The web host's `startFlow` opens
`.../integrations/authorize/?kind={slack|github}&next=…&connect_from=posthog_web`
in a tab; the backend needs a `posthog_web` mapping for the flow to complete. No
callback relay is required because the integration is created server-side and the
connect hooks refetch `getIntegrations()` on window-focus. See Known gaps for
what remains once the mapping exists.

### CORS — no action needed

Verified against `us.posthog.com`: `/oauth/token` answers preflight with the
request origin allowed, and `/api/*` responds `access-control-allow-origin: *`
with `authorization` in the allowed headers. The agent-proxy stream service has
a CORS origin allowlist (`TASKS_AGENT_PROXY_CORS_ORIGINS` in
`PostHog/posthog/services/agent-proxy`), and `CloudTaskService` falls back to
the CORS-open Django stream leg regardless.

## Known gaps

Limitations that remain even after the hosting setup above — each needs a code
or backend change, not just configuration.

- **Slack / GitHub connect** is client-wired (`startFlow` opens the authorize
URL; the connection is detected via a window-focus refetch of
`getIntegrations()`, not a callback relay) but gated on backend support for the
`posthog_web` connect origin (see Hosting). Even with that mapping, the GitHub
*user* flow (`POST /api/users/@me/integrations/github/start/`) hardcodes
`connect_from: "posthog_code"` in `@posthog/api-client` and returns **400** on
web — it needs a host-parameterized `connect_from`. Where a project already has
the integration connected, the settings pages render the connected/manage state
correctly.
- **Per-device stores** (cloud workspaces, archive, pins, browser tabs) are
localStorage-only — not durable across devices or a site-data clear. Desktop
persists these in SQLite; the browser host needs server-side state to match.
- **Skill dependency expansion** is a passthrough: a skill that declares
`dependencies:` on other skills won't pull them in automatically (pick them
explicitly). This is a pipeline gap, not just a web gap — `exportSkill` strips
SKILL.md frontmatter and the team-skills API has no `dependencies` field, so
the dependency list never reaches any client (desktop only expands local
on-disk skills). Needs `dependencies` carried end-to-end through
export → publish → the LlmSkill API (backend) → `fetchSkillForInstall`.
68 changes: 0 additions & 68 deletions apps/web/dev-server.mjs

This file was deleted.

16 changes: 13 additions & 3 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,32 +7,42 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@agentclientprotocol/sdk": "0.22.1",
"@inversifyjs/strongly-typed": "2.2.0",
"@pierre/diffs": "^1.2.10",
"@posthog/agent": "workspace:*",
"@posthog/core": "workspace:*",
"@posthog/di": "workspace:*",
"@posthog/host-router": "workspace:*",
"@posthog/host-trpc": "workspace:*",
"@posthog/platform": "workspace:*",
"@posthog/shared": "workspace:*",
"@posthog/ui": "workspace:*",
"@posthog/workspace-client": "workspace:*",
"@tanstack/react-query": "^5.100.14",
"@trpc/client": "^11.17.0",
"@trpc/server": "^11.17.0",
"@trpc/tanstack-react-query": "^11.17.0",
"inversify": "^7.10.6",
"react": "19.2.6",
"react-dom": "19.2.6",
"reflect-metadata": "^0.2.2"
"reflect-metadata": "^0.2.2",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@tanstack/router-plugin": "catalog:",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.2.1",
"jsdom": "^26.0.0",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3",
"vite": "^6.0.7"
"vite": "^6.0.7",
"vitest": "^4.1.8"
}
}
16 changes: 13 additions & 3 deletions apps/web/src/Providers.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import { HostTRPCProvider } from "@posthog/host-router/react";
import { ThemeWrapper } from "@posthog/ui/primitives/ThemeWrapper";
import { WorkspaceClientProvider } from "@posthog/workspace-client/provider";
import { QueryClientProvider } from "@tanstack/react-query";
import type React from "react";
import { HotkeysProvider } from "react-hotkeys-hook";
import { queryClient } from "./web-container";
import { hostTrpcClient } from "./web-trpc";

// Web transport wiring — the per-host counterpart of apps/code's Providers.tsx.
// @posthog/ui consumes the HOST router context (useHostTRPCClient), so web only
// needs HostTRPCProvider over the HTTP client. No electron TrpcRouter context.
// @posthog/ui consumes the HOST router context (useHostTRPCClient), so web needs
// HostTRPCProvider over the in-process client. No electron TrpcRouter context.
//
// It also mounts WorkspaceClientProvider with connection={null}: several
// task-detail components (useTaskData, FileTreePanel, staging) call
// useWorkspaceTRPC() unconditionally, so the context must exist or they throw.
// There is no workspace-server on the web host, so the provider points at its
// UNAVAILABLE dead URL — those local-only calls reject as failed queries (and
// are gated on a repoPath that cloud tasks never have, so they don't fire).

export const Providers: React.FC<{ children: React.ReactNode }> = ({
children,
Expand All @@ -17,7 +25,9 @@ export const Providers: React.FC<{ children: React.ReactNode }> = ({
<HotkeysProvider>
<QueryClientProvider client={queryClient}>
<HostTRPCProvider trpcClient={hostTrpcClient} queryClient={queryClient}>
<ThemeWrapper>{children}</ThemeWrapper>
<WorkspaceClientProvider connection={null} queryClient={queryClient}>
<ThemeWrapper>{children}</ThemeWrapper>
</WorkspaceClientProvider>
</HostTRPCProvider>
</QueryClientProvider>
</HotkeysProvider>
Expand Down
Loading
Loading