@raidiant/notifai 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +20 -4
  2. package/dist/atomic-file.d.ts +4 -0
  3. package/dist/atomic-file.js +60 -10
  4. package/dist/atomic-file.js.map +1 -1
  5. package/dist/client.d.ts +25 -2
  6. package/dist/client.js +45 -1
  7. package/dist/client.js.map +1 -1
  8. package/dist/commands.d.ts +85 -6
  9. package/dist/commands.js +1133 -322
  10. package/dist/commands.js.map +1 -1
  11. package/dist/config-schema.d.ts +62 -0
  12. package/dist/config-schema.js +312 -0
  13. package/dist/config-schema.js.map +1 -0
  14. package/dist/config.d.ts +38 -3
  15. package/dist/config.js +61 -14
  16. package/dist/config.js.map +1 -1
  17. package/dist/file-lock.d.ts +26 -0
  18. package/dist/file-lock.js +268 -0
  19. package/dist/file-lock.js.map +1 -0
  20. package/dist/harnesses.d.ts +28 -0
  21. package/dist/harnesses.js +40 -0
  22. package/dist/harnesses.js.map +1 -0
  23. package/dist/hook-adapter.d.ts +27 -0
  24. package/dist/hook-adapter.js +188 -0
  25. package/dist/hook-adapter.js.map +1 -0
  26. package/dist/hook-input.d.ts +10 -0
  27. package/dist/hook-input.js +64 -0
  28. package/dist/hook-input.js.map +1 -0
  29. package/dist/hooks.d.ts +66 -10
  30. package/dist/hooks.js +751 -276
  31. package/dist/hooks.js.map +1 -1
  32. package/dist/install-hooks.d.ts +40 -16
  33. package/dist/install-hooks.js +217 -73
  34. package/dist/install-hooks.js.map +1 -1
  35. package/dist/interactive.d.ts +4 -0
  36. package/dist/interactive.js +604 -0
  37. package/dist/interactive.js.map +1 -0
  38. package/dist/logging.d.ts +142 -0
  39. package/dist/logging.js +533 -0
  40. package/dist/logging.js.map +1 -0
  41. package/dist/main.js +201 -51
  42. package/dist/main.js.map +1 -1
  43. package/dist/opencode-plugin.d.ts +7 -7
  44. package/dist/opencode-plugin.js +12 -13
  45. package/dist/opencode-plugin.js.map +1 -1
  46. package/dist/ui/banner.d.ts +22 -0
  47. package/dist/ui/banner.js +87 -0
  48. package/dist/ui/banner.js.map +1 -0
  49. package/dist/ui/config-view.d.ts +32 -0
  50. package/dist/ui/config-view.js +108 -0
  51. package/dist/ui/config-view.js.map +1 -0
  52. package/dist/ui/help.d.ts +46 -0
  53. package/dist/ui/help.js +105 -0
  54. package/dist/ui/help.js.map +1 -0
  55. package/dist/ui/theme.d.ts +108 -0
  56. package/dist/ui/theme.js +261 -0
  57. package/dist/ui/theme.js.map +1 -0
  58. package/package.json +11 -9
  59. package/src/atomic-file.ts +86 -7
  60. package/src/client.test.ts +12 -0
  61. package/src/client.ts +72 -4
  62. package/src/commands.test.ts +971 -44
  63. package/src/commands.ts +1323 -337
  64. package/src/config-schema.test.ts +152 -0
  65. package/src/config-schema.ts +363 -0
  66. package/src/config.test.ts +6 -5
  67. package/src/config.ts +85 -16
  68. package/src/file-lock-process-worker.test.ts +64 -0
  69. package/src/file-lock.test.ts +279 -0
  70. package/src/file-lock.ts +318 -0
  71. package/src/harnesses.test.ts +48 -0
  72. package/src/harnesses.ts +59 -0
  73. package/src/hook-adapter.test.ts +187 -0
  74. package/src/hook-adapter.ts +212 -0
  75. package/src/hook-input.test.ts +39 -0
  76. package/src/hook-input.ts +72 -0
  77. package/src/hooks.test.ts +1318 -240
  78. package/src/hooks.ts +1046 -296
  79. package/src/install-hooks.test.ts +90 -41
  80. package/src/install-hooks.ts +259 -74
  81. package/src/interactive.ts +692 -0
  82. package/src/logging-process-worker.test.ts +39 -0
  83. package/src/logging.test.ts +453 -0
  84. package/src/logging.ts +639 -0
  85. package/src/main.ts +220 -48
  86. package/src/opencode-plugin.ts +15 -16
  87. package/src/session-end-log-lock-worker.test.ts +35 -0
  88. package/src/skill.test.ts +11 -0
  89. package/src/ui/banner.ts +95 -0
  90. package/src/ui/config-view.test.ts +88 -0
  91. package/src/ui/config-view.ts +138 -0
  92. package/src/ui/help.ts +124 -0
  93. package/src/ui/theme.test.ts +92 -0
  94. package/src/ui/theme.ts +279 -0
@@ -0,0 +1,318 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import {
3
+ chmodSync,
4
+ closeSync,
5
+ fstatSync,
6
+ lstatSync,
7
+ mkdirSync,
8
+ openSync,
9
+ readdirSync,
10
+ renameSync,
11
+ rmdirSync,
12
+ unlinkSync,
13
+ } from 'node:fs'
14
+ import path from 'node:path'
15
+
16
+ /**
17
+ * Serialize a short cross-process filesystem transaction.
18
+ *
19
+ * Each contender publishes all of its ownership in one unique filename. A
20
+ * two-phase bakery lock then orders live contenders by ticket and process
21
+ * identity. Because paths are never reused, recovering an entry left by a dead
22
+ * process cannot remove a later owner's entry; because process liveness, rather
23
+ * than age, decides recovery, a delayed holder is never mistaken for a dead one.
24
+ *
25
+ * Acquisition is deliberately bounded because both harness hooks and logging
26
+ * must fail open rather than hold an agent turn indefinitely.
27
+ */
28
+ const FILE_LOCK_WAIT_MS = 1_000
29
+ const FILE_LOCK_POLL_MS = 5
30
+ const FILE_LOCK_TOKEN_BYTES = 12
31
+ const ENTRY_PATTERN = /^(choosing|ticket-(\d+))-(\d+)-([0-9a-f]+)$/
32
+ const lockSleep = new Int32Array(new SharedArrayBuffer(4))
33
+
34
+ export type FileLockObservation =
35
+ | { phase: 'choosing-published'; entry: string }
36
+ | { phase: 'stale-entry'; entry: string }
37
+ | { phase: 'waiting'; blockers: string[] }
38
+
39
+ interface FileLockOptions {
40
+ waitMs?: number
41
+ observe?: (observation: FileLockObservation) => void
42
+ /** Installer transactions report lock replacement after a successful action. */
43
+ strictRelease?: boolean
44
+ }
45
+
46
+ interface FileIdentity {
47
+ dev: number
48
+ ino: number
49
+ }
50
+
51
+ interface ChoosingEntry {
52
+ kind: 'choosing'
53
+ name: string
54
+ pid: number
55
+ token: string
56
+ }
57
+
58
+ interface TicketEntry {
59
+ kind: 'ticket'
60
+ name: string
61
+ pid: number
62
+ token: string
63
+ ticket: bigint
64
+ }
65
+
66
+ type LockEntry = ChoosingEntry | TicketEntry
67
+
68
+ function parseEntry(name: string): LockEntry | null {
69
+ const match = ENTRY_PATTERN.exec(name)
70
+ if (match === null) return null
71
+ const pid = Number(match[3])
72
+ if (!Number.isSafeInteger(pid) || pid <= 0) return null
73
+ const token = match[4]!
74
+ if (match[1] === 'choosing') return { kind: 'choosing', name, pid, token }
75
+ try {
76
+ const ticket = BigInt(match[2]!)
77
+ if (ticket <= 0n) return null
78
+ return { kind: 'ticket', name, pid, token, ticket }
79
+ } catch {
80
+ return null
81
+ }
82
+ }
83
+
84
+ function processIsAlive(pid: number): boolean {
85
+ if (pid === process.pid) return true
86
+ try {
87
+ process.kill(pid, 0)
88
+ return true
89
+ } catch (err) {
90
+ const code = (err as NodeJS.ErrnoException).code
91
+ if (code === 'ESRCH') return false
92
+ // EPERM means the process exists but is owned by somebody else. Unknown
93
+ // failures are also treated as live: losing recovery is safer than allowing
94
+ // two holders into the transaction.
95
+ return true
96
+ }
97
+ }
98
+
99
+ function currentUid(): number | undefined {
100
+ return typeof process.getuid === 'function' ? process.getuid() : undefined
101
+ }
102
+
103
+ function assertOwnedDirectory(directory: string): FileIdentity {
104
+ const stat = lstatSync(directory)
105
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
106
+ throw new Error(`${directory} is not a regular directory; refusing to use it as a file lock.`)
107
+ }
108
+ const uid = currentUid()
109
+ if (uid !== undefined && stat.uid !== uid) {
110
+ throw new Error(`${directory} is owned by uid ${stat.uid}, not the current user.`)
111
+ }
112
+ return { dev: stat.dev, ino: stat.ino }
113
+ }
114
+
115
+ function assertSameDirectory(directory: string, expected: FileIdentity): void {
116
+ const current = lstatSync(directory)
117
+ if (
118
+ current.isSymbolicLink() ||
119
+ !current.isDirectory() ||
120
+ current.dev !== expected.dev ||
121
+ current.ino !== expected.ino
122
+ ) {
123
+ throw new Error(`${directory} changed while the file lock was active.`)
124
+ }
125
+ }
126
+
127
+ function sameIdentity(file: string, expected: FileIdentity): boolean {
128
+ try {
129
+ const current = lstatSync(file)
130
+ return current.dev === expected.dev && current.ino === expected.ino
131
+ } catch {
132
+ return false
133
+ }
134
+ }
135
+
136
+ /** Live protocol entries, pruning only unique paths whose process is gone. */
137
+ function liveEntries(
138
+ directory: string,
139
+ directoryIdentity: FileIdentity,
140
+ observe: FileLockOptions['observe'],
141
+ ): LockEntry[] {
142
+ assertSameDirectory(directory, directoryIdentity)
143
+ const live: LockEntry[] = []
144
+ for (const name of readdirSync(directory)) {
145
+ const entry = parseEntry(name)
146
+ if (entry === null) continue
147
+ const entryPath = path.join(directory, name)
148
+ let stat
149
+ try {
150
+ stat = lstatSync(entryPath)
151
+ } catch (err) {
152
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') continue
153
+ throw err
154
+ }
155
+ const uid = currentUid()
156
+ if (stat.isSymbolicLink() || !stat.isFile() || (uid !== undefined && stat.uid !== uid)) {
157
+ throw new Error(`${entryPath} is not a current-user-owned regular file-lock entry.`)
158
+ }
159
+ if (processIsAlive(entry.pid)) {
160
+ live.push(entry)
161
+ continue
162
+ }
163
+ observe?.({ phase: 'stale-entry', entry: name })
164
+ try {
165
+ // Recheck the unique dead owner's inode so delayed recovery cannot reap
166
+ // a replacement published at the same path by external interference.
167
+ const current = lstatSync(entryPath)
168
+ if (current.dev === stat.dev && current.ino === stat.ino) unlinkSync(entryPath)
169
+ } catch {
170
+ // Another contender already recovered this exact dead owner's unique path.
171
+ }
172
+ }
173
+ return live
174
+ }
175
+
176
+ function publishEmpty(file: string): FileIdentity {
177
+ const handle = openSync(file, 'wx', 0o600)
178
+ try {
179
+ const stat = fstatSync(handle)
180
+ return { dev: stat.dev, ino: stat.ino }
181
+ } finally {
182
+ closeSync(handle)
183
+ }
184
+ }
185
+
186
+ function publishChoosing(
187
+ directory: string,
188
+ name: string,
189
+ deadline: number,
190
+ observe: FileLockOptions['observe'],
191
+ ): { file: string; fileIdentity: FileIdentity; directoryIdentity: FileIdentity } {
192
+ const file = path.join(directory, name)
193
+ for (;;) {
194
+ try {
195
+ mkdirSync(directory, { recursive: true, mode: 0o700 })
196
+ const directoryIdentity = assertOwnedDirectory(directory)
197
+ chmodSync(directory, 0o700)
198
+ // Recover before registration so a delayed stale cleanup can only name an
199
+ // old owner's unique path, never this contender's future one.
200
+ liveEntries(directory, directoryIdentity, observe)
201
+ const fileIdentity = publishEmpty(file)
202
+ assertSameDirectory(directory, directoryIdentity)
203
+ return { file, fileIdentity, directoryIdentity }
204
+ } catch (err) {
205
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
206
+ // The previous holder removed an empty rendezvous directory between our
207
+ // mkdir and publication. Recreate it; once our entry exists, no cooperating
208
+ // releaser can remove the non-empty directory underneath us.
209
+ if (Date.now() >= deadline) {
210
+ throw new Error(`timed out publishing file lock contender in ${directory}`)
211
+ }
212
+ Atomics.wait(lockSleep, 0, 0, FILE_LOCK_POLL_MS)
213
+ }
214
+ }
215
+ }
216
+
217
+ function comesBefore(entry: TicketEntry, ticket: bigint, pid: number, token: string): boolean {
218
+ if (entry.ticket !== ticket) return entry.ticket < ticket
219
+ if (entry.pid !== pid) return entry.pid < pid
220
+ return entry.token < token
221
+ }
222
+
223
+ export function withFileLock<T>(file: string, action: () => T, options: FileLockOptions = {}): T {
224
+ const waitMs = options.waitMs ?? FILE_LOCK_WAIT_MS
225
+ const deadline = Date.now() + waitMs
226
+ const pid = process.pid
227
+ const token = randomBytes(FILE_LOCK_TOKEN_BYTES).toString('hex')
228
+ const choosingName = `choosing-${pid}-${token}`
229
+ let ownedPath: string | null = null
230
+ let ownedIdentity: FileIdentity | null = null
231
+ let directoryIdentity: FileIdentity | null = null
232
+ let operationFailed = false
233
+
234
+ try {
235
+ const published = publishChoosing(file, choosingName, deadline, options.observe)
236
+ ownedPath = published.file
237
+ ownedIdentity = published.fileIdentity
238
+ directoryIdentity = published.directoryIdentity
239
+ options.observe?.({ phase: 'choosing-published', entry: choosingName })
240
+ let highest = 0n
241
+ for (const entry of liveEntries(file, directoryIdentity, options.observe)) {
242
+ if (entry.kind === 'ticket' && entry.ticket > highest) highest = entry.ticket
243
+ }
244
+ const ticket = highest + 1n
245
+ const ticketName = `ticket-${ticket}-${pid}-${token}`
246
+ const ticketPath = path.join(file, ticketName)
247
+
248
+ // The rename is the doorway: observers see either a chooser or its complete
249
+ // ticket, never a gap in which a live contender has no published state.
250
+ renameSync(ownedPath, ticketPath)
251
+ ownedPath = ticketPath
252
+
253
+ for (;;) {
254
+ const blockers: string[] = []
255
+ for (const entry of liveEntries(file, directoryIdentity, options.observe)) {
256
+ if (entry.pid === pid && entry.token === token) continue
257
+ if (entry.kind === 'choosing' || comesBefore(entry, ticket, pid, token)) {
258
+ blockers.push(entry.name)
259
+ }
260
+ }
261
+ if (blockers.length === 0) {
262
+ assertSameDirectory(file, directoryIdentity)
263
+ return action()
264
+ }
265
+ options.observe?.({ phase: 'waiting', blockers })
266
+ if (Date.now() >= deadline) throw new Error(`timed out waiting for file lock ${file}`)
267
+ Atomics.wait(lockSleep, 0, 0, FILE_LOCK_POLL_MS)
268
+ }
269
+ } catch (err) {
270
+ operationFailed = true
271
+ throw err
272
+ } finally {
273
+ let releaseChanged = false
274
+ if (ownedPath !== null && ownedIdentity !== null) {
275
+ try {
276
+ if (sameIdentity(ownedPath, ownedIdentity)) unlinkSync(ownedPath)
277
+ else releaseChanged = true
278
+ } catch {
279
+ // External cleanup cannot turn a release failure into a caller failure.
280
+ }
281
+ }
282
+ try {
283
+ if (directoryIdentity !== null) assertSameDirectory(file, directoryIdentity)
284
+ rmdirSync(file)
285
+ } catch {
286
+ // Another contender has published, or external cleanup already removed it.
287
+ }
288
+ if (releaseChanged && options.strictRelease === true && !operationFailed) {
289
+ throw new Error(`${ownedPath ?? file} changed while held; refusing to remove the replacement.`)
290
+ }
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Serialize a read/merge/write transaction for one user-owned target file.
296
+ *
297
+ * This is the single installer-facing lock policy: the target's direct parent
298
+ * is a real current-user-owned directory, remains the same inode throughout,
299
+ * and the bakery rendezvous lives beside the target rather than inside it.
300
+ */
301
+ export function withTargetFileLock<T>(
302
+ target: string,
303
+ action: () => T,
304
+ options: Omit<FileLockOptions, 'strictRelease'> = {},
305
+ ): T {
306
+ const parent = path.dirname(target)
307
+ mkdirSync(parent, { recursive: true })
308
+ const parentIdentity = assertOwnedDirectory(parent)
309
+ const lockDirectory = path.join(parent, `.${path.basename(target)}.notifai.lock`)
310
+ return withFileLock(
311
+ lockDirectory,
312
+ () => {
313
+ assertSameDirectory(parent, parentIdentity)
314
+ return action()
315
+ },
316
+ { ...options, strictRelease: true },
317
+ )
318
+ }
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { HARNESS_CAPABILITIES, HARNESSES, HOOK_TIMING } from './harnesses.js'
3
+
4
+ describe('harness continuation contract', () => {
5
+ it('requires one explicit native delivery contract for every shipped harness', () => {
6
+ expect(Object.keys(HARNESS_CAPABILITIES).sort()).toEqual([...HARNESSES].sort())
7
+ expect(HARNESS_CAPABILITIES).toEqual({
8
+ 'claude-code': {
9
+ stopContinuation: 'decision-block',
10
+ dormantWake: 'unsupported',
11
+ deliveryContract:
12
+ 'live Stop continuation; no automatic wake after its bounded wait returns',
13
+ },
14
+ codex: {
15
+ stopContinuation: 'decision-block',
16
+ dormantWake: 'unsupported',
17
+ deliveryContract:
18
+ 'live Stop continuation; no automatic wake after its bounded wait returns',
19
+ },
20
+ cursor: {
21
+ stopContinuation: 'unsupported',
22
+ dormantWake: 'unsupported',
23
+ deliveryContract:
24
+ 'the hook can return a live follow-up, but the invoking shell exposes no exact conversation id; asynchronous ask is unsupported',
25
+ },
26
+ opencode: {
27
+ stopContinuation: 'unsupported',
28
+ dormantWake: 'unsupported',
29
+ deliveryContract:
30
+ 'no proven answer continuation after session.idle; use a blocking reply command',
31
+ },
32
+ })
33
+ })
34
+
35
+ it('derives the maximum grace from the reserves every Stop must retain', () => {
36
+ expect(HOOK_TIMING.maxGraceSeconds).toBe(
37
+ HOOK_TIMING.totalSeconds -
38
+ HOOK_TIMING.finalizationReserveSeconds -
39
+ HOOK_TIMING.submissionReserveSeconds -
40
+ HOOK_TIMING.minimumReplySeconds -
41
+ HOOK_TIMING.schedulingSlackSeconds,
42
+ )
43
+ expect(HOOK_TIMING.finalizationReserveSeconds).toBeGreaterThan(
44
+ HOOK_TIMING.stdoutReserveSeconds,
45
+ )
46
+ expect(HOOK_TIMING.totalSeconds + HOOK_TIMING.hostHeadroomSeconds).toBe(540)
47
+ })
48
+ })
@@ -0,0 +1,59 @@
1
+ /** Every harness Notifai ships. This is the sole source of the harness union. */
2
+ export const HARNESSES = ['claude-code', 'codex', 'cursor', 'opencode'] as const
3
+
4
+ export type Harness = (typeof HARNESSES)[number]
5
+
6
+ /** One canonical wall-clock budget shared by config, hooks, and installers. */
7
+ export const HOOK_TIMING = Object.freeze({
8
+ totalSeconds: 480,
9
+ finalizationReserveSeconds: 45,
10
+ submissionReserveSeconds: 40,
11
+ minimumReplySeconds: 60,
12
+ schedulingSlackSeconds: 1,
13
+ stdoutReserveSeconds: 5,
14
+ hostHeadroomSeconds: 60,
15
+ maxGraceSeconds: 334,
16
+ })
17
+
18
+ export type StopContinuation =
19
+ | 'decision-block'
20
+ | 'followup-message'
21
+ | 'unsupported'
22
+
23
+ export interface HarnessCapability {
24
+ /** How an answer is admitted to another turn in the already-open session. */
25
+ stopContinuation: StopContinuation
26
+ /** Whether delivery remains native after the turn-end event has returned. */
27
+ dormantWake: 'unsupported' | 'native-session-prompt'
28
+ /** Concise, honest readiness text for doctor and installation guidance. */
29
+ deliveryContract: string
30
+ }
31
+
32
+ /**
33
+ * Exhaustive runtime contract. Adding a harness without deciding every
34
+ * continuation joint is a type error, not a silently degraded notification.
35
+ */
36
+ export const HARNESS_CAPABILITIES: Record<Harness, HarnessCapability> = {
37
+ 'claude-code': {
38
+ stopContinuation: 'decision-block',
39
+ dormantWake: 'unsupported',
40
+ deliveryContract: 'live Stop continuation; no automatic wake after its bounded wait returns',
41
+ },
42
+ codex: {
43
+ stopContinuation: 'decision-block',
44
+ dormantWake: 'unsupported',
45
+ deliveryContract: 'live Stop continuation; no automatic wake after its bounded wait returns',
46
+ },
47
+ cursor: {
48
+ stopContinuation: 'unsupported',
49
+ dormantWake: 'unsupported',
50
+ deliveryContract:
51
+ 'the hook can return a live follow-up, but the invoking shell exposes no exact conversation id; asynchronous ask is unsupported',
52
+ },
53
+ opencode: {
54
+ stopContinuation: 'unsupported',
55
+ dormantWake: 'unsupported',
56
+ deliveryContract:
57
+ 'no proven answer continuation after session.idle; use a blocking reply command',
58
+ },
59
+ }
@@ -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
+ })