@raidiant/notifai 10.1.0 → 10.1.2

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.
Files changed (204) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +3 -0
  3. package/dist/cli-bin.d.ts +21 -1
  4. package/dist/cli-bin.js +141 -10
  5. package/dist/cli-bin.js.map +1 -1
  6. package/dist/cli-update-contract.d.ts +7 -0
  7. package/dist/cli-update-contract.js +10 -0
  8. package/dist/cli-update-contract.js.map +1 -0
  9. package/dist/client.d.ts +6 -1
  10. package/dist/client.js +1 -0
  11. package/dist/client.js.map +1 -1
  12. package/dist/codex-wake.d.ts +2 -0
  13. package/dist/codex-wake.js +2 -1
  14. package/dist/codex-wake.js.map +1 -1
  15. package/dist/commands-auth.d.ts +15 -1
  16. package/dist/commands-auth.js +29 -11
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-core.d.ts +9 -2
  19. package/dist/commands-core.js +11 -4
  20. package/dist/commands-core.js.map +1 -1
  21. package/dist/commands-devices.d.ts +10 -1
  22. package/dist/commands-devices.js +17 -13
  23. package/dist/commands-devices.js.map +1 -1
  24. package/dist/commands-doctor.js +93 -49
  25. package/dist/commands-doctor.js.map +1 -1
  26. package/dist/commands-hooks.js +47 -10
  27. package/dist/commands-hooks.js.map +1 -1
  28. package/dist/commands-init.d.ts +16 -0
  29. package/dist/commands-init.js +254 -93
  30. package/dist/commands-init.js.map +1 -1
  31. package/dist/commands-send-support.js +3 -0
  32. package/dist/commands-send-support.js.map +1 -1
  33. package/dist/commands-send.d.ts +1 -1
  34. package/dist/commands-send.js +121 -35
  35. package/dist/commands-send.js.map +1 -1
  36. package/dist/commands-skill.js +72 -54
  37. package/dist/commands-skill.js.map +1 -1
  38. package/dist/commands-update.d.ts +10 -0
  39. package/dist/commands-update.js +141 -0
  40. package/dist/commands-update.js.map +1 -0
  41. package/dist/commands.d.ts +2 -1
  42. package/dist/commands.js +2 -1
  43. package/dist/commands.js.map +1 -1
  44. package/dist/hook-adapter.d.ts +2 -0
  45. package/dist/hook-adapter.js +12 -1
  46. package/dist/hook-adapter.js.map +1 -1
  47. package/dist/hooks.d.ts +43 -2
  48. package/dist/hooks.js +163 -21
  49. package/dist/hooks.js.map +1 -1
  50. package/dist/invocation-context.d.ts +1 -0
  51. package/dist/invocation-context.js +9 -1
  52. package/dist/invocation-context.js.map +1 -1
  53. package/dist/main-run.d.ts +1 -0
  54. package/dist/main-run.js +42 -0
  55. package/dist/main-run.js.map +1 -0
  56. package/dist/main.js +10 -31
  57. package/dist/main.js.map +1 -1
  58. package/dist/native-skills.d.ts +13 -3
  59. package/dist/native-skills.js +51 -12
  60. package/dist/native-skills.js.map +1 -1
  61. package/dist/node-floor.d.ts +19 -0
  62. package/dist/node-floor.js +39 -0
  63. package/dist/node-floor.js.map +1 -0
  64. package/dist/orca-session-title.d.ts +5 -4
  65. package/dist/orca-session-title.js +32 -19
  66. package/dist/orca-session-title.js.map +1 -1
  67. package/dist/platform.d.ts +11 -17
  68. package/dist/platform.js +20 -23
  69. package/dist/platform.js.map +1 -1
  70. package/dist/program.d.ts +2 -1
  71. package/dist/program.js +22 -5
  72. package/dist/program.js.map +1 -1
  73. package/dist/question-settlement-process.d.ts +15 -0
  74. package/dist/question-settlement-process.js +35 -0
  75. package/dist/question-settlement-process.js.map +1 -0
  76. package/dist/readiness.d.ts +25 -1
  77. package/dist/readiness.js +32 -2
  78. package/dist/readiness.js.map +1 -1
  79. package/dist/release.d.ts +5 -5
  80. package/dist/release.js +5 -5
  81. package/dist/setup-destinations.d.ts +23 -0
  82. package/dist/setup-destinations.js +44 -0
  83. package/dist/setup-destinations.js.map +1 -0
  84. package/dist/skill-integrity.d.ts +49 -0
  85. package/dist/skill-integrity.js +154 -0
  86. package/dist/skill-integrity.js.map +1 -0
  87. package/dist/skill-source/manifest.json +16 -0
  88. package/dist/skill-source/notifai/SKILL.md +390 -0
  89. package/dist/skill-source/notifai/references/harness-setup.md +220 -0
  90. package/package.json +11 -14
  91. package/src/atomic-file.ts +0 -198
  92. package/src/claude-wake.test.ts +0 -321
  93. package/src/claude-wake.ts +0 -499
  94. package/src/cli-bin.test.ts +0 -38
  95. package/src/cli-bin.ts +0 -67
  96. package/src/cli-release.test.ts +0 -46
  97. package/src/cli-release.ts +0 -78
  98. package/src/client.test.ts +0 -390
  99. package/src/client.ts +0 -345
  100. package/src/codex-wake.test.ts +0 -254
  101. package/src/codex-wake.ts +0 -297
  102. package/src/command-session.ts +0 -38
  103. package/src/commands-agent-sessions.ts +0 -72
  104. package/src/commands-auth.ts +0 -205
  105. package/src/commands-config.ts +0 -336
  106. package/src/commands-core.ts +0 -298
  107. package/src/commands-devices.test.ts +0 -15
  108. package/src/commands-devices.ts +0 -173
  109. package/src/commands-doctor.ts +0 -1440
  110. package/src/commands-guidance.ts +0 -181
  111. package/src/commands-harness-context.ts +0 -207
  112. package/src/commands-hook-shape.ts +0 -69
  113. package/src/commands-hooks.ts +0 -2268
  114. package/src/commands-init.ts +0 -878
  115. package/src/commands-io.ts +0 -108
  116. package/src/commands-logs.ts +0 -243
  117. package/src/commands-project.ts +0 -47
  118. package/src/commands-send-support.test.ts +0 -156
  119. package/src/commands-send-support.ts +0 -311
  120. package/src/commands-send.ts +0 -769
  121. package/src/commands-setup-proof.test.ts +0 -72
  122. package/src/commands-setup-proof.ts +0 -124
  123. package/src/commands-skill.test.ts +0 -44
  124. package/src/commands-skill.ts +0 -204
  125. package/src/commands-sounds.test.ts +0 -106
  126. package/src/commands-sounds.ts +0 -49
  127. package/src/commands.test.ts +0 -7828
  128. package/src/commands.ts +0 -75
  129. package/src/config-schema.test.ts +0 -153
  130. package/src/config-schema.ts +0 -349
  131. package/src/config.test.ts +0 -581
  132. package/src/config.ts +0 -475
  133. package/src/credentials.test.ts +0 -403
  134. package/src/credentials.ts +0 -403
  135. package/src/file-lock-process-worker.test.ts +0 -64
  136. package/src/file-lock.test.ts +0 -320
  137. package/src/file-lock.ts +0 -393
  138. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
  139. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
  140. package/src/generated-session-label.ts +0 -155
  141. package/src/guidance-content.ts +0 -278
  142. package/src/guidance-render.ts +0 -56
  143. package/src/guidance.test.ts +0 -327
  144. package/src/guidance.ts +0 -233
  145. package/src/harness-integration.test.ts +0 -294
  146. package/src/harnesses.test.ts +0 -149
  147. package/src/harnesses.ts +0 -164
  148. package/src/hook-adapter.test.ts +0 -496
  149. package/src/hook-adapter.ts +0 -410
  150. package/src/hook-input.test.ts +0 -39
  151. package/src/hook-input.ts +0 -72
  152. package/src/hooks.test.ts +0 -5549
  153. package/src/hooks.ts +0 -3759
  154. package/src/install-hooks.test.ts +0 -1614
  155. package/src/install-hooks.ts +0 -1501
  156. package/src/interactive.test.ts +0 -387
  157. package/src/interactive.ts +0 -858
  158. package/src/invocation-context.test.ts +0 -352
  159. package/src/invocation-context.ts +0 -197
  160. package/src/logging-process-worker.test.ts +0 -45
  161. package/src/logging.test.ts +0 -544
  162. package/src/logging.ts +0 -698
  163. package/src/main.ts +0 -36
  164. package/src/native-skills.test.ts +0 -115
  165. package/src/native-skills.ts +0 -162
  166. package/src/onboarding-docs.test.ts +0 -25
  167. package/src/openclaw-plugin.ts +0 -409
  168. package/src/opencode-plugin.ts +0 -298
  169. package/src/orca-session-title.test.ts +0 -54
  170. package/src/orca-session-title.ts +0 -103
  171. package/src/pack-feedback-slice.test.ts +0 -212
  172. package/src/pack-feedback-slice.ts +0 -187
  173. package/src/platform.test.ts +0 -152
  174. package/src/platform.ts +0 -173
  175. package/src/program.test.ts +0 -282
  176. package/src/program.ts +0 -818
  177. package/src/project-enablement.test.ts +0 -55
  178. package/src/project-enablement.ts +0 -99
  179. package/src/question-caps.test.ts +0 -49
  180. package/src/question-timing.ts +0 -56
  181. package/src/readiness.test.ts +0 -25
  182. package/src/readiness.ts +0 -179
  183. package/src/release.ts +0 -51
  184. package/src/send-attempts.test.ts +0 -143
  185. package/src/send-attempts.ts +0 -188
  186. package/src/send.test.ts +0 -28
  187. package/src/send.ts +0 -436
  188. package/src/session-activation.ts +0 -61
  189. package/src/session-end-log-lock-worker.test.ts +0 -35
  190. package/src/session-labels.test.ts +0 -490
  191. package/src/session-labels.ts +0 -474
  192. package/src/skill.test.ts +0 -415
  193. package/src/sound-ref.test.ts +0 -31
  194. package/src/sound-ref.ts +0 -34
  195. package/src/trust-policy.test.ts +0 -701
  196. package/src/ui/banner.ts +0 -95
  197. package/src/ui/config-view.test.ts +0 -90
  198. package/src/ui/config-view.ts +0 -138
  199. package/src/ui/help.test.ts +0 -16
  200. package/src/ui/help.ts +0 -128
  201. package/src/ui/theme.test.ts +0 -92
  202. package/src/ui/theme.ts +0 -265
  203. package/src/url-policy.ts +0 -399
  204. package/tsconfig.json +0 -10
@@ -1,78 +0,0 @@
1
- import { packageVersion } from './release.js'
2
-
3
- /**
4
- * The newest published CLI, read from the registry that publishes it.
5
- *
6
- * Which version is newest is a registry fact. Doctor consults it as a
7
- * best-effort, cached, short-timeout hint: never fatal, and never on a
8
- * non-interactive or CI invocation — those callers already get structured
9
- * support state from the server when they are signed in.
10
- */
11
- const DIST_TAGS_URL = 'https://registry.npmjs.org/-/package/@raidiant/notifai/dist-tags'
12
- const REQUEST_TIMEOUT_MS = 2_000
13
- const CACHE_TTL_MS = 60 * 60 * 1000
14
-
15
- let cached: { value: string | null; at: number } | null = null
16
-
17
- export function compareCliSemVer(left: string, right: string): number | null {
18
- const parse = (value: string): [number, number, number] | null => {
19
- const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(value)
20
- if (!match) return null
21
- return [Number(match[1]), Number(match[2]), Number(match[3])]
22
- }
23
- const a = parse(left)
24
- const b = parse(right)
25
- if (a === null || b === null) return null
26
- for (let index = 0; index < 3; index += 1) {
27
- if (a[index] !== b[index]) return a[index]! - b[index]!
28
- }
29
- return 0
30
- }
31
-
32
- export function shouldConsultCliRegistry(input: {
33
- interactive?: boolean
34
- json?: boolean
35
- env?: NodeJS.ProcessEnv
36
- }): boolean {
37
- if (input.json === true) return false
38
- if (input.interactive !== true) return false
39
- const ci = input.env?.['CI']
40
- return ci !== '1' && ci !== 'true'
41
- }
42
-
43
- export async function latestPublishedCliVersion(
44
- fetchImpl: typeof fetch = fetch,
45
- ): Promise<string | null> {
46
- if (cached !== null && Date.now() - cached.at < CACHE_TTL_MS) return cached.value
47
- try {
48
- const response = await fetchImpl(DIST_TAGS_URL, {
49
- headers: { accept: 'application/json' },
50
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
51
- })
52
- if (!response.ok) return cached?.value ?? null
53
- const body: unknown = await response.json()
54
- if (typeof body !== 'object' || body === null) return cached?.value ?? null
55
- const latest = (body as { latest?: unknown }).latest
56
- if (typeof latest !== 'string' || compareCliSemVer(latest, '0.0.0') === null) {
57
- return cached?.value ?? null
58
- }
59
- cached = { value: latest, at: Date.now() }
60
- return latest
61
- } catch {
62
- return cached?.value ?? null
63
- }
64
- }
65
-
66
- export function newerPublishedCli(local: string | null, latest: string | null): string | null {
67
- if (local === null || latest === null) return null
68
- const comparison = compareCliSemVer(latest, local)
69
- return comparison !== null && comparison > 0 ? latest : null
70
- }
71
-
72
- export function thisCliVersion(): string | null {
73
- return packageVersion()
74
- }
75
-
76
- export function resetLatestPublishedCliVersionForTest(): void {
77
- cached = null
78
- }
@@ -1,390 +0,0 @@
1
- import { createServer, type Server } from 'node:http'
2
- import type { AddressInfo } from 'node:net'
3
- import { afterEach, describe, expect, it } from 'vitest'
4
- import { CAPABILITIES_HEADER, CLI_VERSION_HEADER } from '@raidiant/notifai-protocol'
5
- import { ApiCallError, NetworkError, createClient } from './client.js'
6
-
7
- /**
8
- * These run against a real socket rather than a stubbed fetch, because the
9
- * failure being fixed is a property of the transport: a server that accepts the
10
- * connection and then says nothing. A mock that resolves or rejects cannot
11
- * reproduce it.
12
- */
13
- let server: Server | undefined
14
-
15
- afterEach(async () => {
16
- if (server) {
17
- const closing = server
18
- server = undefined
19
- await new Promise<void>((resolve) => closing.close(() => resolve()))
20
- }
21
- })
22
-
23
- /** Starts a server with the given behaviour and returns its base URL. */
24
- async function serving(handler: Parameters<typeof createServer>[1]): Promise<string> {
25
- server = createServer(handler)
26
- await new Promise<void>((resolve) => server?.listen(0, '127.0.0.1', resolve))
27
- const { port } = server.address() as AddressInfo
28
- return `http://127.0.0.1:${port}`
29
- }
30
-
31
- describe('a server that never answers', () => {
32
- it('gives up instead of hanging until the harness kills the hook', async () => {
33
- // Accepts the request, holds the socket open, writes nothing — ever.
34
- const held: unknown[] = []
35
- const baseUrl = await serving((_request, response) => {
36
- held.push(response)
37
- })
38
- const client = createClient(baseUrl, null, { timeoutMs: 300 })
39
-
40
- const started = Date.now()
41
- await expect(client.listDevices()).rejects.toBeInstanceOf(NetworkError)
42
- // The point is that it returns at all; the bound is loose so a busy CI box
43
- // does not make this flaky.
44
- expect(Date.now() - started).toBeLessThan(5_000)
45
- expect(held).toHaveLength(1)
46
- })
47
-
48
- it('says the server went quiet rather than reporting a bare abort', async () => {
49
- const baseUrl = await serving(() => {})
50
- const client = createClient(baseUrl, null, { timeoutMs: 300 })
51
-
52
- await expect(client.listDevices()).rejects.toThrow(/did not respond within/)
53
- })
54
-
55
- it('clamps a late request to the caller whole-operation deadline', async () => {
56
- const baseUrl = await serving(() => {})
57
- const started = Date.now()
58
- const client = createClient(baseUrl, null, {
59
- timeoutMs: 5_000,
60
- deadlineAt: started + 200,
61
- })
62
-
63
- await expect(client.listDevices()).rejects.toBeInstanceOf(NetworkError)
64
- expect(Date.now() - started).toBeLessThan(2_000)
65
- })
66
-
67
- it('allows for the long poll it asked the server to hold', async () => {
68
- // The deadline is per request, so a 25s long poll must not be cut off by
69
- // the ordinary budget — otherwise waiting for a reply could never work.
70
- let seen: string | undefined
71
- const baseUrl = await serving((request, response) => {
72
- seen = request.url
73
- response.setHeader('content-type', 'application/json')
74
- response.end(JSON.stringify({ request_id: 'req_1', reply_expires_at: null, replies: [] }))
75
- })
76
- const client = createClient(baseUrl, null, { timeoutMs: 300 })
77
-
78
- const result = await client.replies('req_1', { waitSeconds: 25, afterSeq: 0 })
79
-
80
- expect(seen).toContain('wait_seconds=25')
81
- expect(result.replies).toEqual([])
82
- })
83
-
84
- it('encodes pairing and evidence path segments', async () => {
85
- const seen: string[] = []
86
- const baseUrl = await serving((request, response) => {
87
- seen.push(`${request.method} ${request.url}`)
88
- response.setHeader('content-type', 'application/json')
89
- if (request.url?.includes('/poll')) {
90
- response.end(JSON.stringify({ status: 'pending' }))
91
- return
92
- }
93
- response.end(
94
- JSON.stringify({
95
- request_id: 'req/encoded',
96
- overall: 'accepted',
97
- deliveries: [],
98
- warnings: [],
99
- }),
100
- )
101
- })
102
- const client = createClient(baseUrl, 'Bearer test')
103
-
104
- await client.pollPairing('pair/id', 'verifier')
105
- await client.evidence('req/encoded')
106
-
107
- expect(seen).toEqual([
108
- 'POST /api/v1/pairings/pair%2Fid/poll',
109
- 'GET /api/v1/notifications/req%2Fencoded',
110
- ])
111
- })
112
-
113
- it('lists Account custom sounds from the catalog endpoint', async () => {
114
- let seen: string | undefined
115
- const baseUrl = await serving((request, response) => {
116
- seen = `${request.method} ${request.url}`
117
- response.setHeader('content-type', 'application/json')
118
- response.end(JSON.stringify({ sounds: [] }))
119
- })
120
- const result = await createClient(baseUrl, 'Bearer test').listSounds()
121
- expect(seen).toBe('GET /api/v1/sounds')
122
- expect(result).toEqual({ sounds: [] })
123
- })
124
-
125
- it('refuses redirects on bearer-authenticated calls', async () => {
126
- let followed = false
127
- const baseUrl = await serving((request, response) => {
128
- if (request.url === '/api/v1/devices') {
129
- response.statusCode = 302
130
- response.setHeader('location', '/stolen')
131
- response.end()
132
- return
133
- }
134
- followed = true
135
- response.end('stolen')
136
- })
137
-
138
- await expect(createClient(baseUrl, 'Bearer secret').listDevices()).rejects.toBeInstanceOf(
139
- NetworkError,
140
- )
141
- expect(followed).toBe(false)
142
- })
143
-
144
- it('still follows redirects when the call has no bearer', async () => {
145
- const baseUrl = await serving((request, response) => {
146
- if (request.url === '/healthz') {
147
- response.statusCode = 302
148
- response.setHeader('location', '/healthz-ok')
149
- response.end()
150
- return
151
- }
152
- response.setHeader('content-type', 'application/json')
153
- response.end('{}')
154
- })
155
-
156
- await expect(createClient(baseUrl, null).health()).resolves.toBe(true)
157
- })
158
-
159
- it('puts and fetches Agent Acknowledgements on the encoded request path', async () => {
160
- const seen: { method?: string; url?: string; body?: unknown }[] = []
161
- const baseUrl = await serving((request, response) => {
162
- let raw = ''
163
- request.on('data', (chunk) => {
164
- raw += String(chunk)
165
- })
166
- request.on('end', () => {
167
- seen.push({
168
- method: request.method,
169
- url: request.url,
170
- ...(raw === '' ? {} : { body: JSON.parse(raw) as unknown }),
171
- })
172
- response.setHeader('content-type', 'application/json')
173
- if (request.method === 'PUT') {
174
- response.end(
175
- JSON.stringify({
176
- status: 'recorded',
177
- agent_acknowledgement: {
178
- text: 'I will deploy.',
179
- created_at: '2026-08-13T12:01:00.000Z',
180
- },
181
- }),
182
- )
183
- } else {
184
- response.end(
185
- JSON.stringify({
186
- request_id: 'req/encoded',
187
- agent_acknowledgement_required: true,
188
- agent_acknowledgement: null,
189
- }),
190
- )
191
- }
192
- })
193
- })
194
- const client = createClient(baseUrl, 'Bearer test')
195
-
196
- await client.putAgentAcknowledgement('req/encoded', { text: 'I will deploy.' })
197
- await client.agentAcknowledgement('req/encoded', { waitSeconds: 25 })
198
-
199
- expect(seen).toEqual([
200
- {
201
- method: 'PUT',
202
- url: '/api/v1/notifications/req%2Fencoded/agent-acknowledgement',
203
- body: { text: 'I will deploy.' },
204
- },
205
- {
206
- method: 'GET',
207
- url: '/api/v1/notifications/req%2Fencoded/agent-acknowledgement?wait_seconds=25',
208
- },
209
- ])
210
- })
211
-
212
- it('finalizes provider metadata before treating a storage upload as usable', async () => {
213
- const seen: { method?: string; url?: string; authorization?: string; bytes: number }[] = []
214
- const baseUrl = await serving((request, response) => {
215
- const chunks: Buffer[] = []
216
- request.on('data', (chunk) => chunks.push(Buffer.from(chunk)))
217
- request.on('end', () => {
218
- seen.push({
219
- method: request.method,
220
- url: request.url,
221
- ...(request.headers.authorization
222
- ? { authorization: request.headers.authorization }
223
- : {}),
224
- bytes: Buffer.concat(chunks).byteLength,
225
- })
226
- if (request.method === 'POST') {
227
- response.setHeader('content-type', 'application/json')
228
- response.end(JSON.stringify({ media_id: 'med_1', size_bytes: 4, status: 'ready' }))
229
- } else {
230
- response.statusCode = 200
231
- response.end()
232
- }
233
- })
234
- })
235
- const client = createClient(baseUrl, 'Bearer machine')
236
-
237
- await client.uploadMedia(
238
- {
239
- media_id: 'med_1',
240
- upload_url: `${baseUrl}/signed-upload`,
241
- upload_headers: { 'content-type': 'image/png' },
242
- expires_at: '2026-08-14T12:00:00.000Z',
243
- },
244
- new Uint8Array([1, 2, 3, 4]),
245
- )
246
-
247
- expect(seen).toEqual([
248
- { method: 'PUT', url: '/signed-upload', bytes: 4 },
249
- {
250
- method: 'POST',
251
- url: '/api/v1/media/med_1/finalize',
252
- authorization: 'Bearer machine',
253
- bytes: 0,
254
- },
255
- ])
256
- })
257
-
258
- it('refuses a signed upload redirect without replaying its headers or finalizing', async () => {
259
- let followed = false
260
- let finalized = false
261
- const baseUrl = await serving((request, response) => {
262
- if (request.url === '/signed-upload') {
263
- response.statusCode = 302
264
- response.setHeader('location', `${baseUrl}/stolen`)
265
- response.end()
266
- return
267
- }
268
- if (request.url === '/stolen') followed = true
269
- if (request.url === '/api/v1/media/med_1/finalize') finalized = true
270
- response.statusCode = 200
271
- response.end()
272
- })
273
- const client = createClient(baseUrl, 'Bearer machine')
274
-
275
- await expect(
276
- client.uploadMedia(
277
- {
278
- media_id: 'med_1',
279
- upload_url: `${baseUrl}/signed-upload`,
280
- upload_headers: { authorization: 'Signed secret' },
281
- expires_at: '2026-08-14T12:00:00.000Z',
282
- },
283
- new Uint8Array([1, 2, 3, 4]),
284
- ),
285
- ).rejects.toThrow(/redirect is refused/i)
286
- expect(followed).toBe(false)
287
- expect(finalized).toBe(false)
288
- })
289
-
290
- it('treats a truncated body as a transport failure, not a crash', async () => {
291
- // A body that stops mid-JSON used to escape as a raw parse error outside
292
- // the retry path, so nothing backed off and retried it.
293
- const baseUrl = await serving((_request, response) => {
294
- response.setHeader('content-type', 'application/json')
295
- response.write('{"devices": [')
296
- response.destroy()
297
- })
298
- const client = createClient(baseUrl, null, { timeoutMs: 500 })
299
-
300
- await expect(client.listDevices()).rejects.toBeInstanceOf(NetworkError)
301
- })
302
- })
303
-
304
- describe('compatibility metadata transport', () => {
305
- it('advertises named CLI capabilities only on authenticated traffic', async () => {
306
- const seen: Array<{
307
- url: string | undefined
308
- cliVersion: string | string[] | undefined
309
- capabilities: string | string[] | undefined
310
- }> = []
311
- const baseUrl = await serving((request, response) => {
312
- seen.push({
313
- url: request.url,
314
- cliVersion: request.headers[CLI_VERSION_HEADER],
315
- capabilities: request.headers[CAPABILITIES_HEADER],
316
- })
317
- response.setHeader('content-type', 'application/json')
318
- response.end(
319
- JSON.stringify({
320
- schema_version: 1,
321
- platform: 'macos',
322
- payload_limit_bytes: 4096,
323
- sounds: [],
324
- interruption_levels: [],
325
- fields: [],
326
- }),
327
- )
328
- })
329
- const options = {
330
- cliVersion: '5.0.0',
331
- capabilities: ['agent_acknowledgement'] as const,
332
- }
333
-
334
- await createClient(baseUrl, 'Bearer machine', options).capabilities('macos', '0.1.0', '42')
335
- await createClient(baseUrl, null, options).capabilities('macos')
336
-
337
- expect(seen).toEqual([
338
- {
339
- url: '/api/v1/capabilities/macos?app_version=0.1.0&app_build=42',
340
- cliVersion: '5.0.0',
341
- capabilities: 'agent_acknowledgement',
342
- },
343
- {
344
- url: '/api/v1/capabilities/macos',
345
- cliVersion: undefined,
346
- capabilities: undefined,
347
- },
348
- ])
349
- })
350
-
351
- it('keeps recovery typed when a feature is unavailable', async () => {
352
- const baseUrl = await serving((_request, response) => {
353
- response.statusCode = 422
354
- response.setHeader('content-type', 'application/json')
355
- response.end(
356
- JSON.stringify({
357
- error: {
358
- code: 'feature_unavailable',
359
- message: "The selected device can't answer questions.",
360
- next_action: 'run an untrusted server command',
361
- recovery_action: 'update_companion',
362
- details: {
363
- affected_operation: 'answer_questions',
364
- missing_capabilities: ['answer'],
365
- },
366
- },
367
- }),
368
- )
369
- })
370
-
371
- let caught: unknown
372
- try {
373
- await createClient(baseUrl, 'Bearer machine').compatibility()
374
- } catch (error) {
375
- caught = error
376
- }
377
-
378
- expect(caught).toBeInstanceOf(ApiCallError)
379
- expect(caught).toMatchObject({
380
- status: 422,
381
- code: 'feature_unavailable',
382
- recoveryAction: 'update_companion',
383
- nextAction: 'run an untrusted server command',
384
- details: {
385
- affected_operation: 'answer_questions',
386
- missing_capabilities: ['answer'],
387
- },
388
- })
389
- })
390
- })