@raidiant/notifai 7.0.2 → 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 (157) hide show
  1. package/CHANGELOG.md +75 -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-devices.d.ts +2 -1
  24. package/dist/commands-devices.js +20 -8
  25. package/dist/commands-devices.js.map +1 -1
  26. package/dist/commands-doctor.d.ts +1 -0
  27. package/dist/commands-doctor.js +85 -27
  28. package/dist/commands-doctor.js.map +1 -1
  29. package/dist/commands-guidance.d.ts +27 -0
  30. package/dist/commands-guidance.js +163 -0
  31. package/dist/commands-guidance.js.map +1 -0
  32. package/dist/commands-hooks.d.ts +16 -5
  33. package/dist/commands-hooks.js +433 -38
  34. package/dist/commands-hooks.js.map +1 -1
  35. package/dist/commands-init.d.ts +10 -18
  36. package/dist/commands-init.js +139 -54
  37. package/dist/commands-init.js.map +1 -1
  38. package/dist/commands-send-support.d.ts +17 -2
  39. package/dist/commands-send-support.js +66 -8
  40. package/dist/commands-send-support.js.map +1 -1
  41. package/dist/commands-send.d.ts +0 -1
  42. package/dist/commands-send.js +105 -39
  43. package/dist/commands-send.js.map +1 -1
  44. package/dist/commands-setup-proof.d.ts +8 -0
  45. package/dist/commands-setup-proof.js +31 -2
  46. package/dist/commands-setup-proof.js.map +1 -1
  47. package/dist/commands-skill.d.ts +11 -1
  48. package/dist/commands-skill.js +61 -11
  49. package/dist/commands-skill.js.map +1 -1
  50. package/dist/commands.d.ts +2 -1
  51. package/dist/commands.js +1 -0
  52. package/dist/commands.js.map +1 -1
  53. package/dist/config-schema.d.ts +1 -1
  54. package/dist/config-schema.js +30 -14
  55. package/dist/config-schema.js.map +1 -1
  56. package/dist/config.d.ts +24 -5
  57. package/dist/config.js +37 -8
  58. package/dist/config.js.map +1 -1
  59. package/dist/credentials.d.ts +10 -1
  60. package/dist/credentials.js +25 -12
  61. package/dist/credentials.js.map +1 -1
  62. package/dist/guidance-content.d.ts +50 -0
  63. package/dist/guidance-content.js +240 -0
  64. package/dist/guidance-content.js.map +1 -0
  65. package/dist/guidance.d.ts +69 -0
  66. package/dist/guidance.js +173 -0
  67. package/dist/guidance.js.map +1 -0
  68. package/dist/hook-adapter.js +37 -3
  69. package/dist/hook-adapter.js.map +1 -1
  70. package/dist/hooks.d.ts +68 -7
  71. package/dist/hooks.js +238 -92
  72. package/dist/hooks.js.map +1 -1
  73. package/dist/install-hooks.d.ts +10 -18
  74. package/dist/install-hooks.js +110 -23
  75. package/dist/install-hooks.js.map +1 -1
  76. package/dist/interactive.d.ts +1 -1
  77. package/dist/interactive.js +14 -9
  78. package/dist/interactive.js.map +1 -1
  79. package/dist/logging.d.ts +8 -1
  80. package/dist/logging.js +43 -2
  81. package/dist/logging.js.map +1 -1
  82. package/dist/main.js +5 -487
  83. package/dist/main.js.map +1 -1
  84. package/dist/native-skills.d.ts +19 -0
  85. package/dist/native-skills.js +30 -8
  86. package/dist/native-skills.js.map +1 -1
  87. package/dist/opencode-plugin.js +14 -1
  88. package/dist/opencode-plugin.js.map +1 -1
  89. package/dist/program.d.ts +49 -0
  90. package/dist/program.js +576 -0
  91. package/dist/program.js.map +1 -0
  92. package/dist/send.d.ts +12 -10
  93. package/dist/send.js +65 -29
  94. package/dist/send.js.map +1 -1
  95. package/dist/session-activation.d.ts +15 -0
  96. package/dist/session-activation.js +35 -0
  97. package/dist/session-activation.js.map +1 -0
  98. package/dist/session-labels.d.ts +9 -3
  99. package/dist/session-labels.js +38 -7
  100. package/dist/session-labels.js.map +1 -1
  101. package/dist/url-policy.d.ts +85 -0
  102. package/dist/url-policy.js +369 -0
  103. package/dist/url-policy.js.map +1 -0
  104. package/package.json +6 -4
  105. package/src/atomic-file.ts +36 -2
  106. package/src/cli-bin.test.ts +38 -0
  107. package/src/cli-bin.ts +67 -0
  108. package/src/cli-release.test.ts +46 -0
  109. package/src/cli-release.ts +78 -0
  110. package/src/client.test.ts +95 -0
  111. package/src/client.ts +16 -3
  112. package/src/commands-auth.ts +32 -3
  113. package/src/commands-config.ts +25 -2
  114. package/src/commands-core.ts +38 -3
  115. package/src/commands-devices.ts +28 -9
  116. package/src/commands-doctor.ts +116 -25
  117. package/src/commands-guidance.ts +216 -0
  118. package/src/commands-hooks.ts +513 -42
  119. package/src/commands-init.ts +170 -60
  120. package/src/commands-send-support.test.ts +156 -0
  121. package/src/commands-send-support.ts +72 -8
  122. package/src/commands-send.ts +123 -38
  123. package/src/commands-setup-proof.ts +36 -2
  124. package/src/commands-skill.ts +78 -15
  125. package/src/commands.test.ts +1490 -197
  126. package/src/commands.ts +6 -0
  127. package/src/config-schema.ts +32 -16
  128. package/src/config.test.ts +57 -2
  129. package/src/config.ts +49 -10
  130. package/src/credentials.test.ts +71 -3
  131. package/src/credentials.ts +29 -15
  132. package/src/guidance-content.ts +256 -0
  133. package/src/guidance.test.ts +299 -0
  134. package/src/guidance.ts +233 -0
  135. package/src/hook-adapter.test.ts +113 -1
  136. package/src/hook-adapter.ts +37 -3
  137. package/src/hooks.test.ts +783 -170
  138. package/src/hooks.ts +306 -131
  139. package/src/install-hooks.test.ts +135 -16
  140. package/src/install-hooks.ts +113 -25
  141. package/src/interactive.ts +14 -9
  142. package/src/logging.test.ts +66 -3
  143. package/src/logging.ts +45 -2
  144. package/src/main.ts +6 -614
  145. package/src/native-skills.test.ts +41 -1
  146. package/src/native-skills.ts +40 -6
  147. package/src/opencode-plugin.ts +14 -1
  148. package/src/program.test.ts +237 -0
  149. package/src/program.ts +751 -0
  150. package/src/send.test.ts +28 -0
  151. package/src/send.ts +81 -34
  152. package/src/session-activation.ts +45 -0
  153. package/src/session-labels.test.ts +120 -3
  154. package/src/session-labels.ts +38 -7
  155. package/src/skill.test.ts +153 -12
  156. package/src/trust-policy.test.ts +701 -0
  157. package/src/url-policy.ts +399 -0
package/src/commands.ts CHANGED
@@ -47,12 +47,18 @@ export {
47
47
  configShowCommand,
48
48
  configUnsetCommand,
49
49
  } from './commands-config.js'
50
+ export {
51
+ guidanceSetCommand,
52
+ guidanceShowCommand,
53
+ guidanceUnsetCommand,
54
+ } from './commands-guidance.js'
50
55
  export { logsCommand, parseSince, type LogsFlags } from './commands-logs.js'
51
56
  export { SKILLS_SOURCE } from './commands-skill.js'
52
57
  export {
53
58
  initCommand,
54
59
  projectSlugFrom,
55
60
  type InitFlags,
61
+ type SetupScope,
56
62
  } from './commands-init.js'
57
63
  export { assessReadiness, doctorCommand } from './commands-doctor.js'
58
64
  export { realIo } from './commands-io.js'
@@ -25,7 +25,7 @@ export type ConfigKind = 'string' | 'url' | 'integer' | 'boolean' | 'enum' | 'li
25
25
  * Which surface a key belongs to. Grouping is the difference between a flat
26
26
  * list of keys and a few short ones a reader can hold in their head.
27
27
  */
28
- export type ConfigGroup = 'questions' | 'delivery' | 'project' | 'diagnostics'
28
+ export type ConfigGroup = 'questions' | 'delivery' | 'project' | 'trust' | 'diagnostics'
29
29
 
30
30
  export const CONFIG_GROUPS: { id: ConfigGroup; title: string; blurb: string }[] = [
31
31
  {
@@ -41,7 +41,12 @@ export const CONFIG_GROUPS: { id: ConfigGroup; title: string; blurb: string }[]
41
41
  {
42
42
  id: 'project',
43
43
  title: 'This project',
44
- blurb: 'How work in this directory identifies itself, and the house rules agents read before notifying.',
44
+ blurb: 'How work in this directory identifies itself. House rules for agents live in `notifai guidance`.',
45
+ },
46
+ {
47
+ id: 'trust',
48
+ title: 'Network trust',
49
+ blurb: 'Origins this machine may fetch from or open beyond the safe defaults. Only you can widen these — a repository cannot.',
45
50
  },
46
51
  {
47
52
  id: 'diagnostics',
@@ -83,7 +88,7 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
83
88
  label: 'Send questions to my devices',
84
89
  group: 'questions',
85
90
  kind: 'boolean',
86
- summary: 'Whether a registered question may reach your iPhone at all',
91
+ summary: 'Whether a registered question may reach your Companion devices at all',
87
92
  detail:
88
93
  'The master switch for question routing. When this is off, a question an agent registers with `notifai ask` stays in the terminal and never leaves this machine, whatever else is configured. Turn it off to stop being reached for a while without uninstalling the harness hooks.',
89
94
  example: 'true',
@@ -137,10 +142,10 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
137
142
  active: 'wakes the screen and plays the sound',
138
143
  time_sensitive: 'marks the notification urgent; Focus breakthrough is not available yet',
139
144
  },
140
- summary: 'Set how insistently notifications from here arrive',
145
+ summary: 'Set how insistently notifications reach Apple devices',
141
146
  detail:
142
- 'How hard a notification tries to reach you, in the operating system\'s terms. Setting this pins one level for everything sent from here; kind chooses the sound, never this.\n\nLeave this unset to omit the field and let the destination use its normal behavior. `time_sensitive` is accepted and marks the notification urgent, but Focus breakthrough is not available yet — run `notifai capabilities` for what a platform actually honours.',
143
- unsetMeans: 'not specified; the destination uses its normal behavior',
147
+ 'Apple interruption level controls how hard a notification tries to reach you in the operating system\'s terms. Setting this pins one Apple-only level for sends from here; kind chooses the sound, never this. Android does not accept caller-selected interruption levels: its product-owned channels and your device settings own attention.\n\nLeave this unset to omit the field and let each destination use its normal behavior. `time_sensitive` is accepted on Apple platforms and marks the notification urgent, but Focus breakthrough is not available yet — run `notifai capabilities --platform <platform>` for the exact contract.',
148
+ unsetMeans: 'not specified; each destination uses its normal behavior',
144
149
  },
145
150
  devices: {
146
151
  label: 'Default devices',
@@ -193,17 +198,28 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
193
198
  unsetMeans: 'inferred from Git or the current directory',
194
199
  example: 'my-app',
195
200
  },
196
- notify_criteria: {
197
- label: 'House rules for agents',
198
- group: 'project',
199
- kind: 'string',
200
- summary: 'Free text an agent reads before deciding whether to notify you',
201
+ media_origins: {
202
+ label: 'Extra image origins',
203
+ group: 'trust',
204
+ kind: 'list',
205
+ summary: 'Origins remote --image URLs may use beyond public HTTPS',
201
206
  detail:
202
- 'Your own guidance, in your own words, about what is worth a notification in this project — read by agents before they send. Something like "only tell me about failures and finished deploys; never per-file progress".\n\nIt is advice to the agent, not a filter the service enforces. Agents follow it as your literal standing word, so an agent may write it only when you ask for a preference to be remembered — and only in your words, never its own summary.',
203
- unsetMeans: 'agents fall back to their default judgement',
204
- example: 'Only failures and completed deploys',
207
+ 'By default a remote `--image` URL must be HTTPS to a publicly routable host, and every redirect hop is held to the same rule — an agent given a hostile URL cannot be used to reach this machine\'s local network. List an exact origin (`scheme://host[:port]`) here to allow a self-hosted or intranet image server, including plain-http ones.\n\nThis key is yours alone: it is read from your machine and personal-project configuration and ignored in the repository\'s shared `.notifai/config.toml`, so a cloned repository can never widen it.',
208
+ unsetMeans: 'public HTTPS only',
209
+ example: 'http://imgs.intranet.example:8080',
210
+ advanced: true,
211
+ },
212
+ approve_origins: {
213
+ label: 'Extra approval origins',
214
+ group: 'trust',
215
+ kind: 'list',
216
+ summary: 'Dashboard origins a pairing approval link may open',
217
+ detail:
218
+ 'During `notifai login` the server names the browser page where you approve this machine. The CLI opens that link only when it is the Notifai dashboard, the origin you are pairing with, or loopback. If you self-host with the dashboard on its own origin, list that exact origin (`scheme://host[:port]`) here.\n\nThis key is yours alone: it is read from your machine and personal-project configuration and ignored in the repository\'s shared `.notifai/config.toml`, so a cloned repository can never widen it.',
219
+ unsetMeans: 'the Notifai dashboard, the pairing origin, and loopback',
220
+ example: 'https://dash.selfhost.example',
221
+ advanced: true,
205
222
  },
206
-
207
223
  log_level: {
208
224
  label: 'Log level',
209
225
  group: 'diagnostics',
@@ -217,7 +233,7 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
217
233
  },
218
234
  summary: 'How much this machine records about what it did',
219
235
  detail:
220
- 'Notifai keeps a local record of what it did — every command, every notification, and in particular every decision a harness hook made about whether a question could leave the terminal. Hooks run headless, so without this there is no account of them at all: `notifai logs` is how an agent finds out afterwards why a question never reached your phone.\n\nThe log never leaves this machine. It is not uploaded, and nothing sends it anywhere.\n\n`debug` also records which config layer won and the detail of each request, which is what to turn on when something is behaving in a way the ordinary log does not explain.',
236
+ 'Notifai keeps a local record of what it did — every command, every notification, and in particular every decision a harness hook made about whether a question could leave the terminal. Hooks run headless, so without this there is no account of them at all: `notifai logs` is how an agent finds out afterwards why a question never reached your device.\n\nThe log never leaves this machine. It is not uploaded, and nothing sends it anywhere.\n\n`debug` also records which config layer won and the detail of each request, which is what to turn on when something is behaving in a way the ordinary log does not explain.',
221
237
  example: 'info',
222
238
  },
223
239
  log_max_bytes: {
@@ -86,7 +86,7 @@ describe('harness config layers', () => {
86
86
  projectToml: 'base_url = "https://attacker.example"\n',
87
87
  })
88
88
  expect(loadConfig({ cwd, env }).base_url).toEqual({
89
- value: 'https://notifai.fly.dev',
89
+ value: 'https://api.notifai.sh',
90
90
  source: 'default',
91
91
  })
92
92
  expect(
@@ -201,7 +201,11 @@ describe('draft building', () => {
201
201
  targets: { mode: 'all' },
202
202
  delivery: { ttl_seconds: 86400, collapse_key: null },
203
203
  // An unlabelled draft is ordinary news, and news has a sound.
204
- platform: { ios: { sound: 'default' }, macos: { sound: 'default' } },
204
+ platform: {
205
+ ios: { sound: 'default' },
206
+ macos: { sound: 'default' },
207
+ android: { sound: 'default' },
208
+ },
205
209
  })
206
210
  })
207
211
 
@@ -218,6 +222,7 @@ describe('draft building', () => {
218
222
  const build = buildDraft(config, { title: 'T', body: 'B', ...flags })
219
223
  if (!build.ok) throw new Error(build.error)
220
224
  expect(build.draft.platform?.ios?.sound).toBe(sound)
225
+ expect(build.draft.platform?.android?.sound).toBe(sound)
221
226
  expect(build.draft.platform?.ios?.interruption_level).toBeUndefined()
222
227
  })
223
228
 
@@ -239,6 +244,7 @@ describe('draft building', () => {
239
244
  sound: 'alert',
240
245
  interruption_level: 'active',
241
246
  })
247
+ expect(configured.draft.platform?.android).toEqual({ sound: 'alert' })
242
248
 
243
249
  const explicit = buildDraft(config, {
244
250
  title: 'T',
@@ -252,6 +258,7 @@ describe('draft building', () => {
252
258
  sound: null,
253
259
  interruption_level: 'time_sensitive',
254
260
  })
261
+ expect(explicit.draft.platform?.android).toEqual({ sound: null })
255
262
  })
256
263
 
257
264
  it('keeps a question answerable for a day by default', () => {
@@ -453,6 +460,10 @@ describe('draft building', () => {
453
460
  custom_data: { run_id: '42', branch: 'main' },
454
461
  })
455
462
  expect(build.draft.platform?.macos).toEqual(build.draft.platform?.ios)
463
+ expect(build.draft.platform?.android).toEqual({
464
+ sound: null,
465
+ custom_data: { run_id: '42', branch: 'main' },
466
+ })
456
467
  })
457
468
 
458
469
  it('maps optional fields into the selected macOS platform slot', () => {
@@ -479,6 +490,50 @@ describe('draft building', () => {
479
490
  })
480
491
  })
481
492
 
493
+ it('maps supported optional fields into the selected Android platform slot', () => {
494
+ const config = loadConfig({ cwd: base.cwd, env: base.env })
495
+ const build = buildDraft(config, {
496
+ title: 'T',
497
+ body: 'B',
498
+ platform: 'android',
499
+ sound: 'none',
500
+ threadId: 'mobile-builds',
501
+ data: ['run_id=42'],
502
+ })
503
+ if (!build.ok) throw new Error(build.error)
504
+
505
+ expect(build.platform).toBe('android')
506
+ expect(build.draft.platform).toEqual({
507
+ android: {
508
+ sound: null,
509
+ thread_id: 'mobile-builds',
510
+ custom_data: { run_id: '42' },
511
+ },
512
+ })
513
+ })
514
+
515
+ it('rejects explicit or configured interruption levels for Android', () => {
516
+ const config = loadConfig({ cwd: base.cwd, env: base.env })
517
+ expect(
518
+ buildDraft(config, {
519
+ title: 'T',
520
+ body: 'B',
521
+ platform: 'android',
522
+ level: 'active',
523
+ }),
524
+ ).toMatchObject({
525
+ ok: false,
526
+ error: expect.stringContaining('Android does not support caller-selected interruption levels'),
527
+ })
528
+
529
+ const configuredCase = setup({ projectToml: 'interruption_level = "passive"\n' })
530
+ const configured = loadConfig({ cwd: configuredCase.cwd, env: configuredCase.env })
531
+ expect(buildDraft(configured, { title: 'T', body: 'B', platform: 'android' })).toMatchObject({
532
+ ok: false,
533
+ error: expect.stringContaining('unset the configured interruption_level'),
534
+ })
535
+ })
536
+
482
537
  it('limits collapse keys by UTF-8 bytes', () => {
483
538
  const config = loadConfig({ cwd: base.cwd, env: base.env })
484
539
  expect(buildDraft(config, { title: 'T', body: 'B', collapseKey: '😀'.repeat(16) }).ok).toBe(
package/src/config.ts CHANGED
@@ -8,6 +8,7 @@ import { existsSync, readFileSync, realpathSync } from 'node:fs'
8
8
  import path from 'node:path'
9
9
  import { parse as parseToml } from 'smol-toml'
10
10
  import { configHome, stateHome } from './platform.js'
11
+ import { normalizeOrigin } from './url-policy.js'
11
12
 
12
13
  /**
13
14
  * Layered configuration with provenance. Most specific wins:
@@ -61,8 +62,6 @@ export interface CliConfig {
61
62
  interruption_level: ResolvedValue<CliInterruptionLevel | null>
62
63
  /** Project identifier stamped on sends; typically set in .notifai/config.toml. */
63
64
  project: ResolvedValue<string | null>
64
- /** Free-text notification criteria agents consult before sending. */
65
- notify_criteria: ResolvedValue<string | null>
66
65
  /** Whether a registered agent question may reach companion devices at all. */
67
66
  ask_notifications: ResolvedValue<boolean>
68
67
  /**
@@ -95,6 +94,17 @@ export interface CliConfig {
95
94
  reply_window_seconds: ResolvedValue<number>
96
95
  /** Log files kept in total, the active one included. */
97
96
  log_max_files: ResolvedValue<number>
97
+ /**
98
+ * Extra origins remote `--image` URLs may use beyond the default public
99
+ * HTTPS policy — the self-host and intranet escape hatch. User-owned: the
100
+ * repository layer cannot supply it (see `USER_OWNED_CONFIG_KEYS`).
101
+ */
102
+ media_origins: ResolvedValue<string[]>
103
+ /**
104
+ * Extra dashboard origins a pairing approval URL may open, for self-hosts
105
+ * whose dashboard lives on a different origin than the API. User-owned.
106
+ */
107
+ approve_origins: ResolvedValue<string[]>
98
108
  }
99
109
 
100
110
  export const CONFIG_KEYS = [
@@ -105,16 +115,29 @@ export const CONFIG_KEYS = [
105
115
  'sound',
106
116
  'interruption_level',
107
117
  'project',
108
- 'notify_criteria',
109
118
  'ask_notifications',
110
119
  'ask_grace_seconds',
111
120
  'reply_window_seconds',
112
121
  'log_level',
113
122
  'log_max_bytes',
114
123
  'log_max_files',
124
+ 'media_origins',
125
+ 'approve_origins',
115
126
  ] as const
116
127
  export type ConfigKey = (typeof CONFIG_KEYS)[number]
117
128
 
129
+ /**
130
+ * Keys only the User may set. The shared `.notifai/config.toml` arrives with
131
+ * a clone, so for most keys an unwanted value is at worst a clamped nuisance —
132
+ * but these keys widen which network destinations this machine will touch,
133
+ * and a hostile repository must not be able to widen them. Resolution skips
134
+ * the repository layer for them entirely.
135
+ */
136
+ export const USER_OWNED_CONFIG_KEYS: readonly ConfigKey[] = ['media_origins', 'approve_origins']
137
+
138
+ /** Keys whose TOML value must be a list of normalized http(s) origins. */
139
+ export const ORIGIN_LIST_CONFIG_KEYS: readonly ConfigKey[] = ['media_origins', 'approve_origins']
140
+
118
141
  /** Keys whose TOML value must parse as a boolean; used by `config set`. */
119
142
  export const BOOLEAN_CONFIG_KEYS: readonly ConfigKey[] = ['ask_notifications']
120
143
 
@@ -141,8 +164,8 @@ export const ENUM_CONFIG_VALUES: Partial<Record<ConfigKey, readonly string[]>> =
141
164
  log_level: LOG_LEVELS,
142
165
  }
143
166
 
144
- /** Compiled service origin. Not a user-facing setting; override with `--base-url` or `NOTIFAI_BASE_URL`. */
145
- export const DEFAULT_BASE_URL = 'https://notifai.fly.dev'
167
+ /** Compiled service origin. Not a user-facing setting; login may override with `--base-url` or `NOTIFAI_BASE_URL`. */
168
+ export const DEFAULT_BASE_URL = 'https://api.notifai.sh'
146
169
 
147
170
  const DEFAULTS = {
148
171
  wait_seconds: 10,
@@ -152,7 +175,6 @@ const DEFAULTS = {
152
175
  sound: null,
153
176
  interruption_level: null,
154
177
  project: null,
155
- notify_criteria: null,
156
178
  // Installing the hooks is the opt-in; these exist to switch the behaviour
157
179
  // back off for a project or a session without uninstalling anything.
158
180
  ask_notifications: true,
@@ -169,6 +191,11 @@ const DEFAULTS = {
169
191
  // agent traffic and small enough that nobody has to think about it.
170
192
  log_max_bytes: 2_000_000,
171
193
  log_max_files: 3,
194
+ // Empty by default: remote images use public HTTPS and pairing approval
195
+ // uses the canonical dashboard, the pairing origin, or loopback. Entries
196
+ // here are the User widening that, never a repository.
197
+ media_origins: [],
198
+ approve_origins: [],
172
199
  } satisfies Record<ConfigKey, unknown>
173
200
 
174
201
  /** The shipped value beneath every user-controlled configuration layer. */
@@ -334,6 +361,13 @@ function coerce(key: ConfigKey, value: unknown): unknown | undefined {
334
361
  return Math.min(bounds.max, Math.max(bounds.min, Math.trunc(value)))
335
362
  }
336
363
  if (BOOLEAN_CONFIG_KEYS.includes(key)) return typeof value === 'boolean' ? value : undefined
364
+ if (ORIGIN_LIST_CONFIG_KEYS.includes(key)) {
365
+ // A trust rule is all-or-nothing: one malformed entry drops the whole
366
+ // value rather than silently enforcing a subset of what was written.
367
+ if (!Array.isArray(value)) return undefined
368
+ const origins = value.map((entry) => (typeof entry === 'string' ? normalizeOrigin(entry) : null))
369
+ return origins.every((origin): origin is string => origin !== null) ? origins : undefined
370
+ }
337
371
  const allowed = ENUM_CONFIG_VALUES[key]
338
372
  if (allowed !== undefined) {
339
373
  return typeof value === 'string' && allowed.includes(value) ? value : undefined
@@ -364,8 +398,9 @@ export interface FlagOverrides {
364
398
  }
365
399
 
366
400
  /**
367
- * Service origin is not a user setting. Flag and env are developer/self-host
368
- * overrides; signed-in traffic still pins to the credential origin elsewhere.
401
+ * Service origin is not a user setting. Flag and env are pairing/self-host
402
+ * overrides for unsigned-in traffic (login). Signed-in traffic always uses
403
+ * the origin stored with the Machine Credential.
369
404
  */
370
405
  function resolveServiceUrl(
371
406
  flags: FlagOverrides,
@@ -402,11 +437,14 @@ export function loadConfig(options: {
402
437
  const sessionRaw = sessionPath && existsSync(sessionPath) ? readTomlFile(sessionPath) : {}
403
438
 
404
439
  function resolve<K extends ConfigKey>(key: K): ResolvedValue<never> {
440
+ // The shared project file arrives with a clone, so keys that widen
441
+ // network trust never read from it — whatever a repository writes there.
442
+ const repositoryLayerReadable = !USER_OWNED_CONFIG_KEYS.includes(key)
405
443
  const layers: [unknown, ConfigSource][] = [
406
444
  [(flags as Record<string, unknown>)[key], 'flag'],
407
445
  [sessionPath ? sessionRaw[key] : undefined, `session:${sessionPath ?? ''}`],
408
446
  [projectLocalPath ? projectLocalRaw[key] : undefined, `project-local:${projectLocalPath ?? ''}`],
409
- [projectPath ? projectRaw[key] : undefined, `project:${projectPath ?? ''}`],
447
+ [projectPath && repositoryLayerReadable ? projectRaw[key] : undefined, `project:${projectPath ?? ''}`],
410
448
  [globalRaw[key], `global:${globalPath}`],
411
449
  ]
412
450
  for (const [raw, source] of layers) {
@@ -427,12 +465,13 @@ export function loadConfig(options: {
427
465
  sound: resolve('sound'),
428
466
  interruption_level: resolve('interruption_level'),
429
467
  project: resolve('project'),
430
- notify_criteria: resolve('notify_criteria'),
431
468
  ask_notifications: resolve('ask_notifications'),
432
469
  ask_grace_seconds: resolve('ask_grace_seconds'),
433
470
  reply_window_seconds: resolve('reply_window_seconds'),
434
471
  log_level: resolve('log_level'),
435
472
  log_max_bytes: resolve('log_max_bytes'),
436
473
  log_max_files: resolve('log_max_files'),
474
+ media_origins: resolve('media_origins'),
475
+ approve_origins: resolve('approve_origins'),
437
476
  }
438
477
  }
@@ -1,4 +1,5 @@
1
1
  import {
2
+ chmodSync,
2
3
  existsSync,
3
4
  mkdirSync,
4
5
  mkdtempSync,
@@ -13,6 +14,7 @@ import path from 'node:path'
13
14
  import { afterAll, describe, expect, it } from 'vitest'
14
15
  import {
15
16
  FileStore,
17
+ KeychainStore,
16
18
  WindowsDpapiStore,
17
19
  defaultCredentialStore,
18
20
  windowsDpapiOperation,
@@ -106,9 +108,10 @@ function mockDpapi(options: { failProtect?: boolean; failUnprotect?: boolean } =
106
108
  function assertNoSecretInArgs(calls: RunCommandSpec[]): void {
107
109
  for (const call of calls) {
108
110
  const argv = [call.command, ...call.args].join('\0')
109
- expect(argv).not.toContain(SAMPLE.secret)
110
- expect(argv).not.toContain(OTHER.secret)
111
- expect(argv).not.toContain(SAMPLE.machineId)
111
+ for (const credential of [SAMPLE, OTHER]) {
112
+ for (const value of Object.values(credential)) expect(argv).not.toContain(value)
113
+ expect(argv).not.toContain(JSON.stringify(credential))
114
+ }
112
115
  expect(argv).not.toContain('machineId')
113
116
  }
114
117
  }
@@ -206,6 +209,27 @@ describe('FileStore', () => {
206
209
  })
207
210
  })
208
211
 
212
+ it('creates the credential directory mode 0700 on POSIX', () => {
213
+ if (process.platform === 'win32') return
214
+ const { env } = sandbox('file-dir-mode')
215
+ const store = new FileStore(env, { platform: 'linux' })
216
+ store.save(SAMPLE)
217
+ const dir = path.join(env.XDG_CONFIG_HOME!, 'notifai')
218
+ expect(statSync(dir).mode & 0o777).toBe(0o700)
219
+ })
220
+
221
+ it('tightens an existing world-readable credential directory on POSIX', () => {
222
+ if (process.platform === 'win32') return
223
+ const { env } = sandbox('file-dir-chmod')
224
+ const dir = path.join(env.XDG_CONFIG_HOME!, 'notifai')
225
+ mkdirSync(dir, { recursive: true, mode: 0o755 })
226
+ chmodSync(dir, 0o755)
227
+ expect(statSync(dir).mode & 0o777).toBe(0o755)
228
+ const store = new FileStore(env, { platform: 'linux' })
229
+ store.save(SAMPLE)
230
+ expect(statSync(dir).mode & 0o777).toBe(0o700)
231
+ })
232
+
209
233
  it('does not interpret or erase a credential from a future format epoch', () => {
210
234
  const { env } = sandbox('file-future-format')
211
235
  const file = path.join(env.XDG_CONFIG_HOME!, 'notifai', 'credentials.json')
@@ -216,6 +240,50 @@ describe('FileStore', () => {
216
240
  })
217
241
  })
218
242
 
243
+ describe('KeychainStore', () => {
244
+ it('sends the serialized credential through stdin and never process argv', () => {
245
+ const calls: RunCommandSpec[] = []
246
+ const runner: ProcessRunner = {
247
+ run(spec) {
248
+ calls.push(spec)
249
+ return { status: 0, stdout: Buffer.alloc(0) }
250
+ },
251
+ }
252
+ const store = new KeychainStore({ run: runner })
253
+ store.save(SAMPLE)
254
+ store.save(OTHER)
255
+
256
+ expect(calls).toHaveLength(2)
257
+ for (const [index, call] of calls.entries()) {
258
+ expect(call.command).toBe('security')
259
+ expect(call.args).toEqual(['-q', '-i'])
260
+ expect(call.timeoutMs).toBe(15_000)
261
+ expect(call.input.toString('ascii')).toMatch(
262
+ /^add-generic-password -U -s io\.notifai\.cli -a machine -X [0-9a-f]+\n$/,
263
+ )
264
+ const encoded = call.input.toString('ascii').match(/-X ([0-9a-f]+)\n$/)?.[1]
265
+ expect(encoded).toBeDefined()
266
+ expect(JSON.parse(Buffer.from(encoded!, 'hex').toString('utf8'))).toEqual({
267
+ format: 'notifai.machine-credential.v1',
268
+ ...(index === 0 ? SAMPLE : OTHER),
269
+ })
270
+ expect(call.input.toString('utf8')).not.toContain(index === 0 ? SAMPLE.secret : OTHER.secret)
271
+ }
272
+ assertNoSecretInArgs(calls)
273
+ })
274
+
275
+ it('fails closed when the Keychain helper rejects the write', () => {
276
+ const runner: ProcessRunner = {
277
+ run() {
278
+ return { status: 1, stdout: Buffer.alloc(0) }
279
+ },
280
+ }
281
+ expect(() => new KeychainStore({ run: runner }).save(SAMPLE)).toThrow(
282
+ /Keychain credential save failed/,
283
+ )
284
+ })
285
+ })
286
+
219
287
  describe('WindowsDpapiStore', () => {
220
288
  it('protects under LOCALAPPDATA and never writes roaming or config trees', () => {
221
289
  const { env, roaming, config } = sandbox('dpapi-location')
@@ -49,6 +49,7 @@ const DPAPI_FORMAT = 'notifai.dpapi.current-user.v1'
49
49
  const DPAPI_ENTROPY = 'io.notifai.cli'
50
50
  const DPAPI_TIMEOUT_MS = 15_000
51
51
  const DPAPI_FILE = 'credentials.dpapi'
52
+ const KEYCHAIN_TIMEOUT_MS = 15_000
52
53
 
53
54
  const PROTECT_SCRIPT = [
54
55
  "$ErrorActionPreference = 'Stop'",
@@ -109,8 +110,21 @@ export const defaultProcessRunner: ProcessRunner = {
109
110
  },
110
111
  }
111
112
 
112
- /** macOS Keychain via the `security` CLI — no native addons (proposal concern). */
113
+ /**
114
+ * macOS Keychain via the first-party `security` Security.framework client.
115
+ *
116
+ * `security add-generic-password -w <value>` puts the value in process argv.
117
+ * Interactive mode instead parses a command from stdin, so the credential
118
+ * crosses the helper boundary only through the pipe. `-X` keeps arbitrary
119
+ * serialized bytes out of the command parser's quoting rules.
120
+ */
113
121
  export class KeychainStore implements CredentialStore {
122
+ private readonly run: ProcessRunner
123
+
124
+ constructor(options: CredentialStoreOptions = {}) {
125
+ this.run = options.run ?? defaultProcessRunner
126
+ }
127
+
114
128
  load(): MachineCredential | null {
115
129
  try {
116
130
  const raw = execFileSync(
@@ -127,20 +141,20 @@ export class KeychainStore implements CredentialStore {
127
141
  }
128
142
 
129
143
  save(credential: MachineCredential): void {
130
- execFileSync(
131
- 'security',
132
- [
133
- 'add-generic-password',
134
- '-U',
135
- '-s',
136
- SERVICE,
137
- '-a',
138
- 'machine',
139
- '-w',
140
- JSON.stringify(serializeCredential(credential)),
141
- ],
142
- { stdio: 'ignore' },
144
+ const serialized = Buffer.from(JSON.stringify(serializeCredential(credential)), 'utf8')
145
+ const command = Buffer.from(
146
+ `add-generic-password -U -s ${SERVICE} -a machine -X ${serialized.toString('hex')}\n`,
147
+ 'ascii',
143
148
  )
149
+ const result = this.run.run({
150
+ command: 'security',
151
+ args: ['-q', '-i'],
152
+ input: command,
153
+ timeoutMs: KEYCHAIN_TIMEOUT_MS,
154
+ })
155
+ if (result.status !== 0) {
156
+ throw new Error('macOS Keychain credential save failed')
157
+ }
144
158
  }
145
159
 
146
160
  clear(): void {
@@ -381,6 +395,6 @@ export function defaultCredentialStore(
381
395
  options.keychainAvailable !== undefined
382
396
  ? options.keychainAvailable
383
397
  : platform === 'darwin' && keychainProbeSucceeds()
384
- if (keychain) return new KeychainStore()
398
+ if (keychain) return new KeychainStore(options)
385
399
  return new FileStore(env, { platform })
386
400
  }