@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/.env.example
CHANGED
|
@@ -340,3 +340,8 @@ BIND_HOST=0.0.0.0
|
|
|
340
340
|
# additionally requires a client that acknowledges displaying continue_note.
|
|
341
341
|
# Keep this in ~/.cos-glasses/.env; Control does not yet persist it on update.
|
|
342
342
|
COS_CONTINUE_FULL_PERMISSIONS=1
|
|
343
|
+
|
|
344
|
+
# Optional absolute executable paths for COS-managed whisper.cpp.
|
|
345
|
+
# Unset uses Homebrew then PATH. An invalid explicit path fails closed.
|
|
346
|
+
# COS_WHISPER_CLI_BIN=/absolute/path/to/whisper-cli
|
|
347
|
+
# COS_WHISPER_SERVER_BIN=/absolute/path/to/whisper-server
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,27 @@
|
|
|
1
|
+
## 6.67.0 — 2026-10-09 — glasses pairing
|
|
2
|
+
|
|
3
|
+
- Pair COS Glasses with your Mac by scanning a code instead of pasting the pairing token. COS Control shows a QR and a short code; the phone claims it, and nothing happens until you press Allow on the Mac. Pairs with COS Glasses 6.10.621 and the COS Control pairing release. Contract: `docs/pairing-contract.md`.
|
|
4
|
+
- `GET /api/health` adds `capabilities.pairing = { version: 1 }`. An older server leaves it out, which is how the phone knows to say "Update COS on your Mac".
|
|
5
|
+
- `POST /api/pairing/code` (token, and only from this Mac) makes an 8-character code, good for 5 minutes and one use, plus the QR text `COS1/MAC/<CODE>/<host>:<port>[+…]/<EXP>`, which uses only characters a QR can pack tightly. Tailscale addresses come first. A Wi-Fi address is offered only when "Allow pairing on this Wi-Fi" is on, and that lasts 10 minutes. A new code cancels the old one and any claim waiting for Allow.
|
|
6
|
+
- `POST /api/pairing/claim` and `GET /api/pairing/claim/<nonce>` need no token. A claim is accepted over Tailscale, or over the home Wi-Fi only while that is allowed. It waits up to 60 seconds for Allow. The phone then reads the pairing token once; a second read says `delivered` without it. The poll must come from the same address as the claim, and keeps answering during an update so an allowed token is never lost.
|
|
7
|
+
- `GET /api/pairing/status` and `POST /api/pairing/decision` (token, and only from this Mac) are how Control shows "wants to pair: Allow / Deny" and turns green, which happens only when the new phone makes its first signed-in request.
|
|
8
|
+
- Limits: 5 claims a minute per address, 20 tries per code (then it locks until a new code). The limits use the real connection address, never `X-Forwarded-For`.
|
|
9
|
+
- Everything is kept in memory. A restart forgets every code (`unknown_code`, a new `bootId`). The pairing routes write no files, so they never hold up an update; during one they answer `draining`.
|
|
10
|
+
- Logs carry the address, the result and a 2-character hash of the code, never the code, the nonce, the token or the QR text.
|
|
11
|
+
- A rejected token now says "Paste the pairing token from COS Control, or scan its code with COS Glasses 6.10.621 or newer." The `reason` stays `pairing_token_rejected`.
|
|
12
|
+
- The pairing token is still the only standing credential. See SECURITY.md.
|
|
13
|
+
- Compatibility: nothing changes for existing users. Pasting the pairing token still works, and older COS Control and COS Glasses builds keep working with this server. Scanning needs COS Glasses 6.10.621 and the COS Control release with the Glasses setup section. The QR needs Tailscale on both the Mac and the iPhone, or the 10-minute "Allow pairing on this Wi-Fi" switch at home.
|
|
14
|
+
- `docs/pairing-fixture.json` is a parity fixture the glasses app copies: the alphabet, real QR text, the reasons and the limits, checked against the server's code by its tests.
|
|
15
|
+
|
|
16
|
+
## 6.66.0 — 2026-10-08 — Managed voice setup and Haiku polish
|
|
17
|
+
|
|
18
|
+
- Advertise Haiku, Sonnet and GPT-5.6 Luna Fast. Default omitted-model polish to Haiku for lower cost, while honoring explicit model choices and retaining the transcript on failure.
|
|
19
|
+
- Glasses app 6.10.616 explicitly sends the chosen model and already works with server 6.65.0.
|
|
20
|
+
- Setup, live preview, committed transcription and HQ share explicit `COS_WHISPER_CLI_BIN` / `COS_WHISPER_SERVER_BIN` resolution. A broken explicit path fails closed; existing Homebrew/PATH detection remains the fallback.
|
|
21
|
+
- COS Control can prepare Turbo first for a local benchmark, then the chosen tier, without overwriting saved voice settings. Both preparation flags require `--setup-transcription --prepare-only`.
|
|
22
|
+
- Pairs with COS Control 0.5.273 and the signed/notarized whisper.cpp 1.9.1 runtime.
|
|
23
|
+
- Claude health probes resolve the installed absolute binary path, including managed-service environments without the interactive shell PATH.
|
|
24
|
+
|
|
1
25
|
## 6.65.0 — 2026-10-07 — Work search by meaning, and task dates
|
|
2
26
|
|
|
3
27
|
- `POST /api/work/search` `{ query, domain?, scope?, ids? }` finds the Work card the user means, not only the words they typed. Pairs with COS Control 0.5.259, which shows its own word matches at once and asks this after a pause.
|
package/README.md
CHANGED
|
@@ -646,18 +646,24 @@ and counts only, never a speaker name, path or error text; those stay in
|
|
|
646
646
|
|
|
647
647
|
## Final dictation polish (6.61.5)
|
|
648
648
|
|
|
649
|
-
With glasses app 6.10.
|
|
650
|
-
(the default,
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
649
|
+
With glasses app 6.10.616, **Settings → Voice → Polish model** offers **Haiku 5.5**
|
|
650
|
+
(the lower-cost default), **Sonnet 5.5** (higher-quality polish), and **GPT-5.6 Luna Fast**.
|
|
651
|
+
Haiku and Sonnet use your Claude CLI sign-in; Luna uses your Cursor CLI sign-in.
|
|
652
|
+
Luna uses `gpt-5.6-luna-none-fast` and needs server 6.61.5. Existing explicit choices
|
|
653
|
+
are preserved. Unset or unknown app preferences use Haiku. Both phone text and
|
|
654
|
+
recovered audio drafts honor the choice. Existing server 6.65.0 already accepts
|
|
655
|
+
explicit Haiku requests, so the new app does not require a server update.
|
|
656
|
+
|
|
657
|
+
The next server release also defaults omitted-model requests to Haiku and advertises
|
|
658
|
+
all three options in `capabilities.dictationCleanup.models`. Until that release,
|
|
659
|
+
6.65.0 still defaults requests without a model to Sonnet; 6.10.616 sends its choice
|
|
660
|
+
explicitly. This is a polish-model change, not a change to the speech recognition engine.
|
|
654
661
|
|
|
655
662
|
Cleanup uses subscription CLIs only, with no API-key fallback or automatic model
|
|
656
663
|
substitution. A failed or unavailable cleanup keeps the glossary-corrected
|
|
657
664
|
transcript. Turn **Final polish (AI)** off to skip the model call. Claude tools
|
|
658
665
|
and hooks are disabled; Cursor runs in read-only ask mode with a temporary profile
|
|
659
666
|
and empty workspace. Live preview and canonical meeting transcription are unchanged.
|
|
660
|
-
Older clients may still explicitly request Haiku.
|
|
661
667
|
|
|
662
668
|
## HQ dictation
|
|
663
669
|
|
|
@@ -917,3 +923,23 @@ This is a local owner instance, separate from app.gotcos.com tenants and hosted
|
|
|
917
923
|
## Provider parity in 6.62.0
|
|
918
924
|
|
|
919
925
|
See [provider capabilities and limits](docs/provider-parity.md) for Continue permissions, client compatibility, hook trust, observation limits and rollback. Glasses 6.10.600 shows the server's Continue note. Older clients retain Cursor Ask behavior; forks remain read-only.
|
|
926
|
+
|
|
927
|
+
### Managed Whisper runtime (6.66.0 candidate)
|
|
928
|
+
|
|
929
|
+
COS Control 0.5.272 prepares a signed, notarized whisper.cpp 1.9.1 runtime during
|
|
930
|
+
voice setup on Apple silicon. It supplies absolute `COS_WHISPER_CLI_BIN` and
|
|
931
|
+
`COS_WHISPER_SERVER_BIN` paths to the server; Homebrew is not needed for that flow.
|
|
932
|
+
Direct server installs still fall back to Homebrew and then PATH when overrides
|
|
933
|
+
are unset. A configured but missing/non-executable override is an error, never a
|
|
934
|
+
silent change of runtime.
|
|
935
|
+
|
|
936
|
+
`--setup-transcription --prepare-only --voice-benchmark-prepare` prepares Turbo
|
|
937
|
+
without changing saved tier keys. Control measures the device and follows with
|
|
938
|
+
`--setup-transcription --prepare-only --transcription-tier balanced|max
|
|
939
|
+
--preserve-voice-settings`. The latter prepares all required models, including
|
|
940
|
+
Large-v3 for saved-meeting HQ in both tiers. Applying a tier remains Control's
|
|
941
|
+
transactional action. Downloading or cancelling preparation does not change it.
|
|
942
|
+
Runtime/model setup is local; the existing provider sign-in requirements remain.
|
|
943
|
+
|
|
944
|
+
Candidate only: publish the versioned runtime asset and server package before
|
|
945
|
+
promoting the matching Control appcast entry.
|
package/SECURITY.md
CHANGED
|
@@ -27,6 +27,22 @@ reproduction you have. Do not include real transcripts.
|
|
|
27
27
|
|
|
28
28
|
- The pairing token (`X-Cos-Token`) is the only credential. Rotating it
|
|
29
29
|
invalidates every display-stream ticket; there is no server-side ticket store.
|
|
30
|
+
- Pairing codes (6.67.0, `docs/pairing-contract.md`) are not credentials. A code
|
|
31
|
+
lives 5 minutes in memory, works once, is made only over loopback with the token,
|
|
32
|
+
and releases the pairing token only after someone presses Allow on the Mac, and
|
|
33
|
+
then only once, to the address that claimed it. Claims are accepted over Tailscale
|
|
34
|
+
(100.64.0.0/10), or a private Wi-Fi address only while the user has allowed it for
|
|
35
|
+
10 minutes. Limits: 5 claims a minute per connection address, 20 tries per code.
|
|
36
|
+
Codes, nonces, tokens and QR text are never logged. The pairing token is still the
|
|
37
|
+
only standing credential.
|
|
38
|
+
- A device on your tailnet can lock a live pairing code by guessing at it 20 times.
|
|
39
|
+
This is accepted: COS Control says why and offers a new code, and a code already
|
|
40
|
+
claimed is never locked by later guesses.
|
|
41
|
+
- While "Allow pairing on this Wi-Fi" is on, the pairing token reaches the phone over
|
|
42
|
+
plain HTTP on that Wi-Fi, as the pasted token always has. Use it only at home; it
|
|
43
|
+
turns itself off after 10 minutes. Over Tailscale the traffic is encrypted.
|
|
44
|
+
- Behind `tailscale serve` or `funnel`, requests arrive from a loopback proxy, so
|
|
45
|
+
every claim is refused (`not_allowed_network`): pairing fails closed there.
|
|
30
46
|
- `GET /api/display-stream` is public by design and returns lifecycle events
|
|
31
47
|
only. Content requires a ticket or the token header. See
|
|
32
48
|
`server/routes/display.ts` for the allowlist.
|
package/bin/cli.cjs
CHANGED
|
@@ -25,7 +25,12 @@ const { homedir } = require('os')
|
|
|
25
25
|
const PKG_ROOT = resolve(__dirname, '..')
|
|
26
26
|
const CONFIG_DIR = join(homedir(), '.cos-glasses')
|
|
27
27
|
const PREPARE_ONLY = process.argv.includes('--prepare-only')
|
|
28
|
+
const VOICE_BENCHMARK_PREPARE = process.argv.includes('--voice-benchmark-prepare')
|
|
28
29
|
const SETUP_TRANSCRIPTION = process.argv.includes('--setup-transcription')
|
|
30
|
+
if ((VOICE_BENCHMARK_PREPARE || process.argv.includes('--preserve-voice-settings')) && !(SETUP_TRANSCRIPTION && PREPARE_ONLY)) {
|
|
31
|
+
console.error('Voice preparation flags require --setup-transcription --prepare-only.');
|
|
32
|
+
process.exit(64)
|
|
33
|
+
}
|
|
29
34
|
const SETUP_SPEAKER_MODEL = process.argv.includes('--setup-speaker-model')
|
|
30
35
|
const HOOKS_ACTION = process.argv.includes('--hooks') ? process.argv[process.argv.indexOf('--hooks') + 1] : undefined
|
|
31
36
|
function optionValue(name) {
|
|
@@ -540,12 +545,15 @@ function upsertEnvValue(file, key, value) {
|
|
|
540
545
|
chmodSync(file, 0o600)
|
|
541
546
|
}
|
|
542
547
|
|
|
543
|
-
if (SETUP_TRANSCRIPTION) {
|
|
548
|
+
if (SETUP_TRANSCRIPTION && !VOICE_BENCHMARK_PREPARE) {
|
|
549
|
+
const preserveVoiceSettings = process.argv.includes('--preserve-voice-settings')
|
|
544
550
|
const previewModel = TRANSCRIPTION_TIER === 'max' ? 'turbo' : 'small.en'
|
|
545
551
|
const commitModel = TRANSCRIPTION_TIER === 'max' ? 'large-v3' : 'turbo'
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
552
|
+
if (!preserveVoiceSettings) {
|
|
553
|
+
upsertEnvValue(ENV_FILE, 'COS_WHISPER_TRANSCRIPTION_TIER', TRANSCRIPTION_TIER)
|
|
554
|
+
upsertEnvValue(ENV_FILE, 'COS_WHISPER_PREVIEW_MODEL', previewModel)
|
|
555
|
+
upsertEnvValue(ENV_FILE, 'COS_WHISPER_COMMIT_MODEL', commitModel)
|
|
556
|
+
}
|
|
549
557
|
process.env.COS_WHISPER_TRANSCRIPTION_TIER = TRANSCRIPTION_TIER
|
|
550
558
|
process.env.COS_WHISPER_PREVIEW_MODEL = previewModel
|
|
551
559
|
process.env.COS_WHISPER_COMMIT_MODEL = commitModel
|
|
@@ -573,8 +581,7 @@ if (!process.env.COS_PROFILE_PATH) process.env.COS_PROFILE_PATH = PROFILE_FILE
|
|
|
573
581
|
|
|
574
582
|
// Step 5: local Whisper detection + model download. Voice stays local-only by
|
|
575
583
|
// default; cloud fallback requires an explicit flag plus a configured key.
|
|
576
|
-
const
|
|
577
|
-
const WHISPER_SERVER_KNOWN_PATHS = ['/opt/homebrew/bin/whisper-server', '/usr/local/bin/whisper-server']
|
|
584
|
+
const { resolveWhisperBinary } = require('./whisper-runtime.cjs')
|
|
578
585
|
const WHISPER_MODEL_DIR = join(homedir(), '.local/share/whisper-models')
|
|
579
586
|
const WHISPER_MODEL_PATH = join(WHISPER_MODEL_DIR, 'ggml-large-v3-turbo.bin')
|
|
580
587
|
const WHISPER_MODEL_PARTIAL = WHISPER_MODEL_PATH + '.partial'
|
|
@@ -591,24 +598,8 @@ const WHISPER_LARGE_MODEL_MIN_BYTES = 2_800_000_000
|
|
|
591
598
|
const WHISPER_MODEL_EXPECTED_BYTES = 1_624_555_275
|
|
592
599
|
const WHISPER_SMALL_MODEL_EXPECTED_BYTES = 487_614_201
|
|
593
600
|
const WHISPER_LARGE_MODEL_EXPECTED_BYTES = 3_095_033_483
|
|
594
|
-
function findWhisperCli() {
|
|
595
|
-
|
|
596
|
-
try {
|
|
597
|
-
const found = execSync('command -v whisper-cli 2>/dev/null', { shell: '/bin/sh', stdio: 'pipe', timeout: 2000 }).toString().trim()
|
|
598
|
-
return found || null
|
|
599
|
-
} catch {
|
|
600
|
-
return null
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
function findWhisperServer() {
|
|
604
|
-
for (const p of WHISPER_SERVER_KNOWN_PATHS) { if (existsSync(p)) return p }
|
|
605
|
-
try {
|
|
606
|
-
const found = execSync('command -v whisper-server 2>/dev/null', { shell: '/bin/sh', stdio: 'pipe', timeout: 2000 }).toString().trim()
|
|
607
|
-
return found || null
|
|
608
|
-
} catch {
|
|
609
|
-
return null
|
|
610
|
-
}
|
|
611
|
-
}
|
|
601
|
+
function findWhisperCli() { return resolveWhisperBinary('whisper-cli') }
|
|
602
|
+
function findWhisperServer() { return resolveWhisperBinary('whisper-server') }
|
|
612
603
|
function isValidWhisperModel(p, minBytes = WHISPER_MODEL_MIN_BYTES) {
|
|
613
604
|
if (!existsSync(p)) return false
|
|
614
605
|
try { return statSync(p).size >= minBytes } catch { return false }
|
|
@@ -652,10 +643,10 @@ if (SETUP_TRANSCRIPTION && process.env.SKIP_WHISPER_DOWNLOAD !== '1') {
|
|
|
652
643
|
mkdirSync(WHISPER_MODEL_DIR, { recursive: true })
|
|
653
644
|
const targets = [
|
|
654
645
|
[WHISPER_MODEL_PATH, WHISPER_MODEL_PARTIAL, WHISPER_MODEL_MIN_BYTES, WHISPER_MODEL_EXPECTED_BYTES],
|
|
655
|
-
...(TRANSCRIPTION_TIER === 'balanced'
|
|
646
|
+
...(!VOICE_BENCHMARK_PREPARE && TRANSCRIPTION_TIER === 'balanced'
|
|
656
647
|
? [[WHISPER_SMALL_MODEL_PATH, WHISPER_SMALL_MODEL_PARTIAL, WHISPER_SMALL_MODEL_MIN_BYTES, WHISPER_SMALL_MODEL_EXPECTED_BYTES]]
|
|
657
648
|
: []),
|
|
658
|
-
[WHISPER_LARGE_MODEL_PATH, WHISPER_LARGE_MODEL_PARTIAL, WHISPER_LARGE_MODEL_MIN_BYTES, WHISPER_LARGE_MODEL_EXPECTED_BYTES],
|
|
649
|
+
...(!VOICE_BENCHMARK_PREPARE ? [[WHISPER_LARGE_MODEL_PATH, WHISPER_LARGE_MODEL_PARTIAL, WHISPER_LARGE_MODEL_MIN_BYTES, WHISPER_LARGE_MODEL_EXPECTED_BYTES]] : []),
|
|
659
650
|
]
|
|
660
651
|
const missingBytes = targets.reduce((sum, [path, , minimum, expected]) =>
|
|
661
652
|
sum + (isValidWhisperModel(path, minimum) ? 0 : expected), 0)
|
|
@@ -716,7 +707,7 @@ if (process.env.COS_OPENAI_WHISPER_FALLBACK === '1') {
|
|
|
716
707
|
|
|
717
708
|
const previewChoice = (process.env.COS_WHISPER_PREVIEW_MODEL || process.env.COS_WHISPER_REALTIME_MODEL || 'auto').trim().toLowerCase()
|
|
718
709
|
const wantsSmallPreview = ['small', 'small.en', 'ggml-small.en.bin'].includes(previewChoice)
|
|
719
|
-
if (whisperCliPath && wantsSmallPreview) {
|
|
710
|
+
if (whisperCliPath && wantsSmallPreview && !VOICE_BENCHMARK_PREPARE) {
|
|
720
711
|
if (isValidWhisperModel(WHISPER_SMALL_MODEL_PATH, WHISPER_SMALL_MODEL_MIN_BYTES)) {
|
|
721
712
|
console.log(green(' ✓') + ' Small.en preview model ready ' + dim('— Turbo remains authoritative'))
|
|
722
713
|
} else {
|
|
@@ -745,7 +736,7 @@ if (whisperCliPath && wantsSmallPreview) {
|
|
|
745
736
|
}
|
|
746
737
|
}
|
|
747
738
|
|
|
748
|
-
if (whisperCliPath && SETUP_TRANSCRIPTION) {
|
|
739
|
+
if (whisperCliPath && SETUP_TRANSCRIPTION && !VOICE_BENCHMARK_PREPARE) {
|
|
749
740
|
if (isValidWhisperModel(WHISPER_LARGE_MODEL_PATH, WHISPER_LARGE_MODEL_MIN_BYTES)) {
|
|
750
741
|
const largeRole = TRANSCRIPTION_TIER === 'max'
|
|
751
742
|
? 'live preview + commit and saved-meeting polish'
|
|
@@ -789,7 +780,7 @@ if (whisperCliPath && SETUP_TRANSCRIPTION) {
|
|
|
789
780
|
// -- sixty times larger -- downloaded here with SKIP_WHISPER_DOWNLOAD as the
|
|
790
781
|
// escape hatch. Same shape, same opt-out, so nobody on a metered or restricted
|
|
791
782
|
// network is forced into it.
|
|
792
|
-
if (!SETUP_SPEAKER_MODEL && process.env.SKIP_SPEAKER_MODEL_DOWNLOAD !== '1') {
|
|
783
|
+
if (!VOICE_BENCHMARK_PREPARE && !SETUP_SPEAKER_MODEL && process.env.SKIP_SPEAKER_MODEL_DOWNLOAD !== '1') {
|
|
793
784
|
const spkDest = join(CONFIG_DIR, 'models', SPEAKER_MODEL.filename)
|
|
794
785
|
if (existsSync(spkDest) && sha256File(spkDest) === SPEAKER_MODEL.sha256) {
|
|
795
786
|
console.log(green(' ✓') + ' Voiceprint model ready ' + dim('— named speakers available'))
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Shared by setup and both live Whisper workers. Explicit overrides fail closed.
|
|
2
|
+
const { accessSync, constants, statSync } = require('node:fs')
|
|
3
|
+
const { isAbsolute, join } = require('node:path')
|
|
4
|
+
function executable(path) {
|
|
5
|
+
try { accessSync(path, constants.X_OK); return statSync(path).isFile() } catch { return false }
|
|
6
|
+
}
|
|
7
|
+
function resolveWhisperBinary(name, env = process.env, available = executable) {
|
|
8
|
+
if (!['whisper-cli', 'whisper-server'].includes(name)) throw new Error('Unknown Whisper binary')
|
|
9
|
+
const key = name === 'whisper-cli' ? 'COS_WHISPER_CLI_BIN' : 'COS_WHISPER_SERVER_BIN'
|
|
10
|
+
if (env[key] !== undefined && env[key] !== '') {
|
|
11
|
+
const path = env[key].trim()
|
|
12
|
+
return isAbsolute(path) && available(path) ? path : null
|
|
13
|
+
}
|
|
14
|
+
const candidates = ['/opt/homebrew/bin', '/usr/local/bin', ...(env.PATH || '').split(':')]
|
|
15
|
+
return candidates.filter(isAbsolute).map(p => join(p, name)).find(available) || null
|
|
16
|
+
}
|
|
17
|
+
module.exports = { resolveWhisperBinary }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function resolveWhisperBinary(name: 'whisper-cli' | 'whisper-server', env?: NodeJS.ProcessEnv, available?: (path: string) => boolean): string | null
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
<!-- Copy of operations/personal/wk41_2026/CONTRACT_glasses_pairing_2026-10-09.md in the COS repo, the single source of truth for server 6.67.0, glasses 6.10.621 and the Control pairing branch. Server notes on what the contract left open are at the end. -->
|
|
2
|
+
|
|
3
|
+
# Contract: glasses pairing (server 6.67.0, glasses 6.10.621, Control branch for Codex)
|
|
4
|
+
|
|
5
|
+
This is the single source of truth for the three builders. Design and reasons: `PLAN_glasses_onboarding_quick_connect_2026-10-08.md`, sections v2 and v4.
|
|
6
|
+
|
|
7
|
+
**Miles, 2026-10-09 07:23:** "We'll use the next version release as the canary. We'll start with Miles network, he should see the QR with the newer Activity setup view that contains the QR code for scanning."
|
|
8
|
+
|
|
9
|
+
**Scope of this release: pairing only.** Durability (Part C), approve-from-glasses (Part B) and the tour come in later releases.
|
|
10
|
+
|
|
11
|
+
## Capability
|
|
12
|
+
- `GET /api/health` adds `capabilities.pairing = { version: 1 }`.
|
|
13
|
+
- Clients check it before any pairing call. When it is missing, the phone says "Update COS on your Mac (server 6.67 or newer)" and Control shows "Update the server".
|
|
14
|
+
|
|
15
|
+
## Code and QR grammar (the server builds both, clients only parse)
|
|
16
|
+
- **Code:** 8 characters, Crockford base32 (`0123456789ABCDEFGHJKMNPQRSTVWXYZ`).
|
|
17
|
+
- Normalize input: uppercase it, map O→0, I and L→1, and strip spaces and dashes.
|
|
18
|
+
- Display it as `XXXX-XXXX`.
|
|
19
|
+
- **QR text:** `COS1/MAC/<CODE>/<host>:<port>[+<host>:<port>]/<EXP>`
|
|
20
|
+
- All uppercase alphanumeric plus `/ : + .`, so the QR stays at version 3 or 4. (Changed from `,` after QA round 1: a comma is not in the QR alphanumeric set. The server writes `+`; clients accept `+` and `,`.)
|
|
21
|
+
- `<EXP>` is the expiry, in unix seconds, written in base36 uppercase.
|
|
22
|
+
- Hosts are IPv4 only, http. Tailscale (100.64.0.0/10) comes first; a LAN address (RFC1918) is included only while LAN pairing is armed.
|
|
23
|
+
- `MAC` is the kind. `CLOUD` is reserved for later. Any other kind, or a version other than `COS1`, is refused with "Update COS to use this code".
|
|
24
|
+
- Clients refuse hosts outside 100.64/10 and RFC1918.
|
|
25
|
+
|
|
26
|
+
## Server routes (6.67.0)
|
|
27
|
+
All under `/api/pairing`. Every response carries a `reason` string on failure. The reasons are:
|
|
28
|
+
`expired`, `used`, `locked`, `rate_limited`, `not_loopback`, `not_allowed_network`, `unknown_code`, `denied`, `draining`, `bad_request`.
|
|
29
|
+
|
|
30
|
+
| Route | Auth | Body or params | 200/202 response |
|
|
31
|
+
|---|---|---|---|
|
|
32
|
+
| `POST /pairing/code` | token **and** loopback socket (`req.socket.remoteAddress`, both forms; never `req.ip` or XFF) | `{ allowLan?: boolean }` (`allowLan` arms LAN claims for 10 minutes) | `{ code, display, qr, expiresAt, bootId, hosts:[{host,port,kind:'tailscale'|'lan'}], lanArmedUntil|null }`. A new code cancels the previous one and any pending claim. |
|
|
33
|
+
| `POST /pairing/claim` | **public**, an exact `POST` match in `isPublicApiRequest`, with its own JSON parser capped at 1 KB | `{ code, nonce }` (`nonce` is 16 to 64 characters from `[A-Za-z0-9_-]`, made by the phone) | 202 `{ pending: true }`. Idempotent for the same nonce and code. |
|
|
34
|
+
| `GET /pairing/claim/:nonce` | **public**, and the caller's socket IP must equal the claim's IP | | `{ state: 'pending'|'allowed'|'denied'|'expired'|'delivered', token?, serverName? }`. The token is returned **once** (the `allowed` state moves to `delivered`). A later read returns `delivered` with no token. |
|
|
35
|
+
| `GET /pairing/status` | token and loopback | | `{ code: {display, expiresAt}|null, pending: {nonce, ip, at}|null, lastClaim: {ip, at, allowed}|null, firstAuthAfterClaimAt|null, lanArmedUntil|null, bootId }` |
|
|
36
|
+
| `POST /pairing/decision` | token and loopback | `{ nonce, allow: boolean }` | `{ ok: true }` |
|
|
37
|
+
|
|
38
|
+
### Rules
|
|
39
|
+
- **Network:** a claim is accepted from 100.64/10. It is accepted from RFC1918 only while LAN is armed. Anything else gets `not_allowed_network`. This check sits on top of the existing network allowlist.
|
|
40
|
+
- **Code lifetime:** 5 minutes, single use (the first successful claim moves it to `used`).
|
|
41
|
+
- **Pending claims:** one at a time. A pending claim waits up to 60 s for a decision, then becomes `expired`. A second claim while one is pending is refused with `locked`, and that does not cancel the first.
|
|
42
|
+
- **Limits:**
|
|
43
|
+
- 5 claim attempts per minute per socket IP, then `rate_limited`.
|
|
44
|
+
- 20 attempts per code, then the code is `locked` until a new code is made.
|
|
45
|
+
- The limiter keys on the socket IP. Trust-proxy is never enabled.
|
|
46
|
+
- **State:** in memory only. A restart loses codes, so a claim with an old code gets `unknown_code`, and `bootId` changes.
|
|
47
|
+
- **Drain:** the pairing routes are exempt from the mutation lease, since they write no files. When shutting down, `code`, `claim`, `status` and `decision` return 503 `draining`. The poll `GET /pairing/claim/:nonce` is still served (QA round 1), so a token already allowed reaches the phone even if the update gate closes before the next poll.
|
|
48
|
+
- **First authenticated call:** when a request passes `requireApiToken` and its socket IP equals an allowed `lastClaim.ip`, stamp `firstAuthAfterClaimAt` once.
|
|
49
|
+
- **Logging:** never the code, nonce, token or QR text. Log the IP, the result, and the first 2 characters of a sha256 of the code. A test must fail if any of those values appear in log output.
|
|
50
|
+
- **Docs:**
|
|
51
|
+
- SECURITY.md: the pairing token is still the only standing credential; pairing codes are short-lived and single use.
|
|
52
|
+
- `docs/pairing-contract.md`: a copy of this file.
|
|
53
|
+
- Change the 401 copy to "Paste the pairing token from COS Control, or scan its code with COS Glasses 6.10.621 or newer." (QA round 1: older glasses show it word for word.) The `reason` value stays `pairing_token_rejected`.
|
|
54
|
+
|
|
55
|
+
## Glasses 6.10.621
|
|
56
|
+
- **Parser:** `src/lib/pair-code.ts` parses the QR grammar, normalizes codes and orders hosts. It is unit tested and exposed to the ES5 wizard as `window.__COS_PAIR`.
|
|
57
|
+
- **Scan entry points:**
|
|
58
|
+
- the wizard Start screen (first option, "Scan code on your Mac")
|
|
59
|
+
- `setup-step1` (the reauth path)
|
|
60
|
+
- the Settings overlay
|
|
61
|
+
- the local starter banner's Connect Mac
|
|
62
|
+
- **Ways in:**
|
|
63
|
+
- **Scan:** `window._evenBridge.captureImageFromCamera()`
|
|
64
|
+
- **Use a screenshot:** `pickImageFromAlbum()`
|
|
65
|
+
- **Type the code:** the code plus the Mac address
|
|
66
|
+
- Scan and screenshot are shown only when the bridge method is a function.
|
|
67
|
+
- **Decoding:** jsQR, decoding at the native width capped at 1920, then a second pass at 1280. A miss shows "Move closer so the code fills your screen".
|
|
68
|
+
- **Flow:**
|
|
69
|
+
1. Check health on the first host that answers (try hosts in order, 3 s each).
|
|
70
|
+
2. Make a nonce, then claim.
|
|
71
|
+
3. Poll `GET /pairing/claim/:nonce` every 1.5 s for up to 70 s. The screen and the lens say "Waiting for Allow on your Mac".
|
|
72
|
+
4. When the token arrives, write `cos_server_url = http://host:port` and `cos_api_token`, then run the existing `fetchAuthenticatedTarget`, then `finishConnectedSetup`, exactly as the IP form does.
|
|
73
|
+
5. Keep the token in memory and retry if `/api/models` fails.
|
|
74
|
+
- **Re-pairing:** when a committed target already exists, ask "Replace <old host> with <host>?" first.
|
|
75
|
+
- **Lens lines:** "Pairing with your Mac…", "Waiting for Allow on your Mac", "Paired", "Code expired: show a new one", "Mac is updating". Each fits 6 lines and the width, using only drawable glyphs. isWearing is never used.
|
|
76
|
+
- **Manifest:** `camera` and `album` go in app.json **and** app-hub.json. Update `verify-timer-quarantine-build.mjs` and the `marketplace-hardening` pin. Strip URL-bearing comments from the jsQR bundle. Add a `THIRD_PARTY_NOTICES` entry.
|
|
77
|
+
- **Version touchpoints:** COS_VERSION, package.json, package-lock (both fields), CHANGELOG, the index.html changelog, app.json, app-hub.json, and the Hub receipt.
|
|
78
|
+
|
|
79
|
+
## Control (branch off `origin/codex/whisper-runtime` 601a944, released by Codex)
|
|
80
|
+
- **New helper verbs:**
|
|
81
|
+
- `tailscale-status` runs the Tailscale bundle binary, `/Applications/Tailscale.app/Contents/MacOS/tailscale`, with **`SHLVL=1`** in the child's environment. It returns `{installed, running, selfIPv4, selfDNS, peers:[{os, dns, ipv4, online, sameUser}]}`, read from `status --json`. Use `Self.UserID` to work out `sameUser`; never use `HostName`.
|
|
82
|
+
- `tailscale-whois <ip>` returns `{node, os, user}`.
|
|
83
|
+
- `pairing-code [--allow-lan]`, `pairing-status` and `pairing-decision <nonce> allow|deny` call the server routes above over loopback with the token.
|
|
84
|
+
- **Setup guide, new "Glasses" section.** It is **not counted** in Finish setup, and it replaces `GlassesGuideRow`. It has three rows:
|
|
85
|
+
1. **Tailscale on this Mac:**
|
|
86
|
+
- not installed: Get Tailscale (https://tailscale.com/download/mac)
|
|
87
|
+
- signed out: Open Tailscale
|
|
88
|
+
- running: green, with the address
|
|
89
|
+
2. **Tailscale on your iPhone:**
|
|
90
|
+
- shows a QR for `https://apps.apple.com/app/tailscale/id1470499037`
|
|
91
|
+
- copy: "Sign in with the same account as this Mac"
|
|
92
|
+
- turns green when a same-user iOS peer is online, naming it ("iphone-15-pro-max is on your tailnet")
|
|
93
|
+
3. **Connect your glasses:**
|
|
94
|
+
- Shown only when `capabilities.pairing` is present.
|
|
95
|
+
- The QR is drawn with CoreImage `CIQRCodeGenerator`, correction level Q, from the server's `qr` string, at least 220 pt. Show `display` and the first host as text, with the copy "Hold your phone so the code fills the screen".
|
|
96
|
+
- Re-mint only while the pane is visible: when the code expires or `bootId` changes, and on **Show a new code**.
|
|
97
|
+
- When `pending` appears, show the **Allow / Deny** card: "<whois node> (<user>) wants to pair". For a non-tailnet IP: "Device on Wi-Fi <ip>".
|
|
98
|
+
- It turns green on `firstAuthAfterClaimAt`, showing "Paired: <node>, <time>".
|
|
99
|
+
- "Allow pairing on this Wi-Fi (home only)" is a toggle (`--allow-lan`), off by default.
|
|
100
|
+
- **Plumbing:** add new Swift files to every `Tests/run.sh` source list. Every compile goes through `Tests/compile-guard.sh`, one at a time.
|
|
101
|
+
- **Reset pairing** (token rotation): **not in this release.** It ships with Part B.
|
|
102
|
+
|
|
103
|
+
## Server 6.67.0: what the contract left open, as built
|
|
104
|
+
|
|
105
|
+
- **Times** in JSON (`expiresAt`, `lanArmedUntil`, `at`, `firstAuthAfterClaimAt`) are ISO 8601 strings, or null. Only the QR `<EXP>` is base36 unix seconds.
|
|
106
|
+
- **No host:** when the Mac has no Tailscale IPv4 and LAN is not armed, `POST /pairing/code` still mints, with `hosts: []` and `qr: null`, so Control can say why instead of drawing a QR no phone can use.
|
|
107
|
+
- **Hosts:** at most 3, Tailscale first. LAN addresses come only from RFC1918 on interfaces a phone can reach (never `utun` VPNs that are not Tailscale, `bridge`, `awdl`, `llw`, `vmnet`, `vboxnet`).
|
|
108
|
+
- **allowLan:** `true` arms LAN for 10 minutes; a mint without it disarms LAN, so Control's toggle is the source of truth.
|
|
109
|
+
- **Failures** are `{ reason, message }`: 400 `bad_request`, 403 `not_loopback` / `not_allowed_network` / `denied`, 404 `unknown_code`, 409 `used` / `locked`, 410 `expired`, 429 `rate_limited` (with `Retry-After`), 503 `draining`.
|
|
110
|
+
- **Poll:** an unknown nonce (for example after a restart) is 404 `unknown_code`; a poll from a different socket IP is 403 `not_allowed_network` and does not spend the token. `serverName` comes with the token and with `delivered`. Only a real GET may read the token: a HEAD (which needs the token header to get past the gate at all) is answered 405.
|
|
111
|
+
- **Status:** `code` is present while the current code is unexpired and carries an extra `state: 'active'|'used'|'locked'`.
|
|
112
|
+
- **Decision** on a nonce that is not pending: 404 `unknown_code`, 410 `expired`, or 409 `used` (already decided).
|
|
113
|
+
- **Per-code limit:** every claim attempt while a code is active counts against it, right code or wrong; the 21st attempt locks it. Once a code is `used`, later attempts neither count nor lock it: the right code answers `locked` while its claim waits for Allow and `used` after, a wrong one `unknown_code`. Codes are compared in constant time.
|
|
114
|
+
- **firstAuthAfterClaimAt** is stamped only after the allowed claim's token was delivered, on the first token-authenticated request from that claim's IP. A new Allow clears it.
|
|
115
|
+
- **Draining** means the server is shutting down or the maintenance gate is closed (an update in progress, including the startup gate before Control releases it). The poll is never refused for draining.
|
|
116
|
+
- **Loopback** accepts any 127.0.0.0/8, `::1` and `::ffff:127.x` socket at the pairing check, but the server's global network allowlist (unchanged) admits only `127.0.0.1` and `::1` exactly, so another 127.x address is refused before pairing sees it.
|
|
117
|
+
- **QR alphabet:** hosts are joined with `+`, which is in the QR alphanumeric set (`0-9 A-Z space $ % * + - . / :`), so every QR the server writes can be encoded in alphanumeric mode.
|
|
118
|
+
- **Parity fixture:** `docs/pairing-fixture.json` holds the alphabet, nonce pattern, QR pattern and charset, normalization cases, two real `buildPairingQr` outputs (one and two hosts), the reason list with statuses and messages, the poll states and the limits. `server/lib/glasses-pairing.test.ts` recomputes every value, so it fails if the fixture drifts. Clients copy it; nobody edits it by hand.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "Server-generated parity fixture for glasses pairing (docs/pairing-contract.md). server/lib/glasses-pairing.test.ts fails if any value here stops matching what the server produces. Clients copy it; never hand-edit it.",
|
|
3
|
+
"protocolVersion": 1,
|
|
4
|
+
"capability": {
|
|
5
|
+
"pairing": {
|
|
6
|
+
"version": 1
|
|
7
|
+
}
|
|
8
|
+
},
|
|
9
|
+
"alphabet": "0123456789ABCDEFGHJKMNPQRSTVWXYZ",
|
|
10
|
+
"codeLength": 8,
|
|
11
|
+
"noncePattern": "^[A-Za-z0-9_-]{16,64}$",
|
|
12
|
+
"qrHostSeparator": "+",
|
|
13
|
+
"qrPattern": "^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]+$",
|
|
14
|
+
"qrCharset": "^[0-9A-Z/:+.]+$",
|
|
15
|
+
"normalize": {
|
|
16
|
+
"abcd-efgh": "ABCDEFGH",
|
|
17
|
+
" oOil LI10 ": "00111110",
|
|
18
|
+
"7k3m 9pqr": "7K3M9PQR",
|
|
19
|
+
"ABCD-EFGU": null,
|
|
20
|
+
"ABCD-EFG": null
|
|
21
|
+
},
|
|
22
|
+
"examples": [
|
|
23
|
+
{
|
|
24
|
+
"code": "7K3M9PQR",
|
|
25
|
+
"display": "7K3M-9PQR",
|
|
26
|
+
"expiresAtMs": 1791547500000,
|
|
27
|
+
"hosts": [
|
|
28
|
+
{
|
|
29
|
+
"host": "100.100.10.20",
|
|
30
|
+
"port": 3141,
|
|
31
|
+
"kind": "tailscale"
|
|
32
|
+
}
|
|
33
|
+
],
|
|
34
|
+
"qr": "COS1/MAC/7K3M9PQR/100.100.10.20:3141/TMN2WC"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"code": "7K3M9PQR",
|
|
38
|
+
"display": "7K3M-9PQR",
|
|
39
|
+
"expiresAtMs": 1791547500000,
|
|
40
|
+
"hosts": [
|
|
41
|
+
{
|
|
42
|
+
"host": "100.100.10.20",
|
|
43
|
+
"port": 3141,
|
|
44
|
+
"kind": "tailscale"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"host": "192.168.1.20",
|
|
48
|
+
"port": 3141,
|
|
49
|
+
"kind": "lan"
|
|
50
|
+
}
|
|
51
|
+
],
|
|
52
|
+
"qr": "COS1/MAC/7K3M9PQR/100.100.10.20:3141+192.168.1.20:3141/TMN2WC"
|
|
53
|
+
}
|
|
54
|
+
],
|
|
55
|
+
"reasons": {
|
|
56
|
+
"bad_request": 400,
|
|
57
|
+
"not_loopback": 403,
|
|
58
|
+
"not_allowed_network": 403,
|
|
59
|
+
"denied": 403,
|
|
60
|
+
"unknown_code": 404,
|
|
61
|
+
"used": 409,
|
|
62
|
+
"locked": 409,
|
|
63
|
+
"expired": 410,
|
|
64
|
+
"rate_limited": 429,
|
|
65
|
+
"draining": 503
|
|
66
|
+
},
|
|
67
|
+
"reasonMessages": {
|
|
68
|
+
"bad_request": "That pairing request was not understood. Scan the code again.",
|
|
69
|
+
"not_loopback": "Pairing codes are made only by COS Control on this Mac.",
|
|
70
|
+
"not_allowed_network": "Connect over Tailscale, or turn on \"Allow pairing on this Wi-Fi\" in COS Control.",
|
|
71
|
+
"denied": "Pairing was denied on the Mac.",
|
|
72
|
+
"unknown_code": "This code is not current. Show a new one in COS Control.",
|
|
73
|
+
"used": "This code was already used. Show a new one in COS Control.",
|
|
74
|
+
"locked": "Too many tries, or another device is waiting. Show a new code in COS Control.",
|
|
75
|
+
"expired": "Code expired: show a new one in COS Control.",
|
|
76
|
+
"rate_limited": "Too many tries. Wait a minute and try again.",
|
|
77
|
+
"draining": "Your Mac is updating. Try again in a minute."
|
|
78
|
+
},
|
|
79
|
+
"pollStates": [
|
|
80
|
+
"pending",
|
|
81
|
+
"allowed",
|
|
82
|
+
"denied",
|
|
83
|
+
"expired",
|
|
84
|
+
"delivered"
|
|
85
|
+
],
|
|
86
|
+
"limits": {
|
|
87
|
+
"codeTtlMs": 300000,
|
|
88
|
+
"pendingTtlMs": 60000,
|
|
89
|
+
"lanArmMs": 600000,
|
|
90
|
+
"claimsPerIpPerMinute": 5,
|
|
91
|
+
"attemptsPerCode": 20,
|
|
92
|
+
"maxQrHosts": 3
|
|
93
|
+
}
|
|
94
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.67.0",
|
|
4
4
|
"description": "COS Glasses: self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"!server/scripts/work-intake-mutation-gate.mjs",
|
|
48
48
|
"!server/scripts/work-progress-mutation-gate.mjs",
|
|
49
49
|
"!server/scripts/work-search-mutation-gate.mjs",
|
|
50
|
+
"!server/scripts/pairing-mutation-gate.mjs",
|
|
50
51
|
"!server/scripts/away-hold-mutation-gate.mjs",
|
|
51
52
|
"!server/scripts/nemotron-mutation-gate.mjs",
|
|
52
53
|
"!server/scripts/merged-scribe-mutation-gate.mjs",
|
|
@@ -77,7 +78,11 @@
|
|
|
77
78
|
"docs/work-task-runtime.md",
|
|
78
79
|
"!server/scripts/build-work-task-runtime.py",
|
|
79
80
|
"bin/hooks/cos-cursor-observer.cjs",
|
|
80
|
-
"docs/provider-parity.md"
|
|
81
|
+
"docs/provider-parity.md",
|
|
82
|
+
"docs/pairing-contract.md",
|
|
83
|
+
"docs/pairing-fixture.json",
|
|
84
|
+
"bin/whisper-runtime.cjs",
|
|
85
|
+
"bin/whisper-runtime.d.cts"
|
|
81
86
|
],
|
|
82
87
|
"author": "COS Contributors",
|
|
83
88
|
"license": "MIT",
|
package/server/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
8
8
|
import { createServer as createHttpsServer } from 'node:https'
|
|
9
9
|
import { createServer as createHttpServer } from 'node:http'
|
|
10
10
|
import { readFileSync, existsSync } from 'node:fs'
|
|
11
|
-
import { networkInterfaces, homedir } from 'node:os'
|
|
11
|
+
import { networkInterfaces, homedir, hostname } from 'node:os'
|
|
12
12
|
import { join } from 'node:path'
|
|
13
13
|
import { execSync } from 'node:child_process'
|
|
14
14
|
import { randomBytes, randomUUID } from 'node:crypto'
|
|
@@ -162,9 +162,12 @@ import {
|
|
|
162
162
|
import {
|
|
163
163
|
isAllowedNetworkIp,
|
|
164
164
|
isAllowedNetworkOrigin,
|
|
165
|
-
|
|
165
|
+
isLoopbackSocket,
|
|
166
|
+
reachableIpv4Addresses,
|
|
166
167
|
} from './lib/network-policy.js'
|
|
167
168
|
import { requireApiToken } from './lib/api-auth.js'
|
|
169
|
+
import { PairingState, pairingHostsFrom } from './lib/glasses-pairing.js'
|
|
170
|
+
import { createPairingRouter } from './routes/pairing.js'
|
|
168
171
|
import { isManagedRuntime } from './lib/managed-runtime.js'
|
|
169
172
|
import { reportClaudeExtraToolConfiguration } from './lib/claude-tool-access.js'
|
|
170
173
|
import {
|
|
@@ -317,7 +320,27 @@ if (threadAttachEnabled()) {
|
|
|
317
320
|
// authenticated /tts/prepare for native audio players) and
|
|
318
321
|
// /display-stream/<exp>.<hmac> GET/HEAD (minted on authenticated /api/models for
|
|
319
322
|
// EventSource, which cannot set X-Cos-Token either). See api-auth.ts.
|
|
320
|
-
|
|
323
|
+
// 6.67.0 glasses pairing state: in memory only, so a restart loses every code and
|
|
324
|
+
// claim and `bootId` changes (docs/pairing-contract.md).
|
|
325
|
+
const pairingState = new PairingState({
|
|
326
|
+
token: () => API_TOKEN,
|
|
327
|
+
// The Mac's name as the phone shows it ("Paired with <name>").
|
|
328
|
+
serverName: () => hostname().replace(/\.local$/i, ''),
|
|
329
|
+
hosts: lanArmed => pairingHostsFrom(reachableIpv4Addresses(networkInterfaces()), PORT, lanArmed),
|
|
330
|
+
})
|
|
331
|
+
app.use('/api', requireApiToken(API_TOKEN, {
|
|
332
|
+
onAuthenticated: req => pairingState.noteAuthenticated(req.socket?.remoteAddress),
|
|
333
|
+
}))
|
|
334
|
+
|
|
335
|
+
// Pairing routes sit HERE on purpose: after the token gate (mint, status and decision
|
|
336
|
+
// need it; the claim and its poll are exact public doors in api-auth.ts), and BEFORE the
|
|
337
|
+
// mutation-lease catch-all and the global 10 MB parser. They write no files, so they
|
|
338
|
+
// never hold the lease, and each parses its own body at 1 KB. During a drain or
|
|
339
|
+
// shutdown they answer 503 `draining`.
|
|
340
|
+
app.use('/api', createPairingRouter({
|
|
341
|
+
state: () => pairingState,
|
|
342
|
+
isDraining: () => gracefulShutdownStarted || !maintenanceAdmissionsOpen(),
|
|
343
|
+
}))
|
|
321
344
|
|
|
322
345
|
// Fail-closed catch-all for mutation routes that do not own a more specific
|
|
323
346
|
// lifecycle lease below. This closes the admission/drain race for secondary
|
|
@@ -375,9 +398,7 @@ app.use('/api', (req, res, next) => {
|
|
|
375
398
|
// still closed. Permit only its two bounded loopback proofs, and only with
|
|
376
399
|
// the controller-held operation receipt. Normal phone/LAN admissions stay
|
|
377
400
|
// closed throughout the update.
|
|
378
|
-
const
|
|
379
|
-
const loopback = address === '::1' || address === '127.0.0.1'
|
|
380
|
-
|| address.startsWith('127.') || address.startsWith('::ffff:127.')
|
|
401
|
+
const loopback = isLoopbackSocket(req)
|
|
381
402
|
const controllerProofPath = req.path === '/diagnostics/provider-proof'
|
|
382
403
|
|| req.path === '/tts/prepare'
|
|
383
404
|
const controllerProof = loopback && controllerProofPath
|
|
@@ -1225,17 +1246,11 @@ listenRequiredServers(listeners).then(() => {
|
|
|
1225
1246
|
// Print ADDRESSES THE PHONE CAN ACTUALLY REACH. The bind address (0.0.0.0) is
|
|
1226
1247
|
// not paste-able — enumerate real interfaces and label the Tailscale one.
|
|
1227
1248
|
try {
|
|
1228
|
-
const
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
if (info.family !== 'IPv4' || info.internal) continue
|
|
1233
|
-
const isTailscale = isTailscaleIpv4(info.address) || name.startsWith('tailscale')
|
|
1234
|
-
addrs.push({ ip: info.address, label: isTailscale ? 'Tailscale — works from anywhere' : `${name} — same Wi-Fi only` })
|
|
1235
|
-
}
|
|
1236
|
-
}
|
|
1249
|
+
const addrs = reachableIpv4Addresses(networkInterfaces()).map(entry => ({
|
|
1250
|
+
ip: entry.address,
|
|
1251
|
+
label: entry.kind === 'tailscale' ? 'Tailscale — works from anywhere' : `${entry.name} — same Wi-Fi only`,
|
|
1252
|
+
}))
|
|
1237
1253
|
if (addrs.length > 0) {
|
|
1238
|
-
addrs.sort((a, b) => Number(b.label.startsWith('Tailscale')) - Number(a.label.startsWith('Tailscale')))
|
|
1239
1254
|
console.log('')
|
|
1240
1255
|
console.log('[COS API] Server URL for the COS Glasses app:')
|
|
1241
1256
|
for (const a of addrs) console.log(`[COS API] http://${a.ip}:${PORT} (${a.label})`)
|