fix(openclaw-gateway): enforce join token validation and add smoke preflight gates
This commit is contained in:
@@ -25,29 +25,41 @@ Open the printed `Dashboard URL` (includes `#token=...`) in your browser.
|
|||||||
|
|
||||||
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. Case A (manual issue test).
|
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).
|
||||||
|
- If you can run API checks with board auth:
|
||||||
|
```bash
|
||||||
|
AGENT_ID="<newly-created-agent-id>"
|
||||||
|
curl -sS -H "Cookie: $PAPERCLIP_COOKIE" "http://127.0.0.1:3100/api/agents/$AGENT_ID" | jq '{adapterType,adapterConfig:{url:.adapterConfig.url,tokenLen:(.adapterConfig.headers["x-openclaw-token"] // .adapterConfig.headers["x-openclaw-auth"] // "" | length)}}'
|
||||||
|
```
|
||||||
|
- Expected: `adapterType=openclaw_gateway` and `tokenLen >= 16`.
|
||||||
|
|
||||||
|
7. Case A (manual issue test).
|
||||||
- Create an issue assigned to the OpenClaw agent.
|
- Create an issue assigned to the OpenClaw agent.
|
||||||
- Put instructions: “post comment `OPENCLAW_CASE_A_OK_<timestamp>` and mark done.”
|
- Put instructions: “post comment `OPENCLAW_CASE_A_OK_<timestamp>` and mark done.”
|
||||||
- Verify in UI: issue status becomes `done` and comment exists.
|
- Verify in UI: issue status becomes `done` and comment exists.
|
||||||
|
|
||||||
7. Case B (message tool test).
|
8. Case B (message tool test).
|
||||||
- Create another issue assigned to OpenClaw.
|
- Create another issue assigned to OpenClaw.
|
||||||
- Instructions: “send `OPENCLAW_CASE_B_OK_<timestamp>` to main webchat via message tool, then comment same marker on issue, then mark done.”
|
- Instructions: “send `OPENCLAW_CASE_B_OK_<timestamp>` to main webchat via message tool, then comment same marker on issue, then mark done.”
|
||||||
- Verify both:
|
- Verify both:
|
||||||
- marker comment on issue
|
- marker comment on issue
|
||||||
- marker text appears in OpenClaw main chat
|
- marker text appears in OpenClaw main chat
|
||||||
|
|
||||||
8. Case C (new session memory/skills test).
|
9. Case C (new session memory/skills test).
|
||||||
- In OpenClaw, start `/new` session.
|
- In OpenClaw, start `/new` session.
|
||||||
- Ask it to create a new CLA issue in Paperclip with unique title `OPENCLAW_CASE_C_CREATED_<timestamp>`.
|
- Ask it to create a new CLA issue in Paperclip with unique title `OPENCLAW_CASE_C_CREATED_<timestamp>`.
|
||||||
- Verify in Paperclip UI that new issue exists.
|
- Verify in Paperclip UI that new issue exists.
|
||||||
|
|
||||||
9. Watch logs during test (optional but helpful):
|
10. Watch logs during test (optional but helpful):
|
||||||
```bash
|
```bash
|
||||||
docker compose -f /tmp/openclaw-docker/docker-compose.yml -f /tmp/openclaw-docker/.paperclip-openclaw.override.yml logs -f openclaw-gateway
|
docker compose -f /tmp/openclaw-docker/docker-compose.yml -f /tmp/openclaw-docker/.paperclip-openclaw.override.yml logs -f openclaw-gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
10. Expected pass criteria.
|
11. Expected pass criteria.
|
||||||
|
- Preflight: `openclaw_gateway` + non-placeholder token (`tokenLen >= 16`).
|
||||||
- Case A: `done` + marker comment.
|
- Case A: `done` + marker comment.
|
||||||
- Case B: `done` + marker comment + main-chat message visible.
|
- Case B: `done` + marker comment + main-chat message visible.
|
||||||
- Case C: original task done and new issue created from `/new` session.
|
- Case C: original task done and new issue created from `/new` session.
|
||||||
|
|||||||
@@ -78,26 +78,27 @@ Implication:
|
|||||||
|
|
||||||
## Deep Code Findings (Gaps)
|
## Deep Code Findings (Gaps)
|
||||||
|
|
||||||
### 1) Onboarding content is still OpenClaw-HTTP specific
|
### 1) Onboarding manifest/text gateway path (resolved)
|
||||||
`server/src/routes/access.ts` hardcodes onboarding to:
|
Resolved in `server/src/routes/access.ts`:
|
||||||
- `recommendedAdapterType: "openclaw"`
|
- `recommendedAdapterType` now points to `openclaw_gateway`.
|
||||||
- Required `agentDefaultsPayload.headers.x-openclaw-auth`
|
- Onboarding examples now require `adapterType: "openclaw_gateway"` + `ws://`/`wss://` URL + gateway token header.
|
||||||
- HTTP callback URL guidance and `/v1/responses` examples.
|
- Added fail-fast guidance for short/placeholder tokens.
|
||||||
|
|
||||||
There is no adapter-specific onboarding manifest/text for `openclaw_gateway`.
|
### 2) Company settings snippet gateway path (resolved)
|
||||||
|
Resolved in `ui/src/pages/CompanySettings.tsx`:
|
||||||
|
- Snippet now instructs OpenClaw Gateway onboarding.
|
||||||
|
- Snippet explicitly says not to use `/v1/responses` or `/hooks/*` for this flow.
|
||||||
|
|
||||||
### 2) Company settings snippet is OpenClaw HTTP-first
|
### 3) Invite landing “agent join” UX is not wired for OpenClaw adapters (open)
|
||||||
`ui/src/pages/CompanySettings.tsx` generates one snippet that:
|
|
||||||
- Assumes OpenClaw HTTP callback setup.
|
|
||||||
- Instructs enabling `gateway.http.endpoints.responses.enabled=true`.
|
|
||||||
- Does not provide a dedicated gateway onboarding path.
|
|
||||||
|
|
||||||
### 3) Invite landing “agent join” UX is not wired for OpenClaw adapters
|
|
||||||
`ui/src/pages/InviteLanding.tsx` shows `openclaw` and `openclaw_gateway` as disabled (“Coming soon”) in join UI.
|
`ui/src/pages/InviteLanding.tsx` shows `openclaw` and `openclaw_gateway` as disabled (“Coming soon”) in join UI.
|
||||||
|
|
||||||
### 4) Join normalization/replay logic only special-cases `adapterType === "openclaw"`
|
### 4) Join normalization/replay logic parity (partially resolved)
|
||||||
`server/src/routes/access.ts` helper paths (`buildJoinDefaultsPayloadForAccept`, replay, normalization diagnostics) are OpenClaw-HTTP specific.
|
Resolved:
|
||||||
No equivalent normalization/diagnostics for gateway defaults.
|
- `buildJoinDefaultsPayloadForAccept` now normalizes wrapped gateway token headers for `openclaw_gateway`.
|
||||||
|
- `normalizeAgentDefaultsForJoin` now validates `openclaw_gateway` URL/token and rejects short placeholder tokens at invite-accept time.
|
||||||
|
|
||||||
|
Still open:
|
||||||
|
- Invite replay path is still special-cased to legacy `openclaw` joins.
|
||||||
|
|
||||||
### 5) Webhook confusion is expected in current setup
|
### 5) Webhook confusion is expected in current setup
|
||||||
For `openclaw` + `streamTransport=webhook`:
|
For `openclaw` + `streamTransport=webhook`:
|
||||||
@@ -257,11 +258,16 @@ POST /api/companies/$CLA_COMPANY_ID/invites
|
|||||||
```
|
```
|
||||||
|
|
||||||
3. Approve join request.
|
3. Approve join request.
|
||||||
4. Claim API key with `claimSecret`.
|
4. **Hard gate before any task run:** fetch created agent config and validate:
|
||||||
5. Save claimed token to OpenClaw expected file path (`~/.openclaw/workspace/paperclip-claimed-api-key.json`) and ensure `PAPERCLIP_API_KEY` + `PAPERCLIP_API_URL` are available for OpenClaw skill execution context.
|
- `adapterType == "openclaw_gateway"`
|
||||||
|
- `adapterConfig.url` uses `ws://` or `wss://`
|
||||||
|
- `adapterConfig.headers.x-openclaw-token` exists and is not placeholder/too-short (`len >= 16`)
|
||||||
|
- token hash matches the OpenClaw `gateway.auth.token` used for join
|
||||||
|
5. Claim API key with `claimSecret`.
|
||||||
|
6. Save claimed token to OpenClaw expected file path (`~/.openclaw/workspace/paperclip-claimed-api-key.json`) and ensure `PAPERCLIP_API_KEY` + `PAPERCLIP_API_URL` are available for OpenClaw skill execution context.
|
||||||
- Write compatibility JSON keys (`token` and `apiKey`) to avoid runtime parser mismatch.
|
- Write compatibility JSON keys (`token` and `apiKey`) to avoid runtime parser mismatch.
|
||||||
6. Ensure Paperclip skill is installed for OpenClaw runtime.
|
7. Ensure Paperclip skill is installed for OpenClaw runtime.
|
||||||
7. Send one bootstrap prompt to OpenClaw containing all setup instructions needed for this run (auth file usage, heartbeat procedure, required tools). If needed, send one follow-up nudge only.
|
8. Send one bootstrap prompt to OpenClaw containing all setup instructions needed for this run (auth file usage, heartbeat procedure, required tools). If needed, send one follow-up nudge only.
|
||||||
|
|
||||||
## 6) E2E Validation Cases
|
## 6) E2E Validation Cases
|
||||||
|
|
||||||
@@ -311,6 +317,7 @@ Responsibilities:
|
|||||||
- CLA company resolution.
|
- CLA company resolution.
|
||||||
- Old OpenClaw agent cleanup.
|
- Old OpenClaw agent cleanup.
|
||||||
- Invite/join/approve/claim orchestration.
|
- Invite/join/approve/claim orchestration.
|
||||||
|
- Gateway agent config/token preflight validation before connectivity or case execution.
|
||||||
- E2E case execution + assertions.
|
- E2E case execution + assertions.
|
||||||
- Final summary with run IDs, issue IDs, agent ID.
|
- Final summary with run IDs, issue IDs, agent ID.
|
||||||
|
|
||||||
@@ -347,5 +354,6 @@ Responsibilities:
|
|||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
- No webhook-mode ambiguity: webhook path does not silently appear as SSE success without explicit compatibility signal.
|
- No webhook-mode ambiguity: webhook path does not silently appear as SSE success without explicit compatibility signal.
|
||||||
- Gateway onboarding is first-class and copy/pasteable from company settings.
|
- Gateway onboarding is first-class and copy/pasteable from company settings.
|
||||||
|
- Gateway join fails fast if token is missing/placeholder, and smoke preflight verifies adapter/token parity before task runs.
|
||||||
- Codex can run end-to-end onboarding and validation against CLA with repeatable cleanup.
|
- Codex can run end-to-end onboarding and validation against CLA with repeatable cleanup.
|
||||||
- All three validation cases are documented with pass/fail criteria and reproducible evidence paths.
|
- All three validation cases are documented with pass/fail criteria and reproducible evidence paths.
|
||||||
|
|||||||
@@ -524,6 +524,34 @@ inject_agent_api_key_payload_template() {
|
|||||||
assert_status "200"
|
assert_status "200"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validate_joined_gateway_agent() {
|
||||||
|
local expected_gateway_token="$1"
|
||||||
|
|
||||||
|
api_request "GET" "/agents/${AGENT_ID}"
|
||||||
|
assert_status "200"
|
||||||
|
|
||||||
|
local adapter_type gateway_url configured_token
|
||||||
|
adapter_type="$(jq -r '.adapterType // empty' <<<"$RESPONSE_BODY")"
|
||||||
|
gateway_url="$(jq -r '.adapterConfig.url // empty' <<<"$RESPONSE_BODY")"
|
||||||
|
configured_token="$(jq -r '.adapterConfig.headers["x-openclaw-token"] // .adapterConfig.headers["x-openclaw-auth"] // empty' <<<"$RESPONSE_BODY")"
|
||||||
|
|
||||||
|
[[ "$adapter_type" == "openclaw_gateway" ]] || fail "joined agent adapterType is '${adapter_type}', expected 'openclaw_gateway'"
|
||||||
|
[[ "$gateway_url" =~ ^wss?:// ]] || fail "joined agent gateway url is invalid: '${gateway_url}'"
|
||||||
|
[[ -n "$configured_token" ]] || fail "joined agent missing adapterConfig.headers.x-openclaw-token"
|
||||||
|
if (( ${#configured_token} < 16 )); then
|
||||||
|
fail "joined agent gateway token looks too short (${#configured_token} chars)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local expected_hash configured_hash
|
||||||
|
expected_hash="$(hash_prefix "$expected_gateway_token")"
|
||||||
|
configured_hash="$(hash_prefix "$configured_token")"
|
||||||
|
if [[ "$expected_hash" != "$configured_hash" ]]; then
|
||||||
|
fail "joined agent gateway token hash mismatch (expected ${expected_hash}, got ${configured_hash})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "validated joined gateway agent config (token sha256 prefix ${configured_hash})"
|
||||||
|
}
|
||||||
|
|
||||||
trigger_wakeup() {
|
trigger_wakeup() {
|
||||||
local reason="$1"
|
local reason="$1"
|
||||||
local issue_id="${2:-}"
|
local issue_id="${2:-}"
|
||||||
@@ -840,6 +868,7 @@ main() {
|
|||||||
|
|
||||||
create_and_approve_gateway_join "$gateway_token"
|
create_and_approve_gateway_join "$gateway_token"
|
||||||
log "joined/approved agent ${AGENT_ID} invite=${INVITE_ID} joinRequest=${JOIN_REQUEST_ID}"
|
log "joined/approved agent ${AGENT_ID} invite=${INVITE_ID} joinRequest=${JOIN_REQUEST_ID}"
|
||||||
|
validate_joined_gateway_agent "$gateway_token"
|
||||||
|
|
||||||
trigger_wakeup "openclaw_gateway_smoke_connectivity"
|
trigger_wakeup "openclaw_gateway_smoke_connectivity"
|
||||||
if [[ -n "$RUN_ID" ]]; then
|
if [[ -n "$RUN_ID" ]]; then
|
||||||
|
|||||||
@@ -208,4 +208,41 @@ describe("buildJoinDefaultsPayloadForAccept", () => {
|
|||||||
|
|
||||||
expect(result).toEqual(defaultsPayload);
|
expect(result).toEqual(defaultsPayload);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes wrapped gateway token headers for openclaw_gateway", () => {
|
||||||
|
const result = buildJoinDefaultsPayloadForAccept({
|
||||||
|
adapterType: "openclaw_gateway",
|
||||||
|
defaultsPayload: {
|
||||||
|
url: "ws://127.0.0.1:18789",
|
||||||
|
headers: {
|
||||||
|
"x-openclaw-token": {
|
||||||
|
value: "gateway-token-1234567890",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
url: "ws://127.0.0.1:18789",
|
||||||
|
headers: {
|
||||||
|
"x-openclaw-token": "gateway-token-1234567890",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts inbound x-openclaw-token for openclaw_gateway", () => {
|
||||||
|
const result = buildJoinDefaultsPayloadForAccept({
|
||||||
|
adapterType: "openclaw_gateway",
|
||||||
|
defaultsPayload: {
|
||||||
|
url: "ws://127.0.0.1:18789",
|
||||||
|
},
|
||||||
|
inboundOpenClawTokenHeader: "gateway-token-1234567890",
|
||||||
|
}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
headers: {
|
||||||
|
"x-openclaw-token": "gateway-token-1234567890",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ describe("buildInviteOnboardingTextDocument", () => {
|
|||||||
expect(text).toContain("~/.openclaw/workspace/paperclip-claimed-api-key.json");
|
expect(text).toContain("~/.openclaw/workspace/paperclip-claimed-api-key.json");
|
||||||
expect(text).toContain("PAPERCLIP_API_KEY");
|
expect(text).toContain("PAPERCLIP_API_KEY");
|
||||||
expect(text).toContain("saved token field");
|
expect(text).toContain("saved token field");
|
||||||
|
expect(text).toContain("Gateway token unexpectedly short");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes loopback diagnostics for authenticated/private onboarding", () => {
|
it("includes loopback diagnostics for authenticated/private onboarding", () => {
|
||||||
|
|||||||
@@ -311,6 +311,25 @@ function toAuthorizationHeaderValue(rawToken: string): string {
|
|||||||
return /^bearer\s+/i.test(trimmed) ? trimmed : `Bearer ${trimmed}`;
|
return /^bearer\s+/i.test(trimmed) ? trimmed : `Bearer ${trimmed}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tokenFromAuthorizationHeader(rawHeader: string | null): string | null {
|
||||||
|
const trimmed = nonEmptyTrimmedString(rawHeader);
|
||||||
|
if (!trimmed) return null;
|
||||||
|
const bearerMatch = trimmed.match(/^bearer\s+(.+)$/i);
|
||||||
|
if (bearerMatch?.[1]) {
|
||||||
|
return nonEmptyTrimmedString(bearerMatch[1]);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBooleanLike(value: unknown): boolean | null {
|
||||||
|
if (typeof value === "boolean") return value;
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (normalized === "true" || normalized === "1") return true;
|
||||||
|
if (normalized === "false" || normalized === "0") return false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildJoinDefaultsPayloadForAccept(input: {
|
export function buildJoinDefaultsPayloadForAccept(input: {
|
||||||
adapterType: string | null;
|
adapterType: string | null;
|
||||||
defaultsPayload: unknown;
|
defaultsPayload: unknown;
|
||||||
@@ -322,6 +341,59 @@ export function buildJoinDefaultsPayloadForAccept(input: {
|
|||||||
inboundOpenClawAuthHeader?: string | null;
|
inboundOpenClawAuthHeader?: string | null;
|
||||||
inboundOpenClawTokenHeader?: string | null;
|
inboundOpenClawTokenHeader?: string | null;
|
||||||
}): unknown {
|
}): unknown {
|
||||||
|
if (input.adapterType === "openclaw_gateway") {
|
||||||
|
const merged = isPlainObject(input.defaultsPayload)
|
||||||
|
? { ...(input.defaultsPayload as Record<string, unknown>) }
|
||||||
|
: ({} as Record<string, unknown>);
|
||||||
|
|
||||||
|
if (!nonEmptyTrimmedString(merged.paperclipApiUrl)) {
|
||||||
|
const legacyPaperclipApiUrl = nonEmptyTrimmedString(input.paperclipApiUrl);
|
||||||
|
if (legacyPaperclipApiUrl) merged.paperclipApiUrl = legacyPaperclipApiUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedHeaders = normalizeHeaderMap(merged.headers) ?? {};
|
||||||
|
|
||||||
|
const inboundOpenClawAuthHeader = nonEmptyTrimmedString(
|
||||||
|
input.inboundOpenClawAuthHeader
|
||||||
|
);
|
||||||
|
const inboundOpenClawTokenHeader = nonEmptyTrimmedString(
|
||||||
|
input.inboundOpenClawTokenHeader
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
inboundOpenClawTokenHeader &&
|
||||||
|
!headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-token")
|
||||||
|
) {
|
||||||
|
mergedHeaders["x-openclaw-token"] = inboundOpenClawTokenHeader;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
inboundOpenClawAuthHeader &&
|
||||||
|
!headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-auth")
|
||||||
|
) {
|
||||||
|
mergedHeaders["x-openclaw-auth"] = inboundOpenClawAuthHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
const discoveredToken =
|
||||||
|
headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-token") ??
|
||||||
|
headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-auth") ??
|
||||||
|
tokenFromAuthorizationHeader(
|
||||||
|
headerMapGetIgnoreCase(mergedHeaders, "authorization")
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
discoveredToken &&
|
||||||
|
!headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-token")
|
||||||
|
) {
|
||||||
|
mergedHeaders["x-openclaw-token"] = discoveredToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(mergedHeaders).length > 0) {
|
||||||
|
merged.headers = mergedHeaders;
|
||||||
|
} else {
|
||||||
|
delete merged.headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(merged).length > 0 ? merged : null;
|
||||||
|
}
|
||||||
|
|
||||||
if (input.adapterType !== "openclaw") {
|
if (input.adapterType !== "openclaw") {
|
||||||
return input.defaultsPayload;
|
return input.defaultsPayload;
|
||||||
}
|
}
|
||||||
@@ -516,6 +588,37 @@ function summarizeOpenClawDefaultsForLog(defaultsPayload: unknown) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function summarizeOpenClawGatewayDefaultsForLog(defaultsPayload: unknown) {
|
||||||
|
const defaults = isPlainObject(defaultsPayload)
|
||||||
|
? (defaultsPayload as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const headers = defaults ? normalizeHeaderMap(defaults.headers) : undefined;
|
||||||
|
const gatewayTokenValue = headers
|
||||||
|
? headerMapGetIgnoreCase(headers, "x-openclaw-token") ??
|
||||||
|
headerMapGetIgnoreCase(headers, "x-openclaw-auth") ??
|
||||||
|
tokenFromAuthorizationHeader(
|
||||||
|
headerMapGetIgnoreCase(headers, "authorization")
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
present: Boolean(defaults),
|
||||||
|
keys: defaults ? Object.keys(defaults).sort() : [],
|
||||||
|
url: defaults ? nonEmptyTrimmedString(defaults.url) : null,
|
||||||
|
paperclipApiUrl: defaults
|
||||||
|
? nonEmptyTrimmedString(defaults.paperclipApiUrl)
|
||||||
|
: null,
|
||||||
|
headerKeys: headers ? Object.keys(headers).sort() : [],
|
||||||
|
sessionKeyStrategy: defaults
|
||||||
|
? nonEmptyTrimmedString(defaults.sessionKeyStrategy)
|
||||||
|
: null,
|
||||||
|
waitTimeoutMs:
|
||||||
|
defaults && typeof defaults.waitTimeoutMs === "number"
|
||||||
|
? defaults.waitTimeoutMs
|
||||||
|
: null,
|
||||||
|
gatewayToken: summarizeSecretForLog(gatewayTokenValue)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function buildJoinConnectivityDiagnostics(input: {
|
function buildJoinConnectivityDiagnostics(input: {
|
||||||
deploymentMode: DeploymentMode;
|
deploymentMode: DeploymentMode;
|
||||||
deploymentExposure: DeploymentExposure;
|
deploymentExposure: DeploymentExposure;
|
||||||
@@ -597,12 +700,222 @@ function normalizeAgentDefaultsForJoin(input: {
|
|||||||
bindHost: string;
|
bindHost: string;
|
||||||
allowedHostnames: string[];
|
allowedHostnames: string[];
|
||||||
}) {
|
}) {
|
||||||
|
const fatalErrors: string[] = [];
|
||||||
const diagnostics: JoinDiagnostic[] = [];
|
const diagnostics: JoinDiagnostic[] = [];
|
||||||
if (input.adapterType !== "openclaw") {
|
if (
|
||||||
|
input.adapterType !== "openclaw" &&
|
||||||
|
input.adapterType !== "openclaw_gateway"
|
||||||
|
) {
|
||||||
const normalized = isPlainObject(input.defaultsPayload)
|
const normalized = isPlainObject(input.defaultsPayload)
|
||||||
? (input.defaultsPayload as Record<string, unknown>)
|
? (input.defaultsPayload as Record<string, unknown>)
|
||||||
: null;
|
: null;
|
||||||
return { normalized, diagnostics };
|
return { normalized, diagnostics, fatalErrors };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.adapterType === "openclaw_gateway") {
|
||||||
|
if (!isPlainObject(input.defaultsPayload)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: "openclaw_gateway_defaults_missing",
|
||||||
|
level: "warn",
|
||||||
|
message:
|
||||||
|
"No OpenClaw gateway config was provided in agentDefaultsPayload.",
|
||||||
|
hint:
|
||||||
|
"Include agentDefaultsPayload.url and headers.x-openclaw-token for OpenClaw gateway joins."
|
||||||
|
});
|
||||||
|
fatalErrors.push(
|
||||||
|
"agentDefaultsPayload is required for adapterType=openclaw_gateway"
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
normalized: null as Record<string, unknown> | null,
|
||||||
|
diagnostics,
|
||||||
|
fatalErrors
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaults = input.defaultsPayload as Record<string, unknown>;
|
||||||
|
const normalized: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
let gatewayUrl: URL | null = null;
|
||||||
|
const rawGatewayUrl = nonEmptyTrimmedString(defaults.url);
|
||||||
|
if (!rawGatewayUrl) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: "openclaw_gateway_url_missing",
|
||||||
|
level: "warn",
|
||||||
|
message: "OpenClaw gateway URL is missing.",
|
||||||
|
hint: "Set agentDefaultsPayload.url to ws:// or wss:// gateway URL."
|
||||||
|
});
|
||||||
|
fatalErrors.push("agentDefaultsPayload.url is required");
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
gatewayUrl = new URL(rawGatewayUrl);
|
||||||
|
if (
|
||||||
|
gatewayUrl.protocol !== "ws:" &&
|
||||||
|
gatewayUrl.protocol !== "wss:"
|
||||||
|
) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: "openclaw_gateway_url_protocol",
|
||||||
|
level: "warn",
|
||||||
|
message: `OpenClaw gateway URL must use ws:// or wss:// (got ${gatewayUrl.protocol}).`
|
||||||
|
});
|
||||||
|
fatalErrors.push(
|
||||||
|
"agentDefaultsPayload.url must use ws:// or wss:// for openclaw_gateway"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
normalized.url = gatewayUrl.toString();
|
||||||
|
diagnostics.push({
|
||||||
|
code: "openclaw_gateway_url_configured",
|
||||||
|
level: "info",
|
||||||
|
message: `Gateway endpoint set to ${gatewayUrl.toString()}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
diagnostics.push({
|
||||||
|
code: "openclaw_gateway_url_invalid",
|
||||||
|
level: "warn",
|
||||||
|
message: `Invalid OpenClaw gateway URL: ${rawGatewayUrl}`
|
||||||
|
});
|
||||||
|
fatalErrors.push("agentDefaultsPayload.url is not a valid URL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = normalizeHeaderMap(defaults.headers) ?? {};
|
||||||
|
const gatewayToken =
|
||||||
|
headerMapGetIgnoreCase(headers, "x-openclaw-token") ??
|
||||||
|
headerMapGetIgnoreCase(headers, "x-openclaw-auth") ??
|
||||||
|
tokenFromAuthorizationHeader(headerMapGetIgnoreCase(headers, "authorization"));
|
||||||
|
if (
|
||||||
|
gatewayToken &&
|
||||||
|
!headerMapHasKeyIgnoreCase(headers, "x-openclaw-token")
|
||||||
|
) {
|
||||||
|
headers["x-openclaw-token"] = gatewayToken;
|
||||||
|
}
|
||||||
|
if (Object.keys(headers).length > 0) {
|
||||||
|
normalized.headers = headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!gatewayToken) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: "openclaw_gateway_auth_header_missing",
|
||||||
|
level: "warn",
|
||||||
|
message: "Gateway auth token is missing from agent defaults.",
|
||||||
|
hint:
|
||||||
|
"Set agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth)."
|
||||||
|
});
|
||||||
|
fatalErrors.push(
|
||||||
|
"agentDefaultsPayload.headers.x-openclaw-token (or x-openclaw-auth) is required"
|
||||||
|
);
|
||||||
|
} else if (gatewayToken.trim().length < 16) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: "openclaw_gateway_auth_header_too_short",
|
||||||
|
level: "warn",
|
||||||
|
message: `Gateway auth token appears too short (${gatewayToken.trim().length} chars).`,
|
||||||
|
hint:
|
||||||
|
"Use the full gateway auth token from ~/.openclaw/openclaw.json (typically long random string)."
|
||||||
|
});
|
||||||
|
fatalErrors.push(
|
||||||
|
"agentDefaultsPayload.headers.x-openclaw-token is too short; expected a full gateway token"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
diagnostics.push({
|
||||||
|
code: "openclaw_gateway_auth_header_configured",
|
||||||
|
level: "info",
|
||||||
|
message: "Gateway auth token configured."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlainObject(defaults.payloadTemplate)) {
|
||||||
|
normalized.payloadTemplate = defaults.payloadTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedDisableDeviceAuth = parseBooleanLike(defaults.disableDeviceAuth);
|
||||||
|
if (parsedDisableDeviceAuth !== null) {
|
||||||
|
normalized.disableDeviceAuth = parsedDisableDeviceAuth;
|
||||||
|
}
|
||||||
|
|
||||||
|
const waitTimeoutMs =
|
||||||
|
typeof defaults.waitTimeoutMs === "number" &&
|
||||||
|
Number.isFinite(defaults.waitTimeoutMs)
|
||||||
|
? Math.floor(defaults.waitTimeoutMs)
|
||||||
|
: typeof defaults.waitTimeoutMs === "string"
|
||||||
|
? Number.parseInt(defaults.waitTimeoutMs.trim(), 10)
|
||||||
|
: NaN;
|
||||||
|
if (Number.isFinite(waitTimeoutMs) && waitTimeoutMs > 0) {
|
||||||
|
normalized.waitTimeoutMs = waitTimeoutMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutSec =
|
||||||
|
typeof defaults.timeoutSec === "number" && Number.isFinite(defaults.timeoutSec)
|
||||||
|
? Math.floor(defaults.timeoutSec)
|
||||||
|
: typeof defaults.timeoutSec === "string"
|
||||||
|
? Number.parseInt(defaults.timeoutSec.trim(), 10)
|
||||||
|
: NaN;
|
||||||
|
if (Number.isFinite(timeoutSec) && timeoutSec > 0) {
|
||||||
|
normalized.timeoutSec = timeoutSec;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionKeyStrategy = nonEmptyTrimmedString(defaults.sessionKeyStrategy);
|
||||||
|
if (
|
||||||
|
sessionKeyStrategy === "fixed" ||
|
||||||
|
sessionKeyStrategy === "issue" ||
|
||||||
|
sessionKeyStrategy === "run"
|
||||||
|
) {
|
||||||
|
normalized.sessionKeyStrategy = sessionKeyStrategy;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionKey = nonEmptyTrimmedString(defaults.sessionKey);
|
||||||
|
if (sessionKey) {
|
||||||
|
normalized.sessionKey = sessionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = nonEmptyTrimmedString(defaults.role);
|
||||||
|
if (role) {
|
||||||
|
normalized.role = role;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(defaults.scopes)) {
|
||||||
|
const scopes = defaults.scopes
|
||||||
|
.filter((entry): entry is string => typeof entry === "string")
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (scopes.length > 0) {
|
||||||
|
normalized.scopes = scopes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawPaperclipApiUrl =
|
||||||
|
typeof defaults.paperclipApiUrl === "string"
|
||||||
|
? defaults.paperclipApiUrl.trim()
|
||||||
|
: "";
|
||||||
|
if (rawPaperclipApiUrl) {
|
||||||
|
try {
|
||||||
|
const parsedPaperclipApiUrl = new URL(rawPaperclipApiUrl);
|
||||||
|
if (
|
||||||
|
parsedPaperclipApiUrl.protocol !== "http:" &&
|
||||||
|
parsedPaperclipApiUrl.protocol !== "https:"
|
||||||
|
) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: "openclaw_gateway_paperclip_api_url_protocol",
|
||||||
|
level: "warn",
|
||||||
|
message: `paperclipApiUrl must use http:// or https:// (got ${parsedPaperclipApiUrl.protocol}).`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
normalized.paperclipApiUrl = parsedPaperclipApiUrl.toString();
|
||||||
|
diagnostics.push({
|
||||||
|
code: "openclaw_gateway_paperclip_api_url_configured",
|
||||||
|
level: "info",
|
||||||
|
message: `paperclipApiUrl set to ${parsedPaperclipApiUrl.toString()}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
diagnostics.push({
|
||||||
|
code: "openclaw_gateway_paperclip_api_url_invalid",
|
||||||
|
level: "warn",
|
||||||
|
message: `Invalid paperclipApiUrl: ${rawPaperclipApiUrl}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { normalized, diagnostics, fatalErrors };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isPlainObject(input.defaultsPayload)) {
|
if (!isPlainObject(input.defaultsPayload)) {
|
||||||
@@ -613,7 +926,11 @@ function normalizeAgentDefaultsForJoin(input: {
|
|||||||
"No OpenClaw callback config was provided in agentDefaultsPayload.",
|
"No OpenClaw callback config was provided in agentDefaultsPayload.",
|
||||||
hint: "Include agentDefaultsPayload.url so Paperclip can invoke the OpenClaw endpoint immediately after approval."
|
hint: "Include agentDefaultsPayload.url so Paperclip can invoke the OpenClaw endpoint immediately after approval."
|
||||||
});
|
});
|
||||||
return { normalized: null as Record<string, unknown> | null, diagnostics };
|
return {
|
||||||
|
normalized: null as Record<string, unknown> | null,
|
||||||
|
diagnostics,
|
||||||
|
fatalErrors
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaults = input.defaultsPayload as Record<string, unknown>;
|
const defaults = input.defaultsPayload as Record<string, unknown>;
|
||||||
@@ -790,7 +1107,7 @@ function normalizeAgentDefaultsForJoin(input: {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return { normalized, diagnostics };
|
return { normalized, diagnostics, fatalErrors };
|
||||||
}
|
}
|
||||||
|
|
||||||
function toInviteSummaryResponse(
|
function toInviteSummaryResponse(
|
||||||
@@ -1091,6 +1408,7 @@ export function buildInviteOnboardingTextDocument(
|
|||||||
|
|
||||||
TOKEN="$(node -p 'require(process.env.HOME+\"/.openclaw/openclaw.json\").gateway.auth.token')"
|
TOKEN="$(node -p 'require(process.env.HOME+\"/.openclaw/openclaw.json\").gateway.auth.token')"
|
||||||
test -n "$TOKEN" || (echo "Missing TOKEN" && exit 1)
|
test -n "$TOKEN" || (echo "Missing TOKEN" && exit 1)
|
||||||
|
test "\${#TOKEN}" -ge 16 || (echo "Gateway token unexpectedly short (\${#TOKEN})" && exit 1)
|
||||||
|
|
||||||
3) IMPORTANT: Don't accidentally drop the token when generating JSON
|
3) IMPORTANT: Don't accidentally drop the token when generating JSON
|
||||||
If you build JSON with Node, pass the token explicitly (argv), don't rely on an un-exported env var.
|
If you build JSON with Node, pass the token explicitly (argv), don't rely on an un-exported env var.
|
||||||
@@ -1931,9 +2249,14 @@ export function accessRoutes(
|
|||||||
})
|
})
|
||||||
: {
|
: {
|
||||||
normalized: null as Record<string, unknown> | null,
|
normalized: null as Record<string, unknown> | null,
|
||||||
diagnostics: [] as JoinDiagnostic[]
|
diagnostics: [] as JoinDiagnostic[],
|
||||||
|
fatalErrors: [] as string[]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (requestType === "agent" && joinDefaults.fatalErrors.length > 0) {
|
||||||
|
throw badRequest(joinDefaults.fatalErrors.join("; "));
|
||||||
|
}
|
||||||
|
|
||||||
if (requestType === "agent" && adapterType === "openclaw") {
|
if (requestType === "agent" && adapterType === "openclaw") {
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
@@ -1950,6 +2273,22 @@ export function accessRoutes(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (requestType === "agent" && adapterType === "openclaw_gateway") {
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
inviteId: invite.id,
|
||||||
|
joinRequestDiagnostics: joinDefaults.diagnostics.map((diag) => ({
|
||||||
|
code: diag.code,
|
||||||
|
level: diag.level
|
||||||
|
})),
|
||||||
|
normalizedAgentDefaults: summarizeOpenClawGatewayDefaultsForLog(
|
||||||
|
joinDefaults.normalized
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"invite accept normalized OpenClaw gateway defaults"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const claimSecret =
|
const claimSecret =
|
||||||
requestType === "agent" && !inviteAlreadyAccepted
|
requestType === "agent" && !inviteAlreadyAccepted
|
||||||
? createClaimSecret()
|
? createClaimSecret()
|
||||||
|
|||||||
Reference in New Issue
Block a user