@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
@@ -8,11 +8,12 @@ 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 { firstBlocker, type Readiness, type ReadinessRefresh, type ReadinessState } from './readiness.js'
15
+ import { SKILLS_INSTALLER_SPEC, type SkillScope } from './native-skills.js'
16
+ import { firstBlocker, readinessJson, 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'
18
19
  import {
@@ -26,8 +27,15 @@ 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
+ setupProofProject,
34
+ setupProofApplies,
35
+ setupProofIsStale,
36
+ writeSetupProof,
37
+ } from './commands-setup-proof.js'
38
+ import { listScopedNotifaiSkills, SKILLS_SOURCE } from './commands-skill.js'
31
39
 
32
40
  // ---------------------------------------------------------------------------
33
41
  // init
@@ -39,7 +47,12 @@ export function projectSlugFrom(name: string): string {
39
47
  return inferredProjectSlugFrom(name) ?? 'project'
40
48
  }
41
49
 
50
+ /** Project checkout vs this machine. Drives skill, hooks, and config together. */
51
+ export type SetupScope = SkillScope
52
+
42
53
  export interface InitFlags {
54
+ /** Emit the final shared readiness model and never prompt. */
55
+ json?: boolean
43
56
  projectId?: string
44
57
  /**
45
58
  * Install the agent guidance skill. Tri-state on purpose:
@@ -48,7 +61,13 @@ export interface InitFlags {
48
61
  * never spawn npx against the network by default.
49
62
  */
50
63
  skills?: boolean
51
- /** Scope selected by an unattended caller; humans choose inside npx skills. */
64
+ /**
65
+ * One setup-scope for skill, hooks, and config. The unattended answer to
66
+ * init's single project-vs-machine question. Pairing, devices, and the CLI
67
+ * binary are not this flag.
68
+ */
69
+ setupScope?: SetupScope
70
+ /** Alias of `--setup-scope` when installing the skill unattended. */
52
71
  skillsScope?: SkillScope
53
72
  /** Same tri-state, for the harness hooks. */
54
73
  hooks?: boolean
@@ -97,12 +116,17 @@ async function closeGap(
97
116
  flags: InitFlags,
98
117
  ): Promise<GapCloseResult> {
99
118
  if (state.id === 'project') {
100
- const configPath = path.join(deps.cwd, '.notifai', 'config.toml')
119
+ const slug = projectSlugFrom(flags.projectId ?? path.basename(deps.cwd))
120
+ const configPath =
121
+ flags.setupScope === 'global'
122
+ ? personalProjectConfigPath(deps.cwd, deps.env)
123
+ : path.join(deps.cwd, '.notifai', 'config.toml')
101
124
  const existing = existsSync(configPath)
102
125
  ? (parseToml(readFileSync(configPath, 'utf8')) as Record<string, unknown>)
103
126
  : {}
104
- existing['project'] = projectSlugFrom(flags.projectId ?? path.basename(deps.cwd))
105
- mkdirSync(path.dirname(configPath), { recursive: true })
127
+ existing['project'] = slug
128
+ if (flags.setupScope === 'global') ensurePrivateDirectory(path.dirname(configPath))
129
+ else mkdirSync(path.dirname(configPath), { recursive: true })
106
130
  writeFileSync(configPath, `${stringifyToml(existing)}\n`)
107
131
  return 'closed'
108
132
  }
@@ -111,8 +135,9 @@ async function closeGap(
111
135
  const harnesses = await pickHarnessesToInstall(deps)
112
136
  if (harnesses === null || harnesses.length === 0) return 'failed'
113
137
  let ok = true
138
+ const hookFlags = flags.setupScope === 'global' ? { global: true as const } : {}
114
139
  for (const harness of harnesses) {
115
- if (hooksInstallCommand(deps, { harness }) !== EXIT.ok) ok = false
140
+ if (hooksInstallCommand(deps, { harness, ...hookFlags }) !== EXIT.ok) ok = false
116
141
  }
117
142
  return ok ? 'closed' : 'failed'
118
143
  }
@@ -130,22 +155,43 @@ async function closeGap(
130
155
  )
131
156
  return 'failed'
132
157
  }
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 = {
158
+ const installScope = flags.setupScope ?? flags.skillsScope
159
+ if (installScope === undefined) {
160
+ deps.io.err(
161
+ 'Skill installation refused — choose a setup scope: `notifai init --skills --setup-scope project` or `... global`.',
162
+ )
163
+ return 'failed'
164
+ }
165
+ const { installed } = await listScopedNotifaiSkills(deps)
166
+ const extras = installed.filter((skill) => skill.scope !== installScope)
167
+ for (const extra of extras) {
168
+ const code = await deps.nativeSkills.remove({
169
+ skill: 'notifai',
170
+ scope: extra.scope,
171
+ cwd: deps.cwd,
172
+ env: deps.env,
173
+ })
174
+ if (code !== 0) {
175
+ deps.io.err(
176
+ `Skill installation refused — could not uninstall the ${extra.scope} copy (${extra.ref ?? 'unknown pin'}), so installing ${installScope} would leave both active.`,
177
+ )
178
+ return 'failed'
179
+ }
180
+ }
181
+ deps.io.out(`Starting the native npx skills setup for the notifai agent skill (${installScope} scope)...`)
182
+ const code = await deps.nativeSkills.add({
136
183
  source: SKILLS_SOURCE,
137
184
  skill: 'notifai',
138
185
  cwd: deps.cwd,
139
186
  env: deps.env,
140
- ...(flags.skillsScope === undefined ? {} : { scope: flags.skillsScope }),
141
- }
142
- const code = await deps.nativeSkills.add(addOptions)
187
+ scope: installScope,
188
+ })
143
189
  if (code !== 0) {
144
190
  deps.io.err('Skill installation failed — run it manually with:')
145
191
  deps.io.err(
146
- ` npx skills add ${SKILLS_SOURCE} --skill notifai${
147
- flags.skillsScope === 'global' ? ' --global' : ''
148
- }${flags.skillsScope === undefined ? '' : ' --yes'}`,
192
+ ` npx -y ${SKILLS_INSTALLER_SPEC} add ${SKILLS_SOURCE} --skill notifai${
193
+ installScope === 'global' ? ' --global' : ''
194
+ } --yes`,
149
195
  )
150
196
  }
151
197
  return code === 0 ? 'closed' : 'failed'
@@ -289,7 +335,6 @@ function setupProofDraft(
289
335
  project === null
290
336
  ? 'This real notification completed setup verification.'
291
337
  : `This real notification completed setup verification for ${project}.`,
292
- event: 'setup_verified',
293
338
  kind: 'update',
294
339
  platform: device.platform,
295
340
  device: [device.device_id],
@@ -356,21 +401,41 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
356
401
  return 'failed'
357
402
  }
358
403
  const candidates = readyCompanionDevices(devices)
359
- const existing = readSetupProof(deps)
360
- const target =
361
- candidates.find(
362
- (device) =>
363
- device.device_id === existing?.device_id && existing.project === config.project.value,
364
- ) ?? candidates[0]
404
+ const project = setupProofProject(deps, config.project.value)
405
+ const existing = readSetupProof(deps, project)
406
+ const existingApplies = setupProofApplies(
407
+ existing,
408
+ project,
409
+ candidates.map((device) => device.device_id),
410
+ )
411
+ const target = (existingApplies
412
+ ? candidates.find((device) => device.device_id === existing.device_id)
413
+ : undefined) ?? candidates[0]
365
414
  if (!target) {
366
415
  deps.io.err('Setup proof needs a receipt-capable iPhone or Android Companion App.')
367
416
  return 'pending'
368
417
  }
369
418
 
370
419
  let proof =
371
- existing?.device_id === target.device_id && existing.project === config.project.value
420
+ existingApplies && existing.device_id === target.device_id
372
421
  ? existing
373
422
  : null
423
+ const nowMs = (deps.now ?? Date.now)()
424
+ if (proof !== null && setupProofIsStale(proof, nowMs)) {
425
+ let keepStale = false
426
+ try {
427
+ const snapshot = await authed.client.evidence(proof.request_id)
428
+ keepStale = observedCompanionReceipt(snapshot, proof.device_id) !== null
429
+ } catch {
430
+ keepStale = false
431
+ }
432
+ if (!keepStale) {
433
+ deps.io.out(
434
+ `The saved proof ${proof.request_id} is older than 24h without a Companion Receipt; sending a replacement.`,
435
+ )
436
+ proof = null
437
+ }
438
+ }
374
439
  if (proof === null) {
375
440
  const receipt = await submitSetupProof(deps, authed.client, config, target)
376
441
  if (receipt === null) return 'failed'
@@ -381,7 +446,7 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
381
446
  proof = {
382
447
  request_id: receipt.request_id,
383
448
  device_id: target.device_id,
384
- project: config.project.value,
449
+ project,
385
450
  started_at: new Date((deps.now ?? Date.now)()).toISOString(),
386
451
  }
387
452
  if (!writeSetupProof(deps, proof)) return 'failed'
@@ -429,7 +494,7 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
429
494
  proof = {
430
495
  request_id: receipt.request_id,
431
496
  device_id: target.device_id,
432
- project: config.project.value,
497
+ project,
433
498
  started_at: new Date(now()).toISOString(),
434
499
  }
435
500
  if (!writeSetupProof(deps, proof)) return 'failed'
@@ -503,36 +568,93 @@ function wantsOptional(deps: CommandDeps, state: ReadinessState, flags: InitFlag
503
568
  * and a revoked credential are the same code path arriving at different
504
569
  * states rather than four branches to enumerate.
505
570
  */
506
- export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<number> {
571
+ function isSetupScope(value: string | undefined): value is SetupScope {
572
+ return value === 'project' || value === 'global'
573
+ }
574
+
575
+ async function resolveSetupScope(
576
+ deps: CommandDeps,
577
+ flags: InitFlags,
578
+ ): Promise<SetupScope | undefined | 'usage'> {
579
+ if (flags.setupScope !== undefined && !isSetupScope(flags.setupScope)) {
580
+ deps.io.err('Invalid setup scope. Choose `project` or `global`.')
581
+ return 'usage'
582
+ }
583
+ if (flags.skillsScope !== undefined && !isSetupScope(flags.skillsScope)) {
584
+ deps.io.err('Invalid skill scope. Choose `project` or `global`.')
585
+ return 'usage'
586
+ }
507
587
  if (
588
+ flags.setupScope !== undefined &&
508
589
  flags.skillsScope !== undefined &&
509
- flags.skillsScope !== 'project' &&
510
- flags.skillsScope !== 'global'
590
+ flags.setupScope !== flags.skillsScope
511
591
  ) {
512
- deps.io.err('Invalid skill scope. Choose `project` or `global`.')
513
- return EXIT.usage
592
+ deps.io.err('`--setup-scope` and `--skills-scope` disagree. Pass one, or pass the same value twice.')
593
+ return 'usage'
514
594
  }
515
595
  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
596
+ deps.io.err('`--skills-scope` requires `--skills`. Use `--setup-scope` to choose project or machine for the whole setup.')
597
+ return 'usage'
518
598
  }
519
- if (
520
- flags.skills === true &&
521
- deps.io.interactive !== true &&
522
- flags.skillsScope === undefined
523
- ) {
599
+ const fromFlags = flags.setupScope ?? flags.skillsScope
600
+ if (fromFlags !== undefined) return fromFlags
601
+ if (flags.skills === false && flags.hooks === false) return undefined
602
+ if (deps.io.interactive === true && deps.io.select) {
603
+ const selected = await deps.io.select(
604
+ 'Should this Notifai setup apply to this project only, or to every project on this machine?',
605
+ [
606
+ {
607
+ value: 'project',
608
+ label: 'This project',
609
+ hint: 'skill, hooks, and shared config stay in this checkout',
610
+ },
611
+ {
612
+ value: 'global',
613
+ label: 'This machine',
614
+ hint: 'skill and hooks follow you into every repo',
615
+ },
616
+ ],
617
+ )
618
+ if (selected === 'project' || selected === 'global') return selected
619
+ deps.io.err('No setup scope selected. Pass `--setup-scope project` or `--setup-scope global`.')
620
+ return 'usage'
621
+ }
622
+ if (flags.skills === true) {
524
623
  deps.io.err(
525
- 'Unattended skill setup requires an explicit scope: `notifai init --skills --skills-scope project` or `... global`.',
624
+ 'Unattended skill setup requires an explicit scope: `notifai init --skills --setup-scope project` or `... global`.',
526
625
  )
527
- return EXIT.usage
626
+ return 'usage'
627
+ }
628
+ return undefined
629
+ }
630
+
631
+ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<number> {
632
+ const workingDeps: CommandDeps = flags.json === true
633
+ ? {
634
+ ...deps,
635
+ io: {
636
+ interactive: false,
637
+ out: () => {},
638
+ err: (line) => deps.io.err(line),
639
+ confirm: async () => false,
640
+ openUrl: (url) => deps.io.openUrl(url),
641
+ },
642
+ }
643
+ : deps
644
+ const setupScope = await resolveSetupScope(workingDeps, flags)
645
+ if (setupScope === 'usage') return EXIT.usage
646
+ const resolved: InitFlags = {
647
+ ...flags,
648
+ ...(setupScope === undefined ? {} : { setupScope, skillsScope: flags.skillsScope ?? setupScope }),
528
649
  }
529
- await deps.io.intro?.('Notifai setup')
650
+ await workingDeps.io.intro?.('Notifai setup')
530
651
 
531
- const skillOpts = flags.skillsScope === undefined ? {} : { skillScope: flags.skillsScope }
532
- let readiness = await assessReadiness(deps, skillOpts)
652
+ const skillOpts = resolved.setupScope === undefined ? {} : { skillScope: resolved.setupScope }
653
+ let readiness = await assessReadiness(workingDeps, { ...skillOpts, ...(flags.json === true ? { json: true } : {}) })
533
654
  const reassess = (refresh?: readonly ReadinessRefresh[]) =>
534
- assessReadiness(deps, {
655
+ assessReadiness(workingDeps, {
535
656
  ...skillOpts,
657
+ ...(flags.json === true ? { json: true } : {}),
536
658
  ...(refresh === undefined ? {} : { previous: readiness, refresh }),
537
659
  })
538
660
  let failed = false
@@ -560,9 +682,9 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
560
682
  }
561
683
 
562
684
  if (state.status === 'optional-gap') {
563
- if (remedy.by !== 'cli' || !(await wantsOptional(deps, state, flags))) continue
685
+ if (remedy.by !== 'cli' || !(await wantsOptional(workingDeps, state, resolved))) continue
564
686
  attempted.add(state.id)
565
- const result = await closeGap(deps, state, flags)
687
+ const result = await closeGap(workingDeps, state, resolved)
566
688
  if (result === 'failed') failed = true
567
689
  if (result === 'failed' && state.status === 'optional-gap') {
568
690
  readiness = await reassess(refreshAfterClose(state.id))
@@ -580,7 +702,7 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
580
702
 
581
703
  if (remedy.by === 'cli') {
582
704
  attempted.add(state.id)
583
- const result = await closeGap(deps, state, flags)
705
+ const result = await closeGap(workingDeps, state, resolved)
584
706
  if (result === 'failed') failed = true
585
707
  if (result !== 'closed') {
586
708
  stop = true
@@ -596,11 +718,11 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
596
718
  if (
597
719
  remedy.by === 'user-here' &&
598
720
  remedy.interactive === true &&
599
- deps.io.interactive === true
721
+ workingDeps.io.interactive === true
600
722
  ) {
601
723
  attempted.add(state.id)
602
- deps.io.out('Opening your browser to approve this machine — Ctrl-C to stop.')
603
- if ((await loginCommand(deps, {})) !== EXIT.ok) {
724
+ workingDeps.io.out('Opening your browser to approve this machine — Ctrl-C to stop.')
725
+ if ((await loginCommand(workingDeps, {})) !== EXIT.ok) {
604
726
  failed = true
605
727
  stop = true
606
728
  break
@@ -613,10 +735,10 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
613
735
  if (
614
736
  state.id === 'devices' &&
615
737
  remedy.by === 'user-elsewhere' &&
616
- deps.io.interactive === true
738
+ workingDeps.io.interactive === true
617
739
  ) {
618
740
  attempted.add(state.id)
619
- const result = await waitForReadyDevice(deps, state)
741
+ const result = await waitForReadyDevice(workingDeps, state)
620
742
  if (result === 'failed') failed = true
621
743
  if (result !== 'closed') {
622
744
  stop = true
@@ -635,10 +757,11 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
635
757
  if (stop || !advanced) break
636
758
  }
637
759
 
638
- await printInitClose(deps, readiness, flags)
760
+ if (flags.json === true) deps.io.out(JSON.stringify(readinessJson(readiness), null, 2))
761
+ else await printInitClose(deps, readiness, resolved)
639
762
  const blocker = firstBlocker(readiness)
640
763
  if (blocker === null) return failed ? EXIT.failed : EXIT.ok
641
- return failed || deps.io.interactive !== true ? EXIT.failed : EXIT.ok
764
+ return failed || workingDeps.io.interactive !== true ? EXIT.failed : EXIT.ok
642
765
  }
643
766
 
644
767
  function isHookSubstate(id: string): boolean {
@@ -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
  }