@corbet-labs/ccht 0.2.0 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/LICENSE.md +11 -6
  2. package/LICENSES/LGPL-3.0-linking-exception.txt +16 -0
  3. package/LICENSES/LGPL-3.0-only WITH LGPL-3.0-linking-exception.txt +16 -0
  4. package/LICENSES/dependencies/bytes-1.12.1/LICENSE +25 -0
  5. package/README.md +194 -212
  6. package/THIRD-PARTY.md +17 -0
  7. package/index.d.ts +6 -1
  8. package/index.js +8 -1
  9. package/package.json +17 -3
  10. package/source/.ci/wasm-bundle/Cargo.toml +1 -1
  11. package/source/CHANGELOG.md +59 -2
  12. package/source/Cargo.lock +9 -1
  13. package/source/Cargo.toml +4 -4
  14. package/source/LICENSE.md +11 -6
  15. package/source/LICENSES/LGPL-3.0-linking-exception.txt +16 -0
  16. package/source/LICENSES/LGPL-3.0-only WITH LGPL-3.0-linking-exception.txt +16 -0
  17. package/source/README.md +67 -12
  18. package/source/THIRD-PARTY.md +17 -0
  19. package/source/dependencies.tar.gz +0 -0
  20. package/source/src/auth.rs +435 -0
  21. package/source/src/configuration.rs +51 -0
  22. package/source/src/conversation.rs +25 -0
  23. package/source/src/dock.rs +564 -0
  24. package/source/src/lib.rs +9 -0
  25. package/source/src/native/client.rs +6 -0
  26. package/source/src/native/drivers/codex.rs +506 -0
  27. package/source/src/native/drivers/mod.rs +305 -0
  28. package/source/src/native/drivers/opencode.rs +531 -0
  29. package/source/src/native/env.rs +264 -0
  30. package/source/src/native/fixture.py +78 -1
  31. package/source/src/native/mod.rs +5 -0
  32. package/source/src/native/pool.rs +440 -0
  33. package/source/src/native/session.rs +6 -0
  34. package/source/src/native/tests.rs +204 -0
  35. package/source/src/transport.rs +330 -0
  36. package/src/auth.ts +149 -0
  37. package/src/components/AccountConnection.svelte +201 -0
  38. package/src/components/Dock.svelte +172 -0
  39. package/src/dock.ts +244 -0
  40. package/wasm/ccht_bg.wasm +0 -0
@@ -0,0 +1,330 @@
1
+ //! Transport abstraction for ACP-style JSON-RPC.
2
+ //!
3
+ //! The same event contract can travel over a native stdio pipe or over a
4
+ //! socket used by a Wasm bridge. This module only describes and validates
5
+ //! endpoints; it never connects, spawns processes, or performs network I/O.
6
+ //!
7
+ //! Validation is offline and never echoes addresses or credentials: error
8
+ //! messages use fixed text so secrets cannot leak through [`TransportError`].
9
+
10
+ /// How an ACP-style endpoint is reached.
11
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
12
+ pub enum TransportKind {
13
+ /// A child process speaking JSON-RPC over stdin and stdout.
14
+ Stdio,
15
+ /// A socket endpoint (`ws`/`wss`/`http`/`https`) used by a bridge.
16
+ Socket,
17
+ }
18
+
19
+ /// A validated endpoint description for ACP-style JSON-RPC.
20
+ ///
21
+ /// Implementors must be sendable across threads; validation is offline only
22
+ /// and performs no connecting or process spawning.
23
+ pub trait Transport: Send {
24
+ /// Which mechanism this endpoint uses.
25
+ fn kind(&self) -> TransportKind;
26
+ /// Non-sensitive hint for logs or UI, without credentials.
27
+ fn address_hint(&self) -> Option<String>;
28
+ /// Check endpoint shape without connecting or spawning.
29
+ ///
30
+ /// # Errors
31
+ ///
32
+ /// Returns [`TransportError`] when the endpoint shape is invalid.
33
+ fn validate(&self) -> Result<(), TransportError>;
34
+ }
35
+
36
+ /// Transport validation failure without sensitive data.
37
+ ///
38
+ /// Messages are fixed strings; they never echo programs, URLs, or tokens.
39
+ #[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
40
+ pub enum TransportError {
41
+ /// The endpoint address shape is invalid.
42
+ #[error("invalid transport address: {0}")]
43
+ InvalidAddress(&'static str),
44
+ /// This transport kind is not supported in the current context.
45
+ #[error("unsupported transport: {0}")]
46
+ Unsupported(&'static str),
47
+ /// The transport is already closed.
48
+ #[error("transport is closed")]
49
+ Closed,
50
+ }
51
+
52
+ /// Stdio endpoint: an explicit program plus arguments.
53
+ ///
54
+ /// No shell parsing is involved; the program must be non-empty.
55
+ #[derive(Debug, Clone, PartialEq, Eq)]
56
+ pub struct StdioTransport {
57
+ /// Explicit executable path or name; never a shell string.
58
+ pub program: String,
59
+ /// Arguments passed directly to the executable.
60
+ pub args: Vec<String>,
61
+ }
62
+
63
+ impl StdioTransport {
64
+ /// Create a stdio endpoint with an explicit program and arguments.
65
+ pub fn new(program: impl Into<String>, args: Vec<String>) -> Self {
66
+ Self {
67
+ program: program.into(),
68
+ args,
69
+ }
70
+ }
71
+ }
72
+
73
+ impl Transport for StdioTransport {
74
+ /// Report [`TransportKind::Stdio`].
75
+ fn kind(&self) -> TransportKind {
76
+ TransportKind::Stdio
77
+ }
78
+
79
+ /// The program name when non-empty, without arguments.
80
+ fn address_hint(&self) -> Option<String> {
81
+ let program = self.program.trim();
82
+ if program.is_empty() {
83
+ None
84
+ } else {
85
+ Some(program.to_owned())
86
+ }
87
+ }
88
+
89
+ /// Require a non-empty program; arguments need no validation.
90
+ ///
91
+ /// # Errors
92
+ ///
93
+ /// Returns [`TransportError::InvalidAddress`] when the program is empty.
94
+ fn validate(&self) -> Result<(), TransportError> {
95
+ if self.program.trim().is_empty() {
96
+ return Err(TransportError::InvalidAddress(
97
+ "stdio program must not be empty",
98
+ ));
99
+ }
100
+ Ok(())
101
+ }
102
+ }
103
+
104
+ /// Socket endpoint: a URL string validated offline, never dialed.
105
+ ///
106
+ /// Accepted schemes are `ws`, `wss`, `http`, and `https` with a non-empty
107
+ /// host. Validation checks shape only and never connects.
108
+ #[derive(Debug, Clone, PartialEq, Eq)]
109
+ pub struct SocketTransport {
110
+ /// Endpoint URL; validated for shape only, never connected.
111
+ pub url: String,
112
+ }
113
+
114
+ impl SocketTransport {
115
+ /// Create a socket endpoint; call [`Transport::validate`] to check shape.
116
+ pub fn new(url: impl Into<String>) -> Self {
117
+ Self { url: url.into() }
118
+ }
119
+ }
120
+
121
+ impl Transport for SocketTransport {
122
+ /// Report [`TransportKind::Socket`].
123
+ fn kind(&self) -> TransportKind {
124
+ TransportKind::Socket
125
+ }
126
+
127
+ /// The URL when non-empty; callers must redact userinfo before display
128
+ /// when the URL may carry credentials.
129
+ fn address_hint(&self) -> Option<String> {
130
+ if self.url.trim().is_empty() {
131
+ None
132
+ } else {
133
+ Some(self.url.clone())
134
+ }
135
+ }
136
+
137
+ /// Validate scheme and host presence without connecting.
138
+ ///
139
+ /// # Errors
140
+ ///
141
+ /// Returns [`TransportError::InvalidAddress`] when the scheme is not
142
+ /// `ws`/`wss`/`http`/`https` or when no host is present.
143
+ fn validate(&self) -> Result<(), TransportError> {
144
+ validate_socket_url(&self.url)
145
+ }
146
+ }
147
+
148
+ /// Check socket URL shape without connecting or echoing the URL in errors.
149
+ fn validate_socket_url(url: &str) -> Result<(), TransportError> {
150
+ if url.trim().is_empty() {
151
+ return Err(TransportError::InvalidAddress(
152
+ "socket url must not be empty",
153
+ ));
154
+ }
155
+ if url != url.trim() || url.chars().any(char::is_whitespace) {
156
+ return Err(TransportError::InvalidAddress(
157
+ "socket url must not contain whitespace",
158
+ ));
159
+ }
160
+ let Some((scheme, rest)) = url.split_once("://") else {
161
+ return Err(TransportError::InvalidAddress(
162
+ "socket url must include a scheme",
163
+ ));
164
+ };
165
+ match scheme.to_ascii_lowercase().as_str() {
166
+ "ws" | "wss" | "http" | "https" => {}
167
+ _ => {
168
+ return Err(TransportError::InvalidAddress(
169
+ "socket url scheme must be ws, wss, http, or https",
170
+ ));
171
+ }
172
+ }
173
+ if rest.is_empty() {
174
+ return Err(TransportError::InvalidAddress(
175
+ "socket url must include a host",
176
+ ));
177
+ }
178
+ let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
179
+ if authority.is_empty() {
180
+ return Err(TransportError::InvalidAddress(
181
+ "socket url must include a host",
182
+ ));
183
+ }
184
+ let host_port = authority.rsplit('@').next().unwrap_or("");
185
+ let host = if let Some(bracketed) = host_port.strip_prefix('[') {
186
+ let Some(end) = bracketed.find(']') else {
187
+ return Err(TransportError::InvalidAddress(
188
+ "socket url must include a host",
189
+ ));
190
+ };
191
+ &bracketed[..end]
192
+ } else {
193
+ host_port.split(':').next().unwrap_or("")
194
+ };
195
+ if host.is_empty() {
196
+ return Err(TransportError::InvalidAddress(
197
+ "socket url must include a host",
198
+ ));
199
+ }
200
+ Ok(())
201
+ }
202
+
203
+ #[cfg(test)]
204
+ mod tests {
205
+ use super::*;
206
+
207
+ fn assert_send<T: Send>() {}
208
+
209
+ #[test]
210
+ fn transports_are_send() {
211
+ assert_send::<StdioTransport>();
212
+ assert_send::<SocketTransport>();
213
+ assert_send::<TransportKind>();
214
+ assert_send::<TransportError>();
215
+ }
216
+
217
+ #[test]
218
+ fn stdio_accepts_explicit_program() {
219
+ let transport = StdioTransport::new("agent", vec!["--flag".into()]);
220
+ assert_eq!(transport.kind(), TransportKind::Stdio);
221
+ assert_eq!(transport.address_hint().as_deref(), Some("agent"));
222
+ transport.validate().unwrap();
223
+ }
224
+
225
+ #[test]
226
+ fn stdio_rejects_empty_program() {
227
+ for program in ["", " ", "\t\n"] {
228
+ let transport = StdioTransport::new(program, Vec::new());
229
+ assert_eq!(transport.address_hint(), None);
230
+ assert_eq!(
231
+ transport.validate().unwrap_err(),
232
+ TransportError::InvalidAddress("stdio program must not be empty")
233
+ );
234
+ }
235
+ }
236
+
237
+ #[test]
238
+ fn socket_accepts_supported_schemes_with_host() {
239
+ for url in [
240
+ "ws://bridge.local/session",
241
+ "wss://bridge.example.com:443/session",
242
+ "http://localhost:8080/bridge",
243
+ "https://example.com",
244
+ "ws://127.0.0.1:9000",
245
+ "wss://[::1]:9000/session",
246
+ "https://example.com/path?query=1#fragment",
247
+ ] {
248
+ let transport = SocketTransport::new(url);
249
+ assert_eq!(transport.kind(), TransportKind::Socket);
250
+ assert_eq!(transport.address_hint().as_deref(), Some(url));
251
+ transport.validate().unwrap();
252
+ }
253
+ }
254
+
255
+ #[test]
256
+ fn socket_scheme_match_is_case_insensitive() {
257
+ SocketTransport::new("WSS://bridge.example.com/session")
258
+ .validate()
259
+ .unwrap();
260
+ }
261
+
262
+ #[test]
263
+ fn socket_rejects_missing_or_unsupported_address() {
264
+ for url in [
265
+ "",
266
+ " ",
267
+ "bridge.example.com/session",
268
+ "ftp://bridge.example.com/session",
269
+ "file:///tmp/socket",
270
+ "ws://",
271
+ "wss://",
272
+ "https://",
273
+ "ws:///path-only",
274
+ "https:///path-only",
275
+ "ws://?query-only",
276
+ "ws://#fragment-only",
277
+ "ws://:8080/no-host",
278
+ "ws://bridge.example.com:8080/pa th",
279
+ " ws://bridge.example.com",
280
+ "ws://bridge.example.com ",
281
+ ] {
282
+ let transport = SocketTransport::new(url);
283
+ assert!(
284
+ transport.validate().is_err(),
285
+ "expected rejection for {url:?}"
286
+ );
287
+ }
288
+ }
289
+
290
+ #[test]
291
+ fn error_messages_never_echo_addresses() {
292
+ let secret = "super-secret-token-9f8e7d6c";
293
+ let transport = SocketTransport::new(format!("ftp://bridge.example.com/{secret}"));
294
+ let error = transport.validate().unwrap_err();
295
+ assert_eq!(
296
+ error,
297
+ TransportError::InvalidAddress("socket url scheme must be ws, wss, http, or https")
298
+ );
299
+ let rendered = format!("{error} {error:?}");
300
+ assert!(!rendered.contains(secret));
301
+ assert!(!rendered.contains("ftp://"));
302
+
303
+ let stdio_error = StdioTransport::new("", Vec::new()).validate().unwrap_err();
304
+ assert!(!format!("{stdio_error}").contains(secret));
305
+ }
306
+
307
+ #[test]
308
+ fn closed_and_unsupported_variants_stay_static() {
309
+ let closed = TransportError::Closed;
310
+ let unsupported = TransportError::Unsupported("socket bridge");
311
+ assert_eq!(format!("{closed}"), "transport is closed");
312
+ assert_eq!(
313
+ format!("{unsupported}"),
314
+ "unsupported transport: socket bridge"
315
+ );
316
+ }
317
+
318
+ #[test]
319
+ fn socket_hint_is_none_for_blank_address() {
320
+ for url in ["", " ", "\t\n"] {
321
+ assert_eq!(SocketTransport::new(url).address_hint(), None);
322
+ }
323
+ }
324
+
325
+ #[test]
326
+ fn stdio_hint_trims_program_name() {
327
+ let transport = StdioTransport::new(" agent ", Vec::new());
328
+ assert_eq!(transport.address_hint().as_deref(), Some("agent"));
329
+ }
330
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,149 @@
1
+ /** Typed mirrors of the Rust auth surface (`ccht::auth`) for web consumers.
2
+ *
3
+ * The conversation model stays in Rust/Wasm; these types only describe *who*
4
+ * may act. Transport, login ceremonies, credential storage, polling and
5
+ * product copy belong to the embedding application. Nothing here touches the
6
+ * DOM, performs network I/O, or spawns processes: effects run in the
7
+ * application backend and reach the browser through app-supplied async
8
+ * callbacks and explicit service keys.
9
+ */
10
+
11
+ /** Login state of one provider, mirroring `ccht::auth::AuthState`.
12
+ *
13
+ * The shape round-trips the Rust `snake_case` JSON byte-for-byte, so a state
14
+ * serialized by Rust deserializes here and vice versa:
15
+ * - `Unknown` becomes the string `"unknown"`.
16
+ * - `Unauthenticated` becomes the string `"unauthenticated"`.
17
+ * - `Authenticated { account }` becomes `{"authenticated": {"account": ...}}`,
18
+ * where `account` is a display-only label (string), `null`, or absent.
19
+ * The label is provenance for the UI and never an authentication proof.
20
+ */
21
+ export type AuthState = 'unknown' | 'unauthenticated' | { authenticated: { account?: string | null } };
22
+
23
+ /** Whether routine work may proceed without prompting for login first. Mirrors `AuthState::authenticated`. */
24
+ export function isAuthenticated(state: AuthState): boolean {
25
+ return typeof state === 'object' && state !== null && 'authenticated' in state;
26
+ }
27
+
28
+ /** Display-only account label (username, email, or subscription name), or null when absent. Never a secret. */
29
+ export function authAccount(state: AuthState): string | null {
30
+ if (typeof state === 'object' && state !== null && 'authenticated' in state) {
31
+ return state.authenticated.account ?? null;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ /** Whether a decoded JSON value has the exact Rust `AuthState` shape. Unknown fields inside the inner object are ignored, matching serde's default. */
37
+ export function isAuthState(value: unknown): value is AuthState {
38
+ try {
39
+ parseAuthState(value);
40
+ return true;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ /** Validate a decoded JSON value as the Rust `AuthState` shape. Throws a `TypeError` when the shape differs. */
47
+ export function parseAuthState(value: unknown): AuthState {
48
+ if (value === 'unknown' || value === 'unauthenticated') {
49
+ return value;
50
+ }
51
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
52
+ const keys = Object.keys(value);
53
+ if (keys.length === 1 && keys[0] === 'authenticated') {
54
+ const inner = (value as { authenticated: unknown }).authenticated;
55
+ if (typeof inner === 'object' && inner !== null && !Array.isArray(inner)) {
56
+ const account = (inner as { account?: unknown }).account;
57
+ if (account === undefined) {
58
+ return { authenticated: {} };
59
+ }
60
+ if (account === null || typeof account === 'string') {
61
+ return { authenticated: { account } };
62
+ }
63
+ }
64
+ }
65
+ }
66
+ throw new TypeError('invalid AuthState');
67
+ }
68
+
69
+ /** Device-code challenge as exchanged during a provider login ceremony.
70
+ *
71
+ * Field names stay `snake_case` to match the wire format applications pass
72
+ * between their backend and the browser. The backend remains authoritative:
73
+ * it issues the challenge, pins any provider-specific endpoint, and polls
74
+ * for completion. The browser only displays a validated challenge and never
75
+ * invents one.
76
+ */
77
+ export interface Challenge {
78
+ verification_url: string;
79
+ user_code: string;
80
+ }
81
+
82
+ const CHALLENGE_CODE_PATTERN = /^[A-Za-z0-9-]+$/;
83
+ const CHALLENGE_CODE_MAX_LENGTH = 64;
84
+
85
+ /** Whether a value is a renderable device-code challenge.
86
+ *
87
+ * Accepts any `https:` URL with a host (applications may narrow this
88
+ * further, for example by pinning the provider's device endpoint) and a
89
+ * non-empty code of up to 64 ASCII alphanumeric or `-` characters. URLs with
90
+ * embedded credentials are rejected. Never throws; validate before rendering
91
+ * a challenge as a link or code.
92
+ */
93
+ export function validateChallenge(value: unknown): value is Challenge {
94
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
95
+ return false;
96
+ }
97
+ const { verification_url, user_code } = value as Record<string, unknown>;
98
+ if (typeof verification_url !== 'string' || typeof user_code !== 'string') {
99
+ return false;
100
+ }
101
+ if (user_code.length < 1 || user_code.length > CHALLENGE_CODE_MAX_LENGTH) {
102
+ return false;
103
+ }
104
+ if (!CHALLENGE_CODE_PATTERN.test(user_code)) {
105
+ return false;
106
+ }
107
+ let url: URL;
108
+ try {
109
+ url = new URL(verification_url);
110
+ } catch {
111
+ return false;
112
+ }
113
+ if (url.protocol !== 'https:' || url.hostname === '') {
114
+ return false;
115
+ }
116
+ if (url.username !== '' || url.password !== '') {
117
+ return false;
118
+ }
119
+ return true;
120
+ }
121
+
122
+ /** Opaque credential, mirroring `ccht::auth::Credential`.
123
+ *
124
+ * Stores treat both fields as opaque. `account` is display-only; `secret`
125
+ * holds the raw secret bytes. Encode at the application boundary (for
126
+ * example with `TextEncoder` or base64) when the backing store needs text,
127
+ * and never write secrets to logs, errors, or fixtures.
128
+ */
129
+ export interface Credential {
130
+ account: string;
131
+ secret: Uint8Array;
132
+ }
133
+
134
+ /** Application-supplied credential persistence, mirroring `ccht::auth::CredentialsProvider`.
135
+ *
136
+ * One method triple per service key. Service keys are application-defined
137
+ * opaque strings (never ambient authority); native applications back this
138
+ * with the OS keychain and web applications with origin-scoped browser
139
+ * storage. Removing a missing entry succeeds. Implementations reject the
140
+ * promise when the backing store fails.
141
+ */
142
+ export interface CredentialsProvider {
143
+ /** Read stored credentials, if any, for a service key. */
144
+ readCredentials(service: string): Promise<Credential | null>;
145
+ /** Persist credentials for a service key, replacing any previous entry. */
146
+ writeCredentials(service: string, credential: Credential): Promise<void>;
147
+ /** Remove stored credentials for a service key. Missing entries are not an error. */
148
+ deleteCredentials(service: string): Promise<void>;
149
+ }
@@ -0,0 +1,201 @@
1
+ <!-- Product-neutral account connector. The application backend owns all effects
2
+ (starting device flows, polling logins, storing keys, signing out); this
3
+ component only renders state and forwards user intent through app-supplied
4
+ async callbacks. It never spawns agents, polls, or touches credentials. -->
5
+ <script lang="ts">
6
+ import {
7
+ authAccount,
8
+ isAuthenticated,
9
+ validateChallenge,
10
+ type AuthState,
11
+ type Challenge
12
+ } from '../auth.js';
13
+
14
+ interface AccountRef {
15
+ id: string;
16
+ label: string;
17
+ kind: 'chatgpt' | 'opencode_go' | (string & {});
18
+ }
19
+
20
+ let {
21
+ account,
22
+ state: authState = 'unknown' as AuthState,
23
+ busy = false,
24
+ challenge = null as Challenge | null,
25
+ onStart,
26
+ onCancel,
27
+ onKeySubmit,
28
+ onDisconnect
29
+ }: {
30
+ account: AccountRef;
31
+ state: AuthState;
32
+ busy: boolean;
33
+ challenge?: Challenge | null;
34
+ onStart: () => void | Promise<void>;
35
+ onCancel: () => void | Promise<void>;
36
+ onKeySubmit: (key: string) => void | Promise<void>;
37
+ onDisconnect?: () => void | Promise<void>;
38
+ } = $props();
39
+
40
+ let key = $state('');
41
+ let copyError = $state('');
42
+
43
+ // Renamed locally: a binding called `state` would shadow the `$state` rune analysis.
44
+ const authenticated: boolean = $derived(isAuthenticated(authState));
45
+ const accountName: string | null = $derived(authAccount(authState));
46
+ const validChallenge: Challenge | null = $derived(
47
+ challenge != null && validateChallenge(challenge) ? challenge : null
48
+ );
49
+ const isDeviceCodeAccount: boolean = $derived(account.kind === 'chatgpt');
50
+ const isKeyAccount: boolean = $derived(account.kind === 'opencode_go');
51
+ const keyInputId: string = $derived(`ccht-key-${account.id.replace(/[^a-zA-Z0-9_-]/g, '-')}`);
52
+ const canSubmitKey: boolean = $derived(key.trim().length >= 8 && !busy);
53
+
54
+ // Callbacks run application effects; the app surfaces their failures
55
+ // through its own operation state, so a rejection here stays silent.
56
+ function invoke(action: () => void | Promise<void>): void {
57
+ try {
58
+ const result = action();
59
+ if (result instanceof Promise) {
60
+ result.catch(() => {});
61
+ }
62
+ } catch {
63
+ // Handled by the application through its own operation state.
64
+ }
65
+ }
66
+
67
+ function submitKey(event: SubmitEvent): void {
68
+ event.preventDefault();
69
+ const value = key.trim();
70
+ // Clear immediately so the secret never lingers in component state.
71
+ key = '';
72
+ if (value.length < 8) {
73
+ return;
74
+ }
75
+ invoke(() => onKeySubmit(value));
76
+ }
77
+
78
+ async function copyAndContinue(): Promise<void> {
79
+ if (!validChallenge) {
80
+ return;
81
+ }
82
+ // Open during the click so browsers do not block the sign-in window after copying.
83
+ if (typeof window !== 'undefined') {
84
+ window.open(validChallenge.verification_url, '_blank', 'noopener,noreferrer');
85
+ }
86
+ try {
87
+ await navigator.clipboard.writeText(validChallenge.user_code);
88
+ copyError = '';
89
+ } catch {
90
+ copyError = 'Could not copy automatically. Copy the code above into the sign-in page.';
91
+ }
92
+ }
93
+ </script>
94
+
95
+ <section class="ccht-account" aria-label={`${account.label} connection`}>
96
+ {#if authenticated}
97
+ <p class="ccht-status" role="status">Connected{#if accountName} · {accountName}{/if}</p>
98
+ <button
99
+ type="button"
100
+ class="ccht-button ccht-disconnect"
101
+ disabled={busy}
102
+ onclick={() => invoke(onDisconnect ?? onCancel)}
103
+ >
104
+ Disconnect
105
+ </button>
106
+ {:else if authState === 'unknown'}
107
+ <p class="ccht-status" role="status">Checking {account.label} connection status…</p>
108
+ {#if busy}
109
+ <button type="button" class="ccht-button ccht-cancel" onclick={() => invoke(onCancel)}>
110
+ Cancel
111
+ </button>
112
+ {/if}
113
+ {:else if isDeviceCodeAccount}
114
+ {#if validChallenge}
115
+ <div class="ccht-challenge" aria-live="polite">
116
+ <p class="ccht-hint">Enter this code on the {account.label} sign-in page:</p>
117
+ <code class="ccht-code">{validChallenge.user_code}</code>
118
+ <button type="button" class="ccht-button" disabled={busy} onclick={() => void copyAndContinue()}>
119
+ Copy code and continue
120
+ </button>
121
+ <p>
122
+ <a
123
+ class="ccht-link"
124
+ href={validChallenge.verification_url}
125
+ target="_blank"
126
+ rel="noopener noreferrer"
127
+ >
128
+ Continue to {account.label}
129
+ </a>
130
+ </p>
131
+ {#if copyError}<p class="ccht-error" role="alert">{copyError}</p>{/if}
132
+ </div>
133
+ {:else if busy}
134
+ <p class="ccht-status" role="status">Starting account connection…</p>
135
+ {:else}
136
+ <p class="ccht-hint">Sign in with your {account.label} account.</p>
137
+ <button type="button" class="ccht-button" onclick={() => invoke(onStart)}>
138
+ Sign in to {account.label}
139
+ </button>
140
+ {/if}
141
+ {#if busy}
142
+ <button type="button" class="ccht-button ccht-cancel" onclick={() => invoke(onCancel)}>
143
+ Cancel
144
+ </button>
145
+ {/if}
146
+ {:else if isKeyAccount}
147
+ <form class="ccht-keyform" onsubmit={submitKey}>
148
+ <label class="ccht-label" for={keyInputId}>
149
+ {account.label} account key
150
+ <input
151
+ id={keyInputId}
152
+ class="ccht-input"
153
+ type="password"
154
+ autocomplete="off"
155
+ spellcheck="false"
156
+ maxlength="4096"
157
+ bind:value={key}
158
+ disabled={busy}
159
+ />
160
+ </label>
161
+ <button type="submit" class="ccht-button" disabled={!canSubmitKey}>
162
+ Connect {account.label}
163
+ </button>
164
+ </form>
165
+ {#if busy}
166
+ <button type="button" class="ccht-button ccht-cancel" onclick={() => invoke(onCancel)}>
167
+ Cancel
168
+ </button>
169
+ {/if}
170
+ {:else}
171
+ <p class="ccht-status" role="status">Not connected</p>
172
+ <p class="ccht-hint">Sign in with your {account.label} account.</p>
173
+ <button type="button" class="ccht-button" disabled={busy} onclick={() => invoke(onStart)}>
174
+ Sign in to {account.label}
175
+ </button>
176
+ {#if busy}
177
+ <button type="button" class="ccht-button ccht-cancel" onclick={() => invoke(onCancel)}>
178
+ Cancel
179
+ </button>
180
+ {/if}
181
+ {/if}
182
+ </section>
183
+
184
+ <style>
185
+ .ccht-account {
186
+ color: var(--ccht-fg, inherit);
187
+ }
188
+ .ccht-status,
189
+ .ccht-hint {
190
+ color: var(--ccht-muted, inherit);
191
+ }
192
+ .ccht-error {
193
+ color: var(--ccht-error, #b91c1c);
194
+ }
195
+ .ccht-code {
196
+ background-color: var(--ccht-code-bg, transparent);
197
+ }
198
+ .ccht-link {
199
+ color: var(--ccht-accent, inherit);
200
+ }
201
+ </style>