@raidiant/notifai 10.1.0 → 10.1.2
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 +37 -0
- package/README.md +3 -0
- package/dist/cli-bin.d.ts +21 -1
- package/dist/cli-bin.js +141 -10
- package/dist/cli-bin.js.map +1 -1
- package/dist/cli-update-contract.d.ts +7 -0
- package/dist/cli-update-contract.js +10 -0
- package/dist/cli-update-contract.js.map +1 -0
- package/dist/client.d.ts +6 -1
- package/dist/client.js +1 -0
- package/dist/client.js.map +1 -1
- package/dist/codex-wake.d.ts +2 -0
- package/dist/codex-wake.js +2 -1
- package/dist/codex-wake.js.map +1 -1
- package/dist/commands-auth.d.ts +15 -1
- package/dist/commands-auth.js +29 -11
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-core.d.ts +9 -2
- package/dist/commands-core.js +11 -4
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-devices.d.ts +10 -1
- package/dist/commands-devices.js +17 -13
- package/dist/commands-devices.js.map +1 -1
- package/dist/commands-doctor.js +93 -49
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-hooks.js +47 -10
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +16 -0
- package/dist/commands-init.js +254 -93
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.js +3 -0
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.d.ts +1 -1
- package/dist/commands-send.js +121 -35
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-skill.js +72 -54
- package/dist/commands-skill.js.map +1 -1
- package/dist/commands-update.d.ts +10 -0
- package/dist/commands-update.js +141 -0
- package/dist/commands-update.js.map +1 -0
- package/dist/commands.d.ts +2 -1
- package/dist/commands.js +2 -1
- package/dist/commands.js.map +1 -1
- package/dist/hook-adapter.d.ts +2 -0
- package/dist/hook-adapter.js +12 -1
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +43 -2
- package/dist/hooks.js +163 -21
- package/dist/hooks.js.map +1 -1
- package/dist/invocation-context.d.ts +1 -0
- package/dist/invocation-context.js +9 -1
- package/dist/invocation-context.js.map +1 -1
- package/dist/main-run.d.ts +1 -0
- package/dist/main-run.js +42 -0
- package/dist/main-run.js.map +1 -0
- package/dist/main.js +10 -31
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +13 -3
- package/dist/native-skills.js +51 -12
- package/dist/native-skills.js.map +1 -1
- package/dist/node-floor.d.ts +19 -0
- package/dist/node-floor.js +39 -0
- package/dist/node-floor.js.map +1 -0
- package/dist/orca-session-title.d.ts +5 -4
- package/dist/orca-session-title.js +32 -19
- package/dist/orca-session-title.js.map +1 -1
- package/dist/platform.d.ts +11 -17
- package/dist/platform.js +20 -23
- package/dist/platform.js.map +1 -1
- package/dist/program.d.ts +2 -1
- package/dist/program.js +22 -5
- package/dist/program.js.map +1 -1
- package/dist/question-settlement-process.d.ts +15 -0
- package/dist/question-settlement-process.js +35 -0
- package/dist/question-settlement-process.js.map +1 -0
- package/dist/readiness.d.ts +25 -1
- package/dist/readiness.js +32 -2
- package/dist/readiness.js.map +1 -1
- package/dist/release.d.ts +5 -5
- package/dist/release.js +5 -5
- package/dist/setup-destinations.d.ts +23 -0
- package/dist/setup-destinations.js +44 -0
- package/dist/setup-destinations.js.map +1 -0
- package/dist/skill-integrity.d.ts +49 -0
- package/dist/skill-integrity.js +154 -0
- package/dist/skill-integrity.js.map +1 -0
- package/dist/skill-source/manifest.json +16 -0
- package/dist/skill-source/notifai/SKILL.md +390 -0
- package/dist/skill-source/notifai/references/harness-setup.md +220 -0
- package/package.json +11 -14
- package/src/atomic-file.ts +0 -198
- package/src/claude-wake.test.ts +0 -321
- package/src/claude-wake.ts +0 -499
- package/src/cli-bin.test.ts +0 -38
- package/src/cli-bin.ts +0 -67
- package/src/cli-release.test.ts +0 -46
- package/src/cli-release.ts +0 -78
- package/src/client.test.ts +0 -390
- package/src/client.ts +0 -345
- package/src/codex-wake.test.ts +0 -254
- package/src/codex-wake.ts +0 -297
- package/src/command-session.ts +0 -38
- package/src/commands-agent-sessions.ts +0 -72
- package/src/commands-auth.ts +0 -205
- package/src/commands-config.ts +0 -336
- package/src/commands-core.ts +0 -298
- package/src/commands-devices.test.ts +0 -15
- package/src/commands-devices.ts +0 -173
- package/src/commands-doctor.ts +0 -1440
- package/src/commands-guidance.ts +0 -181
- package/src/commands-harness-context.ts +0 -207
- package/src/commands-hook-shape.ts +0 -69
- package/src/commands-hooks.ts +0 -2268
- package/src/commands-init.ts +0 -878
- package/src/commands-io.ts +0 -108
- package/src/commands-logs.ts +0 -243
- package/src/commands-project.ts +0 -47
- package/src/commands-send-support.test.ts +0 -156
- package/src/commands-send-support.ts +0 -311
- package/src/commands-send.ts +0 -769
- package/src/commands-setup-proof.test.ts +0 -72
- package/src/commands-setup-proof.ts +0 -124
- package/src/commands-skill.test.ts +0 -44
- package/src/commands-skill.ts +0 -204
- package/src/commands-sounds.test.ts +0 -106
- package/src/commands-sounds.ts +0 -49
- package/src/commands.test.ts +0 -7828
- package/src/commands.ts +0 -75
- package/src/config-schema.test.ts +0 -153
- package/src/config-schema.ts +0 -349
- package/src/config.test.ts +0 -581
- package/src/config.ts +0 -475
- package/src/credentials.test.ts +0 -403
- package/src/credentials.ts +0 -403
- package/src/file-lock-process-worker.test.ts +0 -64
- package/src/file-lock.test.ts +0 -320
- package/src/file-lock.ts +0 -393
- package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
- package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
- package/src/generated-session-label.ts +0 -155
- package/src/guidance-content.ts +0 -278
- package/src/guidance-render.ts +0 -56
- package/src/guidance.test.ts +0 -327
- package/src/guidance.ts +0 -233
- package/src/harness-integration.test.ts +0 -294
- package/src/harnesses.test.ts +0 -149
- package/src/harnesses.ts +0 -164
- package/src/hook-adapter.test.ts +0 -496
- package/src/hook-adapter.ts +0 -410
- package/src/hook-input.test.ts +0 -39
- package/src/hook-input.ts +0 -72
- package/src/hooks.test.ts +0 -5549
- package/src/hooks.ts +0 -3759
- package/src/install-hooks.test.ts +0 -1614
- package/src/install-hooks.ts +0 -1501
- package/src/interactive.test.ts +0 -387
- package/src/interactive.ts +0 -858
- package/src/invocation-context.test.ts +0 -352
- package/src/invocation-context.ts +0 -197
- package/src/logging-process-worker.test.ts +0 -45
- package/src/logging.test.ts +0 -544
- package/src/logging.ts +0 -698
- package/src/main.ts +0 -36
- package/src/native-skills.test.ts +0 -115
- package/src/native-skills.ts +0 -162
- package/src/onboarding-docs.test.ts +0 -25
- package/src/openclaw-plugin.ts +0 -409
- package/src/opencode-plugin.ts +0 -298
- package/src/orca-session-title.test.ts +0 -54
- package/src/orca-session-title.ts +0 -103
- package/src/pack-feedback-slice.test.ts +0 -212
- package/src/pack-feedback-slice.ts +0 -187
- package/src/platform.test.ts +0 -152
- package/src/platform.ts +0 -173
- package/src/program.test.ts +0 -282
- package/src/program.ts +0 -818
- package/src/project-enablement.test.ts +0 -55
- package/src/project-enablement.ts +0 -99
- package/src/question-caps.test.ts +0 -49
- package/src/question-timing.ts +0 -56
- package/src/readiness.test.ts +0 -25
- package/src/readiness.ts +0 -179
- package/src/release.ts +0 -51
- package/src/send-attempts.test.ts +0 -143
- package/src/send-attempts.ts +0 -188
- package/src/send.test.ts +0 -28
- package/src/send.ts +0 -436
- package/src/session-activation.ts +0 -61
- package/src/session-end-log-lock-worker.test.ts +0 -35
- package/src/session-labels.test.ts +0 -490
- package/src/session-labels.ts +0 -474
- package/src/skill.test.ts +0 -415
- package/src/sound-ref.test.ts +0 -31
- package/src/sound-ref.ts +0 -34
- package/src/trust-policy.test.ts +0 -701
- package/src/ui/banner.ts +0 -95
- package/src/ui/config-view.test.ts +0 -90
- package/src/ui/config-view.ts +0 -138
- package/src/ui/help.test.ts +0 -16
- package/src/ui/help.ts +0 -128
- package/src/ui/theme.test.ts +0 -92
- package/src/ui/theme.ts +0 -265
- package/src/url-policy.ts +0 -399
- package/tsconfig.json +0 -10
package/src/ui/theme.ts
DELETED
|
@@ -1,265 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One visual language for every human-facing surface: help, `config show`,
|
|
3
|
-
* `doctor`, and the interactive app.
|
|
4
|
-
*
|
|
5
|
-
* Two rules hold everything here together.
|
|
6
|
-
*
|
|
7
|
-
* Colour is decoration, never information. Every state that colour marks also
|
|
8
|
-
* carries a glyph and a word, because the output is read through pipes, on
|
|
9
|
-
* monochrome terminals, by screen readers, and by people who cannot separate
|
|
10
|
-
* red from green. `picocolors` resolves to identity functions when stdout is
|
|
11
|
-
* not a TTY or `NO_COLOR` is set, so the plain path is the same code, not a
|
|
12
|
-
* second implementation that can drift.
|
|
13
|
-
*
|
|
14
|
-
* And an agent's bytes are not ours to restyle. Machine-readable output
|
|
15
|
-
* (`--json`) never routes through this module, and every non-TTY invocation
|
|
16
|
-
* lands on the uncoloured branch by construction.
|
|
17
|
-
*/
|
|
18
|
-
import pc from 'picocolors'
|
|
19
|
-
|
|
20
|
-
/** True when the terminal can render the box-drawing and status glyphs below. */
|
|
21
|
-
export const unicode: boolean =
|
|
22
|
-
process.platform !== 'win32' ||
|
|
23
|
-
process.env['WT_SESSION'] !== undefined ||
|
|
24
|
-
process.env['TERM_PROGRAM'] === 'vscode' ||
|
|
25
|
-
process.env['ConEmuTask'] === '{cmd::Cmder}'
|
|
26
|
-
|
|
27
|
-
/** True when 24-bit colour is available, which the banner gradient needs. */
|
|
28
|
-
function supportsTrueColor(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
29
|
-
if (!pc.isColorSupported) return false
|
|
30
|
-
const colorterm = env['COLORTERM'] ?? ''
|
|
31
|
-
if (/^(truecolor|24bit)$/i.test(colorterm)) return true
|
|
32
|
-
return env['TERM_PROGRAM'] === 'iTerm.app' || env['TERM_PROGRAM'] === 'vscode'
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export const color = pc
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Brand ramp, shared with the companion app icon: pink → coral → rose →
|
|
39
|
-
* magenta. Used for the wordmark and for accenting the surfaces a human sees.
|
|
40
|
-
*/
|
|
41
|
-
export const BRAND_RAMP = ['#FF9EC0', '#FF816B', '#EC5A73', '#B84E93'] as const
|
|
42
|
-
|
|
43
|
-
interface Rgb {
|
|
44
|
-
r: number
|
|
45
|
-
g: number
|
|
46
|
-
b: number
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function hexToRgb(hex: string): Rgb {
|
|
50
|
-
const value = Number.parseInt(hex.slice(1), 16)
|
|
51
|
-
return { r: (value >> 16) & 255, g: (value >> 8) & 255, b: value & 255 }
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function mix(a: Rgb, b: Rgb, t: number): Rgb {
|
|
55
|
-
return {
|
|
56
|
-
r: Math.round(a.r + (b.r - a.r) * t),
|
|
57
|
-
g: Math.round(a.g + (b.g - a.g) * t),
|
|
58
|
-
b: Math.round(a.b + (b.b - a.b) * t),
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Sample the brand ramp at `t` in [0, 1]. Multi-stop so the gradient keeps the
|
|
64
|
-
* icon's coral midtones instead of washing straight from pink to magenta.
|
|
65
|
-
*/
|
|
66
|
-
function rampAt(t: number, ramp: readonly string[] = BRAND_RAMP): string {
|
|
67
|
-
const clamped = Math.min(1, Math.max(0, t))
|
|
68
|
-
const span = ramp.length - 1
|
|
69
|
-
const scaled = clamped * span
|
|
70
|
-
const index = Math.min(span - 1, Math.floor(scaled))
|
|
71
|
-
const { r, g, b } = mix(hexToRgb(ramp[index]!), hexToRgb(ramp[index + 1]!), scaled - index)
|
|
72
|
-
return `[38;2;${r};${g};${b}m`
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const RESET = '[39m'
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* SGR escape sequences, in the three shapes the helpers below need: stripping
|
|
79
|
-
* them to measure, splitting around them to slice, and recognising one on its
|
|
80
|
-
* own. Kept together so a change to what counts as an escape lands once.
|
|
81
|
-
*/
|
|
82
|
-
const ANSI_GLOBAL = /\[[0-9;]*m/g
|
|
83
|
-
const ANSI_SPLIT = /(\[[0-9;]*m)/
|
|
84
|
-
const ANSI_ONLY = /^\[[0-9;]*m$/
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Paint each line of a block so the gradient runs left-to-right across the
|
|
89
|
-
* whole block rather than restarting per line. The wordmark is one image, and
|
|
90
|
-
* per-line gradients make it read as several.
|
|
91
|
-
*/
|
|
92
|
-
export function gradientBlock(lines: readonly string[], ramp: readonly string[] = BRAND_RAMP): string[] {
|
|
93
|
-
if (!pc.isColorSupported) return [...lines]
|
|
94
|
-
const width = Math.max(1, ...lines.map((line) => [...line].length))
|
|
95
|
-
if (!supportsTrueColor()) return lines.map((line) => pc.magenta(line))
|
|
96
|
-
return lines.map((line) =>
|
|
97
|
-
[...line]
|
|
98
|
-
.map((char, i) => (char === ' ' ? char : `${rampAt(i / Math.max(1, width - 1), ramp)}${char}`))
|
|
99
|
-
.join('') + RESET,
|
|
100
|
-
)
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// ---------------------------------------------------------------------------
|
|
104
|
-
// Semantic styles
|
|
105
|
-
// ---------------------------------------------------------------------------
|
|
106
|
-
|
|
107
|
-
export const style = {
|
|
108
|
-
/** A heading that opens a section. */
|
|
109
|
-
heading: (text: string): string => pc.bold(text),
|
|
110
|
-
/** The brand voice — command names, the product name, the selected thing. */
|
|
111
|
-
accent: (text: string): string => pc.magenta(text),
|
|
112
|
-
/** Secondary text: hints, provenance, units, defaults. */
|
|
113
|
-
dim: (text: string): string => pc.dim(text),
|
|
114
|
-
/** Something the reader can type. */
|
|
115
|
-
code: (text: string): string => pc.cyan(text),
|
|
116
|
-
ok: (text: string): string => pc.green(text),
|
|
117
|
-
warn: (text: string): string => pc.yellow(text),
|
|
118
|
-
bad: (text: string): string => pc.red(text),
|
|
119
|
-
/** A value as it currently stands. */
|
|
120
|
-
value: (text: string): string => pc.bold(pc.white(text)),
|
|
121
|
-
/** A value that is not set, or is inherited rather than chosen. */
|
|
122
|
-
unset: (text: string): string => pc.dim(pc.italic(text)),
|
|
123
|
-
} as const
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Status marks. The glyph and the colour say the same thing twice on purpose;
|
|
127
|
-
* either alone is enough to read the line.
|
|
128
|
-
*/
|
|
129
|
-
export const glyph = {
|
|
130
|
-
ok: unicode ? '●' : '*',
|
|
131
|
-
warn: unicode ? '▲' : '!',
|
|
132
|
-
bad: unicode ? '✕' : 'x',
|
|
133
|
-
pending: unicode ? '○' : 'o',
|
|
134
|
-
bullet: unicode ? '•' : '-',
|
|
135
|
-
arrow: unicode ? '→' : '->',
|
|
136
|
-
chevron: unicode ? '›' : '>',
|
|
137
|
-
bar: unicode ? '│' : '|',
|
|
138
|
-
} as const
|
|
139
|
-
|
|
140
|
-
export type Tone = 'ok' | 'warn' | 'bad' | 'pending'
|
|
141
|
-
|
|
142
|
-
/** A coloured glyph for a tone; the caller supplies the words. */
|
|
143
|
-
export function mark(tone: Tone): string {
|
|
144
|
-
switch (tone) {
|
|
145
|
-
case 'ok':
|
|
146
|
-
return style.ok(glyph.ok)
|
|
147
|
-
case 'warn':
|
|
148
|
-
return style.warn(glyph.warn)
|
|
149
|
-
case 'bad':
|
|
150
|
-
return style.bad(glyph.bad)
|
|
151
|
-
case 'pending':
|
|
152
|
-
return style.dim(glyph.pending)
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/** Visible width, ignoring ANSI escapes, for alignment maths. */
|
|
157
|
-
export function width(text: string): number {
|
|
158
|
-
return [...text.replace(ANSI_GLOBAL, '')].length
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/** Pad to `size` using visible width, so colour never breaks a column. */
|
|
162
|
-
export function pad(text: string, size: number): string {
|
|
163
|
-
return text + ' '.repeat(Math.max(0, size - width(text)))
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Usable terminal width: the real one, capped so paragraphs do not run to 200
|
|
168
|
-
* characters on a maximised window.
|
|
169
|
-
*
|
|
170
|
-
* No lower clamp. Raising a narrow terminal to a comfortable minimum sounds
|
|
171
|
-
* harmless and is not — every box drawn to that invented width overflows the
|
|
172
|
-
* real one and wraps, which looks far worse than a cramped box that fits.
|
|
173
|
-
*/
|
|
174
|
-
export function terminalWidth(fallback = 80): number {
|
|
175
|
-
const columns = process.stdout.columns
|
|
176
|
-
if (typeof columns !== 'number' || columns <= 0) return fallback
|
|
177
|
-
return Math.min(100, columns)
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* A titled box around `rows`.
|
|
182
|
-
*
|
|
183
|
-
* Hand-drawn rather than delegated, because the obvious library call sizes
|
|
184
|
-
* itself from `process.stdout.columns` and divides by it — a pty that reports
|
|
185
|
-
* zero columns (some CI runners, some `script` invocations) makes the padding
|
|
186
|
-
* negative and throws, taking the whole screen down with it. A status card is
|
|
187
|
-
* decoration; it may degrade, but it must never be what crashes the app.
|
|
188
|
-
*
|
|
189
|
-
* Rows are measured with `width`, so colour inside them does not skew the
|
|
190
|
-
* border, and anything too wide for the terminal is clipped rather than
|
|
191
|
-
* wrapped — a status line that wraps stops being a status line.
|
|
192
|
-
*/
|
|
193
|
-
export function card(title: string, rows: readonly string[], columns = terminalWidth()): string[] {
|
|
194
|
-
const [tl, tr, bl, br, h, v] = unicode
|
|
195
|
-
? ['╭', '╮', '╰', '╯', '─', '│']
|
|
196
|
-
: ['+', '+', '+', '+', '-', '|']
|
|
197
|
-
const inner = Math.max(20, columns - 4)
|
|
198
|
-
const content = rows.map((row) => (width(row) > inner ? clip(row, inner) : row))
|
|
199
|
-
const bodyWidth = Math.max(width(title) + 4, ...content.map((row) => width(row)))
|
|
200
|
-
const boxWidth = Math.min(inner, bodyWidth)
|
|
201
|
-
const heading = `${h} ${style.heading(title)} `
|
|
202
|
-
const lines: string[] = []
|
|
203
|
-
lines.push(style.dim(tl) + heading + style.dim(h!.repeat(Math.max(0, boxWidth - width(heading) + 2))) + style.dim(tr))
|
|
204
|
-
for (const row of content) {
|
|
205
|
-
lines.push(`${style.dim(v)} ${pad(row, boxWidth)} ${style.dim(v)}`)
|
|
206
|
-
}
|
|
207
|
-
lines.push(style.dim(bl! + h!.repeat(boxWidth + 2) + br))
|
|
208
|
-
return lines
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Truncate to a visible width, carrying escape sequences through untouched.
|
|
213
|
-
*
|
|
214
|
-
* Counting only printable characters is the whole point: slicing a coloured
|
|
215
|
-
* string by `String.length` cuts through an escape sequence and spills raw
|
|
216
|
-
* `[38;2` into the terminal.
|
|
217
|
-
*/
|
|
218
|
-
export function clip(text: string, limit: number): string {
|
|
219
|
-
if (width(text) <= limit) return text
|
|
220
|
-
const parts = text.split(ANSI_SPLIT)
|
|
221
|
-
let out = ''
|
|
222
|
-
let visible = 0
|
|
223
|
-
for (const part of parts) {
|
|
224
|
-
if (part === undefined || part === '') continue
|
|
225
|
-
if (ANSI_ONLY.test(part)) {
|
|
226
|
-
out += part
|
|
227
|
-
continue
|
|
228
|
-
}
|
|
229
|
-
for (const char of part) {
|
|
230
|
-
if (visible >= limit - 1) return `${out}\u2026${RESET}`
|
|
231
|
-
out += char
|
|
232
|
-
visible += 1
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
return `${out}${RESET}`
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Wrap `text` to `columns`, returning bare lines.
|
|
240
|
-
*
|
|
241
|
-
* Indentation is the caller's, deliberately: an earlier version indented
|
|
242
|
-
* continuation lines itself, callers indented every line as well, and each
|
|
243
|
-
* wrapped paragraph stepped further right down the page.
|
|
244
|
-
*/
|
|
245
|
-
export function wrap(text: string, columns: number): string[] {
|
|
246
|
-
// Honour the width asked for. A floor here would silently ignore small
|
|
247
|
-
// values and hand back lines wider than the caller reserved room for; a
|
|
248
|
-
// width of zero or less is the only nonsense, and that falls back.
|
|
249
|
-
const limit = columns > 0 ? columns : 80
|
|
250
|
-
const words = text.split(/\s+/).filter(Boolean)
|
|
251
|
-
if (words.length === 0) return ['']
|
|
252
|
-
const lines: string[] = []
|
|
253
|
-
let line = ''
|
|
254
|
-
for (const word of words) {
|
|
255
|
-
const candidate = line === '' ? word : `${line} ${word}`
|
|
256
|
-
if (width(candidate) > limit && line !== '') {
|
|
257
|
-
lines.push(line)
|
|
258
|
-
line = word
|
|
259
|
-
} else {
|
|
260
|
-
line = candidate
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
if (line !== '') lines.push(line)
|
|
264
|
-
return lines
|
|
265
|
-
}
|
package/src/url-policy.ts
DELETED
|
@@ -1,399 +0,0 @@
|
|
|
1
|
-
import { lookup as dnsLookup } from 'node:dns/promises'
|
|
2
|
-
import { request as httpRequest, type IncomingMessage } from 'node:http'
|
|
3
|
-
import { request as httpsRequest } from 'node:https'
|
|
4
|
-
import { BlockList, isIP } from 'node:net'
|
|
5
|
-
import { Readable } from 'node:stream'
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* The URL trust policy: which network destinations this CLI will touch on
|
|
9
|
-
* input it did not author. `docs/TRUST.md` is the written policy; this module
|
|
10
|
-
* is its enforcement, and the two must change together.
|
|
11
|
-
*
|
|
12
|
-
* The threat is not a hostile user — it is hostile *input* reaching a
|
|
13
|
-
* trusted user's CLI: a cloned repository, a compromised server response, or
|
|
14
|
-
* an agent following text it found in a working tree. Every rule here is
|
|
15
|
-
* therefore fail-closed, and every escape hatch resolves only from
|
|
16
|
-
* configuration layers the User owns — never from a repository.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
/** The one dashboard origin the shipped service uses for pairing approval. */
|
|
20
|
-
export const CANONICAL_DASHBOARD_ORIGIN = 'https://app.notifai.sh'
|
|
21
|
-
|
|
22
|
-
/** Redirect hops a remote image fetch may follow, each re-validated. */
|
|
23
|
-
export const MEDIA_REDIRECT_LIMIT = 3
|
|
24
|
-
|
|
25
|
-
/** One address attempt must not hold an agent command indefinitely. */
|
|
26
|
-
export const MEDIA_FETCH_TIMEOUT_MS = 15_000
|
|
27
|
-
|
|
28
|
-
export type UrlRefusal = { ok: false; reason: string }
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Normalize one configured origin entry to `scheme://host[:port]`, or null
|
|
32
|
-
* when the entry is not a bare http(s) origin. Paths, queries, fragments, and
|
|
33
|
-
* userinfo are rejected rather than stripped: a config entry that carries
|
|
34
|
-
* them says the author expected them to matter, and silently ignoring parts
|
|
35
|
-
* of a trust rule is how allowlists rot.
|
|
36
|
-
*/
|
|
37
|
-
export function normalizeOrigin(entry: string): string | null {
|
|
38
|
-
let url: URL
|
|
39
|
-
try {
|
|
40
|
-
url = new URL(entry)
|
|
41
|
-
} catch {
|
|
42
|
-
return null
|
|
43
|
-
}
|
|
44
|
-
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null
|
|
45
|
-
if (url.username !== '' || url.password !== '') return null
|
|
46
|
-
if (url.pathname !== '/' || url.search !== '' || url.hash !== '') return null
|
|
47
|
-
// `https://*.example` parses, and would then match exactly one impossible
|
|
48
|
-
// host while reading like a wildcard. Refusing it is how someone finds out
|
|
49
|
-
// that origins are exact before they trust one that never matches.
|
|
50
|
-
if (!/^(?:[a-z0-9-]+(?:\.[a-z0-9-]+)*|\[[0-9a-f:.]+\])$/i.test(url.hostname)) return null
|
|
51
|
-
return url.origin
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Addresses that are not publicly routable: loopback, RFC 1918, link-local
|
|
56
|
-
* (including cloud metadata at 169.254.169.254), CGNAT, benchmark,
|
|
57
|
-
* documentation, multicast, and reserved space; for IPv6 also unspecified,
|
|
58
|
-
* unique-local, site-local, and discard prefixes. IPv4-mapped IPv6 addresses
|
|
59
|
-
* are classified as the IPv4 address they carry.
|
|
60
|
-
*/
|
|
61
|
-
const NON_PUBLIC = new BlockList()
|
|
62
|
-
const PUBLIC_IPV6 = new BlockList()
|
|
63
|
-
PUBLIC_IPV6.addSubnet('2000::', 3, 'ipv6')
|
|
64
|
-
for (const [subnet, prefix] of [
|
|
65
|
-
['0.0.0.0', 8],
|
|
66
|
-
['10.0.0.0', 8],
|
|
67
|
-
['100.64.0.0', 10],
|
|
68
|
-
['127.0.0.0', 8],
|
|
69
|
-
['169.254.0.0', 16],
|
|
70
|
-
['172.16.0.0', 12],
|
|
71
|
-
['192.0.0.0', 24],
|
|
72
|
-
['192.0.2.0', 24],
|
|
73
|
-
['192.88.99.0', 24],
|
|
74
|
-
['192.168.0.0', 16],
|
|
75
|
-
['198.18.0.0', 15],
|
|
76
|
-
['198.51.100.0', 24],
|
|
77
|
-
['203.0.113.0', 24],
|
|
78
|
-
['224.0.0.0', 4],
|
|
79
|
-
['240.0.0.0', 4],
|
|
80
|
-
] as const) {
|
|
81
|
-
NON_PUBLIC.addSubnet(subnet, prefix, 'ipv4')
|
|
82
|
-
}
|
|
83
|
-
for (const [subnet, prefix] of [
|
|
84
|
-
['::', 128],
|
|
85
|
-
['::1', 128],
|
|
86
|
-
['64:ff9b::', 96], // NAT64 — fail closed rather than trust the embedded address
|
|
87
|
-
['64:ff9b:1::', 48],
|
|
88
|
-
['100::', 64],
|
|
89
|
-
['2001::', 32], // Teredo embeds an IPv4 destination
|
|
90
|
-
['2001:2::', 48],
|
|
91
|
-
['2001:10::', 28],
|
|
92
|
-
['2001:20::', 28],
|
|
93
|
-
['2001:db8::', 32],
|
|
94
|
-
['2002::', 16], // 6to4 embeds an IPv4 address; fail closed
|
|
95
|
-
['3fff::', 20],
|
|
96
|
-
['fc00::', 7],
|
|
97
|
-
['fe80::', 10],
|
|
98
|
-
['fec0::', 10],
|
|
99
|
-
['ff00::', 8],
|
|
100
|
-
// Deliberately no `::ffff:0:0/96` rule: Node checks an IPv4 address against
|
|
101
|
-
// IPv6 rules through its own IPv4-mapped form, so that entry would classify
|
|
102
|
-
// every address on the internet as private. `mappedIpv4` handles the mapped
|
|
103
|
-
// spellings instead.
|
|
104
|
-
] as const) {
|
|
105
|
-
NON_PUBLIC.addSubnet(subnet, prefix, 'ipv6')
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* The IPv4 address an IPv4-mapped IPv6 address carries, in either spelling:
|
|
110
|
-
* `::ffff:127.0.0.1` and `::ffff:7f00:1` are the same address, and only the
|
|
111
|
-
* first would survive being classified as IPv6.
|
|
112
|
-
*/
|
|
113
|
-
function mappedIpv4(address: string): string | null {
|
|
114
|
-
const lower = address.toLowerCase()
|
|
115
|
-
if (!lower.startsWith('::ffff:')) return null
|
|
116
|
-
const rest = lower.slice('::ffff:'.length)
|
|
117
|
-
if (isIP(rest) === 4) return rest
|
|
118
|
-
const hex = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(rest)
|
|
119
|
-
if (hex === null) return null
|
|
120
|
-
const high = Number.parseInt(hex[1]!, 16)
|
|
121
|
-
const low = Number.parseInt(hex[2]!, 16)
|
|
122
|
-
return `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** Whether one IP literal is publicly routable. */
|
|
126
|
-
export function isPublicAddress(address: string): boolean {
|
|
127
|
-
const candidate = mappedIpv4(address) ?? address
|
|
128
|
-
const family = isIP(candidate)
|
|
129
|
-
if (family === 0) return false
|
|
130
|
-
// Fail closed outside the currently allocated global-unicast block. This
|
|
131
|
-
// catches reserved IPv6 space without depending on an inevitably stale
|
|
132
|
-
// enumeration; explicit exclusions below then carve special-use prefixes
|
|
133
|
-
// out of global unicast itself.
|
|
134
|
-
if (family === 6 && !PUBLIC_IPV6.check(candidate, 'ipv6')) return false
|
|
135
|
-
return !NON_PUBLIC.check(candidate, family === 6 ? 'ipv6' : 'ipv4')
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** A URL hostname as an address-or-name: IPv6 hosts arrive bracketed. */
|
|
139
|
-
function hostnameAddress(hostname: string): string {
|
|
140
|
-
return hostname.startsWith('[') && hostname.endsWith(']')
|
|
141
|
-
? hostname.slice(1, -1)
|
|
142
|
-
: hostname
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export function isLoopbackHostname(hostname: string): boolean {
|
|
146
|
-
const host = hostnameAddress(hostname).toLowerCase()
|
|
147
|
-
if (host === 'localhost' || host.endsWith('.localhost')) return true
|
|
148
|
-
const family = isIP(host)
|
|
149
|
-
if (family === 4) return host.startsWith('127.')
|
|
150
|
-
if (family === 6) return host === '::1' || /^::ffff:127\./.test(host)
|
|
151
|
-
return false
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
export interface MediaUrlPolicy {
|
|
155
|
-
/**
|
|
156
|
-
* Extra origins remote media may use beyond public HTTPS — the self-host
|
|
157
|
-
* and intranet escape hatch. Entries are normalized origins from
|
|
158
|
-
* User-owned configuration (`media_origins`); a repository cannot supply
|
|
159
|
-
* them.
|
|
160
|
-
*/
|
|
161
|
-
allowOrigins: readonly string[]
|
|
162
|
-
/** Test seam; production resolves with the system resolver. */
|
|
163
|
-
lookup?: (hostname: string) => Promise<string[]>
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
type MediaUrlApproval = { ok: true; addresses: readonly string[] }
|
|
167
|
-
|
|
168
|
-
async function resolveAddresses(
|
|
169
|
-
hostname: string,
|
|
170
|
-
policy: MediaUrlPolicy,
|
|
171
|
-
): Promise<string[] | null> {
|
|
172
|
-
if (policy.lookup !== undefined) {
|
|
173
|
-
try {
|
|
174
|
-
return await policy.lookup(hostname)
|
|
175
|
-
} catch {
|
|
176
|
-
return null
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
try {
|
|
180
|
-
const results = await dnsLookup(hostname, { all: true, verbatim: true })
|
|
181
|
-
return results.map((entry) => entry.address)
|
|
182
|
-
} catch {
|
|
183
|
-
return null
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Whether one URL is a destination remote media may be fetched from.
|
|
189
|
-
*
|
|
190
|
-
* Default policy: `https` to a host every resolved address of which is
|
|
191
|
-
* publicly routable. A User-allowed origin skips the scheme and address
|
|
192
|
-
* checks — the User said that exact origin is theirs to trust — but nothing
|
|
193
|
-
* skips the userinfo refusal, and every redirect hop is checked again.
|
|
194
|
-
*
|
|
195
|
-
* The approved addresses come back with the verdict rather than being
|
|
196
|
-
* rediscovered later: `fetchMediaUrl` connects to one of exactly these, so a
|
|
197
|
-
* resolver that would answer differently on a second lookup cannot rebind the
|
|
198
|
-
* request onto a private address between the check and the connection.
|
|
199
|
-
*/
|
|
200
|
-
export async function checkMediaUrl(
|
|
201
|
-
target: URL,
|
|
202
|
-
policy: MediaUrlPolicy,
|
|
203
|
-
): Promise<MediaUrlApproval | UrlRefusal> {
|
|
204
|
-
if (target.username !== '' || target.password !== '') {
|
|
205
|
-
return { ok: false, reason: 'Image URLs with embedded credentials are never fetched.' }
|
|
206
|
-
}
|
|
207
|
-
if (target.protocol !== 'https:' && target.protocol !== 'http:') {
|
|
208
|
-
return { ok: false, reason: `Only http(s) image URLs are supported, not ${target.protocol.replace(/:$/, '')}.` }
|
|
209
|
-
}
|
|
210
|
-
const userAllowed = policy.allowOrigins.includes(target.origin)
|
|
211
|
-
const escapeHatch =
|
|
212
|
-
'Remote images use public HTTPS; for a self-hosted or intranet image server, allow its exact origin with `notifai config set media_origins <origin>`.'
|
|
213
|
-
if (!userAllowed && target.protocol !== 'https:') {
|
|
214
|
-
return { ok: false, reason: `${target.origin} is http. ${escapeHatch}` }
|
|
215
|
-
}
|
|
216
|
-
const literal = hostnameAddress(target.hostname)
|
|
217
|
-
if (isIP(literal) !== 0) {
|
|
218
|
-
return userAllowed || isPublicAddress(literal)
|
|
219
|
-
? { ok: true, addresses: [literal] }
|
|
220
|
-
: { ok: false, reason: `${target.hostname} is not a public address. ${escapeHatch}` }
|
|
221
|
-
}
|
|
222
|
-
const addresses = await resolveAddresses(target.hostname, policy)
|
|
223
|
-
if (addresses === null || addresses.length === 0) {
|
|
224
|
-
return { ok: false, reason: `Could not resolve ${target.hostname}.` }
|
|
225
|
-
}
|
|
226
|
-
if (addresses.some((address) => isIP(address) === 0)) {
|
|
227
|
-
return { ok: false, reason: `${target.hostname} resolved to an invalid address.` }
|
|
228
|
-
}
|
|
229
|
-
if (!userAllowed && !addresses.every((address) => isPublicAddress(address))) {
|
|
230
|
-
return { ok: false, reason: `${target.hostname} resolves to a non-public address. ${escapeHatch}` }
|
|
231
|
-
}
|
|
232
|
-
return { ok: true, addresses }
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])
|
|
236
|
-
|
|
237
|
-
function responseHeaders(response: IncomingMessage): Headers {
|
|
238
|
-
const headers = new Headers()
|
|
239
|
-
for (const [name, value] of Object.entries(response.headers)) {
|
|
240
|
-
if (Array.isArray(value)) {
|
|
241
|
-
for (const entry of value) headers.append(name, entry)
|
|
242
|
-
} else if (value !== undefined) {
|
|
243
|
-
headers.set(name, value)
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
return headers
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* Fetch through one already-vetted address while retaining the URL hostname
|
|
251
|
-
* for Host and TLS verification. This closes the DNS-rebinding gap between
|
|
252
|
-
* policy resolution and the connection: the request cannot resolve the name
|
|
253
|
-
* a second time to a private address.
|
|
254
|
-
*/
|
|
255
|
-
async function fetchPinned(target: URL, addresses: readonly string[]): Promise<Response> {
|
|
256
|
-
let lastError: unknown = new Error(`No address was available for ${target.hostname}.`)
|
|
257
|
-
for (const address of addresses) {
|
|
258
|
-
try {
|
|
259
|
-
return await new Promise<Response>((resolve, reject) => {
|
|
260
|
-
const family = isIP(address)
|
|
261
|
-
const request = (target.protocol === 'https:' ? httpsRequest : httpRequest)(
|
|
262
|
-
target,
|
|
263
|
-
{
|
|
264
|
-
method: 'GET',
|
|
265
|
-
headers: { 'accept-encoding': 'identity' },
|
|
266
|
-
signal: AbortSignal.timeout(MEDIA_FETCH_TIMEOUT_MS),
|
|
267
|
-
// Node asks for every address at once when it is racing address
|
|
268
|
-
// families, and for a single one otherwise. Answering in the wrong
|
|
269
|
-
// shape throws inside the connect path rather than failing the
|
|
270
|
-
// request, so both forms are answered explicitly.
|
|
271
|
-
lookup: (_hostname, options, callback) => {
|
|
272
|
-
if (options.all === true) {
|
|
273
|
-
callback(null, [{ address, family }])
|
|
274
|
-
return
|
|
275
|
-
}
|
|
276
|
-
callback(null, address, family)
|
|
277
|
-
},
|
|
278
|
-
},
|
|
279
|
-
(incoming) => {
|
|
280
|
-
const status = incoming.statusCode ?? 500
|
|
281
|
-
const hasBody = ![101, 204, 205, 304].includes(status)
|
|
282
|
-
const body = hasBody
|
|
283
|
-
? (Readable.toWeb(incoming) as ReadableStream<Uint8Array>)
|
|
284
|
-
: null
|
|
285
|
-
resolve(
|
|
286
|
-
new Response(body, {
|
|
287
|
-
status,
|
|
288
|
-
...(incoming.statusMessage === undefined
|
|
289
|
-
? {}
|
|
290
|
-
: { statusText: incoming.statusMessage }),
|
|
291
|
-
headers: responseHeaders(incoming),
|
|
292
|
-
}),
|
|
293
|
-
)
|
|
294
|
-
},
|
|
295
|
-
)
|
|
296
|
-
request.once('error', reject)
|
|
297
|
-
request.end()
|
|
298
|
-
})
|
|
299
|
-
} catch (error) {
|
|
300
|
-
lastError = error
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
throw lastError
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
/**
|
|
307
|
-
* Fetch a remote image under the media URL policy, following at most
|
|
308
|
-
* `MEDIA_REDIRECT_LIMIT` redirects and re-validating every hop — a public
|
|
309
|
-
* HTTPS URL redirecting into an intranet is the exact attack the per-hop
|
|
310
|
-
* check exists for.
|
|
311
|
-
*/
|
|
312
|
-
export async function fetchMediaUrl(
|
|
313
|
-
source: string,
|
|
314
|
-
policy: MediaUrlPolicy,
|
|
315
|
-
fetchImpl?: typeof fetch,
|
|
316
|
-
): Promise<{ ok: true; response: Response } | UrlRefusal> {
|
|
317
|
-
let target: URL
|
|
318
|
-
try {
|
|
319
|
-
target = new URL(source)
|
|
320
|
-
} catch {
|
|
321
|
-
return { ok: false, reason: `"${source}" is not a valid URL.` }
|
|
322
|
-
}
|
|
323
|
-
for (let hop = 0; ; hop += 1) {
|
|
324
|
-
const allowed = await checkMediaUrl(target, policy)
|
|
325
|
-
if (!allowed.ok) return allowed
|
|
326
|
-
const response =
|
|
327
|
-
fetchImpl === undefined
|
|
328
|
-
? await fetchPinned(target, allowed.addresses)
|
|
329
|
-
: await fetchImpl(target, { redirect: 'manual' })
|
|
330
|
-
if (!REDIRECT_STATUSES.has(response.status)) return { ok: true, response }
|
|
331
|
-
const location = response.headers.get('location')
|
|
332
|
-
await response.body?.cancel()
|
|
333
|
-
if (location === null) {
|
|
334
|
-
return { ok: false, reason: `${target.origin} redirected without a destination.` }
|
|
335
|
-
}
|
|
336
|
-
if (hop >= MEDIA_REDIRECT_LIMIT) {
|
|
337
|
-
return { ok: false, reason: `${source} redirected more than ${MEDIA_REDIRECT_LIMIT} times.` }
|
|
338
|
-
}
|
|
339
|
-
try {
|
|
340
|
-
target = new URL(location, target)
|
|
341
|
-
} catch {
|
|
342
|
-
return { ok: false, reason: `${target.origin} redirected to an invalid URL.` }
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/**
|
|
348
|
-
* Whether a server-supplied pairing approval URL may be shown and opened.
|
|
349
|
-
*
|
|
350
|
-
* The server chooses `approve_url`, so a compromised or misconfigured one
|
|
351
|
-
* must not be able to drive this machine's browser to an arbitrary
|
|
352
|
-
* destination. Allowed: the canonical dashboard, the exact origin the user
|
|
353
|
-
* pointed this pairing at, loopback for local development, and origins the
|
|
354
|
-
* User allowed (`approve_origins`) for a self-host whose dashboard lives on
|
|
355
|
-
* a different origin than its API.
|
|
356
|
-
*/
|
|
357
|
-
export function checkApproveUrl(
|
|
358
|
-
raw: string,
|
|
359
|
-
baseUrl: string,
|
|
360
|
-
allowOrigins: readonly string[],
|
|
361
|
-
): { ok: true } | UrlRefusal {
|
|
362
|
-
let target: URL
|
|
363
|
-
try {
|
|
364
|
-
target = new URL(raw)
|
|
365
|
-
} catch {
|
|
366
|
-
return { ok: false, reason: `the server sent an invalid approval URL.` }
|
|
367
|
-
}
|
|
368
|
-
if (target.protocol !== 'https:' && target.protocol !== 'http:') {
|
|
369
|
-
return {
|
|
370
|
-
ok: false,
|
|
371
|
-
reason: `the server sent a ${target.protocol.replace(/:$/, '')} approval URL; only http(s) is ever opened.`,
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
if (target.username !== '' || target.password !== '') {
|
|
375
|
-
return { ok: false, reason: 'the server sent an approval URL with embedded credentials.' }
|
|
376
|
-
}
|
|
377
|
-
if (target.origin === CANONICAL_DASHBOARD_ORIGIN) return { ok: true }
|
|
378
|
-
if (isLoopbackHostname(target.hostname)) return { ok: true }
|
|
379
|
-
let pairingOrigin: string | null = null
|
|
380
|
-
try {
|
|
381
|
-
pairingOrigin = new URL(baseUrl).origin
|
|
382
|
-
} catch {
|
|
383
|
-
pairingOrigin = null
|
|
384
|
-
}
|
|
385
|
-
if (allowOrigins.includes(target.origin)) return { ok: true }
|
|
386
|
-
if (
|
|
387
|
-
pairingOrigin !== null &&
|
|
388
|
-
target.origin === pairingOrigin &&
|
|
389
|
-
target.protocol === 'https:'
|
|
390
|
-
) {
|
|
391
|
-
return { ok: true }
|
|
392
|
-
}
|
|
393
|
-
return {
|
|
394
|
-
ok: false,
|
|
395
|
-
reason:
|
|
396
|
-
`the server sent an approval URL on ${target.origin}, which is not the Notifai dashboard, ` +
|
|
397
|
-
'the origin being paired with, loopback, or an origin in `approve_origins`.',
|
|
398
|
-
}
|
|
399
|
-
}
|