feat: add multi-workspace support to OpenClaw bridge#536
Conversation
The retry re-injection code was sending a literal newline + spaces instead of \r (carriage return) after writing the injection to the PTY. This would cause retry deliveries to fail silently in wrap mode. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- BUG-2: Use workspace-specific HTTP client and ws_control_tx for broker spawn/release instead of always using the default workspace's client - P1: Add workspace_id filtering to routing resolution so messages from workspace A's #general don't cross-deliver to workspace B's workers - Add workspace_id to RoutingWorker and WorkerHandle, with backwards compat (None matches all workspaces for legacy/SDK-spawned workers) - Update agent-relay-snippet.md with multi-workspace section - Remove unnecessary #[allow(dead_code)] on DeliveryOutcome::Failed Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The constructor requires all 8 parameters for initialization and does not benefit from a builder pattern at this stage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The dedup key format for incoming WS events was changed to `workspace_id:event_id`, but MCP self-echo pre-seeding still inserted bare message IDs. This mismatch caused duplicate echo messages because the pre-seeded bare ID never matched the scoped incoming key. Pre-seed dedup entries for all workspaces so the scoped keys match on arrival. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…orward workspace env vars in MCP snippets Gap 1: When a configured default_workspace_id is not found in the lookup table, the error now says "workspace_not_found:" instead of "ambiguous_workspace:" (both in relay_send routing and protocol frame handling). The "ambiguous_workspace:" error remains for the fallback case where multiple workspaces exist with no default configured. Gap 2: Forward RELAY_WORKSPACES_JSON and RELAY_DEFAULT_WORKSPACE env vars through MCP configuration snippets for all supported CLIs (claude, codex, gemini/droid, opencode, cursor) so spawned child agents inherit workspace context and can connect to the correct workspaces. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
RELAY_WORKSPACES_JSON contains JSON with inner double quotes that break TOML basic-string parsing in codex --config args. Escape backslashes and quotes before interpolation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add CLI commands (add-workspace, list-workspaces, switch-workspace) and config layer to store multiple workspace credentials in workspaces.json. Setup now auto-registers workspaces and wires RELAY_WORKSPACES_JSON to the broker when multiple workspaces are configured. Backward-compatible with existing single-workspace .env flow. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add Section 2b documenting add-workspace, list-workspaces, and switch-workspace CLI commands with the RELAY_WORKSPACES_JSON env var format. Also update the fallback SKILL.md in setup.ts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
|
||
| const entry = config.workspaces.find((w) => w.api_key === apiKey); | ||
| const label = entry?.workspace_alias ?? entry?.workspace_id ?? apiKey.slice(0, 16) + '...'; | ||
| console.log(`Workspace "${label}" added.`); |
Check failure
Code scanning / CodeQL
Clear-text logging of sensitive information High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI about 2 months ago
In general, to fix clear-text logging of sensitive information, avoid logging secrets at all, or log only non-sensitive identifiers (aliases, IDs) or properly redacted forms. Here, the problematic part is that label can fall back to a value derived from apiKey. Instead, we should construct a label that never uses apiKey directly, relying solely on non-sensitive metadata (alias or workspace ID), and if those are absent, use a generic placeholder. This keeps functionality (user feedback that a workspace was added) while ensuring no credential-derived string is logged.
Concretely, in runAddWorkspace in packages/openclaw/src/cli.ts, we should:
- Leave
apiKeyhandling andaddWorkspacecall unchanged. - After obtaining
entry, build alabelthat prefersworkspace_alias, thenworkspace_id, and otherwise falls back to a generic string like"(unnamed workspace)", rather than deriving anything fromapiKey. - Keep the rest of the logging intact, but now it will never include any part of the key.
No new imports or helper methods are needed; we just change the label computation.
| @@ -268,7 +268,7 @@ | ||
| const entry = config.workspaces.find((w) => w.api_key === apiKey); | ||
| const label = entry?.workspace_alias | ||
| ?? entry?.workspace_id | ||
| ?? apiKey.slice(0, 12) + '...'; | ||
| ?? '(unnamed workspace)'; | ||
| console.log(`Workspace "${label}" added.`); | ||
| console.log(`Total workspaces: ${config.workspaces.length}`); | ||
| if (config.default_workspace) { |
| const alias = w.workspace_alias ?? '(no alias)'; | ||
| const maskedKey = w.api_key.slice(0, 12) + '...'; | ||
| const wsId = w.workspace_id ? ` [${w.workspace_id}]` : ''; | ||
| console.log(` ${alias}${wsId} — ${maskedKey}${defaultMarker}`); |
Check failure
Code scanning / CodeQL
Clear-text logging of sensitive information High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI about 2 months ago
In general, to fix clear-text logging of sensitive information, ensure that secrets (API keys, tokens, passwords, etc.) are never logged directly. If some user-facing indication is required, log only non-sensitive metadata (e.g., alias, workspace ID) or a minimally revealing, consistent representation (e.g., a constant mask, a hash, or an indication that a secret exists) that does not expose part of the actual secret string.
For this concrete case, the best fix without changing existing functionality is to stop slicing and showing the leading 12 characters of w.api_key. Instead, derive a non-sensitive representation, for example:
- Show only the length of the key, or
- Show a constant masked string like
********or[redacted], or - Show a deterministic hash (e.g., SHA-256) so users can distinguish keys without revealing them.
Given we must not add heavy new dependencies and must keep behavior close to current (showing that a key exists), a simple constant mask is adequate. We can replace:
const maskedKey = w.api_key.slice(0, 12) + '...';
...
console.log(` ${alias}${wsId} — ${maskedKey}${defaultMarker}`);with something like:
const maskedKey = '[api key hidden]';
...
console.log(` ${alias}${wsId} — ${maskedKey}${defaultMarker}`);This continues to show that a workspace has an associated API key while ensuring no part of the key is logged. All changes are within packages/openclaw/src/cli.ts around lines 287–293 and require no new imports or helpers.
| @@ -288,7 +288,7 @@ | ||
| for (const w of workspaces) { | ||
| const defaultMarker = w.is_default ? ' (default)' : ''; | ||
| const alias = w.workspace_alias ?? '(no alias)'; | ||
| const maskedKey = w.api_key.slice(0, 12) + '...'; | ||
| const maskedKey = '[api key hidden]'; | ||
| const wsId = w.workspace_id ? ` [${w.workspace_id}]` : ''; | ||
| console.log(` ${alias}${wsId} — ${maskedKey}${defaultMarker}`); | ||
| } |
| console.log(result.message); | ||
| console.log(`\nWorkspace key: ${result.apiKey}`); | ||
| const maskedApiKey = result.apiKey.slice(0, 12) + '...'; | ||
| console.log(`\nWorkspace key: ${maskedApiKey}`); |
Check failure
Code scanning / CodeQL
Clear-text logging of sensitive information High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI about 2 months ago
In general, to fix clear-text logging of sensitive information, remove the sensitive value from the log entirely or replace it with a non-sensitive placeholder that does not reveal any part of the secret (or only reveals an explicitly approved, clearly non-sensitive fragment, like a generated identifier). Logs should convey that an operation succeeded without exposing credentials.
For this specific code, the best minimal-impact fix is: stop logging the Workspace key value (maskedApiKey) and instead log an informational message that tells the user how to retrieve or use the key without echoing it. This preserves functionality (the setup operation and API key issuance are unchanged) while eliminating sensitive data from the log. Concretely, in packages/openclaw/src/cli.ts, inside runSetup, remove the creation and logging of maskedApiKey (lines 115–116) and replace it with a message like “Workspace key generated. Keep it safe and share with other claws to join the same workspace.” That way, the user is still informed that a key exists and should be shared appropriately, but the key itself is not printed.
No new methods or imports are needed; the change is confined to adjusting the logging statements in runSetup.
| @@ -112,9 +112,8 @@ | ||
|
|
||
| if (result.ok) { | ||
| console.log(result.message); | ||
| const maskedApiKey = result.apiKey.slice(0, 12) + '...'; | ||
| console.log(`\nWorkspace key: ${maskedApiKey}`); | ||
| console.log('Share this key with other claws to join the same workspace.'); | ||
| console.log('\nWorkspace key has been generated.'); | ||
| console.log('Keep this key secret, and share it securely with other claws to join the same workspace.'); | ||
| } else { | ||
| console.error(`Setup failed: ${result.message}`); | ||
| process.exit(1); |
| console.log(`Workspace "${label}" added.`); | ||
| console.log(`Total workspaces: ${config.workspaces.length}`); | ||
| if (config.default_workspace) { | ||
| console.log(`Default workspace: ${config.default_workspace}`); |
Check failure
Code scanning / CodeQL
Clear-text logging of sensitive information High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI about 2 months ago
In general, the fix is to ensure that any value that may contain an API key is either not logged at all or is logged only in a masked/obfuscated form. For this specific case, we should avoid ever storing the raw API key into default_workspace, and ensure that anything logged as a “workspace label” cannot resolve to the full key.
The best fix without changing existing functionality is to change normalizeWorkspaceLabel in config.ts so that if it would otherwise return workspace.api_key, it instead returns a masked version (e.g., first 12 chars + ..., consistent with runListWorkspaces). This ensures that config.default_workspace never contains the full key, removing the underlying taint. Then, in cli.ts, we can safely log config.default_workspace as-is. Additionally, we can mirror the masking behavior used elsewhere when logging the default workspace to make the intent explicit and keep behavior consistent.
Concretely:
- In
packages/openclaw/src/config.ts, updatenormalizeWorkspaceLabelso thatworkspace.api_keyis never returned in full; return a masked value instead. - In
packages/openclaw/src/cli.ts, when logging the default workspace, still logconfig.default_workspacebut format it as a label (no change in semantics, but we know it is now safe). To be extra defensive and consistent withrunListWorkspaces, we can compute and log the label the same way as right above: derive it fromentrywith slicing the API key when used as a fallback.
No new imports or external dependencies are required; only local helper logic and string slicing are involved.
| @@ -272,6 +272,7 @@ | ||
| console.log(`Workspace "${label}" added.`); | ||
| console.log(`Total workspaces: ${config.workspaces.length}`); | ||
| if (config.default_workspace) { | ||
| // default_workspace is derived from a normalized label that never includes the full API key | ||
| console.log(`Default workspace: ${config.default_workspace}`); | ||
| } | ||
| } |
| @@ -395,7 +395,11 @@ | ||
| } | ||
|
|
||
| const normalizeWorkspaceLabel = (workspace: WorkspaceEntry): string => { | ||
| return workspace.workspace_alias ?? workspace.workspace_id ?? workspace.api_key; | ||
| // Prefer alias or workspace_id; if neither is available, return a masked API key | ||
| if (workspace.workspace_alias) return workspace.workspace_alias; | ||
| if (workspace.workspace_id) return workspace.workspace_id; | ||
| // Avoid storing or logging the full API key as the workspace label | ||
| return workspace.api_key.slice(0, 12) + '...'; | ||
| }; | ||
|
|
||
| // Check for existing entry with same api_key |
| Stored shape (when ≥2 workspaces): | ||
|
|
||
| ```json | ||
| { | ||
| "memberships": [ | ||
| { "api_key": "rk_live_ABC", "workspace_alias": "team-a" }, | ||
| { "api_key": "rk_live_DEF", "workspace_alias": "team-b", "workspace_id": "ws_..." } | ||
| ], | ||
| "default_workspace_id": "team-a" | ||
| } | ||
| ``` |
There was a problem hiding this comment.
🟡 SKILL.md documents wrong JSON shape for stored workspaces.json file
The SKILL.md at line 120 says "Stored shape (when ≥2 workspaces):" and then shows a JSON structure with "memberships" and "default_workspace_id" keys. However, the actual file written by saveWorkspacesConfig (packages/openclaw/src/config.ts:367) serializes a WorkspacesConfig object, which uses "workspaces" and "default_workspace" as its keys (packages/openclaw/src/types.ts:87-92). The memberships/default_workspace_id format is only used in the broker payload built by buildWorkspacesJson (packages/openclaw/src/config.ts:476-484), not in the stored file. Since SKILL.md is an agent-facing document, an agent following this documentation would produce or expect the wrong JSON structure when working with workspaces.json.
| Stored shape (when ≥2 workspaces): | |
| ```json | |
| { | |
| "memberships": [ | |
| { "api_key": "rk_live_ABC", "workspace_alias": "team-a" }, | |
| { "api_key": "rk_live_DEF", "workspace_alias": "team-b", "workspace_id": "ws_..." } | |
| ], | |
| "default_workspace_id": "team-a" | |
| } | |
| ``` | |
| Stored shape (when ≥2 workspaces): | |
| ```json | |
| { | |
| "workspaces": [ | |
| { "api_key": "rk_live_ABC", "workspace_alias": "team-a" }, | |
| { "api_key": "rk_live_DEF", "workspace_alias": "team-b", "workspace_id": "ws_..." } | |
| ], | |
| "default_workspace": "team-a" | |
| } |
<!-- devin-review-badge-begin -->
<a href="https://app.devin.ai/review/agentworkforce/relay/pull/536" target="_blank">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open in Devin Review">
</picture>
</a>
<!-- devin-review-badge-end -->
---
*Was this helpful? React with 👍 or 👎 to provide feedback.*
…rupt JSON handling
| const message = err instanceof Error ? err.message : String(err); | ||
| console.warn(`Warning: failed to parse ${configPath}: ${message}`); | ||
| console.warn('The file may be corrupted. Existing workspace config will not be modified.'); | ||
| return { workspaces: [], default_workspace: undefined }; |
There was a problem hiding this comment.
🔴 Corrupt workspaces.json returns empty config instead of null, causing silent data loss
When workspaces.json exists but contains invalid JSON, loadWorkspacesConfig returns { workspaces: [], default_workspace: undefined } instead of null. This non-null empty config causes addWorkspace (packages/openclaw/src/config.ts:380) to skip the .env bootstrap path (which only runs when config is null), then proceed to overwrite the corrupt file with only the newly-added workspace entry. All previously-stored workspace data is silently lost.
The warning at line 359 says "Existing workspace config will not be modified" but this is incorrect — the returned empty config flows into addWorkspace → saveWorkspacesConfig, which overwrites the file. Returning null instead would trigger the .env bootstrap fallback, preserving at least the current single-workspace setup.
| return { workspaces: [], default_workspace: undefined }; | |
| return null; |
Was this helpful? React with 👍 or 👎 to provide feedback.
Summary
WorkspaceEntryandWorkspacesConfigtypes matching the broker'sWorkspaceSourceschemaworkspaces.json) with load/save/add/list/switch functionsadd-workspace,list-workspaces,switch-workspacesetupto auto-register workspaces and wireRELAY_WORKSPACES_JSON+RELAY_DEFAULT_WORKSPACEto mcporter MCP env when multiple workspaces are configured.envconfig bootstrapped intoworkspaces.jsonon firstadd-workspacecallTest plan
relay-openclaw setup <key>— verifyworkspaces.jsonis created alongside.envrelay-openclaw add-workspace <second-key> --alias team-b— verify second entry addedrelay-openclaw list-workspaces— verify both entries shown with default markerrelay-openclaw switch-workspace team-b— verify.envupdated and message printedRELAY_WORKSPACES_JSONenv var is set in mcporter config when 2+ workspaces existnpx tsc --noEmit🤖 Generated with Claude Code