@gotcos/glasses-server 6.40.1 → 6.41.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/CHANGELOG.md CHANGED
@@ -1,3 +1,64 @@
1
+ ## 6.41.0
2
+
3
+ Allowlist mode can finally read the workspace it was pointed at.
4
+
5
+ A public user set `COS_CLAUDE_TRUST_MODE=allowlist` (the security-conscious
6
+ choice, tried first) and got "I don't have access to your workspace files."
7
+ That was the code working as written: in allowlist mode the per-query tool
8
+ list is the entire tool universe, and it was built as WebSearch + WebFetch,
9
+ with Read added only when the query carried a photo. The CLI's working
10
+ directory correctly pointed at the user's workspace; nothing in the list
11
+ could open a file in it.
12
+
13
+ Three changes, one release:
14
+
15
+ - The base tool list now always includes `Read`, `Glob`, and `Grep` — the
16
+ read-only exploration trio. Allowlist mode becomes "web plus read-only
17
+ workspace": shell, edits, and writes remain impossible by construction,
18
+ and a new test pins that Bash/Edit/Write can never appear in this list.
19
+ In trusted mode (the default) the list is only an auto-approve hint, so
20
+ behavior there is unchanged.
21
+ - The allowlist capability prompt now affirms workspace readability — but
22
+ only when the list actually grants it, so the prompt can never over-claim.
23
+ Without the affirmation, a model told it is "genuinely limited" tends to
24
+ refuse reads it has.
25
+ - The one listless spawn (prewarm) no longer passes an empty `--tools` in
26
+ allowlist mode. On the current CLI that flag triggers a spurious context
27
+ compaction and a synthetic 400 (the same pathology isolated by the fork
28
+ bisection on 2026-08-26); denial now rides `--permission-mode dontAsk`
29
+ plus an empty `--allowedTools` alone.
30
+
31
+ ## 6.40.2
32
+
33
+ A finished answer could leave the stream open forever.
34
+
35
+ An Ollama turn with tools streamed its whole answer, persisted it, and then
36
+ never sent the terminal `done` event. On the glasses the turn counted past
37
+ 1,100 seconds while the reply had actually completed in 166 and was already
38
+ saved in Messages. Double-tap to cancel did nothing, because there was no live
39
+ job to cancel.
40
+
41
+ The cause is one line in the query route, and it predates the tool loop. The
42
+ SSE callbacks are declared inside `const sid = await callModelStreaming(...)`,
43
+ so while that call is running `sid` is in its temporal dead zone. `onStart`
44
+ never noticed because its own `sid` PARAMETER shadows the outer binding.
45
+ `onDone` has no such parameter: it read the outer `sid`, threw
46
+ ReferenceError, and — critically — threw AFTER setting `done = true` and
47
+ BEFORE writing the terminal event, so the failure could not even fall through
48
+ to the error path. The socket stayed open with the work already committed.
49
+
50
+ Only Ollama hit it. The child-process bridges resolve outside that window, so
51
+ Claude and Codex kept working; the in-process Ollama loop awaits its own
52
+ finalize before returning, which lands the callback squarely inside the dead
53
+ zone. Both readers now use the session id captured from `onStart`.
54
+
55
+ The existing SSE contract test could never have caught this: it fires
56
+ callbacks on a macrotask after the router resolves, with a comment asserting
57
+ that bridges "never" call back synchronously. That assumption is now marked
58
+ as covering the child-process bridges only, and a new test pins the
59
+ in-process ordering. Reverting the fix makes all three of its cases hang,
60
+ which is the production symptom exactly.
61
+
1
62
  ## 6.40.1
2
63
 
3
64
  The local model can read your meetings and memories.
package/README.md CHANGED
@@ -87,8 +87,12 @@ without silently losing completed replies.
87
87
  > has those tools, so you will see it use them more readily than before.
88
88
  > Set `COS_CLAUDE_TRUST_MODE=allowlist` to remove Claude's permission bypass
89
89
  > and restrict it to COS's explicit per-query tool allowlist; undeclared tools
90
- > then fail closed without prompting. Only the exact value `allowlist` restricts
91
- > anything any other value logs a warning and stays trusted.
90
+ > then fail closed without prompting. In allowlist mode the query keeps web
91
+ > search/fetch and **read-only workspace access** (Read, Glob, Grep) no
92
+ > shell, no edits, no writes. Only the exact value `allowlist` restricts
93
+ > anything — any other value logs a warning and stays trusted. Servers before
94
+ > 6.41.0 denied ALL workspace reads in allowlist mode; if a hardened install
95
+ > answers "I don't have access to your workspace files", update the server.
92
96
 
93
97
  ## Connect your phone (the one gotcha)
94
98
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.40.1",
3
+ "version": "6.41.0",
4
4
  "description": "COS Glasses \u2014 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": {
@@ -459,7 +459,6 @@ export async function callClaudeStreaming(
459
459
  throw err
460
460
  }
461
461
  const allowedToolList = buildClaudeToolList({
462
- includeRead: imagePaths.length > 0,
463
462
  publisherTool: outputImagePublisher?.claudeAllowedTool,
464
463
  })
465
464
  let mcpConfigArgs: string[]
@@ -45,10 +45,16 @@ export function claudePermissionArgs(
45
45
  : ['--dangerously-skip-permissions', '--allowedTools', allowedTools]
46
46
  }
47
47
 
48
- const tools = allowedTools ?? ''
48
+ // No per-query list (the prewarm spawn): deny everything via dontAsk plus an
49
+ // empty allowedTools, but never pass an empty --tools — on the current CLI
50
+ // an empty --tools triggers a spurious context compaction and a synthetic
51
+ // 400 (single-flag bisection 2026-08-26, documented in fork-thread.ts).
52
+ if (allowedTools === null) {
53
+ return ['--permission-mode', 'dontAsk', '--allowedTools', '']
54
+ }
49
55
  return [
50
56
  '--permission-mode', 'dontAsk',
51
- '--tools', tools,
52
- '--allowedTools', tools,
57
+ '--tools', allowedTools,
58
+ '--allowedTools', allowedTools,
53
59
  ]
54
60
  }
@@ -89,12 +89,15 @@ export function reportClaudeExtraToolConfiguration(
89
89
  }
90
90
 
91
91
  export function buildClaudeToolList(input: {
92
- includeRead?: boolean
93
92
  publisherTool?: string
94
93
  env?: NodeJS.ProcessEnv
95
94
  } = {}): string[] {
96
- const tools = ['WebSearch', 'WebFetch']
97
- if (input.includeRead) tools.push('Read')
95
+ // Read-only workspace tools are unconditional: in allowlist mode this list
96
+ // is the entire tool universe, and without them a hardened install cannot
97
+ // read the workspace the glasses are pointed at (field report 2026-08-27).
98
+ // Shell/Edit/Write stay excluded, so allowlist keeps its no-side-effects
99
+ // property. In trusted mode the list is only an auto-approve hint.
100
+ const tools = ['WebSearch', 'WebFetch', 'Read', 'Glob', 'Grep']
98
101
  tools.push(...configuredClaudeExtraTools(input.env))
99
102
  if (input.publisherTool) tools.push(input.publisherTool)
100
103
  return [...new Set(tools)]
@@ -186,8 +189,15 @@ export function claudeToolCapabilityPrompt(
186
189
  const honesty = TOOL_HONESTY_CLAUSE
187
190
 
188
191
  if (mode === 'allowlist') {
192
+ // Affirm workspace readability ONLY when the list actually grants it —
193
+ // this prompt is something the session trusts, and promising reads a
194
+ // caller did not include would recreate the 2026-07-28 class of header
195
+ // mismatch in the opposite direction.
196
+ const workspaceLine = tools.includes('Read')
197
+ ? `\nYour working directory is the user's COS workspace: Read, Glob, and Grep are in the list so you can search and read its files. The restriction here is on shell, writes, and undeclared tools — not on reading the workspace. Never refuse a workspace read in this mode.`
198
+ : ''
189
199
  return `TOOL CAPABILITY CONTRACT:
190
- This request runs in RESTRICTED allowlist mode and is genuinely limited to these tool selectors: ${list}. Undeclared tools are denied without prompting, so a call outside this list will fail.
200
+ This request runs in RESTRICTED allowlist mode and is genuinely limited to these tool selectors: ${list}. Undeclared tools are denied without prompting, so a call outside this list will fail.${workspaceLine}
191
201
  Selectors are permissions, not proof that a connector is online. Use a tool only when it is actually present in this session. If the user asks for a tool or connector that is absent, or a tool call fails, say that it is unavailable. ${honesty}
192
202
  ${UNTRUSTED_CONTENT_CLAUSE}`
193
203
  }
@@ -114,6 +114,22 @@ queryRouter.post('/query', async (req, res) => {
114
114
  res.write(': keepalive\n\n')
115
115
 
116
116
  let done = false
117
+ // The session id AS REPORTED BY THE BRIDGE.
118
+ //
119
+ // `const sid = await callModelStreaming(...)` below is not assigned until the
120
+ // whole call resolves, but these callbacks fire DURING that await — so any
121
+ // callback that reads `sid` reads a temporal-dead-zone binding and throws
122
+ // ReferenceError. `onStart` never noticed because its own `sid` PARAMETER
123
+ // shadows the outer const; `onDone` has no such parameter and did throw,
124
+ // after setting `done = true` and before writing the terminal event, which
125
+ // left the SSE socket open forever with the answer already persisted.
126
+ //
127
+ // Measured 2026-08-26: an Ollama tool turn streamed its full answer and never
128
+ // sent `done`; the glasses counted past 1,100 seconds on a turn that had
129
+ // finished in 166. Claude survived only because its bridge resolves outside
130
+ // the window — the same line was one scheduling change away from hanging
131
+ // every provider.
132
+ let streamSessionId: string | undefined = typeof sessionId === 'string' ? sessionId : undefined
117
133
  const abortController = new AbortController()
118
134
  res.on('close', () => {
119
135
  if (!done) abortController.abort()
@@ -123,6 +139,7 @@ queryRouter.post('/query', async (req, res) => {
123
139
  try {
124
140
  const sid = await callModelStreaming(resolvedQuery || '', sessionId, {
125
141
  onStart: (model, sid, cliSessionId, metadata) => {
142
+ streamSessionId = sid
126
143
  if (!done) {
127
144
  const payload = { model, sessionId: sid, cliSessionId, ...metadata }
128
145
  res.write(`event: start\ndata: ${JSON.stringify(payload)}\n\n`)
@@ -161,7 +178,7 @@ queryRouter.post('/query', async (req, res) => {
161
178
  // expiry or make maintenance proof outrun the pending write.
162
179
  if (resolvedAttachments.ids.length > 0) {
163
180
  await getMediaStore().associate(resolvedAttachments.ids, {
164
- sessionId: sid,
181
+ sessionId: streamSessionId ?? '',
165
182
  ...(validGlobalMsgNum ? { globalMsgNum: validGlobalMsgNum } : {}),
166
183
  messageEra: activeMessageEra,
167
184
  }).catch((err) => console.error('[query] attachment association failed:', err))
@@ -171,7 +188,7 @@ queryRouter.post('/query', async (req, res) => {
171
188
  const attachments = mergeMediaAttachmentRefs(attachmentRefs, metadata?.outputAttachments)
172
189
  const { outputAttachments: _outputAttachments, ...runMetadata } = metadata ?? {}
173
190
  const payload = {
174
- text: fullText, sessionId: sid, model, cliSessionId, ...runMetadata,
191
+ text: fullText, sessionId: streamSessionId, model, cliSessionId, ...runMetadata,
175
192
  ...(attachments.length > 0 ? { attachments } : {}),
176
193
  }
177
194
  res.write(`event: done\ndata: ${JSON.stringify(payload)}\n\n`)