@gotcos/glasses-server 6.65.0 → 6.67.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/.env.example +5 -0
- package/CHANGELOG.md +24 -0
- package/README.md +32 -6
- package/SECURITY.md +16 -0
- package/bin/cli.cjs +20 -29
- package/bin/whisper-runtime.cjs +17 -0
- package/bin/whisper-runtime.d.cts +1 -0
- package/docs/pairing-contract.md +118 -0
- package/docs/pairing-fixture.json +94 -0
- package/package.json +7 -2
- package/server/index.ts +31 -16
- package/server/lib/api-auth.ts +27 -2
- package/server/lib/dictation-clean-models.ts +3 -3
- package/server/lib/dictation-clean.ts +1 -1
- package/server/lib/glasses-pairing.ts +444 -0
- package/server/lib/health-static-probes.ts +4 -2
- package/server/lib/network-policy.ts +64 -0
- package/server/lib/whisper-local.ts +4 -11
- package/server/lib/whisper-preview.ts +2 -2
- package/server/routes/health.ts +5 -0
- package/server/routes/pairing.ts +121 -0
- package/server/routes/prompt-drafts.ts +1 -1
package/server/lib/api-auth.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { RequestHandler } from 'express'
|
|
2
2
|
import { timingSafeTokenEqual } from './token-auth.js'
|
|
3
|
+
import { PAIRING_NONCE_BODY } from './glasses-pairing.js'
|
|
3
4
|
|
|
4
5
|
// Recovery/setup clients need these availability surfaces before they have a
|
|
5
6
|
// usable token. Keep private provider state and every mutation route out.
|
|
@@ -34,24 +35,48 @@ const TTS_PLAYBACK_CAPABILITY_PATH = /^\/tts\/play\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-
|
|
|
34
35
|
// callers that can send one; the ticket exists only for the ones that cannot.
|
|
35
36
|
const DISPLAY_STREAM_CAPABILITY_PATH = /^\/display-stream\/\d{1,15}\.[0-9a-f]{64}$/
|
|
36
37
|
|
|
38
|
+
// 6.67.0 glasses pairing (docs/pairing-contract.md). Exactly two public doors, each
|
|
39
|
+
// matched on METHOD and path, never through the method-agnostic set above:
|
|
40
|
+
// - POST /pairing/claim: a phone with no token yet presents the short-lived code.
|
|
41
|
+
// - GET /pairing/claim/<nonce>: the same phone polls for the decision. The route binds
|
|
42
|
+
// the poll to the claim's socket IP, and the token is released only after Allow.
|
|
43
|
+
// Minting, status and decisions stay behind the token AND a loopback socket.
|
|
44
|
+
const PAIRING_POLL_PATH = new RegExp(`^/pairing/claim/${PAIRING_NONCE_BODY}$`)
|
|
45
|
+
|
|
37
46
|
export function isPublicApiRequest(method: string, path: string): boolean {
|
|
38
47
|
if (PUBLIC_API_PATHS.has(path)) return true
|
|
48
|
+
if (method === 'POST' && path === '/pairing/claim') return true
|
|
49
|
+
if (method === 'GET' && PAIRING_POLL_PATH.test(path)) return true
|
|
39
50
|
if (method !== 'GET' && method !== 'HEAD') return false
|
|
40
51
|
return TTS_PLAYBACK_CAPABILITY_PATH.test(path)
|
|
41
52
|
|| DISPLAY_STREAM_CAPABILITY_PATH.test(path)
|
|
42
53
|
}
|
|
43
54
|
|
|
55
|
+
export interface RequireApiTokenOptions {
|
|
56
|
+
/**
|
|
57
|
+
* Called once per request that passed the token check (never for a public path).
|
|
58
|
+
* Pairing uses it to stamp `firstAuthAfterClaimAt`. A throw here is swallowed: an
|
|
59
|
+
* observer must never turn an authenticated request into a failure.
|
|
60
|
+
*/
|
|
61
|
+
onAuthenticated?: (req: Parameters<RequestHandler>[0]) => void
|
|
62
|
+
}
|
|
63
|
+
|
|
44
64
|
/** Global /api authentication boundary. Mount before all body parsers. */
|
|
45
|
-
export function requireApiToken(apiToken: string): RequestHandler {
|
|
65
|
+
export function requireApiToken(apiToken: string, options: RequireApiTokenOptions = {}): RequestHandler {
|
|
46
66
|
return (req, res, next) => {
|
|
47
67
|
if (isPublicApiRequest(req.method, req.path)) return next()
|
|
48
68
|
if (!timingSafeTokenEqual(req.headers['x-cos-token'], apiToken)) {
|
|
49
69
|
return res.status(401).json({
|
|
50
70
|
error: 'unauthorized',
|
|
71
|
+
// Stable on purpose: clients key off this value, not the message.
|
|
51
72
|
reason: 'pairing_token_rejected',
|
|
52
|
-
|
|
73
|
+
// Older COS Glasses builds, which cannot scan, show this message word for word.
|
|
74
|
+
message: 'Paste the pairing token from COS Control, or scan its code with COS Glasses 6.10.621 or newer.',
|
|
53
75
|
})
|
|
54
76
|
}
|
|
77
|
+
if (options.onAuthenticated) {
|
|
78
|
+
try { options.onAuthenticated(req) } catch { /* observer only */ }
|
|
79
|
+
}
|
|
55
80
|
next()
|
|
56
81
|
}
|
|
57
82
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
/** Public choices. Haiku
|
|
2
|
-
export const DICTATION_CLEAN_MODELS = ['sonnet', 'luna-5.6-fast'] as const
|
|
3
|
-
export type DictationCleanModel = typeof DICTATION_CLEAN_MODELS[number]
|
|
1
|
+
/** Public polish choices. Haiku is the cost-first default. */
|
|
2
|
+
export const DICTATION_CLEAN_MODELS = ['haiku', 'sonnet', 'luna-5.6-fast'] as const
|
|
3
|
+
export type DictationCleanModel = typeof DICTATION_CLEAN_MODELS[number]
|
|
4
4
|
export const LUNA_DICTATION_CLI_MODEL = 'gpt-5.6-luna-none-fast'
|
|
5
5
|
export function isDictationCleanModel(value: unknown): value is DictationCleanModel {
|
|
6
6
|
return value === 'sonnet' || value === 'luna-5.6-fast' || value === 'haiku'
|
|
@@ -32,7 +32,7 @@ export async function autoCleanDictation(
|
|
|
32
32
|
opts: { model?: string; signal?: AbortSignal } = {},
|
|
33
33
|
): Promise<string> {
|
|
34
34
|
if (opts.signal?.aborted) throw new Error('Auto-clean aborted')
|
|
35
|
-
const model = (opts.model || process.env.COS_DICTATION_AUTOCLEAN_MODEL || '
|
|
35
|
+
const model = (opts.model || process.env.COS_DICTATION_AUTOCLEAN_MODEL || 'haiku').toLowerCase()
|
|
36
36
|
if (!isDictationCleanModel(model)) throw new Error('Unsupported dictation cleanup model')
|
|
37
37
|
const cursor = model === 'luna-5.6-fast'
|
|
38
38
|
const binary = resolveProviderBinary(cursor ? 'cursor' : 'claude')
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
// Glasses pairing (6.67.0). Contract: docs/pairing-contract.md.
|
|
2
|
+
//
|
|
3
|
+
// COS Control mints a short-lived, single-use code over loopback and shows it as a QR.
|
|
4
|
+
// The phone claims it over Tailscale (or the home Wi-Fi while that is armed), the Mac
|
|
5
|
+
// user presses Allow, and only then does the phone's poll receive the pairing token,
|
|
6
|
+
// exactly once. Everything here is in memory: a restart loses every code and claim,
|
|
7
|
+
// and `bootId` changes so Control knows to show a new code.
|
|
8
|
+
//
|
|
9
|
+
// This module is pure. The clock, the random source, the token, the host list and
|
|
10
|
+
// the server name are injected so every rule in the contract is testable.
|
|
11
|
+
|
|
12
|
+
import { createHash, randomInt as cryptoRandomInt, randomUUID, timingSafeEqual } from 'node:crypto'
|
|
13
|
+
import { isRfc1918Ipv4, isTailscaleIpv4, normalizeRemoteIp } from './network-policy.js'
|
|
14
|
+
|
|
15
|
+
export const PAIRING_PROTOCOL_VERSION = 1
|
|
16
|
+
export const CROCKFORD_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
|
|
17
|
+
export const PAIRING_CODE_LENGTH = 8
|
|
18
|
+
export const PAIRING_CODE_TTL_MS = 5 * 60_000
|
|
19
|
+
export const PAIRING_PENDING_TTL_MS = 60_000
|
|
20
|
+
export const PAIRING_LAN_ARM_MS = 10 * 60_000
|
|
21
|
+
export const PAIRING_IP_LIMIT_PER_MINUTE = 5
|
|
22
|
+
export const PAIRING_IP_WINDOW_MS = 60_000
|
|
23
|
+
export const PAIRING_CODE_ATTEMPT_LIMIT = 20
|
|
24
|
+
/** Decided claims stay pollable this long, so a slow phone still sees `denied`. */
|
|
25
|
+
export const PAIRING_CLAIM_RETENTION_MS = 10 * 60_000
|
|
26
|
+
/** At most this many hosts in a QR, Tailscale first, so the QR stays small. */
|
|
27
|
+
export const PAIRING_MAX_QR_HOSTS = 3
|
|
28
|
+
/** The nonce body, unanchored. The single source for the route check and the public door in api-auth.ts. */
|
|
29
|
+
export const PAIRING_NONCE_BODY = '[A-Za-z0-9_-]{16,64}'
|
|
30
|
+
export const PAIRING_NONCE_PATTERN = new RegExp(`^${PAIRING_NONCE_BODY}$`)
|
|
31
|
+
/**
|
|
32
|
+
* Between hosts in the QR. `+` is in the QR alphanumeric set (`0-9 A-Z space $ % * + - . / :`);
|
|
33
|
+
* a comma is not, and would push a two-host code into byte mode. Clients accept `+` and `,`.
|
|
34
|
+
*/
|
|
35
|
+
export const PAIRING_QR_HOST_SEPARATOR = '+'
|
|
36
|
+
/** The QR grammar. Clients parse it; the server is the only writer. */
|
|
37
|
+
export const PAIRING_QR_PATTERN = /^COS1\/MAC\/[0-9A-HJKMNP-TV-Z]{8}\/(?:\d{1,3}(?:\.\d{1,3}){3}:\d{1,5})(?:\+\d{1,3}(?:\.\d{1,3}){3}:\d{1,5})*\/[0-9A-Z]+$/
|
|
38
|
+
/** Every character the server ever writes into a QR: QR alphanumeric mode, so the code stays small. */
|
|
39
|
+
export const PAIRING_QR_CHARSET = /^[0-9A-Z/:+.]+$/
|
|
40
|
+
|
|
41
|
+
export type PairingReason =
|
|
42
|
+
| 'expired'
|
|
43
|
+
| 'used'
|
|
44
|
+
| 'locked'
|
|
45
|
+
| 'rate_limited'
|
|
46
|
+
| 'not_loopback'
|
|
47
|
+
| 'not_allowed_network'
|
|
48
|
+
| 'unknown_code'
|
|
49
|
+
| 'denied'
|
|
50
|
+
| 'draining'
|
|
51
|
+
| 'bad_request'
|
|
52
|
+
|
|
53
|
+
export const PAIRING_REASON_STATUS: Record<PairingReason, number> = {
|
|
54
|
+
bad_request: 400,
|
|
55
|
+
not_loopback: 403,
|
|
56
|
+
not_allowed_network: 403,
|
|
57
|
+
denied: 403,
|
|
58
|
+
unknown_code: 404,
|
|
59
|
+
used: 409,
|
|
60
|
+
locked: 409,
|
|
61
|
+
expired: 410,
|
|
62
|
+
rate_limited: 429,
|
|
63
|
+
draining: 503,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const PAIRING_REASON_MESSAGE: Record<PairingReason, string> = {
|
|
67
|
+
bad_request: 'That pairing request was not understood. Scan the code again.',
|
|
68
|
+
not_loopback: 'Pairing codes are made only by COS Control on this Mac.',
|
|
69
|
+
not_allowed_network: 'Connect over Tailscale, or turn on "Allow pairing on this Wi-Fi" in COS Control.',
|
|
70
|
+
denied: 'Pairing was denied on the Mac.',
|
|
71
|
+
unknown_code: 'This code is not current. Show a new one in COS Control.',
|
|
72
|
+
used: 'This code was already used. Show a new one in COS Control.',
|
|
73
|
+
locked: 'Too many tries, or another device is waiting. Show a new code in COS Control.',
|
|
74
|
+
expired: 'Code expired: show a new one in COS Control.',
|
|
75
|
+
rate_limited: 'Too many tries. Wait a minute and try again.',
|
|
76
|
+
draining: 'Your Mac is updating. Try again in a minute.',
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type PairingHostKind = 'tailscale' | 'lan'
|
|
80
|
+
export interface PairingHost {
|
|
81
|
+
host: string
|
|
82
|
+
port: number
|
|
83
|
+
kind: PairingHostKind
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type ClaimState = 'pending' | 'allowed' | 'denied' | 'expired' | 'delivered'
|
|
87
|
+
|
|
88
|
+
export class PairingError extends Error {
|
|
89
|
+
readonly reason: PairingReason
|
|
90
|
+
readonly status: number
|
|
91
|
+
readonly retryAfterSeconds?: number
|
|
92
|
+
constructor(reason: PairingReason, retryAfterSeconds?: number) {
|
|
93
|
+
super(reason)
|
|
94
|
+
this.reason = reason
|
|
95
|
+
this.status = PAIRING_REASON_STATUS[reason]
|
|
96
|
+
this.retryAfterSeconds = retryAfterSeconds
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Code and QR grammar ─────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
export function generatePairingCode(randomInt: (max: number) => number = max => cryptoRandomInt(max)): string {
|
|
103
|
+
let code = ''
|
|
104
|
+
for (let i = 0; i < PAIRING_CODE_LENGTH; i++) code += CROCKFORD_ALPHABET[randomInt(CROCKFORD_ALPHABET.length)]
|
|
105
|
+
return code
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Uppercase, O→0, I/L→1, strip spaces and dashes. Null unless 8 Crockford characters remain. */
|
|
109
|
+
export function normalizePairingCode(input: unknown): string | null {
|
|
110
|
+
if (typeof input !== 'string' || input.length > 32) return null
|
|
111
|
+
const code = input.toUpperCase().replace(/[\s-]/g, '').replace(/O/g, '0').replace(/[IL]/g, '1')
|
|
112
|
+
if (code.length !== PAIRING_CODE_LENGTH) return null
|
|
113
|
+
for (const ch of code) if (!CROCKFORD_ALPHABET.includes(ch)) return null
|
|
114
|
+
return code
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function displayPairingCode(code: string): string {
|
|
118
|
+
return `${code.slice(0, 4)}-${code.slice(4)}`
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** `COS1/MAC/<CODE>/<host>:<port>[+<host>:<port>]/<EXP>`, EXP = unix seconds in base36 uppercase. */
|
|
122
|
+
export function buildPairingQr(code: string, hosts: readonly PairingHost[], expiresAtMs: number): string {
|
|
123
|
+
const hostList = hosts.map(h => `${h.host}:${h.port}`).join(PAIRING_QR_HOST_SEPARATOR)
|
|
124
|
+
const exp = Math.floor(expiresAtMs / 1000).toString(36).toUpperCase()
|
|
125
|
+
return `COS1/MAC/${code}/${hostList}/${exp}`
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Constant-time compare of two normalized codes. */
|
|
129
|
+
export function pairingCodesEqual(a: string, b: string): boolean {
|
|
130
|
+
const left = Buffer.from(a, 'utf8')
|
|
131
|
+
const right = Buffer.from(b, 'utf8')
|
|
132
|
+
return left.length === right.length && timingSafeEqual(left, right)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** First 2 hex characters of sha256(code): the only form of a code that is ever logged. */
|
|
136
|
+
export function pairingCodeTag(code: string): string {
|
|
137
|
+
return createHash('sha256').update(code).digest('hex').slice(0, 2)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Pairing hosts from the reachable-address list: Tailscale IPv4 first, then RFC1918
|
|
142
|
+
* only while LAN is armed. Never IPv6 or MagicDNS. Deduplicated, capped for QR size.
|
|
143
|
+
*/
|
|
144
|
+
export function pairingHostsFrom(
|
|
145
|
+
addresses: ReadonlyArray<{ address: string; kind: 'tailscale' | 'lan' | 'other' }>,
|
|
146
|
+
port: number,
|
|
147
|
+
lanArmed: boolean,
|
|
148
|
+
): PairingHost[] {
|
|
149
|
+
const seen = new Set<string>()
|
|
150
|
+
const tailscale: PairingHost[] = []
|
|
151
|
+
const lan: PairingHost[] = []
|
|
152
|
+
for (const entry of addresses) {
|
|
153
|
+
if (seen.has(entry.address)) continue
|
|
154
|
+
if (isTailscaleIpv4(entry.address)) {
|
|
155
|
+
seen.add(entry.address)
|
|
156
|
+
tailscale.push({ host: entry.address, port, kind: 'tailscale' })
|
|
157
|
+
} else if (lanArmed && entry.kind === 'lan' && isRfc1918Ipv4(entry.address)) {
|
|
158
|
+
seen.add(entry.address)
|
|
159
|
+
lan.push({ host: entry.address, port, kind: 'lan' })
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return [...tailscale, ...lan].slice(0, PAIRING_MAX_QR_HOSTS)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── State machine ───────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
interface CodeRecord {
|
|
168
|
+
code: string
|
|
169
|
+
expiresAt: number
|
|
170
|
+
attempts: number
|
|
171
|
+
status: 'active' | 'used' | 'locked'
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
interface ClaimRecord {
|
|
175
|
+
nonce: string
|
|
176
|
+
code: string
|
|
177
|
+
ip: string
|
|
178
|
+
at: number
|
|
179
|
+
state: ClaimState
|
|
180
|
+
decidedAt: number | null
|
|
181
|
+
deliveredAt: number | null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface PairingStateOptions {
|
|
185
|
+
now?: () => number
|
|
186
|
+
randomInt?: (max: number) => number
|
|
187
|
+
bootId?: string
|
|
188
|
+
/** The standing pairing token, read at delivery time so a rotation is honoured. */
|
|
189
|
+
token: () => string
|
|
190
|
+
serverName: () => string
|
|
191
|
+
/** Hosts for the QR, given whether LAN is armed right now. */
|
|
192
|
+
hosts: (lanArmed: boolean) => PairingHost[]
|
|
193
|
+
log?: (line: string) => void
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface MintResult {
|
|
197
|
+
code: string
|
|
198
|
+
display: string
|
|
199
|
+
qr: string | null
|
|
200
|
+
expiresAt: string
|
|
201
|
+
bootId: string
|
|
202
|
+
hosts: PairingHost[]
|
|
203
|
+
lanArmedUntil: string | null
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface PollResult {
|
|
207
|
+
state: ClaimState
|
|
208
|
+
token?: string
|
|
209
|
+
serverName?: string
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface PairingStatus {
|
|
213
|
+
code: { display: string; expiresAt: string; state: CodeRecord['status'] } | null
|
|
214
|
+
pending: { nonce: string; ip: string; at: string } | null
|
|
215
|
+
lastClaim: { ip: string; at: string; allowed: boolean } | null
|
|
216
|
+
firstAuthAfterClaimAt: string | null
|
|
217
|
+
lanArmedUntil: string | null
|
|
218
|
+
bootId: string
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const iso = (ms: number) => new Date(ms).toISOString()
|
|
222
|
+
|
|
223
|
+
export class PairingState {
|
|
224
|
+
readonly bootId: string
|
|
225
|
+
private readonly now: () => number
|
|
226
|
+
private readonly randomInt?: (max: number) => number
|
|
227
|
+
private readonly opts: PairingStateOptions
|
|
228
|
+
private readonly log: (line: string) => void
|
|
229
|
+
private current: CodeRecord | null = null
|
|
230
|
+
private pendingNonce: string | null = null
|
|
231
|
+
private readonly claims = new Map<string, ClaimRecord>()
|
|
232
|
+
private lastClaimNonce: string | null = null
|
|
233
|
+
private firstAuthAfterClaimAt: number | null = null
|
|
234
|
+
private lanArmedUntil: number | null = null
|
|
235
|
+
private readonly ipAttempts = new Map<string, number[]>()
|
|
236
|
+
|
|
237
|
+
constructor(opts: PairingStateOptions) {
|
|
238
|
+
this.opts = opts
|
|
239
|
+
this.now = opts.now ?? (() => Date.now())
|
|
240
|
+
this.randomInt = opts.randomInt
|
|
241
|
+
this.bootId = opts.bootId ?? randomUUID()
|
|
242
|
+
this.log = opts.log ?? (line => console.log(line))
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private lanArmed(at: number): boolean {
|
|
246
|
+
return this.lanArmedUntil != null && at < this.lanArmedUntil
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Lazy timers: a pending claim older than 60 s expires; old decided claims are dropped. */
|
|
250
|
+
private sweep(at: number): void {
|
|
251
|
+
if (this.pendingNonce) {
|
|
252
|
+
const pending = this.claims.get(this.pendingNonce)
|
|
253
|
+
if (!pending || pending.state !== 'pending') {
|
|
254
|
+
this.pendingNonce = null
|
|
255
|
+
} else if (at - pending.at >= PAIRING_PENDING_TTL_MS) {
|
|
256
|
+
pending.state = 'expired'
|
|
257
|
+
pending.decidedAt = at
|
|
258
|
+
this.pendingNonce = null
|
|
259
|
+
this.log(`[pairing] claim expired ip=${pending.ip} code#=${pairingCodeTag(pending.code)}`)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
for (const [nonce, claim] of this.claims) {
|
|
263
|
+
if (claim.state !== 'pending' && at - claim.at > PAIRING_CLAIM_RETENTION_MS && nonce !== this.lastClaimNonce) {
|
|
264
|
+
this.claims.delete(nonce)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (this.lanArmedUntil != null && at >= this.lanArmedUntil) this.lanArmedUntil = null
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** POST /pairing/code. A new code cancels the previous code and any pending claim. */
|
|
271
|
+
mint(input: { allowLan?: boolean } = {}): MintResult {
|
|
272
|
+
const at = this.now()
|
|
273
|
+
this.sweep(at)
|
|
274
|
+
if (this.pendingNonce) {
|
|
275
|
+
const pending = this.claims.get(this.pendingNonce)
|
|
276
|
+
if (pending && pending.state === 'pending') {
|
|
277
|
+
pending.state = 'expired'
|
|
278
|
+
pending.decidedAt = at
|
|
279
|
+
}
|
|
280
|
+
this.pendingNonce = null
|
|
281
|
+
}
|
|
282
|
+
this.lanArmedUntil = input.allowLan === true ? at + PAIRING_LAN_ARM_MS : null
|
|
283
|
+
const code = generatePairingCode(this.randomInt)
|
|
284
|
+
const expiresAt = at + PAIRING_CODE_TTL_MS
|
|
285
|
+
this.current = { code, expiresAt, attempts: 0, status: 'active' }
|
|
286
|
+
const hosts = this.opts.hosts(this.lanArmed(at))
|
|
287
|
+
const qr = hosts.length > 0 ? buildPairingQr(code, hosts, expiresAt) : null
|
|
288
|
+
this.log(`[pairing] code minted hosts=${hosts.map(h => h.host).join(',') || 'none'} lan=${this.lanArmedUntil ? 'armed' : 'off'} code#=${pairingCodeTag(code)}`)
|
|
289
|
+
return {
|
|
290
|
+
code,
|
|
291
|
+
display: displayPairingCode(code),
|
|
292
|
+
qr,
|
|
293
|
+
expiresAt: iso(expiresAt),
|
|
294
|
+
bootId: this.bootId,
|
|
295
|
+
hosts,
|
|
296
|
+
lanArmedUntil: this.lanArmedUntil != null ? iso(this.lanArmedUntil) : null,
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Throws PairingError unless `ip` may claim at all (network, then per-IP limit). */
|
|
301
|
+
private admitClaimer(ip: string, at: number): void {
|
|
302
|
+
if (!isTailscaleIpv4(ip) && !(isRfc1918Ipv4(ip) && this.lanArmed(at))) {
|
|
303
|
+
throw new PairingError('not_allowed_network')
|
|
304
|
+
}
|
|
305
|
+
const recent = (this.ipAttempts.get(ip) ?? []).filter(t => at - t < PAIRING_IP_WINDOW_MS)
|
|
306
|
+
if (recent.length >= PAIRING_IP_LIMIT_PER_MINUTE) {
|
|
307
|
+
this.ipAttempts.set(ip, recent)
|
|
308
|
+
const retry = Math.max(1, Math.ceil((PAIRING_IP_WINDOW_MS - (at - recent[0])) / 1000))
|
|
309
|
+
throw new PairingError('rate_limited', retry)
|
|
310
|
+
}
|
|
311
|
+
recent.push(at)
|
|
312
|
+
this.ipAttempts.set(ip, recent)
|
|
313
|
+
if (this.ipAttempts.size > 1000) {
|
|
314
|
+
for (const [key, times] of this.ipAttempts) {
|
|
315
|
+
if (times.every(t => at - t >= PAIRING_IP_WINDOW_MS)) this.ipAttempts.delete(key)
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** POST /pairing/claim. Returns normally for 202 `{ pending: true }`; throws PairingError otherwise. */
|
|
321
|
+
claim(input: { code: unknown; nonce: unknown }, socketIp: string): void {
|
|
322
|
+
const at = this.now()
|
|
323
|
+
this.sweep(at)
|
|
324
|
+
const ip = normalizeRemoteIp(socketIp)
|
|
325
|
+
this.admitClaimer(ip, at)
|
|
326
|
+
const nonce = typeof input.nonce === 'string' && PAIRING_NONCE_PATTERN.test(input.nonce) ? input.nonce : null
|
|
327
|
+
const code = normalizePairingCode(input.code)
|
|
328
|
+
if (!nonce || !code) {
|
|
329
|
+
this.log(`[pairing] claim ip=${ip} result=bad_request`)
|
|
330
|
+
throw new PairingError('bad_request')
|
|
331
|
+
}
|
|
332
|
+
const tag = pairingCodeTag(code)
|
|
333
|
+
const refuse = (reason: PairingReason): never => {
|
|
334
|
+
this.log(`[pairing] claim ip=${ip} result=${reason} code#=${tag}`)
|
|
335
|
+
throw new PairingError(reason)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Idempotent for the same nonce and code from the same socket IP.
|
|
339
|
+
const existing = this.claims.get(nonce)
|
|
340
|
+
if (existing) {
|
|
341
|
+
if (!pairingCodesEqual(existing.code, code) || existing.ip !== ip) refuse('locked')
|
|
342
|
+
if (existing.state === 'pending') return
|
|
343
|
+
if (existing.state === 'denied') refuse('denied')
|
|
344
|
+
if (existing.state === 'expired') refuse('expired')
|
|
345
|
+
// allowed or delivered: the claim already succeeded; the poll carries the token.
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const current = this.current
|
|
350
|
+
if (!current) refuse('unknown_code')
|
|
351
|
+
const record = current as CodeRecord
|
|
352
|
+
const matches = pairingCodesEqual(record.code, code)
|
|
353
|
+
if (at >= record.expiresAt) refuse(matches ? 'expired' : 'unknown_code')
|
|
354
|
+
if (record.status === 'locked') refuse('locked')
|
|
355
|
+
// A used code has nothing left to guess: further attempts neither count nor lock it,
|
|
356
|
+
// so a tailnet peer cannot turn "used" into "locked" under the claim that won.
|
|
357
|
+
if (record.status === 'used') {
|
|
358
|
+
if (!matches) refuse('unknown_code')
|
|
359
|
+
refuse(this.pendingNonce ? 'locked' : 'used')
|
|
360
|
+
}
|
|
361
|
+
// Every attempt while a code is live counts against it, right or wrong.
|
|
362
|
+
record.attempts++
|
|
363
|
+
if (record.attempts > PAIRING_CODE_ATTEMPT_LIMIT) {
|
|
364
|
+
record.status = 'locked'
|
|
365
|
+
refuse('locked')
|
|
366
|
+
}
|
|
367
|
+
if (!matches) refuse('unknown_code')
|
|
368
|
+
|
|
369
|
+
record.status = 'used'
|
|
370
|
+
this.claims.set(nonce, { nonce, code, ip, at, state: 'pending', decidedAt: null, deliveredAt: null })
|
|
371
|
+
this.pendingNonce = nonce
|
|
372
|
+
this.lastClaimNonce = nonce
|
|
373
|
+
this.log(`[pairing] claim ip=${ip} result=pending code#=${tag}`)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** GET /pairing/claim/:nonce. The token is returned once; `allowed` then reads `delivered`. */
|
|
377
|
+
poll(nonce: string, socketIp: string): PollResult {
|
|
378
|
+
const at = this.now()
|
|
379
|
+
this.sweep(at)
|
|
380
|
+
const ip = normalizeRemoteIp(socketIp)
|
|
381
|
+
const claim = this.claims.get(nonce)
|
|
382
|
+
if (!claim) throw new PairingError('unknown_code')
|
|
383
|
+
if (claim.ip !== ip) {
|
|
384
|
+
this.log(`[pairing] poll ip=${ip} result=not_allowed_network code#=${pairingCodeTag(claim.code)}`)
|
|
385
|
+
throw new PairingError('not_allowed_network')
|
|
386
|
+
}
|
|
387
|
+
if (claim.state === 'allowed') {
|
|
388
|
+
claim.state = 'delivered'
|
|
389
|
+
claim.deliveredAt = at
|
|
390
|
+
this.log(`[pairing] token delivered ip=${ip} code#=${pairingCodeTag(claim.code)}`)
|
|
391
|
+
return { state: 'allowed', token: this.opts.token(), serverName: this.opts.serverName() }
|
|
392
|
+
}
|
|
393
|
+
if (claim.state === 'delivered') return { state: 'delivered', serverName: this.opts.serverName() }
|
|
394
|
+
return { state: claim.state }
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** POST /pairing/decision. */
|
|
398
|
+
decide(nonce: unknown, allow: unknown): void {
|
|
399
|
+
const at = this.now()
|
|
400
|
+
this.sweep(at)
|
|
401
|
+
if (typeof nonce !== 'string' || typeof allow !== 'boolean') throw new PairingError('bad_request')
|
|
402
|
+
const claim = this.claims.get(nonce)
|
|
403
|
+
if (!claim) throw new PairingError('unknown_code')
|
|
404
|
+
if (claim.state === 'expired') throw new PairingError('expired')
|
|
405
|
+
if (claim.state !== 'pending') throw new PairingError('used')
|
|
406
|
+
claim.state = allow ? 'allowed' : 'denied'
|
|
407
|
+
claim.decidedAt = at
|
|
408
|
+
this.pendingNonce = null
|
|
409
|
+
this.lastClaimNonce = nonce
|
|
410
|
+
if (allow) this.firstAuthAfterClaimAt = null
|
|
411
|
+
this.log(`[pairing] decision ip=${claim.ip} result=${allow ? 'allowed' : 'denied'} code#=${pairingCodeTag(claim.code)}`)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Called after a request passes requireApiToken. Stamps `firstAuthAfterClaimAt` once,
|
|
416
|
+
* for the first authenticated request from the allowed claim's IP after its token
|
|
417
|
+
* was delivered, so another phone's traffic can never turn Control green.
|
|
418
|
+
*/
|
|
419
|
+
noteAuthenticated(socketIp: string | undefined): void {
|
|
420
|
+
if (this.firstAuthAfterClaimAt != null || !this.lastClaimNonce) return
|
|
421
|
+
const claim = this.claims.get(this.lastClaimNonce)
|
|
422
|
+
if (!claim || claim.state !== 'delivered') return
|
|
423
|
+
if (normalizeRemoteIp(socketIp ?? '') !== claim.ip) return
|
|
424
|
+
this.firstAuthAfterClaimAt = this.now()
|
|
425
|
+
this.log(`[pairing] first authenticated request ip=${claim.ip}`)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** GET /pairing/status. */
|
|
429
|
+
status(): PairingStatus {
|
|
430
|
+
const at = this.now()
|
|
431
|
+
this.sweep(at)
|
|
432
|
+
const current = this.current && at < this.current.expiresAt ? this.current : null
|
|
433
|
+
const pending = this.pendingNonce ? this.claims.get(this.pendingNonce) ?? null : null
|
|
434
|
+
const last = this.lastClaimNonce ? this.claims.get(this.lastClaimNonce) ?? null : null
|
|
435
|
+
return {
|
|
436
|
+
code: current ? { display: displayPairingCode(current.code), expiresAt: iso(current.expiresAt), state: current.status } : null,
|
|
437
|
+
pending: pending ? { nonce: pending.nonce, ip: pending.ip, at: iso(pending.at) } : null,
|
|
438
|
+
lastClaim: last ? { ip: last.ip, at: iso(last.at), allowed: last.state === 'allowed' || last.state === 'delivered' } : null,
|
|
439
|
+
firstAuthAfterClaimAt: this.firstAuthAfterClaimAt != null ? iso(this.firstAuthAfterClaimAt) : null,
|
|
440
|
+
lanArmedUntil: this.lanArmedUntil != null ? iso(this.lanArmedUntil) : null,
|
|
441
|
+
bootId: this.bootId,
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
@@ -123,9 +123,11 @@ async function probePython(): Promise<string> {
|
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
async function probeClaude(): Promise<{ value: string; available: boolean }> {
|
|
126
|
+
export async function probeClaude(): Promise<{ value: string; available: boolean }> {
|
|
127
127
|
try {
|
|
128
|
-
const
|
|
128
|
+
const resolved = resolveProviderBinary('claude')
|
|
129
|
+
if (!resolved.ok) return { value: `unresolved (${resolved.detail})`, available: false }
|
|
130
|
+
const result = await execute(resolved.path, ['--version'])
|
|
129
131
|
return { value: firstNonemptyLine(result.stdout) ?? 'available', available: true }
|
|
130
132
|
} catch {
|
|
131
133
|
return { value: 'error', available: false }
|
|
@@ -40,3 +40,67 @@ export function isAllowedNetworkOrigin(origin: string): boolean {
|
|
|
40
40
|
return false
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
/** True for RFC1918 private IPv4 (10/8, 172.16/12, 192.168/16). */
|
|
45
|
+
export function isRfc1918Ipv4(value: string): boolean {
|
|
46
|
+
const octets = parseIpv4(normalizeRemoteIp(value))
|
|
47
|
+
if (!octets) return false
|
|
48
|
+
return octets[0] === 10 ||
|
|
49
|
+
(octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) ||
|
|
50
|
+
(octets[0] === 192 && octets[1] === 168)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Loopback in every form a Node socket reports it: 127.0.0.0/8, ::1, ::ffff:127.x. */
|
|
54
|
+
export function isLoopbackAddress(value: string | undefined | null): boolean {
|
|
55
|
+
const address = value ?? ''
|
|
56
|
+
return address === '::1' || address === '127.0.0.1'
|
|
57
|
+
|| address.startsWith('127.') || address.startsWith('::ffff:127.')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Loopback check on the SOCKET, never `req.ip` or `X-Forwarded-For`. The server never
|
|
62
|
+
* enables trust-proxy, but reading the socket keeps this true even if someone does.
|
|
63
|
+
*/
|
|
64
|
+
export function isLoopbackSocket(req: { socket?: { remoteAddress?: string | undefined } | null }): boolean {
|
|
65
|
+
return isLoopbackAddress(req.socket?.remoteAddress)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type ReachableIpv4Kind = 'tailscale' | 'lan' | 'other'
|
|
69
|
+
export interface ReachableIpv4 {
|
|
70
|
+
name: string
|
|
71
|
+
address: string
|
|
72
|
+
kind: ReachableIpv4Kind
|
|
73
|
+
}
|
|
74
|
+
type InterfaceInfo = { address: string; family: string | number; internal: boolean }
|
|
75
|
+
|
|
76
|
+
// Interfaces a phone can never reach: macOS VPN tunnels that are not Tailscale's
|
|
77
|
+
// address, Internet Sharing / VM bridges, AirDrop (awdl) and low-latency WLAN (llw),
|
|
78
|
+
// and VM host-only networks.
|
|
79
|
+
const UNREACHABLE_INTERFACE = /^(utun|bridge|awdl|llw|vmnet|vboxnet)/
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Every external IPv4 address on this machine, Tailscale first. `kind` is
|
|
83
|
+
* `tailscale` for 100.64/10 (or a Linux `tailscale*` interface), `lan` for RFC1918 on
|
|
84
|
+
* an interface a phone can reach, `other` for the rest. Shared by the startup print
|
|
85
|
+
* and the pairing host list.
|
|
86
|
+
*/
|
|
87
|
+
export function reachableIpv4Addresses(
|
|
88
|
+
nets: Record<string, InterfaceInfo[] | undefined>,
|
|
89
|
+
): ReachableIpv4[] {
|
|
90
|
+
const out: ReachableIpv4[] = []
|
|
91
|
+
for (const [name, infos] of Object.entries(nets)) {
|
|
92
|
+
for (const info of infos ?? []) {
|
|
93
|
+
const family = typeof info.family === 'number' ? (info.family === 4 ? 'IPv4' : 'IPv6') : info.family
|
|
94
|
+
if (family !== 'IPv4' || info.internal) continue
|
|
95
|
+
const kind: ReachableIpv4Kind = isTailscaleIpv4(info.address) || name.startsWith('tailscale')
|
|
96
|
+
? 'tailscale'
|
|
97
|
+
: isRfc1918Ipv4(info.address) && !UNREACHABLE_INTERFACE.test(name) ? 'lan' : 'other'
|
|
98
|
+
out.push({ name, address: info.address, kind })
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const rank = (kind: ReachableIpv4Kind) => (kind === 'tailscale' ? 0 : kind === 'lan' ? 1 : 2)
|
|
102
|
+
return out
|
|
103
|
+
.map((entry, index) => ({ entry, index }))
|
|
104
|
+
.sort((a, b) => rank(a.entry.kind) - rank(b.entry.kind) || a.index - b.index)
|
|
105
|
+
.map(({ entry }) => entry)
|
|
106
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveWhisperBinary } from '../../bin/whisper-runtime.cjs'
|
|
1
2
|
// Local Whisper transcription via whisper-server (persistent) or whisper-cli (fallback)
|
|
2
3
|
// Eliminates 1-3s OpenAI API round-trip by running inference on M3 Ultra locally.
|
|
3
4
|
//
|
|
@@ -130,17 +131,9 @@ interface WhisperJsonResponse {
|
|
|
130
131
|
text?: unknown
|
|
131
132
|
}
|
|
132
133
|
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
function resolveWhisperBin(name: string): string {
|
|
137
|
-
for (const prefix of ['/opt/homebrew/bin', '/usr/local/bin']) {
|
|
138
|
-
if (existsSync(`${prefix}/${name}`)) return `${prefix}/${name}`
|
|
139
|
-
}
|
|
140
|
-
return `/opt/homebrew/bin/${name}`
|
|
141
|
-
}
|
|
142
|
-
const WHISPER_CLI = resolveWhisperBin('whisper-cli')
|
|
143
|
-
const WHISPER_SERVER = resolveWhisperBin('whisper-server')
|
|
134
|
+
// Same resolution as setup and preview; a broken explicit override never falls through to Homebrew.
|
|
135
|
+
const WHISPER_CLI = resolveWhisperBinary('whisper-cli') ?? '/__cos_whisper_unavailable__/whisper-cli'
|
|
136
|
+
const WHISPER_SERVER = resolveWhisperBinary('whisper-server') ?? '/__cos_whisper_unavailable__/whisper-server'
|
|
144
137
|
const MODEL_DIR = join(process.env.HOME ?? homedir(), '.local/share/whisper-models')
|
|
145
138
|
export const WHISPER_TURBO_MODEL_PATH = join(MODEL_DIR, 'ggml-large-v3-turbo.bin')
|
|
146
139
|
export const WHISPER_LARGE_V3_MODEL_PATH = join(MODEL_DIR, 'ggml-large-v3.bin')
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveWhisperBinary } from '../../bin/whisper-runtime.cjs'
|
|
1
2
|
// Adaptive provisional transcription:
|
|
2
3
|
// Balanced -> isolated Small.en cosmetic preview + Turbo live commit
|
|
3
4
|
// Max -> isolated Turbo cosmetic preview + Large-v3 live commit
|
|
@@ -55,8 +56,7 @@ export const WHISPER_SMALL_EN_MODEL_PATH = join(MODEL_DIR, 'ggml-small.en.bin')
|
|
|
55
56
|
const WHISPER_TURBO_MODEL_PATH = join(MODEL_DIR, 'ggml-large-v3-turbo.bin')
|
|
56
57
|
const VAD_MODEL_PATH = join(MODEL_DIR, 'ggml-silero-v5.1.2.bin')
|
|
57
58
|
const VAD_ENABLED = process.env.COS_WHISPER_VAD !== '0'
|
|
58
|
-
const WHISPER_SERVER =
|
|
59
|
-
.find(existsSync) ?? '/opt/homebrew/bin/whisper-server'
|
|
59
|
+
const WHISPER_SERVER = resolveWhisperBinary('whisper-server') ?? '/__cos_whisper_unavailable__/whisper-server'
|
|
60
60
|
const PREVIEW_PORT = 8177
|
|
61
61
|
const PREVIEW_URL = `http://127.0.0.1:${PREVIEW_PORT}`
|
|
62
62
|
const PROCESS_PROBE_TIMEOUT_MS = 2_000
|
package/server/routes/health.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DICTATION_CLEAN_MODELS } from '../lib/dictation-clean-models.js'
|
|
2
|
+
import { PAIRING_PROTOCOL_VERSION } from '../lib/glasses-pairing.js'
|
|
2
3
|
import { taskBridgeAvailable } from '../lib/task-bridge.js'
|
|
3
4
|
import { Router } from 'express'
|
|
4
5
|
import { isWorthRecovering } from '../lib/quarantine-auto-recover.js'
|
|
@@ -465,6 +466,10 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
465
466
|
...(voiceProvenance ? { voice_provenance: voiceProvenance } : {}),
|
|
466
467
|
capabilities: {
|
|
467
468
|
dictationCleanup: { models: DICTATION_CLEAN_MODELS },
|
|
469
|
+
// 6.67.0: glasses pairing (docs/pairing-contract.md). Public on purpose: the phone
|
|
470
|
+
// reads it before it holds a token, and an older server omits it, which is how
|
|
471
|
+
// the phone knows to say "Update COS on your Mac (server 6.67 or newer)".
|
|
472
|
+
pairing: { version: PAIRING_PROTOCOL_VERSION },
|
|
468
473
|
// Advertised on the PUBLIC health route on purpose: a client deciding whether
|
|
469
474
|
// to request a display ticket may not hold a usable token yet, and an old
|
|
470
475
|
// server simply omits this key, which is how a new client detects it.
|