Add CEO OpenClaw invite endpoint and update onboarding UX
This commit is contained in:
@@ -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`.
|
3. In Paperclip UI, go to `http://127.0.0.1:3100/CLA/company/settings`.
|
||||||
|
|
||||||
4. Use the agent snippet flow.
|
4. Use the OpenClaw invite prompt flow.
|
||||||
- Copy the snippet from company settings.
|
- 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.
|
- Paste it into OpenClaw main chat as one message.
|
||||||
- If it stalls, send one follow-up: `How is onboarding going? Continue setup now.`
|
- 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.
|
5. Approve the join request in Paperclip UI, then confirm the OpenClaw agent appears in CLA agents.
|
||||||
|
|
||||||
6. Gateway preflight (required before task tests).
|
6. Gateway preflight (required before task tests).
|
||||||
- Confirm the created agent uses `openclaw_gateway` (not `openclaw`).
|
- Confirm the created agent uses `openclaw_gateway` (not `openclaw`).
|
||||||
- Confirm gateway URL is `ws://...` or `wss://...`.
|
- Confirm gateway URL is `ws://...` or `wss://...`.
|
||||||
- Confirm gateway token is non-trivial (not empty / not 1-char placeholder).
|
- 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:
|
- Confirm pairing mode is explicit:
|
||||||
- recommended default: `adapterConfig.disableDeviceAuth` is false/absent and `adapterConfig.devicePrivateKeyPem` is present
|
- required default: device auth enabled (`adapterConfig.disableDeviceAuth` false/absent) with persisted `adapterConfig.devicePrivateKeyPem`
|
||||||
- fallback only: `adapterConfig.disableDeviceAuth=true` when pairing cannot be supported in that environment
|
- do not rely on `disableDeviceAuth` for normal onboarding
|
||||||
- If you can run API checks with board auth:
|
- If you can run API checks with board auth:
|
||||||
```bash
|
```bash
|
||||||
AGENT_ID="<newly-created-agent-id>"
|
AGENT_ID="<newly-created-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`.
|
- Expected: `adapterType=openclaw_gateway`, `tokenLen >= 16`, `hasDeviceKey=true`, and `disableDeviceAuth=false`.
|
||||||
|
|
||||||
Pairing handshake note:
|
Pairing handshake note:
|
||||||
- The adapter now attempts one automatic pairing approval + retry on first `pairing required` (when shared gateway auth token/password is valid).
|
- Clean run expectation: first task should succeed without manual pairing commands.
|
||||||
- If auto-pair cannot complete, the first gateway run may still return `pairing required` once for a new device key.
|
- 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.
|
- 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.
|
- Approve it in OpenClaw, then retry the task.
|
||||||
- For local docker smoke, you can approve from host:
|
- For local docker smoke, you can approve from host:
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ export {
|
|||||||
updateBudgetSchema,
|
updateBudgetSchema,
|
||||||
createAssetImageMetadataSchema,
|
createAssetImageMetadataSchema,
|
||||||
createCompanyInviteSchema,
|
createCompanyInviteSchema,
|
||||||
|
createOpenClawInvitePromptSchema,
|
||||||
acceptInviteSchema,
|
acceptInviteSchema,
|
||||||
listJoinRequestsQuerySchema,
|
listJoinRequestsQuerySchema,
|
||||||
claimJoinRequestApiKeySchema,
|
claimJoinRequestApiKeySchema,
|
||||||
@@ -206,6 +207,7 @@ export {
|
|||||||
type UpdateBudget,
|
type UpdateBudget,
|
||||||
type CreateAssetImageMetadata,
|
type CreateAssetImageMetadata,
|
||||||
type CreateCompanyInvite,
|
type CreateCompanyInvite,
|
||||||
|
type CreateOpenClawInvitePrompt,
|
||||||
type AcceptInvite,
|
type AcceptInvite,
|
||||||
type ListJoinRequestsQuery,
|
type ListJoinRequestsQuery,
|
||||||
type ClaimJoinRequestApiKey,
|
type ClaimJoinRequestApiKey,
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ export const createCompanyInviteSchema = z.object({
|
|||||||
|
|
||||||
export type CreateCompanyInvite = z.infer<typeof createCompanyInviteSchema>;
|
export type CreateCompanyInvite = z.infer<typeof createCompanyInviteSchema>;
|
||||||
|
|
||||||
|
export const createOpenClawInvitePromptSchema = z.object({
|
||||||
|
agentMessage: z.string().max(4000).optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateOpenClawInvitePrompt = z.infer<
|
||||||
|
typeof createOpenClawInvitePromptSchema
|
||||||
|
>;
|
||||||
|
|
||||||
export const acceptInviteSchema = z.object({
|
export const acceptInviteSchema = z.object({
|
||||||
requestType: z.enum(JOIN_REQUEST_TYPES),
|
requestType: z.enum(JOIN_REQUEST_TYPES),
|
||||||
agentName: z.string().min(1).max(120).optional(),
|
agentName: z.string().min(1).max(120).optional(),
|
||||||
|
|||||||
@@ -119,12 +119,14 @@ export {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
createCompanyInviteSchema,
|
createCompanyInviteSchema,
|
||||||
|
createOpenClawInvitePromptSchema,
|
||||||
acceptInviteSchema,
|
acceptInviteSchema,
|
||||||
listJoinRequestsQuerySchema,
|
listJoinRequestsQuerySchema,
|
||||||
claimJoinRequestApiKeySchema,
|
claimJoinRequestApiKeySchema,
|
||||||
updateMemberPermissionsSchema,
|
updateMemberPermissionsSchema,
|
||||||
updateUserCompanyAccessSchema,
|
updateUserCompanyAccessSchema,
|
||||||
type CreateCompanyInvite,
|
type CreateCompanyInvite,
|
||||||
|
type CreateOpenClawInvitePrompt,
|
||||||
type AcceptInvite,
|
type AcceptInvite,
|
||||||
type ListJoinRequestsQuery,
|
type ListJoinRequestsQuery,
|
||||||
type ClaimJoinRequestApiKey,
|
type ClaimJoinRequestApiKey,
|
||||||
|
|||||||
181
server/src/__tests__/openclaw-invite-prompt-route.test.ts
Normal file
181
server/src/__tests__/openclaw-invite-prompt-route.test.ts
Normal file
@@ -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<string, unknown>, db: Record<string, unknown>) {
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
acceptInviteSchema,
|
acceptInviteSchema,
|
||||||
claimJoinRequestApiKeySchema,
|
claimJoinRequestApiKeySchema,
|
||||||
createCompanyInviteSchema,
|
createCompanyInviteSchema,
|
||||||
|
createOpenClawInvitePromptSchema,
|
||||||
listJoinRequestsQuerySchema,
|
listJoinRequestsQuerySchema,
|
||||||
updateMemberPermissionsSchema,
|
updateMemberPermissionsSchema,
|
||||||
updateUserCompanyAccessSchema,
|
updateUserCompanyAccessSchema,
|
||||||
@@ -1942,6 +1943,80 @@ export function accessRoutes(
|
|||||||
if (!allowed) throw forbidden("Permission denied");
|
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<string, unknown> | 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) => {
|
router.get("/skills/index", (_req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
skills: [
|
skills: [
|
||||||
@@ -1967,49 +2042,14 @@ export function accessRoutes(
|
|||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
await assertCompanyPermission(req, companyId, "users:invite");
|
await assertCompanyPermission(req, companyId, "users:invite");
|
||||||
const normalizedAgentMessage =
|
const { token, created, normalizedAgentMessage } =
|
||||||
typeof req.body.agentMessage === "string"
|
await createCompanyInviteForCompany({
|
||||||
? req.body.agentMessage.trim() || null
|
req,
|
||||||
: null;
|
companyId,
|
||||||
const insertValues = {
|
allowedJoinTypes: req.body.allowedJoinTypes,
|
||||||
companyId,
|
defaultsPayload: req.body.defaultsPayload ?? null,
|
||||||
inviteType: "company_join" as const,
|
agentMessage: req.body.agentMessage ?? null
|
||||||
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."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await logActivity(db, {
|
await logActivity(db, {
|
||||||
companyId,
|
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) => {
|
router.get("/invites/:token", async (req, res) => {
|
||||||
const token = (req.params.token as string).trim();
|
const token = (req.params.token as string).trim();
|
||||||
if (!token) throw notFound("Invite not found");
|
if (!token) throw notFound("Invite not found");
|
||||||
|
|||||||
@@ -91,6 +91,30 @@ Workspace rules:
|
|||||||
- For repo-only setup, omit `cwd` and provide `repoUrl`.
|
- For repo-only setup, omit `cwd` and provide `repoUrl`.
|
||||||
- Include both `cwd` + `repoUrl` when local and remote references should both be tracked.
|
- 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
|
## Critical Rules
|
||||||
|
|
||||||
- **Always checkout** before working. Never PATCH to `in_progress` manually.
|
- **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) |
|
| Update task | `PATCH /api/issues/:issueId` (optional `comment` field) |
|
||||||
| Add comment | `POST /api/issues/:issueId/comments` |
|
| Add comment | `POST /api/issues/:issueId/comments` |
|
||||||
| Create subtask | `POST /api/companies/:companyId/issues` |
|
| 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 | `POST /api/companies/:companyId/projects` |
|
||||||
| Create project workspace | `POST /api/projects/:projectId/workspaces` |
|
| Create project workspace | `POST /api/projects/:projectId/workspaces` |
|
||||||
| Set instructions path | `PATCH /api/agents/:agentId/instructions-path` |
|
| Set instructions path | `PATCH /api/agents/:agentId/instructions-path` |
|
||||||
|
|||||||
@@ -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.
|
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
|
## Setting Agent Instructions Path
|
||||||
@@ -505,6 +522,7 @@ Terminal states: `done`, `cancelled`
|
|||||||
| GET | `/api/goals/:goalId` | Goal details |
|
| GET | `/api/goals/:goalId` | Goal details |
|
||||||
| POST | `/api/companies/:companyId/goals` | Create goal |
|
| POST | `/api/companies/:companyId/goals` | Create goal |
|
||||||
| PATCH | `/api/goals/:goalId` | Update 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
|
### Approvals, Costs, Activity, Dashboard
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,17 @@ type BoardClaimStatus = {
|
|||||||
claimedByUserId: string | null;
|
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 = {
|
export const accessApi = {
|
||||||
createCompanyInvite: (
|
createCompanyInvite: (
|
||||||
companyId: string,
|
companyId: string,
|
||||||
@@ -73,16 +84,18 @@ export const accessApi = {
|
|||||||
agentMessage?: string | null;
|
agentMessage?: string | null;
|
||||||
} = {},
|
} = {},
|
||||||
) =>
|
) =>
|
||||||
api.post<{
|
api.post<CompanyInviteCreated>(`/companies/${companyId}/invites`, input),
|
||||||
id: string;
|
|
||||||
token: string;
|
createOpenClawInvitePrompt: (
|
||||||
inviteUrl: string;
|
companyId: string,
|
||||||
expiresAt: string;
|
input: {
|
||||||
allowedJoinTypes: "human" | "agent" | "both";
|
agentMessage?: string | null;
|
||||||
onboardingTextPath?: string;
|
} = {},
|
||||||
onboardingTextUrl?: string;
|
) =>
|
||||||
inviteMessage?: string | null;
|
api.post<CompanyInviteCreated>(
|
||||||
}>(`/companies/${companyId}/invites`, input),
|
`/companies/${companyId}/openclaw/invite-prompt`,
|
||||||
|
input,
|
||||||
|
),
|
||||||
|
|
||||||
getInvite: (token: string) => api.get<InviteSummary>(`/invites/${token}`),
|
getInvite: (token: string) => api.get<InviteSummary>(`/invites/${token}`),
|
||||||
getInviteOnboarding: (token: string) =>
|
getInviteOnboarding: (token: string) =>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState, type ComponentType } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "@/lib/router";
|
import { useNavigate } from "@/lib/router";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
@@ -9,12 +10,77 @@ import {
|
|||||||
DialogContent,
|
DialogContent,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
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() {
|
export function NewAgentDialog() {
|
||||||
const { newAgentOpen, closeNewAgent, openNewIssue } = useDialog();
|
const { newAgentOpen, closeNewAgent, openNewIssue } = useDialog();
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [showAdvancedCards, setShowAdvancedCards] = useState(false);
|
||||||
|
|
||||||
const { data: agents } = useQuery({
|
const { data: agents } = useQuery({
|
||||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||||
@@ -34,15 +100,23 @@ export function NewAgentDialog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleAdvancedConfig() {
|
function handleAdvancedConfig() {
|
||||||
|
setShowAdvancedCards(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAdvancedAdapterPick(adapterType: AdvancedAdapterType) {
|
||||||
closeNewAgent();
|
closeNewAgent();
|
||||||
navigate("/agents/new");
|
setShowAdvancedCards(false);
|
||||||
|
navigate(`/agents/new?adapterType=${encodeURIComponent(adapterType)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={newAgentOpen}
|
open={newAgentOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) closeNewAgent();
|
if (!open) {
|
||||||
|
setShowAdvancedCards(false);
|
||||||
|
closeNewAgent();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
@@ -56,39 +130,84 @@ export function NewAgentDialog() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-xs"
|
size="icon-xs"
|
||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
onClick={closeNewAgent}
|
onClick={() => {
|
||||||
|
setShowAdvancedCards(false);
|
||||||
|
closeNewAgent();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-lg leading-none">×</span>
|
<span className="text-lg leading-none">×</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
{/* Recommendation */}
|
{!showAdvancedCards ? (
|
||||||
<div className="text-center space-y-3">
|
<>
|
||||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-accent">
|
{/* Recommendation */}
|
||||||
<Sparkles className="h-6 w-6 text-foreground" />
|
<div className="text-center space-y-3">
|
||||||
</div>
|
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-accent">
|
||||||
<p className="text-sm text-muted-foreground">
|
<Sparkles className="h-6 w-6 text-foreground" />
|
||||||
We recommend letting your CEO handle agent setup — they know the
|
</div>
|
||||||
org structure and can configure reporting, permissions, and
|
<p className="text-sm text-muted-foreground">
|
||||||
adapters.
|
We recommend letting your CEO handle agent setup — they know the
|
||||||
</p>
|
org structure and can configure reporting, permissions, and
|
||||||
</div>
|
adapters.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button className="w-full" size="lg" onClick={handleAskCeo}>
|
<Button className="w-full" size="lg" onClick={handleAskCeo}>
|
||||||
<Bot className="h-4 w-4 mr-2" />
|
<Bot className="h-4 w-4 mr-2" />
|
||||||
Ask the CEO to create a new agent
|
Ask the CEO to create a new agent
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Advanced link */}
|
{/* Advanced link */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<button
|
<button
|
||||||
className="text-xs text-muted-foreground hover:text-foreground underline underline-offset-2 transition-colors"
|
className="text-xs text-muted-foreground hover:text-foreground underline underline-offset-2 transition-colors"
|
||||||
onClick={handleAdvancedConfig}
|
onClick={handleAdvancedConfig}
|
||||||
>
|
>
|
||||||
I want advanced configuration myself
|
I want advanced configuration myself
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
onClick={() => setShowAdvancedCards(false)}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-3.5 w-3.5" />
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Choose your adapter type for advanced setup.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{ADVANCED_ADAPTER_OPTIONS.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center gap-1.5 rounded-md border border-border p-3 text-xs transition-colors hover:bg-accent/50 relative"
|
||||||
|
)}
|
||||||
|
onClick={() => handleAdvancedAdapterPick(opt.value)}
|
||||||
|
>
|
||||||
|
{opt.recommended && (
|
||||||
|
<span className="absolute -top-1.5 right-1.5 bg-green-500 text-white text-[9px] font-semibold px-1.5 py-0.5 rounded-full leading-none">
|
||||||
|
Recommended
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<opt.icon className="h-4 w-4" />
|
||||||
|
<span className="font-medium">{opt.label}</span>
|
||||||
|
<span className="text-muted-foreground text-[10px]">
|
||||||
|
{opt.desc}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Terminal,
|
Terminal,
|
||||||
Globe,
|
|
||||||
Sparkles,
|
Sparkles,
|
||||||
MousePointer2,
|
MousePointer2,
|
||||||
Check,
|
Check,
|
||||||
@@ -673,38 +672,19 @@ export function OnboardingWizard() {
|
|||||||
icon: Terminal,
|
icon: Terminal,
|
||||||
desc: "Local Pi agent"
|
desc: "Local Pi agent"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
value: "openclaw" as const,
|
|
||||||
label: "OpenClaw",
|
|
||||||
icon: Bot,
|
|
||||||
desc: "Notify OpenClaw webhook",
|
|
||||||
comingSoon: true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
value: "openclaw_gateway" as const,
|
value: "openclaw_gateway" as const,
|
||||||
label: "OpenClaw Gateway",
|
label: "OpenClaw Gateway",
|
||||||
icon: Bot,
|
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,
|
value: "cursor" as const,
|
||||||
label: "Cursor",
|
label: "Cursor",
|
||||||
icon: MousePointer2,
|
icon: MousePointer2,
|
||||||
desc: "Local Cursor agent"
|
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) => (
|
].map((opt) => (
|
||||||
<button
|
<button
|
||||||
@@ -744,7 +724,10 @@ export function OnboardingWizard() {
|
|||||||
<opt.icon className="h-4 w-4" />
|
<opt.icon className="h-4 w-4" />
|
||||||
<span className="font-medium">{opt.label}</span>
|
<span className="font-medium">{opt.label}</span>
|
||||||
<span className="text-muted-foreground text-[10px]">
|
<span className="text-muted-foreground text-[10px]">
|
||||||
{opt.comingSoon ? "Coming soon" : opt.desc}
|
{opt.comingSoon
|
||||||
|
? (opt as { disabledLabel?: string }).disabledLabel ??
|
||||||
|
"Coming soon"
|
||||||
|
: opt.desc}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -77,9 +77,7 @@ export function CompanySettings() {
|
|||||||
|
|
||||||
const inviteMutation = useMutation({
|
const inviteMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
accessApi.createCompanyInvite(selectedCompanyId!, {
|
accessApi.createOpenClawInvitePrompt(selectedCompanyId!),
|
||||||
allowedJoinTypes: "agent"
|
|
||||||
}),
|
|
||||||
onSuccess: async (invite) => {
|
onSuccess: async (invite) => {
|
||||||
setInviteError(null);
|
setInviteError(null);
|
||||||
const base = window.location.origin.replace(/\/+$/, "");
|
const base = window.location.origin.replace(/\/+$/, "");
|
||||||
@@ -317,9 +315,9 @@ export function CompanySettings() {
|
|||||||
<div className="space-y-3 rounded-md border border-border px-4 py-4">
|
<div className="space-y-3 rounded-md border border-border px-4 py-4">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Generate an agent snippet for join flows.
|
Generate an openclaw agent invite snippet.
|
||||||
</span>
|
</span>
|
||||||
<HintIcon text="Creates an agent-only invite (10m) and renders a copy-ready snippet." />
|
<HintIcon text="Creates a short-lived OpenClaw agent invite and renders a copy-ready prompt." />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -329,7 +327,7 @@ export function CompanySettings() {
|
|||||||
>
|
>
|
||||||
{inviteMutation.isPending
|
{inviteMutation.isPending
|
||||||
? "Generating..."
|
? "Generating..."
|
||||||
: "Generate agent snippet"}
|
: "Generate OpenClaw Invite Prompt"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{inviteError && (
|
{inviteError && (
|
||||||
@@ -339,7 +337,7 @@ export function CompanySettings() {
|
|||||||
<div className="rounded-md border border-border bg-muted/30 p-2">
|
<div className="rounded-md border border-border bg-muted/30 p-2">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
Agent Snippet
|
OpenClaw Invite Prompt
|
||||||
</div>
|
</div>
|
||||||
{snippetCopied && (
|
{snippetCopied && (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "@/lib/router";
|
import { useNavigate, useSearchParams } from "@/lib/router";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
@@ -19,12 +19,45 @@ import { AgentConfigForm, type CreateConfigValues } from "../components/AgentCon
|
|||||||
import { defaultCreateValues } from "../components/agent-config-defaults";
|
import { defaultCreateValues } from "../components/agent-config-defaults";
|
||||||
import { getUIAdapter } from "../adapters";
|
import { getUIAdapter } from "../adapters";
|
||||||
import { AgentIcon } from "../components/AgentIconPicker";
|
import { AgentIcon } from "../components/AgentIconPicker";
|
||||||
|
import {
|
||||||
|
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
||||||
|
DEFAULT_CODEX_LOCAL_MODEL,
|
||||||
|
} from "@paperclipai/adapter-codex-local";
|
||||||
|
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||||
|
|
||||||
|
const SUPPORTED_ADVANCED_ADAPTER_TYPES = new Set<CreateConfigValues["adapterType"]>([
|
||||||
|
"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() {
|
export function NewAgent() {
|
||||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const presetAdapterType = searchParams.get("adapterType");
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
@@ -71,6 +104,18 @@ export function NewAgent() {
|
|||||||
}
|
}
|
||||||
}, [isFirstAgent]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [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({
|
const createAgent = useMutation({
|
||||||
mutationFn: (data: Record<string, unknown>) =>
|
mutationFn: (data: Record<string, unknown>) =>
|
||||||
agentsApi.hire(selectedCompanyId!, data),
|
agentsApi.hire(selectedCompanyId!, data),
|
||||||
|
|||||||
Reference in New Issue
Block a user