Skip to content

fix(gateway): preserve stored scopes when reconnecting with device token#46032

Merged
steipete merged 2 commits intoopenclaw:mainfrom
caicongyang:fix-windows-operator-scope-v2
Apr 4, 2026
Merged

fix(gateway): preserve stored scopes when reconnecting with device token#46032
steipete merged 2 commits intoopenclaw:mainfrom
caicongyang:fix-windows-operator-scope-v2

Conversation

@caicongyang
Copy link
Copy Markdown

Description

On Windows, when the local gateway reissues the operator device token (after restart or reconnect), it was losing the operator.read scope, breaking status, probe, and health commands.

Root Cause

When the gateway client reconnects using a stored device token, it was defaulting to ["operator.admin"] scopes instead of preserving the previously authorized scopes from the stored token in device-auth.json. This caused the token to be regenerated with only operator.admin scope.

Changes

Modified src/gateway/client.ts:

  1. Modified selectConnectAuth() to also load the stored scopes along with the stored token
  2. Modified the connect logic to use stored scopes when reconnecting with a valid device token
  3. Falls back to explicitly requested scopes or default ["operator.admin"] when no stored scopes exist

This ensures that when the CLI reconnects to the gateway (e.g., after gateway restart), it preserves the previously authorized scopes including operator.read, operator.write, operator.approvals, and operator.pairing.

Related Issue

Fixes: #46000

@openclaw-barnacle openclaw-barnacle bot added gateway Gateway runtime size: XS labels Mar 14, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 14, 2026

Greptile Summary

This PR fixes scope loss during gateway reconnection on Windows by reading previously authorized scopes from device-auth.json and replaying them when reconnecting with a stored device token, rather than always defaulting to ["operator.admin"].

  • The SelectedConnectAuth type and selectConnectAuth() are extended to surface storedScopes from the loaded DeviceAuthEntry
  • sendConnect() now prefers storedScopes over this.opts.scopes ?? ["operator.admin"] when a resolvedDeviceToken is present
  • Backward compatibility is maintained: if storedScopes is undefined (e.g. old device-auth.json without a scopes field), the condition short-circuits and the original fallback is used
  • One edge case is present: storedScopes = [] (an empty array) is truthy in JavaScript, so the fallback is not triggered when the server previously returned no scopes — the client would then send an empty scopes array on reconnect instead of the intended default

Confidence Score: 3/5

  • The fix is logically correct for the common case but has an empty-array edge case that could cause zero scopes to be sent on reconnect.
  • The core approach is sound and the minimal diff correctly addresses the described regression. However, the guard condition storedScopes && resolvedDeviceToken does not account for storedScopes = [] being truthy, which can arise when the server response omits the scopes field and storeDeviceAuthToken persists an empty array. In that scenario the fallback to ["operator.admin"] is skipped and an empty scopes list is sent to the server, potentially breaking authentication in the edge case.
  • src/gateway/client.ts — specifically line 341–342 where the scope-selection condition should guard against an empty stored-scopes array.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/gateway/client.ts
Line: 341-342

Comment:
**Empty stored scopes bypass the fallback**

`storedScopes` can be an empty array (`[]`), which is truthy in JavaScript. When `storedScopes = []` and `resolvedDeviceToken` is set, the condition `storedScopes && resolvedDeviceToken` evaluates to truthy, causing `scopes` to be set to `[]` instead of falling back to `this.opts.scopes ?? ["operator.admin"]`.

This can occur because `storeDeviceAuthToken` is called with `scopes: authInfo.scopes ?? []` — if the server's `HelloOk` response doesn't include a `scopes` field, an empty array is persisted. On the next reconnect, `storedScopes = []` and the client sends zero scopes to the server, which may cause the server to reject the connection or grant fewer permissions than expected.

The condition should explicitly guard against the empty array case:

```suggestion
    const scopes =
      storedScopes && storedScopes.length > 0 && resolvedDeviceToken ? storedScopes : (this.opts.scopes ?? ["operator.admin"]);
```

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: 032211c

Comment on lines +341 to +342
const scopes =
storedScopes && resolvedDeviceToken ? storedScopes : (this.opts.scopes ?? ["operator.admin"]);
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.

Empty stored scopes bypass the fallback

storedScopes can be an empty array ([]), which is truthy in JavaScript. When storedScopes = [] and resolvedDeviceToken is set, the condition storedScopes && resolvedDeviceToken evaluates to truthy, causing scopes to be set to [] instead of falling back to this.opts.scopes ?? ["operator.admin"].

This can occur because storeDeviceAuthToken is called with scopes: authInfo.scopes ?? [] — if the server's HelloOk response doesn't include a scopes field, an empty array is persisted. On the next reconnect, storedScopes = [] and the client sends zero scopes to the server, which may cause the server to reject the connection or grant fewer permissions than expected.

The condition should explicitly guard against the empty array case:

Suggested change
const scopes =
storedScopes && resolvedDeviceToken ? storedScopes : (this.opts.scopes ?? ["operator.admin"]);
const scopes =
storedScopes && storedScopes.length > 0 && resolvedDeviceToken ? storedScopes : (this.opts.scopes ?? ["operator.admin"]);
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/client.ts
Line: 341-342

Comment:
**Empty stored scopes bypass the fallback**

`storedScopes` can be an empty array (`[]`), which is truthy in JavaScript. When `storedScopes = []` and `resolvedDeviceToken` is set, the condition `storedScopes && resolvedDeviceToken` evaluates to truthy, causing `scopes` to be set to `[]` instead of falling back to `this.opts.scopes ?? ["operator.admin"]`.

This can occur because `storeDeviceAuthToken` is called with `scopes: authInfo.scopes ?? []` — if the server's `HelloOk` response doesn't include a `scopes` field, an empty array is persisted. On the next reconnect, `storedScopes = []` and the client sends zero scopes to the server, which may cause the server to reject the connection or grant fewer permissions than expected.

The condition should explicitly guard against the empty array case:

```suggestion
    const scopes =
      storedScopes && storedScopes.length > 0 && resolvedDeviceToken ? storedScopes : (this.opts.scopes ?? ["operator.admin"]);
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 032211cb8d

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +341 to +342
const scopes =
storedScopes && resolvedDeviceToken ? storedScopes : (this.opts.scopes ?? ["operator.admin"]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Respect explicit scopes when explicit device token is used

This now overrides this.opts.scopes whenever resolvedDeviceToken is set, but resolvedDeviceToken is also set for an explicitly provided deviceToken (not just cached token reuse). In that case, any cached storedScopes from device-auth.json can replace the caller’s requested scopes and cause explicit device-token auth to fail when scopes differ, because server-side verification checks requested scopes against the explicit token’s allowed scopes (verifyDeviceToken enforces this in src/infra/device-pairing.ts). Previously, explicit scopes were honored for explicit device-token connects.

Useful? React with 👍 / 👎.

Assistant and others added 2 commits April 4, 2026 22:09
When the gateway client reconnects using a stored device token, it was
defaulting to ["operator.admin"] scopes instead of preserving the
previously authorized scopes from the stored token. This caused the
operator device token to be regenerated without operator.read scope,
breaking status/probe/health commands.

This fix:
1. Loads the stored scopes along with the stored token in selectConnectAuth
2. Uses the stored scopes when reconnecting with a valid device token
3. Falls back to explicitly requested scopes or default admin-only scope
   when no stored scopes exist

Fixes openclaw#46000
@steipete steipete force-pushed the fix-windows-operator-scope-v2 branch from 032211c to 71f78ef Compare April 4, 2026 13:17
@steipete steipete merged commit 3f1b270 into openclaw:main Apr 4, 2026
23 of 27 checks passed
@steipete
Copy link
Copy Markdown
Contributor

steipete commented Apr 4, 2026

Landed via temp rebase onto main.

Gate:

  • pnpm test src/gateway/client.test.ts

  • pnpm build

  • pnpm check ⚠️ blocked by unrelated current-main TypeScript errors in extensions/openrouter/index.test.ts:46 and extensions/openrouter/index.ts:94

  • pnpm test src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts ⚠️ reproduces an unrelated server-side failure in does not let backend reconnect bypass the paired scope baseline

  • Land commit: 71f78ef

  • Merge commit: 3f1b270

Thanks @caicongyang!

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 71f78efd88

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +440 to +443
const scopes =
usingStoredDeviceToken && storedScopes && storedScopes.length > 0
? storedScopes
: (this.opts.scopes ?? ["operator.admin"]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Honor caller scopes on cached-token reconnects

This branch replaces this.opts.scopes with storedScopes whenever a cached device token is reused, even when the caller explicitly passed scopes. That regresses the least-privilege behavior used by gateway calls (for example, src/gateway/call.ts passes per-method scopes): a command that requested operator.read can reconnect with broader cached scopes (like admin/write) and run the session with more privilege than requested. The fallback to cached scopes should only happen when no explicit scopes were provided by the caller.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gateway Gateway runtime size: S

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Windows local gateway reissues operator device token without operator.read on 2026.3.13, breaking status/probe/health

2 participants