diff --git a/doc/OPENCLAW_ONBOARDING.md b/doc/OPENCLAW_ONBOARDING.md index 14a251de..bdb098b3 100644 --- a/doc/OPENCLAW_ONBOARDING.md +++ b/doc/OPENCLAW_ONBOARDING.md @@ -18,20 +18,28 @@ Open the printed `Dashboard URL` (includes `#token=...`) in your browser. 3. In Paperclip UI, go to `http://127.0.0.1:3100/CLA/company/settings`. -4. Use the agent snippet flow. -- Copy the snippet from company settings. +4. Use the OpenClaw invite prompt flow. +- In the Invites section, click `Generate OpenClaw Invite Prompt`. +- Copy the generated prompt from `OpenClaw Invite Prompt`. - Paste it into OpenClaw main chat as one message. - If it stalls, send one follow-up: `How is onboarding going? Continue setup now.` +Security/control note: +- The OpenClaw invite prompt is created from a controlled endpoint: + - `POST /api/companies/{companyId}/openclaw/invite-prompt` + - board users with invite permission can call it + - agent callers are limited to the company CEO agent + 5. Approve the join request in Paperclip UI, then confirm the OpenClaw agent appears in CLA agents. 6. Gateway preflight (required before task tests). - Confirm the created agent uses `openclaw_gateway` (not `openclaw`). - Confirm gateway URL is `ws://...` or `wss://...`. - Confirm gateway token is non-trivial (not empty / not 1-char placeholder). +- The OpenClaw Gateway adapter UI should not expose `disableDeviceAuth` for normal onboarding. - Confirm pairing mode is explicit: - - recommended default: `adapterConfig.disableDeviceAuth` is false/absent and `adapterConfig.devicePrivateKeyPem` is present - - fallback only: `adapterConfig.disableDeviceAuth=true` when pairing cannot be supported in that environment + - required default: device auth enabled (`adapterConfig.disableDeviceAuth` false/absent) with persisted `adapterConfig.devicePrivateKeyPem` + - do not rely on `disableDeviceAuth` for normal onboarding - If you can run API checks with board auth: ```bash AGENT_ID="" @@ -40,8 +48,9 @@ curl -sS -H "Cookie: $PAPERCLIP_COOKIE" "http://127.0.0.1:3100/api/agents/$AGENT - Expected: `adapterType=openclaw_gateway`, `tokenLen >= 16`, `hasDeviceKey=true`, and `disableDeviceAuth=false`. Pairing handshake note: -- The adapter now attempts one automatic pairing approval + retry on first `pairing required` (when shared gateway auth token/password is valid). -- If auto-pair cannot complete, the first gateway run may still return `pairing required` once for a new device key. +- Clean run expectation: first task should succeed without manual pairing commands. +- The adapter attempts one automatic pairing approval + retry on first `pairing required` (when shared gateway auth token/password is valid). +- If auto-pair cannot complete (for example token mismatch or no pending request), the first gateway run may still return `pairing required`. - This is a separate approval from Paperclip invite approval. You must approve the pending device in OpenClaw itself. - Approve it in OpenClaw, then retry the task. - For local docker smoke, you can approve from host: diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 59ec9eb6..a91f8844 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -197,6 +197,7 @@ export { updateBudgetSchema, createAssetImageMetadataSchema, createCompanyInviteSchema, + createOpenClawInvitePromptSchema, acceptInviteSchema, listJoinRequestsQuerySchema, claimJoinRequestApiKeySchema, @@ -206,6 +207,7 @@ export { type UpdateBudget, type CreateAssetImageMetadata, type CreateCompanyInvite, + type CreateOpenClawInvitePrompt, type AcceptInvite, type ListJoinRequestsQuery, type ClaimJoinRequestApiKey, diff --git a/packages/shared/src/validators/access.ts b/packages/shared/src/validators/access.ts index 614b302e..75b31709 100644 --- a/packages/shared/src/validators/access.ts +++ b/packages/shared/src/validators/access.ts @@ -15,6 +15,14 @@ export const createCompanyInviteSchema = z.object({ export type CreateCompanyInvite = z.infer; +export const createOpenClawInvitePromptSchema = z.object({ + agentMessage: z.string().max(4000).optional().nullable(), +}); + +export type CreateOpenClawInvitePrompt = z.infer< + typeof createOpenClawInvitePromptSchema +>; + export const acceptInviteSchema = z.object({ requestType: z.enum(JOIN_REQUEST_TYPES), agentName: z.string().min(1).max(120).optional(), diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 12ad7ffb..f4130c67 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -119,12 +119,14 @@ export { export { createCompanyInviteSchema, + createOpenClawInvitePromptSchema, acceptInviteSchema, listJoinRequestsQuerySchema, claimJoinRequestApiKeySchema, updateMemberPermissionsSchema, updateUserCompanyAccessSchema, type CreateCompanyInvite, + type CreateOpenClawInvitePrompt, type AcceptInvite, type ListJoinRequestsQuery, type ClaimJoinRequestApiKey, diff --git a/server/src/__tests__/openclaw-invite-prompt-route.test.ts b/server/src/__tests__/openclaw-invite-prompt-route.test.ts new file mode 100644 index 00000000..68cb8759 --- /dev/null +++ b/server/src/__tests__/openclaw-invite-prompt-route.test.ts @@ -0,0 +1,181 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { accessRoutes } from "../routes/access.js"; +import { errorHandler } from "../middleware/index.js"; + +const mockAccessService = vi.hoisted(() => ({ + hasPermission: vi.fn(), + canUser: vi.fn(), + isInstanceAdmin: vi.fn(), + getMembership: vi.fn(), + ensureMembership: vi.fn(), + listMembers: vi.fn(), + setMemberPermissions: vi.fn(), + promoteInstanceAdmin: vi.fn(), + demoteInstanceAdmin: vi.fn(), + listUserCompanyAccess: vi.fn(), + setUserCompanyAccess: vi.fn(), + setPrincipalGrants: vi.fn(), +})); + +const mockAgentService = vi.hoisted(() => ({ + getById: vi.fn(), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn()); + +vi.mock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + deduplicateAgentName: vi.fn(), + logActivity: mockLogActivity, + notifyHireApproved: vi.fn(), +})); + +function createDbStub() { + const createdInvite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "agent", + defaultsPayload: null, + expiresAt: new Date("2026-03-07T00:10:00.000Z"), + invitedByUserId: null, + tokenHash: "hash", + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:00:00.000Z"), + }; + const returning = vi.fn().mockResolvedValue([createdInvite]); + const values = vi.fn().mockReturnValue({ returning }); + const insert = vi.fn().mockReturnValue({ values }); + return { + insert, + }; +} + +function createApp(actor: Record, db: Record) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use( + "/api", + accessRoutes(db as any, { + deploymentMode: "local_trusted", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }), + ); + app.use(errorHandler); + return app; +} + +describe("POST /companies/:companyId/openclaw/invite-prompt", () => { + beforeEach(() => { + mockAccessService.canUser.mockResolvedValue(false); + mockAgentService.getById.mockReset(); + mockLogActivity.mockResolvedValue(undefined); + }); + + it("rejects non-CEO agent callers", async () => { + const db = createDbStub(); + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "company-1", + role: "engineer", + }); + const app = createApp( + { + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + }, + db, + ); + + const res = await request(app) + .post("/api/companies/company-1/openclaw/invite-prompt") + .send({}); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Only CEO agents"); + }); + + it("allows CEO agent callers and creates an agent-only invite", async () => { + const db = createDbStub(); + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "company-1", + role: "ceo", + }); + const app = createApp( + { + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + }, + db, + ); + + const res = await request(app) + .post("/api/companies/company-1/openclaw/invite-prompt") + .send({ agentMessage: "Join and configure OpenClaw gateway." }); + + expect(res.status).toBe(201); + expect(res.body.allowedJoinTypes).toBe("agent"); + expect(typeof res.body.token).toBe("string"); + expect(res.body.onboardingTextPath).toContain("/api/invites/"); + }); + + it("allows board callers with invite permission", async () => { + const db = createDbStub(); + mockAccessService.canUser.mockResolvedValue(true); + const app = createApp( + { + type: "board", + userId: "user-1", + companyIds: ["company-1"], + source: "session", + isInstanceAdmin: false, + }, + db, + ); + + const res = await request(app) + .post("/api/companies/company-1/openclaw/invite-prompt") + .send({}); + + expect(res.status).toBe(201); + expect(res.body.allowedJoinTypes).toBe("agent"); + }); + + it("rejects board callers without invite permission", async () => { + const db = createDbStub(); + mockAccessService.canUser.mockResolvedValue(false); + const app = createApp( + { + type: "board", + userId: "user-1", + companyIds: ["company-1"], + source: "session", + isInstanceAdmin: false, + }, + db, + ); + + const res = await request(app) + .post("/api/companies/company-1/openclaw/invite-prompt") + .send({}); + + expect(res.status).toBe(403); + expect(res.body.error).toBe("Permission denied"); + }); +}); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 406e4bd3..3e2ba527 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -21,6 +21,7 @@ import { acceptInviteSchema, claimJoinRequestApiKeySchema, createCompanyInviteSchema, + createOpenClawInvitePromptSchema, listJoinRequestsQuerySchema, updateMemberPermissionsSchema, updateUserCompanyAccessSchema, @@ -1942,6 +1943,80 @@ export function accessRoutes( if (!allowed) throw forbidden("Permission denied"); } + async function assertCanGenerateOpenClawInvitePrompt( + req: Request, + companyId: string + ) { + assertCompanyAccess(req, companyId); + if (req.actor.type === "agent") { + if (!req.actor.agentId) throw forbidden("Agent authentication required"); + const actorAgent = await agents.getById(req.actor.agentId); + if (!actorAgent || actorAgent.companyId !== companyId) { + throw forbidden("Agent key cannot access another company"); + } + if (actorAgent.role !== "ceo") { + throw forbidden("Only CEO agents can generate OpenClaw invite prompts"); + } + return; + } + if (req.actor.type !== "board") throw unauthorized(); + if (isLocalImplicit(req)) return; + const allowed = await access.canUser(companyId, req.actor.userId, "users:invite"); + if (!allowed) throw forbidden("Permission denied"); + } + + async function createCompanyInviteForCompany(input: { + req: Request; + companyId: string; + allowedJoinTypes: "human" | "agent" | "both"; + defaultsPayload?: Record | null; + agentMessage?: string | null; + }) { + const normalizedAgentMessage = + typeof input.agentMessage === "string" + ? input.agentMessage.trim() || null + : null; + const insertValues = { + companyId: input.companyId, + inviteType: "company_join" as const, + allowedJoinTypes: input.allowedJoinTypes, + defaultsPayload: mergeInviteDefaults( + input.defaultsPayload ?? null, + normalizedAgentMessage + ), + expiresAt: companyInviteExpiresAt(), + invitedByUserId: input.req.actor.userId ?? null + }; + + let token: string | null = null; + let created: typeof invites.$inferSelect | null = null; + for (let attempt = 0; attempt < INVITE_TOKEN_MAX_RETRIES; attempt += 1) { + const candidateToken = createInviteToken(); + try { + const row = await db + .insert(invites) + .values({ + ...insertValues, + tokenHash: hashToken(candidateToken) + }) + .returning() + .then((rows) => rows[0]); + token = candidateToken; + created = row; + break; + } catch (error) { + if (!isInviteTokenHashCollisionError(error)) { + throw error; + } + } + } + if (!token || !created) { + throw conflict("Failed to generate a unique invite token. Please retry."); + } + + return { token, created, normalizedAgentMessage }; + } + router.get("/skills/index", (_req, res) => { res.json({ skills: [ @@ -1967,49 +2042,14 @@ export function accessRoutes( async (req, res) => { const companyId = req.params.companyId as string; await assertCompanyPermission(req, companyId, "users:invite"); - const normalizedAgentMessage = - typeof req.body.agentMessage === "string" - ? req.body.agentMessage.trim() || null - : null; - const insertValues = { - companyId, - inviteType: "company_join" as const, - allowedJoinTypes: req.body.allowedJoinTypes, - defaultsPayload: mergeInviteDefaults( - req.body.defaultsPayload ?? null, - normalizedAgentMessage - ), - expiresAt: companyInviteExpiresAt(), - invitedByUserId: req.actor.userId ?? null - }; - - let token: string | null = null; - let created: typeof invites.$inferSelect | null = null; - for (let attempt = 0; attempt < INVITE_TOKEN_MAX_RETRIES; attempt += 1) { - const candidateToken = createInviteToken(); - try { - const row = await db - .insert(invites) - .values({ - ...insertValues, - tokenHash: hashToken(candidateToken) - }) - .returning() - .then((rows) => rows[0]); - token = candidateToken; - created = row; - break; - } catch (error) { - if (!isInviteTokenHashCollisionError(error)) { - throw error; - } - } - } - if (!token || !created) { - throw conflict( - "Failed to generate a unique invite token. Please retry." - ); - } + const { token, created, normalizedAgentMessage } = + await createCompanyInviteForCompany({ + req, + companyId, + allowedJoinTypes: req.body.allowedJoinTypes, + defaultsPayload: req.body.defaultsPayload ?? null, + agentMessage: req.body.agentMessage ?? null + }); await logActivity(db, { companyId, @@ -2041,6 +2081,51 @@ export function accessRoutes( } ); + router.post( + "/companies/:companyId/openclaw/invite-prompt", + validate(createOpenClawInvitePromptSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + await assertCanGenerateOpenClawInvitePrompt(req, companyId); + const { token, created, normalizedAgentMessage } = + await createCompanyInviteForCompany({ + req, + companyId, + allowedJoinTypes: "agent", + defaultsPayload: null, + agentMessage: req.body.agentMessage ?? null + }); + + await logActivity(db, { + companyId, + actorType: req.actor.type === "agent" ? "agent" : "user", + actorId: + req.actor.type === "agent" + ? req.actor.agentId ?? "unknown-agent" + : req.actor.userId ?? "board", + action: "invite.openclaw_prompt_created", + entityType: "invite", + entityId: created.id, + details: { + inviteType: created.inviteType, + allowedJoinTypes: created.allowedJoinTypes, + expiresAt: created.expiresAt.toISOString(), + hasAgentMessage: Boolean(normalizedAgentMessage) + } + }); + + const inviteSummary = toInviteSummaryResponse(req, token, created); + res.status(201).json({ + ...created, + token, + inviteUrl: `/invite/${token}`, + onboardingTextPath: inviteSummary.onboardingTextPath, + onboardingTextUrl: inviteSummary.onboardingTextUrl, + inviteMessage: inviteSummary.inviteMessage + }); + } + ); + router.get("/invites/:token", async (req, res) => { const token = (req.params.token as string).trim(); if (!token) throw notFound("Invite not found"); diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 92fe3ba4..bb3cbb04 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -91,6 +91,30 @@ Workspace rules: - For repo-only setup, omit `cwd` and provide `repoUrl`. - Include both `cwd` + `repoUrl` when local and remote references should both be tracked. +## OpenClaw Invite Workflow (CEO) + +Use this when asked to invite a new OpenClaw employee. + +1. Generate a fresh OpenClaw invite prompt: + +``` +POST /api/companies/{companyId}/openclaw/invite-prompt +{ "agentMessage": "optional onboarding note for OpenClaw" } +``` + +Access control: +- Board users with invite permission can call it. +- Agent callers: only the company CEO agent can call it. + +2. Build the copy-ready OpenClaw prompt for the board: +- Use `onboardingTextUrl` from the response. +- Ask the board to paste that prompt into OpenClaw. +- If the issue includes an OpenClaw URL (for example `ws://127.0.0.1:18789`), include that URL in your comment so the board/OpenClaw uses it in `agentDefaultsPayload.url`. + +3. Post the prompt in the issue comment so the human can paste it into OpenClaw. + +4. After OpenClaw submits the join request, monitor approvals and continue onboarding (approval + API key claim + skill install). + ## Critical Rules - **Always checkout** before working. Never PATCH to `in_progress` manually. @@ -206,6 +230,7 @@ PATCH /api/agents/{agentId}/instructions-path | Update task | `PATCH /api/issues/:issueId` (optional `comment` field) | | Add comment | `POST /api/issues/:issueId/comments` | | Create subtask | `POST /api/companies/:companyId/issues` | +| Generate OpenClaw invite prompt (CEO) | `POST /api/companies/:companyId/openclaw/invite-prompt` | | Create project | `POST /api/companies/:companyId/projects` | | Create project workspace | `POST /api/projects/:projectId/workspaces` | | Set instructions path | `PATCH /api/agents/:agentId/instructions-path` | diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index a88abb82..cbf5ef05 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -280,6 +280,23 @@ GET /api/companies/{companyId}/dashboard — health summary: agent/task counts, Use the dashboard for situational awareness, especially if you're a manager or CEO. +## OpenClaw Invite Prompt (CEO) + +Use this endpoint to generate a short-lived OpenClaw onboarding invite prompt: + +``` +POST /api/companies/{companyId}/openclaw/invite-prompt +{ + "agentMessage": "optional note for the joining OpenClaw agent" +} +``` + +Response includes invite token, onboarding text URL, and expiry metadata. + +Access is intentionally constrained: +- board users with invite permission +- CEO agent only (non-CEO agents are rejected) + --- ## Setting Agent Instructions Path @@ -505,6 +522,7 @@ Terminal states: `done`, `cancelled` | GET | `/api/goals/:goalId` | Goal details | | POST | `/api/companies/:companyId/goals` | Create goal | | PATCH | `/api/goals/:goalId` | Update goal | +| POST | `/api/companies/:companyId/openclaw/invite-prompt` | Generate OpenClaw invite prompt (CEO/board only) | ### Approvals, Costs, Activity, Dashboard diff --git a/ui/src/api/access.ts b/ui/src/api/access.ts index 7e89afd6..ce565f6d 100644 --- a/ui/src/api/access.ts +++ b/ui/src/api/access.ts @@ -64,6 +64,17 @@ type BoardClaimStatus = { claimedByUserId: string | null; }; +type CompanyInviteCreated = { + id: string; + token: string; + inviteUrl: string; + expiresAt: string; + allowedJoinTypes: "human" | "agent" | "both"; + onboardingTextPath?: string; + onboardingTextUrl?: string; + inviteMessage?: string | null; +}; + export const accessApi = { createCompanyInvite: ( companyId: string, @@ -73,16 +84,18 @@ export const accessApi = { agentMessage?: string | null; } = {}, ) => - api.post<{ - id: string; - token: string; - inviteUrl: string; - expiresAt: string; - allowedJoinTypes: "human" | "agent" | "both"; - onboardingTextPath?: string; - onboardingTextUrl?: string; - inviteMessage?: string | null; - }>(`/companies/${companyId}/invites`, input), + api.post(`/companies/${companyId}/invites`, input), + + createOpenClawInvitePrompt: ( + companyId: string, + input: { + agentMessage?: string | null; + } = {}, + ) => + api.post( + `/companies/${companyId}/openclaw/invite-prompt`, + input, + ), getInvite: (token: string) => api.get(`/invites/${token}`), getInviteOnboarding: (token: string) => diff --git a/ui/src/components/NewAgentDialog.tsx b/ui/src/components/NewAgentDialog.tsx index b3ab9233..18830792 100644 --- a/ui/src/components/NewAgentDialog.tsx +++ b/ui/src/components/NewAgentDialog.tsx @@ -1,3 +1,4 @@ +import { useState, type ComponentType } from "react"; import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@/lib/router"; import { useDialog } from "../context/DialogContext"; @@ -9,12 +10,77 @@ import { DialogContent, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; -import { Bot, Sparkles } from "lucide-react"; +import { + ArrowLeft, + Bot, + Code, + MousePointer2, + Sparkles, + Terminal, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon"; + +type AdvancedAdapterType = + | "claude_local" + | "codex_local" + | "opencode_local" + | "pi_local" + | "cursor" + | "openclaw_gateway"; + +const ADVANCED_ADAPTER_OPTIONS: Array<{ + value: AdvancedAdapterType; + label: string; + desc: string; + icon: ComponentType<{ className?: string }>; + recommended?: boolean; +}> = [ + { + value: "claude_local", + label: "Claude Code", + icon: Sparkles, + desc: "Local Claude agent", + recommended: true, + }, + { + value: "codex_local", + label: "Codex", + icon: Code, + desc: "Local Codex agent", + recommended: true, + }, + { + value: "opencode_local", + label: "OpenCode", + icon: OpenCodeLogoIcon, + desc: "Local multi-provider agent", + }, + { + value: "pi_local", + label: "Pi", + icon: Terminal, + desc: "Local Pi agent", + }, + { + value: "cursor", + label: "Cursor", + icon: MousePointer2, + desc: "Local Cursor agent", + }, + { + value: "openclaw_gateway", + label: "OpenClaw Gateway", + icon: Bot, + desc: "Invoke OpenClaw via gateway protocol", + }, +]; export function NewAgentDialog() { const { newAgentOpen, closeNewAgent, openNewIssue } = useDialog(); const { selectedCompanyId } = useCompany(); const navigate = useNavigate(); + const [showAdvancedCards, setShowAdvancedCards] = useState(false); const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), @@ -34,15 +100,23 @@ export function NewAgentDialog() { } function handleAdvancedConfig() { + setShowAdvancedCards(true); + } + + function handleAdvancedAdapterPick(adapterType: AdvancedAdapterType) { closeNewAgent(); - navigate("/agents/new"); + setShowAdvancedCards(false); + navigate(`/agents/new?adapterType=${encodeURIComponent(adapterType)}`); } return ( { - if (!open) closeNewAgent(); + if (!open) { + setShowAdvancedCards(false); + closeNewAgent(); + } }} > { + setShowAdvancedCards(false); + closeNewAgent(); + }} > ×
- {/* Recommendation */} -
-
- -
-

- We recommend letting your CEO handle agent setup — they know the - org structure and can configure reporting, permissions, and - adapters. -

-
+ {!showAdvancedCards ? ( + <> + {/* Recommendation */} +
+
+ +
+

+ We recommend letting your CEO handle agent setup — they know the + org structure and can configure reporting, permissions, and + adapters. +

+
- + - {/* Advanced link */} -
- -
+ {/* Advanced link */} +
+ +
+ + ) : ( + <> +
+ +

+ Choose your adapter type for advanced setup. +

+
+ +
+ {ADVANCED_ADAPTER_OPTIONS.map((opt) => ( + + ))} +
+ + )}
diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 7a4bceeb..e1520356 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -38,7 +38,6 @@ import { ArrowLeft, ArrowRight, Terminal, - Globe, Sparkles, MousePointer2, Check, @@ -673,38 +672,19 @@ export function OnboardingWizard() { icon: Terminal, desc: "Local Pi agent" }, - { - value: "openclaw" as const, - label: "OpenClaw", - icon: Bot, - desc: "Notify OpenClaw webhook", - comingSoon: true - }, { value: "openclaw_gateway" as const, label: "OpenClaw Gateway", icon: Bot, - desc: "Invoke OpenClaw via gateway protocol" + desc: "Invoke OpenClaw via gateway protocol", + comingSoon: true, + disabledLabel: "Configure OpenClaw within the App" }, { value: "cursor" as const, label: "Cursor", icon: MousePointer2, desc: "Local Cursor agent" - }, - { - value: "process" as const, - label: "Shell Command", - icon: Terminal, - desc: "Run a process", - comingSoon: true - }, - { - value: "http" as const, - label: "HTTP Webhook", - icon: Globe, - desc: "Call an endpoint", - comingSoon: true } ].map((opt) => ( ))} diff --git a/ui/src/pages/CompanySettings.tsx b/ui/src/pages/CompanySettings.tsx index c11bd8b9..878c5193 100644 --- a/ui/src/pages/CompanySettings.tsx +++ b/ui/src/pages/CompanySettings.tsx @@ -77,9 +77,7 @@ export function CompanySettings() { const inviteMutation = useMutation({ mutationFn: () => - accessApi.createCompanyInvite(selectedCompanyId!, { - allowedJoinTypes: "agent" - }), + accessApi.createOpenClawInvitePrompt(selectedCompanyId!), onSuccess: async (invite) => { setInviteError(null); const base = window.location.origin.replace(/\/+$/, ""); @@ -317,9 +315,9 @@ export function CompanySettings() {
- Generate an agent snippet for join flows. + Generate an openclaw agent invite snippet. - +
{inviteError && ( @@ -339,7 +337,7 @@ export function CompanySettings() {
- Agent Snippet + OpenClaw Invite Prompt
{snippetCopied && ( ([ + "claude_local", + "codex_local", + "opencode_local", + "pi_local", + "cursor", + "openclaw_gateway", +]); + +function createValuesForAdapterType( + adapterType: CreateConfigValues["adapterType"], +): CreateConfigValues { + const { adapterType: _discard, ...defaults } = defaultCreateValues; + const nextValues: CreateConfigValues = { ...defaults, adapterType }; + if (adapterType === "codex_local") { + nextValues.model = DEFAULT_CODEX_LOCAL_MODEL; + nextValues.dangerouslyBypassSandbox = + DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX; + } else if (adapterType === "cursor") { + nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL; + } else if (adapterType === "opencode_local") { + nextValues.model = ""; + } + return nextValues; +} export function NewAgent() { - const { selectedCompanyId, selectedCompany } = useCompany(); + const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const presetAdapterType = searchParams.get("adapterType"); const [name, setName] = useState(""); const [title, setTitle] = useState(""); @@ -71,6 +104,18 @@ export function NewAgent() { } }, [isFirstAgent]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + const requested = presetAdapterType; + if (!requested) return; + if (!SUPPORTED_ADVANCED_ADAPTER_TYPES.has(requested as CreateConfigValues["adapterType"])) { + return; + } + setConfigValues((prev) => { + if (prev.adapterType === requested) return prev; + return createValuesForAdapterType(requested as CreateConfigValues["adapterType"]); + }); + }, [presetAdapterType]); + const createAgent = useMutation({ mutationFn: (data: Record) => agentsApi.hire(selectedCompanyId!, data),