diff --git a/src/servers/api/agent-status/router.ts b/src/servers/api/agent-status/router.ts new file mode 100644 index 00000000..d581eadf --- /dev/null +++ b/src/servers/api/agent-status/router.ts @@ -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({ + 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({ installed: true, loggedIn: true, isOwner: true, instruction: null }); + } + + const row = await getUserById(user.id); + if (!row?.osUser) { + return ctx.json({ + 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({ ...state, isOwner: false, instruction }); +}); diff --git a/src/servers/capabilities/registry.ts b/src/servers/capabilities/registry.ts index 5a9528f6..788c82d1 100644 --- a/src/servers/capabilities/registry.ts +++ b/src/servers/capabilities/registry.ts @@ -282,7 +282,11 @@ export const CAPABILITIES: Capability[] = [ label: 'Chat', description: 'The agent', 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'], routes: ['/chat'], }, diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 52cd9177..8066a6a1 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -53,6 +53,7 @@ import { emailRouter } from './api/email/router'; import { browserRouter } from './api/browser/router'; import { desktopRouter } from './api/desktop/rest'; import { bugReportRouter } from './api/bug-report/bug-report'; +import { agentStatusRouter } from './api/agent-status/router'; import { chatRouter } from './api/chat/chat'; import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes'; import { CustomError } from './custom-errors'; @@ -226,6 +227,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType ['/email', emailRouter], ['/browser', browserRouter], ['/bug-report', bugReportRouter], + ['/agent-status', agentStatusRouter], ['/chat', chatRouter], ['/pipeline-jobs', pipelineJobsRouter], ['/jobs', pipelineJobsRouter], // unified jobs API (script + pipeline); /pipeline-jobs kept for the existing UI