@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.
- package/CHANGELOG.md +75 -0
- package/README.md +20 -13
- package/dist/atomic-file.d.ts +14 -0
- package/dist/atomic-file.js +27 -2
- package/dist/atomic-file.js.map +1 -1
- package/dist/cli-bin.d.ts +4 -0
- package/dist/cli-bin.js +62 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/cli-release.d.ts +10 -0
- package/dist/cli-release.js +75 -0
- package/dist/cli-release.js.map +1 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.js +13 -3
- package/dist/client.js.map +1 -1
- package/dist/commands-auth.d.ts +6 -0
- package/dist/commands-auth.js +28 -3
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-config.js +22 -3
- package/dist/commands-config.js.map +1 -1
- package/dist/commands-core.d.ts +12 -0
- package/dist/commands-core.js +27 -3
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-devices.d.ts +2 -1
- package/dist/commands-devices.js +20 -8
- package/dist/commands-devices.js.map +1 -1
- package/dist/commands-doctor.d.ts +1 -0
- package/dist/commands-doctor.js +85 -27
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.d.ts +27 -0
- package/dist/commands-guidance.js +163 -0
- package/dist/commands-guidance.js.map +1 -0
- package/dist/commands-hooks.d.ts +16 -5
- package/dist/commands-hooks.js +433 -38
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +10 -18
- package/dist/commands-init.js +139 -54
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.d.ts +17 -2
- package/dist/commands-send-support.js +66 -8
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.d.ts +0 -1
- package/dist/commands-send.js +105 -39
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-setup-proof.d.ts +8 -0
- package/dist/commands-setup-proof.js +31 -2
- package/dist/commands-setup-proof.js.map +1 -1
- package/dist/commands-skill.d.ts +11 -1
- package/dist/commands-skill.js +61 -11
- package/dist/commands-skill.js.map +1 -1
- package/dist/commands.d.ts +2 -1
- package/dist/commands.js +1 -0
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +30 -14
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +24 -5
- package/dist/config.js +37 -8
- package/dist/config.js.map +1 -1
- package/dist/credentials.d.ts +10 -1
- package/dist/credentials.js +25 -12
- package/dist/credentials.js.map +1 -1
- package/dist/guidance-content.d.ts +50 -0
- package/dist/guidance-content.js +240 -0
- package/dist/guidance-content.js.map +1 -0
- package/dist/guidance.d.ts +69 -0
- package/dist/guidance.js +173 -0
- package/dist/guidance.js.map +1 -0
- package/dist/hook-adapter.js +37 -3
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +68 -7
- package/dist/hooks.js +238 -92
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +10 -18
- package/dist/install-hooks.js +110 -23
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +14 -9
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +8 -1
- package/dist/logging.js +43 -2
- package/dist/logging.js.map +1 -1
- package/dist/main.js +5 -487
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +19 -0
- package/dist/native-skills.js +30 -8
- package/dist/native-skills.js.map +1 -1
- package/dist/opencode-plugin.js +14 -1
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/program.d.ts +49 -0
- package/dist/program.js +576 -0
- package/dist/program.js.map +1 -0
- package/dist/send.d.ts +12 -10
- package/dist/send.js +65 -29
- package/dist/send.js.map +1 -1
- package/dist/session-activation.d.ts +15 -0
- package/dist/session-activation.js +35 -0
- package/dist/session-activation.js.map +1 -0
- package/dist/session-labels.d.ts +9 -3
- package/dist/session-labels.js +38 -7
- package/dist/session-labels.js.map +1 -1
- package/dist/url-policy.d.ts +85 -0
- package/dist/url-policy.js +369 -0
- package/dist/url-policy.js.map +1 -0
- package/package.json +6 -4
- package/src/atomic-file.ts +36 -2
- package/src/cli-bin.test.ts +38 -0
- package/src/cli-bin.ts +67 -0
- package/src/cli-release.test.ts +46 -0
- package/src/cli-release.ts +78 -0
- package/src/client.test.ts +95 -0
- package/src/client.ts +16 -3
- package/src/commands-auth.ts +32 -3
- package/src/commands-config.ts +25 -2
- package/src/commands-core.ts +38 -3
- package/src/commands-devices.ts +28 -9
- package/src/commands-doctor.ts +116 -25
- package/src/commands-guidance.ts +216 -0
- package/src/commands-hooks.ts +513 -42
- package/src/commands-init.ts +170 -60
- package/src/commands-send-support.test.ts +156 -0
- package/src/commands-send-support.ts +72 -8
- package/src/commands-send.ts +123 -38
- package/src/commands-setup-proof.ts +36 -2
- package/src/commands-skill.ts +78 -15
- package/src/commands.test.ts +1490 -197
- package/src/commands.ts +6 -0
- package/src/config-schema.ts +32 -16
- package/src/config.test.ts +57 -2
- package/src/config.ts +49 -10
- package/src/credentials.test.ts +71 -3
- package/src/credentials.ts +29 -15
- package/src/guidance-content.ts +256 -0
- package/src/guidance.test.ts +299 -0
- package/src/guidance.ts +233 -0
- package/src/hook-adapter.test.ts +113 -1
- package/src/hook-adapter.ts +37 -3
- package/src/hooks.test.ts +783 -170
- package/src/hooks.ts +306 -131
- package/src/install-hooks.test.ts +135 -16
- package/src/install-hooks.ts +113 -25
- package/src/interactive.ts +14 -9
- package/src/logging.test.ts +66 -3
- package/src/logging.ts +45 -2
- package/src/main.ts +6 -614
- package/src/native-skills.test.ts +41 -1
- package/src/native-skills.ts +40 -6
- package/src/opencode-plugin.ts +14 -1
- package/src/program.test.ts +237 -0
- package/src/program.ts +751 -0
- package/src/send.test.ts +28 -0
- package/src/send.ts +81 -34
- package/src/session-activation.ts +45 -0
- package/src/session-labels.test.ts +120 -3
- package/src/session-labels.ts +38 -7
- package/src/skill.test.ts +153 -12
- package/src/trust-policy.test.ts +701 -0
- 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'
|
package/src/config-schema.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
145
|
+
summary: 'Set how insistently notifications reach Apple devices',
|
|
141
146
|
detail:
|
|
142
|
-
'
|
|
143
|
-
unsetMeans: 'not specified;
|
|
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
|
-
|
|
197
|
-
label: '
|
|
198
|
-
group: '
|
|
199
|
-
kind: '
|
|
200
|
-
summary: '
|
|
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
|
-
'
|
|
203
|
-
unsetMeans: '
|
|
204
|
-
example: '
|
|
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
|
|
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: {
|
package/src/config.test.ts
CHANGED
|
@@ -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.
|
|
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: {
|
|
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.
|
|
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
|
|
368
|
-
* overrides
|
|
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
|
}
|
package/src/credentials.test.ts
CHANGED
|
@@ -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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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')
|
package/src/credentials.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
}
|