Skip to content

Commit 674b62a

Browse files
committed
fix(web): stop pre-emptively blocking an AMR send that has a wallet
An Open Design Cloud run is billed to the CALLER's own wallet, so the only defensible client-side veto is "there is no billing principal at all". The gate instead required THIS PROJECT's workspace scope to resolve, and blocked the send whenever it did not: an unbound project, a membership-directory read that transiently failed (offline / 504 / timeout all collapse into one `ok: false` upstream), a workspace that is billing_past_due or locked, or a team the caller has since left. In every one of those the user is spending their own quota. It is also not the enforcement point. Real enforcement is server-side — the daemon's `WORKSPACE_CONTEXT_REQUIRED` 401 plus vela's own billing check. The client gate could only convert a request the server would have answered into a dead, unexplained button, which is strictly worse than an honest server error. It arrived as 21f452f, whose entire message is "fail closed on unresolved workspace authority" with no body and no stated product requirement. `workspaceIdentityCanBillAmr` names the invariant on the identity read, and the gate now admits either witness of a billing principal: that identity, or a project scope that already resolves to an explicit personal/team principal. Strictly a widening — every state it newly admits was previously blocked, and nothing previously admitted becomes blocked. Deliberately NOT treated as "no wallet": loading — the identity read holds no answer yet. Reporting "signed out" on a frame that has not heard back is the bug shape this replaces. failure 'unavailable' — a transient outage taught us nothing about the user. failure 'unsupported' — an old daemon with no workspace endpoint keeps its legal pre-workspace behavior. A genuinely signed-out caller — a settled, authoritative read that came back with no workspace — stays blocked: there is no wallet, so the run cannot be billed and cannot succeed. No UI, no notice, no copy: #6178 wrote copy for this dead end instead of removing it, and #6184 reverts that. This removes the dead end.
1 parent 99f4bc9 commit 674b62a

3 files changed

Lines changed: 221 additions & 15 deletions

File tree

apps/web/src/collab/useWorkspaceContext.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,40 @@ export interface WorkspaceContextState {
4040
failure?: 'unsupported' | 'unavailable';
4141
}
4242

43+
/**
44+
* Whether an Open Design Cloud (AMR) run has a cloud identity that could pay
45+
* for it.
46+
*
47+
* AMR bills the caller's OWN wallet — their current workspace. The only state
48+
* in which it genuinely cannot run is "there is no cloud identity at all": a
49+
* settled, authoritative read that came back with no workspace. Every other
50+
* state is the user spending their own quota, and whether they may is the
51+
* server's call — the daemon's `WORKSPACE_CONTEXT_REQUIRED` 401 and vela's
52+
* own billing check are the enforcement points. A client-side veto on a
53+
* weaker signal cannot add safety; it can only turn a request the server
54+
* would have answered into a dead, unexplained button.
55+
*
56+
* Deliberately NOT treated as "cannot be billed":
57+
*
58+
* - `loading` — the read holds no answer yet. Reporting "signed out" on a
59+
* frame that has not heard back is the bug shape this replaces.
60+
* - `failure: 'unavailable'` — a transient outage. Offline, a 504, and a
61+
* timeout all collapse into one `ok: false` upstream, so nothing was
62+
* learned about the user's identity.
63+
* - `failure: 'unsupported'` — an old daemon with no workspace endpoint,
64+
* which keeps its legal pre-workspace behavior.
65+
*
66+
* Note this asks about the CALLER's identity, not about the project. A
67+
* project whose own workspace scope is `unbound` or `unavailable` says
68+
* nothing about whether the signed-in user has a wallet.
69+
*/
70+
export function workspaceIdentityCanBillAmr(state: WorkspaceContextState): boolean {
71+
if (state.context !== null) return true;
72+
if (state.loading) return true;
73+
if (state.failure) return true;
74+
return false;
75+
}
76+
4377
/** Coalescing key for `GET /api/workspace/context`; shared so an identity
4478
* change can evict exactly this read instead of the whole cache. */
4579
const WORKSPACE_CONTEXT_COALESCE_KEY = 'workspace-context';

apps/web/src/components/ProjectView.tsx

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,10 @@ import { localizePluginTitle } from './plugins-home/localization';
230230
import { DesignSystemPicker } from './DesignSystemPicker';
231231
import { PresenceBar } from '../collab/PresenceBar';
232232
import { useProjectCollab } from '../collab/useProjectCollab';
233-
import { useWorkspaceContext } from '../collab/useWorkspaceContext';
233+
import {
234+
useWorkspaceContext,
235+
workspaceIdentityCanBillAmr,
236+
} from '../collab/useWorkspaceContext';
234237
import {
235238
projectWorkspaceContext,
236239
projectWorkspaceScopeAuthorizesAmr,
@@ -1545,13 +1548,32 @@ export function ProjectView({
15451548
);
15461549
const projectRunRequiresWorkspaceScope =
15471550
config.mode === 'daemon' && config.agentId === 'amr';
1548-
const projectRunWorkspaceScopeReady =
1551+
// An Open Design Cloud run needs a wallet, and the ONLY client-side veto is
1552+
// "there is no billing principal at all". Either witness suffices: the
1553+
// caller's own cloud identity, or a project scope that already names an
1554+
// explicit personal/team principal.
1555+
//
1556+
// What this deliberately stops doing is requiring the PROJECT's scope to
1557+
// resolve. An `unbound` project, or one whose membership-directory read
1558+
// transiently failed (offline / 504 / timeout, all collapsed into one
1559+
// `ok: false` upstream), or one pinned to a team the caller has left, says
1560+
// nothing about whether the signed-in user can pay — they are spending their
1561+
// own quota. Withholding the send on those turned a request the server would
1562+
// have answered into a dead, unexplained button (21f452ffe, which landed as
1563+
// a defensive default with no stated product requirement). Whether the run is
1564+
// actually permitted stays the server's call: the daemon's
1565+
// `WORKSPACE_CONTEXT_REQUIRED` 401 plus vela's own billing check are the
1566+
// enforcement points, and an honest server error beats a dead button.
1567+
//
1568+
// Strictly a widening: every state this admits was previously blocked, and
1569+
// nothing previously admitted becomes blocked.
1570+
const projectRunHasBillableAmrPrincipal =
15491571
!projectRunRequiresWorkspaceScope ||
1572+
workspaceIdentityCanBillAmr(workspaceContextState) ||
15501573
projectWorkspaceScopeAuthorizesAmr(projectWorkspaceScopeState.scope);
1551-
// Holding the send closed above is deliberate; holding it closed silently is
1552-
// not. This classifies the block so the composer can name the reason and offer
1553-
// the remedy that clears it. It never feeds back into
1554-
// `projectRunWorkspaceScopeReady` — the gate stays exactly as strict.
1574+
// #6184 reverts the notice this classifier fed; it is left untouched here so
1575+
// the revert owns its removal. It never feeds back into
1576+
// `projectRunHasBillableAmrPrincipal`, so it cannot widen or narrow the gate.
15551577
const projectRunAmrScopeBlock = amrWorkspaceScopeBlock({
15561578
requiresWorkspaceScope: projectRunRequiresWorkspaceScope,
15571579
projectScope: projectWorkspaceScopeState,
@@ -2137,7 +2159,7 @@ export function ProjectView({
21372159
currentConversationHasActiveRun
21382160
&& !currentConversationStreaming
21392161
&& !currentConversationHasProgrammaticBrandExtractionRun;
2140-
const currentConversationSendDisabled = !projectRunWorkspaceScopeReady
2162+
const currentConversationSendDisabled = !projectRunHasBillableAmrPrincipal
21412163
|| currentConversationLoading
21422164
|| failedMessagesConversationId === activeConversationId
21432165
|| currentConversationAwaitingActiveRunAttach;
@@ -5306,7 +5328,7 @@ export function ProjectView({
53065328
// run can start. Local CLI and BYOK runtimes do not consume the Vela
53075329
// wallet, so old daemons without this endpoint and directory outages
53085330
// must not disable those runtimes.
5309-
if (!projectRunWorkspaceScopeReady) return false;
5331+
if (!projectRunHasBillableAmrPrincipal) return false;
53105332
const effectiveAttachments = mergeChatAttachments(
53115333
attachments,
53125334
...commentAttachments.map((attachment) =>
@@ -6684,7 +6706,7 @@ export function ProjectView({
66846706
byokVideoModelOptionsPV,
66856707
byokSpeechModelOptionsPV,
66866708
projectRunWorkspaceContext,
6687-
projectRunWorkspaceScopeReady,
6709+
projectRunHasBillableAmrPrincipal,
66886710
],
66896711
);
66906712

@@ -8996,7 +9018,7 @@ export function ProjectView({
89969018
useEffect(() => {
89979019
if (autoSentRef.current) return;
89989020
if (!activeConversationId) return;
8999-
if (!projectRunWorkspaceScopeReady) return;
9021+
if (!projectRunHasBillableAmrPrincipal) return;
90009022
// Wait for the initial listMessages DB read to land. Without this gate
90019023
// the auto-send fires before the in-flight DB response, which then
90029024
// arrives with `setMessages([])` and wipes the freshly-pushed user +
@@ -9061,7 +9083,7 @@ export function ProjectView({
90619083
project.metadata,
90629084
initialDraft,
90639085
project.pendingPrompt,
9064-
projectRunWorkspaceScopeReady,
9086+
projectRunHasBillableAmrPrincipal,
90659087
handleSend,
90669088
]);
90679089

apps/web/tests/components/ProjectView.run-isolation.test.tsx

Lines changed: 154 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ const showCompletionNotification = vi.fn();
4848
const analyticsTrackMock = vi.fn();
4949
const workspaceScopeMocks = vi.hoisted(() => ({
5050
ambientContext: null as WorkspaceCollabContext | null,
51+
ambientLoading: false,
52+
ambientFailure: null as 'unsupported' | 'unavailable' | null,
5153
projectScope: {
5254
loading: false,
5355
scope: {
@@ -86,8 +88,18 @@ vi.mock('../../src/providers/anthropic', () => ({
8688
vi.mock('../../src/collab/useWorkspaceContext', () => ({
8789
useWorkspaceContext: () => ({
8890
context: workspaceScopeMocks.ambientContext,
89-
loading: false,
91+
loading: workspaceScopeMocks.ambientLoading,
92+
...(workspaceScopeMocks.ambientFailure
93+
? { failure: workspaceScopeMocks.ambientFailure }
94+
: {}),
9095
}),
96+
// Mirrors the real predicate: only a settled, authoritative "no workspace"
97+
// read means AMR has no wallet.
98+
workspaceIdentityCanBillAmr: (state: {
99+
context: unknown;
100+
loading: boolean;
101+
failure?: string;
102+
}) => state.context !== null || state.loading || Boolean(state.failure),
91103
useWorkspaceBilling: () => null,
92104
}));
93105

@@ -655,6 +667,8 @@ describe('ProjectView conversation run isolation', () => {
655667
beforeEach(() => {
656668
window.localStorage.clear();
657669
workspaceScopeMocks.ambientContext = null;
670+
workspaceScopeMocks.ambientLoading = false;
671+
workspaceScopeMocks.ambientFailure = null;
658672
workspaceScopeMocks.projectScope = {
659673
loading: false,
660674
scope: {
@@ -787,6 +801,133 @@ describe('ProjectView conversation run isolation', () => {
787801
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
788802
});
789803

804+
// An Open Design Cloud run is billed to the CALLER's own wallet. The gate must
805+
// therefore ask about the caller's identity, not about this project's
806+
// workspace scope — a project whose scope is unresolved says nothing about
807+
// whether the signed-in user can pay, and holding the send closed there just
808+
// produced a dead button (21f452ffe). Cases below pin the narrowed rule.
809+
const amrAgents = [{
810+
id: 'amr',
811+
name: 'AMR',
812+
bin: 'amr',
813+
available: true,
814+
models: [{ id: 'glm-5', label: 'GLM 5' }],
815+
}];
816+
817+
it.each([
818+
[
819+
'an unbound project',
820+
{
821+
loading: false,
822+
scope: {
823+
kind: 'unbound' as const,
824+
projectId: project.id,
825+
workspaceId: null,
826+
context: null,
827+
},
828+
},
829+
],
830+
[
831+
'a project pinned to a workspace the caller is not in',
832+
{
833+
loading: false,
834+
scope: {
835+
kind: 'unavailable' as const,
836+
projectId: project.id,
837+
workspaceId: 'workspace-elsewhere',
838+
visibility: 'personal' as const,
839+
context: null,
840+
},
841+
},
842+
],
843+
[
844+
'a workspace-directory outage',
845+
{ loading: false, scope: null, failure: 'unavailable' as const },
846+
],
847+
])(
848+
'lets a signed-in user send an AMR run with %s — they are spending their own quota',
849+
async (_label, projectScope) => {
850+
conversationAMessages = [];
851+
// Signed in: there IS a wallet. This is the whole difference from the
852+
// signed-out case below.
853+
workspaceScopeMocks.ambientContext = teamWorkspaceContext(
854+
'workspace-team',
855+
'member-team',
856+
);
857+
workspaceScopeMocks.projectScope = projectScope;
858+
859+
renderProjectView({ ...config, agentId: 'amr' }, project, amrAgents);
860+
861+
await waitFor(() =>
862+
expect(screen.getByTestId('active-conversation').textContent).toBe('conv-a'),
863+
);
864+
await waitFor(() =>
865+
expect(screen.getByTestId('send-message')).toHaveProperty('disabled', false),
866+
);
867+
fireEvent.click(screen.getByTestId('send-message'));
868+
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
869+
},
870+
);
871+
872+
it('still blocks an AMR run for a genuinely signed-out caller', async () => {
873+
conversationAMessages = [];
874+
// A settled, authoritative read that came back with no workspace: there is
875+
// no wallet at all, so the run cannot be billed and cannot succeed.
876+
workspaceScopeMocks.ambientContext = null;
877+
workspaceScopeMocks.ambientLoading = false;
878+
workspaceScopeMocks.ambientFailure = null;
879+
workspaceScopeMocks.projectScope = {
880+
loading: false,
881+
scope: {
882+
kind: 'unbound',
883+
projectId: project.id,
884+
workspaceId: null,
885+
context: null,
886+
},
887+
};
888+
889+
renderProjectView({ ...config, agentId: 'amr' }, project, amrAgents);
890+
891+
await waitFor(() =>
892+
expect(screen.getByTestId('active-conversation').textContent).toBe('conv-a'),
893+
);
894+
expect(screen.getByTestId('send-message')).toHaveProperty('disabled', true);
895+
fireEvent.click(screen.getByTestId('send-message'));
896+
expect(streamViaDaemon).not.toHaveBeenCalled();
897+
});
898+
899+
it.each([
900+
['an identity read still in flight', { loading: true, failure: null }],
901+
['a transient identity outage', { loading: false, failure: 'unavailable' as const }],
902+
['an old daemon with no workspace endpoint', { loading: false, failure: 'unsupported' as const }],
903+
])(
904+
'does not disable the AMR send on %s — an unsettled read is not a signed-out user',
905+
async (_label, identity) => {
906+
conversationAMessages = [];
907+
workspaceScopeMocks.ambientContext = null;
908+
workspaceScopeMocks.ambientLoading = identity.loading;
909+
workspaceScopeMocks.ambientFailure = identity.failure;
910+
workspaceScopeMocks.projectScope = {
911+
loading: false,
912+
scope: {
913+
kind: 'unbound',
914+
projectId: project.id,
915+
workspaceId: null,
916+
context: null,
917+
},
918+
};
919+
920+
renderProjectView({ ...config, agentId: 'amr' }, project, amrAgents);
921+
922+
await waitFor(() =>
923+
expect(screen.getByTestId('active-conversation').textContent).toBe('conv-a'),
924+
);
925+
await waitFor(() =>
926+
expect(screen.getByTestId('send-message')).toHaveProperty('disabled', false),
927+
);
928+
},
929+
);
930+
790931
it.each([
791932
[
792933
'an unbound project',
@@ -808,8 +949,12 @@ describe('ProjectView conversation run isolation', () => {
808949
'a workspace-directory outage',
809950
{ loading: false, scope: null, failure: 'unavailable' as const },
810951
],
811-
])('fails closed for AMR with %s', async (_label, projectScope) => {
952+
])(
953+
'fails closed for a SIGNED-OUT AMR caller with %s',
954+
async (_label, projectScope) => {
812955
conversationAMessages = [];
956+
// `ambientContext` stays null from beforeEach: no cloud identity, so no
957+
// wallet. The project's own scope is not what closes the gate here.
813958
workspaceScopeMocks.projectScope = projectScope;
814959

815960
renderProjectView(
@@ -832,7 +977,8 @@ describe('ProjectView conversation run isolation', () => {
832977
expect(screen.getByTestId('send-message')).toHaveProperty('disabled', true);
833978
fireEvent.click(screen.getByTestId('send-message'));
834979
expect(streamViaDaemon).not.toHaveBeenCalled();
835-
});
980+
},
981+
);
836982

837983
it.each([
838984
[
@@ -915,7 +1061,11 @@ describe('ProjectView conversation run isolation', () => {
9151061
await waitFor(() =>
9161062
expect(screen.getByTestId('active-conversation').textContent).toBe('conv-a'),
9171063
);
918-
expect(screen.getByTestId('send-message')).toHaveProperty('disabled', true);
1064+
// The classifier still reports "unresolved" for the project's scope, but
1065+
// that no longer withholds the send: this caller is signed in and is
1066+
// spending their own quota. (#6184 removes the classifier entirely; this
1067+
// case keeps asserting its output only until that revert lands.)
1068+
expect(screen.getByTestId('send-message')).toHaveProperty('disabled', false);
9191069
expect(screen.getByTestId('amr-scope-block').textContent).toBe('unresolved');
9201070
});
9211071

0 commit comments

Comments
 (0)