forked from farhoodlabs/paperclip
897cc322c7
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - Agent creation can happen through local runtimes, managed runtimes, and external agents that onboard through invites. > - The old OpenClaw-oriented invite UX lived under company settings/invites and made a gateway-specific path look like a company access setting. > - That hid the broader bring-your-own-agent flow and forced operators to leave the add-agent modal when adding an external agent. > - This pull request moves external agent invite generation into the add-agent modal and makes the copy agent-oriented instead of OpenClaw-only. > - The benefit is a clearer agent-first onboarding path while company invites stay focused on human access. ## What Changed - Added an external-agent invite branch to the add-agent modal, including a dedicated prompt result view with Back navigation. - Added a shared agent onboarding prompt builder and focused modal coverage for prompt replacement/back navigation. - Removed the agent invite prompt UI from Company Settings and Company Invites, leaving Company Invites focused on human access links and invite history. - Updated the hidden OpenClaw Gateway runtime hint to direct operators to the add-agent invite flow instead of presenting it as a blocked runtime card. - Updated invite/onboarding docs, storybook coverage, and server-side onboarding copy toward generic agent language while preserving existing gateway compatibility. ## Verification - `pnpm -r typecheck` - `pnpm build` - `FAKE_BIN="$(mktemp -d)/bin"; mkdir -p "$FAKE_BIN"; printf '#!/bin/sh\nexit 1\n' > "$FAKE_BIN/tailscale"; chmod +x "$FAKE_BIN/tailscale"; PATH="$FAKE_BIN:$PATH" pnpm test:run` - `pnpm test:run` without the fake `tailscale` shim was also attempted; it failed only in two pre-existing CLI tailnet fallback tests because this host has a real Tailscale address (`100.125.202.3`) where those tests expect no Tailscale. - Focused confirmation for that host-env issue: `FAKE_BIN=... PATH="$FAKE_BIN:$PATH" pnpm exec vitest run --project paperclipai cli/src/__tests__/network-bind.test.ts cli/src/__tests__/onboard.test.ts` - Manual UI verification: served UI locally in light mode, opened add-agent modal, generated external agent prompt, verified the generated prompt replaces the form and Back returns to the form. ### Screenshots    ## Risks - Existing OpenClaw gateway compatibility remains, but operators now discover external agent onboarding from the add-agent modal instead of company settings. - Agent invites still appear in the invite history table, so that page may show agent-scoped invite rows even though it no longer creates agent onboarding prompts. - Low migration risk: no schema changes. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent in Codex desktop; tool-enabled repository, shell, browser, and GitHub workflow. Context window size was not exposed by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge
168 lines
5.4 KiB
TypeScript
168 lines
5.4 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
import { and, eq } from "drizzle-orm";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
activityLog,
|
|
companies,
|
|
companyMemberships,
|
|
createDb,
|
|
principalPermissionGrants,
|
|
} from "@paperclipai/db";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
|
|
vi.hoisted(() => {
|
|
process.env.PAPERCLIP_HOME = "/tmp/paperclip-test-home";
|
|
process.env.PAPERCLIP_INSTANCE_ID = "vitest";
|
|
process.env.PAPERCLIP_LOG_DIR = "/tmp/paperclip-test-home/logs";
|
|
process.env.PAPERCLIP_IN_WORKTREE = "false";
|
|
});
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
type Db = ReturnType<typeof createDb>;
|
|
|
|
async function createApp(db: Db, companyId: string, userId: string) {
|
|
process.env.PAPERCLIP_LOG_DIR = "/tmp/paperclip-test-home/logs";
|
|
process.env.PAPERCLIP_IN_WORKTREE = "false";
|
|
const { accessRoutes } = await import("../routes/access.js");
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
req.actor = {
|
|
type: "board",
|
|
userId,
|
|
source: "local_implicit",
|
|
companyIds: [companyId],
|
|
memberships: [{ companyId, membershipRole: "owner", status: "active" }],
|
|
isInstanceAdmin: true,
|
|
};
|
|
next();
|
|
});
|
|
app.use("/api", accessRoutes(db, {
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "private",
|
|
bindHost: "127.0.0.1",
|
|
allowedHostnames: [],
|
|
}));
|
|
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
|
res.status(err.status ?? 500).json({ error: err.message ?? "Internal server error" });
|
|
});
|
|
return app;
|
|
}
|
|
|
|
async function createCompanyWithOwner(db: Db) {
|
|
const company = await db
|
|
.insert(companies)
|
|
.values({
|
|
name: `Access Routes ${randomUUID()}`,
|
|
issuePrefix: `AR${randomUUID().replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
const owner = await db
|
|
.insert(companyMemberships)
|
|
.values({
|
|
companyId: company.id,
|
|
principalType: "user",
|
|
principalId: `owner-${randomUUID()}`,
|
|
status: "active",
|
|
membershipRole: "owner",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
return { company, owner };
|
|
}
|
|
|
|
describeEmbeddedPostgres("access routes permissions upgrade compatibility", () => {
|
|
let db!: Db;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-access-routes-permissions-upgrade-");
|
|
db = createDb(tempDb.connectionString);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(activityLog);
|
|
await db.delete(principalPermissionGrants);
|
|
await db.delete(companyMemberships);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
it("rejects owner self-lockout through the member route after the permissions upgrade", async () => {
|
|
const { company, owner } = await createCompanyWithOwner(db);
|
|
|
|
const res = await request(await createApp(db, company.id, owner.principalId))
|
|
.patch(`/api/companies/${company.id}/members/${owner.id}`)
|
|
.send({ membershipRole: "admin" });
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
|
expect(res.body.error).toContain("You cannot remove yourself");
|
|
|
|
const unchanged = await db
|
|
.select()
|
|
.from(companyMemberships)
|
|
.where(eq(companyMemberships.id, owner.id))
|
|
.then((rows) => rows[0]!);
|
|
expect(unchanged.membershipRole).toBe("owner");
|
|
}, 10_000);
|
|
|
|
it("keeps custom grants when the role-only member route changes a member role", async () => {
|
|
const { company, owner } = await createCompanyWithOwner(db);
|
|
const member = await db
|
|
.insert(companyMemberships)
|
|
.values({
|
|
companyId: company.id,
|
|
principalType: "user",
|
|
principalId: `admin-${randomUUID()}`,
|
|
status: "active",
|
|
membershipRole: "admin",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
const customScope = { projectIds: ["project-1"] };
|
|
await db.insert(principalPermissionGrants).values({
|
|
companyId: company.id,
|
|
principalType: "user",
|
|
principalId: member.principalId,
|
|
permissionKey: "tasks:assign_scope",
|
|
scope: customScope,
|
|
grantedByUserId: owner.principalId,
|
|
});
|
|
|
|
const res = await request(await createApp(db, company.id, owner.principalId))
|
|
.patch(`/api/companies/${company.id}/members/${member.id}`)
|
|
.send({ membershipRole: "operator" });
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(res.body.membershipRole).toBe("operator");
|
|
|
|
const grants = await db
|
|
.select()
|
|
.from(principalPermissionGrants)
|
|
.where(
|
|
and(
|
|
eq(principalPermissionGrants.companyId, company.id),
|
|
eq(principalPermissionGrants.principalType, "user"),
|
|
eq(principalPermissionGrants.principalId, member.principalId),
|
|
),
|
|
);
|
|
expect(grants).toHaveLength(1);
|
|
expect(grants[0]).toMatchObject({
|
|
permissionKey: "tasks:assign_scope",
|
|
scope: customScope,
|
|
grantedByUserId: owner.principalId,
|
|
});
|
|
});
|
|
});
|