@raidiant/notifai 8.0.0 → 9.0.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 (145) hide show
  1. package/CHANGELOG.md +41 -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/commands-auth.d.ts +6 -0
  16. package/dist/commands-auth.js +28 -3
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-config.js +22 -3
  19. package/dist/commands-config.js.map +1 -1
  20. package/dist/commands-core.d.ts +12 -0
  21. package/dist/commands-core.js +27 -3
  22. package/dist/commands-core.js.map +1 -1
  23. package/dist/commands-doctor.d.ts +1 -0
  24. package/dist/commands-doctor.js +71 -15
  25. package/dist/commands-doctor.js.map +1 -1
  26. package/dist/commands-guidance.d.ts +7 -3
  27. package/dist/commands-guidance.js +63 -28
  28. package/dist/commands-guidance.js.map +1 -1
  29. package/dist/commands-hooks.d.ts +9 -4
  30. package/dist/commands-hooks.js +430 -32
  31. package/dist/commands-hooks.js.map +1 -1
  32. package/dist/commands-init.d.ts +10 -18
  33. package/dist/commands-init.js +114 -32
  34. package/dist/commands-init.js.map +1 -1
  35. package/dist/commands-send-support.d.ts +17 -2
  36. package/dist/commands-send-support.js +61 -5
  37. package/dist/commands-send-support.js.map +1 -1
  38. package/dist/commands-send.js +60 -11
  39. package/dist/commands-send.js.map +1 -1
  40. package/dist/commands-setup-proof.d.ts +8 -0
  41. package/dist/commands-setup-proof.js +31 -2
  42. package/dist/commands-setup-proof.js.map +1 -1
  43. package/dist/commands-skill.d.ts +11 -1
  44. package/dist/commands-skill.js +61 -11
  45. package/dist/commands-skill.js.map +1 -1
  46. package/dist/commands.d.ts +1 -1
  47. package/dist/commands.js.map +1 -1
  48. package/dist/config-schema.d.ts +1 -1
  49. package/dist/config-schema.js +25 -0
  50. package/dist/config-schema.js.map +1 -1
  51. package/dist/config.d.ts +24 -3
  52. package/dist/config.js +37 -5
  53. package/dist/config.js.map +1 -1
  54. package/dist/credentials.d.ts +10 -1
  55. package/dist/credentials.js +25 -12
  56. package/dist/credentials.js.map +1 -1
  57. package/dist/guidance-content.d.ts +20 -0
  58. package/dist/guidance-content.js +73 -8
  59. package/dist/guidance-content.js.map +1 -1
  60. package/dist/guidance.d.ts +21 -0
  61. package/dist/guidance.js +73 -18
  62. package/dist/guidance.js.map +1 -1
  63. package/dist/hook-adapter.js +37 -3
  64. package/dist/hook-adapter.js.map +1 -1
  65. package/dist/hooks.d.ts +68 -7
  66. package/dist/hooks.js +231 -85
  67. package/dist/hooks.js.map +1 -1
  68. package/dist/install-hooks.d.ts +10 -18
  69. package/dist/install-hooks.js +110 -23
  70. package/dist/install-hooks.js.map +1 -1
  71. package/dist/interactive.d.ts +1 -1
  72. package/dist/interactive.js +1 -1
  73. package/dist/interactive.js.map +1 -1
  74. package/dist/logging.d.ts +8 -1
  75. package/dist/logging.js +43 -2
  76. package/dist/logging.js.map +1 -1
  77. package/dist/main.js +2 -13
  78. package/dist/main.js.map +1 -1
  79. package/dist/native-skills.d.ts +19 -0
  80. package/dist/native-skills.js +30 -8
  81. package/dist/native-skills.js.map +1 -1
  82. package/dist/opencode-plugin.js +14 -1
  83. package/dist/opencode-plugin.js.map +1 -1
  84. package/dist/program.js +13 -8
  85. package/dist/program.js.map +1 -1
  86. package/dist/send.d.ts +8 -1
  87. package/dist/send.js +17 -1
  88. package/dist/send.js.map +1 -1
  89. package/dist/session-activation.d.ts +15 -0
  90. package/dist/session-activation.js +35 -0
  91. package/dist/session-activation.js.map +1 -0
  92. package/dist/url-policy.d.ts +85 -0
  93. package/dist/url-policy.js +369 -0
  94. package/dist/url-policy.js.map +1 -0
  95. package/package.json +3 -3
  96. package/src/atomic-file.ts +36 -2
  97. package/src/cli-bin.test.ts +38 -0
  98. package/src/cli-bin.ts +67 -0
  99. package/src/cli-release.test.ts +46 -0
  100. package/src/cli-release.ts +78 -0
  101. package/src/client.test.ts +95 -0
  102. package/src/client.ts +16 -3
  103. package/src/commands-auth.ts +32 -3
  104. package/src/commands-config.ts +25 -2
  105. package/src/commands-core.ts +38 -3
  106. package/src/commands-doctor.ts +99 -13
  107. package/src/commands-guidance.ts +75 -29
  108. package/src/commands-hooks.ts +503 -34
  109. package/src/commands-init.ts +139 -38
  110. package/src/commands-send-support.test.ts +156 -0
  111. package/src/commands-send-support.ts +67 -5
  112. package/src/commands-send.ts +73 -10
  113. package/src/commands-setup-proof.ts +36 -2
  114. package/src/commands-skill.ts +78 -15
  115. package/src/commands.test.ts +1083 -101
  116. package/src/commands.ts +1 -0
  117. package/src/config-schema.ts +28 -1
  118. package/src/config.test.ts +1 -1
  119. package/src/config.ts +49 -5
  120. package/src/credentials.test.ts +71 -3
  121. package/src/credentials.ts +29 -15
  122. package/src/guidance-content.ts +74 -8
  123. package/src/guidance.test.ts +42 -11
  124. package/src/guidance.ts +100 -18
  125. package/src/hook-adapter.test.ts +113 -1
  126. package/src/hook-adapter.ts +37 -3
  127. package/src/hooks.test.ts +746 -165
  128. package/src/hooks.ts +299 -124
  129. package/src/install-hooks.test.ts +135 -16
  130. package/src/install-hooks.ts +113 -25
  131. package/src/interactive.ts +1 -1
  132. package/src/logging.test.ts +66 -3
  133. package/src/logging.ts +45 -2
  134. package/src/main.ts +2 -14
  135. package/src/native-skills.test.ts +41 -1
  136. package/src/native-skills.ts +40 -6
  137. package/src/opencode-plugin.ts +14 -1
  138. package/src/program.test.ts +37 -2
  139. package/src/program.ts +40 -12
  140. package/src/send.test.ts +28 -0
  141. package/src/send.ts +28 -2
  142. package/src/session-activation.ts +45 -0
  143. package/src/skill.test.ts +120 -4
  144. package/src/trust-policy.test.ts +701 -0
  145. package/src/url-policy.ts +399 -0
@@ -8,10 +8,11 @@ import { randomBytes } from 'node:crypto'
8
8
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
9
9
  import path from 'node:path'
10
10
  import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
11
+ import { ensurePrivateDirectory } from './atomic-file.js'
11
12
  import { ApiCallError, NetworkError, type ApiClient } from './client.js'
12
- import { type CliConfig } from './config.js'
13
+ import { personalProjectConfigPath, type CliConfig } from './config.js'
13
14
  import { projectSlugFrom as inferredProjectSlugFrom } from './invocation-context.js'
14
- import type { SkillScope } from './native-skills.js'
15
+ import { SKILLS_INSTALLER_SPEC, type SkillScope } from './native-skills.js'
15
16
  import { firstBlocker, type Readiness, type ReadinessRefresh, type ReadinessState } from './readiness.js'
16
17
  import { buildDraft, formatReceipt } from './send.js'
17
18
  import { loginCommand } from './commands-auth.js'
@@ -26,8 +27,13 @@ import {
26
27
  import { readyCompanionDevices, supportPageUrl } from './commands-devices.js'
27
28
  import { assessReadiness, remedyLine } from './commands-doctor.js'
28
29
  import { hooksInstallCommand, pickHarnessesToInstall } from './commands-hooks.js'
29
- import { observedCompanionReceipt, readSetupProof, writeSetupProof } from './commands-setup-proof.js'
30
- import { SKILLS_SOURCE } from './commands-skill.js'
30
+ import {
31
+ observedCompanionReceipt,
32
+ readSetupProof,
33
+ setupProofIsStale,
34
+ writeSetupProof,
35
+ } from './commands-setup-proof.js'
36
+ import { listScopedNotifaiSkills, SKILLS_SOURCE } from './commands-skill.js'
31
37
 
32
38
  // ---------------------------------------------------------------------------
33
39
  // init
@@ -39,6 +45,9 @@ export function projectSlugFrom(name: string): string {
39
45
  return inferredProjectSlugFrom(name) ?? 'project'
40
46
  }
41
47
 
48
+ /** Project checkout vs this machine. Drives skill, hooks, and config together. */
49
+ export type SetupScope = SkillScope
50
+
42
51
  export interface InitFlags {
43
52
  projectId?: string
44
53
  /**
@@ -48,7 +57,13 @@ export interface InitFlags {
48
57
  * never spawn npx against the network by default.
49
58
  */
50
59
  skills?: boolean
51
- /** Scope selected by an unattended caller; humans choose inside npx skills. */
60
+ /**
61
+ * One setup-scope for skill, hooks, and config. The unattended answer to
62
+ * init's single project-vs-machine question. Pairing, devices, and the CLI
63
+ * binary are not this flag.
64
+ */
65
+ setupScope?: SetupScope
66
+ /** Alias of `--setup-scope` when installing the skill unattended. */
52
67
  skillsScope?: SkillScope
53
68
  /** Same tri-state, for the harness hooks. */
54
69
  hooks?: boolean
@@ -97,12 +112,17 @@ async function closeGap(
97
112
  flags: InitFlags,
98
113
  ): Promise<GapCloseResult> {
99
114
  if (state.id === 'project') {
100
- const configPath = path.join(deps.cwd, '.notifai', 'config.toml')
115
+ const slug = projectSlugFrom(flags.projectId ?? path.basename(deps.cwd))
116
+ const configPath =
117
+ flags.setupScope === 'global'
118
+ ? personalProjectConfigPath(deps.cwd, deps.env)
119
+ : path.join(deps.cwd, '.notifai', 'config.toml')
101
120
  const existing = existsSync(configPath)
102
121
  ? (parseToml(readFileSync(configPath, 'utf8')) as Record<string, unknown>)
103
122
  : {}
104
- existing['project'] = projectSlugFrom(flags.projectId ?? path.basename(deps.cwd))
105
- mkdirSync(path.dirname(configPath), { recursive: true })
123
+ existing['project'] = slug
124
+ if (flags.setupScope === 'global') ensurePrivateDirectory(path.dirname(configPath))
125
+ else mkdirSync(path.dirname(configPath), { recursive: true })
106
126
  writeFileSync(configPath, `${stringifyToml(existing)}\n`)
107
127
  return 'closed'
108
128
  }
@@ -111,8 +131,9 @@ async function closeGap(
111
131
  const harnesses = await pickHarnessesToInstall(deps)
112
132
  if (harnesses === null || harnesses.length === 0) return 'failed'
113
133
  let ok = true
134
+ const hookFlags = flags.setupScope === 'global' ? { global: true as const } : {}
114
135
  for (const harness of harnesses) {
115
- if (hooksInstallCommand(deps, { harness }) !== EXIT.ok) ok = false
136
+ if (hooksInstallCommand(deps, { harness, ...hookFlags }) !== EXIT.ok) ok = false
116
137
  }
117
138
  return ok ? 'closed' : 'failed'
118
139
  }
@@ -130,22 +151,43 @@ async function closeGap(
130
151
  )
131
152
  return 'failed'
132
153
  }
133
- const scopeText = flags.skillsScope === undefined ? 'the scope you choose' : `${flags.skillsScope} scope`
134
- deps.io.out(`Starting the native npx skills setup for the notifai agent skill (${scopeText})...`)
135
- const addOptions = {
154
+ const installScope = flags.setupScope ?? flags.skillsScope
155
+ if (installScope === undefined) {
156
+ deps.io.err(
157
+ 'Skill installation refused — choose a setup scope: `notifai init --skills --setup-scope project` or `... global`.',
158
+ )
159
+ return 'failed'
160
+ }
161
+ const { installed } = await listScopedNotifaiSkills(deps)
162
+ const extras = installed.filter((skill) => skill.scope !== installScope)
163
+ for (const extra of extras) {
164
+ const code = await deps.nativeSkills.remove({
165
+ skill: 'notifai',
166
+ scope: extra.scope,
167
+ cwd: deps.cwd,
168
+ env: deps.env,
169
+ })
170
+ if (code !== 0) {
171
+ deps.io.err(
172
+ `Skill installation refused — could not uninstall the ${extra.scope} copy (${extra.ref ?? 'unknown pin'}), so installing ${installScope} would leave both active.`,
173
+ )
174
+ return 'failed'
175
+ }
176
+ }
177
+ deps.io.out(`Starting the native npx skills setup for the notifai agent skill (${installScope} scope)...`)
178
+ const code = await deps.nativeSkills.add({
136
179
  source: SKILLS_SOURCE,
137
180
  skill: 'notifai',
138
181
  cwd: deps.cwd,
139
182
  env: deps.env,
140
- ...(flags.skillsScope === undefined ? {} : { scope: flags.skillsScope }),
141
- }
142
- const code = await deps.nativeSkills.add(addOptions)
183
+ scope: installScope,
184
+ })
143
185
  if (code !== 0) {
144
186
  deps.io.err('Skill installation failed — run it manually with:')
145
187
  deps.io.err(
146
- ` npx skills add ${SKILLS_SOURCE} --skill notifai${
147
- flags.skillsScope === 'global' ? ' --global' : ''
148
- }${flags.skillsScope === undefined ? '' : ' --yes'}`,
188
+ ` npx -y ${SKILLS_INSTALLER_SPEC} add ${SKILLS_SOURCE} --skill notifai${
189
+ installScope === 'global' ? ' --global' : ''
190
+ } --yes`,
149
191
  )
150
192
  }
151
193
  return code === 0 ? 'closed' : 'failed'
@@ -289,7 +331,6 @@ function setupProofDraft(
289
331
  project === null
290
332
  ? 'This real notification completed setup verification.'
291
333
  : `This real notification completed setup verification for ${project}.`,
292
- event: 'setup_verified',
293
334
  kind: 'update',
294
335
  platform: device.platform,
295
336
  device: [device.device_id],
@@ -371,6 +412,22 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
371
412
  existing?.device_id === target.device_id && existing.project === config.project.value
372
413
  ? existing
373
414
  : null
415
+ const nowMs = (deps.now ?? Date.now)()
416
+ if (proof !== null && setupProofIsStale(proof, nowMs)) {
417
+ let keepStale = false
418
+ try {
419
+ const snapshot = await authed.client.evidence(proof.request_id)
420
+ keepStale = observedCompanionReceipt(snapshot, proof.device_id) !== null
421
+ } catch {
422
+ keepStale = false
423
+ }
424
+ if (!keepStale) {
425
+ deps.io.out(
426
+ `The saved proof ${proof.request_id} is older than 24h without a Companion Receipt; sending a replacement.`,
427
+ )
428
+ proof = null
429
+ }
430
+ }
374
431
  if (proof === null) {
375
432
  const receipt = await submitSetupProof(deps, authed.client, config, target)
376
433
  if (receipt === null) return 'failed'
@@ -503,32 +560,76 @@ function wantsOptional(deps: CommandDeps, state: ReadinessState, flags: InitFlag
503
560
  * and a revoked credential are the same code path arriving at different
504
561
  * states rather than four branches to enumerate.
505
562
  */
506
- export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<number> {
563
+ function isSetupScope(value: string | undefined): value is SetupScope {
564
+ return value === 'project' || value === 'global'
565
+ }
566
+
567
+ async function resolveSetupScope(
568
+ deps: CommandDeps,
569
+ flags: InitFlags,
570
+ ): Promise<SetupScope | undefined | 'usage'> {
571
+ if (flags.setupScope !== undefined && !isSetupScope(flags.setupScope)) {
572
+ deps.io.err('Invalid setup scope. Choose `project` or `global`.')
573
+ return 'usage'
574
+ }
575
+ if (flags.skillsScope !== undefined && !isSetupScope(flags.skillsScope)) {
576
+ deps.io.err('Invalid skill scope. Choose `project` or `global`.')
577
+ return 'usage'
578
+ }
507
579
  if (
580
+ flags.setupScope !== undefined &&
508
581
  flags.skillsScope !== undefined &&
509
- flags.skillsScope !== 'project' &&
510
- flags.skillsScope !== 'global'
582
+ flags.setupScope !== flags.skillsScope
511
583
  ) {
512
- deps.io.err('Invalid skill scope. Choose `project` or `global`.')
513
- return EXIT.usage
584
+ deps.io.err('`--setup-scope` and `--skills-scope` disagree. Pass one, or pass the same value twice.')
585
+ return 'usage'
514
586
  }
515
587
  if (flags.skillsScope !== undefined && flags.skills !== true) {
516
- deps.io.err('`--skills-scope` requires `--skills`. Choose project or global in the native installer.')
517
- return EXIT.usage
588
+ deps.io.err('`--skills-scope` requires `--skills`. Use `--setup-scope` to choose project or machine for the whole setup.')
589
+ return 'usage'
518
590
  }
519
- if (
520
- flags.skills === true &&
521
- deps.io.interactive !== true &&
522
- flags.skillsScope === undefined
523
- ) {
591
+ const fromFlags = flags.setupScope ?? flags.skillsScope
592
+ if (fromFlags !== undefined) return fromFlags
593
+ if (flags.skills === false && flags.hooks === false) return undefined
594
+ if (deps.io.interactive === true && deps.io.select) {
595
+ const selected = await deps.io.select(
596
+ 'Should this Notifai setup apply to this project only, or to every project on this machine?',
597
+ [
598
+ {
599
+ value: 'project',
600
+ label: 'This project',
601
+ hint: 'skill, hooks, and shared config stay in this checkout',
602
+ },
603
+ {
604
+ value: 'global',
605
+ label: 'This machine',
606
+ hint: 'skill and hooks follow you into every repo',
607
+ },
608
+ ],
609
+ )
610
+ if (selected === 'project' || selected === 'global') return selected
611
+ deps.io.err('No setup scope selected. Pass `--setup-scope project` or `--setup-scope global`.')
612
+ return 'usage'
613
+ }
614
+ if (flags.skills === true) {
524
615
  deps.io.err(
525
- 'Unattended skill setup requires an explicit scope: `notifai init --skills --skills-scope project` or `... global`.',
616
+ 'Unattended skill setup requires an explicit scope: `notifai init --skills --setup-scope project` or `... global`.',
526
617
  )
527
- return EXIT.usage
618
+ return 'usage'
619
+ }
620
+ return undefined
621
+ }
622
+
623
+ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<number> {
624
+ const setupScope = await resolveSetupScope(deps, flags)
625
+ if (setupScope === 'usage') return EXIT.usage
626
+ const resolved: InitFlags = {
627
+ ...flags,
628
+ ...(setupScope === undefined ? {} : { setupScope, skillsScope: flags.skillsScope ?? setupScope }),
528
629
  }
529
630
  await deps.io.intro?.('Notifai setup')
530
631
 
531
- const skillOpts = flags.skillsScope === undefined ? {} : { skillScope: flags.skillsScope }
632
+ const skillOpts = resolved.setupScope === undefined ? {} : { skillScope: resolved.setupScope }
532
633
  let readiness = await assessReadiness(deps, skillOpts)
533
634
  const reassess = (refresh?: readonly ReadinessRefresh[]) =>
534
635
  assessReadiness(deps, {
@@ -560,9 +661,9 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
560
661
  }
561
662
 
562
663
  if (state.status === 'optional-gap') {
563
- if (remedy.by !== 'cli' || !(await wantsOptional(deps, state, flags))) continue
664
+ if (remedy.by !== 'cli' || !(await wantsOptional(deps, state, resolved))) continue
564
665
  attempted.add(state.id)
565
- const result = await closeGap(deps, state, flags)
666
+ const result = await closeGap(deps, state, resolved)
566
667
  if (result === 'failed') failed = true
567
668
  if (result === 'failed' && state.status === 'optional-gap') {
568
669
  readiness = await reassess(refreshAfterClose(state.id))
@@ -580,7 +681,7 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
580
681
 
581
682
  if (remedy.by === 'cli') {
582
683
  attempted.add(state.id)
583
- const result = await closeGap(deps, state, flags)
684
+ const result = await closeGap(deps, state, resolved)
584
685
  if (result === 'failed') failed = true
585
686
  if (result !== 'closed') {
586
687
  stop = true
@@ -635,7 +736,7 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
635
736
  if (stop || !advanced) break
636
737
  }
637
738
 
638
- await printInitClose(deps, readiness, flags)
739
+ await printInitClose(deps, readiness, resolved)
639
740
  const blocker = firstBlocker(readiness)
640
741
  if (blocker === null) return failed ? EXIT.failed : EXIT.ok
641
742
  return failed || deps.io.interactive !== true ? EXIT.failed : EXIT.ok
@@ -0,0 +1,156 @@
1
+ import { NOTIFICATION_IMAGE_MAX_BYTES } from '@raidiant/notifai-protocol'
2
+ import { createServer, type Server } from 'node:http'
3
+ import type { AddressInfo } from 'node:net'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+ import { EXIT, type CommandDeps, type CommandIo } from './commands-core.js'
6
+ import { readCappedBytes, uploadImage } from './commands-send-support.js'
7
+ import type { ApiClient } from './client.js'
8
+
9
+ let server: Server | undefined
10
+
11
+ afterEach(async () => {
12
+ if (server) {
13
+ const closing = server
14
+ server = undefined
15
+ await new Promise<void>((resolve) => closing.close(() => resolve()))
16
+ }
17
+ })
18
+
19
+ async function serving(handler: Parameters<typeof createServer>[1]): Promise<string> {
20
+ server = createServer(handler)
21
+ await new Promise<void>((resolve) => server?.listen(0, '127.0.0.1', resolve))
22
+ const { port } = server.address() as AddressInfo
23
+ return `http://127.0.0.1:${port}`
24
+ }
25
+
26
+ function io(): CommandIo {
27
+ return {
28
+ out: () => undefined,
29
+ err: () => undefined,
30
+ confirm: async () => false,
31
+ openUrl: () => undefined,
32
+ }
33
+ }
34
+
35
+ describe('readCappedBytes', () => {
36
+ it('refuses Content-Length over the cap before reading the body', async () => {
37
+ const response = new Response(null, { headers: { 'content-length': '64' } })
38
+ expect(await readCappedBytes(response, 16)).toBe('too-large')
39
+ })
40
+
41
+ it('stops reading once the downloaded bytes exceed the cap', async () => {
42
+ const response = new Response(
43
+ new ReadableStream({
44
+ start(controller) {
45
+ controller.enqueue(new Uint8Array(12))
46
+ controller.enqueue(new Uint8Array(12))
47
+ controller.close()
48
+ },
49
+ }),
50
+ )
51
+ expect(await readCappedBytes(response, 16)).toBe('too-large')
52
+ })
53
+
54
+ it('returns a payload that fits the cap', async () => {
55
+ const payload = new Uint8Array([1, 2, 3, 4])
56
+ expect(await readCappedBytes(new Response(payload), 16)).toEqual(payload)
57
+ })
58
+ })
59
+
60
+ function uploadingClient(uploaded: number[]): ApiClient {
61
+ return {
62
+ createMediaUpload: async () => ({
63
+ media_id: 'med_1',
64
+ upload_url: 'https://upload.invalid/1',
65
+ upload_headers: {},
66
+ expires_at: new Date(Date.now() + 60_000).toISOString(),
67
+ }),
68
+ uploadMedia: async (_grant: unknown, bytes: Uint8Array) => {
69
+ uploaded.push(bytes.byteLength)
70
+ },
71
+ } as unknown as ApiClient
72
+ }
73
+
74
+ function refusingClient(): ApiClient {
75
+ return {
76
+ createMediaUpload: async () => {
77
+ throw new Error('must not request an upload')
78
+ },
79
+ } as unknown as ApiClient
80
+ }
81
+
82
+ describe('uploadImage remote fetch', () => {
83
+ it('uploads bytes under the media limit from an allowed self-hosted origin', async () => {
84
+ const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47])
85
+ const baseUrl = await serving((_request, response) => {
86
+ response.setHeader('content-type', 'image/png')
87
+ response.end(Buffer.from(png))
88
+ })
89
+ const uploaded: number[] = []
90
+ const deps = { io: io(), env: {}, cwd: '/tmp' } as CommandDeps
91
+
92
+ const result = await uploadImage(deps, uploadingClient(uploaded), `${baseUrl}/shot.png`, [
93
+ baseUrl,
94
+ ])
95
+ expect(result).toEqual({ ok: true, mediaId: 'med_1' })
96
+ expect(uploaded).toEqual([png.byteLength])
97
+ })
98
+
99
+ it('refuses that same loopback origin when the User has not allowed it', async () => {
100
+ const baseUrl = await serving((_request, response) => {
101
+ response.setHeader('content-type', 'image/png')
102
+ response.end(Buffer.from(new Uint8Array([0x89, 0x50, 0x4e, 0x47])))
103
+ })
104
+ const deps = { io: io(), env: {}, cwd: '/tmp' } as CommandDeps
105
+
106
+ const result = await uploadImage(deps, refusingClient(), `${baseUrl}/shot.png`)
107
+ expect(result.ok).toBe(false)
108
+ if (result.ok) throw new Error('expected refusal')
109
+ expect(result.exit).toBe(EXIT.usage)
110
+ expect(result.error).toContain('media_origins')
111
+ })
112
+
113
+ it('refuses a remote image whose Content-Length exceeds the server media limit', async () => {
114
+ const baseUrl = await serving((_request, response) => {
115
+ response.setHeader('content-type', 'image/png')
116
+ response.setHeader('content-length', String(NOTIFICATION_IMAGE_MAX_BYTES + 1))
117
+ response.end()
118
+ })
119
+ const deps = { io: io(), env: {}, cwd: '/tmp' } as CommandDeps
120
+
121
+ const result = await uploadImage(deps, refusingClient(), `${baseUrl}/huge.png`, [baseUrl])
122
+ expect(result.ok).toBe(false)
123
+ if (result.ok) throw new Error('expected refusal')
124
+ expect(result.exit).toBe(EXIT.usage)
125
+ expect(result.error).toContain(String(NOTIFICATION_IMAGE_MAX_BYTES))
126
+ })
127
+
128
+ it('refuses a redirect out of an allowed origin into an unallowed one', async () => {
129
+ const target = await serving((_request, response) => {
130
+ response.setHeader('content-type', 'image/png')
131
+ response.end(Buffer.from(new Uint8Array([0x89, 0x50, 0x4e, 0x47])))
132
+ })
133
+ // A second loopback origin the User never allowed. The first hop is
134
+ // trusted; the destination is not, and each hop is checked on its own.
135
+ const redirector = createServer((_request, response) => {
136
+ response.writeHead(302, { location: `${target}/shot.png` })
137
+ response.end()
138
+ })
139
+ await new Promise<void>((resolve) => redirector.listen(0, '127.0.0.1', resolve))
140
+ const { port } = redirector.address() as AddressInfo
141
+ const redirectOrigin = `http://127.0.0.1:${port}`
142
+ const deps = { io: io(), env: {}, cwd: '/tmp' } as CommandDeps
143
+
144
+ try {
145
+ const result = await uploadImage(deps, refusingClient(), `${redirectOrigin}/shot.png`, [
146
+ redirectOrigin,
147
+ ])
148
+ expect(result.ok).toBe(false)
149
+ if (result.ok) throw new Error('expected refusal')
150
+ expect(result.exit).toBe(EXIT.usage)
151
+ expect(result.error).toContain('media_origins')
152
+ } finally {
153
+ await new Promise<void>((resolve) => redirector.close(() => resolve()))
154
+ }
155
+ })
156
+ })
@@ -1,8 +1,9 @@
1
- import { type ListRepliesResponse } from '@raidiant/notifai-protocol'
1
+ import { NOTIFICATION_IMAGE_MAX_BYTES, type ListRepliesResponse } from '@raidiant/notifai-protocol'
2
2
  import { sha256Hex } from '@raidiant/notifai-protocol/node'
3
3
  import { existsSync, readFileSync } from 'node:fs'
4
4
  import path from 'node:path'
5
5
  import { isRetryableReplyPollError, type ApiClient } from './client.js'
6
+ import { fetchMediaUrl } from './url-policy.js'
6
7
  import { buildSourceContext, inferInvocationContext } from './invocation-context.js'
7
8
  import { readOrcaSessionTitle } from './orca-session-title.js'
8
9
  import { type DraftInvocation, type SendFlags } from './send.js'
@@ -209,17 +210,78 @@ type UploadResult =
209
210
  /** `error: null` means `reportError` already said it; do not print it twice. */
210
211
  | { ok: false; error: string | null; exit: number }
211
212
 
212
- /** `--image` accepts a media id, a local file path, or an http(s) URL. */
213
- export async function uploadImage(deps: CommandDeps, client: ApiClient, source: string): Promise<UploadResult> {
213
+ /**
214
+ * Read a remote image without buffering more than the server will accept.
215
+ *
216
+ * Content-Length is an early refusal, not trust: a lying header still hits
217
+ * the byte cap while the body is read. Supported schemes stay http and https.
218
+ */
219
+ export async function readCappedBytes(
220
+ response: Response,
221
+ maxBytes: number,
222
+ ): Promise<Uint8Array | 'too-large'> {
223
+ const contentLength = Number(response.headers.get('content-length'))
224
+ if (Number.isFinite(contentLength) && contentLength > maxBytes) return 'too-large'
225
+ const reader = response.body?.getReader()
226
+ if (reader === undefined) {
227
+ const buf = new Uint8Array(await response.arrayBuffer())
228
+ return buf.byteLength > maxBytes ? 'too-large' : buf
229
+ }
230
+ const chunks: Uint8Array[] = []
231
+ let received = 0
232
+ while (true) {
233
+ const { done, value } = await reader.read()
234
+ if (done) break
235
+ received += value.byteLength
236
+ if (received > maxBytes) {
237
+ await reader.cancel()
238
+ return 'too-large'
239
+ }
240
+ chunks.push(value)
241
+ }
242
+ const out = new Uint8Array(received)
243
+ let offset = 0
244
+ for (const chunk of chunks) {
245
+ out.set(chunk, offset)
246
+ offset += chunk.byteLength
247
+ }
248
+ return out
249
+ }
250
+
251
+ /**
252
+ * `--image` accepts a media id, a local file path, or an http(s) URL.
253
+ *
254
+ * A remote URL is fetched under the media URL policy (`url-policy.ts`):
255
+ * public HTTPS by default, every redirect hop re-validated, and non-public
256
+ * destinations only through the User's `media_origins` — never a
257
+ * repository's. `--image` values routinely arrive from an agent that read
258
+ * them in a working tree, so the URL is hostile input until proven otherwise.
259
+ */
260
+ export async function uploadImage(
261
+ deps: CommandDeps,
262
+ client: ApiClient,
263
+ source: string,
264
+ allowOrigins: readonly string[] = [],
265
+ ): Promise<UploadResult> {
214
266
  let bytes: Uint8Array
215
267
  let mediaType: 'image/jpeg' | 'image/png' | 'image/gif' | undefined
216
268
  if (/^https?:\/\//.test(source)) {
217
269
  try {
218
- const response = await fetch(source)
270
+ const fetched = await fetchMediaUrl(source, { allowOrigins }, deps.fetchImpl)
271
+ if (!fetched.ok) return { ok: false, error: fetched.reason, exit: EXIT.usage }
272
+ const response = fetched.response
219
273
  if (!response.ok) return { ok: false, error: `Could not fetch ${source} (${response.status}).`, exit: EXIT.usage }
220
274
  const contentType = response.headers.get('content-type')?.split(';')[0]?.trim() ?? ''
221
275
  mediaType = (['image/jpeg', 'image/png', 'image/gif'] as const).find((t) => t === contentType)
222
- bytes = new Uint8Array(await response.arrayBuffer())
276
+ const downloaded = await readCappedBytes(response, NOTIFICATION_IMAGE_MAX_BYTES)
277
+ if (downloaded === 'too-large') {
278
+ return {
279
+ ok: false,
280
+ error: `Remote image exceeds the ${NOTIFICATION_IMAGE_MAX_BYTES} byte media limit.`,
281
+ exit: EXIT.usage,
282
+ }
283
+ }
284
+ bytes = downloaded
223
285
  } catch (err) {
224
286
  return { ok: false, error: `Could not fetch ${source}: ${String(err)}`, exit: EXIT.network }
225
287
  }