@raidiant/notifai 8.0.0 → 9.1.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.
Files changed (168) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +20 -13
  3. package/dist/atomic-file.d.ts +14 -0
  4. package/dist/atomic-file.js +27 -2
  5. package/dist/atomic-file.js.map +1 -1
  6. package/dist/cli-bin.d.ts +4 -0
  7. package/dist/cli-bin.js +62 -0
  8. package/dist/cli-bin.js.map +1 -0
  9. package/dist/cli-release.d.ts +10 -0
  10. package/dist/cli-release.js +75 -0
  11. package/dist/cli-release.js.map +1 -0
  12. package/dist/client.d.ts +1 -0
  13. package/dist/client.js +13 -3
  14. package/dist/client.js.map +1 -1
  15. package/dist/command-session.d.ts +14 -0
  16. package/dist/command-session.js +30 -0
  17. package/dist/command-session.js.map +1 -0
  18. package/dist/commands-auth.d.ts +6 -0
  19. package/dist/commands-auth.js +28 -3
  20. package/dist/commands-auth.js.map +1 -1
  21. package/dist/commands-config.js +22 -3
  22. package/dist/commands-config.js.map +1 -1
  23. package/dist/commands-core.d.ts +12 -0
  24. package/dist/commands-core.js +27 -3
  25. package/dist/commands-core.js.map +1 -1
  26. package/dist/commands-doctor.d.ts +1 -0
  27. package/dist/commands-doctor.js +87 -29
  28. package/dist/commands-doctor.js.map +1 -1
  29. package/dist/commands-guidance.d.ts +7 -3
  30. package/dist/commands-guidance.js +40 -31
  31. package/dist/commands-guidance.js.map +1 -1
  32. package/dist/commands-harness-context.js +21 -1
  33. package/dist/commands-harness-context.js.map +1 -1
  34. package/dist/commands-hooks.d.ts +20 -5
  35. package/dist/commands-hooks.js +509 -155
  36. package/dist/commands-hooks.js.map +1 -1
  37. package/dist/commands-init.d.ts +12 -18
  38. package/dist/commands-init.js +149 -47
  39. package/dist/commands-init.js.map +1 -1
  40. package/dist/commands-send-support.d.ts +17 -2
  41. package/dist/commands-send-support.js +61 -5
  42. package/dist/commands-send-support.js.map +1 -1
  43. package/dist/commands-send.d.ts +1 -0
  44. package/dist/commands-send.js +114 -17
  45. package/dist/commands-send.js.map +1 -1
  46. package/dist/commands-setup-proof.d.ts +12 -3
  47. package/dist/commands-setup-proof.js +60 -16
  48. package/dist/commands-setup-proof.js.map +1 -1
  49. package/dist/commands-skill.d.ts +11 -1
  50. package/dist/commands-skill.js +61 -11
  51. package/dist/commands-skill.js.map +1 -1
  52. package/dist/commands.d.ts +2 -2
  53. package/dist/commands.js +1 -1
  54. package/dist/commands.js.map +1 -1
  55. package/dist/config-schema.d.ts +1 -1
  56. package/dist/config-schema.js +25 -0
  57. package/dist/config-schema.js.map +1 -1
  58. package/dist/config.d.ts +24 -3
  59. package/dist/config.js +37 -5
  60. package/dist/config.js.map +1 -1
  61. package/dist/credentials.d.ts +10 -1
  62. package/dist/credentials.js +25 -12
  63. package/dist/credentials.js.map +1 -1
  64. package/dist/guidance-content.d.ts +20 -0
  65. package/dist/guidance-content.js +73 -8
  66. package/dist/guidance-content.js.map +1 -1
  67. package/dist/guidance-render.d.ts +20 -0
  68. package/dist/guidance-render.js +42 -0
  69. package/dist/guidance-render.js.map +1 -0
  70. package/dist/guidance.d.ts +21 -0
  71. package/dist/guidance.js +73 -18
  72. package/dist/guidance.js.map +1 -1
  73. package/dist/hook-adapter.js +37 -3
  74. package/dist/hook-adapter.js.map +1 -1
  75. package/dist/hooks.d.ts +83 -7
  76. package/dist/hooks.js +288 -90
  77. package/dist/hooks.js.map +1 -1
  78. package/dist/install-hooks.d.ts +10 -18
  79. package/dist/install-hooks.js +110 -23
  80. package/dist/install-hooks.js.map +1 -1
  81. package/dist/interactive.d.ts +1 -1
  82. package/dist/interactive.js +1 -1
  83. package/dist/interactive.js.map +1 -1
  84. package/dist/logging.d.ts +8 -1
  85. package/dist/logging.js +43 -2
  86. package/dist/logging.js.map +1 -1
  87. package/dist/main.js +2 -13
  88. package/dist/main.js.map +1 -1
  89. package/dist/native-skills.d.ts +19 -0
  90. package/dist/native-skills.js +30 -8
  91. package/dist/native-skills.js.map +1 -1
  92. package/dist/opencode-plugin.js +25 -1
  93. package/dist/opencode-plugin.js.map +1 -1
  94. package/dist/program.js +34 -20
  95. package/dist/program.js.map +1 -1
  96. package/dist/readiness.d.ts +6 -0
  97. package/dist/readiness.js +37 -0
  98. package/dist/readiness.js.map +1 -1
  99. package/dist/send-attempts.d.ts +26 -0
  100. package/dist/send-attempts.js +139 -0
  101. package/dist/send-attempts.js.map +1 -0
  102. package/dist/send.d.ts +8 -1
  103. package/dist/send.js +17 -1
  104. package/dist/send.js.map +1 -1
  105. package/dist/session-activation.d.ts +11 -0
  106. package/dist/session-activation.js +46 -0
  107. package/dist/session-activation.js.map +1 -0
  108. package/dist/url-policy.d.ts +85 -0
  109. package/dist/url-policy.js +369 -0
  110. package/dist/url-policy.js.map +1 -0
  111. package/package.json +3 -3
  112. package/src/atomic-file.ts +36 -2
  113. package/src/cli-bin.test.ts +38 -0
  114. package/src/cli-bin.ts +67 -0
  115. package/src/cli-release.test.ts +46 -0
  116. package/src/cli-release.ts +78 -0
  117. package/src/client.test.ts +95 -0
  118. package/src/client.ts +16 -3
  119. package/src/command-session.ts +38 -0
  120. package/src/commands-auth.ts +32 -3
  121. package/src/commands-config.ts +25 -2
  122. package/src/commands-core.ts +38 -3
  123. package/src/commands-doctor.ts +117 -41
  124. package/src/commands-guidance.ts +43 -32
  125. package/src/commands-harness-context.ts +24 -1
  126. package/src/commands-hooks.ts +704 -188
  127. package/src/commands-init.ts +180 -57
  128. package/src/commands-send-support.test.ts +156 -0
  129. package/src/commands-send-support.ts +67 -5
  130. package/src/commands-send.ts +135 -16
  131. package/src/commands-setup-proof.test.ts +72 -0
  132. package/src/commands-setup-proof.ts +69 -16
  133. package/src/commands-skill.ts +78 -15
  134. package/src/commands.test.ts +1281 -157
  135. package/src/commands.ts +2 -0
  136. package/src/config-schema.ts +28 -1
  137. package/src/config.test.ts +1 -1
  138. package/src/config.ts +49 -5
  139. package/src/credentials.test.ts +71 -3
  140. package/src/credentials.ts +29 -15
  141. package/src/guidance-content.ts +74 -8
  142. package/src/guidance-render.ts +56 -0
  143. package/src/guidance.test.ts +70 -11
  144. package/src/guidance.ts +100 -18
  145. package/src/hook-adapter.test.ts +113 -1
  146. package/src/hook-adapter.ts +37 -3
  147. package/src/hooks.test.ts +778 -166
  148. package/src/hooks.ts +368 -129
  149. package/src/install-hooks.test.ts +135 -16
  150. package/src/install-hooks.ts +113 -25
  151. package/src/interactive.ts +1 -1
  152. package/src/logging.test.ts +66 -3
  153. package/src/logging.ts +45 -2
  154. package/src/main.ts +2 -14
  155. package/src/native-skills.test.ts +41 -1
  156. package/src/native-skills.ts +40 -6
  157. package/src/opencode-plugin.ts +25 -1
  158. package/src/program.test.ts +37 -2
  159. package/src/program.ts +62 -24
  160. package/src/readiness.ts +44 -0
  161. package/src/send-attempts.test.ts +143 -0
  162. package/src/send-attempts.ts +188 -0
  163. package/src/send.test.ts +28 -0
  164. package/src/send.ts +28 -2
  165. package/src/session-activation.ts +62 -0
  166. package/src/skill.test.ts +125 -11
  167. package/src/trust-policy.test.ts +701 -0
  168. package/src/url-policy.ts +399 -0
@@ -0,0 +1,78 @@
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
+ }
@@ -81,6 +81,69 @@ describe('a server that never answers', () => {
81
81
  expect(result.replies).toEqual([])
82
82
  })
83
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('refuses redirects on bearer-authenticated calls', async () => {
114
+ let followed = false
115
+ const baseUrl = await serving((request, response) => {
116
+ if (request.url === '/api/v1/devices') {
117
+ response.statusCode = 302
118
+ response.setHeader('location', '/stolen')
119
+ response.end()
120
+ return
121
+ }
122
+ followed = true
123
+ response.end('stolen')
124
+ })
125
+
126
+ await expect(createClient(baseUrl, 'Bearer secret').listDevices()).rejects.toBeInstanceOf(
127
+ NetworkError,
128
+ )
129
+ expect(followed).toBe(false)
130
+ })
131
+
132
+ it('still follows redirects when the call has no bearer', async () => {
133
+ const baseUrl = await serving((request, response) => {
134
+ if (request.url === '/healthz') {
135
+ response.statusCode = 302
136
+ response.setHeader('location', '/healthz-ok')
137
+ response.end()
138
+ return
139
+ }
140
+ response.setHeader('content-type', 'application/json')
141
+ response.end('{}')
142
+ })
143
+
144
+ await expect(createClient(baseUrl, null).health()).resolves.toBe(true)
145
+ })
146
+
84
147
  it('puts and fetches Agent Acknowledgements on the encoded request path', async () => {
85
148
  const seen: { method?: string; url?: string; body?: unknown }[] = []
86
149
  const baseUrl = await serving((request, response) => {
@@ -180,6 +243,38 @@ describe('a server that never answers', () => {
180
243
  ])
181
244
  })
182
245
 
246
+ it('refuses a signed upload redirect without replaying its headers or finalizing', async () => {
247
+ let followed = false
248
+ let finalized = false
249
+ const baseUrl = await serving((request, response) => {
250
+ if (request.url === '/signed-upload') {
251
+ response.statusCode = 302
252
+ response.setHeader('location', `${baseUrl}/stolen`)
253
+ response.end()
254
+ return
255
+ }
256
+ if (request.url === '/stolen') followed = true
257
+ if (request.url === '/api/v1/media/med_1/finalize') finalized = true
258
+ response.statusCode = 200
259
+ response.end()
260
+ })
261
+ const client = createClient(baseUrl, 'Bearer machine')
262
+
263
+ await expect(
264
+ client.uploadMedia(
265
+ {
266
+ media_id: 'med_1',
267
+ upload_url: `${baseUrl}/signed-upload`,
268
+ upload_headers: { authorization: 'Signed secret' },
269
+ expires_at: '2026-08-14T12:00:00.000Z',
270
+ },
271
+ new Uint8Array([1, 2, 3, 4]),
272
+ ),
273
+ ).rejects.toThrow(/redirect is refused/i)
274
+ expect(followed).toBe(false)
275
+ expect(finalized).toBe(false)
276
+ })
277
+
183
278
  it('treats a truncated body as a transport failure, not a crash', async () => {
184
279
  // A body that stops mid-JSON used to escape as a raw parse error outside
185
280
  // the retry path, so nothing backed off and retried it.
package/src/client.ts CHANGED
@@ -71,6 +71,7 @@ export interface ApiClient {
71
71
  machine_name: string
72
72
  credential_hash: string
73
73
  poll_verifier_hash: string
74
+ confirmation_hash: string
74
75
  }): Promise<BeginPairingResponse>
75
76
  pollPairing(pairingId: string, pollVerifier: string): Promise<PollPairingResponse>
76
77
  accessStatus(): Promise<AccountAccessResponse>
@@ -209,6 +210,7 @@ export function createClient(
209
210
  ...clientHeaders,
210
211
  },
211
212
  signal,
213
+ ...(bearer ? { redirect: 'error' as const } : {}),
212
214
  ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
213
215
  })
214
216
  } catch (err) {
@@ -243,7 +245,9 @@ export function createClient(
243
245
  return {
244
246
  beginPairing: (body) => call('POST', '/api/v1/pairings', body),
245
247
  pollPairing: (pairingId, pollVerifier) =>
246
- call('POST', `/api/v1/pairings/${pairingId}/poll`, { poll_verifier: pollVerifier }),
248
+ call('POST', `/api/v1/pairings/${encodeURIComponent(pairingId)}/poll`, {
249
+ poll_verifier: pollVerifier,
250
+ }),
247
251
  accessStatus: () => call('GET', '/api/v1/account/access'),
248
252
  listDevices: () => call('GET', '/api/v1/devices'),
249
253
  capabilities: (platform = 'ios', appVersion, appBuild) => {
@@ -251,12 +255,12 @@ export function createClient(
251
255
  if (appVersion !== undefined) query.set('app_version', appVersion)
252
256
  if (appBuild !== undefined) query.set('app_build', appBuild)
253
257
  const suffix = query.size > 0 ? `?${query.toString()}` : ''
254
- return call('GET', `/api/v1/capabilities/${platform}${suffix}`)
258
+ return call('GET', `/api/v1/capabilities/${encodeURIComponent(platform)}${suffix}`)
255
259
  },
256
260
  compatibility: () => call('GET', '/api/v1/compatibility'),
257
261
  submit: (body, waitSeconds) =>
258
262
  call('POST', `/api/v1/notifications?wait_seconds=${waitSeconds}`, body, waitSeconds),
259
- evidence: (requestId) => call('GET', `/api/v1/notifications/${requestId}`),
263
+ evidence: (requestId) => call('GET', `/api/v1/notifications/${encodeURIComponent(requestId)}`),
260
264
  replies: (requestId, { waitSeconds, afterSeq }) =>
261
265
  call(
262
266
  'GET',
@@ -297,10 +301,19 @@ export function createClient(
297
301
  // Media can be large, so this gets its own allowance rather than the
298
302
  // per-request budget — but still a finite one.
299
303
  signal: AbortSignal.timeout(Math.max(budgetMs, 60_000)),
304
+ // The grant names one exact destination and carries signed headers.
305
+ // A redirect would replay both against an origin the server never
306
+ // granted, so it is refused rather than followed.
307
+ redirect: 'manual',
300
308
  })
301
309
  } catch (err) {
302
310
  throw new NetworkError(`Upload failed: ${String(err)}`)
303
311
  }
312
+ if (response.status >= 300 && response.status < 400) {
313
+ throw new NetworkError(
314
+ `Upload redirect is refused, not followed: the grant's headers are signed for one exact destination`,
315
+ )
316
+ }
304
317
  if (!response.ok) {
305
318
  throw new NetworkError(`Upload rejected with status ${response.status}`)
306
319
  }
@@ -0,0 +1,38 @@
1
+ import type { CommandDeps } from './commands-core.js'
2
+ import { resolveActiveHarness } from './commands-harness-context.js'
3
+ import { findOwningSession, readProjectSession } from './hooks.js'
4
+
5
+ export interface ResolvedCommandSession {
6
+ sessionId: string
7
+ source: 'exact-harness' | 'stable-id' | 'project-pointer'
8
+ }
9
+
10
+ /**
11
+ * Resolve only Question Routing lifecycle ownership.
12
+ *
13
+ * Exact harness identity wins across checkouts. A stable question/request id
14
+ * wins next. The cwd pointer remains a compatibility fallback for shells that
15
+ * genuinely have no exact harness identity; it never owns Project or config
16
+ * scope, which stay with the command's invocation directory.
17
+ */
18
+ export function resolveCommandSession(
19
+ deps: CommandDeps,
20
+ stableId?: string,
21
+ ): ResolvedCommandSession | null {
22
+ if (stableId !== undefined) {
23
+ const owner = findOwningSession(stableId, deps.env)
24
+ if (owner.ambiguous) return null
25
+ if (owner.sessionId !== null) return { sessionId: owner.sessionId, source: 'stable-id' }
26
+ }
27
+ const now = (deps.now ?? Date.now)()
28
+ const resolution = resolveActiveHarness(deps.env, deps.cwd, now)
29
+ if (resolution.contested.length > 0) return null
30
+ if (
31
+ resolution.active?.sessionId !== undefined &&
32
+ resolution.contested.length === 0
33
+ ) {
34
+ return { sessionId: resolution.active.sessionId, source: 'exact-harness' }
35
+ }
36
+ const pointer = readProjectSession(deps.cwd, deps.env, now)
37
+ return pointer === null ? null : { sessionId: pointer, source: 'project-pointer' }
38
+ }
@@ -4,6 +4,7 @@ import { randomBytes } from 'node:crypto'
4
4
  import os from 'node:os'
5
5
  import { NetworkError } from './client.js'
6
6
  import { type FlagOverrides } from './config.js'
7
+ import { checkApproveUrl } from './url-policy.js'
7
8
  import {
8
9
  EXIT,
9
10
  authedClient,
@@ -17,6 +18,17 @@ import {
17
18
  // login / logout / auth status
18
19
  // ---------------------------------------------------------------------------
19
20
 
21
+ /**
22
+ * Keep the one-time confirmation secret out of the HTTP request that opens
23
+ * the dashboard. URL fragments are browser-local and are not sent by GET,
24
+ * prefetchers, referrers, or link scanners.
25
+ */
26
+ export function pairingApprovalUrl(approveUrl: string, confirmationSecret: string): string {
27
+ const url = new URL(approveUrl)
28
+ url.hash = new URLSearchParams({ confirmation_secret: confirmationSecret }).toString()
29
+ return url.toString()
30
+ }
31
+
20
32
  export async function loginCommand(
21
33
  deps: CommandDeps,
22
34
  flags: { name?: string; baseUrl?: string; open?: boolean },
@@ -26,6 +38,7 @@ export async function loginCommand(
26
38
  const machineName = flags.name ?? os.hostname()
27
39
  const secret = randomBytes(32).toString('base64url')
28
40
  const pollVerifier = randomBytes(24).toString('base64url')
41
+ const confirmationSecret = randomBytes(32).toString('base64url')
29
42
  const client = makeClient(deps, baseUrl, null)
30
43
 
31
44
  let begin
@@ -34,21 +47,37 @@ export async function loginCommand(
34
47
  machine_name: machineName,
35
48
  credential_hash: sha256Hex(secret),
36
49
  poll_verifier_hash: sha256Hex(pollVerifier),
50
+ confirmation_hash: sha256Hex(confirmationSecret),
37
51
  })
38
52
  } catch (err) {
39
53
  return reportError(deps, err)
40
54
  }
55
+ const approveUrl = pairingApprovalUrl(begin.approve_url, confirmationSecret)
56
+
57
+ // The approval URL is the server's choice, and this machine is about to put
58
+ // it in front of the user's browser. A compromised or misconfigured service
59
+ // must not be able to aim that anywhere it likes, so the pairing stops here
60
+ // rather than showing a link the user would reasonably trust.
61
+ const approvable = checkApproveUrl(begin.approve_url, baseUrl, config.approve_origins.value)
62
+ if (!approvable.ok) {
63
+ deps.io.err(`Pairing stopped: ${approvable.reason}`)
64
+ deps.io.err(
65
+ 'next: If you self-host with the dashboard on its own origin, allow it with ' +
66
+ '`notifai config set approve_origins <origin>` and run `notifai login` again.',
67
+ )
68
+ return EXIT.auth
69
+ }
41
70
 
42
71
  const interactive = deps.io.interactive === true
43
72
  if (interactive) {
44
73
  await deps.io.intro?.('Notifai sign in')
45
- await deps.io.note?.(`Code: ${begin.code}\n${begin.approve_url}`, 'Approve this machine')
74
+ await deps.io.note?.(`Code: ${begin.code}\n${approveUrl}`, 'Approve this machine')
46
75
  } else {
47
76
  deps.io.out(`Pairing code: ${begin.code}`)
48
- deps.io.out(`Approve this machine at: ${begin.approve_url}`)
77
+ deps.io.out(`Approve this machine at: ${approveUrl}`)
49
78
  deps.io.out('Waiting for approval…')
50
79
  }
51
- if (flags.open !== false) deps.io.openUrl(begin.approve_url)
80
+ if (flags.open !== false) deps.io.openUrl(approveUrl)
52
81
 
53
82
  const expiresAt = new Date(begin.expires_at).getTime()
54
83
  const intervalMs = Math.max(begin.poll_interval_seconds, 1) * 1000
@@ -7,11 +7,14 @@ import {
7
7
  } from 'node:fs'
8
8
  import path from 'node:path'
9
9
  import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
10
+ import { ensurePrivateDirectory } from './atomic-file.js'
10
11
  import { acceptedValues, configInfo } from './config-schema.js'
11
12
  import {
12
13
  BOOLEAN_CONFIG_KEYS,
13
14
  CONFIG_KEYS,
14
15
  NUMERIC_CONFIG_KEYS,
16
+ ORIGIN_LIST_CONFIG_KEYS,
17
+ USER_OWNED_CONFIG_KEYS,
15
18
  configBounds,
16
19
  configDefaultValue,
17
20
  findProjectConfigPath,
@@ -20,6 +23,7 @@ import {
20
23
  sessionConfigPath,
21
24
  type ConfigKey,
22
25
  } from './config.js'
26
+ import { normalizeOrigin } from './url-policy.js'
23
27
  import { renderConfigExplain, renderConfigList, renderConfigPlain } from './ui/config-view.js'
24
28
  import { EXIT, loadLoggedConfig, type CommandDeps } from './commands-core.js'
25
29
 
@@ -189,10 +193,28 @@ export async function configSetCommand(
189
193
  deps.io.err(`${key} takes one of: ${info.choices.join(', ')} — not "${rawValue}".`)
190
194
  return EXIT.usage
191
195
  }
192
- if (key === 'devices') value = rawValue.split(',').map((s) => s.trim()).filter(Boolean)
196
+ if (info.kind === 'list') value = rawValue.split(',').map((s) => s.trim()).filter(Boolean)
197
+ if (ORIGIN_LIST_CONFIG_KEYS.includes(configKey)) {
198
+ const origins = (value as string[]).map((entry) => normalizeOrigin(entry))
199
+ const rejected = (value as string[]).filter((_, index) => origins[index] === null)
200
+ if (rejected.length > 0) {
201
+ deps.io.err(
202
+ `${key} entries must be bare http(s) origins like "https://host" or "http://host:8080" — not ${rejected.map((entry) => `"${entry}"`).join(', ')}.`,
203
+ )
204
+ return EXIT.usage
205
+ }
206
+ value = origins
207
+ }
193
208
 
194
209
  const target = await configMutationTarget(deps, flags)
195
210
  if (target === null) return EXIT.usage
211
+ if (target.layer === 'project' && USER_OWNED_CONFIG_KEYS.includes(configKey)) {
212
+ deps.io.err(
213
+ `${key} widens which origins this machine trusts, so it is never read from the repository's shared config — a cloned repository must not be able to set it.`,
214
+ )
215
+ deps.io.err('Store it on this machine (no flag) or for this project personally (--local).')
216
+ return EXIT.usage
217
+ }
196
218
  if (target.layer === 'global' && Object.is(value, configDefaultValue(configKey))) {
197
219
  deps.io.err(`${key} is already the shipped default (${JSON.stringify(value)}).`)
198
220
  deps.io.err(
@@ -213,7 +235,8 @@ export async function configSetCommand(
213
235
  ? (parseToml(readFileSync(target.path, 'utf8')) as Record<string, unknown>)
214
236
  : {}
215
237
  existing[key] = value
216
- mkdirSync(path.dirname(target.path), { recursive: true })
238
+ if (target.layer === 'project') mkdirSync(path.dirname(target.path), { recursive: true })
239
+ else ensurePrivateDirectory(path.dirname(target.path))
217
240
  writeFileSync(target.path, `${stringifyToml(existing)}\n`)
218
241
  deps.io.out(`Wrote ${key} to ${target.path}`)
219
242
  return EXIT.ok
@@ -84,6 +84,8 @@ export interface CommandDeps {
84
84
  nativeSkills?: NativeSkills
85
85
  /** Test seam for Orca's exact-worktree semantic title lookup. */
86
86
  orcaSessionTitle?: OrcaSessionTitleLookup
87
+ /** Test seam for registry reads; production uses fetch. */
88
+ fetchImpl?: typeof fetch
87
89
  /**
88
90
  * The local record of what this invocation did. Optional so a test fake need
89
91
  * not carry one; `log(deps)` supplies a logger that records nothing.
@@ -132,7 +134,36 @@ export function makeClient(
132
134
  }
133
135
 
134
136
  export function resolvedBaseUrl(config: CliConfig, credential: MachineCredential | null): string {
135
- return config.base_url.source === 'default' && credential ? credential.baseUrl : config.base_url.value
137
+ return credential ? credential.baseUrl : config.base_url.value
138
+ }
139
+
140
+ /**
141
+ * Whether a flag or env origin would send signed-in traffic somewhere other
142
+ * than the origin stored with this machine's credential.
143
+ *
144
+ * Login still honours those overrides: pairing has no credential to pin to.
145
+ * Returning the source (not the override URL, and never the bearer) is what
146
+ * lets callers diagnose the ignore without leaking either.
147
+ */
148
+ export function ignoredOriginOverride(
149
+ config: CliConfig,
150
+ credential: MachineCredential,
151
+ ): CliConfig['base_url']['source'] | null {
152
+ if (config.base_url.source === 'default') return null
153
+ if (config.base_url.value === credential.baseUrl) return null
154
+ return config.base_url.source
155
+ }
156
+
157
+ export function diagnoseIgnoredOriginOverride(
158
+ io: Pick<CommandIo, 'err'>,
159
+ config: CliConfig,
160
+ credential: MachineCredential,
161
+ ): void {
162
+ const source = ignoredOriginOverride(config, credential)
163
+ if (source === null) return
164
+ io.err(
165
+ `Ignoring base_url from ${source}; authenticated traffic uses the origin stored with this machine's credential. Run \`notifai login\` to pair with a different origin.`,
166
+ )
136
167
  }
137
168
 
138
169
  export function authedClient(deps: CommandDeps, config: CliConfig): { client: ApiClient; baseUrl: string } | null {
@@ -142,9 +173,10 @@ export function authedClient(deps: CommandDeps, config: CliConfig): { client: Ap
142
173
  // trace: it returns before any request is made, so without this the log
143
174
  // shows an exit code and no cause.
144
175
  log(deps).error('cli.error', { kind: 'auth', message: 'not signed in', store: deps.store.describe() })
145
- deps.io.err('Not signed in. Run `notifai login` first.')
176
+ deps.io.err('Not signed in. Run `notifai init`; it will coordinate machine login and device setup.')
146
177
  return null
147
178
  }
179
+ diagnoseIgnoredOriginOverride(deps.io, config, credential)
148
180
  const baseUrl = resolvedBaseUrl(config, credential)
149
181
  return {
150
182
  client: makeClient(deps, baseUrl, `Bearer nfm_${credential.machineId}.${credential.secret}`),
@@ -233,7 +265,10 @@ export function reportError(
233
265
  'disagree about the contract; check with `notifai doctor`',
234
266
  )
235
267
  }
236
- const recovery = localRecovery(err.recoveryAction, err.details)
268
+ const recovery =
269
+ err.code === 'no_active_devices'
270
+ ? 'next: Run `notifai init` to connect an active Companion App; it will complete machine setup and identify any human-only device step.'
271
+ : localRecovery(err.recoveryAction, err.details)
237
272
  if (recovery !== null) deps.io.err(recovery)
238
273
  else if (err.recoveryAction === null && err.nextAction) deps.io.err(`next: ${err.nextAction}`)
239
274
  if (err.code === 'auth_required' || err.code === 'machine_revoked') return EXIT.auth