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