diff --git a/doc/spec/invite-flow.md b/doc/spec/invite-flow.md index fad3c56e..ac71d79d 100644 --- a/doc/spec/invite-flow.md +++ b/doc/spec/invite-flow.md @@ -32,7 +32,7 @@ flowchart TD Accepted[Invite state: accepted] BootstrapDone[Bootstrap accepted
no join request] HumanReuse{Matching human join request
already exists for same user/email?} - HumanPending[Join request
pending_approval] + HumanPending[Legacy human join request
pending_approval] HumanApproved[Join request
approved] HumanRejected[Join request
rejected] AgentPending[Agent join request
pending_approval
+ optional claim secret] @@ -52,12 +52,10 @@ flowchart TD BootstrapDone --> Accepted Active --> HumanReuse: human accept - HumanReuse --> HumanPending: reuse existing pending request - HumanReuse --> HumanApproved: reuse existing approved request - HumanReuse --> HumanPending: no reusable request
create new request - HumanPending --> HumanApproved: board approves + HumanReuse --> HumanApproved: reuse existing pending/approved request
ensure active membership + HumanReuse --> HumanApproved: no reusable request
create and approve request + HumanPending --> HumanApproved: same invitee replays accepted invite
or board approves legacy request HumanPending --> HumanRejected: board rejects - HumanPending --> Accepted HumanApproved --> Accepted Active --> AgentPending: agent accept @@ -150,7 +148,8 @@ stateDiagram-v2 state AcceptedInviteSummary { [*] --> SummaryBranch - SummaryBranch --> PendingApprovalReload: joinRequestStatus=pending_approval + SummaryBranch --> AcceptPending: human joinRequestStatus=pending_approval/approved
and membership missing + SummaryBranch --> PendingApprovalReload: agent joinRequestStatus=pending_approval SummaryBranch --> OpeningCompany: joinRequestStatus=approved
and human invite user is now a member SummaryBranch --> RejectedReload: joinRequestStatus=rejected SummaryBranch --> ConsumedReload: approved agent invite or other consumed state @@ -177,6 +176,7 @@ sequenceDiagram participant Landing as Invite landing UI participant Auth as Auth session participant Join as join_requests table + participant Membership as company_memberships + grants Board->>Settings: Choose role and click Create invite Settings->>API: POST /api/companies/:companyId/invites @@ -197,15 +197,19 @@ sequenceDiagram API->>Join: Look for reusable human join request alt Reusable pending or approved request exists API->>Invites: Mark invite accepted - API-->>Landing: Existing join request status + API->>Membership: Ensure active membership and role grants + API->>Join: Mark join request approved if needed + API-->>Landing: approved join request else No reusable request exists API->>Invites: Mark invite accepted API->>Join: Insert pending_approval join request - API-->>Landing: New pending_approval join request + API->>Membership: Ensure active membership and role grants + API->>Join: Mark join request approved + API-->>Landing: approved join request end ``` -### Human Approval And Reload Path +### Legacy Human Reload And Repair Path ```mermaid sequenceDiagram @@ -214,8 +218,6 @@ sequenceDiagram participant Landing as Invite landing UI participant API as Access routes participant Join as join_requests table - actor Approver as Company admin - participant Queue as Access queue UI participant Membership as company_memberships + grants Invitee->>Landing: Reload consumed invite URL @@ -223,20 +225,15 @@ sequenceDiagram API->>Join: Load join request by inviteId API-->>Landing: joinRequestStatus + joinRequestType - alt joinRequestStatus = pending_approval - Landing-->>Invitee: Show waiting-for-approval panel - Approver->>Queue: Review request in Company Settings -> Access - Queue->>API: POST /companies/:companyId/join-requests/:requestId/approve - API->>Membership: Ensure membership and grants - API->>Join: Mark join request approved - Invitee->>Landing: Refresh after approval - Landing->>API: GET /api/invites/:token - API->>Join: Reload approved join request + alt human joinRequestStatus = pending_approval or approved but membership missing + Landing->>API: POST /api/invites/:token/accept (requestType=human) + API->>Membership: Ensure active membership and role grants + API->>Join: Mark join request approved if needed API-->>Landing: approved status Landing-->>Invitee: Opening company and redirect else joinRequestStatus = rejected Landing-->>Invitee: Show rejected error panel - else joinRequestStatus = approved but membership missing + else agent invite or unavailable consumed state Landing-->>Invitee: Fall through to consumed/unavailable state end ``` @@ -286,14 +283,15 @@ sequenceDiagram ## Notes - `GET /api/invites/:token` treats `revoked` and `expired` invites as unavailable. Accepted invites remain resolvable when they already have a linked join request, and the summary now includes `joinRequestStatus` plus `joinRequestType`. -- Human acceptance consumes the invite immediately and then either creates a new join request or reuses an existing `pending_approval` or `approved` human join request for the same user/email. +- Human acceptance consumes the invite, creates or reuses the matching human join request, immediately marks it `approved`, and ensures an active company membership with the invite's selected role/grants. - The landing page has two layers of post-accept UI: - immediate mutation-result UI from `POST /api/invites/:token/accept` - reload-time summary UI from `GET /api/invites/:token` once the invite has already been consumed - Reload behavior for accepted company invites is now status-sensitive: - - `pending_approval` re-renders the waiting-for-approval panel + - human `pending_approval` or `approved` states replay acceptance for the same signed-in user/email so legacy consumed invites can repair missing membership + - agent `pending_approval` re-renders the waiting-for-approval panel - `rejected` renders the "This join request was not approved." error panel - - `approved` only becomes a success path for human invites after membership is visible to the current session; otherwise the page falls through to the generic consumed/unavailable state + - `approved` becomes a success path for human invites after membership is visible to the current session - `GET /api/invites/:token/logo` still rejects accepted invites, so accepted-invite reload states may fall back to the generated company icon even though the summary payload still carries `companyLogoUrl`. -- The only accepted-invite replay path in the current implementation is `POST /api/invites/:token/accept` for `agent` requests with `adapterType=openclaw_gateway`, and only when the existing join request is still `pending_approval` or already `approved`. +- Accepted-invite replay is supported for matching human invitees to repair/complete membership, and for `agent` requests with `adapterType=openclaw_gateway` when the existing join request is still `pending_approval` or already `approved`. - `bootstrap_ceo` invites are one-time and do not create join requests. diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts index 1fbf172b..63ec9c70 100644 --- a/packages/plugins/sdk/src/index.ts +++ b/packages/plugins/sdk/src/index.ts @@ -129,6 +129,8 @@ export type { // JSON-RPC protocol types export type { JsonRpcId, + JsonRpcInvocationScope, + JsonRpcInvocationContext, JsonRpcRequest, JsonRpcSuccessResponse, JsonRpcError, diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index 2c76beab..92fcbf42 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -87,6 +87,19 @@ export const JSONRPC_VERSION = "2.0" as const; */ export type JsonRpcId = string | number; +/** + * Host-owned scope attached to a host→worker invocation. Workers may echo the + * invocation id on nested worker→host calls, but they never author this scope. + */ +export interface JsonRpcInvocationScope { + readonly companyId?: string | null; +} + +export interface JsonRpcInvocationContext { + readonly id: string; + readonly scope: JsonRpcInvocationScope; +} + /** * A JSON-RPC 2.0 request message. * diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts index c9466ca9..fe136776 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -168,7 +168,11 @@ export interface ExecutionWorkspaceSummary { id: string; name: string; mode: Exclude | "adapter_managed" | "cloud_sandbox"; + status: ExecutionWorkspaceStatus; + cwd: string | null; + branchName: string | null; projectWorkspaceId: string | null; + lastUsedAt: Date; } export interface ExecutionWorkspace { diff --git a/server/src/__tests__/better-auth.test.ts b/server/src/__tests__/better-auth.test.ts index 2e2821d7..53718076 100644 --- a/server/src/__tests__/better-auth.test.ts +++ b/server/src/__tests__/better-auth.test.ts @@ -44,6 +44,47 @@ describe("Better Auth cookie scoping", () => { cookiePrefix: "paperclip-pap-worktree", useSecureCookies: false, }); + expect(getCookies({ + advanced: buildBetterAuthAdvancedOptions({ disableSecureCookies: true }), + } as BetterAuthOptions).sessionToken.name).toBe("paperclip-pap-worktree.session_token"); + }); + + it("disables secure cookies for authenticated private auto-origin dev servers", () => { + expect(shouldDisableSecureAuthCookies({ + deploymentMode: "authenticated", + deploymentExposure: "private", + authBaseUrlMode: "auto", + authPublicBaseUrl: undefined, + publicUrl: undefined, + })).toBe(true); + }); + + it("keeps secure cookies for authenticated public auto-origin servers", () => { + expect(shouldDisableSecureAuthCookies({ + deploymentMode: "authenticated", + deploymentExposure: "public", + authBaseUrlMode: "auto", + authPublicBaseUrl: undefined, + publicUrl: undefined, + })).toBe(false); + }); + + it("uses an explicit public URL when deciding whether secure cookies are required", () => { + expect(shouldDisableSecureAuthCookies({ + deploymentMode: "authenticated", + deploymentExposure: "private", + authBaseUrlMode: "auto", + authPublicBaseUrl: undefined, + publicUrl: "https://paperclip.example.test", + })).toBe(false); + + expect(shouldDisableSecureAuthCookies({ + deploymentMode: "authenticated", + deploymentExposure: "public", + authBaseUrlMode: "explicit", + authPublicBaseUrl: "http://paperclip.local.test:3100", + publicUrl: undefined, + })).toBe(true); }); it("disables secure cookies when no canonical public auth URL is configured", () => { @@ -71,13 +112,14 @@ describe("Better Auth cookie scoping", () => { } as Parameters[0])).toBe(false); }); - it("lets PAPERCLIP_PUBLIC_URL override the auth base URL for cookie security", () => { - process.env.PAPERCLIP_PUBLIC_URL = "http://paperclip-dev:46259"; + it("uses the caller-resolved public URL for cookie security", () => { + process.env.PAPERCLIP_PUBLIC_URL = "https://ignored.example.test"; expect(shouldDisableSecureAuthCookies({ deploymentMode: "authenticated", authBaseUrlMode: "explicit", authPublicBaseUrl: "https://paperclip.example.test", + publicUrl: "http://paperclip-dev:46259", } as Parameters[0])).toBe(true); }); diff --git a/server/src/__tests__/dev-runner-worktree.test.ts b/server/src/__tests__/dev-runner-worktree.test.ts index 461f2aab..dc326edd 100644 --- a/server/src/__tests__/dev-runner-worktree.test.ts +++ b/server/src/__tests__/dev-runner-worktree.test.ts @@ -63,6 +63,42 @@ describe("dev-runner worktree env bootstrap", () => { expect(env.PAPERCLIP_OPTIONAL).toBe(""); }); + it("repairs stale migrated config paths before loading worktree env", () => { + const root = createTempRoot("paperclip-dev-runner-worktree-migrated-env-"); + const localConfigPath = path.join(root, ".paperclip", "config.json"); + const worktreesDir = path.join(root, ".paperclip-worktrees"); + fs.mkdirSync(path.dirname(localConfigPath), { recursive: true }); + fs.writeFileSync(path.join(root, ".git"), "gitdir: /tmp/paperclip/.git/worktrees/feature\n", "utf8"); + fs.writeFileSync(localConfigPath, "{}\n", "utf8"); + fs.writeFileSync( + resolveWorktreeEnvFilePath(root), + [ + "PAPERCLIP_HOME=/old/home/.paperclip-worktrees", + "PAPERCLIP_INSTANCE_ID=feature-worktree", + "PAPERCLIP_CONFIG=/old/home/paperclip/.paperclip/worktrees/feature/.paperclip/config.json", + "PAPERCLIP_CONTEXT=/old/home/.paperclip-worktrees/context.json", + "PAPERCLIP_IN_WORKTREE=true", + "PAPERCLIP_WORKTREE_NAME=feature-worktree", + "", + ].join("\n"), + "utf8", + ); + + const env: NodeJS.ProcessEnv = { + PAPERCLIP_WORKTREES_DIR: worktreesDir, + }; + const result = bootstrapDevRunnerWorktreeEnv(root, env); + + expect(result).toEqual({ + envPath: resolveWorktreeEnvFilePath(root), + missingEnv: false, + }); + expect(env.PAPERCLIP_HOME).toBe(worktreesDir); + expect(env.PAPERCLIP_CONFIG).toBe(localConfigPath); + expect(env.PAPERCLIP_CONTEXT).toBe(path.join(worktreesDir, "context.json")); + expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree"); + }); + it("reports uninitialized linked worktrees so dev runner can fail fast", () => { const root = createTempRoot("paperclip-dev-runner-worktree-missing-"); fs.writeFileSync(path.join(root, ".git"), "gitdir: /tmp/paperclip/.git/worktrees/feature\n", "utf8"); diff --git a/server/src/__tests__/execution-workspaces-service.test.ts b/server/src/__tests__/execution-workspaces-service.test.ts index f4c87e5f..fc8692fd 100644 --- a/server/src/__tests__/execution-workspaces-service.test.ts +++ b/server/src/__tests__/execution-workspaces-service.test.ts @@ -343,6 +343,83 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { expect(readExecutionWorkspaceConfig(byId.get(untouchedWorkspaceId) ?? null)).toBeNull(); }); + it("limits reusable summaries to open non-shared execution workspaces", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const openWorkspaceId = randomUUID(); + const sharedWorkspaceId = randomUUID(); + const closedWorkspaceId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: "PAP", + requireBoardApprovalForNewAgents: false, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Reusable workspaces", + status: "in_progress", + executionWorkspacePolicy: { + enabled: true, + }, + }); + await db.insert(executionWorkspaces).values([ + { + id: openWorkspaceId, + companyId, + projectId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Open isolated workspace", + status: "idle", + providerType: "git_worktree", + cwd: "/tmp/open-workspace", + branchName: "paperclip/open", + }, + { + id: sharedWorkspaceId, + companyId, + projectId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Shared session", + status: "active", + providerType: "local_fs", + cwd: "/tmp/project-primary", + }, + { + id: closedWorkspaceId, + companyId, + projectId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Closed isolated workspace", + status: "active", + providerType: "git_worktree", + cwd: "/tmp/closed-workspace", + closedAt: new Date("2026-05-23T20:00:00.000Z"), + }, + ]); + + const summaries = await svc.listSummaries(companyId, { + projectId, + reuseEligible: true, + }); + + expect(summaries).toEqual([ + expect.objectContaining({ + id: openWorkspaceId, + name: "Open isolated workspace", + mode: "isolated_workspace", + status: "idle", + cwd: "/tmp/open-workspace", + branchName: "paperclip/open", + }), + ]); + }); + it("warns about dirty and unmerged git worktrees and reports cleanup actions", async () => { const repoRoot = await createTempRepo(); tempDirs.add(repoRoot); diff --git a/server/src/__tests__/invite-accept-existing-member.test.ts b/server/src/__tests__/invite-accept-existing-member.test.ts index 913fcdbf..660a854e 100644 --- a/server/src/__tests__/invite-accept-existing-member.test.ts +++ b/server/src/__tests__/invite-accept-existing-member.test.ts @@ -4,12 +4,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { accessRoutes } from "../routes/access.js"; import { errorHandler } from "../middleware/index.js"; +const accessServiceMock = vi.hoisted(() => ({ + isInstanceAdmin: vi.fn(), + canUser: vi.fn(), + hasPermission: vi.fn(), + ensureMembership: vi.fn(), + setPrincipalGrants: vi.fn(), +})); +const logActivityMock = vi.hoisted(() => vi.fn()); + vi.mock("../services/index.js", () => ({ - accessService: () => ({ - isInstanceAdmin: vi.fn(), - canUser: vi.fn(), - hasPermission: vi.fn(), - }), + accessService: () => accessServiceMock, agentService: () => ({ getById: vi.fn(), }), @@ -20,10 +25,36 @@ vi.mock("../services/index.js", () => ({ revokeBoardApiKey: vi.fn(), }), deduplicateAgentName: vi.fn(), - logActivity: vi.fn(), + logActivity: logActivityMock, notifyHireApproved: vi.fn(), })); +type QueryHooks = { + onSet?: (value: unknown) => void; + onValues?: (value: unknown) => void; +}; + +function createQuery(rows: unknown[], hooks: QueryHooks = {}) { + const query = { + from: vi.fn(() => query), + where: vi.fn(() => query), + orderBy: vi.fn(() => query), + set: vi.fn((value: unknown) => { + hooks.onSet?.(value); + return query; + }), + values: vi.fn((value: unknown) => { + hooks.onValues?.(value); + return query; + }), + returning: vi.fn(() => query), + then(resolve: (value: unknown[]) => unknown, reject?: (reason: unknown) => unknown) { + return Promise.resolve(rows).then(resolve, reject); + }, + }; + return query; +} + function createDbStub() { const updateMock = vi.fn(); const invite = { @@ -75,22 +106,26 @@ function createDbStub() { } function createApp(db: Record) { + return createAppWithActor(db, { + type: "board", + source: "session", + userId: "user-1", + companyIds: ["company-1"], + memberships: [ + { + companyId: "company-1", + membershipRole: "owner", + status: "active", + }, + ], + }); +} + +function createAppWithActor(db: Record, actor: Record) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { - (req as any).actor = { - type: "board", - source: "session", - userId: "user-1", - companyIds: ["company-1"], - memberships: [ - { - companyId: "company-1", - membershipRole: "owner", - status: "active", - }, - ], - }; + (req as any).actor = actor; next(); }); app.use( @@ -106,6 +141,162 @@ function createApp(db: Record) { return app; } +function createDirectHumanInviteDbStub() { + const insertedValues: unknown[] = []; + const updateValues: unknown[] = []; + const invite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: { human: { role: "owner" } }, + expiresAt: new Date("2027-03-10T00:00:00.000Z"), + invitedByUserId: "inviter-user", + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:00:00.000Z"), + }; + const createdJoinRequest = { + id: "join-1", + inviteId: "invite-1", + companyId: "company-1", + requestType: "human", + status: "pending_approval", + requestIp: "::ffff:127.0.0.1", + requestingUserId: "invitee-user", + requestEmailSnapshot: "invitee@example.com", + agentName: null, + adapterType: null, + capabilities: null, + agentDefaultsPayload: null, + claimSecretHash: null, + claimSecretExpiresAt: null, + claimSecretConsumedAt: null, + createdAgentId: null, + approvedByUserId: null, + approvedAt: null, + rejectedByUserId: null, + rejectedAt: null, + createdAt: new Date("2026-03-07T00:01:00.000Z"), + updatedAt: new Date("2026-03-07T00:01:00.000Z"), + }; + const approvedJoinRequest = { + ...createdJoinRequest, + status: "approved", + approvedByUserId: "inviter-user", + approvedAt: new Date("2026-03-07T00:02:00.000Z"), + updatedAt: new Date("2026-03-07T00:02:00.000Z"), + }; + const selectResponses = [ + [invite], + [{ email: "invitee@example.com" }], + [], + ]; + const updateResponses = [[], [approvedJoinRequest]]; + const insertResponses = [[createdJoinRequest]]; + + const db = { + select() { + return createQuery(selectResponses.shift() ?? []); + }, + update() { + return createQuery(updateResponses.shift() ?? [], { + onSet: (value) => updateValues.push(value), + }); + }, + insert() { + return createQuery(insertResponses.shift() ?? [], { + onValues: (value) => insertedValues.push(value), + }); + }, + transaction(callback: (tx: unknown) => unknown) { + return callback(db); + }, + }; + + return { db, insertedValues, updateValues }; +} + +function createAcceptedHumanInviteReplayDbStub() { + const updateValues: unknown[] = []; + const invite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: { human: { role: "operator" } }, + expiresAt: new Date("2027-03-10T00:00:00.000Z"), + invitedByUserId: "inviter-user", + revokedAt: null, + acceptedAt: new Date("2026-03-07T00:05:00.000Z"), + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:05:00.000Z"), + }; + const pendingJoinRequest = { + id: "join-1", + inviteId: "invite-1", + companyId: "company-1", + requestType: "human", + status: "pending_approval", + requestIp: "::ffff:127.0.0.1", + requestingUserId: "invitee-user", + requestEmailSnapshot: "invitee@example.com", + agentName: null, + adapterType: null, + capabilities: null, + agentDefaultsPayload: null, + claimSecretHash: null, + claimSecretExpiresAt: null, + claimSecretConsumedAt: null, + createdAgentId: null, + approvedByUserId: null, + approvedAt: null, + rejectedByUserId: null, + rejectedAt: null, + createdAt: new Date("2026-03-07T00:01:00.000Z"), + updatedAt: new Date("2026-03-07T00:01:00.000Z"), + }; + const replayedJoinRequest = { + ...pendingJoinRequest, + requestIp: "::ffff:127.0.0.1", + updatedAt: new Date("2026-03-07T00:06:00.000Z"), + }; + const approvedJoinRequest = { + ...replayedJoinRequest, + status: "approved", + approvedByUserId: "inviter-user", + approvedAt: new Date("2026-03-07T00:07:00.000Z"), + updatedAt: new Date("2026-03-07T00:07:00.000Z"), + }; + const selectResponses = [ + [invite], + [pendingJoinRequest], + [{ email: "invitee@example.com" }], + [pendingJoinRequest], + ]; + const updateResponses = [[replayedJoinRequest], [approvedJoinRequest]]; + + const db = { + select() { + return createQuery(selectResponses.shift() ?? []); + }, + update() { + return createQuery(updateResponses.shift() ?? [], { + onSet: (value) => updateValues.push(value), + }); + }, + insert: vi.fn(), + transaction(callback: (tx: unknown) => unknown) { + return callback(db); + }, + }; + + return { db, updateValues }; +} + describe("POST /invites/:token/accept", () => { beforeEach(() => { vi.clearAllMocks(); @@ -123,4 +314,126 @@ describe("POST /invites/:token/accept", () => { expect(res.body.error).toBe("You already belong to this company"); expect(updateMock).not.toHaveBeenCalled(); }); + + it("grants company access immediately for a human invite", async () => { + const { db, insertedValues, updateValues } = createDirectHumanInviteDbStub(); + const app = createAppWithActor(db, { + type: "board", + source: "session", + userId: "invitee-user", + companyIds: [], + memberships: [], + }); + + const res = await request(app) + .post("/api/invites/pcp_invite_test/accept") + .send({ requestType: "human" }); + + expect(res.status).toBe(202); + expect(res.body.status).toBe("approved"); + expect(insertedValues).toEqual([ + expect.objectContaining({ + inviteId: "invite-1", + companyId: "company-1", + requestType: "human", + status: "pending_approval", + requestingUserId: "invitee-user", + requestEmailSnapshot: "invitee@example.com", + }), + ]); + expect(updateValues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + acceptedAt: expect.any(Date), + }), + expect.objectContaining({ + status: "approved", + approvedByUserId: "inviter-user", + approvedAt: expect.any(Date), + }), + ]), + ); + expect(accessServiceMock.ensureMembership).toHaveBeenCalledWith( + "company-1", + "user", + "invitee-user", + "owner", + "active", + ); + expect(accessServiceMock.setPrincipalGrants).toHaveBeenCalledWith( + "company-1", + "user", + "invitee-user", + expect.arrayContaining([ + expect.objectContaining({ permissionKey: "users:invite" }), + expect.objectContaining({ permissionKey: "users:manage_permissions" }), + ]), + "inviter-user", + ); + expect(logActivityMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "join.approved", + entityId: "join-1", + details: expect.objectContaining({ source: "human_invite_accept" }), + }), + ); + }); + + it("replays a consumed human invite for the same user and repairs company access", async () => { + const { db, updateValues } = createAcceptedHumanInviteReplayDbStub(); + const app = createAppWithActor(db, { + type: "board", + source: "session", + userId: "invitee-user", + companyIds: [], + memberships: [], + }); + + const res = await request(app) + .post("/api/invites/pcp_invite_test/accept") + .send({ requestType: "human" }); + + expect(res.status).toBe(202); + expect(res.body.status).toBe("approved"); + expect(updateValues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + requestIp: expect.any(String), + updatedAt: expect.any(Date), + }), + expect.objectContaining({ + status: "approved", + approvedByUserId: "inviter-user", + approvedAt: expect.any(Date), + }), + ]), + ); + expect(updateValues).not.toEqual( + expect.arrayContaining([expect.objectContaining({ acceptedAt: expect.any(Date) })]), + ); + expect(accessServiceMock.ensureMembership).toHaveBeenCalledWith( + "company-1", + "user", + "invitee-user", + "operator", + "active", + ); + expect(logActivityMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "join.request_replayed", + entityId: "join-1", + details: expect.objectContaining({ inviteReplay: true }), + }), + ); + expect(logActivityMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "join.approved", + entityId: "join-1", + details: expect.objectContaining({ source: "human_invite_accept" }), + }), + ); + }); }); diff --git a/server/src/__tests__/static-index-html.test.ts b/server/src/__tests__/static-index-html.test.ts new file mode 100644 index 00000000..1ad3c405 --- /dev/null +++ b/server/src/__tests__/static-index-html.test.ts @@ -0,0 +1,49 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import express from "express"; +import request from "supertest"; +import { afterEach, describe, expect, it } from "vitest"; +import { readBrandedStaticIndexHtml } from "../static-index-html.js"; + +describe("static SPA fallback HTML", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("serves the current index.html instead of reusing stale asset hashes", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-static-index-")); + tempDirs.push(tempDir); + const indexPath = path.join(tempDir, "index.html"); + const app = express(); + app.get(/.*/, (_req, res) => { + res + .status(200) + .set("Content-Type", "text/html") + .set("Cache-Control", "no-cache") + .end(readBrandedStaticIndexHtml(tempDir)); + }); + + fs.writeFileSync( + indexPath, + '', + "utf8", + ); + await expect(request(app).get("/PAP/issues/PAP-9939")).resolves.toMatchObject({ + text: expect.stringContaining("/assets/index-old.js"), + }); + + fs.writeFileSync( + indexPath, + '', + "utf8", + ); + const res = await request(app).get("/PAP/issues/PAP-9939"); + expect(res.text).toContain("/assets/index-new.js"); + expect(res.text).not.toContain("/assets/index-old.js"); + }); +}); diff --git a/server/src/__tests__/worktree-config.test.ts b/server/src/__tests__/worktree-config.test.ts index 69a9c5ee..42a9955e 100644 --- a/server/src/__tests__/worktree-config.test.ts +++ b/server/src/__tests__/worktree-config.test.ts @@ -210,6 +210,56 @@ describe("worktree config repair", () => { expect(repairedConfig.database.embeddedPostgresPort).toBe(54331); }); + it("ignores stale migrated env paths when the dev runner resolved the local config", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-migrated-env-")); + const worktreeRoot = path.join(tempRoot, "PAP-9940-what-can-we-learn"); + const paperclipDir = path.join(worktreeRoot, ".paperclip"); + const configPath = path.join(paperclipDir, "config.json"); + const envPath = path.join(paperclipDir, ".env"); + const oldHome = "/old/home/.paperclip-worktrees"; + const isolatedHome = path.join(tempRoot, ".paperclip-worktrees"); + + await fs.mkdir(paperclipDir, { recursive: true }); + await fs.writeFile(configPath, JSON.stringify(buildLegacyConfig(oldHome), null, 2) + "\n", "utf8"); + await fs.writeFile( + envPath, + [ + "# Paperclip environment variables", + "PAPERCLIP_HOME=/old/home/.paperclip-worktrees", + "PAPERCLIP_INSTANCE_ID=pap-9940-what-can-we-learn", + "PAPERCLIP_CONFIG=/old/home/paperclip/.paperclip/worktrees/PAP-9940-what-can-we-learn/.paperclip/config.json", + "PAPERCLIP_CONTEXT=/old/home/.paperclip-worktrees/context.json", + "PAPERCLIP_IN_WORKTREE=true", + "PAPERCLIP_WORKTREE_NAME=PAP-9940-what-can-we-learn", + "", + ].join("\n"), + "utf8", + ); + + process.chdir(worktreeRoot); + process.env.PAPERCLIP_IN_WORKTREE = "true"; + process.env.PAPERCLIP_CONFIG = configPath; + process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome; + delete process.env.PAPERCLIP_HOME; + delete process.env.PAPERCLIP_INSTANCE_ID; + delete process.env.PAPERCLIP_CONTEXT; + + const result = maybeRepairLegacyWorktreeConfigAndEnvFiles(); + const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8")); + const repairedEnv = await fs.readFile(envPath, "utf8"); + const instanceRoot = path.join(isolatedHome, "instances", "pap-9940-what-can-we-learn"); + + expect(result).toEqual({ + repairedConfig: true, + repairedEnv: true, + }); + expect(repairedConfig.database.embeddedPostgresDataDir).toBe(path.join(instanceRoot, "db")); + expect(repairedConfig.secrets.localEncrypted.keyFilePath).toBe(path.join(instanceRoot, "secrets", "master.key")); + expect(repairedEnv).toContain(`PAPERCLIP_HOME=${JSON.stringify(isolatedHome)}`); + expect(repairedEnv).toContain(`PAPERCLIP_CONFIG=${JSON.stringify(configPath)}`); + expect(repairedEnv).not.toContain("/old/home"); + }); + it("does not persist transient runtime home overrides over repo-local worktree env", async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-runtime-override-")); const isolatedHome = path.join(tempRoot, ".paperclip-worktrees"); @@ -508,6 +558,8 @@ describe("worktree config repair", () => { process.env.PAPERCLIP_HOME = isolatedHome; process.env.PAPERCLIP_INSTANCE_ID = "pap-878-create-a-mine-tab-in-inbox"; process.env.PAPERCLIP_CONFIG = configPath; + delete process.env.PORT; + delete process.env.DATABASE_URL; maybePersistWorktreeRuntimePorts({ serverPort: 3103, @@ -590,6 +642,8 @@ describe("worktree config repair", () => { process.env.PAPERCLIP_HOME = isolatedHome; process.env.PAPERCLIP_INSTANCE_ID = "pap-125-public-base-url"; process.env.PAPERCLIP_CONFIG = configPath; + delete process.env.PORT; + delete process.env.DATABASE_URL; maybePersistWorktreeRuntimePorts({ serverPort: 3103, diff --git a/server/src/app.ts b/server/src/app.ts index a7c9a4ed..b2348714 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -41,6 +41,7 @@ import { accessRoutes } from "./routes/access.js"; import { pluginRoutes } from "./routes/plugins.js"; import { adapterRoutes } from "./routes/adapters.js"; import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js"; +import { readBrandedStaticIndexHtml } from "./static-index-html.js"; import { applyUiBranding } from "./ui-branding.js"; import { logger } from "./middleware/logger.js"; import { DEFAULT_LOCAL_PLUGIN_DIR, pluginLoader } from "./services/plugin-loader.js"; @@ -328,7 +329,6 @@ export async function createApp( ]; const uiDist = candidates.find((p) => fs.existsSync(path.join(p, "index.html"))); if (uiDist) { - const indexHtml = applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8")); // Hashed asset files (Vite emits them under /assets/..) // never change once built, so they can be cached aggressively. app.use( @@ -368,7 +368,7 @@ export async function createApp( .status(200) .set("Content-Type", "text/html") .set("Cache-Control", "no-cache") - .end(indexHtml); + .end(readBrandedStaticIndexHtml(uiDist)); }); } else { console.warn("[paperclip] UI dist not found; running in API-only mode"); diff --git a/server/src/auth/better-auth.ts b/server/src/auth/better-auth.ts index 6eb20249..f7742b97 100644 --- a/server/src/auth/better-auth.ts +++ b/server/src/auth/better-auth.ts @@ -44,13 +44,26 @@ export function buildBetterAuthAdvancedOptions(input: { disableSecureCookies: bo }; } -export function shouldDisableSecureAuthCookies(config: Config): boolean { - const configuredPublicUrl = ( - process.env.PAPERCLIP_PUBLIC_URL?.trim() || - (config.authBaseUrlMode === "explicit" ? config.authPublicBaseUrl?.trim() : "") +export function shouldDisableSecureAuthCookies(input: { + deploymentMode: Config["deploymentMode"]; + deploymentExposure?: Config["deploymentExposure"]; + authBaseUrlMode: Config["authBaseUrlMode"]; + authPublicBaseUrl: string | undefined; + publicUrl?: string | undefined; +}): boolean { + const publicUrl = ( + input.publicUrl?.trim() || + (input.authBaseUrlMode === "explicit" ? input.authPublicBaseUrl?.trim() : "") + ); + if (publicUrl) return publicUrl.startsWith("http://"); + + return ( + input.deploymentMode === "authenticated" && + ( + (input.deploymentExposure === "private" && input.authBaseUrlMode === "auto") || + input.deploymentExposure === undefined + ) ); - if (!configuredPublicUrl) return true; - return configuredPublicUrl.startsWith("http://"); } function headersFromNodeHeaders(rawHeaders: IncomingHttpHeaders): Headers { @@ -101,6 +114,7 @@ export function deriveAuthTrustedOrigins(config: Config, opts?: { listenPort?: n export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins: string[]): BetterAuthInstance { const baseUrl = config.authBaseUrlMode === "explicit" ? config.authPublicBaseUrl : undefined; + const publicUrl = process.env.PAPERCLIP_PUBLIC_URL?.trim() || baseUrl; const secret = process.env.BETTER_AUTH_SECRET ?? process.env.PAPERCLIP_AGENT_JWT_SECRET; if (!secret) { throw new Error( @@ -108,7 +122,13 @@ export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins: "For local development, set BETTER_AUTH_SECRET=paperclip-dev-secret in your .env file.", ); } - const disableSecureCookies = shouldDisableSecureAuthCookies(config); + const disableSecureCookies = shouldDisableSecureAuthCookies({ + deploymentMode: config.deploymentMode, + deploymentExposure: config.deploymentExposure, + authBaseUrlMode: config.authBaseUrlMode, + authPublicBaseUrl: config.authPublicBaseUrl, + publicUrl, + }); const authConfig = { baseURL: baseUrl, diff --git a/server/src/dev-runner-worktree.ts b/server/src/dev-runner-worktree.ts index 4e2b5d8d..7779c4e2 100644 --- a/server/src/dev-runner-worktree.ts +++ b/server/src/dev-runner-worktree.ts @@ -1,4 +1,5 @@ import { existsSync, lstatSync, readFileSync } from "node:fs"; +import os from "node:os"; import path from "node:path"; function parseEnvFile(contents: string): Record { @@ -55,6 +56,45 @@ export function resolveWorktreeEnvFilePath(rootDir: string): string { return path.resolve(rootDir, ".paperclip", ".env"); } +function expandHomePrefix(value: string): string { + if (value === "~") return os.homedir(); + if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2)); + return value; +} + +function resolveHomeAwarePath(value: string): string { + return path.resolve(expandHomePrefix(value)); +} + +function resolveDefaultWorktreeHome(env: NodeJS.ProcessEnv): string { + return path.resolve(expandHomePrefix(env.PAPERCLIP_WORKTREES_DIR?.trim() || "~/.paperclip-worktrees")); +} + +function repairStaleMigratedWorktreeEnvEntries( + rootDir: string, + entries: Record, + env: NodeJS.ProcessEnv, +): Record { + const localConfigPath = path.resolve(rootDir, ".paperclip", "config.json"); + const configuredPath = entries.PAPERCLIP_CONFIG?.trim(); + if (!configuredPath) return entries; + + const resolvedConfiguredPath = resolveHomeAwarePath(configuredPath); + const staleConfigPath = + resolvedConfiguredPath !== localConfigPath && + !existsSync(resolvedConfiguredPath) && + existsSync(localConfigPath); + if (!staleConfigPath) return entries; + + const homeDir = resolveDefaultWorktreeHome(env); + return { + ...entries, + PAPERCLIP_HOME: homeDir, + PAPERCLIP_CONFIG: localConfigPath, + PAPERCLIP_CONTEXT: path.resolve(homeDir, "context.json"), + }; +} + export function bootstrapDevRunnerWorktreeEnv( rootDir: string, env: NodeJS.ProcessEnv = process.env, @@ -74,7 +114,11 @@ export function bootstrapDevRunnerWorktreeEnv( }; } - const entries = parseEnvFile(readFileSync(envPath, "utf8")); + const entries = repairStaleMigratedWorktreeEnvEntries( + rootDir, + parseEnvFile(readFileSync(envPath, "utf8")), + env, + ); for (const [key, value] of Object.entries(entries)) { if (typeof env[key] === "string" && env[key]!.trim().length > 0) continue; env[key] = value; diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index e9c07f87..44b1fbaf 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -2721,6 +2721,83 @@ export function accessRoutes( return { token, created, normalizedAgentMessage }; } + async function approveHumanJoinRequestFromInvite(input: { + req: Request; + invite: typeof invites.$inferSelect; + joinRequest: typeof joinRequests.$inferSelect; + companyId: string; + }) { + if (input.joinRequest.requestType !== "human") { + throw badRequest("Only human join requests can be approved through a human invite"); + } + if (!input.joinRequest.requestingUserId) { + throw conflict("Join request missing user identity"); + } + + const membershipRole = resolveHumanInviteRole( + input.invite.defaultsPayload as Record | null, + ); + await access.ensureMembership( + input.companyId, + "user", + input.joinRequest.requestingUserId, + membershipRole, + "active", + ); + const grants = humanJoinGrantsFromDefaults( + input.invite.defaultsPayload as Record | null, + membershipRole, + ); + await access.setPrincipalGrants( + input.companyId, + "user", + input.joinRequest.requestingUserId, + grants, + input.invite.invitedByUserId ?? null, + ); + + if (input.joinRequest.status === "approved") { + return input.joinRequest; + } + + const approvedAt = new Date(); + const approvedByUserId = + input.invite.invitedByUserId ?? (isLocalImplicit(input.req) ? "local-board" : null); + const approved = await db + .update(joinRequests) + .set({ + status: "approved", + approvedByUserId, + approvedAt, + updatedAt: approvedAt, + }) + .where(eq(joinRequests.id, input.joinRequest.id)) + .returning() + .then((rows) => rows[0] ?? null); + + await logActivity(db, { + companyId: input.companyId, + actorType: "user", + actorId: approvedByUserId ?? "board", + action: "join.approved", + entityType: "join_request", + entityId: input.joinRequest.id, + details: { + requestType: "human", + inviteId: input.invite.id, + source: "human_invite_accept", + }, + }); + + return approved ?? { + ...input.joinRequest, + status: "approved", + approvedByUserId, + approvedAt, + updatedAt: approvedAt, + }; + } + async function getInviteCompanyBranding( companyId: string | null, inviteToken: string | null = null, @@ -3255,9 +3332,26 @@ export function accessRoutes( } } + const actorEmail = + requestType === "human" ? await resolveActorEmail(db, req) : null; + const actorRequestingUserId = + requestType === "human" + ? req.actor.userId ?? "local-board" + : null; + const canReplayHumanInviteAccept = + inviteAlreadyAccepted && + requestType === "human" && + existingJoinRequestForInvite?.requestType === "human" && + Boolean( + findReusableHumanJoinRequest([existingJoinRequestForInvite], { + requestingUserId: actorRequestingUserId, + requestEmailSnapshot: actorEmail, + }) + ); const adapterType = req.body.adapterType ?? null; if ( inviteAlreadyAccepted && + !canReplayHumanInviteAccept && !canReplayOpenClawGatewayInviteAccept({ requestType, adapterType, @@ -3336,8 +3430,6 @@ export function accessRoutes( ? new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) : null; - const actorEmail = - requestType === "human" ? await resolveActorEmail(db, req) : null; const existingHumanJoinRequest = requestType === "human" ? findReusableHumanJoinRequest( @@ -3352,12 +3444,12 @@ export function accessRoutes( ) .orderBy(desc(joinRequests.createdAt)), { - requestingUserId: req.actor.userId ?? "local-board", + requestingUserId: actorRequestingUserId, requestEmailSnapshot: actorEmail } ) : null; - const created = !inviteAlreadyAccepted + let created = !inviteAlreadyAccepted ? existingHumanJoinRequest ? await db.transaction(async (tx) => { await tx @@ -3566,6 +3658,15 @@ export function accessRoutes( } }); + if (requestType === "human") { + created = await approveHumanJoinRequestFromInvite({ + req, + invite, + joinRequest: created, + companyId, + }); + } + const response = toJoinRequestResponse(created); if (claimSecret) { const companyBranding = await getInviteCompanyBranding(invite.companyId); diff --git a/server/src/services/cloud-upstreams.ts b/server/src/services/cloud-upstreams.ts index 765dd432..576d0daf 100644 --- a/server/src/services/cloud-upstreams.ts +++ b/server/src/services/cloud-upstreams.ts @@ -103,6 +103,7 @@ type UpstreamTransferManifest = { idempotencyKey: string; generatedAt: string; entityCount: number; + perEntityTypeCounts: Record; entities: UpstreamTransferEntityRecord[]; chunks: Array & { manifestHash: NormalizedSha256 }>; warnings: UpstreamTransferWarning[]; @@ -914,6 +915,7 @@ function buildLocalUpstreamExportBundle(input: { idempotencyKey: input.idempotencyKey, generatedAt: new Date(0).toISOString(), entityCount: entities.length, + perEntityTypeCounts: countEntityTypesForManifest(entities), entities: entities.map((entity) => entity.record), chunks: chunksWithoutManifestHash.map(({ payload: _payload, ...chunk }) => chunk), warnings: input.warnings ?? [], @@ -931,6 +933,15 @@ function buildLocalUpstreamExportBundle(input: { }; } +function countEntityTypesForManifest(entities: LocalUpstreamExportEntity[]): Record { + const counts: Record = {}; + for (const entity of entities) { + const entityType = entity.record.key.sourceEntityType; + counts[entityType] = (counts[entityType] ?? 0) + 1; + } + return counts; +} + function buildLocalChunks(entities: LocalUpstreamExportEntity[], maxEntitiesPerChunk: number): LocalUpstreamExportChunk[] { if (!Number.isInteger(maxEntitiesPerChunk) || maxEntitiesPerChunk < 1) { throw new Error("maxEntitiesPerChunk must be a positive integer"); diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index e5fa36a1..ee5eb48a 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -2,7 +2,7 @@ import { execFile } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; -import { and, desc, eq, inArray } from "drizzle-orm"; +import { and, desc, eq, inArray, isNull } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { executionWorkspaces, issues, projects, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db"; import type { @@ -344,12 +344,18 @@ function toExecutionWorkspace( }; } -function toExecutionWorkspaceSummary(row: Pick): ExecutionWorkspaceSummary { +function toExecutionWorkspaceSummary( + row: Pick, +): ExecutionWorkspaceSummary { return { id: row.id, name: row.name, mode: row.mode as ExecutionWorkspaceSummary["mode"], + status: row.status as ExecutionWorkspaceSummary["status"], + cwd: row.cwd ?? null, + branchName: row.branchName ?? null, projectWorkspaceId: row.projectWorkspaceId ?? null, + lastUsedAt: row.lastUsedAt, }; } @@ -412,6 +418,8 @@ export function executionWorkspaceService(db: Db) { } if (filters?.reuseEligible) { conditions.push(inArray(executionWorkspaces.status, ["active", "idle", "in_review"])); + conditions.push(isNull(executionWorkspaces.closedAt)); + conditions.push(inArray(executionWorkspaces.mode, ["isolated_workspace", "operator_branch", "adapter_managed", "cloud_sandbox"])); } return conditions; } @@ -452,7 +460,11 @@ export function executionWorkspaceService(db: Db) { id: executionWorkspaces.id, name: executionWorkspaces.name, mode: executionWorkspaces.mode, + status: executionWorkspaces.status, + cwd: executionWorkspaces.cwd, + branchName: executionWorkspaces.branchName, projectWorkspaceId: executionWorkspaces.projectWorkspaceId, + lastUsedAt: executionWorkspaces.lastUsedAt, }) .from(executionWorkspaces) .where(and(...conditions)) diff --git a/server/src/services/plugin-worker-manager.ts b/server/src/services/plugin-worker-manager.ts index 9847e879..71cca23a 100644 --- a/server/src/services/plugin-worker-manager.ts +++ b/server/src/services/plugin-worker-manager.ts @@ -559,7 +559,9 @@ export function createPluginWorkerHandle( (message as { paperclipInvocationId?: unknown }).paperclipInvocationId, ); if (!invocationId) { - return activeInvocations.size > 0 ? { invalidInvocationScope: true } : {}; + const hasActiveInvocation = activeInvocations.size > 0 || + Array.from(pendingRequests.values()).some((pending) => pending.invocationId); + return hasActiveInvocation ? { invalidInvocationScope: true } : {}; } const entry = activeInvocations.get(invocationId); if (!entry) return { invalidInvocationScope: true }; diff --git a/server/src/static-index-html.ts b/server/src/static-index-html.ts new file mode 100644 index 00000000..13fa592c --- /dev/null +++ b/server/src/static-index-html.ts @@ -0,0 +1,7 @@ +import fs from "node:fs"; +import path from "node:path"; +import { applyUiBranding } from "./ui-branding.js"; + +export function readBrandedStaticIndexHtml(uiDist: string): string { + return applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8")); +} diff --git a/server/src/worktree-config.ts b/server/src/worktree-config.ts index d4f3ccb1..3c230664 100644 --- a/server/src/worktree-config.ts +++ b/server/src/worktree-config.ts @@ -115,17 +115,23 @@ function resolveWorktreeRuntimeContext( const configPath = resolvePaperclipConfigPath(overrideConfigPath); const envPath = resolvePaperclipEnvPath(configPath); const persistedEnv = readEnvEntries(envPath); + const persistedConfigPath = nonEmpty(persistedEnv.PAPERCLIP_CONFIG); + const persistedConfigLooksStale = + persistedConfigPath !== null && + path.resolve(expandHomePrefix(persistedConfigPath)) !== path.resolve(configPath) && + !fs.existsSync(resolveHomeAwarePath(persistedConfigPath)); + const stablePersistedEnv = persistedConfigLooksStale ? {} : persistedEnv; const worktreeRoot = path.resolve(path.dirname(configPath), ".."); const worktreeName = - nonEmpty(persistedEnv.PAPERCLIP_WORKTREE_NAME) ?? + nonEmpty(stablePersistedEnv.PAPERCLIP_WORKTREE_NAME) ?? nonEmpty(env.PAPERCLIP_WORKTREE_NAME) ?? path.basename(worktreeRoot); const instanceId = - nonEmpty(persistedEnv.PAPERCLIP_INSTANCE_ID) ?? + nonEmpty(stablePersistedEnv.PAPERCLIP_INSTANCE_ID) ?? nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? sanitizeWorktreeInstanceId(worktreeName); const homeDir = resolveHomeAwarePath( - nonEmpty(persistedEnv.PAPERCLIP_HOME) ?? + nonEmpty(stablePersistedEnv.PAPERCLIP_HOME) ?? nonEmpty(env.PAPERCLIP_HOME) ?? nonEmpty(env.PAPERCLIP_WORKTREES_DIR) ?? "~/.paperclip-worktrees", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 905804a9..f6d81f2b 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -30,10 +30,10 @@ import { Activity } from "./pages/Activity"; import { Inbox } from "./pages/Inbox"; import { CompanySettings } from "./pages/CompanySettings"; import { CompanyEnvironments } from "./pages/CompanyEnvironments"; -import { CompanySettingsPluginPage } from "./pages/CompanySettingsPluginPage"; -import { CompanyAccess, CompanyAccessLegacyRoute } from "./pages/CompanyAccess"; import { CloudUpstream } from "./pages/CloudUpstream"; import { CloudUpstreamUxLab } from "./pages/CloudUpstreamUxLab"; +import { CompanySettingsPluginPage } from "./pages/CompanySettingsPluginPage"; +import { CompanyAccess, CompanyAccessLegacyRoute } from "./pages/CompanyAccess"; import { CompanyInvites } from "./pages/CompanyInvites"; import { CompanySkills } from "./pages/CompanySkills"; import { Secrets } from "./pages/Secrets"; @@ -72,6 +72,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/components/CompanySettingsSidebar.test.tsx b/ui/src/components/CompanySettingsSidebar.test.tsx index cdff2d8b..124725bb 100644 --- a/ui/src/components/CompanySettingsSidebar.test.tsx +++ b/ui/src/components/CompanySettingsSidebar.test.tsx @@ -1,6 +1,5 @@ // @vitest-environment jsdom -import { act } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -10,10 +9,10 @@ const sidebarNavItemMock = vi.hoisted(() => vi.fn()); const mockSidebarBadgesApi = vi.hoisted(() => ({ get: vi.fn(), })); -const mockUsePluginSlots = vi.hoisted(() => vi.fn()); const mockInstanceSettingsApi = vi.hoisted(() => ({ getExperimental: vi.fn(), })); +const mockUsePluginSlots = vi.hoisted(() => vi.fn()); vi.mock("@/lib/router", () => ({ Link: ({ @@ -65,22 +64,28 @@ vi.mock("@/api/sidebarBadges", () => ({ sidebarBadgesApi: mockSidebarBadgesApi, })); -vi.mock("@/plugins/slots", () => ({ - usePluginSlots: mockUsePluginSlots, -})); - vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: mockInstanceSettingsApi, })); +vi.mock("@/plugins/slots", () => ({ + usePluginSlots: mockUsePluginSlots, +})); + // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +async function act(callback: () => void | Promise) { + await callback(); + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); +} + async function flushReact() { - await act(async () => { + for (let i = 0; i < 3; i += 1) { await Promise.resolve(); await new Promise((resolve) => window.setTimeout(resolve, 0)); - }); + } } describe("CompanySettingsSidebar", () => { @@ -95,6 +100,9 @@ describe("CompanySettingsSidebar", () => { failedRuns: 0, joinRequests: 2, }); + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableCloudSync: false, + }); mockUsePluginSlots.mockReturnValue({ slots: [], isLoading: false, @@ -130,6 +138,7 @@ describe("CompanySettingsSidebar", () => { expect(container.textContent).toContain("Company Settings"); expect(container.textContent).toContain("General"); expect(container.textContent).toContain("Environments"); + expect(container.textContent).not.toContain("Cloud upstream"); expect(container.textContent).toContain("Members"); expect(container.textContent).not.toContain("Cloud upstream"); expect(container.textContent).toContain("Invites"); @@ -176,6 +185,38 @@ describe("CompanySettingsSidebar", () => { }); }); + it("shows cloud upstream only when cloud sync is enabled", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableCloudSync: true, + }); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + expect(container.textContent).toContain("Cloud upstream"); + expect(sidebarNavItemMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "/company/settings/cloud-upstream", + label: "Cloud upstream", + end: true, + }), + ); + + await act(async () => { + root.unmount(); + }); + }); + it("renders company settings pages contributed by ready plugins", async () => { mockUsePluginSlots.mockReturnValue({ slots: [ diff --git a/ui/src/components/EntityRow.tsx b/ui/src/components/EntityRow.tsx index 4c375fbd..de1c69e6 100644 --- a/ui/src/components/EntityRow.tsx +++ b/ui/src/components/EntityRow.tsx @@ -12,6 +12,7 @@ interface EntityRowProps { to?: string; onClick?: () => void; className?: string; + reserveSubtitleSpace?: boolean; } export function EntityRow({ @@ -24,6 +25,7 @@ export function EntityRow({ to, onClick, className, + reserveSubtitleSpace, }: EntityRowProps) { const isClickable = !!(to || onClick); const classes = cn( @@ -45,8 +47,13 @@ export function EntityRow({ )} {title} - {subtitle && ( -

{subtitle}

+ {(subtitle || reserveSubtitleSpace) && ( +

+ {subtitle} +

)} {trailing &&
{trailing}
} diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index ebb46349..dd92af4b 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -450,8 +450,10 @@ describe("MarkdownBody", () => { expect(html).toContain("paperclip-markdown-codeblock"); expect(html).toContain("paperclip-markdown-codeblock-actions"); + expect(html).toContain("position:absolute;top:0.4rem;right:0.4rem;display:inline-flex"); expect(html).toContain("paperclip-markdown-codeblock-wrap"); expect(html).toContain('aria-label="Wrap lines"'); + expect(html).toContain("position:static;opacity:1;display:inline-flex"); expect(html).toContain("paperclip-markdown-codeblock-copy"); expect(html).toContain('aria-label="Copy code"'); expect(html).toContain("lucide-copy"); diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index 8c81de26..5d4fd886 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -84,6 +84,39 @@ const scrollableBlockStyle: React.CSSProperties = { overflowX: "auto", }; +const codeBlockActionsStyle: React.CSSProperties = { + position: "absolute", + top: "0.4rem", + right: "0.4rem", + display: "inline-flex", + alignItems: "center", + gap: "0.25rem", +}; + +const codeBlockActionStyle: React.CSSProperties = { + position: "static", + opacity: 1, + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + gap: "0.25rem", + minHeight: "1.55rem", + padding: "0.2rem 0.4rem", + borderRadius: "calc(var(--radius) - 4px)", + border: "1px solid color-mix(in oklab, var(--foreground) 14%, transparent)", + backgroundColor: "color-mix(in oklab, var(--muted) 92%, var(--background) 8%)", + color: "var(--muted-foreground)", + fontSize: "0.7rem", + lineHeight: 1, + cursor: "pointer", +}; + +const codeBlockWrapActionStyle: React.CSSProperties = { + ...codeBlockActionStyle, + width: "1.55rem", + paddingInline: 0, +}; + const tableCellWrapStyle: React.CSSProperties = { overflowWrap: "anywhere", wordBreak: "normal", @@ -426,6 +459,7 @@ function CodeBlock({
- +
+ diff --git a/ui/src/pages/InviteUxLab.tsx b/ui/src/pages/InviteUxLab.tsx index 9a356d0a..38bcd24c 100644 --- a/ui/src/pages/InviteUxLab.tsx +++ b/ui/src/pages/InviteUxLab.tsx @@ -371,10 +371,10 @@ function AcceptInvitePreview({

Accept company invite

{autoAccept - ? "Submitting your join request for Acme Robotics." + ? "Granting your access to Acme Robotics." : isCurrentMember ? "This account already belongs to Acme Robotics." - : "This will submit or complete your join request for Acme Robotics."} + : "This will grant or complete your access to Acme Robotics."}

{error ?

{error}

: null} @@ -572,7 +572,7 @@ function CompanyInvitesPreview() {
- Each invite link is single-use. The first successful use consumes the link and creates or reuses the matching join request before approval. + Each invite link is single-use. Human invitees get the selected role immediately after sign-in; agent invites still create a join request for approval.
diff --git a/ui/src/pages/Projects.test.tsx b/ui/src/pages/Projects.test.tsx new file mode 100644 index 00000000..185db766 --- /dev/null +++ b/ui/src/pages/Projects.test.tsx @@ -0,0 +1,164 @@ +// @vitest-environment jsdom + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { Project } from "@paperclipai/shared"; +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Projects } from "./Projects"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const mockProjectsApi = vi.hoisted(() => ({ + list: vi.fn(), +})); +const mockOpenNewProject = vi.hoisted(() => vi.fn()); +const mockSetBreadcrumbs = vi.hoisted(() => vi.fn()); + +vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi })); +vi.mock("@/lib/router", () => ({ + Link: ({ children, to }: { children?: ReactNode; to: string }) => {children}, +})); +vi.mock("../context/CompanyContext", () => ({ + useCompany: () => ({ selectedCompanyId: "company-1" }), +})); +vi.mock("../context/DialogContext", () => ({ + useDialogActions: () => ({ openNewProject: mockOpenNewProject }), +})); +vi.mock("../context/BreadcrumbContext", () => ({ + useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }), +})); + +function project(overrides: Partial): Project { + const now = new Date("2026-05-01T00:00:00Z"); + return { + id: "project-1", + companyId: "company-1", + urlKey: "project-1", + goalId: null, + goalIds: [], + goals: [], + name: "Project", + description: null, + status: "in_progress", + leadAgentId: null, + targetDate: null, + color: "#14b8a6", + env: null, + pauseReason: null, + pausedAt: null, + executionWorkspacePolicy: null, + codebase: { + workspaceId: null, + repoUrl: null, + repoRef: null, + defaultRef: null, + repoName: null, + localFolder: null, + managedFolder: "/tmp/project-1", + effectiveLocalFolder: "/tmp/project-1", + origin: "managed_checkout", + }, + workspaces: [], + primaryWorkspace: null, + archivedAt: null, + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +async function renderProjects(container: HTMLElement) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + let root: Root | null = null; + + await act(async () => { + root = createRoot(container); + root.render( + + + , + ); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + return root; +} + +function projectLinkNames(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll("a[href^='/projects/']")).map((link) => { + const title = link.querySelector("span.truncate"); + return title?.textContent ?? ""; + }); +} + +describe("Projects", () => { + let root: Root | null = null; + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + mockProjectsApi.list.mockResolvedValue([ + project({ + id: "project-bravo", + urlKey: "bravo", + name: "Bravo", + description: null, + updatedAt: new Date("2026-05-02T00:00:00Z"), + }), + project({ + id: "project-alpha", + urlKey: "alpha", + name: "Alpha", + description: "First project", + updatedAt: new Date("2026-05-01T00:00:00Z"), + }), + project({ + id: "project-charlie", + urlKey: "charlie", + name: "Charlie", + description: null, + updatedAt: new Date("2026-05-03T00:00:00Z"), + }), + ]); + }); + + afterEach(() => { + act(() => root?.unmount()); + root = null; + container.remove(); + vi.clearAllMocks(); + }); + + it("sorts projects by name by default and can switch sort mode", async () => { + root = await renderProjects(container); + + expect(projectLinkNames(container)).toEqual(["Alpha", "Bravo", "Charlie"]); + + const select = container.querySelector("select"); + expect(select).not.toBeNull(); + + await act(async () => { + select!.value = "updated"; + select!.dispatchEvent(new Event("change", { bubbles: true })); + }); + + expect(projectLinkNames(container)).toEqual(["Charlie", "Bravo", "Alpha"]); + }); + + it("reserves description line height for projects without descriptions", async () => { + root = await renderProjects(container); + + const bravoLink = Array.from(container.querySelectorAll("a")).find((link) => + link.textContent?.includes("Bravo"), + ); + const hiddenDescriptionLine = bravoLink?.querySelector("p[aria-hidden='true']"); + + expect(hiddenDescriptionLine).not.toBeNull(); + expect(hiddenDescriptionLine?.className).toContain("min-h-4"); + }); +}); diff --git a/ui/src/pages/Projects.tsx b/ui/src/pages/Projects.tsx index 08d246a8..a26c17dd 100644 --- a/ui/src/pages/Projects.tsx +++ b/ui/src/pages/Projects.tsx @@ -1,5 +1,6 @@ -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; +import type { Project } from "@paperclipai/shared"; import { projectsApi } from "../api/projects"; import { useCompany } from "../context/CompanyContext"; import { useDialogActions } from "../context/DialogContext"; @@ -11,12 +12,67 @@ import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { formatDate, projectUrl } from "../lib/utils"; import { Button } from "@/components/ui/button"; -import { Hexagon, Plus } from "lucide-react"; +import { ArrowUpDown, Hexagon, Plus } from "lucide-react"; + +type ProjectSortMode = "name" | "updated" | "targetDate" | "status"; + +const PROJECT_SORT_LABELS: Record = { + name: "Name", + updated: "Recently updated", + targetDate: "Target date", + status: "Status", +}; + +const PROJECT_STATUS_RANK: Record = { + in_progress: 0, + planned: 1, + backlog: 2, + completed: 3, + cancelled: 4, +}; + +function projectTime(project: Project, field: "createdAt" | "updatedAt"): number { + const value = project[field]; + const time = value instanceof Date ? value.getTime() : new Date(value).getTime(); + return Number.isFinite(time) ? time : 0; +} + +function compareProjectNames(left: Project, right: Project): number { + const nameDiff = left.name.localeCompare(right.name, undefined, { sensitivity: "base" }); + return nameDiff !== 0 ? nameDiff : left.id.localeCompare(right.id); +} + +function compareTargetDates(left: Project, right: Project): number { + if (!left.targetDate && !right.targetDate) return compareProjectNames(left, right); + if (!left.targetDate) return 1; + if (!right.targetDate) return -1; + + const dateDiff = left.targetDate.localeCompare(right.targetDate); + return dateDiff !== 0 ? dateDiff : compareProjectNames(left, right); +} + +function sortProjects(projects: Project[], sortMode: ProjectSortMode): Project[] { + return [...projects].sort((left, right) => { + if (sortMode === "updated") { + const updatedDiff = projectTime(right, "updatedAt") - projectTime(left, "updatedAt"); + return updatedDiff !== 0 ? updatedDiff : compareProjectNames(left, right); + } + if (sortMode === "targetDate") { + return compareTargetDates(left, right); + } + if (sortMode === "status") { + const statusDiff = PROJECT_STATUS_RANK[left.status] - PROJECT_STATUS_RANK[right.status]; + return statusDiff !== 0 ? statusDiff : compareProjectNames(left, right); + } + return compareProjectNames(left, right); + }); +} export function Projects() { const { selectedCompanyId } = useCompany(); const { openNewProject } = useDialogActions(); const { setBreadcrumbs } = useBreadcrumbs(); + const [sortMode, setSortMode] = useState("name"); useEffect(() => { setBreadcrumbs([{ label: "Projects" }]); @@ -31,6 +87,10 @@ export function Projects() { () => (allProjects ?? []).filter((p) => !p.archivedAt), [allProjects], ); + const sortedProjects = useMemo( + () => sortProjects(projects, sortMode), + [projects, sortMode], + ); if (!selectedCompanyId) { return ; @@ -42,7 +102,22 @@ export function Projects() { return (
-
+
+