Skip to content

Commit 40ce60f

Browse files
authored
Merge pull request #240 from paperclipinc/staging
Promote staging → main: cloud onboarding parity + BYOK (managed-experience revert)
2 parents b57c11d + 800195f commit 40ce60f

52 files changed

Lines changed: 994 additions & 1733 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/docker.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ on:
55
branches:
66
- "master"
77
- "main"
8+
# Staging image channel: pushes to `staging` build staging-<ts>-<sha>
9+
# tags that only the staging.paperclip.inc Flux ImagePolicy tracks.
10+
# Prod keeps tracking main-* — promotion is merging staging -> main.
11+
- "staging"
812
tags:
913
- "v*"
1014
# Manual fallback: occasionally a push event fails to trigger this workflow,
@@ -44,6 +48,7 @@ jobs:
4448
tags: |
4549
type=raw,value=latest,enable={{is_default_branch}}
4650
type=raw,value=main-{{date 'YYYYMMDDHHmmss'}}-{{sha}},enable={{is_default_branch}}
51+
type=raw,value=staging-{{date 'YYYYMMDDHHmmss'}}-{{sha}},enable=${{ github.ref == 'refs/heads/staging' }}
4752
type=semver,pattern={{version}}
4853
type=semver,pattern={{major}}.{{minor}}
4954
type=sha
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Fork: Managed-Experience Removal + PR A Adoption Implementation Plan
2+
3+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4+
5+
**Goal:** Delete the fork-only "managed experience" (forced opencode + hosted model) and adopt upstream PR A (lazy cloud-tenant onboarding), so cloud users get the self-hosted experience: real onboarding, free runtime choice, BYO keys.
6+
7+
**Architecture:** Two phases on one branch. Phase 1 cherry-picks the 8 PR A commits (upstream lazy onboarding — branch `contrib/cloud-tenant-lazy-onboarding`, https://github.com/paperclipai/paperclip/pull/9466). Phase 2 deletes the managed experience using `git diff origin/master -- <file>` as the authority: for every shared file, managed-related fork deltas are reverted to upstream's shape while non-managed fork deltas (cloud_tenant auth, seed CLI, cloud billing surfaces) are preserved. Fork-only managed/cloud-onboarding modules are deleted outright.
8+
9+
**Tech Stack:** Same as repo (Express/Drizzle/React/Vitest).
10+
11+
**Spec:** `docs/superpowers/specs/2026-07-12-cloud-onboarding-parity-design.md` §6.2 (fork deletions), §6.3, §7 "Fork-only PR" (on branch `feat/cloud-onboarding-parity-spec` in the main checkout; this plan is self-contained).
12+
13+
## Global Constraints
14+
15+
- Work in worktree `/Users/jannesstubbemann/repos/paperclip/wt-fork-managed-revert`, branch `feat/managed-experience-revert` (based on fork/main b57c11d7a).
16+
- Never commit `pnpm-lock.yaml`. No DB migrations.
17+
- **Preserve fork deltas that are NOT managed-experience:** cloud_tenant middleware additions, the seed CLI (`auth seed-instance-admin`), cloud billing UI/proxies (CloudTrialBanner, cloudBilling api, budgets checkout paths), `PAPERCLIP_CLOUD_MAX_CONCURRENT_RUNS_PER_COMPANY` and other cloud env reads, `PAPERCLIP_DEFAULT_THEME`, adapter model listing via `PAPERCLIP_ADAPTER_MODELS` (that env is removed in the MONO repo, not here — the code path is generic).
18+
- **Preserve additional-company creation in cloud:** `ui/src/api/cloudCompanies.ts` stays. The wizard's cloud branch that creates ADDITIONAL companies via the gateway (`cloudCompaniesApi.create` → navigate to new stack URL) must survive, but ONLY for the case where the user already has ≥1 company; the first-company path must be upstream's `companiesApi.create` (PR A makes the server force the stack id). The rename-in-place branch and `ui/src/lib/cloud-onboarding.ts` (+ test) are deleted.
19+
- Deletion authority: `git fetch origin master` then `git diff origin/master -- <file>`. A hunk is "managed" if it references managedExperience / ManagedAgentDefaults / ManagedRun / PAPERCLIP_MANAGED_* / managed-mode UI folding. When in doubt whether a hunk is managed vs other fork feature: STOP and report the hunk (do not guess).
20+
- After Phase 2, `grep -rn "managedExperience\|ManagedAgentDefaults\|ManagedRun\|PAPERCLIP_MANAGED" server/src ui/src cli/src packages/shared/src` must return ZERO hits (excluding this plan file).
21+
- Conventional commits. Each task ends green: `pnpm --filter @paperclipai/server typecheck && pnpm --filter @paperclipai/ui typecheck` plus the named suites.
22+
23+
---
24+
25+
### Task 1: Cherry-pick PR A (8 commits)
26+
27+
**Files:** the 8 commits touch `server/src/services/cloud-tenant-company.{ts,test.ts}`, `server/src/middleware/auth.ts` + `cloud-tenant-actor.test.ts`, `server/src/types/express.d.ts`, `server/src/routes/companies.ts` + `companies-cloud-create.test.ts`, `server/src/routes/access.ts`, `server/src/__tests__/auth-session-route.test.ts`, `ui/src/api/access.ts`, `ui/src/lib/cloud-zero-company.{ts,test.ts}`, `ui/src/components/{WorkspaceSetupPendingPage,CloudAccessGate,Layout,OnboardingWizard}.tsx`, `docs/deploy/deployment-modes.md`.
28+
29+
- [ ] Step 1: `git fetch fork contrib/cloud-tenant-lazy-onboarding && git fetch origin master`
30+
- [ ] Step 2: `git cherry-pick 4cb219dd7^..72a413916` (the 8 PR A commits; resolve conflicts toward PR A's content — expected conflict sites: `auth.ts` (fork kept `resolveCloudTenantWsAuth` + `friendlyCloudCompanyName` which upstream lacks: keep `resolveCloudTenantWsAuth` (fork WS feature, re-point it at the imported `cloudTenantCompanyId`), delete `friendlyCloudCompanyName` + `issuePrefixForCloudStack` per PR A), `Layout.tsx`, `OnboardingWizard.tsx` (fork cloud branches collide with PR A's 409 catch — keep both PR A's catch AND the fork cloud-create branch for now; Task 4 rewrites it), `CloudAccessGate.tsx`.
31+
- [ ] Step 3: Fork's own middleware tests (`cloud-tenant-actor.test.ts`) may have fork-added cases (multi-membership select) that PR A's rewrite dropped upstream — reconcile: keep PR A's suite; re-add fork-only cases ONLY if the fork code they test still exists (report what you decided).
32+
- [ ] Step 4: `npx vitest run server/src/middleware/cloud-tenant-actor.test.ts server/src/services/cloud-tenant-company.test.ts server/src/routes/companies-cloud-create.test.ts server/src/__tests__/auth-session-route.test.ts && cd ui && npx vitest run src/lib/cloud-zero-company.test.ts` — all green; both typechecks green.
33+
- [ ] Step 5: Commit state is the 8 cherry-picked commits (+ conflict resolutions inside them). No extra commit needed.
34+
35+
### Task 2: Delete managed-experience server code
36+
37+
**Files (delete):** `server/src/services/managed-agent-defaults.ts`, `server/src/services/managed-agent-defaults.test.ts`
38+
**Files (revert managed hunks vs origin/master):** `server/src/services/heartbeat.ts` (~:266-267 import, ~:5488 `overrideAgentForManagedRun` in getAgent), `server/src/services/default-agent-instructions.ts` (:3, :39), `server/src/services/instance-settings.ts` (env-authoritative `managedExperience` block), `server/src/services/workspace-runtime.ts`, `server/src/index.ts`, `packages/shared/src/types/instance.ts`, `packages/shared/src/validators/instance.ts`, and the agent create/hire route(s) calling `applyManagedAgentDefaults` (grep — earlier at `server/src/routes/agents.ts`).
39+
**Tests:** `server/src/__tests__/instance-settings-service.test.ts`, `server/src/__tests__/workspace-runtime.test.ts` — remove managed-specific cases (compare with origin/master versions; if the whole test exists upstream, revert to upstream's version).
40+
41+
- [ ] Step 1: For each file run `git diff origin/master -- <file>`, revert exactly the managed hunks (method in Global Constraints). Delete the two fork-only files.
42+
- [ ] Step 2: `grep -rn "managedExperience\|ManagedAgentDefaults\|ManagedRun\|PAPERCLIP_MANAGED" server/src packages/shared/src` → zero hits.
43+
- [ ] Step 3: `pnpm --filter @paperclipai/server typecheck && npx vitest run server/src/__tests__/instance-settings-service.test.ts server/src/__tests__/workspace-runtime.test.ts server/src/services` — green.
44+
- [ ] Step 4: Commit `revert(managed): remove managed-experience server enforcement and flag`.
45+
46+
### Task 3: Delete managed-experience UI code
47+
48+
**Files (revert managed hunks vs origin/master):** `ui/src/components/AgentConfigForm.tsx` (+ `.test.tsx`), `ui/src/components/OnboardingWizard.tsx` (+ `.test.tsx`; ONLY managed hunks here — cloud branches are Task 4), `ui/src/components/NewAgentDialog.tsx`, `ui/src/pages/Dashboard.tsx`, `ui/src/components/CloudTrialBanner.test.tsx` (managed refs only — the banner itself is cloud billing, KEEP), `ui/src/pages/InstanceExperimentalSettings.test.tsx`.
49+
50+
- [ ] Step 1: Revert managed hunks per file (adapter/model pickers render unconditionally, managed-mode copy removed, `omitManagedAdapterAndModel` logic removed, hire payload always includes adapterType/adapterConfig).
51+
- [ ] Step 2: `grep -rn "managedExperience\|managed" ui/src --include="*.tsx" --include="*.ts" | grep -iv "management\|manager"` → review every remaining hit is non-managed-experience (report the list).
52+
- [ ] Step 3: `pnpm --filter @paperclipai/ui typecheck && cd ui && npx vitest run src/components/AgentConfigForm.test.tsx src/components/OnboardingWizard.test.tsx src/components/CloudTrialBanner.test.tsx src/pages/InstanceExperimentalSettings.test.tsx` — green (adapt tests that asserted managed folding).
53+
- [ ] Step 4: Commit `revert(managed): unconditional runtime/model pickers in UI`.
54+
55+
### Task 4: Cloud onboarding fork-artifact cleanup (post-PR A shape)
56+
57+
**Files:** delete `ui/src/lib/cloud-onboarding.ts` + `ui/src/lib/cloud-onboarding.test.ts`; rewrite the wizard's cloud branch in `OnboardingWizard.tsx`; check `ui/src/App.tsx` / `ui/src/lib/onboarding-route.ts` for fork-only cloud onboarding wiring vs origin/master and revert non-PR-A deltas.
58+
59+
- [ ] Step 1: Delete `cloud-onboarding.ts` + test; remove any imports (`grep -rn "cloud-onboarding" ui/src`).
60+
- [ ] Step 2: Rewrite `handleConfirmMission`'s cloud logic to exactly this decision: `const isCloud = health?.deploymentMode === "authenticated"` (or the wizard's existing cloud detection); if `isCloud && companies.length > 0` → existing gateway path (`cloudCompaniesApi.create()` + `window.location.assign(created.url)`, keep its 402/409 error handling); otherwise → upstream/PR A path (`companiesApi.create({ name })`, which in cloud creates the stack company server-side). Delete the rename-in-place branch (`createdCompanyId` early-return stays — that's upstream — but the fork's "rename existing cloud company" logic goes).
61+
- [ ] Step 3: `cd ui && npx vitest run src/components/OnboardingWizard.test.tsx && pnpm --filter @paperclipai/ui typecheck` — green; adapt wizard tests asserting the rename branch.
62+
- [ ] Step 4: Commit `revert(cloud-onboarding): first company onboards via standard wizard; gateway path only for additional companies`.
63+
64+
### Task 5: Full sweep + fork PR via staging
65+
66+
- [ ] Step 1: `pnpm typecheck && pnpm test` — triage: known master-side flake (heartbeat-process-recovery "bounded retries"), pre-existing env failures (heartbeat-workspace-branch-containment /tmp realpath, workspace-runtime adopt-live-auto-port) are ignorable; anything touching managed/cloud-onboarding files is ours.
67+
- [ ] Step 2: Grep gate from Global Constraints (zero managed refs repo-wide).
68+
- [ ] Step 3: Push to the fork's staging branch: `git push fork feat/managed-experience-revert:staging --force-with-lease` (staging branch currently = fork/main + docker.yml staging channel commit; force-with-lease is expected — RE-APPLY the docker.yml staging-channel commit first: cherry-pick `845e14d` from `fork/staging` so the image channel survives). Verify a `staging-*` image builds (gh run list --branch staging).
69+
- [ ] Step 4: STOP — hand back to the controller for staging.paperclip.inc verification (image auto-rolls via Flux; controller runs diag-staging + browser checks) before any fork-main PR.
70+
71+
### Task 6 (controller, after staging verification): fork PR
72+
73+
- `gh pr create` on paperclipinc/paperclip: base `main`, head `feat/managed-experience-revert`, title `revert(managed): remove managed experience; adopt upstream lazy cloud onboarding (PR A)`. Body: what/why, staging verification evidence, hard-cut note for existing tenants, follow-ups (mono env cleanup). Merge per fork policy (`--squash --admin`) after CI.
74+
75+
## Self-Review Notes
76+
77+
- The wizard is the riskiest file (PR A cherry-pick conflict + managed hunks + cloud-branch rewrite touch the same function). Tasks 1/3/4 are ordered so each rewrite lands on a green, committed state.
78+
- `PAPERCLIP_ADAPTER_MODELS` model-listing code is generic and stays; only the MONO env value is removed later (spec §6.6).
79+
- CloudTrialBanner/billing surfaces are cloud-billing (area 4), not managed experience — preserved.

docs/deploy/deployment-modes.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,23 @@ pnpm paperclipai onboard
5959
# Choose "authenticated" -> "public"
6060
```
6161

62+
### Tenant company creation (trusted-header gateways)
63+
64+
When a trusted gateway fronts the instance with `x-paperclip-cloud-*` headers,
65+
the stack's company is **not** pre-provisioned. The first stack **owner or
66+
admin** to sign in is taken through the standard onboarding wizard; the company
67+
they create is bound to a deterministic id derived from the stack id, so
68+
gateway slug→company routing needs no coordination. Stack **members** and
69+
**support** users see a "workspace is being set up" page until onboarding
70+
completes; their memberships are established automatically on their next
71+
request afterwards. A concurrent second create for the same stack fails with
72+
`409 Conflict` (first to complete wins).
73+
74+
> Behavior change (2026-07): earlier builds auto-created a placeholder company
75+
> ("<Name>'s company") on the first authenticated request. Deployments relying
76+
> on that should complete onboarding once per stack instead; existing company
77+
> rows are unaffected.
78+
6279
## Board Claim Flow
6380

6481
When migrating from `local_trusted` to `authenticated`, Paperclip emits a one-time claim URL at startup:

packages/adapters/claude-local/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,5 @@ Notes:
6565
- The Claude ACP lane requires Node >=22.12.0 and @agentclientprotocol/claude-agent-acp to be installed with this adapter package. Auto engine selection falls back to CLI when those prerequisites are unavailable; explicit engine="acp" fails loudly.
6666
- For ACP runs, model selection is passed through ANTHROPIC_MODEL at ACP server startup; Paperclip-managed Claude permissions and ephemeral skill materialization are handled by the shared ACP engine.
6767
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
68+
- Subscription (Claude Pro/Max) auth in headless/remote runs, where there is no on-disk Claude login, can be provided by setting CLAUDE_CODE_OAUTH_TOKEN in env (mint one with \`claude setup-token\`). Subscription usage/quota reporting requires an on-disk login and is unavailable with token-only auth.
6869
`;

packages/adapters/claude-local/src/server/acp.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
buildClaudeAcpConfig,
88
createClaudeAcpExecutor,
99
nodeVersionMeetsClaudeAcpMinimum,
10+
resolveClaudeAuthAdvice,
1011
resolveClaudeExecutionEngine,
1112
resolveClaudeExecutionEngineForRun,
1213
testClaudeAcpEnvironment,
@@ -446,3 +447,33 @@ describe("claude_local ACP lane", () => {
446447
expect(runtimes[1]?.ensureInputs[0]?.resumeSessionId).toBe("acp-1");
447448
});
448449
});
450+
451+
// NOTE: upstream PR #9488 also adds resolveClaudeAcpBillingIdentity tests; that
452+
// function lands with upstream #9471, which this fork picks up on the next
453+
// routine rebase. Only the auth-advice surface is carried here.
454+
455+
describe("resolveClaudeAuthAdvice (ACP lane)", () => {
456+
it("recognizes CLAUDE_CODE_OAUTH_TOKEN as valid subscription auth", () => {
457+
expect(
458+
resolveClaudeAuthAdvice({ CLAUDE_CODE_OAUTH_TOKEN: "sk-ant-oat01-fake-token-value" }),
459+
).toEqual({
460+
code: "claude_acp_subscription_token_detected",
461+
level: "info",
462+
message:
463+
"CLAUDE_CODE_OAUTH_TOKEN is set; Claude will authenticate with the configured subscription token.",
464+
});
465+
});
466+
467+
it("defers to the ANTHROPIC_API_KEY branch when both are set", () => {
468+
expect(
469+
resolveClaudeAuthAdvice({
470+
ANTHROPIC_API_KEY: "sk-ant-api-fake",
471+
CLAUDE_CODE_OAUTH_TOKEN: "sk-ant-oat01-fake-token-value",
472+
}),
473+
).toBeNull();
474+
});
475+
476+
it("returns null when neither auth signal is present (unchanged local-login guidance)", () => {
477+
expect(resolveClaudeAuthAdvice({})).toBeNull();
478+
});
479+
});

packages/adapters/claude-local/src/server/acp.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,22 @@ function isNonEmpty(value: unknown): value is string {
270270
return typeof value === "string" && value.trim().length > 0;
271271
}
272272

273+
// ACP-lane mirror of the CLI-lane auth advice helper in test.ts: same
274+
// CLAUDE_CODE_OAUTH_TOKEN recognition contract, ACP-prefixed check code to
275+
// match this file's existing `claude_acp_*` naming.
276+
export function resolveClaudeAuthAdvice(env: Record<string, unknown>): AdapterEnvironmentCheck | null {
277+
if (isNonEmpty(env.ANTHROPIC_API_KEY)) return null;
278+
if (isNonEmpty(env.CLAUDE_CODE_OAUTH_TOKEN)) {
279+
return {
280+
code: "claude_acp_subscription_token_detected",
281+
level: "info",
282+
message:
283+
"CLAUDE_CODE_OAUTH_TOKEN is set; Claude will authenticate with the configured subscription token.",
284+
};
285+
}
286+
return null;
287+
}
288+
273289
export async function testClaudeAcpEnvironment(
274290
ctx: AdapterEnvironmentTestContext,
275291
): Promise<AdapterEnvironmentTestResult> {
@@ -365,12 +381,17 @@ export async function testClaudeAcpEnvironment(
365381
detail: `Detected in ${source}.`,
366382
hint: "Unset ANTHROPIC_API_KEY if you want subscription-based Claude login behavior.",
367383
});
368-
} else if (!targetIsRemote) {
369-
checks.push({
370-
code: "claude_acp_subscription_mode_possible",
371-
level: "info",
372-
message: "ANTHROPIC_API_KEY is not set; subscription-based auth can be used if Claude is logged in.",
373-
});
384+
} else {
385+
const authAdvice = resolveClaudeAuthAdvice(envConfig);
386+
if (authAdvice) {
387+
checks.push(authAdvice);
388+
} else if (!targetIsRemote) {
389+
checks.push({
390+
code: "claude_acp_subscription_mode_possible",
391+
level: "info",
392+
message: "ANTHROPIC_API_KEY is not set; subscription-based auth can be used if Claude is logged in.",
393+
});
394+
}
374395
}
375396

376397
const mode = firstNonEmptyString(config.mode, config.acpMode) ?? DEFAULT_ACP_ENGINE_MODE;

packages/adapters/claude-local/src/server/execute.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ function isBedrockAuth(env: Record<string, string>): boolean {
138138
);
139139
}
140140

141-
function resolveClaudeBillingType(env: Record<string, string>): "api" | "subscription" | "metered_api" {
141+
export function resolveClaudeBillingType(env: Record<string, string>): "api" | "subscription" | "metered_api" {
142142
if (isBedrockAuth(env)) return "metered_api";
143143
return hasNonEmptyEnvValue(env, "ANTHROPIC_API_KEY") ? "api" : "subscription";
144144
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, expect, it } from "vitest";
2+
import { resolveClaudeAuthAdvice } from "./test.js";
3+
import { resolveClaudeBillingType } from "./execute.js";
4+
5+
describe("resolveClaudeAuthAdvice (CLI lane)", () => {
6+
it("recognizes CLAUDE_CODE_OAUTH_TOKEN as valid subscription auth", () => {
7+
expect(
8+
resolveClaudeAuthAdvice({ CLAUDE_CODE_OAUTH_TOKEN: "sk-ant-oat01-fake-token-value" }),
9+
).toEqual({
10+
code: "claude_subscription_token_detected",
11+
level: "info",
12+
message:
13+
"CLAUDE_CODE_OAUTH_TOKEN is set; Claude will authenticate with the configured subscription token.",
14+
});
15+
});
16+
17+
it("defers to the ANTHROPIC_API_KEY branch when both are set", () => {
18+
expect(
19+
resolveClaudeAuthAdvice({
20+
ANTHROPIC_API_KEY: "sk-ant-api-fake",
21+
CLAUDE_CODE_OAUTH_TOKEN: "sk-ant-oat01-fake-token-value",
22+
}),
23+
).toBeNull();
24+
});
25+
26+
it("returns null when neither auth signal is present (unchanged local-login guidance)", () => {
27+
expect(resolveClaudeAuthAdvice({})).toBeNull();
28+
});
29+
30+
it("does not treat an empty-string token as set", () => {
31+
expect(resolveClaudeAuthAdvice({ CLAUDE_CODE_OAUTH_TOKEN: " " })).toBeNull();
32+
});
33+
});
34+
35+
describe("resolveClaudeBillingType with a subscription token", () => {
36+
it("classifies a CLAUDE_CODE_OAUTH_TOKEN-only env as subscription billing (no production change)", () => {
37+
expect(
38+
resolveClaudeBillingType({ CLAUDE_CODE_OAUTH_TOKEN: "sk-ant-oat01-fake-token-value" }),
39+
).toBe("subscription");
40+
});
41+
});

0 commit comments

Comments
 (0)