Skip to content

feat(try-cli): free-trial gateway + signed-in BYO tier for AI demos#882

Merged
Atharva-Kanherkar merged 2 commits into
mainfrom
feat/try-cli-gateway
May 29, 2026
Merged

feat(try-cli): free-trial gateway + signed-in BYO tier for AI demos#882
Atharva-Kanherkar merged 2 commits into
mainfrom
feat/try-cli-gateway

Conversation

@Atharva-Kanherkar
Copy link
Copy Markdown
Collaborator

Summary

Adds the free trial + signed-in auth model for the Try CLI AI agents, the safe way. Follow-up to #881.

  • Anonymous visitor: try Claude Code / Codex / Grok for ~7 min with no login, no API key.
  • Sign in with AgentClash: longer (~20 min) session where they bring their own provider credentials (runs on their quota, not ours).

Why a gateway (and not key injection)

We never put our provider keys in the sandbox — that violates OpenAI/Anthropic terms and anyone in the shell can cat the key and drain the account. Instead a metered passthrough gateway lives in the try-cli service:

  • Real ANTHROPIC_/OPENAI_/XAI_API_KEY live only on the service.
  • Each anonymous session is minted a short-lived, spend-capped proxy token (separate from the loggable session id). The sandbox CLIs send it as their key and point their base URL at /gw/<provider>; the gateway validates it against a live, in-budget session, swaps in the real key, streams the response straight back, and meters real token usage.
  • Caps: per-session USD budget + a durable Redis-backed global daily ceiling (the real backstop for a public no-login endpoint — fails closed if Redis is down) + per-request output-token clamp + request body-size cap.
  • Signed-in tier uses BYO creds (no gateway, no cost). The Vercel proxy forwards the user id behind a shared secret so the public service can't be spoofed into the authed tier.

Verified end-to-end (real E2B sandbox)

  • Claude Code & Codex actually route through a passthrough gateway when pointed at a custom base URL (Codex via model_providers config — the risky case, confirmed working).
  • Valid token → 200; invalid/expired token → 401; spend accumulates and the per-session cap returns 402 once crossed; the durable daily ledger accumulates across sessions.
  • PTY env injection confirmed; metering parses real usage (forces identity encoding so it isn't reading gzip).
  • Web tsc + eslint (0 errors) + next build pass.

Tiers / wiring

Anonymous (free trial) Signed-in
Length ~7 min ~20 min
Creds our keys via gateway, capped their own (BYO)
Claude Code ANTHROPIC_AUTH_TOKEN + base URL /login
Codex config.toml custom provider --device-auth
Grok GROK_BASE_URL + token own XAI_API_KEY
OpenCode BYO-only (v1) paste key

Deploy (post-merge) — env to set

On the Railway try-cli service: ANTHROPIC_API_KEY, OPENAI_API_KEY, XAI_API_KEY, TRY_CLI_GATEWAY_URL (its own public URL), REDIS_URL (project Redis), TRY_CLI_PROXY_SECRET, optional caps (GW_DAILY_CEILING_USD, GW_SESSION_BUDGET_USD, …). On Vercel: matching TRY_CLI_PROXY_SECRET. Full list in docs/deployment/try-cli.md.

Grok's free trial only activates once XAI_API_KEY is set on the service.

Anonymous visitors can now try the AI coding agents with no login and no API
key for a few minutes; signing in with AgentClash unlocks a longer session
where they bring their own provider credentials.

Safety model — our keys are NEVER injected into the sandbox (that violates
provider terms and is trivially exfiltratable). Instead:

- A metered passthrough gateway lives in the try-cli service (/gw/<provider>);
  the real ANTHROPIC/OPENAI/XAI keys live only there.
- Each anonymous session is minted a short-lived, spend-capped proxy token
  (separate from the loggable session id). The sandbox CLIs send it as their
  key and point their base URL at the gateway; the gateway validates the token
  against a live in-budget session, swaps in the real key, streams the response
  straight back, and meters real token usage.
- Per-session USD budget + a durable Redis-backed global daily ceiling (the
  hard backstop for a public no-login endpoint). Fails closed if Redis is down.
  Per-request output-token clamp + request body-size cap bound worst case.
- Verified end-to-end in a real sandbox: claude/codex route through the gateway;
  invalid/expired tokens 401; spend accumulates and the per-session cap returns
  402 once crossed.

Tiers:
- Anonymous: ~7 min, gateway-backed, wired for claude-code (Anthropic),
  codex (OpenAI, via config.toml), grok (xAI). opencode stays BYO-only for now.
- Authenticated: ~20 min, BYO credentials (no gateway, no cost to us). The
  Vercel proxy forwards the signed-in user id behind a shared secret so the
  public service can't be spoofed into the authed tier.

Frontend: free-trial badge + countdown, a "Sign in to continue" panel, and the
BYO steps reframed as "use your own account" during a trial.

Schema/wiring is config-driven; caps + session lengths are env-tunable. Docs
updated with the gateway deploy config. Claude uses ANTHROPIC_AUTH_TOKEN (Bearer)
to skip the first-use key-approval prompt.
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 29, 2026

Greptile Summary

This PR introduces a metered passthrough gateway and a two-tier session model for the Try CLI demo: anonymous visitors get a short (~7 min) free trial backed by AgentClash's provider keys (spend-capped per session and by a Redis-backed daily ceiling), while signed-in users get a longer (~20 min) BYO-credentials session at no cost to the service.

  • Gateway (gateway.ts, daily-ledger.ts): New /gw/<provider>/... endpoint validates a per-session proxy token, strips it, injects the real provider key, streams the response back, and meters USD spend via an async tee. The Redis-backed daily ledger is the hard backstop; the per-session budget is the per-visitor comfort cap.
  • Session tiers (sessions.ts, index.ts): SessionManager gains proxy token generation, gateway wiring (wireGatewayTrial), and spend tracking. sessionTier() in the server uses a shared secret forwarded by the Vercel proxy to distinguish authenticated from anonymous requests.
  • Vercel proxy (route.ts): Calls withAuth() and, when a secret is configured, attaches the user id and secret to the forwarded request so the Railway service can grant the longer session.

Confidence Score: 3/5

The gateway key-security model is sound — provider keys stay server-side and proxy tokens are properly scoped — but the sessionTier() guard inverts its intended fallback, making the service publicly exploitable for extended sandbox time whenever the env var is unconfigured.

The PROXY_SECRET guard in sessionTier() evaluates to falsy when the variable is unset, meaning any caller who adds x-agentclash-user to a direct Railway request bypasses the tier check. This is wrong on a publicly accessible endpoint and contradicts the deployment docs. The per-session budget also has a concurrency hole where multiple in-flight requests can all pass the spend check before any of them are metered. Both issues are on the changed path and would be present from the first deploy.

services/try-cli/server/index.ts for the tier-guard inversion; services/try-cli/server/gateway.ts for async metering and concurrent budget checks.

Security Review

  • Auth tier bypass when TRY_CLI_PROXY_SECRET is unset (index.ts L23): The PROXY_SECRET && ... guard short-circuits to falsy when the env var is not configured, so any external request carrying an x-agentclash-user header is granted the authenticated (20-minute) tier. This is exploitable from the public Railway URL and is the inverse of the documented "safe default."
  • No other credential leakage identified: Provider keys are never sent into the E2B sandbox; proxy tokens are high-entropy, session-scoped, and invalidated on destroy. Gateway header scrubbing (host, authorization, x-api-key) before upstream forwarding is correct.

Important Files Changed

Filename Overview
services/try-cli/server/index.ts Adds sessionTier() helper and /gw/ routing. Critical logic bug: the PROXY_SECRET guard short-circuits to false when the env var is unset, granting the authenticated tier to any request that includes x-agentclash-user.
services/try-cli/server/gateway.ts New metered passthrough gateway. Well-structured with good auth and header hygiene. Spend metering is async and does not reserve budget before forwarding, allowing multiple concurrent requests to race past the per-session cap.
services/try-cli/server/sessions.ts Extends sessions with tier, proxyToken, budget tracking, and gateway wiring. Token generation and cleanup are correct. wireGatewayTrial is properly guarded and sets trialWired only after env injection.
services/try-cli/server/daily-ledger.ts Redis-backed daily spend ledger with correct fail-closed get() behavior. add() falls back to 0 on error rather than Infinity, making the error mode inconsistent with the documented design.
web/src/app/api/try/[[...path]]/route.ts Vercel proxy now adds x-agentclash-user and x-agentclash-proxy-secret headers when the user is signed in. Auth lookup via withAuth() is wrapped in try/catch so unauthenticated requests degrade gracefully.
web/src/components/try-cli/demo-client.tsx Adds tier and trial state, "Free trial" / "Your account" badges, and the free-trial CTA card. All state is derived from server responses; no client-side trust decisions.

Sequence Diagram

sequenceDiagram
    participant Browser
    participant Vercel as Vercel (Next.js proxy)
    participant Service as Try-CLI Service (Railway)
    participant E2B as E2B Sandbox
    participant Provider as AI Provider

    Browser->>Vercel: "POST /api/try/sessions {slug}"
    Vercel->>Vercel: withAuth() → user?
    Vercel->>Service: POST /api/sessions + x-agentclash-user + x-agentclash-proxy-secret
    Service->>Service: "sessionTier() → anonymous|authenticated"
    Service->>E2B: Sandbox.create()
    Service->>Service: wireGatewayTrial() — inject ANTHROPIC_BASE_URL + proxyToken into PTY env
    Service-->>Vercel: "{id, tier, expiresAt}"
    Vercel-->>Browser: session info

    Browser->>Service: "WS /ws?sessionId=…"
    Service->>E2B: PTY attach (envs include proxyToken as API key)

    E2B->>Service: POST /gw/anthropic/v1/messages (Authorization: Bearer proxyToken)
    Service->>Service: validateGatewayToken() + budget check + daily ceiling check
    Service->>Provider: forward with real ANTHROPIC_API_KEY (streaming)
    Provider-->>Service: SSE stream
    Service-->>E2B: stream (toClient)
    Service->>Service: meter(toMeter) — parse usage → addGatewaySpend + daily.add()
Loading

Fix All in Codex

Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
services/try-cli/server/index.ts:20-27
**Auth tier bypass when `PROXY_SECRET` is unset**

The condition `PROXY_SECRET && ...` short-circuits to `undefined` (falsy) when the env var is not set, so the inner comparison is never evaluated. Any request that includes an `x-agentclash-user` header — even one sent directly to the Railway service by an attacker — then falls through to `return "authenticated"`. This is the opposite of the documented "safe default" (anonymous). An attacker who knows the header name can always claim the 20-minute tier, burning E2B compute, without needing any secret.

```suggestion
function sessionTier(req: Request): "anonymous" | "authenticated" {
  const user = req.headers.get("x-agentclash-user");
  if (!user) return "anonymous";
  // Require the secret to be both configured AND matching. If it is unset, fall
  // back to anonymous so a misconfigured deploy never auto-grants the authed tier.
  if (!PROXY_SECRET || req.headers.get("x-agentclash-proxy-secret") !== PROXY_SECRET) {
    return "anonymous";
  }
  return "authenticated";
}
```

### Issue 2 of 3
services/try-cli/server/gateway.ts:170-176
**Per-session budget can be exceeded by concurrent requests**

The budget check reads `session.gatewaySpentUsd` synchronously at request start, but the `meter()` function that updates it only runs after the full response stream is consumed. Two (or more) requests that start before either completes both see a spend of `0`, both pass the `>= gatewayBudgetUsd` check, and both get forwarded upstream — potentially consuming 2× (or more) the intended budget. The global daily ceiling is the real backstop, but the per-session cap can be silently exceeded.

```suggestion
  // Per-session budget. Include already-reserved (in-flight) spend so concurrent
  // requests can't bypass the cap while a prior request is still streaming.
  if (session.gatewaySpentUsd + (session.gatewayReservedUsd ?? 0) >= session.gatewayBudgetUsd) {
    return json(
      { error: "Free trial budget reached. Sign in with your own account to continue." },
      402,
    );
  }
```

### Issue 3 of 3
services/try-cli/server/daily-ledger.ts:45-52
**`add()` returns `0` on Redis failure instead of `Infinity`**

`get()` correctly returns `Infinity` on Redis errors to fail closed, but `add()` falls back to `0`. Intermittent Redis failures during `add()` silently under-count daily spend, and the fail-closed guarantee then depends entirely on `get()` also failing — a narrower window than the design intends. Returning `Infinity` makes both methods consistent.

```suggestion
      async add(usd) {
        return safe(async () => {
          const key = todayKey();
          const total = await client.send("INCRBYFLOAT", [key, String(usd)]);
          await client.send("EXPIRE", [key, "172800"]); // 48h
          return parseFloat(String(total));
        }, Number.POSITIVE_INFINITY);
      },
```

Reviews (1): Last reviewed commit: "feat(try-cli): free-trial gateway + sign..." | Re-trigger Greptile

Comment on lines +20 to +27
function sessionTier(req: Request): "anonymous" | "authenticated" {
const user = req.headers.get("x-agentclash-user");
if (!user) return "anonymous";
if (PROXY_SECRET && req.headers.get("x-agentclash-proxy-secret") !== PROXY_SECRET) {
return "anonymous"; // header present but unsigned — don't trust it
}
return "authenticated";
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Auth tier bypass when PROXY_SECRET is unset

The condition PROXY_SECRET && ... short-circuits to undefined (falsy) when the env var is not set, so the inner comparison is never evaluated. Any request that includes an x-agentclash-user header — even one sent directly to the Railway service by an attacker — then falls through to return "authenticated". This is the opposite of the documented "safe default" (anonymous). An attacker who knows the header name can always claim the 20-minute tier, burning E2B compute, without needing any secret.

Suggested change
function sessionTier(req: Request): "anonymous" | "authenticated" {
const user = req.headers.get("x-agentclash-user");
if (!user) return "anonymous";
if (PROXY_SECRET && req.headers.get("x-agentclash-proxy-secret") !== PROXY_SECRET) {
return "anonymous"; // header present but unsigned — don't trust it
}
return "authenticated";
}
function sessionTier(req: Request): "anonymous" | "authenticated" {
const user = req.headers.get("x-agentclash-user");
if (!user) return "anonymous";
// Require the secret to be both configured AND matching. If it is unset, fall
// back to anonymous so a misconfigured deploy never auto-grants the authed tier.
if (!PROXY_SECRET || req.headers.get("x-agentclash-proxy-secret") !== PROXY_SECRET) {
return "anonymous";
}
return "authenticated";
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: services/try-cli/server/index.ts
Line: 20-27

Comment:
**Auth tier bypass when `PROXY_SECRET` is unset**

The condition `PROXY_SECRET && ...` short-circuits to `undefined` (falsy) when the env var is not set, so the inner comparison is never evaluated. Any request that includes an `x-agentclash-user` header — even one sent directly to the Railway service by an attacker — then falls through to `return "authenticated"`. This is the opposite of the documented "safe default" (anonymous). An attacker who knows the header name can always claim the 20-minute tier, burning E2B compute, without needing any secret.

```suggestion
function sessionTier(req: Request): "anonymous" | "authenticated" {
  const user = req.headers.get("x-agentclash-user");
  if (!user) return "anonymous";
  // Require the secret to be both configured AND matching. If it is unset, fall
  // back to anonymous so a misconfigured deploy never auto-grants the authed tier.
  if (!PROXY_SECRET || req.headers.get("x-agentclash-proxy-secret") !== PROXY_SECRET) {
    return "anonymous";
  }
  return "authenticated";
}
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Codex

Comment thread services/try-cli/server/gateway.ts Outdated
Comment on lines +170 to +176
// Per-session budget.
if (session.gatewaySpentUsd >= session.gatewayBudgetUsd) {
return json(
{ error: "Free trial budget reached. Sign in with your own account to continue." },
402,
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Per-session budget can be exceeded by concurrent requests

The budget check reads session.gatewaySpentUsd synchronously at request start, but the meter() function that updates it only runs after the full response stream is consumed. Two (or more) requests that start before either completes both see a spend of 0, both pass the >= gatewayBudgetUsd check, and both get forwarded upstream — potentially consuming 2× (or more) the intended budget. The global daily ceiling is the real backstop, but the per-session cap can be silently exceeded.

Suggested change
// Per-session budget.
if (session.gatewaySpentUsd >= session.gatewayBudgetUsd) {
return json(
{ error: "Free trial budget reached. Sign in with your own account to continue." },
402,
);
}
// Per-session budget. Include already-reserved (in-flight) spend so concurrent
// requests can't bypass the cap while a prior request is still streaming.
if (session.gatewaySpentUsd + (session.gatewayReservedUsd ?? 0) >= session.gatewayBudgetUsd) {
return json(
{ error: "Free trial budget reached. Sign in with your own account to continue." },
402,
);
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: services/try-cli/server/gateway.ts
Line: 170-176

Comment:
**Per-session budget can be exceeded by concurrent requests**

The budget check reads `session.gatewaySpentUsd` synchronously at request start, but the `meter()` function that updates it only runs after the full response stream is consumed. Two (or more) requests that start before either completes both see a spend of `0`, both pass the `>= gatewayBudgetUsd` check, and both get forwarded upstream — potentially consuming 2× (or more) the intended budget. The global daily ceiling is the real backstop, but the per-session cap can be silently exceeded.

```suggestion
  // Per-session budget. Include already-reserved (in-flight) spend so concurrent
  // requests can't bypass the cap while a prior request is still streaming.
  if (session.gatewaySpentUsd + (session.gatewayReservedUsd ?? 0) >= session.gatewayBudgetUsd) {
    return json(
      { error: "Free trial budget reached. Sign in with your own account to continue." },
      402,
    );
  }
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Codex

Comment on lines +45 to +52
async add(usd) {
return safe(async () => {
const key = todayKey();
const total = await client.send("INCRBYFLOAT", [key, String(usd)]);
await client.send("EXPIRE", [key, "172800"]); // 48h
return parseFloat(String(total));
}, 0);
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 add() returns 0 on Redis failure instead of Infinity

get() correctly returns Infinity on Redis errors to fail closed, but add() falls back to 0. Intermittent Redis failures during add() silently under-count daily spend, and the fail-closed guarantee then depends entirely on get() also failing — a narrower window than the design intends. Returning Infinity makes both methods consistent.

Suggested change
async add(usd) {
return safe(async () => {
const key = todayKey();
const total = await client.send("INCRBYFLOAT", [key, String(usd)]);
await client.send("EXPIRE", [key, "172800"]); // 48h
return parseFloat(String(total));
}, 0);
},
async add(usd) {
return safe(async () => {
const key = todayKey();
const total = await client.send("INCRBYFLOAT", [key, String(usd)]);
await client.send("EXPIRE", [key, "172800"]); // 48h
return parseFloat(String(total));
}, Number.POSITIVE_INFINITY);
},
Prompt To Fix With AI
This is a comment left during a code review.
Path: services/try-cli/server/daily-ledger.ts
Line: 45-52

Comment:
**`add()` returns `0` on Redis failure instead of `Infinity`**

`get()` correctly returns `Infinity` on Redis errors to fail closed, but `add()` falls back to `0`. Intermittent Redis failures during `add()` silently under-count daily spend, and the fail-closed guarantee then depends entirely on `get()` also failing — a narrower window than the design intends. Returning `Infinity` makes both methods consistent.

```suggestion
      async add(usd) {
        return safe(async () => {
          const key = todayKey();
          const total = await client.send("INCRBYFLOAT", [key, String(usd)]);
          await client.send("EXPIRE", [key, "172800"]); // 48h
          return parseFloat(String(total));
        }, Number.POSITIVE_INFINITY);
      },
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Codex

- sessionTier: fall back to anonymous when TRY_CLI_PROXY_SECRET is unset
  (the previous `SECRET && ...` short-circuit auto-granted the authed tier to
  any request carrying the header on a misconfigured deploy).
- gateway: hold a per-request spend reservation so concurrent requests can't
  all pass the per-session budget check before any finishes metering; released
  in meter()'s finally and on every early-return/error path.
- daily-ledger: add() now fails closed (Infinity) on Redis error, matching get().
@Atharva-Kanherkar
Copy link
Copy Markdown
Collaborator Author

Addressed all three Greptile findings in 4005b67:

  1. Auth tier bypasssessionTier now falls back to anonymous when TRY_CLI_PROXY_SECRET is unset (the SECRET && … short-circuit previously auto-granted the authed tier to any request with the header).
  2. Concurrent budget bypass — added a per-request spend reservation (gatewayReservedUsd) counted in the budget check, released in meter()'s finally and on every early-return/error path. Concurrent requests can no longer all pass before metering completes.
  3. daily-ledger fail-closedadd() now returns Infinity on Redis error, matching get().

Verified: service transpiles clean.

@Atharva-Kanherkar Atharva-Kanherkar merged commit e1f4dbf into main May 29, 2026
5 checks passed
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