/login works in the chat window
Officer authenticates claude with a Claude SUBSCRIPTION over OAuth, and that is a browser round trip: open a URL, authorise, paste a code back. It was the one part of running an agent a browser-only user could not do from the browser — the answer was "open a terminal panel and drive the CLI by hand", which works, and is a strange thing to ask of somebody whose entire session is a web page. It needs no pty. `claude auth login` behaves identically through plain pipes, verified by capturing its raw bytes, so this is an ordinary subprocess with two reads and one write: spawn, read until the URL, wait for the person, write the code to stdin. The parsing is the only non-obvious part. The CLI wraps the URL in an OSC-8 hyperlink escape AND prints it again as visible text, so `https://\S+` returns two URLs concatenated with no separator between them. Stripping the escape wrappers first leaves exactly one. Written against captured bytes rather than against what the terminal appeared to show. Three decisions worth keeping: NOT under /auth. totality.ts exempts that prefix from the permission gate entirely, and correctly — it is where a caller goes to BECOME authenticated. Mounted there, an unauthenticated POST could have started a login as the owner. Verified after moving it: unauthenticated POST /api/claude-auth is 401. On the `chat` grant rather than its own. The pairing is exact: an account that may use the agent must be able to authenticate the agent, and an account that may not has nothing to authenticate. `confined` then carries the rest for free — authorize.ts drops a confined grant for an account with no osUser, which is precisely the account that cannot hold credentials of its own. Nothing Anthropic-shaped is inherited by the child. For the owner those variables point at the local proxy, whose whole job is swapping a shared secret for the owner's bearer token — handing them to a login is how you get a success that authenticates nothing. A member's login runs through setpriv --reset-env as their own Linux user, into their own home, because their turns bill their own subscription and that only holds if the credential is theirs. Pending logins are in memory on purpose: one is worthless once the process holding its PKCE verifier dies, so persisting it would store a row that can only ever be stale. Ten-minute reaper, one per account, and the dialog abandons its own on close so a dismissed dialog does not leave claude parked on a read of stdin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Check, ExternalLink, Loader2 } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { errorText } from 'helpers/error-text';
|
||||
|
||||
/*
|
||||
`/login` — the same flow as the terminal, without the terminal.
|
||||
|
||||
Officer authenticates `claude` with a Claude SUBSCRIPTION over OAuth, which is a browser round trip:
|
||||
open a URL, authorise, paste a code back. Until now the only way to do that here was to open a terminal
|
||||
panel and drive the CLI by hand — which works, and is a strange thing to ask of somebody whose whole
|
||||
session is a browser.
|
||||
|
||||
Three states, and the middle one is the person leaving to do something else:
|
||||
|
||||
starting → waiting for the code → done
|
||||
*/
|
||||
|
||||
type Started = { id: string; url: string; expiresInMs: number };
|
||||
|
||||
type Props = { open: boolean; onOpenChange: (open: boolean) => void };
|
||||
|
||||
export const ClaudeLoginDialog = ({ open, onOpenChange }: Props) => {
|
||||
const client = useClient();
|
||||
const [started, setStarted] = useState<Started | null>(null);
|
||||
const [code, setCode] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
const codeRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Start on open, and abandon on close. The server holds a live `claude` process per pending login, so a
|
||||
// dialog dismissed without telling it leaves that process parked on a read of stdin.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setStarted(null);
|
||||
setCode('');
|
||||
setError(null);
|
||||
setDone(false);
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await client.post<Started>('/claude-auth', {});
|
||||
if (!cancelled) setStarted(res);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(errorText(err, 'could not start the login'));
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open && started) void client.delete(`/claude-auth/${started.id}`).catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!started || !code.trim() || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await client.post(`/claude-auth/${started.id}/code`, { code: code.trim() });
|
||||
setDone(true);
|
||||
} catch (err) {
|
||||
setError(errorText(err, 'that code was not accepted'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sign in to Claude</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{done ? (
|
||||
<div className="flex items-center gap-2 py-4 text-sm">
|
||||
<Check className="h-4 w-4 text-success" />
|
||||
Signed in. New turns use these credentials — no restart needed.
|
||||
</div>
|
||||
) : !started ? (
|
||||
<div className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
|
||||
{error ? (
|
||||
<span className="text-destructive">{error}</span>
|
||||
) : (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Asking the CLI for a sign-in link…
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Open this, authorise, and paste back the code it gives you.
|
||||
</p>
|
||||
{/* A real anchor. Every link here opens a new tab, which is what you want for this one —
|
||||
losing the dialog would lose the process holding the other half of the exchange. */}
|
||||
<a
|
||||
href={started.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90"
|
||||
>
|
||||
Authorise in claude.com
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
ref={codeRef}
|
||||
autoFocus
|
||||
value={code}
|
||||
onChange={(ev) => setCode(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') void submit();
|
||||
}}
|
||||
placeholder="paste the code"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Expires in {Math.round(started.expiresInMs / 60_000)} minutes. Run <code>/login</code> again if it does.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!code.trim() || busy}
|
||||
onClick={() => void submit()}
|
||||
className="inline-flex items-center gap-2 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground disabled:opacity-40"
|
||||
>
|
||||
{busy && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
||||
import type { QueuedPrompt, UseEmbeddableChatType } from '../EmbeddableChat/useEmbeddableChat';
|
||||
import { ModelSelector } from './ModelSelector';
|
||||
import { BackgroundJobDialog } from './BackgroundJobDialog';
|
||||
import { ClaudeLoginDialog } from './ClaudeLoginDialog';
|
||||
import { armBackgroundJob } from '../../../background-job-origin';
|
||||
import { useWorkspace } from '../../../components/Workspace';
|
||||
import { AttachmentList } from './AttachmentList';
|
||||
@@ -17,6 +18,7 @@ type InputAreaProps = {
|
||||
};
|
||||
|
||||
export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
const {
|
||||
input,
|
||||
setInput,
|
||||
@@ -105,7 +107,17 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(ev) => setInput(ev.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyDown={(ev) => {
|
||||
// Enter is how anybody actually sends, so the interception has to be here as well as on the
|
||||
// button. Shift-Enter is a newline and stays one.
|
||||
if (ev.key === 'Enter' && !ev.shiftKey && input.trim() === '/login') {
|
||||
ev.preventDefault();
|
||||
setInput('');
|
||||
setLoginOpen(true);
|
||||
return;
|
||||
}
|
||||
handleKeyDown(ev);
|
||||
}}
|
||||
onPaste={(ev) => {
|
||||
// Let an image paste fall through to the browser's default (nothing) rather than attaching
|
||||
// something this model will discard.
|
||||
@@ -141,7 +153,16 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
onClick={() => {
|
||||
// `/login` never reaches the agent. It is not a prompt — it is the browser-only user doing the
|
||||
// one thing that used to need a terminal panel, so the composer answers it locally.
|
||||
if (input.trim() === '/login') {
|
||||
setInput('');
|
||||
setLoginOpen(true);
|
||||
return;
|
||||
}
|
||||
handleSend();
|
||||
}}
|
||||
disabled={!input.trim() || !isConnected}
|
||||
size="icon"
|
||||
title={isGenerating ? 'Queue — sends when the current turn finishes' : 'Send'}
|
||||
@@ -169,6 +190,7 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
/>
|
||||
|
||||
<WebpageDialog open={urlDialogOpen} onOpenChange={setUrlDialogOpen} onSubmit={attachWebpage} />
|
||||
<ClaudeLoginDialog open={loginOpen} onOpenChange={setLoginOpen} />
|
||||
<BackgroundJobDialog
|
||||
open={jobDialogOpen}
|
||||
onOpenChange={setJobDialogOpen}
|
||||
|
||||
Reference in New Issue
Block a user