tell a member why their agent is not working, instead of 403

f0af723 granted chat to every role by default, which is right, but the route
still refuses non-owners — so a new member gets a tile that resolves and an API
that 403s, the exact broken state b4f88ec and eda004a were built to remove.

The fix is not to withdraw the grant. It is to answer the question the member
actually has, which is "what do I do about it": their own claude, in their own
home, needs them to sign in once with their own Anthropic account. The platform
cannot do that for them — logging in is an interactive act against an account
that is theirs, and the alternative, pointing them at the owner's credential
proxy, spends the owner's subscription on their turns.

GET /api-status returns two booleans about the caller's own home plus the one
instruction that fits their case, so the UI can render a terminal saying "run
claude once" rather than an error.

Its own router, deliberately not on chatRouter: that router refuses every
non-owner wholesale and is right to — reads there leak the owner's project
directory names — which means an endpoint on it could not be read by the
accounts that need it most. Same `chat` capability, no owner gate, and nothing
in the response describes anyone but the caller.

Frontend not done: nothing calls this yet, so behaviour is still unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 21:58:18 +00:00
co-authored by Claude Opus 5
parent 6a85ca5d1f
commit 2bd96a9a98
3 changed files with 86 additions and 1 deletions
+79
View File
@@ -0,0 +1,79 @@
import { getUserById } from 'officerdb';
import { claudeLoginState } from '@@/os-user-claude';
import { resolveHomeDir } from '@@/user-home';
import { createRouter } from '../../create-router';
// Can this account actually run an agent, and if not, what does it need to do about it?
//
// ── Why this is not in `api/chat` ──
//
// It would be the obvious home, and it cannot go there: `chat.ts:49` refuses every non-owner wholesale, so an
// endpoint on that router could not be read by the accounts that most need it. The refusal is right — reads
// there leak the owner's project directory names — but it means the answer to "why is my agent not working"
// has to live somewhere a member can reach.
//
// So this is its own router under the `chat` capability. Same grant, no owner gate, and nothing here reports
// on anyone but the caller: two booleans about their own home. Denying it would not restrict an account, it
// would just replace an explanation with a silence.
//
// ── Why the platform cannot fix `loggedIn` for them ──
//
// `claude` authenticates interactively against an Anthropic account, so logging in is the member's own act.
// The alternative — pointing them at the owner's credential proxy — spends the owner's subscription on their
// turns, which is the thing the whole per-user design exists to avoid. What the UI does with a `false` here is
// render a terminal and tell them to run `claude` once themselves.
export const agentStatusRouter = createRouter();
type AgentStatus = {
/** Their own `claude` is installed in their home. */
installed: boolean;
/** They have logged in, so a turn can actually run. */
loggedIn: boolean;
/** True for the owner, whose agent has always worked and who needs no instructions. */
isOwner: boolean;
/** Null unless something is missing — the one thing to tell them, already resolved to their case. */
instruction: string | null;
};
agentStatusRouter.get('/', async (ctx) => {
const user = ctx.get('user');
const resolved = await resolveHomeDir(user.id);
if (!resolved.ok) {
// No Linux account means nothing is confined and nothing can run — the same refusal the file browser
// gives, reported rather than thrown because this endpoint exists to explain, not to gate.
return ctx.json<AgentStatus>({
installed: false,
loggedIn: false,
isOwner: false,
instruction: resolved.reason,
});
}
// The owner runs in their real login home with the credential the proxy already holds. There is no member
// account to `runAs`, and nothing to instruct.
if (resolved.isOwner) {
return ctx.json<AgentStatus>({ installed: true, loggedIn: true, isOwner: true, instruction: null });
}
const row = await getUserById(user.id);
if (!row?.osUser) {
return ctx.json<AgentStatus>({
installed: false,
loggedIn: false,
isOwner: false,
instruction: 'This account has no Linux user on this machine yet — ask the server owner to provision it.',
});
}
const state = await claudeLoginState({ email: row.email, osUser: row.osUser });
const instruction = !state.installed
? 'Claude is not installed in your home yet — ask the server owner to reprovision your account.'
: !state.loggedIn
? 'Open a terminal and run `claude` once to sign in with your own Anthropic account. It stays signed in.'
: null;
return ctx.json<AgentStatus>({ ...state, isOwner: false, instruction });
});
+5 -1
View File
@@ -282,7 +282,11 @@ export const CAPABILITIES: Capability[] = [
label: 'Chat', label: 'Chat',
description: 'The agent', description: 'The agent',
kind: 'confined', kind: 'confined',
api: ['/chat'], // `/agent-status` is on this grant but deliberately NOT on `chatRouter`, because that router refuses
// non-owners wholesale — so an endpoint there could not be read by the accounts that need it. It reports
// two booleans about the caller's own home and nothing about anyone else, which is what lets a member be
// told "run `claude` once to sign in" instead of being shown a tile that 403s.
api: ['/chat', '/agent-status'],
ws: ['chat'], ws: ['chat'],
routes: ['/chat'], routes: ['/chat'],
}, },
+2
View File
@@ -53,6 +53,7 @@ import { emailRouter } from './api/email/router';
import { browserRouter } from './api/browser/router'; import { browserRouter } from './api/browser/router';
import { desktopRouter } from './api/desktop/rest'; import { desktopRouter } from './api/desktop/rest';
import { bugReportRouter } from './api/bug-report/bug-report'; import { bugReportRouter } from './api/bug-report/bug-report';
import { agentStatusRouter } from './api/agent-status/router';
import { chatRouter } from './api/chat/chat'; import { chatRouter } from './api/chat/chat';
import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes'; import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes';
import { CustomError } from './custom-errors'; import { CustomError } from './custom-errors';
@@ -226,6 +227,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
['/email', emailRouter], ['/email', emailRouter],
['/browser', browserRouter], ['/browser', browserRouter],
['/bug-report', bugReportRouter], ['/bug-report', bugReportRouter],
['/agent-status', agentStatusRouter],
['/chat', chatRouter], ['/chat', chatRouter],
['/pipeline-jobs', pipelineJobsRouter], ['/pipeline-jobs', pipelineJobsRouter],
['/jobs', pipelineJobsRouter], // unified jobs API (script + pipeline); /pipeline-jobs kept for the existing UI ['/jobs', pipelineJobsRouter], // unified jobs API (script + pipeline); /pipeline-jobs kept for the existing UI