codex-agent-view 0.4.7 → 0.5.0
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.
- package/.codex-plugin/plugin.json +4 -3
- package/README.ko.md +36 -34
- package/README.md +33 -31
- package/bin/codex-agent-view.mjs +340 -25
- package/package.json +2 -2
- package/public/app.js +351 -41
- package/public/index.html +1 -1
- package/skills/codex-agent-view/SKILL.md +22 -201
- package/src/runtime/server.mjs +371 -14
- package/skills/show-agents/SKILL.md +0 -112
- package/skills/show-agents/agents/openai.yaml +0 -6
package/public/app.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
const API_STATE_URL = "/api/state";
|
|
2
|
+
const API_EXCHANGE_URL = "/api/viewer/exchange";
|
|
2
3
|
const POLL_INTERVAL_MS = 2_000;
|
|
3
4
|
const SESSION_TOKEN_KEY = "codex-agent-view-access-token";
|
|
5
|
+
const RECOVERY_CREDENTIAL_KEY = "codex-agent-view-recovery-credential";
|
|
6
|
+
const RECOVERY_HEADER = "x-codex-agent-view-recovery";
|
|
7
|
+
const ACCESS_HEADER = "x-codex-agent-view-access";
|
|
8
|
+
const ACCESS_CLIENT_TTL_MS = 15 * 60 * 1_000;
|
|
9
|
+
const RECOVERY_CLIENT_TTL_MS = 30 * 60 * 1_000;
|
|
10
|
+
const RECOVERY_REFRESH_THRESHOLD_MS = 5 * 60 * 1_000;
|
|
4
11
|
const EXCLUDED_SESSION_KEY = "codex-agent-view-excluded-session";
|
|
5
12
|
const LANGUAGE_KEY = "codex-agent-view-language";
|
|
6
13
|
const SUPPORTED_LANGUAGES = new Set(["en", "ko", "es"]);
|
|
@@ -14,6 +21,7 @@ const KNOWN_STATUSES = new Set([
|
|
|
14
21
|
"unknown",
|
|
15
22
|
]);
|
|
16
23
|
const VIEWER_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
24
|
+
const SIGNED_CREDENTIAL_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$/;
|
|
17
25
|
const CANONICAL_SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
18
26
|
|
|
19
27
|
const MESSAGES = Object.freeze({
|
|
@@ -62,7 +70,7 @@ const MESSAGES = Object.freeze({
|
|
|
62
70
|
completionNotObservedExplanation: "No end signal was received for this item, so completion cannot be confirmed.",
|
|
63
71
|
staleExplanation: "This status is out of date and no end signal was received. Completion cannot be confirmed.",
|
|
64
72
|
interruptedExplanation: "This activity was still open when the work ended; its own completion signal was not observed.",
|
|
65
|
-
connectingCopy: "Connecting to the Codex
|
|
73
|
+
connectingCopy: "Connecting this browser to the local Codex monitor on this device.",
|
|
66
74
|
sessionListAria: "Codex work list",
|
|
67
75
|
privacyPrompt: "This view shows only a shortened request summary; it never displays the full request or tool inputs.",
|
|
68
76
|
privacyLocal: "Data is read only from the local monitor on this device.",
|
|
@@ -89,9 +97,13 @@ const MESSAGES = Object.freeze({
|
|
|
89
97
|
retry: "Retry connection",
|
|
90
98
|
retryAuthentication: "Try this tab again",
|
|
91
99
|
checkAuthentication: "Check authentication again",
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
100
|
+
reconnectAuthentication: "Reconnect securely",
|
|
101
|
+
recoveryAvailableTitle: "Reconnect this live view",
|
|
102
|
+
recoveryAvailableStep: "This tab has a recent, read-only recovery credential.",
|
|
103
|
+
recoveryAvailableNote: "Select Reconnect securely to restore this browser tab without reopening Codex Agent View.",
|
|
104
|
+
recoveryTitle: "New authentication is required",
|
|
105
|
+
recoveryStep: "Return to the Codex app and run @codex-agent-view again.",
|
|
106
|
+
recoveryNote: "Codex Agent View opens a newly authenticated page in your default browser. For safety, this page cannot create or replace its own authentication.",
|
|
95
107
|
resultsFiltered: "Showing {visible} of {total}",
|
|
96
108
|
resultsTotal: "{count} work items",
|
|
97
109
|
searchEmptyTitle: "No matching results.",
|
|
@@ -111,7 +123,7 @@ const MESSAGES = Object.freeze({
|
|
|
111
123
|
disconnectedTitle: "Local status disconnected; retrying.",
|
|
112
124
|
authTitle: "This live view cannot be authenticated.",
|
|
113
125
|
retryWithState: "Reconnecting automatically every 2 seconds while keeping the last good state visible.",
|
|
114
|
-
retryWithoutState: "Reconnecting automatically every 2 seconds. You can leave this
|
|
126
|
+
retryWithoutState: "Reconnecting automatically every 2 seconds. You can leave this browser tab open or retry immediately.",
|
|
115
127
|
missingToken: "This tab does not have the authentication needed to display live work.",
|
|
116
128
|
expiredToken: "This tab's live-view authentication was rejected or is no longer valid.",
|
|
117
129
|
requestFailed: "Status request failed ({status})",
|
|
@@ -201,7 +213,7 @@ const MESSAGES = Object.freeze({
|
|
|
201
213
|
completionNotObservedExplanation: "이 항목의 종료 신호를 받지 못해 완료 여부를 확정할 수 없습니다.",
|
|
202
214
|
staleExplanation: "상태 정보가 오래되었고 종료 신호를 받지 못했습니다. 완료 여부를 확정할 수 없습니다.",
|
|
203
215
|
interruptedExplanation: "전체 작업이 끝날 때 이 활동이 열린 상태였습니다. 이 활동 자체의 완료 신호는 확인되지 않았습니다.",
|
|
204
|
-
connectingCopy: "
|
|
216
|
+
connectingCopy: "이 브라우저를 기기의 로컬 Codex 모니터에 연결하고 있습니다.",
|
|
205
217
|
sessionListAria: "Codex 작업 목록",
|
|
206
218
|
privacyPrompt: "이 화면은 짧게 줄인 요청 요약만 표시하며, 전체 요청이나 도구 입력은 표시하지 않습니다.",
|
|
207
219
|
privacyLocal: "데이터는 이 기기의 로컬 모니터에서만 읽습니다.",
|
|
@@ -228,9 +240,13 @@ const MESSAGES = Object.freeze({
|
|
|
228
240
|
retry: "연결 다시 시도",
|
|
229
241
|
retryAuthentication: "이 탭에서 다시 시도",
|
|
230
242
|
checkAuthentication: "인증 정보 다시 확인",
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
243
|
+
reconnectAuthentication: "안전하게 다시 연결",
|
|
244
|
+
recoveryAvailableTitle: "이 실시간 화면 다시 연결",
|
|
245
|
+
recoveryAvailableStep: "이 탭에 최근 발급된 읽기 전용 복구 인증 정보가 있습니다.",
|
|
246
|
+
recoveryAvailableNote: "안전하게 다시 연결을 누르면 Codex Agent View를 다시 열지 않고 이 브라우저 탭의 접근을 복구합니다.",
|
|
247
|
+
recoveryTitle: "새 인증이 필요합니다",
|
|
248
|
+
recoveryStep: "Codex 앱으로 돌아가 @codex-agent-view를 다시 실행하세요.",
|
|
249
|
+
recoveryNote: "Codex Agent View가 기본 브라우저에 새 인증 화면을 엽니다. 안전을 위해 이 페이지 자체에서는 인증 정보를 만들거나 교체할 수 없습니다.",
|
|
234
250
|
resultsFiltered: "전체 {total}개 중 {visible}개 표시",
|
|
235
251
|
resultsTotal: "작업 {count}개",
|
|
236
252
|
searchEmptyTitle: "검색 결과가 없습니다.",
|
|
@@ -250,7 +266,7 @@ const MESSAGES = Object.freeze({
|
|
|
250
266
|
disconnectedTitle: "로컬 상태 연결이 끊겨 다시 시도 중입니다.",
|
|
251
267
|
authTitle: "이 실시간 화면을 인증할 수 없습니다.",
|
|
252
268
|
retryWithState: "2초마다 자동으로 다시 연결합니다. 마지막 정상 상태를 계속 표시합니다.",
|
|
253
|
-
retryWithoutState: "2초마다 자동으로 다시 연결합니다.
|
|
269
|
+
retryWithoutState: "2초마다 자동으로 다시 연결합니다. 이 브라우저 탭을 열어 두거나 지금 바로 다시 시도할 수 있습니다.",
|
|
254
270
|
missingToken: "이 탭에는 실시간 작업을 표시하는 데 필요한 인증 정보가 없습니다.",
|
|
255
271
|
expiredToken: "이 탭의 실시간 화면 인증이 거부되었거나 더 이상 유효하지 않습니다.",
|
|
256
272
|
requestFailed: "상태 요청 실패 ({status})",
|
|
@@ -340,7 +356,7 @@ const MESSAGES = Object.freeze({
|
|
|
340
356
|
completionNotObservedExplanation: "No se recibió una señal de fin para este elemento, por lo que no se puede confirmar que haya terminado.",
|
|
341
357
|
staleExplanation: "El estado está desactualizado y no se recibió una señal de fin. No se puede confirmar que haya terminado.",
|
|
342
358
|
interruptedExplanation: "Esta actividad seguía abierta cuando terminó el trabajo; no se observó su propia señal de finalización.",
|
|
343
|
-
connectingCopy: "Conectando al
|
|
359
|
+
connectingCopy: "Conectando este navegador al monitor local de Codex en este dispositivo.",
|
|
344
360
|
sessionListAria: "Lista de trabajos de Codex",
|
|
345
361
|
privacyPrompt: "Esta vista solo muestra un resumen abreviado de la solicitud; nunca muestra la solicitud completa ni las entradas de herramientas.",
|
|
346
362
|
privacyLocal: "Los datos se leen únicamente del monitor local de este dispositivo.",
|
|
@@ -367,9 +383,13 @@ const MESSAGES = Object.freeze({
|
|
|
367
383
|
retry: "Reintentar conexión",
|
|
368
384
|
retryAuthentication: "Reintentar en esta pestaña",
|
|
369
385
|
checkAuthentication: "Volver a comprobar la autenticación",
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
386
|
+
reconnectAuthentication: "Reconectar de forma segura",
|
|
387
|
+
recoveryAvailableTitle: "Reconectar esta vista en vivo",
|
|
388
|
+
recoveryAvailableStep: "Esta pestaña tiene una credencial reciente de recuperación de solo lectura.",
|
|
389
|
+
recoveryAvailableNote: "Selecciona Reconectar de forma segura para recuperar esta pestaña sin volver a abrir Codex Agent View.",
|
|
390
|
+
recoveryTitle: "Se necesita una autenticación nueva",
|
|
391
|
+
recoveryStep: "Vuelve a la aplicación Codex y ejecuta @codex-agent-view de nuevo.",
|
|
392
|
+
recoveryNote: "Codex Agent View abre una página recién autenticada en tu navegador predeterminado. Por seguridad, esta página no puede crear ni reemplazar su propia autenticación.",
|
|
373
393
|
resultsFiltered: "Mostrando {visible} de {total}",
|
|
374
394
|
resultsTotal: "{count} trabajos",
|
|
375
395
|
searchEmptyTitle: "No hay resultados.",
|
|
@@ -389,7 +409,7 @@ const MESSAGES = Object.freeze({
|
|
|
389
409
|
disconnectedTitle: "Se perdió la conexión local; reintentando.",
|
|
390
410
|
authTitle: "No se puede autenticar esta vista en vivo.",
|
|
391
411
|
retryWithState: "Se reconecta automáticamente cada 2 segundos y mantiene visible el último estado válido.",
|
|
392
|
-
retryWithoutState: "Se reconecta automáticamente cada 2 segundos. Puedes dejar esta
|
|
412
|
+
retryWithoutState: "Se reconecta automáticamente cada 2 segundos. Puedes dejar abierta esta pestaña o reintentar de inmediato.",
|
|
393
413
|
missingToken: "Esta pestaña no tiene la autenticación necesaria para mostrar el trabajo en vivo.",
|
|
394
414
|
expiredToken: "La autenticación de esta pestaña fue rechazada o ya no es válida.",
|
|
395
415
|
requestFailed: "Falló la solicitud de estado ({status})",
|
|
@@ -520,14 +540,20 @@ function t(key, replacements = {}) {
|
|
|
520
540
|
|
|
521
541
|
function isExactLiveFragment(entries) {
|
|
522
542
|
const tokenEntries = entries.filter(([key]) => key === "token");
|
|
543
|
+
const grantEntries = entries.filter(([key]) => key === "grant");
|
|
523
544
|
const excludeEntries = entries.filter(([key]) => key === "exclude");
|
|
524
|
-
|
|
545
|
+
const validGrant =
|
|
546
|
+
entries.length === 1 &&
|
|
547
|
+
grantEntries.length === 1 &&
|
|
548
|
+
SIGNED_CREDENTIAL_PATTERN.test(grantEntries[0][1]) &&
|
|
549
|
+
grantEntries[0][1].length <= 1_024;
|
|
550
|
+
const validLegacyToken =
|
|
525
551
|
entries.length === tokenEntries.length + excludeEntries.length &&
|
|
526
552
|
tokenEntries.length === 1 &&
|
|
527
553
|
excludeEntries.length <= 1 &&
|
|
528
554
|
VIEWER_TOKEN_PATTERN.test(tokenEntries[0][1]) &&
|
|
529
|
-
(excludeEntries.length === 0 || CANONICAL_SESSION_ID_PATTERN.test(excludeEntries[0][1]))
|
|
530
|
-
|
|
555
|
+
(excludeEntries.length === 0 || CANONICAL_SESSION_ID_PATTERN.test(excludeEntries[0][1]));
|
|
556
|
+
return validGrant || validLegacyToken;
|
|
531
557
|
}
|
|
532
558
|
|
|
533
559
|
function consumeLiveContext() {
|
|
@@ -536,8 +562,11 @@ function consumeLiveContext() {
|
|
|
536
562
|
? [...new URLSearchParams(window.location.hash.slice(1)).entries()]
|
|
537
563
|
: [];
|
|
538
564
|
const validFragment = hasFragment && isExactLiveFragment(entries);
|
|
565
|
+
const fragmentGrant = validFragment
|
|
566
|
+
? entries.find(([key]) => key === "grant")?.[1] || ""
|
|
567
|
+
: "";
|
|
539
568
|
const fragmentToken = validFragment
|
|
540
|
-
? entries.find(([key]) => key === "token")[1]
|
|
569
|
+
? entries.find(([key]) => key === "token")?.[1] || ""
|
|
541
570
|
: "";
|
|
542
571
|
const fragmentExclude = validFragment
|
|
543
572
|
? entries.find(([key]) => key === "exclude")?.[1] || ""
|
|
@@ -555,13 +584,19 @@ function consumeLiveContext() {
|
|
|
555
584
|
let excludedSessionId = fragmentExclude;
|
|
556
585
|
try {
|
|
557
586
|
if (validFragment) {
|
|
587
|
+
window.sessionStorage.removeItem(RECOVERY_CREDENTIAL_KEY);
|
|
588
|
+
}
|
|
589
|
+
if (fragmentGrant) {
|
|
590
|
+
window.sessionStorage.removeItem(SESSION_TOKEN_KEY);
|
|
591
|
+
window.sessionStorage.removeItem(EXCLUDED_SESSION_KEY);
|
|
592
|
+
} else if (validFragment && fragmentToken) {
|
|
558
593
|
window.sessionStorage.setItem(SESSION_TOKEN_KEY, fragmentToken);
|
|
559
594
|
if (fragmentExclude) {
|
|
560
595
|
window.sessionStorage.setItem(EXCLUDED_SESSION_KEY, fragmentExclude);
|
|
561
596
|
} else {
|
|
562
597
|
window.sessionStorage.removeItem(EXCLUDED_SESSION_KEY);
|
|
563
598
|
}
|
|
564
|
-
} else {
|
|
599
|
+
} else if (!fragmentGrant) {
|
|
565
600
|
token = window.sessionStorage.getItem(SESSION_TOKEN_KEY)?.trim() || "";
|
|
566
601
|
excludedSessionId = window.sessionStorage.getItem(EXCLUDED_SESSION_KEY)?.trim() || "";
|
|
567
602
|
}
|
|
@@ -572,13 +607,205 @@ function consumeLiveContext() {
|
|
|
572
607
|
|
|
573
608
|
return {
|
|
574
609
|
accessToken: VIEWER_TOKEN_PATTERN.test(token) ? token : "",
|
|
610
|
+
bootstrapCredential: SIGNED_CREDENTIAL_PATTERN.test(fragmentGrant)
|
|
611
|
+
? fragmentGrant
|
|
612
|
+
: "",
|
|
575
613
|
excludedSessionId: CANONICAL_SESSION_ID_PATTERN.test(excludedSessionId)
|
|
576
614
|
? excludedSessionId
|
|
577
615
|
: "",
|
|
578
616
|
};
|
|
579
617
|
}
|
|
580
618
|
|
|
581
|
-
let { accessToken, excludedSessionId } = consumeLiveContext();
|
|
619
|
+
let { accessToken, bootstrapCredential, excludedSessionId } = consumeLiveContext();
|
|
620
|
+
let recoveredAccessToken = "";
|
|
621
|
+
let authenticationExchangeInFlight = false;
|
|
622
|
+
|
|
623
|
+
function readRecoveryCredential() {
|
|
624
|
+
try {
|
|
625
|
+
const raw = window.sessionStorage.getItem(RECOVERY_CREDENTIAL_KEY);
|
|
626
|
+
if (!raw) return "";
|
|
627
|
+
const value = JSON.parse(raw);
|
|
628
|
+
if (
|
|
629
|
+
value === null ||
|
|
630
|
+
typeof value !== "object" ||
|
|
631
|
+
Array.isArray(value) ||
|
|
632
|
+
!SIGNED_CREDENTIAL_PATTERN.test(value.credential) ||
|
|
633
|
+
value.credential.length > 1_024 ||
|
|
634
|
+
!Number.isSafeInteger(value.expires_at_ms) ||
|
|
635
|
+
value.expires_at_ms <= Date.now()
|
|
636
|
+
) {
|
|
637
|
+
window.sessionStorage.removeItem(RECOVERY_CREDENTIAL_KEY);
|
|
638
|
+
return "";
|
|
639
|
+
}
|
|
640
|
+
return value.credential;
|
|
641
|
+
} catch {
|
|
642
|
+
return "";
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function persistRecoveryCredential(
|
|
647
|
+
credential,
|
|
648
|
+
expiresInMs = RECOVERY_CLIENT_TTL_MS,
|
|
649
|
+
{ force = false } = {},
|
|
650
|
+
) {
|
|
651
|
+
if (!SIGNED_CREDENTIAL_PATTERN.test(credential) || credential.length > 1_024) {
|
|
652
|
+
return false;
|
|
653
|
+
}
|
|
654
|
+
try {
|
|
655
|
+
if (!force) {
|
|
656
|
+
const existingRaw = window.sessionStorage.getItem(RECOVERY_CREDENTIAL_KEY);
|
|
657
|
+
if (existingRaw) {
|
|
658
|
+
const existing = JSON.parse(existingRaw);
|
|
659
|
+
if (
|
|
660
|
+
existing !== null &&
|
|
661
|
+
typeof existing === "object" &&
|
|
662
|
+
SIGNED_CREDENTIAL_PATTERN.test(existing.credential) &&
|
|
663
|
+
Number.isSafeInteger(existing.expires_at_ms) &&
|
|
664
|
+
existing.expires_at_ms - Date.now() > RECOVERY_REFRESH_THRESHOLD_MS
|
|
665
|
+
) {
|
|
666
|
+
return false;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
window.sessionStorage.setItem(RECOVERY_CREDENTIAL_KEY, JSON.stringify({
|
|
671
|
+
credential,
|
|
672
|
+
expires_at_ms: Date.now() + Math.min(expiresInMs, RECOVERY_CLIENT_TTL_MS),
|
|
673
|
+
}));
|
|
674
|
+
return true;
|
|
675
|
+
} catch {
|
|
676
|
+
// A valid live view remains usable when origin storage is unavailable.
|
|
677
|
+
return false;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function storeRecoveryCredential(response) {
|
|
682
|
+
const credential = response.headers?.get(RECOVERY_HEADER) || "";
|
|
683
|
+
persistRecoveryCredential(credential);
|
|
684
|
+
return credential;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function refreshRecoveredAccess(response, stateAccessCredential, recoveryCredential) {
|
|
688
|
+
const credential = response.headers?.get(ACCESS_HEADER) || "";
|
|
689
|
+
if (SIGNED_CREDENTIAL_PATTERN.test(credential) && credential.length <= 1_024) {
|
|
690
|
+
recoveredAccessToken = credential;
|
|
691
|
+
if (
|
|
692
|
+
stateAccessCredential === accessToken &&
|
|
693
|
+
VIEWER_TOKEN_PATTERN.test(accessToken) &&
|
|
694
|
+
SIGNED_CREDENTIAL_PATTERN.test(recoveryCredential) &&
|
|
695
|
+
recoveryCredential.length <= 1_024
|
|
696
|
+
) {
|
|
697
|
+
accessToken = "";
|
|
698
|
+
try {
|
|
699
|
+
window.sessionStorage.removeItem(SESSION_TOKEN_KEY);
|
|
700
|
+
window.sessionStorage.removeItem(EXCLUDED_SESSION_KEY);
|
|
701
|
+
} catch {
|
|
702
|
+
// The signed credentials remain usable when storage is unavailable.
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function clearRejectedViewerToken() {
|
|
709
|
+
accessToken = "";
|
|
710
|
+
recoveredAccessToken = "";
|
|
711
|
+
try {
|
|
712
|
+
window.sessionStorage.removeItem(SESSION_TOKEN_KEY);
|
|
713
|
+
} catch {
|
|
714
|
+
// Storage can be unavailable in hardened browser contexts.
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function clearRecoveryCredential() {
|
|
719
|
+
try {
|
|
720
|
+
window.sessionStorage.removeItem(RECOVERY_CREDENTIAL_KEY);
|
|
721
|
+
} catch {
|
|
722
|
+
// Storage can be unavailable in hardened browser contexts.
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function validateExchangePayload(payload) {
|
|
727
|
+
return Boolean(
|
|
728
|
+
payload !== null &&
|
|
729
|
+
typeof payload === "object" &&
|
|
730
|
+
!Array.isArray(payload) &&
|
|
731
|
+
Object.keys(payload).sort().join(",") ===
|
|
732
|
+
"access_credential,access_expires_in_ms,excluded_session_id,recovery_credential,recovery_expires_in_ms,status" &&
|
|
733
|
+
payload.status === "exchanged" &&
|
|
734
|
+
SIGNED_CREDENTIAL_PATTERN.test(payload.access_credential) &&
|
|
735
|
+
payload.access_credential.length <= 1_024 &&
|
|
736
|
+
Number.isSafeInteger(payload.access_expires_in_ms) &&
|
|
737
|
+
payload.access_expires_in_ms > 0 &&
|
|
738
|
+
payload.access_expires_in_ms <= ACCESS_CLIENT_TTL_MS &&
|
|
739
|
+
SIGNED_CREDENTIAL_PATTERN.test(payload.recovery_credential) &&
|
|
740
|
+
payload.recovery_credential.length <= 1_024 &&
|
|
741
|
+
Number.isSafeInteger(payload.recovery_expires_in_ms) &&
|
|
742
|
+
payload.recovery_expires_in_ms > 0 &&
|
|
743
|
+
payload.recovery_expires_in_ms <= RECOVERY_CLIENT_TTL_MS &&
|
|
744
|
+
(
|
|
745
|
+
payload.excluded_session_id === null ||
|
|
746
|
+
(
|
|
747
|
+
typeof payload.excluded_session_id === "string" &&
|
|
748
|
+
CANONICAL_SESSION_ID_PATTERN.test(payload.excluded_session_id)
|
|
749
|
+
)
|
|
750
|
+
)
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
async function exchangeViewerCredential(credential, { source }) {
|
|
755
|
+
if (
|
|
756
|
+
authenticationExchangeInFlight ||
|
|
757
|
+
!SIGNED_CREDENTIAL_PATTERN.test(credential) ||
|
|
758
|
+
credential.length > 1_024
|
|
759
|
+
) {
|
|
760
|
+
return false;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
authenticationExchangeInFlight = true;
|
|
764
|
+
try {
|
|
765
|
+
const response = await fetch(API_EXCHANGE_URL, {
|
|
766
|
+
body: JSON.stringify({ credential }),
|
|
767
|
+
cache: "no-store",
|
|
768
|
+
credentials: "same-origin",
|
|
769
|
+
headers: {
|
|
770
|
+
Accept: "application/json",
|
|
771
|
+
"Content-Type": "application/json",
|
|
772
|
+
},
|
|
773
|
+
method: "POST",
|
|
774
|
+
});
|
|
775
|
+
if (!response.ok) {
|
|
776
|
+
await response.body?.cancel();
|
|
777
|
+
if (
|
|
778
|
+
(response.status === 401 || response.status === 403 || response.status === 409)
|
|
779
|
+
) {
|
|
780
|
+
if (source === "recovery") {
|
|
781
|
+
clearRecoveryCredential();
|
|
782
|
+
} else if (source === "bootstrap") {
|
|
783
|
+
bootstrapCredential = "";
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
throw new Error(t("requestFailed", { status: response.status }));
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const payload = await response.json();
|
|
790
|
+
if (!validateExchangePayload(payload)) {
|
|
791
|
+
throw new Error(t("invalidState"));
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
recoveredAccessToken = payload.access_credential;
|
|
795
|
+
if (source === "bootstrap") {
|
|
796
|
+
bootstrapCredential = "";
|
|
797
|
+
}
|
|
798
|
+
excludedSessionId = payload.excluded_session_id || "";
|
|
799
|
+
persistRecoveryCredential(
|
|
800
|
+
payload.recovery_credential,
|
|
801
|
+
payload.recovery_expires_in_ms,
|
|
802
|
+
{ force: true },
|
|
803
|
+
);
|
|
804
|
+
return true;
|
|
805
|
+
} finally {
|
|
806
|
+
authenticationExchangeInFlight = false;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
582
809
|
|
|
583
810
|
const elements = Object.freeze({
|
|
584
811
|
connectionStatus: document.querySelector("#connection-status"),
|
|
@@ -1272,27 +1499,37 @@ function setStateMessage(kind, title, description, retryMode = "") {
|
|
|
1272
1499
|
copy.textContent = description;
|
|
1273
1500
|
elements.stateMessage.append(heading, copy);
|
|
1274
1501
|
|
|
1502
|
+
const recoveryAvailable = retryMode === "authentication" && Boolean(
|
|
1503
|
+
accessToken || recoveredAccessToken || bootstrapCredential || readRecoveryCredential(),
|
|
1504
|
+
);
|
|
1505
|
+
|
|
1275
1506
|
if (retryMode === "authentication") {
|
|
1276
1507
|
const recovery = document.createElement("div");
|
|
1277
1508
|
recovery.className = "recovery-guidance";
|
|
1278
1509
|
|
|
1279
1510
|
const recoveryTitle = document.createElement("strong");
|
|
1280
|
-
recoveryTitle.textContent = t(
|
|
1511
|
+
recoveryTitle.textContent = t(
|
|
1512
|
+
recoveryAvailable ? "recoveryAvailableTitle" : "recoveryTitle",
|
|
1513
|
+
);
|
|
1281
1514
|
const recoveryStep = document.createElement("p");
|
|
1282
|
-
recoveryStep.textContent = t(
|
|
1515
|
+
recoveryStep.textContent = t(
|
|
1516
|
+
recoveryAvailable ? "recoveryAvailableStep" : "recoveryStep",
|
|
1517
|
+
);
|
|
1283
1518
|
const recoveryNote = document.createElement("p");
|
|
1284
1519
|
recoveryNote.className = "recovery-note";
|
|
1285
|
-
recoveryNote.textContent = t(
|
|
1520
|
+
recoveryNote.textContent = t(
|
|
1521
|
+
recoveryAvailable ? "recoveryAvailableNote" : "recoveryNote",
|
|
1522
|
+
);
|
|
1286
1523
|
recovery.append(recoveryTitle, recoveryStep, recoveryNote);
|
|
1287
1524
|
elements.stateMessage.append(recovery);
|
|
1288
1525
|
}
|
|
1289
1526
|
|
|
1290
|
-
if (retryMode) {
|
|
1527
|
+
if (retryMode && (retryMode !== "authentication" || recoveryAvailable)) {
|
|
1291
1528
|
const retry = document.createElement("button");
|
|
1292
1529
|
retry.type = "button";
|
|
1293
1530
|
retry.className = "retry-button";
|
|
1294
1531
|
retry.textContent = retryMode === "authentication"
|
|
1295
|
-
? (accessToken ? t("retryAuthentication") : t("
|
|
1532
|
+
? (accessToken ? t("retryAuthentication") : t("reconnectAuthentication"))
|
|
1296
1533
|
: t("retry");
|
|
1297
1534
|
retry.addEventListener(
|
|
1298
1535
|
"click",
|
|
@@ -1302,12 +1539,30 @@ function setStateMessage(kind, title, description, retryMode = "") {
|
|
|
1302
1539
|
}
|
|
1303
1540
|
}
|
|
1304
1541
|
|
|
1305
|
-
function retryAuthentication() {
|
|
1542
|
+
async function retryAuthentication() {
|
|
1306
1543
|
const refreshedContext = consumeLiveContext();
|
|
1307
|
-
|
|
1308
|
-
|
|
1544
|
+
if (refreshedContext.accessToken) {
|
|
1545
|
+
accessToken = refreshedContext.accessToken;
|
|
1546
|
+
excludedSessionId = refreshedContext.excludedSessionId;
|
|
1547
|
+
}
|
|
1548
|
+
if (refreshedContext.bootstrapCredential) {
|
|
1549
|
+
bootstrapCredential = refreshedContext.bootstrapCredential;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
if (accessToken) {
|
|
1553
|
+
viewState.authenticationFailed = false;
|
|
1554
|
+
viewState.canRetry = true;
|
|
1555
|
+
viewState.errorKey = "";
|
|
1556
|
+
viewState.errorMessage = "";
|
|
1557
|
+
setConnectionStatus("connecting");
|
|
1558
|
+
render();
|
|
1559
|
+
await refreshState();
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1309
1562
|
|
|
1310
|
-
|
|
1563
|
+
const credential = bootstrapCredential || readRecoveryCredential();
|
|
1564
|
+
const source = bootstrapCredential ? "bootstrap" : "recovery";
|
|
1565
|
+
if (!credential) {
|
|
1311
1566
|
viewState.hasLoaded = true;
|
|
1312
1567
|
viewState.canRetry = false;
|
|
1313
1568
|
viewState.authenticationFailed = true;
|
|
@@ -1319,12 +1574,28 @@ function retryAuthentication() {
|
|
|
1319
1574
|
}
|
|
1320
1575
|
|
|
1321
1576
|
viewState.authenticationFailed = false;
|
|
1322
|
-
viewState.canRetry =
|
|
1577
|
+
viewState.canRetry = false;
|
|
1323
1578
|
viewState.errorKey = "";
|
|
1324
1579
|
viewState.errorMessage = "";
|
|
1325
1580
|
setConnectionStatus("connecting");
|
|
1326
1581
|
render();
|
|
1327
|
-
|
|
1582
|
+
|
|
1583
|
+
try {
|
|
1584
|
+
const exchanged = await exchangeViewerCredential(credential, { source });
|
|
1585
|
+
if (!exchanged) return;
|
|
1586
|
+
viewState.canRetry = true;
|
|
1587
|
+
await refreshState();
|
|
1588
|
+
} catch (error) {
|
|
1589
|
+
viewState.hasLoaded = true;
|
|
1590
|
+
viewState.canRetry = false;
|
|
1591
|
+
viewState.authenticationFailed = true;
|
|
1592
|
+
viewState.errorKey = "expiredToken";
|
|
1593
|
+
viewState.errorMessage = error instanceof Error
|
|
1594
|
+
? error.message
|
|
1595
|
+
: t("unknownConnectionError");
|
|
1596
|
+
setConnectionStatus("error", t("authenticationRequired"));
|
|
1597
|
+
render();
|
|
1598
|
+
}
|
|
1328
1599
|
}
|
|
1329
1600
|
|
|
1330
1601
|
function setEmptyObservationMessage() {
|
|
@@ -1472,7 +1743,8 @@ async function refreshState() {
|
|
|
1472
1743
|
return;
|
|
1473
1744
|
}
|
|
1474
1745
|
|
|
1475
|
-
|
|
1746
|
+
const stateAccessCredential = accessToken || recoveredAccessToken;
|
|
1747
|
+
if (!stateAccessCredential) {
|
|
1476
1748
|
viewState.hasLoaded = true;
|
|
1477
1749
|
viewState.canRetry = false;
|
|
1478
1750
|
viewState.authenticationFailed = true;
|
|
@@ -1489,17 +1761,26 @@ async function refreshState() {
|
|
|
1489
1761
|
}
|
|
1490
1762
|
|
|
1491
1763
|
try {
|
|
1764
|
+
const stateHeaders = {
|
|
1765
|
+
Accept: "application/json",
|
|
1766
|
+
Authorization: `Bearer ${stateAccessCredential}`,
|
|
1767
|
+
};
|
|
1768
|
+
if (
|
|
1769
|
+
stateAccessCredential === accessToken &&
|
|
1770
|
+
VIEWER_TOKEN_PATTERN.test(accessToken) &&
|
|
1771
|
+
CANONICAL_SESSION_ID_PATTERN.test(excludedSessionId)
|
|
1772
|
+
) {
|
|
1773
|
+
stateHeaders["x-codex-agent-view-exclude-session"] = excludedSessionId;
|
|
1774
|
+
}
|
|
1492
1775
|
const response = await fetch(API_STATE_URL, {
|
|
1493
1776
|
cache: "no-store",
|
|
1494
1777
|
credentials: "same-origin",
|
|
1495
|
-
headers:
|
|
1496
|
-
Accept: "application/json",
|
|
1497
|
-
Authorization: `Bearer ${accessToken}`,
|
|
1498
|
-
},
|
|
1778
|
+
headers: stateHeaders,
|
|
1499
1779
|
});
|
|
1500
1780
|
|
|
1501
1781
|
if (response.status === 401 || response.status === 403) {
|
|
1502
1782
|
await response.body?.cancel();
|
|
1783
|
+
clearRejectedViewerToken();
|
|
1503
1784
|
viewState.hasLoaded = true;
|
|
1504
1785
|
viewState.canRetry = false;
|
|
1505
1786
|
viewState.authenticationFailed = true;
|
|
@@ -1513,6 +1794,8 @@ async function refreshState() {
|
|
|
1513
1794
|
throw new Error(t("requestFailed", { status: response.status }));
|
|
1514
1795
|
}
|
|
1515
1796
|
|
|
1797
|
+
const recoveryCredential = storeRecoveryCredential(response);
|
|
1798
|
+
refreshRecoveredAccess(response, stateAccessCredential, recoveryCredential);
|
|
1516
1799
|
const nextState = normalizeState(await response.json());
|
|
1517
1800
|
viewState.updatedAtMs = nextState.updatedAtMs;
|
|
1518
1801
|
viewState.sessions = nextState.sessions;
|
|
@@ -1559,9 +1842,36 @@ document.addEventListener("visibilitychange", () => {
|
|
|
1559
1842
|
}
|
|
1560
1843
|
});
|
|
1561
1844
|
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1845
|
+
async function initializeLiveView() {
|
|
1846
|
+
applyStaticTranslations();
|
|
1847
|
+
render();
|
|
1848
|
+
|
|
1849
|
+
if (bootstrapCredential) {
|
|
1850
|
+
viewState.authenticationFailed = false;
|
|
1851
|
+
setConnectionStatus("connecting");
|
|
1852
|
+
try {
|
|
1853
|
+
const exchanged = await exchangeViewerCredential(bootstrapCredential, {
|
|
1854
|
+
source: "bootstrap",
|
|
1855
|
+
});
|
|
1856
|
+
if (exchanged) {
|
|
1857
|
+
await refreshState();
|
|
1858
|
+
}
|
|
1859
|
+
} catch (error) {
|
|
1860
|
+
viewState.hasLoaded = true;
|
|
1861
|
+
viewState.canRetry = false;
|
|
1862
|
+
viewState.authenticationFailed = true;
|
|
1863
|
+
viewState.errorKey = "expiredToken";
|
|
1864
|
+
viewState.errorMessage = error instanceof Error
|
|
1865
|
+
? error.message
|
|
1866
|
+
: t("unknownConnectionError");
|
|
1867
|
+
setConnectionStatus("error", t("authenticationRequired"));
|
|
1868
|
+
render();
|
|
1869
|
+
}
|
|
1870
|
+
} else {
|
|
1871
|
+
await refreshState();
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1566
1874
|
window.setInterval(refreshState, POLL_INTERVAL_MS);
|
|
1567
1875
|
}
|
|
1876
|
+
|
|
1877
|
+
void initializeLiveView();
|
package/public/index.html
CHANGED
|
@@ -138,7 +138,7 @@
|
|
|
138
138
|
|
|
139
139
|
<div id="state-message" class="state-message" role="status" aria-live="polite">
|
|
140
140
|
<strong data-i18n="loadingState">Loading status.</strong>
|
|
141
|
-
<span data-i18n="connectingCopy">Connecting to the Codex
|
|
141
|
+
<span data-i18n="connectingCopy">Connecting this browser to the local Codex monitor on this device.</span>
|
|
142
142
|
</div>
|
|
143
143
|
|
|
144
144
|
<ul id="session-list" class="session-list" aria-label="Codex work list" data-i18n-aria-label="sessionListAria" hidden></ul>
|