@raidiant/notifai 0.3.0 → 0.5.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/README.md +19 -4
- package/dist/atomic-file.d.ts +4 -0
- package/dist/atomic-file.js +60 -10
- package/dist/atomic-file.js.map +1 -1
- package/dist/client.d.ts +25 -2
- package/dist/client.js +45 -1
- package/dist/client.js.map +1 -1
- package/dist/commands.d.ts +56 -7
- package/dist/commands.js +980 -318
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +46 -8
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +38 -3
- package/dist/config.js +61 -14
- package/dist/config.js.map +1 -1
- package/dist/file-lock.d.ts +26 -0
- package/dist/file-lock.js +268 -0
- package/dist/file-lock.js.map +1 -0
- package/dist/harnesses.d.ts +28 -0
- package/dist/harnesses.js +40 -0
- package/dist/harnesses.js.map +1 -0
- package/dist/hook-adapter.d.ts +27 -0
- package/dist/hook-adapter.js +188 -0
- package/dist/hook-adapter.js.map +1 -0
- package/dist/hook-input.d.ts +10 -0
- package/dist/hook-input.js +64 -0
- package/dist/hook-input.js.map +1 -0
- package/dist/hooks.d.ts +66 -10
- package/dist/hooks.js +751 -276
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +40 -16
- package/dist/install-hooks.js +217 -73
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +3 -3
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +142 -0
- package/dist/logging.js +533 -0
- package/dist/logging.js.map +1 -0
- package/dist/main.js +97 -36
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.d.ts +7 -7
- package/dist/opencode-plugin.js +12 -13
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/ui/config-view.js +3 -0
- package/dist/ui/config-view.js.map +1 -1
- package/dist/ui/theme.d.ts +1 -1
- package/package.json +3 -2
- package/src/atomic-file.ts +86 -7
- package/src/claude-wake.test.ts +320 -0
- package/src/claude-wake.ts +489 -0
- package/src/client.test.ts +12 -0
- package/src/client.ts +72 -4
- package/src/codex-wake.test.ts +253 -0
- package/src/codex-wake.ts +288 -0
- package/src/commands.test.ts +1261 -77
- package/src/commands.ts +1482 -375
- package/src/config-schema.test.ts +3 -3
- package/src/config-schema.ts +57 -44
- package/src/config.test.ts +22 -16
- package/src/config.ts +81 -66
- package/src/file-lock-process-worker.test.ts +64 -0
- package/src/file-lock.test.ts +279 -0
- package/src/file-lock.ts +318 -0
- package/src/harnesses.test.ts +34 -0
- package/src/harnesses.ts +57 -0
- package/src/hook-adapter.test.ts +187 -0
- package/src/hook-adapter.ts +212 -0
- package/src/hook-input.test.ts +39 -0
- package/src/hook-input.ts +72 -0
- package/src/hooks.test.ts +1985 -417
- package/src/hooks.ts +1338 -461
- package/src/install-hooks.test.ts +131 -41
- package/src/install-hooks.ts +284 -84
- package/src/interactive.ts +3 -8
- package/src/logging-process-worker.test.ts +39 -0
- package/src/logging.test.ts +453 -0
- package/src/logging.ts +639 -0
- package/src/main.ts +113 -32
- package/src/opencode-plugin.ts +15 -16
- package/src/session-end-log-lock-worker.test.ts +35 -0
- package/src/skill.test.ts +34 -0
- package/src/ui/config-view.test.ts +9 -7
- package/src/ui/config-view.ts +3 -0
- package/src/idle.test.ts +0 -32
- package/src/idle.ts +0 -41
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
statSync,
|
|
9
|
+
symlinkSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from 'node:fs'
|
|
12
|
+
import os from 'node:os'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { describe, expect, it } from 'vitest'
|
|
15
|
+
import {
|
|
16
|
+
hookAdapterPath,
|
|
17
|
+
inspectHookAdapter,
|
|
18
|
+
installHookAdapter,
|
|
19
|
+
} from './hook-adapter.js'
|
|
20
|
+
import { buildHookConfig, codexHookIdentityHash, type InstalledHandler } from './install-hooks.js'
|
|
21
|
+
|
|
22
|
+
function isolated(): { root: string; homeDir: string } {
|
|
23
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-hook-adapter-'))
|
|
24
|
+
return { root, homeDir: path.join(root, 'home') }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('stable hook adapter', () => {
|
|
28
|
+
it('executes paths with spaces, quotes, and shell syntax without evaluating them', () => {
|
|
29
|
+
const { root, homeDir } = isolated()
|
|
30
|
+
const odd = path.join(root, "odd ' $(touch should-not-run)")
|
|
31
|
+
mkdirSync(odd, { recursive: true })
|
|
32
|
+
const script = path.join(odd, 'target script.js')
|
|
33
|
+
writeFileSync(script, 'process.stdout.write(JSON.stringify(process.argv.slice(2)))\n')
|
|
34
|
+
|
|
35
|
+
const installed = installHookAdapter({ execPath: process.execPath, scriptPath: script }, homeDir)
|
|
36
|
+
const run = spawnSync(installed.path, ['hook', 'stop', '--owner', 'notifai'], {
|
|
37
|
+
encoding: 'utf8',
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
expect(run.status).toBe(0)
|
|
41
|
+
expect(JSON.parse(run.stdout)).toEqual(['hook', 'stop', '--owner', 'notifai'])
|
|
42
|
+
expect(statSync(installed.path).mode & 0o777).toBe(0o700)
|
|
43
|
+
expect(inspectHookAdapter(homeDir).problems).toEqual([])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('retargets Node and CLI paths without changing any harness definition or Codex hash', () => {
|
|
47
|
+
const { root, homeDir } = isolated()
|
|
48
|
+
const first = path.join(root, 'first.js')
|
|
49
|
+
const second = path.join(root, 'second.js')
|
|
50
|
+
writeFileSync(first, '')
|
|
51
|
+
writeFileSync(second, '')
|
|
52
|
+
const adapter = installHookAdapter({ execPath: process.execPath, scriptPath: first }, homeDir)
|
|
53
|
+
const before = buildHookConfig({
|
|
54
|
+
adapterPath: adapter.path,
|
|
55
|
+
harness: 'codex',
|
|
56
|
+
})
|
|
57
|
+
const beforeSource = readFileSync(adapter.path, 'utf8')
|
|
58
|
+
|
|
59
|
+
installHookAdapter({ execPath: process.execPath, scriptPath: second }, homeDir)
|
|
60
|
+
const after = buildHookConfig({
|
|
61
|
+
adapterPath: hookAdapterPath(homeDir),
|
|
62
|
+
harness: 'codex',
|
|
63
|
+
})
|
|
64
|
+
const afterSource = readFileSync(adapter.path, 'utf8')
|
|
65
|
+
const stop = (config: typeof before): InstalledHandler => {
|
|
66
|
+
const handler = config['Stop']![0]!.hooks[0]!
|
|
67
|
+
return {
|
|
68
|
+
event: 'Stop',
|
|
69
|
+
groupIndex: 0,
|
|
70
|
+
handlerIndex: 0,
|
|
71
|
+
command: handler.command,
|
|
72
|
+
timeout: handler.timeout,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
expect(after).toEqual(before)
|
|
77
|
+
expect(afterSource).not.toBe(beforeSource)
|
|
78
|
+
expect(codexHookIdentityHash(stop(after))).toBe(codexHookIdentityHash(stop(before)))
|
|
79
|
+
expect(inspectHookAdapter(homeDir).target?.scriptPath).toBe(second)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('uses a fixed home-relative pathname with no XDG input surface', () => {
|
|
83
|
+
const { homeDir } = isolated()
|
|
84
|
+
expect(hookAdapterPath(homeDir)).toBe(
|
|
85
|
+
path.join(homeDir, '.notifai', 'bin', 'hook-adapter'),
|
|
86
|
+
)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('uses the OS account home even when HOME is overridden', () => {
|
|
90
|
+
const original = process.env['HOME']
|
|
91
|
+
process.env['HOME'] = '/tmp/attacker-selected-home'
|
|
92
|
+
try {
|
|
93
|
+
expect(hookAdapterPath()).toBe(
|
|
94
|
+
path.join(os.userInfo().homedir, '.notifai', 'bin', 'hook-adapter'),
|
|
95
|
+
)
|
|
96
|
+
expect(hookAdapterPath()).not.toContain('attacker-selected-home')
|
|
97
|
+
} finally {
|
|
98
|
+
if (original === undefined) delete process.env['HOME']
|
|
99
|
+
else process.env['HOME'] = original
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('repairs permissive adapter permissions without changing its path', () => {
|
|
104
|
+
const { root, homeDir } = isolated()
|
|
105
|
+
const script = path.join(root, 'target.js')
|
|
106
|
+
writeFileSync(script, '')
|
|
107
|
+
const first = installHookAdapter({ execPath: process.execPath, scriptPath: script }, homeDir)
|
|
108
|
+
chmodSync(first.path, 0o755)
|
|
109
|
+
|
|
110
|
+
const repaired = installHookAdapter({ execPath: process.execPath, scriptPath: script }, homeDir)
|
|
111
|
+
|
|
112
|
+
expect(repaired).toEqual({ path: first.path, changed: true })
|
|
113
|
+
expect(statSync(first.path).mode & 0o777).toBe(0o700)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('refuses a symlinked adapter instead of reading or replacing its target', () => {
|
|
117
|
+
const { root, homeDir } = isolated()
|
|
118
|
+
const target = path.join(root, 'foreign')
|
|
119
|
+
const adapter = hookAdapterPath(homeDir)
|
|
120
|
+
mkdirSync(path.dirname(adapter), { recursive: true })
|
|
121
|
+
writeFileSync(target, 'leave me')
|
|
122
|
+
symlinkSync(target, adapter)
|
|
123
|
+
|
|
124
|
+
expect(() =>
|
|
125
|
+
installHookAdapter({ execPath: process.execPath, scriptPath: target }, homeDir),
|
|
126
|
+
).toThrow(/not a regular file/)
|
|
127
|
+
expect(readFileSync(target, 'utf8')).toBe('leave me')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('refuses a symlinked Notifai-owned parent directory', () => {
|
|
131
|
+
const { root, homeDir } = isolated()
|
|
132
|
+
const foreign = path.join(root, 'foreign-dir')
|
|
133
|
+
const script = path.join(root, 'target.js')
|
|
134
|
+
mkdirSync(homeDir, { recursive: true })
|
|
135
|
+
mkdirSync(foreign)
|
|
136
|
+
writeFileSync(script, '')
|
|
137
|
+
symlinkSync(foreign, path.join(homeDir, '.notifai'))
|
|
138
|
+
|
|
139
|
+
expect(() =>
|
|
140
|
+
installHookAdapter({ execPath: process.execPath, scriptPath: script }, homeDir),
|
|
141
|
+
).toThrow(/not a regular directory/)
|
|
142
|
+
expect(readFileSync(script, 'utf8')).toBe('')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('diagnoses a stale registered runtime and CLI without executing either', () => {
|
|
146
|
+
const { root, homeDir } = isolated()
|
|
147
|
+
const missingRuntime = path.join(root, 'removed-node')
|
|
148
|
+
const missingCli = path.join(root, 'removed-cli.js')
|
|
149
|
+
writeFileSync(missingRuntime, '#!/bin/sh\n')
|
|
150
|
+
chmodSync(missingRuntime, 0o700)
|
|
151
|
+
writeFileSync(missingCli, '')
|
|
152
|
+
installHookAdapter({ execPath: missingRuntime, scriptPath: missingCli }, homeDir)
|
|
153
|
+
rmSync(missingRuntime)
|
|
154
|
+
rmSync(missingCli)
|
|
155
|
+
|
|
156
|
+
expect(inspectHookAdapter(homeDir).problems).toEqual([
|
|
157
|
+
`registered runtime ${missingRuntime} is missing`,
|
|
158
|
+
`registered CLI ${missingCli} is missing`,
|
|
159
|
+
])
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('fails closed instead of executing an unregistered PATH target after the registered target vanished', () => {
|
|
163
|
+
const { root, homeDir } = isolated()
|
|
164
|
+
const bin = path.join(root, 'new-bin')
|
|
165
|
+
const replacement = path.join(bin, 'notifai')
|
|
166
|
+
mkdirSync(bin, { recursive: true })
|
|
167
|
+
writeFileSync(replacement, '#!/bin/sh\nprintf "%s" "$*"\n')
|
|
168
|
+
chmodSync(replacement, 0o700)
|
|
169
|
+
const oldNode = path.join(root, 'old-node')
|
|
170
|
+
const oldCli = path.join(root, 'old-cli.js')
|
|
171
|
+
writeFileSync(oldNode, '#!/bin/sh\n')
|
|
172
|
+
chmodSync(oldNode, 0o700)
|
|
173
|
+
writeFileSync(oldCli, '')
|
|
174
|
+
const installed = installHookAdapter({ execPath: oldNode, scriptPath: oldCli }, homeDir)
|
|
175
|
+
rmSync(oldNode)
|
|
176
|
+
rmSync(oldCli)
|
|
177
|
+
|
|
178
|
+
const run = spawnSync(installed.path, ['hook', 'stop'], {
|
|
179
|
+
encoding: 'utf8',
|
|
180
|
+
env: { PATH: bin },
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
expect(run.status).toBe(127)
|
|
184
|
+
expect(run.stdout).toBe('')
|
|
185
|
+
expect(run.stderr).toMatch(/target is stale/)
|
|
186
|
+
})
|
|
187
|
+
})
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
lstatSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
statSync,
|
|
8
|
+
} from 'node:fs'
|
|
9
|
+
import os from 'node:os'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import { atomicWriteFileSync } from './atomic-file.js'
|
|
12
|
+
import { withTargetFileLock } from './file-lock.js'
|
|
13
|
+
|
|
14
|
+
const ADAPTER_MARKER = '# notifai managed hook adapter'
|
|
15
|
+
const ADAPTER_VERSION = 1
|
|
16
|
+
|
|
17
|
+
export interface HookAdapterTarget {
|
|
18
|
+
execPath: string
|
|
19
|
+
scriptPath: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface HookAdapterInspection {
|
|
23
|
+
path: string
|
|
24
|
+
target: HookAdapterTarget | null
|
|
25
|
+
problems: string[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* One user-level identity for every harness and every project/global install.
|
|
30
|
+
*
|
|
31
|
+
* The pathname is deliberately outside XDG configuration/state routing. XDG
|
|
32
|
+
* overrides routinely differ between shells, package managers, and harness
|
|
33
|
+
* processes; allowing one to select this path would put mutable environment
|
|
34
|
+
* back into every trusted definition. Only the user's home boundary selects
|
|
35
|
+
* it; CLI, Node, package-manager, checkout, and preference paths stay behind
|
|
36
|
+
* it and may change without changing hook identity.
|
|
37
|
+
*/
|
|
38
|
+
export function hookAdapterPath(homeDir: string = os.userInfo().homedir): string {
|
|
39
|
+
return path.join(homeDir, '.notifai', 'bin', 'hook-adapter')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Install or repair the stable adapter and atomically retarget its implementation. */
|
|
43
|
+
export function installHookAdapter(
|
|
44
|
+
target: HookAdapterTarget,
|
|
45
|
+
homeDir?: string,
|
|
46
|
+
): { path: string; changed: boolean } {
|
|
47
|
+
assertUsableTarget(target)
|
|
48
|
+
const file = hookAdapterPath(homeDir)
|
|
49
|
+
ensureManagedDirectories(homeDir ?? os.userInfo().homedir)
|
|
50
|
+
const source = hookAdapterSource(target)
|
|
51
|
+
return withTargetFileLock(file, () => {
|
|
52
|
+
const existing = existsSync(file) ? readSafeManagedFile(file) : null
|
|
53
|
+
const changed =
|
|
54
|
+
existing === null || existing.contents !== source || (existing.mode & 0o777) !== 0o700
|
|
55
|
+
if (changed) {
|
|
56
|
+
atomicWriteFileSync(file, source, {
|
|
57
|
+
mode: 0o700,
|
|
58
|
+
preserveMode: false,
|
|
59
|
+
requireCurrentUserOwner: true,
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
return { path: file, changed }
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function assertUsableTarget(target: HookAdapterTarget): void {
|
|
67
|
+
let exec
|
|
68
|
+
try {
|
|
69
|
+
exec = statSync(target.execPath)
|
|
70
|
+
} catch {
|
|
71
|
+
throw new Error(`Hook adapter runtime ${target.execPath} does not exist.`)
|
|
72
|
+
}
|
|
73
|
+
if (!exec.isFile() || (exec.mode & 0o111) === 0) {
|
|
74
|
+
throw new Error(`Hook adapter runtime ${target.execPath} is not executable.`)
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
if (!statSync(target.scriptPath).isFile()) {
|
|
78
|
+
throw new Error(`Hook adapter CLI ${target.scriptPath} is not a regular file.`)
|
|
79
|
+
}
|
|
80
|
+
} catch (err) {
|
|
81
|
+
if (err instanceof Error && err.message.startsWith('Hook adapter CLI ')) throw err
|
|
82
|
+
throw new Error(`Hook adapter CLI ${target.scriptPath} does not exist.`)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Inspect the shared adapter without executing either it or its registered CLI. */
|
|
87
|
+
export function inspectHookAdapter(
|
|
88
|
+
homeDir?: string,
|
|
89
|
+
): HookAdapterInspection {
|
|
90
|
+
const file = hookAdapterPath(homeDir)
|
|
91
|
+
if (!existsSync(file)) {
|
|
92
|
+
return { path: file, target: null, problems: [`${file} is missing; rerun \`notifai hooks install\``] }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let stat
|
|
96
|
+
try {
|
|
97
|
+
stat = lstatSync(file)
|
|
98
|
+
} catch (err) {
|
|
99
|
+
return { path: file, target: null, problems: [String(err)] }
|
|
100
|
+
}
|
|
101
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
102
|
+
return {
|
|
103
|
+
path: file,
|
|
104
|
+
target: null,
|
|
105
|
+
problems: [`${file} is not a regular file; refusing the adapter`],
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const problems: string[] = []
|
|
110
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
|
|
111
|
+
if (uid !== undefined && stat.uid !== uid) {
|
|
112
|
+
problems.push(`${file} is owned by uid ${stat.uid}, not the current user`)
|
|
113
|
+
}
|
|
114
|
+
if ((stat.mode & 0o077) !== 0 || (stat.mode & 0o700) !== 0o700) {
|
|
115
|
+
problems.push(`${file} permissions are ${(stat.mode & 0o777).toString(8)}, expected 700`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const contents = readFileSync(file, 'utf8')
|
|
119
|
+
if (!contents.startsWith(`#!/bin/sh\n${ADAPTER_MARKER}\n`)) {
|
|
120
|
+
problems.push(`${file} has no managed adapter marker`)
|
|
121
|
+
}
|
|
122
|
+
const version = Number(/^# adapter-version: (\d+)$/m.exec(contents)?.[1] ?? Number.NaN)
|
|
123
|
+
if (version !== ADAPTER_VERSION) problems.push(`${file} uses obsolete adapter version ${version}`)
|
|
124
|
+
const target = parseTarget(contents)
|
|
125
|
+
if (target === null) {
|
|
126
|
+
problems.push(`${file} has malformed target metadata`)
|
|
127
|
+
} else {
|
|
128
|
+
try {
|
|
129
|
+
const exec = statSync(target.execPath)
|
|
130
|
+
if (!exec.isFile() || (exec.mode & 0o111) === 0) {
|
|
131
|
+
problems.push(`registered runtime ${target.execPath} is not executable`)
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
problems.push(`registered runtime ${target.execPath} is missing`)
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
if (!statSync(target.scriptPath).isFile()) {
|
|
138
|
+
problems.push(`registered CLI ${target.scriptPath} is not a regular file`)
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
problems.push(`registered CLI ${target.scriptPath} is missing`)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { path: file, target, problems }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function hookAdapterSource(target: HookAdapterTarget): string {
|
|
148
|
+
return `#!/bin/sh
|
|
149
|
+
${ADAPTER_MARKER}
|
|
150
|
+
# adapter-version: ${ADAPTER_VERSION}
|
|
151
|
+
# target-exec-json: ${JSON.stringify(target.execPath)}
|
|
152
|
+
# target-script-json: ${JSON.stringify(target.scriptPath)}
|
|
153
|
+
set -eu
|
|
154
|
+
|
|
155
|
+
registered_exec=${quote(target.execPath)}
|
|
156
|
+
registered_script=${quote(target.scriptPath)}
|
|
157
|
+
|
|
158
|
+
if [ -x "$registered_exec" ] && [ -f "$registered_script" ]; then
|
|
159
|
+
exec "$registered_exec" "$registered_script" "$@"
|
|
160
|
+
fi
|
|
161
|
+
|
|
162
|
+
printf '%s\n' 'Notifai hook adapter target is stale; run notifai hooks install.' >&2
|
|
163
|
+
exit 127
|
|
164
|
+
`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function parseTarget(contents: string): HookAdapterTarget | null {
|
|
168
|
+
const exec = /^# target-exec-json: (.+)$/m.exec(contents)?.[1]
|
|
169
|
+
const script = /^# target-script-json: (.+)$/m.exec(contents)?.[1]
|
|
170
|
+
if (exec === undefined || script === undefined) return null
|
|
171
|
+
try {
|
|
172
|
+
const execPath: unknown = JSON.parse(exec)
|
|
173
|
+
const scriptPath: unknown = JSON.parse(script)
|
|
174
|
+
return typeof execPath === 'string' && typeof scriptPath === 'string'
|
|
175
|
+
? { execPath, scriptPath }
|
|
176
|
+
: null
|
|
177
|
+
} catch {
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function quote(value: string): string {
|
|
183
|
+
return `'${value.replaceAll("'", `'\\''`)}'`
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function ensureManagedDirectories(homeDir: string): void {
|
|
187
|
+
if (!existsSync(homeDir)) mkdirSync(homeDir, { recursive: true, mode: 0o700 })
|
|
188
|
+
for (const dir of [path.join(homeDir, '.notifai'), path.join(homeDir, '.notifai', 'bin')]) {
|
|
189
|
+
if (!existsSync(dir)) mkdirSync(dir, { mode: 0o700 })
|
|
190
|
+
const stat = lstatSync(dir)
|
|
191
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
192
|
+
throw new Error(`${dir} is not a regular directory; refusing to install the hook adapter.`)
|
|
193
|
+
}
|
|
194
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
|
|
195
|
+
if (uid !== undefined && stat.uid !== uid) {
|
|
196
|
+
throw new Error(`${dir} is owned by uid ${stat.uid}, not the current user.`)
|
|
197
|
+
}
|
|
198
|
+
chmodSync(dir, 0o700)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function readSafeManagedFile(file: string): { contents: string; mode: number } {
|
|
203
|
+
const stat = lstatSync(file)
|
|
204
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
205
|
+
throw new Error(`${file} is not a regular file; refusing to replace it.`)
|
|
206
|
+
}
|
|
207
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
|
|
208
|
+
if (uid !== undefined && stat.uid !== uid) {
|
|
209
|
+
throw new Error(`${file} is owned by uid ${stat.uid}, not the current user.`)
|
|
210
|
+
}
|
|
211
|
+
return { contents: readFileSync(file, 'utf8'), mode: stat.mode }
|
|
212
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { PassThrough } from 'node:stream'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { readStdinWithTimeout } from './hook-input.js'
|
|
4
|
+
|
|
5
|
+
describe('hook input reader', () => {
|
|
6
|
+
it('rejects a timeout when the harness pipe delivered no bytes', async () => {
|
|
7
|
+
const input = new PassThrough()
|
|
8
|
+
await expect(readStdinWithTimeout(input, 5)).rejects.toThrow(/timed out waiting 5ms/)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('rejects a stream failure when no useful input exists', async () => {
|
|
12
|
+
const input = new PassThrough()
|
|
13
|
+
const reading = readStdinWithTimeout(input, 1_000)
|
|
14
|
+
input.destroy(new Error('stdin disappeared'))
|
|
15
|
+
await expect(reading).rejects.toThrow('stdin disappeared')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('returns partial bytes on timeout so the hook can diagnose truncation', async () => {
|
|
19
|
+
const input = new PassThrough()
|
|
20
|
+
const reading = readStdinWithTimeout(input, 5)
|
|
21
|
+
input.write('{"session_id":')
|
|
22
|
+
await expect(reading).resolves.toBe('{"session_id":')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('preserves complete buffered JSON when the stream fails after delivering it', async () => {
|
|
26
|
+
const input = new PassThrough()
|
|
27
|
+
const reading = readStdinWithTimeout(input, 1_000)
|
|
28
|
+
input.write('{"session_id":"kept"}')
|
|
29
|
+
input.destroy(new Error('late pipe failure'))
|
|
30
|
+
await expect(reading).resolves.toBe('{"session_id":"kept"}')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('marks an oversized payload as truncated instead of treating it as empty', async () => {
|
|
34
|
+
const input = new PassThrough()
|
|
35
|
+
const reading = readStdinWithTimeout(input, 1_000, 12)
|
|
36
|
+
input.end('{"session_id":"far-too-long"}')
|
|
37
|
+
await expect(reading).resolves.toMatch(/hook input truncated/)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Readable } from 'node:stream'
|
|
2
|
+
|
|
3
|
+
const TRUNCATED_INPUT_SUFFIX = '\n[notifai: hook input truncated]\n'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Read one harness-hook envelope without letting a stuck pipe hold the prompt.
|
|
7
|
+
*
|
|
8
|
+
* A timeout or stream error before any bytes arrive rejects, so the hook can
|
|
9
|
+
* record that its input was unavailable. Once bytes exist they are more useful
|
|
10
|
+
* than the transport failure: complete JSON can still run, while partial or
|
|
11
|
+
* capped JSON reaches the hook's malformed/truncated diagnostic.
|
|
12
|
+
*/
|
|
13
|
+
export function readStdinWithTimeout(
|
|
14
|
+
input: Readable = process.stdin,
|
|
15
|
+
timeoutMs = 2_000,
|
|
16
|
+
maxBytes = 1_000_000,
|
|
17
|
+
): Promise<string> {
|
|
18
|
+
if ((input as Readable & { isTTY?: boolean }).isTTY === true) return Promise.resolve('')
|
|
19
|
+
|
|
20
|
+
return new Promise<string>((resolve, reject) => {
|
|
21
|
+
const chunks: Buffer[] = []
|
|
22
|
+
let total = 0
|
|
23
|
+
let settled = false
|
|
24
|
+
|
|
25
|
+
const cleanup = (): void => {
|
|
26
|
+
clearTimeout(timer)
|
|
27
|
+
input.off('data', onData)
|
|
28
|
+
input.off('end', onEnd)
|
|
29
|
+
input.off('error', onError)
|
|
30
|
+
input.destroy()
|
|
31
|
+
}
|
|
32
|
+
const finishBuffered = (): void => {
|
|
33
|
+
if (settled) return
|
|
34
|
+
settled = true
|
|
35
|
+
cleanup()
|
|
36
|
+
resolve(Buffer.concat(chunks).toString('utf8'))
|
|
37
|
+
}
|
|
38
|
+
const failEmpty = (err: Error): void => {
|
|
39
|
+
if (settled) return
|
|
40
|
+
if (chunks.length > 0) return finishBuffered()
|
|
41
|
+
settled = true
|
|
42
|
+
cleanup()
|
|
43
|
+
reject(err)
|
|
44
|
+
}
|
|
45
|
+
const onData = (chunk: Buffer | string): void => {
|
|
46
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
47
|
+
const remaining = Math.max(0, maxBytes - total)
|
|
48
|
+
if (bytes.length <= remaining) {
|
|
49
|
+
chunks.push(bytes)
|
|
50
|
+
total += bytes.length
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
if (remaining > 0) {
|
|
54
|
+
chunks.push(bytes.subarray(0, remaining))
|
|
55
|
+
total += remaining
|
|
56
|
+
}
|
|
57
|
+
chunks.push(Buffer.from(TRUNCATED_INPUT_SUFFIX))
|
|
58
|
+
finishBuffered()
|
|
59
|
+
}
|
|
60
|
+
const onEnd = (): void => finishBuffered()
|
|
61
|
+
const onError = (err: Error): void => failEmpty(err)
|
|
62
|
+
|
|
63
|
+
const timer = setTimeout(
|
|
64
|
+
() => failEmpty(new Error(`timed out waiting ${timeoutMs}ms for hook input`)),
|
|
65
|
+
timeoutMs,
|
|
66
|
+
)
|
|
67
|
+
timer.unref?.()
|
|
68
|
+
input.on('data', onData)
|
|
69
|
+
input.once('end', onEnd)
|
|
70
|
+
input.once('error', onError)
|
|
71
|
+
})
|
|
72
|
+
}
|