Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/cli/src/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,4 @@
- For OpenClaw setup flow, cover self-setup behavior, config patch idempotency, and missing-file validation.
- For registry invite flow, cover admin-auth create path, public redeem path, config persistence failures, and command exit-code behavior.
- Keep tests deterministic by mocking network and filesystem dependencies.
- File-copy/integration-style skill installer tests must set explicit per-test timeouts (for example `15_000ms`) to avoid false failures under loaded CI/pre-push environments.
3 changes: 3 additions & 0 deletions apps/cli/src/commands/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,12 +113,15 @@
## Pair Command Rules
- `pair start <agentName>` must call proxy `/pair/start` with `Authorization: Claw <AIT>` and signed PoP headers from local agent `secret.key`.
- `pair start` must rely on local Claw agent auth + PoP headers only; ownership is validated server-side via proxy-to-registry internal service auth.
- `pair start` must load/create a local X25519 identity (`agents/<agent>/e2ee-identity.json`) and include `initiatorE2ee.{keyId,x25519PublicKey}` in the request.
- `pair start --qr` must generate a one-time local PNG QR containing the returned ticket and print the filesystem path.
- `pair start --qr` must sweep expired QR artifacts in `~/.clawdentity/pairing` before writing a new file.
- `pair confirm <agentName>` must call proxy `/pair/confirm` with `Authorization: Claw <AIT>` and signed PoP headers from local agent `secret.key`.
- `pair confirm` must accept either `--qr-file <path>` (primary) or `--ticket <clwpair1_...>` (fallback), never both.
- `pair confirm` must include local `responderE2ee.{keyId,x25519PublicKey}` in the confirm request.
- `pair confirm --qr-file` must delete the consumed QR file after successful confirm (best effort, non-fatal on cleanup failure).
- `pair status <agentName> --ticket <clwpair1_...>` must poll `/pair/status` and persist peers locally when status transitions to `confirmed`.
- Pair persistence must store peer E2EE bundle under `peers.<alias>.e2ee` so connector runtime can encrypt outbound traffic without additional bootstrap.
- After peer persistence, pair flows must best-effort sync OpenClaw transform peer snapshot (`hooks/transforms/clawdentity-peers.json`) when `~/.clawdentity/openclaw-relay.json` provides `relayTransformPeersPath`, so relay delivery works without manual file copying.
- `pair start --wait` should use `/pair/status` polling and auto-save the responder peer locally so reverse pairing is not required.
- `pair` commands must resolve proxy URL automatically from CLI config/registry metadata, with `CLAWDENTITY_PROXY_URL` env override support.
Expand Down
51 changes: 49 additions & 2 deletions apps/cli/src/commands/openclaw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,12 +196,18 @@ type PeerEntry = {
proxyUrl: string;
agentName?: string;
humanName?: string;
e2ee?: PeerE2eeBundle;
};

type PeersConfig = {
peers: Record<string, PeerEntry>;
};

type PeerE2eeBundle = {
keyId: string;
x25519PublicKey: string;
};

export type OpenclawInviteResult = {
code: string;
did: string;
Expand Down Expand Up @@ -366,6 +372,42 @@ function parseOptionalProfileName(
return parseNonEmptyString(value, label);
}

function parsePeerE2eeBundle(
value: unknown,
alias: string,
): PeerE2eeBundle | undefined {
if (value === undefined) {
return undefined;
}
if (!isRecord(value)) {
throw createCliError(
"CLI_OPENCLAW_INVALID_PEERS_CONFIG",
"Peer e2ee config must be an object",
{ alias },
);
}

const keyId = parseNonEmptyString(value.keyId, `Peer ${alias} e2ee.keyId`);
const x25519PublicKey = parseNonEmptyString(
value.x25519PublicKey,
`Peer ${alias} e2ee.x25519PublicKey`,
);

try {
if (decodeBase64url(x25519PublicKey).length !== 32) {
throw new Error("invalid key length");
}
} catch {
throw createCliError(
"CLI_OPENCLAW_INVALID_PEERS_CONFIG",
"Peer e2ee x25519PublicKey is invalid",
{ alias },
);
}

return { keyId, x25519PublicKey };
}

function parsePeerAlias(value: unknown): string {
const alias = parseNonEmptyString(value, "peer alias");
if (alias.length > 128) {
Expand Down Expand Up @@ -996,13 +1038,18 @@ async function loadPeersConfig(peersPath: string): Promise<PeersConfig> {
const proxyUrl = parseProxyUrl(value.proxyUrl);
const agentName = parseOptionalProfileName(value.agentName, "agentName");
const humanName = parseOptionalProfileName(value.humanName, "humanName");
const e2ee = parsePeerE2eeBundle(value.e2ee, normalizedAlias);

if (agentName === undefined && humanName === undefined) {
if (
agentName === undefined &&
humanName === undefined &&
e2ee === undefined
) {
peers[normalizedAlias] = { did, proxyUrl };
continue;
}

peers[normalizedAlias] = { did, proxyUrl, agentName, humanName };
peers[normalizedAlias] = { did, proxyUrl, agentName, humanName, e2ee };
}

return { peers };
Expand Down
39 changes: 33 additions & 6 deletions apps/cli/src/commands/pair.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ const RESPONDER_PROFILE = {
humanName: "Ira",
};

const INITIATOR_E2EE = {
keyId: "01HF7YAT31JZHSMW1CG6Q6MHB7",
x25519PublicKey: Buffer.alloc(32, 7).toString("base64url"),
};

const RESPONDER_E2EE = {
keyId: "01HF7YAT31JZHSMW1CG6Q6MHB8",
x25519PublicKey: Buffer.alloc(32, 8).toString("base64url"),
};

const createPairFixture = async (): Promise<PairFixture> => {
const keypair = await generateEd25519Keypair();
const encoded = encodeEd25519KeypairBase64url(keypair);
Expand Down Expand Up @@ -118,6 +128,7 @@ describe("pair command helpers", () => {
{
initiatorAgentDid: "did:claw:agent:01HAAA11111111111111111111",
initiatorProfile: INITIATOR_PROFILE,
initiatorE2ee: INITIATOR_E2EE,
ticket: "clwpair1_eyJ2IjoxfQ",
expiresAt: "2026-02-18T00:00:00.000Z",
},
Expand Down Expand Up @@ -164,8 +175,8 @@ describe("pair command helpers", () => {
expect(unlinkImpl).toHaveBeenCalledWith(
"/tmp/.clawdentity/pairing/alpha-pair-1699999000.png",
);
expect(writeFileImpl).toHaveBeenCalledTimes(1);
expect(mkdirImpl).toHaveBeenCalledTimes(1);
expect(writeFileImpl).toHaveBeenCalledTimes(2);
expect(mkdirImpl).toHaveBeenCalledTimes(2);
const [, init] = fetchImpl.mock.calls[1] as [string, RequestInit];
expect(init?.method).toBe("POST");
const headers = new Headers(init?.headers);
Expand All @@ -191,6 +202,7 @@ describe("pair command helpers", () => {
{
initiatorAgentDid: "did:claw:agent:01HAAA11111111111111111111",
initiatorProfile: INITIATOR_PROFILE,
initiatorE2ee: INITIATOR_E2EE,
ticket: "clwpair1_eyJ2IjoxfQ",
expiresAt: "2026-02-18T00:00:00.000Z",
},
Expand Down Expand Up @@ -234,6 +246,7 @@ describe("pair command helpers", () => {
{
initiatorAgentDid: "did:claw:agent:01HAAA11111111111111111111",
initiatorProfile: INITIATOR_PROFILE,
initiatorE2ee: INITIATOR_E2EE,
ticket: "clwpair1_eyJ2IjoxfQ",
expiresAt: "2026-02-18T00:00:00.000Z",
},
Expand Down Expand Up @@ -280,6 +293,7 @@ describe("pair command helpers", () => {
{
initiatorAgentDid: "did:claw:agent:01HAAA11111111111111111111",
initiatorProfile: INITIATOR_PROFILE,
initiatorE2ee: INITIATOR_E2EE,
ticket: "clwpair1_eyJ2IjoxfQ",
expiresAt: "2026-02-18T00:00:00.000Z",
},
Expand Down Expand Up @@ -373,6 +387,7 @@ describe("pair command helpers", () => {
status: "pending",
initiatorAgentDid: "did:claw:agent:01HAAA11111111111111111111",
initiatorProfile: INITIATOR_PROFILE,
initiatorE2ee: INITIATOR_E2EE,
expiresAt: "2026-02-18T00:00:00.000Z",
},
{ status: 200 },
Expand Down Expand Up @@ -426,8 +441,10 @@ describe("pair command helpers", () => {
paired: true,
initiatorAgentDid: "did:claw:agent:01HAAA11111111111111111111",
initiatorProfile: INITIATOR_PROFILE,
initiatorE2ee: INITIATOR_E2EE,
responderAgentDid: "did:claw:agent:01HBBB22222222222222222222",
responderProfile: RESPONDER_PROFILE,
responderE2ee: RESPONDER_E2EE,
},
{ status: 201 },
);
Expand Down Expand Up @@ -477,8 +494,8 @@ describe("pair command helpers", () => {
expect(String(init?.body ?? "")).toContain("responderProfile");
expect(unlinkImpl).toHaveBeenCalledTimes(1);
expect(unlinkImpl).toHaveBeenCalledWith("/tmp/pair.png");
expect(writeFileImpl).toHaveBeenCalledTimes(1);
expect(chmodImpl).toHaveBeenCalledTimes(1);
expect(writeFileImpl).toHaveBeenCalledTimes(2);
expect(chmodImpl).toHaveBeenCalledTimes(2);
});

it("syncs OpenClaw relay peers snapshot after pair confirm", async () => {
Expand Down Expand Up @@ -533,8 +550,10 @@ describe("pair command helpers", () => {
paired: true,
initiatorAgentDid: "did:claw:agent:01HAAA11111111111111111111",
initiatorProfile: INITIATOR_PROFILE,
initiatorE2ee: INITIATOR_E2EE,
responderAgentDid: "did:claw:agent:01HBBB22222222222222222222",
responderProfile: RESPONDER_PROFILE,
responderE2ee: RESPONDER_E2EE,
},
{ status: 201 },
);
Expand Down Expand Up @@ -577,8 +596,8 @@ describe("pair command helpers", () => {
expect.any(String),
"utf8",
);
expect(mkdirImpl).toHaveBeenCalledTimes(2);
expect(chmodImpl).toHaveBeenCalledTimes(2);
expect(mkdirImpl).toHaveBeenCalledTimes(3);
expect(chmodImpl).toHaveBeenCalledTimes(3);
});

it("checks pending pair status without persisting peers", async () => {
Expand All @@ -605,6 +624,7 @@ describe("pair command helpers", () => {
status: "pending",
initiatorAgentDid: "did:claw:agent:01HAAA11111111111111111111",
initiatorProfile: INITIATOR_PROFILE,
initiatorE2ee: INITIATOR_E2EE,
expiresAt: "2026-02-18T00:00:00.000Z",
},
{ status: 200 },
Expand Down Expand Up @@ -658,14 +678,17 @@ describe("pair command helpers", () => {
status: "pending",
initiatorAgentDid: "did:claw:agent:01HAAA11111111111111111111",
initiatorProfile: INITIATOR_PROFILE,
initiatorE2ee: INITIATOR_E2EE,
expiresAt: "2026-02-18T00:00:00.000Z",
},
{
status: "confirmed",
initiatorAgentDid: "did:claw:agent:01HAAA11111111111111111111",
initiatorProfile: INITIATOR_PROFILE,
initiatorE2ee: INITIATOR_E2EE,
responderAgentDid: "did:claw:agent:01HBBB22222222222222222222",
responderProfile: RESPONDER_PROFILE,
responderE2ee: RESPONDER_E2EE,
expiresAt: "2026-02-18T00:00:00.000Z",
confirmedAt: "2026-02-18T00:00:05.000Z",
},
Expand Down Expand Up @@ -809,6 +832,7 @@ describe("pair command output", () => {
{
initiatorAgentDid: "did:claw:agent:01HAAA11111111111111111111",
initiatorProfile: INITIATOR_PROFILE,
initiatorE2ee: INITIATOR_E2EE,
ticket: "clwpair1_eyJ2IjoxfQ",
expiresAt: "2026-02-18T00:00:00.000Z",
},
Expand Down Expand Up @@ -865,8 +889,10 @@ describe("pair command output", () => {
paired: true,
initiatorAgentDid: "did:claw:agent:01HAAA11111111111111111111",
initiatorProfile: INITIATOR_PROFILE,
initiatorE2ee: INITIATOR_E2EE,
responderAgentDid: "did:claw:agent:01HBBB22222222222222222222",
responderProfile: RESPONDER_PROFILE,
responderE2ee: RESPONDER_E2EE,
},
{ status: 201 },
);
Expand Down Expand Up @@ -928,6 +954,7 @@ describe("pair command output", () => {
status: "pending",
initiatorAgentDid: "did:claw:agent:01HAAA11111111111111111111",
initiatorProfile: INITIATOR_PROFILE,
initiatorE2ee: INITIATOR_E2EE,
expiresAt: "2026-02-18T00:00:00.000Z",
},
{ status: 200 },
Expand Down
Loading