@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.
- package/CHANGELOG.md +48 -0
- package/README.md +20 -13
- package/dist/atomic-file.d.ts +14 -0
- package/dist/atomic-file.js +27 -2
- package/dist/atomic-file.js.map +1 -1
- package/dist/cli-bin.d.ts +4 -0
- package/dist/cli-bin.js +62 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/cli-release.d.ts +10 -0
- package/dist/cli-release.js +75 -0
- package/dist/cli-release.js.map +1 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.js +13 -3
- package/dist/client.js.map +1 -1
- package/dist/command-session.d.ts +14 -0
- package/dist/command-session.js +30 -0
- package/dist/command-session.js.map +1 -0
- package/dist/commands-auth.d.ts +6 -0
- package/dist/commands-auth.js +28 -3
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-config.js +22 -3
- package/dist/commands-config.js.map +1 -1
- package/dist/commands-core.d.ts +12 -0
- package/dist/commands-core.js +27 -3
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-doctor.d.ts +1 -0
- package/dist/commands-doctor.js +87 -29
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.d.ts +7 -3
- package/dist/commands-guidance.js +40 -31
- package/dist/commands-guidance.js.map +1 -1
- package/dist/commands-harness-context.js +21 -1
- package/dist/commands-harness-context.js.map +1 -1
- package/dist/commands-hooks.d.ts +20 -5
- package/dist/commands-hooks.js +509 -155
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +12 -18
- package/dist/commands-init.js +149 -47
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.d.ts +17 -2
- package/dist/commands-send-support.js +61 -5
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.d.ts +1 -0
- package/dist/commands-send.js +114 -17
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-setup-proof.d.ts +12 -3
- package/dist/commands-setup-proof.js +60 -16
- package/dist/commands-setup-proof.js.map +1 -1
- package/dist/commands-skill.d.ts +11 -1
- package/dist/commands-skill.js +61 -11
- package/dist/commands-skill.js.map +1 -1
- package/dist/commands.d.ts +2 -2
- package/dist/commands.js +1 -1
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +25 -0
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +24 -3
- package/dist/config.js +37 -5
- package/dist/config.js.map +1 -1
- package/dist/credentials.d.ts +10 -1
- package/dist/credentials.js +25 -12
- package/dist/credentials.js.map +1 -1
- package/dist/guidance-content.d.ts +20 -0
- package/dist/guidance-content.js +73 -8
- package/dist/guidance-content.js.map +1 -1
- package/dist/guidance-render.d.ts +20 -0
- package/dist/guidance-render.js +42 -0
- package/dist/guidance-render.js.map +1 -0
- package/dist/guidance.d.ts +21 -0
- package/dist/guidance.js +73 -18
- package/dist/guidance.js.map +1 -1
- package/dist/hook-adapter.js +37 -3
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +83 -7
- package/dist/hooks.js +288 -90
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +10 -18
- package/dist/install-hooks.js +110 -23
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +1 -1
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +8 -1
- package/dist/logging.js +43 -2
- package/dist/logging.js.map +1 -1
- package/dist/main.js +2 -13
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +19 -0
- package/dist/native-skills.js +30 -8
- package/dist/native-skills.js.map +1 -1
- package/dist/opencode-plugin.js +25 -1
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/program.js +34 -20
- package/dist/program.js.map +1 -1
- package/dist/readiness.d.ts +6 -0
- package/dist/readiness.js +37 -0
- package/dist/readiness.js.map +1 -1
- package/dist/send-attempts.d.ts +26 -0
- package/dist/send-attempts.js +139 -0
- package/dist/send-attempts.js.map +1 -0
- package/dist/send.d.ts +8 -1
- package/dist/send.js +17 -1
- package/dist/send.js.map +1 -1
- package/dist/session-activation.d.ts +11 -0
- package/dist/session-activation.js +46 -0
- package/dist/session-activation.js.map +1 -0
- package/dist/url-policy.d.ts +85 -0
- package/dist/url-policy.js +369 -0
- package/dist/url-policy.js.map +1 -0
- package/package.json +3 -3
- package/src/atomic-file.ts +36 -2
- package/src/cli-bin.test.ts +38 -0
- package/src/cli-bin.ts +67 -0
- package/src/cli-release.test.ts +46 -0
- package/src/cli-release.ts +78 -0
- package/src/client.test.ts +95 -0
- package/src/client.ts +16 -3
- package/src/command-session.ts +38 -0
- package/src/commands-auth.ts +32 -3
- package/src/commands-config.ts +25 -2
- package/src/commands-core.ts +38 -3
- package/src/commands-doctor.ts +117 -41
- package/src/commands-guidance.ts +43 -32
- package/src/commands-harness-context.ts +24 -1
- package/src/commands-hooks.ts +704 -188
- package/src/commands-init.ts +180 -57
- package/src/commands-send-support.test.ts +156 -0
- package/src/commands-send-support.ts +67 -5
- package/src/commands-send.ts +135 -16
- package/src/commands-setup-proof.test.ts +72 -0
- package/src/commands-setup-proof.ts +69 -16
- package/src/commands-skill.ts +78 -15
- package/src/commands.test.ts +1281 -157
- package/src/commands.ts +2 -0
- package/src/config-schema.ts +28 -1
- package/src/config.test.ts +1 -1
- package/src/config.ts +49 -5
- package/src/credentials.test.ts +71 -3
- package/src/credentials.ts +29 -15
- package/src/guidance-content.ts +74 -8
- package/src/guidance-render.ts +56 -0
- package/src/guidance.test.ts +70 -11
- package/src/guidance.ts +100 -18
- package/src/hook-adapter.test.ts +113 -1
- package/src/hook-adapter.ts +37 -3
- package/src/hooks.test.ts +778 -166
- package/src/hooks.ts +368 -129
- package/src/install-hooks.test.ts +135 -16
- package/src/install-hooks.ts +113 -25
- package/src/interactive.ts +1 -1
- package/src/logging.test.ts +66 -3
- package/src/logging.ts +45 -2
- package/src/main.ts +2 -14
- package/src/native-skills.test.ts +41 -1
- package/src/native-skills.ts +40 -6
- package/src/opencode-plugin.ts +25 -1
- package/src/program.test.ts +37 -2
- package/src/program.ts +62 -24
- package/src/readiness.ts +44 -0
- package/src/send-attempts.test.ts +143 -0
- package/src/send-attempts.ts +188 -0
- package/src/send.test.ts +28 -0
- package/src/send.ts +28 -2
- package/src/session-activation.ts +62 -0
- package/src/skill.test.ts +125 -11
- package/src/trust-policy.test.ts +701 -0
- 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
|
+
}
|
package/src/client.test.ts
CHANGED
|
@@ -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`, {
|
|
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
|
+
}
|
package/src/commands-auth.ts
CHANGED
|
@@ -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${
|
|
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: ${
|
|
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(
|
|
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
|
package/src/commands-config.ts
CHANGED
|
@@ -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 (
|
|
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
|
package/src/commands-core.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 =
|
|
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
|