@gotcos/glasses-server 6.99.33 → 7.0.18
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/.env.example +37 -0
- package/CHANGELOG.md +587 -0
- package/README.md +3 -1
- package/docs/web-key-verifier-contract.md +378 -0
- package/managed-runtime-contract.json +12 -1
- package/package.json +10 -3
- package/server/bootstrap.ts +10 -0
- package/server/index.ts +155 -30
- package/server/lib/agent-session-output-images.ts +627 -49
- package/server/lib/agent-session-recent.ts +321 -0
- package/server/lib/agent-session-store.ts +27 -14
- package/server/lib/agent-session-tool-images.ts +224 -0
- package/server/lib/agent-session-turns.ts +19 -2
- package/server/lib/api-auth.ts +43 -1
- package/server/lib/ask-work-plan.ts +3 -1
- package/server/lib/calendar-meetings.ts +70 -0
- package/server/lib/calendar-source.ts +536 -0
- package/server/lib/claude-hooks-installer.ts +111 -12
- package/server/lib/codex-bridge.ts +4 -1
- package/server/lib/codex-run-ledger.ts +29 -0
- package/server/lib/context-builder.ts +2 -1
- package/server/lib/conversation.ts +56 -28
- package/server/lib/cors-reject-log.ts +51 -0
- package/server/lib/disk-io-meter.ts +202 -0
- package/server/lib/drop-p2-runtime.ts +1 -1
- package/server/lib/drop-service.ts +47 -12
- package/server/lib/drop-store.ts +31 -5
- package/server/lib/event-loop-stall.ts +62 -0
- package/server/lib/full-hold-engine.ts +114 -0
- package/server/lib/full-hold.ts +124 -0
- package/server/lib/g2-turn-provenance.ts +128 -9
- package/server/lib/learning-needs-you.ts +24 -7
- package/server/lib/listener-startup.ts +18 -0
- package/server/lib/live-events.ts +533 -0
- package/server/lib/local-path-redact.ts +272 -0
- package/server/lib/meeting-ask-orientation.ts +40 -6
- package/server/lib/meeting-ask-runner.ts +30 -10
- package/server/lib/meeting-batch-transcribe.ts +32 -5
- package/server/lib/morning-brief-runtime.ts +4 -2
- package/server/lib/native-mac-caller.ts +29 -0
- package/server/lib/permission-broker.ts +443 -11
- package/server/lib/ping-engine.ts +1 -1
- package/server/lib/ping-sources.ts +11 -6
- package/server/lib/preview-content.ts +172 -0
- package/server/lib/preview-error-code.ts +10 -0
- package/server/lib/preview-folders-setting.ts +133 -0
- package/server/lib/preview-log-throttle.ts +18 -0
- package/server/lib/preview-thumbs.ts +139 -0
- package/server/lib/secret-path-guard.ts +83 -0
- package/server/lib/session-cache-writer.ts +8 -1
- package/server/lib/session-files-setting.ts +88 -0
- package/server/lib/session-hook-events.ts +23 -1
- package/server/lib/session-hooks-runtime.ts +34 -0
- package/server/lib/session-links.ts +280 -0
- package/server/lib/session-log.ts +313 -4
- package/server/lib/session-state-derive.ts +36 -0
- package/server/lib/session-stream-events.ts +25 -1
- package/server/lib/thread-turn-queue.ts +10 -0
- package/server/lib/today-brief.ts +640 -85
- package/server/lib/turn-end-ask.ts +320 -0
- package/server/lib/turn-footer.ts +3 -3
- package/server/lib/web-control-integrity.ts +160 -0
- package/server/lib/web-control-offnet-page.ts +58 -0
- package/server/lib/web-control-static.ts +53 -9
- package/server/lib/web-key-authority.ts +372 -0
- package/server/lib/web-key-envelope.ts +98 -0
- package/server/lib/web-key-handoff.ts +135 -0
- package/server/lib/web-key-origin.ts +21 -0
- package/server/lib/web-key-passkey.ts +155 -0
- package/server/lib/web-key-registry.ts +825 -0
- package/server/lib/web-key-signer-app.ts +152 -0
- package/server/lib/web-memories-bridge.ts +3 -1
- package/server/lib/web-session-auth.ts +133 -15
- package/server/lib/whisper-local.ts +93 -7
- package/server/lib/whisper-preview.ts +5 -3
- package/server/lib/work-activity.ts +56 -6
- package/server/lib/work-orchestrator/snapshot.ts +17 -3
- package/server/lib/work-review-runtime.ts +17 -4
- package/server/lib/work-session-activity.ts +260 -0
- package/server/lib/your-move-count.ts +9 -3
- package/server/routes/agent-session-bindings.ts +29 -1
- package/server/routes/agent-session-stream.ts +23 -0
- package/server/routes/agent-sessions.ts +146 -11
- package/server/routes/ask-work-plan.ts +4 -1
- package/server/routes/claude-sessions.ts +5 -1
- package/server/routes/drops.ts +7 -2
- package/server/routes/g2-turns.ts +35 -0
- package/server/routes/health.ts +46 -1
- package/server/routes/live-events.ts +334 -0
- package/server/routes/permission-broker.ts +95 -5
- package/server/routes/query-jobs.ts +3 -0
- package/server/routes/session-preview.ts +200 -0
- package/server/routes/sessions.ts +15 -24
- package/server/routes/thread-turn-queue.ts +223 -12
- package/server/routes/today-brief.ts +134 -44
- package/server/routes/web-control.ts +37 -4
- package/server/routes/web-key.ts +284 -0
- package/server/routes/welcome-context.ts +28 -4
- package/server/routes/work-board.ts +9 -2
- package/server/routes/work-orchestrator.ts +12 -7
- package/server/scripts/write-web-control-manifest.mjs +43 -0
- package/server/web-control/.manifest.json +44 -0
- package/server/web-control/app.css +76 -1
- package/server/web-control/app.js +2247 -98
- package/server/web-control/apple-touch-icon.png +0 -0
- package/server/web-control/control.css +25 -0
- package/server/web-control/index.html +21 -3
- package/server/web-control/memory-review.js +100 -0
- package/server/web-control/panes/cos-chat.css +94 -9
- package/server/web-control/panes/cos-chat.js +628 -44
- package/server/web-control/panes/meetings.css +18 -2
- package/server/web-control/panes/meetings.js +24 -8
- package/server/web-control/panes/memories.css +17 -2
- package/server/web-control/panes/memories.js +127 -42
- package/server/web-control/panes/messages.js +21 -2
- package/server/web-control/panes/preview.js +318 -0
- package/server/web-control/panes/questions.js +162 -21
- package/server/web-control/panes/reader-media.js +409 -23
- package/server/web-control/panes/sessions.css +32 -3
- package/server/web-control/panes/settings.js +97 -6
- package/server/web-control/panes/today-actions.css +46 -0
- package/server/web-control/panes/today-actions.js +119 -17
- package/server/web-control/panes/work.css +13 -0
- package/server/web-control/panes/work.js +188 -37
- package/server/web-control/shell.js +17 -14
- package/server/web-signer/.manifest.json +11 -0
- package/server/web-signer/index.html +74 -0
- package/server/web-signer/signer-proof.js +348 -0
- package/server/web-signer/signer-store.js +39 -0
- package/server/web-signer/signer.css +23 -0
- package/server/web-signer/signer.js +341 -0
- package/shared/web-approval-packets.golden.json +124 -0
- package/shared/web-turn-envelope.ts +271 -0
|
@@ -79,13 +79,22 @@ import {
|
|
|
79
79
|
} from '../lib/occupancy-probes.js'
|
|
80
80
|
import type { OccupancyDirs } from '../lib/thread-occupancy.js'
|
|
81
81
|
import { parseTurnsParam, readRecentSessionTurns, type RecentTurnsRead } from '../lib/agent-session-turns.js'
|
|
82
|
+
import { redactLocalPathsDeep } from '../lib/local-path-redact.js'
|
|
83
|
+
import { isNativeMacCaller } from '../lib/native-mac-caller.js'
|
|
82
84
|
import { cosSpawnedPids } from '../lib/agent-session-ownership-store.js'
|
|
83
85
|
import { sessionActFactsFor, sessionActFields } from '../lib/session-act-fields.js'
|
|
84
86
|
import { CONTINUE_NOTE_HEADER, continueNoteAcknowledged } from '../lib/continue-plan.js'
|
|
85
87
|
|
|
86
|
-
import { agentSessionOutputImages } from '../lib/agent-session-output-images.js'
|
|
88
|
+
import { agentSessionOutputImages, sharedFileHeaders } from '../lib/agent-session-output-images.js'
|
|
89
|
+
import { previewErrorCode } from '../lib/preview-error-code.js'
|
|
90
|
+
import { resetPreviewLogThrottle, warnPreviewThrottled } from '../lib/preview-log-throttle.js'
|
|
91
|
+
import { sessionFilesSetting, withoutPreviews } from '../lib/session-files-setting.js'
|
|
92
|
+
import { withoutLinkMarkers } from '../lib/session-links.js'
|
|
93
|
+
import { createSessionPreviewRouter } from './session-preview.js'
|
|
87
94
|
|
|
88
95
|
export const agentSessionsRouter = Router()
|
|
96
|
+
// Preview engine phase 1: GET /api/preview/agent-sessions/... and /api/preview-folders/settings (routes/session-preview.ts).
|
|
97
|
+
agentSessionsRouter.use(createSessionPreviewRouter())
|
|
89
98
|
|
|
90
99
|
function boundedInteger(value: unknown, fallback: number, min: number, max: number): number {
|
|
91
100
|
const parsed = Number(value)
|
|
@@ -567,7 +576,8 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
|
|
|
567
576
|
const queuedOf = await allQueuedWaitingLookup(now)
|
|
568
577
|
const codexRoots = codexSessionRootsOf(roots)
|
|
569
578
|
const subagents = await readCodexSubagentsAcross(sessions.filter(row => row.provider === 'codex').map(row => row.session_id), codexRoots, now, codexRoots.length > 1 ? keyedCodexRootOf() : undefined)
|
|
570
|
-
|
|
579
|
+
// 7.0.4 S1: no absolute Mac path in any string a list row carries (titles, first prompts, summaries).
|
|
580
|
+
res.json(redactLocalPathsDeep({
|
|
571
581
|
sessions: sessions.map((row, index) => {
|
|
572
582
|
const derived = derivedById.get(row.session_id)
|
|
573
583
|
const signal = signalFor(row.session_id)
|
|
@@ -592,7 +602,7 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
|
|
|
592
602
|
// client ignores this key. Zero means the walk found nothing to hide, not
|
|
593
603
|
// that the caps are off.
|
|
594
604
|
dropped,
|
|
595
|
-
})
|
|
605
|
+
}))
|
|
596
606
|
} catch (error) {
|
|
597
607
|
console.error(`[agent-sessions] list failed: ${error instanceof Error ? error.message : error}`)
|
|
598
608
|
res.status(500).json({ error: 'Failed to read agent sessions', reason: 'agent_sessions_read_failed' })
|
|
@@ -631,30 +641,59 @@ agentSessionsRouter.get('/agent-sessions/search', async (req, res) => {
|
|
|
631
641
|
remember: false,
|
|
632
642
|
})
|
|
633
643
|
}
|
|
634
|
-
|
|
644
|
+
// 7.0.4 S1: search hits carry snippets of the same words.
|
|
645
|
+
res.json(redactLocalPathsDeep({
|
|
635
646
|
...result,
|
|
636
647
|
hits: result.hits.map(hit => toSearchHit(hit, queuedOf(hit.provider, hit.session_id), derivedFor(hit))),
|
|
637
|
-
})
|
|
648
|
+
}))
|
|
638
649
|
} catch (error) {
|
|
639
650
|
console.error(`[agent-sessions] search failed: ${error instanceof Error ? error.message : error}`)
|
|
640
651
|
res.status(500).json({ error: 'Failed to search agent sessions', reason: 'agent_sessions_search_failed' })
|
|
641
652
|
}
|
|
642
653
|
})
|
|
643
654
|
|
|
655
|
+
/** 7.0.4 S2: the 403 every local-file route answers for a file whose name says it may hold secrets. */
|
|
656
|
+
export const SECRET_FILE_DENIED = 'secret_file_denied'
|
|
657
|
+
/** 7.0.4 QA: the 503 a preview answers when the Mac is already reading its limit of previews (not unreachable). */
|
|
658
|
+
export const PREVIEWS_BUSY = 'previews_busy'
|
|
659
|
+
|
|
660
|
+
type PreviewKind = 'output_image' | 'tool_image' | 'sent_file'
|
|
661
|
+
/**
|
|
662
|
+
* 7.0.4 QA: every preview refusal and failure is logged with its kind and a reason code, never a path or a session's
|
|
663
|
+
* words. A burst of the same reason (a page of thumbnails while the reader is full) logs once per 10 seconds, with the
|
|
664
|
+
* count it held back (lib/preview-log-throttle.ts, shared with the preview engine routes since 7.0.7).
|
|
665
|
+
*/
|
|
666
|
+
export function logPreviewOutcome(kind: PreviewKind, reason: string, now = Date.now()): void {
|
|
667
|
+
warnPreviewThrottled(kind + ':' + reason, `[agent-sessions] preview ${kind} refused reason=${reason}`, now)
|
|
668
|
+
}
|
|
669
|
+
/** Tests only: forget the burst window, so each case logs. */
|
|
670
|
+
export { resetPreviewLogThrottle }
|
|
671
|
+
/** The answer for a preview read that did not succeed, logged (a missing id is ordinary and is not). */
|
|
672
|
+
function previewRefusal(res: import('express').Response, kind: PreviewKind, status: 'not_found' | 'unavailable' | 'too_large' | 'secret' | 'busy'): void {
|
|
673
|
+
const noun = kind === 'sent_file' ? 'file' : 'image'
|
|
674
|
+
if (status !== 'not_found') logPreviewOutcome(kind, status === 'secret' ? SECRET_FILE_DENIED : status)
|
|
675
|
+
if (status === 'secret') { res.status(403).json({ error: SECRET_FILE_DENIED }); return }
|
|
676
|
+
if (status === 'not_found') { res.status(404).json({ error: `${noun}_not_found` }); return }
|
|
677
|
+
if (status === 'too_large') { res.status(413).json({ error: `${noun}_too_large` }); return }
|
|
678
|
+
if (status === 'busy') { res.set('Retry-After', '2'); res.status(503).json({ error: PREVIEWS_BUSY }); return }
|
|
679
|
+
res.status(503).json({ error: `${noun}_unavailable` })
|
|
680
|
+
}
|
|
681
|
+
|
|
644
682
|
// Authenticated by index.ts's shared /api gate. Neither the request nor the response
|
|
645
683
|
// carries a filesystem path; the resolver reauthorizes exact transcript membership.
|
|
646
684
|
agentSessionsRouter.get('/agent-sessions/:provider/:sessionId/output-images/:imageId/content', async (req, res) => {
|
|
647
685
|
res.set('Cache-Control', 'private, no-store')
|
|
648
686
|
res.set('X-Content-Type-Options', 'nosniff')
|
|
687
|
+
// 6.99.35: "Show files and images your sessions make" off answers before anything is read.
|
|
688
|
+
if (!sessionFilesSetting.read().enabled) { res.status(403).json({ error: 'previews_off' }); return }
|
|
649
689
|
const provider = asProvider(String(req.params.provider ?? '').toLowerCase())
|
|
650
690
|
if (!provider || (req.query.variant !== undefined && req.query.variant !== 'thumb' && req.query.variant !== 'phone')) {
|
|
651
691
|
res.status(404).json({ error: 'image_not_found' }); return
|
|
652
692
|
}
|
|
653
693
|
try {
|
|
654
694
|
const content = await agentSessionOutputImages.content(provider, String(req.params.sessionId), String(req.params.imageId), req.query.variant === 'thumb' ? 'thumb' : 'phone')
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
}
|
|
695
|
+
// 7.0.4 S2: the id was authorized, and the file's name says it may hold secrets (lib/secret-path-guard.ts).
|
|
696
|
+
if (content.status !== 'ok') { previewRefusal(res, 'output_image', content.status === 'expired' ? 'unavailable' : content.status); return }
|
|
658
697
|
res.type('image/jpeg')
|
|
659
698
|
// `send` defaults to `dotfiles: 'ignore'` and applies it to the WHOLE absolute path
|
|
660
699
|
// when no `root` is given, so a media root under `~/.cos-glasses/data` 404'd on
|
|
@@ -666,8 +705,77 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId/output-images/:ima
|
|
|
666
705
|
if (!res.headersSent) res.status(503).json({ error: 'image_unavailable' })
|
|
667
706
|
else res.destroy()
|
|
668
707
|
})
|
|
669
|
-
} catch {
|
|
670
|
-
|
|
708
|
+
} catch (error) {
|
|
709
|
+
logPreviewOutcome('output_image', 'error_' + previewErrorCode(error))
|
|
710
|
+
res.status(503).json({ error: 'image_unavailable' })
|
|
711
|
+
}
|
|
712
|
+
})
|
|
713
|
+
|
|
714
|
+
// 6.99.35: "Show files and images your sessions make" (lib/session-files-setting.ts), Control Web Settings, Privacy.
|
|
715
|
+
// Default on; COS_WEB_SESSION_FILES=0 on the Mac forces it off and the answer says so (source: 'env').
|
|
716
|
+
agentSessionsRouter.get('/session-files/settings', (_req, res) => {
|
|
717
|
+
res.set('Cache-Control', 'private, no-store')
|
|
718
|
+
try { res.json(sessionFilesSetting.read()) } catch { res.status(500).json({ error: 'session_files_settings_unavailable' }) }
|
|
719
|
+
})
|
|
720
|
+
agentSessionsRouter.post('/session-files/settings', (req, res) => {
|
|
721
|
+
const body = req.body
|
|
722
|
+
if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(k => k !== 'enabled') || typeof body.enabled !== 'boolean') {
|
|
723
|
+
res.status(400).json({ error: { code: 'invalid_session_files_settings', message: 'Send { enabled: true } or { enabled: false }.' } }); return
|
|
724
|
+
}
|
|
725
|
+
try { res.json(sessionFilesSetting.set(body.enabled)) } catch { res.status(500).json({ error: 'session_files_settings_unavailable' }) }
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
// 6.99.35: a file a Claude session sent with SendUserFile. Same gate, same re-authorization, no path
|
|
729
|
+
// in the request or the response. HTML is an attachment of type octet-stream with a sandbox CSP, never
|
|
730
|
+
// same-origin HTML; a PDF may open inline.
|
|
731
|
+
agentSessionsRouter.get('/agent-sessions/:provider/:sessionId/output-files/:fileId/content', async (req, res) => {
|
|
732
|
+
res.set('Cache-Control', 'private, no-store')
|
|
733
|
+
res.set('X-Content-Type-Options', 'nosniff')
|
|
734
|
+
if (!sessionFilesSetting.read().enabled) { res.status(403).json({ error: 'previews_off' }); return }
|
|
735
|
+
const provider = asProvider(String(req.params.provider ?? '').toLowerCase())
|
|
736
|
+
if (!provider) { res.status(404).json({ error: 'file_not_found' }); return }
|
|
737
|
+
try {
|
|
738
|
+
const content = await agentSessionOutputImages.fileContent(provider, String(req.params.sessionId), String(req.params.fileId))
|
|
739
|
+
if (content.status !== 'ok') { previewRefusal(res, 'sent_file', content.status); return }
|
|
740
|
+
res.set(sharedFileHeaders(content.name, content.kind))
|
|
741
|
+
res.status(200).end(content.bytes)
|
|
742
|
+
} catch (error) {
|
|
743
|
+
logPreviewOutcome('sent_file', 'error_' + previewErrorCode(error))
|
|
744
|
+
res.status(503).json({ error: 'file_unavailable' })
|
|
745
|
+
}
|
|
746
|
+
})
|
|
747
|
+
|
|
748
|
+
// Images a session looked at (tool results such as a Read of a PNG or a browser screenshot) and images attached
|
|
749
|
+
// to its prompts. Same gate and switch as output images, same rule: no path in the request or the response. Embedded
|
|
750
|
+
// bytes are served as the image type they ARE (png, jpeg, webp or gif, sniffed, never the declared type), with a
|
|
751
|
+
// sandbox CSP; a path-only Read comes back through the output-image normalizer as a JPEG.
|
|
752
|
+
export const TOOL_IMAGE_CSP = "sandbox; default-src 'none'"
|
|
753
|
+
agentSessionsRouter.get('/agent-sessions/:provider/:sessionId/tool-images/:imageId/content', async (req, res) => {
|
|
754
|
+
res.set('Cache-Control', 'private, no-store')
|
|
755
|
+
res.set('X-Content-Type-Options', 'nosniff')
|
|
756
|
+
if (!sessionFilesSetting.read().enabled) { res.status(403).json({ error: 'previews_off' }); return }
|
|
757
|
+
const provider = asProvider(String(req.params.provider ?? '').toLowerCase())
|
|
758
|
+
if (!provider) { res.status(404).json({ error: 'image_not_found' }); return }
|
|
759
|
+
try {
|
|
760
|
+
const content = await agentSessionOutputImages.toolImageContent(provider, String(req.params.sessionId), String(req.params.imageId))
|
|
761
|
+
if (content.status !== 'ok') { previewRefusal(res, 'tool_image', content.status); return }
|
|
762
|
+
res.set('Content-Security-Policy', TOOL_IMAGE_CSP)
|
|
763
|
+
res.set('Cross-Origin-Resource-Policy', 'same-origin')
|
|
764
|
+
if ('path' in content) {
|
|
765
|
+
res.type('image/jpeg')
|
|
766
|
+
// dotfiles 'allow': the media root lives under ~/.cos-glasses/data (see the output-images route above).
|
|
767
|
+
res.sendFile(content.path, { dotfiles: 'allow' }, err => {
|
|
768
|
+
if (!err) return
|
|
769
|
+
if (!res.headersSent) res.status(503).json({ error: 'image_unavailable' })
|
|
770
|
+
else res.destroy()
|
|
771
|
+
})
|
|
772
|
+
return
|
|
773
|
+
}
|
|
774
|
+
res.set('Content-Type', content.type)
|
|
775
|
+
res.set('Content-Disposition', 'inline')
|
|
776
|
+
res.status(200).end(content.bytes)
|
|
777
|
+
} catch (error) {
|
|
778
|
+
logPreviewOutcome('tool_image', 'error_' + previewErrorCode(error))
|
|
671
779
|
res.status(503).json({ error: 'image_unavailable' })
|
|
672
780
|
}
|
|
673
781
|
})
|
|
@@ -717,6 +825,8 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
|
|
|
717
825
|
try {
|
|
718
826
|
const images = await agentSessionOutputImages.context(provider, sessionId, found)
|
|
719
827
|
recentTurns = await readRecentSessionTurns(provider, found, turnsWanted, undefined, images.project)
|
|
828
|
+
// 6.99.35: previews off sends one mark per message instead of image and file names.
|
|
829
|
+
if (recentTurns && !sessionFilesSetting.read().enabled) recentTurns = { ...recentTurns, turns: withoutPreviews(recentTurns.turns) }
|
|
720
830
|
} catch (error) {
|
|
721
831
|
console.warn(`[agent-sessions] recent turns failed provider=${provider}: ${error instanceof Error ? error.message : error}`)
|
|
722
832
|
}
|
|
@@ -754,7 +864,10 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
|
|
|
754
864
|
}
|
|
755
865
|
const signal = signalFor(parsed.session_id)
|
|
756
866
|
const hint = runningHintFor(provider, derived, hintClock(signal, derived, rollout), now, activityClockMs(st.mtimeMs, activity))
|
|
757
|
-
|
|
867
|
+
// 7.0.4 S1: every string the detail sends (the reply, the digest, the first prompt, each turn's words, tool
|
|
868
|
+
// labels, file and image names) has its absolute local paths turned into base names (lib/local-path-redact.ts).
|
|
869
|
+
// A walk over the whole value rather than a field list, so a field added later is covered too.
|
|
870
|
+
const body = redactLocalPathsDeep({
|
|
758
871
|
...withRunning({ session_id: parsed.session_id }, running, hint),
|
|
759
872
|
...derivedRowFields(derived),
|
|
760
873
|
...queuedTurnsFields((await allQueuedWaitingLookup(Date.now()))(provider, parsed.session_id)),
|
|
@@ -816,6 +929,28 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
|
|
|
816
929
|
noteAck: continueNoteAcknowledged(req.headers[CONTINUE_NOTE_HEADER]),
|
|
817
930
|
}))[0]),
|
|
818
931
|
})
|
|
932
|
+
// 7.0.4 W1: COS Control's helper on this Mac matches a Work prompt it sent against the session's user turns by
|
|
933
|
+
// their opening words, and it holds the prompt unredacted. Only the user turns' words in `recent_turns` (the one
|
|
934
|
+
// field it matches on) are sent as written, and only to that privileged local caller: a direct loopback request
|
|
935
|
+
// that passed the header-token gate with no browser or proxy header (lib/native-mac-caller.ts). Every browser
|
|
936
|
+
// (cookie or token), phone, LAN, tailnet and proxied request gets the redacted body. The answer is already
|
|
937
|
+
// `private, no-store`; Vary names every header the choice reads, so no cache can serve one as the other.
|
|
938
|
+
if (turnsWanted !== null) res.vary('Cookie').vary('X-COS-Token').vary('Origin').vary('Referer').vary('User-Agent').vary('Sec-Fetch-Mode').vary('Sec-Fetch-Site').vary('Sec-Fetch-Dest')
|
|
939
|
+
// 7.0.7 final QA: link-card markers (`[words](cos-file:N)`) go only to a client that asks for them with `cards=1`
|
|
940
|
+
// (Control Web from 7.0.7: the reader and Today's session sheet). Everyone else gets each card's words, as 7.0.4
|
|
941
|
+
// showed them: a Control Web tab opened before the update (it has no reload-on-version), COS Control (no build
|
|
942
|
+
// renders markers; its own cards come from outputLinks), the lens and any older client. outputLinks is unchanged.
|
|
943
|
+
const native = recentTurns && Array.isArray(body.recent_turns) ? isNativeMacCaller(req, res) : false
|
|
944
|
+
const cardsWanted = req.query.cards === '1'
|
|
945
|
+
// The native helper always gets words, whatever it asks for (no Control build draws markers).
|
|
946
|
+
if (recentTurns && Array.isArray(body.recent_turns) && (native || !cardsWanted)) {
|
|
947
|
+
body.recent_turns = body.recent_turns.map((turn, index) => {
|
|
948
|
+
const original = recentTurns!.turns[index]
|
|
949
|
+
if (native && original && original.role === 'user' && turn.role === 'user') return { ...turn, text: original.text }
|
|
950
|
+
return typeof turn.text === 'string' && turn.text.includes('](cos-file:') ? { ...turn, text: withoutLinkMarkers(turn.text) } : turn
|
|
951
|
+
})
|
|
952
|
+
}
|
|
953
|
+
res.json(body)
|
|
819
954
|
} catch (error) {
|
|
820
955
|
console.error(`[agent-sessions] detail failed: ${error instanceof Error ? error.message : error}`)
|
|
821
956
|
res.status(500).json({ error: 'Failed to read agent session', reason: 'agent_sessions_read_failed' })
|
|
@@ -99,8 +99,11 @@ export function createAskWorkPlanRouter(given: Partial<AskWorkPlanDeps> = {}) {
|
|
|
99
99
|
model: output.model ?? `claude:${planner} (requested)`, effort })
|
|
100
100
|
} catch (error) {
|
|
101
101
|
if (error instanceof AskProcessError && error.terminationUnconfirmed) {
|
|
102
|
-
//
|
|
102
|
+
// No second process or maintenance drain until the runner proves that the
|
|
103
|
+
// original child and group have terminated. Missing/failed proof stays held.
|
|
103
104
|
release = false
|
|
105
|
+
const heldLease = lease
|
|
106
|
+
void error.terminationConfirmed?.then(() => { heldLease?.release(); running = false }).catch(() => { /* remain fail-closed */ })
|
|
104
107
|
console.warn('[ask-work-plan] provider termination unconfirmed; admission and maintenance lease retained')
|
|
105
108
|
fail(new AskWorkPlanError('provider_termination_unconfirmed', 503))
|
|
106
109
|
} else fail(error)
|
|
@@ -95,11 +95,15 @@ export async function readClaudePeerRecords(
|
|
|
95
95
|
dir: string,
|
|
96
96
|
probes: PeerProbes = realProbes,
|
|
97
97
|
showNames = claudeSessionNamesVisible(),
|
|
98
|
+
/** 7.0.1 live events: a folder that exists but cannot be read (permissions, a transient error) throws instead of
|
|
99
|
+
* answering "nothing alive", so the caller keeps its last good reading. A missing folder is still empty. */
|
|
100
|
+
throwWhenUnreadable = false,
|
|
98
101
|
): Promise<ClaudePeerRecord[]> {
|
|
99
102
|
let names: string[]
|
|
100
103
|
try {
|
|
101
104
|
names = await readdir(dir)
|
|
102
|
-
} catch {
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (throwWhenUnreadable && (error as NodeJS.ErrnoException)?.code !== 'ENOENT') throw error
|
|
103
107
|
// No directory means Claude Code has never run here, or the path is wrong.
|
|
104
108
|
// An empty presence list is the honest answer; the route reports `enabled`
|
|
105
109
|
// separately so this cannot be mistaken for "the feature is off".
|
package/server/routes/drops.ts
CHANGED
|
@@ -18,8 +18,9 @@
|
|
|
18
18
|
* POST /drops/:id/move { dest, target?: { id, label, domain? } } DropAnswer
|
|
19
19
|
* POST /drops/:id/ask { clientJobId, generation } DropAnswer
|
|
20
20
|
* POST /drops/:id/sent DropAnswer
|
|
21
|
-
* GET /inbox/summary { unread, waiting, lastLine, lastAt, version }
|
|
21
|
+
* GET /inbox/summary { unread, waiting, asked, yourMove, lastLine, lastAt, version }
|
|
22
22
|
* GET /inbox/feed ?after&limit { items, version, more }
|
|
23
|
+
* GET /inbox/archive { items, total, waitingExpiryDays, version } (7.0.9)
|
|
23
24
|
* POST /inbox/read { through } { unread, waiting, version }
|
|
24
25
|
* POST /inbox/action { itemId, action: 'choose:<id>' | 'undo' } DropAnswer
|
|
25
26
|
* POST /inbox/action { itemId, action: 'dismiss' } { unread, waiting, version }
|
|
@@ -211,9 +212,13 @@ export function createDropsRouter(deps: DropsRouterDependencies): Router {
|
|
|
211
212
|
try { await service.syncAsks() } catch { /* the chat still reads */ }
|
|
212
213
|
try { return res.json(service.feed({ after, limit })) } catch (e) { return send(res, e) }
|
|
213
214
|
}))
|
|
215
|
+
// 7.0.9: the older questions (asked WAITING_EXPIRY_DAYS or more ago and still unanswered), newest first.
|
|
216
|
+
router.get('/inbox/archive', (_req, res) => withService(res, async service => {
|
|
217
|
+
try { return res.json(service.archive()) } catch (e) { return send(res, e) }
|
|
218
|
+
}))
|
|
214
219
|
router.post('/inbox/read', (req, res) => withService(res, async service => {
|
|
215
220
|
if (!isRecord(req.body) || typeof req.body.through !== 'string') return bad(res, 'invalid_read')
|
|
216
|
-
let out: { unread: number; waiting: number; version: number }
|
|
221
|
+
let out: { unread: number; waiting: number; asked: number; version: number }
|
|
217
222
|
try { out = service.markRead(req.body.through) } catch (e) { return send(res, e) }
|
|
218
223
|
// The same `waiting` the summary gives, Work's Your move included.
|
|
219
224
|
const yourMove = await boundedYourMove(deps.yourMove)
|
|
@@ -42,5 +42,40 @@ export function createG2TurnsRouter(deps: {
|
|
|
42
42
|
res.status(500).json({ found: false, error: 'lookup_failed' })
|
|
43
43
|
}
|
|
44
44
|
})
|
|
45
|
+
// 6.99.31 (plan §4.5): POST /api/g2-turns/:turnId/consume {session, provider}. One server-enforced
|
|
46
|
+
// winner per web-key authorization (keys turn:<turnId> and web:<aud>:<authId>, one write), so two receiving sessions
|
|
47
|
+
// cannot both act on one signed message. X-Cos-Token only. A token holder can pre-spend: that is
|
|
48
|
+
// denial of service, not forgery. Not a route while COS_WEB_KEY_AUTHORITY is off.
|
|
49
|
+
router.post('/g2-turns/:turnId/consume', async (req, res, next) => {
|
|
50
|
+
const authority = deps.provenance.webKey
|
|
51
|
+
if (!authority || !authority.enabled()) return next()
|
|
52
|
+
res.set('Cache-Control', 'private, no-store')
|
|
53
|
+
let expected = ''
|
|
54
|
+
try { expected = deps.apiToken ? deps.apiToken() : '' } catch { expected = '' }
|
|
55
|
+
if (!expected || !timingSafeTokenEqual(req.headers['x-cos-token'], expected)) return void res.status(401).json({ consumed: false, error: 'token_required' })
|
|
56
|
+
const turnId = String(req.params.turnId ?? '')
|
|
57
|
+
if (!G2_TURN_ID_RE.test(turnId)) return void res.status(400).json({ consumed: false, error: 'invalid_turn_id' })
|
|
58
|
+
const b = (req.body && typeof req.body === 'object' ? req.body : {}) as Record<string, unknown>
|
|
59
|
+
try {
|
|
60
|
+
const answer = await describeG2Turn(deps.provenance, turnId, now())
|
|
61
|
+
if (!answer) return void res.status(404).json({ consumed: false, turnId, error: 'not_found' })
|
|
62
|
+
if (answer.authority !== 'web-key' || answer.verifiedOrigin !== true || typeof answer.authId !== 'string') {
|
|
63
|
+
return void res.status(409).json({ consumed: false, turnId, error: 'not_web_key_verified', reason: answer.reason ?? null })
|
|
64
|
+
}
|
|
65
|
+
const target = answer.target as { provider?: string; sessionId?: string } | undefined
|
|
66
|
+
if (typeof b.session !== 'string' || typeof b.provider !== 'string' || target?.sessionId !== b.session || target?.provider !== b.provider) {
|
|
67
|
+
return void res.status(409).json({ consumed: false, turnId, error: 'destination_mismatch' })
|
|
68
|
+
}
|
|
69
|
+
// Review B3: one namespace. The turn id and the web authorization are spent in ONE write.
|
|
70
|
+
const keys = Array.isArray(answer.consumeKeys) ? (answer.consumeKeys as unknown[]).filter((k): k is string => typeof k === 'string') : []
|
|
71
|
+
if (keys.length !== 2) return void res.status(409).json({ consumed: false, turnId, error: 'not_web_key_verified' })
|
|
72
|
+
const spent = authority.nonces.spend(keys, b.session, b.provider)
|
|
73
|
+
if (!spent.ok) return void res.status(spent.reason === 'already_consumed' ? 409 : 503).json({ consumed: false, turnId, error: spent.reason })
|
|
74
|
+
res.json({ consumed: true, turnId, authId: answer.authId, keys })
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.error(`[g2-turns] consume failed: ${error instanceof Error ? error.message : error}`)
|
|
77
|
+
res.status(500).json({ consumed: false, error: 'consume_failed' })
|
|
78
|
+
}
|
|
79
|
+
})
|
|
45
80
|
return router
|
|
46
81
|
}
|
package/server/routes/health.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { API_AUTH_BY_LOCAL } from '../lib/api-auth.js'
|
|
1
2
|
import { MAX_ATTACHMENTS_PER_PROMPT } from '../../shared/media-attachment.js'
|
|
3
|
+
import { calendarStatus } from '../lib/calendar-source.js'
|
|
2
4
|
import { TURN_FILE_MAX_BYTES } from '../lib/turn-file-store.js'
|
|
3
5
|
import { dropItCapability } from '../lib/drop-settings.js'
|
|
4
6
|
import { PREP_CAPABILITY } from '../../shared/prep.js'
|
|
@@ -9,6 +11,7 @@ import { customInstructionsCapability, learnedStyleCapability, polishOptionsCapa
|
|
|
9
11
|
import { PAIRING_PROTOCOL_VERSION } from '../lib/glasses-pairing.js'
|
|
10
12
|
import { taskBridgeAvailable } from '../lib/task-bridge.js'
|
|
11
13
|
import { Router } from 'express'
|
|
14
|
+
import { webKeyAuthorityEnabled } from '../lib/web-key-authority.js'
|
|
12
15
|
import { isWorthRecovering } from '../lib/quarantine-auto-recover.js'
|
|
13
16
|
import { claudeSessionsEnabled } from './claude-sessions.js'
|
|
14
17
|
import { statSync } from 'node:fs'
|
|
@@ -56,6 +59,10 @@ import {
|
|
|
56
59
|
isOllamaProviderReady,
|
|
57
60
|
} from '../lib/ollama-catalog.js'
|
|
58
61
|
import { isMediaProcessingReady } from '../lib/image-safety.js'
|
|
62
|
+
import { DEFAULT_MODEL, type ModelPreference } from '../../shared/model-preference.js'
|
|
63
|
+
import { closedEnvDefaultModel } from '../lib/key-profile-routing.js'
|
|
64
|
+
import { getSessionBinding, getSessionModel } from '../lib/conversation.js'
|
|
65
|
+
import { isProfilePreference } from '../../shared/key-preference.js'
|
|
59
66
|
import {
|
|
60
67
|
MAX_OTHER_MEDIA_BYTES,
|
|
61
68
|
MAX_VIDEO_MEDIA_BYTES,
|
|
@@ -455,6 +462,9 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
455
462
|
// 6.99.27: a session turn also takes ANY file up to this size (lib/turn-file-store.ts), sent to /api/media/file with
|
|
456
463
|
// X-COS-Purpose: session-turn. 0 with attach off; absent on an older server, which takes only what the media store reads.
|
|
457
464
|
threadTurnFileBytes: threadAttach.enabled ? TURN_FILE_MAX_BYTES : 0,
|
|
465
|
+
// 6.99.31 (Phase 3): the phone key signer exists (COS_WEB_KEY_AUTHORITY on), so Control Web may offer
|
|
466
|
+
// "Approve on this phone". Absent with the flag off: the composer is exactly 6.99.29's.
|
|
467
|
+
...(webKeyAuthorityEnabled() ? { webKeySigner: 1 } : {}),
|
|
458
468
|
...sessionHooksHealthFields(),
|
|
459
469
|
// 6.52.0: counts, the mode and two timestamps; never an id, a question, a command, or
|
|
460
470
|
// how many are held (that is on the authenticated questions route).
|
|
@@ -625,6 +635,32 @@ function crashLoopParkingHealth(): { enabled: boolean; parked: number } {
|
|
|
625
635
|
}
|
|
626
636
|
}
|
|
627
637
|
|
|
638
|
+
/**
|
|
639
|
+
* 7.0.2 (A7): what "Default" in a model picker resolves to, in model-router's own order
|
|
640
|
+
* (lib/model-router.ts, the resolvedModel line): the request's model, then the session's
|
|
641
|
+
* preference, then COS_G2_DEFAULT_MODEL, then DEFAULT_MODEL. A picker on "Default" sends no
|
|
642
|
+
* model, so the request step only names a model a caller passes in. `source` says which step won.
|
|
643
|
+
* A slot name only: never a key profile (closedEnvDefaultModel and getSessionModel are closed).
|
|
644
|
+
*/
|
|
645
|
+
export type DefaultModelSource = 'request' | 'session' | 'profile' | 'env' | 'built-in'
|
|
646
|
+
/** `preference` is null only for `profile`: the session is bound to a key profile, never named here (plan 5.2). */
|
|
647
|
+
export function resolveDefaultModel(input: { requested?: ModelPreference | null; sessionModel?: ModelPreference | null; sessionProfile?: boolean; env?: string | undefined }): { preference: ModelPreference | null; source: DefaultModelSource } {
|
|
648
|
+
if (input.requested) return { preference: input.requested, source: 'request' }
|
|
649
|
+
if (input.sessionModel) return { preference: input.sessionModel, source: 'session' }
|
|
650
|
+
// model-router routes a session bound to a profile through that profile when the request names no model.
|
|
651
|
+
if (input.sessionProfile) return { preference: null, source: 'profile' }
|
|
652
|
+
const env = closedEnvDefaultModel(input.env)
|
|
653
|
+
if (env) return { preference: env, source: 'env' }
|
|
654
|
+
return { preference: DEFAULT_MODEL, source: 'built-in' }
|
|
655
|
+
}
|
|
656
|
+
// A session id is only looked up in the in-memory session map (an unknown id reads null), so nothing else is checked.
|
|
657
|
+
function defaultModelField(sessionQuery: unknown): { preference: ModelPreference | null; source: DefaultModelSource } {
|
|
658
|
+
const sid = typeof sessionQuery === 'string' && sessionQuery.length <= 128 ? sessionQuery : ''
|
|
659
|
+
const sessionModel = sid ? getSessionModel(sid) : null
|
|
660
|
+
const sessionProfile = !!sid && !sessionModel && isProfilePreference(getSessionBinding(sid))
|
|
661
|
+
return resolveDefaultModel({ sessionModel, sessionProfile, env: process.env.COS_G2_DEFAULT_MODEL })
|
|
662
|
+
}
|
|
663
|
+
|
|
628
664
|
// Stable app slots backed by Codex + Cursor live catalogs. Authenticated by
|
|
629
665
|
// global /api middleware; ?refresh=1 forces discovery.
|
|
630
666
|
healthRouter.get('/models', async (req, res) => {
|
|
@@ -653,9 +689,16 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
653
689
|
// mintDisplayTicket refuses an empty key, because a ticket signed with '' can
|
|
654
690
|
// never verify. Omit the field rather than publish a dead capability.
|
|
655
691
|
const apiToken = process.env.COS_API_TOKEN ?? ''
|
|
692
|
+
// Final review S8 (Q1 and the lead, 2026-11-10 02:20): only a caller the API token let in (the glasses, the phone app,
|
|
693
|
+
// COS Control) gets a display-stream ticket. A browser signed in by cookie never does: Control Web never uses one, and
|
|
694
|
+
// a ticket's socket is authorized once at connect, so it would outlive "Sign out every browser". Fails closed: a
|
|
695
|
+
// request the gate did not mark as the token door gets none.
|
|
696
|
+
const ticketCaller = res.locals?.[API_AUTH_BY_LOCAL] === 'token'
|
|
656
697
|
res.json({
|
|
657
698
|
...catalog,
|
|
658
699
|
...threadAttachHealthFields(threadAttach),
|
|
700
|
+
// 7.0.2 (A7): what a picker's "Default" resolves to (?sessionId= adds that session's preference).
|
|
701
|
+
defaultModel: defaultModelField(req.query.sessionId),
|
|
659
702
|
// 6.62.0 (plans 3.5, 3.11): the phone reads THIS surface, so the live-Continue counters
|
|
660
703
|
// and the per-provider action contract are published here too, same helpers as health.
|
|
661
704
|
...continueLiveHealthFields(),
|
|
@@ -680,6 +723,8 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
680
723
|
ollamaReady: isOllamaProviderReady(),
|
|
681
724
|
// Key lanes (plan v4 3.1): `keyProfiles` only while a profile is active. Absent when off.
|
|
682
725
|
...keyProfilesModelsField(),
|
|
726
|
+
// PH plan P1: where the calendar comes from. Counts only, never titles; not on public /api/health.
|
|
727
|
+
calendar: calendarStatus(),
|
|
683
728
|
serverInstanceId: getServerInstanceId(),
|
|
684
729
|
// Minted here rather than on a route of its own: this response is already
|
|
685
730
|
// authenticated, and the mint is stateless, so it costs nothing and stores
|
|
@@ -716,7 +761,7 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
716
761
|
// 6.8.441 lens fifteen minutes into a meeting. The TTL therefore bounds how
|
|
717
762
|
// long a LEAKED URL can open a new socket, not how long an open socket lives.
|
|
718
763
|
// Changing that is a breaking change that needs a client version gate first.
|
|
719
|
-
...(apiToken ? { displayStreamTicket: mintDisplayTicket(apiToken) } : {}),
|
|
764
|
+
...(apiToken && ticketCaller ? { displayStreamTicket: mintDisplayTicket(apiToken) } : {}),
|
|
720
765
|
capabilities: {
|
|
721
766
|
meetingAsk: { ...meetingAskCapabilities(), webRetrieval: meetingWebCapability().available },
|
|
722
767
|
// 6.91.0: learnedStyle ("Your style", enabled = the server switch) and polishOptions ("Previous polish").
|