Skip to content

feat: add multi-workspace support to OpenClaw bridge#536

Merged
khaliqgant merged 20 commits intomainfrom
feature/multi-workspace-impl
Mar 10, 2026
Merged

feat: add multi-workspace support to OpenClaw bridge#536
khaliqgant merged 20 commits intomainfrom
feature/multi-workspace-impl

Conversation

@khaliqgant
Copy link
Copy Markdown
Member

@khaliqgant khaliqgant commented Mar 10, 2026

Summary

  • Add WorkspaceEntry and WorkspacesConfig types matching the broker's WorkspaceSource schema
  • Add multi-workspace config layer (workspaces.json) with load/save/add/list/switch functions
  • Add 3 new CLI commands: add-workspace, list-workspaces, switch-workspace
  • Update setup to auto-register workspaces and wire RELAY_WORKSPACES_JSON + RELAY_DEFAULT_WORKSPACE to mcporter MCP env when multiple workspaces are configured
  • Backward-compatible: single-workspace users see no change; multi-workspace activates only with 2+ entries
  • Auto-migration: existing single .env config bootstrapped into workspaces.json on first add-workspace call

Test plan

  • Run relay-openclaw setup <key> — verify workspaces.json is created alongside .env
  • Run relay-openclaw add-workspace <second-key> --alias team-b — verify second entry added
  • Run relay-openclaw list-workspaces — verify both entries shown with default marker
  • Run relay-openclaw switch-workspace team-b — verify .env updated and message printed
  • Verify RELAY_WORKSPACES_JSON env var is set in mcporter config when 2+ workspaces exist
  • Verify single-workspace setup (no add-workspace) still works unchanged
  • Type-check passes: npx tsc --noEmit

🤖 Generated with Claude Code


Open with Devin

khaliqgant and others added 14 commits March 9, 2026 16:52
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>
- Added `bun add @agent-relay/sdk` alongside npm install (#385)
- Added plain-text/markdown docs access note and links (#386)

Closes #385
Closes #386

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>
khaliqgant and others added 2 commits March 10, 2026 13:22
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

This logs sensitive data returned by
an access to apiKey
as clear text.

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 apiKey handling and addWorkspace call unchanged.
  • After obtaining entry, build a label that prefers workspace_alias, then workspace_id, and otherwise falls back to a generic string like "(unnamed workspace)", rather than deriving anything from apiKey.
  • 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.

Suggested changeset 1
packages/openclaw/src/cli.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/openclaw/src/cli.ts b/packages/openclaw/src/cli.ts
--- a/packages/openclaw/src/cli.ts
+++ b/packages/openclaw/src/cli.ts
@@ -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) {
EOF
@@ -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) {
Copilot is powered by AI and may make mistakes. Always verify output.
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

This logs sensitive data returned by
an access to api_key
as clear text.

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.

Suggested changeset 1
packages/openclaw/src/cli.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/openclaw/src/cli.ts b/packages/openclaw/src/cli.ts
--- a/packages/openclaw/src/cli.ts
+++ b/packages/openclaw/src/cli.ts
@@ -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}`);
   }
EOF
@@ -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}`);
}
Copilot is powered by AI and may make mistakes. Always verify output.
devin-ai-integration[bot]

This comment was marked as resolved.

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

This logs sensitive data returned by
an access to apiKey
as clear text.

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.

Suggested changeset 1
packages/openclaw/src/cli.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/openclaw/src/cli.ts b/packages/openclaw/src/cli.ts
--- a/packages/openclaw/src/cli.ts
+++ b/packages/openclaw/src/cli.ts
@@ -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);
EOF
@@ -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);
Copilot is powered by AI and may make mistakes. Always verify output.
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

This logs sensitive data returned by
an access to api_key
as clear text.

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, update normalizeWorkspaceLabel so that workspace.api_key is never returned in full; return a masked value instead.
  • In packages/openclaw/src/cli.ts, when logging the default workspace, still log config.default_workspace but format it as a label (no change in semantics, but we know it is now safe). To be extra defensive and consistent with runListWorkspaces, we can compute and log the label the same way as right above: derive it from entry with 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.

Suggested changeset 2
packages/openclaw/src/cli.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/openclaw/src/cli.ts b/packages/openclaw/src/cli.ts
--- a/packages/openclaw/src/cli.ts
+++ b/packages/openclaw/src/cli.ts
@@ -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}`);
   }
 }
EOF
@@ -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}`);
}
}
packages/openclaw/src/config.ts
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/openclaw/src/config.ts b/packages/openclaw/src/config.ts
--- a/packages/openclaw/src/config.ts
+++ b/packages/openclaw/src/config.ts
@@ -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
EOF
@@ -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
Copilot is powered by AI and may make mistakes. Always verify output.
devin-ai-integration[bot]

This comment was marked as resolved.

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 8 additional findings in Devin Review.

Open in Devin Review

Comment on lines +120 to +130
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"
}
```
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Suggested change
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.*

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 9 additional findings in Devin Review.

Open in Devin Review

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 };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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 addWorkspacesaveWorkspacesConfig, which overwrites the file. Returning null instead would trigger the .env bootstrap fallback, preserving at least the current single-workspace setup.

Suggested change
return { workspaces: [], default_workspace: undefined };
return null;
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@khaliqgant khaliqgant merged commit 572fe20 into main Mar 10, 2026
43 of 45 checks passed
@khaliqgant khaliqgant deleted the feature/multi-workspace-impl branch March 10, 2026 13:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants