@raidiant/notifai 10.1.1 → 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.
Files changed (156) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/commands-core.d.ts +1 -1
  3. package/dist/commands-init.js +9 -6
  4. package/dist/commands-init.js.map +1 -1
  5. package/dist/commands-send-support.js +3 -0
  6. package/dist/commands-send-support.js.map +1 -1
  7. package/dist/commands-skill.js +72 -54
  8. package/dist/commands-skill.js.map +1 -1
  9. package/dist/invocation-context.d.ts +1 -0
  10. package/dist/invocation-context.js +9 -1
  11. package/dist/invocation-context.js.map +1 -1
  12. package/dist/native-skills.d.ts +13 -3
  13. package/dist/native-skills.js +51 -12
  14. package/dist/native-skills.js.map +1 -1
  15. package/dist/node-floor.d.ts +2 -1
  16. package/dist/node-floor.js +9 -5
  17. package/dist/node-floor.js.map +1 -1
  18. package/dist/orca-session-title.d.ts +5 -4
  19. package/dist/orca-session-title.js +32 -19
  20. package/dist/orca-session-title.js.map +1 -1
  21. package/dist/platform.d.ts +11 -17
  22. package/dist/platform.js +20 -23
  23. package/dist/platform.js.map +1 -1
  24. package/dist/release.d.ts +5 -5
  25. package/dist/release.js +5 -5
  26. package/dist/setup-destinations.js +1 -1
  27. package/dist/setup-destinations.js.map +1 -1
  28. package/dist/skill-integrity.d.ts +49 -0
  29. package/dist/skill-integrity.js +154 -0
  30. package/dist/skill-integrity.js.map +1 -0
  31. package/dist/skill-source/manifest.json +16 -0
  32. package/dist/skill-source/notifai/SKILL.md +390 -0
  33. package/dist/skill-source/notifai/references/harness-setup.md +220 -0
  34. package/package.json +11 -14
  35. package/src/atomic-file.ts +0 -198
  36. package/src/claude-wake.test.ts +0 -321
  37. package/src/claude-wake.ts +0 -499
  38. package/src/cli-bin.test.ts +0 -176
  39. package/src/cli-bin.ts +0 -233
  40. package/src/cli-release.test.ts +0 -46
  41. package/src/cli-release.ts +0 -78
  42. package/src/cli-update-contract.ts +0 -9
  43. package/src/cli-update.test.ts +0 -125
  44. package/src/client.test.ts +0 -390
  45. package/src/client.ts +0 -352
  46. package/src/codex-wake.test.ts +0 -254
  47. package/src/codex-wake.ts +0 -301
  48. package/src/command-session.ts +0 -38
  49. package/src/commands-agent-sessions.ts +0 -72
  50. package/src/commands-auth.ts +0 -238
  51. package/src/commands-config.ts +0 -336
  52. package/src/commands-core.ts +0 -312
  53. package/src/commands-devices.ts +0 -180
  54. package/src/commands-doctor.ts +0 -1501
  55. package/src/commands-guidance.ts +0 -181
  56. package/src/commands-harness-context.ts +0 -207
  57. package/src/commands-hook-shape.ts +0 -69
  58. package/src/commands-hooks.ts +0 -2318
  59. package/src/commands-init.ts +0 -1055
  60. package/src/commands-io.ts +0 -108
  61. package/src/commands-logs.ts +0 -243
  62. package/src/commands-project.ts +0 -47
  63. package/src/commands-send-support.test.ts +0 -156
  64. package/src/commands-send-support.ts +0 -311
  65. package/src/commands-send.ts +0 -875
  66. package/src/commands-setup-proof.test.ts +0 -72
  67. package/src/commands-setup-proof.ts +0 -124
  68. package/src/commands-skill.test.ts +0 -44
  69. package/src/commands-skill.ts +0 -204
  70. package/src/commands-sounds.test.ts +0 -106
  71. package/src/commands-sounds.ts +0 -49
  72. package/src/commands-update.ts +0 -180
  73. package/src/commands.test.ts +0 -8297
  74. package/src/commands.ts +0 -77
  75. package/src/config-schema.test.ts +0 -153
  76. package/src/config-schema.ts +0 -349
  77. package/src/config.test.ts +0 -581
  78. package/src/config.ts +0 -475
  79. package/src/credentials.test.ts +0 -403
  80. package/src/credentials.ts +0 -403
  81. package/src/file-lock-process-worker.test.ts +0 -64
  82. package/src/file-lock.test.ts +0 -320
  83. package/src/file-lock.ts +0 -393
  84. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
  85. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
  86. package/src/generated-session-label.ts +0 -155
  87. package/src/guidance-content.ts +0 -278
  88. package/src/guidance-render.ts +0 -56
  89. package/src/guidance.test.ts +0 -327
  90. package/src/guidance.ts +0 -233
  91. package/src/harness-integration.test.ts +0 -294
  92. package/src/harnesses.test.ts +0 -149
  93. package/src/harnesses.ts +0 -164
  94. package/src/hook-adapter.test.ts +0 -496
  95. package/src/hook-adapter.ts +0 -424
  96. package/src/hook-input.test.ts +0 -39
  97. package/src/hook-input.ts +0 -72
  98. package/src/hooks.test.ts +0 -5824
  99. package/src/hooks.ts +0 -4011
  100. package/src/install-hooks.test.ts +0 -1614
  101. package/src/install-hooks.ts +0 -1501
  102. package/src/interactive.test.ts +0 -387
  103. package/src/interactive.ts +0 -858
  104. package/src/invocation-context.test.ts +0 -352
  105. package/src/invocation-context.ts +0 -197
  106. package/src/logging-process-worker.test.ts +0 -45
  107. package/src/logging.test.ts +0 -544
  108. package/src/logging.ts +0 -698
  109. package/src/main-run.ts +0 -45
  110. package/src/main.ts +0 -12
  111. package/src/native-skills.test.ts +0 -115
  112. package/src/native-skills.ts +0 -162
  113. package/src/node-floor.test.ts +0 -27
  114. package/src/node-floor.ts +0 -37
  115. package/src/onboarding-docs.test.ts +0 -25
  116. package/src/openclaw-plugin.ts +0 -409
  117. package/src/opencode-plugin.ts +0 -298
  118. package/src/orca-session-title.test.ts +0 -54
  119. package/src/orca-session-title.ts +0 -103
  120. package/src/pack-feedback-slice.test.ts +0 -212
  121. package/src/pack-feedback-slice.ts +0 -187
  122. package/src/platform.test.ts +0 -152
  123. package/src/platform.ts +0 -173
  124. package/src/program.test.ts +0 -317
  125. package/src/program.ts +0 -842
  126. package/src/project-enablement.test.ts +0 -55
  127. package/src/project-enablement.ts +0 -99
  128. package/src/question-caps.test.ts +0 -49
  129. package/src/question-settlement-process.ts +0 -47
  130. package/src/question-timing.ts +0 -56
  131. package/src/readiness.test.ts +0 -25
  132. package/src/readiness.ts +0 -212
  133. package/src/release.ts +0 -51
  134. package/src/send-attempts.test.ts +0 -143
  135. package/src/send-attempts.ts +0 -188
  136. package/src/send.test.ts +0 -28
  137. package/src/send.ts +0 -436
  138. package/src/session-activation.ts +0 -61
  139. package/src/session-end-log-lock-worker.test.ts +0 -35
  140. package/src/session-labels.test.ts +0 -490
  141. package/src/session-labels.ts +0 -474
  142. package/src/setup-destinations.test.ts +0 -41
  143. package/src/setup-destinations.ts +0 -50
  144. package/src/skill.test.ts +0 -426
  145. package/src/sound-ref.test.ts +0 -31
  146. package/src/sound-ref.ts +0 -34
  147. package/src/trust-policy.test.ts +0 -701
  148. package/src/ui/banner.ts +0 -95
  149. package/src/ui/config-view.test.ts +0 -90
  150. package/src/ui/config-view.ts +0 -138
  151. package/src/ui/help.test.ts +0 -16
  152. package/src/ui/help.ts +0 -128
  153. package/src/ui/theme.test.ts +0 -92
  154. package/src/ui/theme.ts +0 -265
  155. package/src/url-policy.ts +0 -399
  156. package/tsconfig.json +0 -10
package/src/harnesses.ts DELETED
@@ -1,164 +0,0 @@
1
- /** Every harness Notifai can name in Source Context when authoritatively known. */
2
- export const SOURCE_CONTEXT_HARNESSES = [
3
- 'claude-code',
4
- 'codex',
5
- 'cursor',
6
- 'opencode',
7
- 'openclaw',
8
- 'hermes',
9
- ] as const
10
-
11
- export type SourceContextHarness = (typeof SOURCE_CONTEXT_HARNESSES)[number]
12
-
13
- /**
14
- * Harnesses whose lifecycle Notifai can install and manage.
15
- *
16
- * Source Context is the broader vocabulary. Managed hook installation is a
17
- * narrower, independently proven cell: Hermes can appear in Source Context
18
- * without being hook-installable.
19
- */
20
- export const HOOK_INSTALLABLE_HARNESSES = [
21
- 'claude-code',
22
- 'codex',
23
- 'cursor',
24
- 'opencode',
25
- 'openclaw',
26
- ] as const
27
-
28
- export type HookInstallableHarness = (typeof HOOK_INSTALLABLE_HARNESSES)[number]
29
-
30
- export function isHookInstallableHarness(value: string): value is HookInstallableHarness {
31
- return (HOOK_INSTALLABLE_HARNESSES as readonly string[]).includes(value)
32
- }
33
-
34
- export const HARNESS_LABELS: Record<SourceContextHarness, string> = {
35
- 'claude-code': 'Claude Code',
36
- codex: 'Codex',
37
- cursor: 'Cursor',
38
- opencode: 'OpenCode',
39
- openclaw: 'OpenClaw',
40
- hermes: 'Hermes',
41
- }
42
-
43
- export type DeliveryRoute =
44
- | 'hook-continuation'
45
- | 'inbox-socket'
46
- | 'owned-control-plane'
47
- | 'cold-resume'
48
- | 'hold-for-next-turn'
49
- | 'unsupported'
50
-
51
- export type StopContinuation = 'decision-block' | 'unsupported'
52
-
53
- export interface HarnessCapability {
54
- /** How an answer is admitted to another turn in the already-open session. */
55
- stopContinuation: StopContinuation
56
- /** Available answer-delivery routes, best current route first. */
57
- deliveryRoutes: readonly DeliveryRoute[]
58
- /** Concise, honest readiness text for doctor and installation guidance. */
59
- deliveryContract: string
60
- }
61
-
62
- const CLAUDE_CODE_CAPABILITY: HarnessCapability = {
63
- stopContinuation: 'decision-block',
64
- deliveryRoutes: ['hook-continuation', 'inbox-socket', 'cold-resume', 'hold-for-next-turn'],
65
- deliveryContract:
66
- 'the Stop hook returns at once and waits out of band through the complete answer window, then posts the answer into this same session over its own inbox socket; a session that has stopped is resumed only once a liveness probe proves it stopped',
67
- }
68
-
69
- const CODEX_CAPABILITY: HarnessCapability = {
70
- stopContinuation: 'decision-block',
71
- deliveryRoutes: ['hook-continuation', 'cold-resume', 'hold-for-next-turn'],
72
- deliveryContract:
73
- 'live Stop continuation while the turn is held through the complete answer window; crash recovery may resume only a stopped thread behind its writer lock',
74
- }
75
-
76
- const CURSOR_CAPABILITY: HarnessCapability = {
77
- stopContinuation: 'unsupported',
78
- deliveryRoutes: ['unsupported'],
79
- deliveryContract:
80
- 'the hook can return a live follow-up, but the invoking shell exposes no exact conversation id; asynchronous ask is unsupported',
81
- }
82
-
83
- const OPENCODE_CAPABILITY: HarnessCapability = {
84
- stopContinuation: 'unsupported',
85
- deliveryRoutes: ['unsupported'],
86
- deliveryContract:
87
- 'no proven answer continuation after session.idle; use a blocking reply command',
88
- }
89
-
90
- const OPENCLAW_CAPABILITY: HarnessCapability = {
91
- stopContinuation: 'unsupported',
92
- deliveryRoutes: ['unsupported'],
93
- deliveryContract:
94
- 'no proven answer continuation after agent_end; use a blocking reply command',
95
- }
96
-
97
- export const HERMES_QUESTION_ROUTING_UNAVAILABLE: HarnessCapability = {
98
- stopContinuation: 'unsupported',
99
- deliveryRoutes: ['unsupported'],
100
- deliveryContract:
101
- 'Hermes asynchronous ask has no proven continuation owner on this integration surface. Use a blocking `notifai send --reply` question',
102
- }
103
-
104
- /**
105
- * The one Hermes integration instance proven by the pinned vertical probe.
106
- *
107
- * This is evidence-bearing capability data, not an adapter. Unsupported and
108
- * deferred surfaces intentionally have no empty implementation object here.
109
- */
110
- export const HERMES_CLASSIC_CLI_LOCAL_CAPABILITY = {
111
- instance: {
112
- harness: 'hermes',
113
- surface: 'classic-cli',
114
- terminalBackend: 'local',
115
- },
116
- setup: 'unsupported',
117
- activation: 'unsupported',
118
- sourceContext: 'hermes-session-id-and-invocation-cwd',
119
- continuation: 'unsupported',
120
- } as const
121
-
122
- export type HermesClassicCliLocalInstance =
123
- (typeof HERMES_CLASSIC_CLI_LOCAL_CAPABILITY)['instance']
124
-
125
- /**
126
- * Resolve only the Hermes instance whose complete marker envelope was proven.
127
- *
128
- * `HERMES_SESSION_ID` is also bridged by deferred gateway, TUI, API, ACP, and
129
- * remote-backend paths. Their first-party routing/source/backend markers keep
130
- * those cells distinct. Missing source/backend values are the pinned classic
131
- * CLI defaults; a gateway key is never part of that instance.
132
- */
133
- export function hermesClassicCliLocalInstance(
134
- env: NodeJS.ProcessEnv,
135
- ): HermesClassicCliLocalInstance | null {
136
- const sessionId = (env['HERMES_SESSION_ID'] ?? '').trim()
137
- const sessionKey = (env['HERMES_SESSION_KEY'] ?? '').trim()
138
- const platform = (env['HERMES_SESSION_PLATFORM'] ?? '').trim().toLowerCase()
139
- const source = (env['HERMES_SESSION_SOURCE'] ?? '').trim().toLowerCase()
140
- const terminalBackend = (env['TERMINAL_ENV'] ?? '').trim().toLowerCase()
141
- if (sessionId === '' || sessionKey !== '' || platform !== '') return null
142
- if (source !== '' && source !== 'cli') return null
143
- if (terminalBackend !== '' && terminalBackend !== 'local') return null
144
- return HERMES_CLASSIC_CLI_LOCAL_CAPABILITY.instance
145
- }
146
-
147
- /**
148
- * Question-routing contract for hook-installable harnesses.
149
- *
150
- * Hermes is deliberately absent: a send-only surface must not occupy this
151
- * table with a fake managed-hook continuation route.
152
- */
153
- export const HARNESS_CAPABILITIES: Record<HookInstallableHarness, HarnessCapability> = {
154
- 'claude-code': CLAUDE_CODE_CAPABILITY,
155
- codex: CODEX_CAPABILITY,
156
- cursor: CURSOR_CAPABILITY,
157
- opencode: OPENCODE_CAPABILITY,
158
- openclaw: OPENCLAW_CAPABILITY,
159
- }
160
-
161
- /** Question-routing capability for the active harness integration. */
162
- export function questionRoutingCapability(harness: HookInstallableHarness): HarnessCapability {
163
- return HARNESS_CAPABILITIES[harness]
164
- }
@@ -1,496 +0,0 @@
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 { fileURLToPath } from 'node:url'
15
- import { describe, expect, it } from 'vitest'
16
- import {
17
- hookAdapterPath,
18
- hookAdapterSource,
19
- inspectHookAdapter,
20
- installHookAdapter,
21
- isNpxAdapterTarget,
22
- } from './hook-adapter.js'
23
- import { buildHookConfig, codexHookIdentityHash, type InstalledHandler } from './install-hooks.js'
24
- import { enableProject, projectBinding } from './project-enablement.js'
25
-
26
- function isolated(): { root: string; homeDir: string } {
27
- const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-hook-adapter-'))
28
- return { root, homeDir: path.join(root, 'home') }
29
- }
30
-
31
- describe('stable hook adapter', () => {
32
- it('keeps the installed adapter silent until the Project is enabled', () => {
33
- const { root, homeDir } = isolated()
34
- const env = {
35
- ...process.env,
36
- HOME: homeDir,
37
- XDG_CONFIG_HOME: path.join(root, 'config'),
38
- XDG_STATE_HOME: path.join(root, 'state'),
39
- }
40
- const scriptPath = fileURLToPath(new URL('../dist/main.js', import.meta.url))
41
- const installed = installHookAdapter({ execPath: process.execPath, scriptPath }, homeDir)
42
- const disabled = spawnSync(
43
- installed.path,
44
- ['hook', 'session-start', '--owner', 'notifai', '--harness', 'codex'],
45
- {
46
- encoding: 'utf8',
47
- input: JSON.stringify({ session_id: 'adapter-session', cwd: root, source: 'startup' }),
48
- env,
49
- },
50
- )
51
-
52
- expect(disabled.status).toBe(0)
53
- expect(disabled.stderr).toBe('')
54
- expect(disabled.stdout).toBe('')
55
-
56
- const binding = projectBinding(root, env)
57
- if (binding === null) throw new Error('test Project binding unavailable')
58
- enableProject(binding)
59
- const run = spawnSync(
60
- installed.path,
61
- ['hook', 'session-start', '--owner', 'notifai', '--harness', 'codex'],
62
- {
63
- encoding: 'utf8',
64
- input: JSON.stringify({ session_id: 'adapter-session', cwd: root, source: 'startup' }),
65
- env,
66
- },
67
- )
68
-
69
- expect(run.status).toBe(0)
70
- expect(run.stderr).toBe('')
71
- expect(JSON.parse(run.stdout)).toMatchObject({
72
- hookSpecificOutput: {
73
- hookEventName: 'SessionStart',
74
- additionalContext: expect.stringMatching(/Notifai.*enabled.*Project/i),
75
- },
76
- })
77
- })
78
-
79
- it('executes paths with spaces, quotes, and shell syntax without evaluating them', () => {
80
- const { root, homeDir } = isolated()
81
- const odd = path.join(root, "odd ' $(touch should-not-run)")
82
- mkdirSync(odd, { recursive: true })
83
- const script = path.join(odd, 'target script.js')
84
- writeFileSync(script, 'process.stdout.write(JSON.stringify(process.argv.slice(2)))\n')
85
-
86
- const installed = installHookAdapter({ execPath: process.execPath, scriptPath: script }, homeDir)
87
- const run = spawnSync(installed.path, ['hook', 'stop', '--owner', 'notifai'], {
88
- encoding: 'utf8',
89
- })
90
-
91
- expect(run.status).toBe(0)
92
- expect(JSON.parse(run.stdout)).toEqual(['hook', 'stop', '--owner', 'notifai'])
93
- expect(statSync(installed.path).mode & 0o777).toBe(0o700)
94
- expect(inspectHookAdapter(homeDir).problems).toEqual([])
95
- })
96
-
97
- it('retargets Node and CLI paths without changing any harness definition or Codex hash', () => {
98
- const { root, homeDir } = isolated()
99
- const first = path.join(root, 'first.js')
100
- const second = path.join(root, 'second.js')
101
- writeFileSync(first, '')
102
- writeFileSync(second, '')
103
- const adapter = installHookAdapter({ execPath: process.execPath, scriptPath: first }, homeDir)
104
- const before = buildHookConfig({
105
- adapterPath: adapter.path,
106
- harness: 'codex',
107
- })
108
- const beforeSource = readFileSync(adapter.path, 'utf8')
109
-
110
- installHookAdapter({ execPath: process.execPath, scriptPath: second }, homeDir)
111
- const after = buildHookConfig({
112
- adapterPath: hookAdapterPath(homeDir),
113
- harness: 'codex',
114
- })
115
- const afterSource = readFileSync(adapter.path, 'utf8')
116
- const stop = (config: typeof before): InstalledHandler => {
117
- const handler = config['Stop']![0]!.hooks[0]!
118
- return {
119
- event: 'Stop',
120
- groupIndex: 0,
121
- handlerIndex: 0,
122
- command: handler.command,
123
- timeout: handler.timeout,
124
- }
125
- }
126
-
127
- expect(after).toEqual(before)
128
- expect(afterSource).not.toBe(beforeSource)
129
- expect(codexHookIdentityHash(stop(after))).toBe(codexHookIdentityHash(stop(before)))
130
- const inspected = inspectHookAdapter(homeDir).target
131
- expect(inspected && !isNpxAdapterTarget(inspected) ? inspected.scriptPath : null).toBe(second)
132
- })
133
-
134
- it('executes a pinned npx spec through npm-cli.js without evaluating it', () => {
135
- const { root, homeDir } = isolated()
136
- const npmCli = path.join(root, 'npm-cli.js')
137
- writeFileSync(
138
- npmCli,
139
- 'process.stdout.write(JSON.stringify(process.argv.slice(2)))\n',
140
- )
141
- const installed = installHookAdapter(
142
- {
143
- kind: 'npx',
144
- execPath: process.execPath,
145
- npmCli,
146
- spec: '@raidiant/notifai@0.5.1',
147
- },
148
- homeDir,
149
- )
150
- const run = spawnSync(installed.path, ['hook', 'stop', '--owner', 'notifai'], {
151
- encoding: 'utf8',
152
- })
153
-
154
- expect(run.status).toBe(0)
155
- expect(JSON.parse(run.stdout)).toEqual([
156
- 'exec',
157
- '--yes',
158
- '--package',
159
- '@raidiant/notifai@0.5.1',
160
- '--',
161
- 'notifai',
162
- 'hook',
163
- 'stop',
164
- '--owner',
165
- 'notifai',
166
- ])
167
- const inspected = inspectHookAdapter(homeDir)
168
- expect(inspected.problems).toEqual([])
169
- expect(inspected.target && isNpxAdapterTarget(inspected.target) ? inspected.target.spec : null).toBe(
170
- '@raidiant/notifai@0.5.1',
171
- )
172
- })
173
-
174
- it('carries the stable POSIX harness parent through direct and npx targets', () => {
175
- const { root, homeDir } = isolated()
176
- const direct = path.join(root, 'direct.js')
177
- const npmCli = path.join(root, 'npm-cli.js')
178
- const source = 'process.stdout.write(process.env.NOTIFAI_HOOK_SOURCE_PID ?? "missing")\n'
179
- writeFileSync(direct, source)
180
- writeFileSync(npmCli, source)
181
-
182
- const directAdapter = installHookAdapter(
183
- { execPath: process.execPath, scriptPath: direct },
184
- homeDir,
185
- )
186
- const directRun = spawnSync(directAdapter.path, [], { encoding: 'utf8' })
187
- const npxAdapter = installHookAdapter(
188
- {
189
- kind: 'npx',
190
- execPath: process.execPath,
191
- npmCli,
192
- spec: '@raidiant/notifai@0.5.1',
193
- },
194
- homeDir,
195
- )
196
- const npxRun = spawnSync(npxAdapter.path, [], { encoding: 'utf8' })
197
-
198
- expect(directRun.status).toBe(0)
199
- expect(npxRun.status).toBe(0)
200
- expect(directRun.stdout).toBe(String(process.pid))
201
- expect(npxRun.stdout).toBe(String(process.pid))
202
- })
203
-
204
- it('uses a fixed home-relative pathname with no XDG input surface', () => {
205
- const { homeDir } = isolated()
206
- expect(hookAdapterPath(homeDir)).toBe(
207
- path.join(homeDir, '.notifai', 'bin', 'hook-adapter'),
208
- )
209
- })
210
-
211
- it('uses the OS account home even when HOME is overridden', () => {
212
- const original = process.env['HOME']
213
- process.env['HOME'] = '/tmp/attacker-selected-home'
214
- try {
215
- expect(hookAdapterPath()).toBe(
216
- path.join(os.userInfo().homedir, '.notifai', 'bin', 'hook-adapter'),
217
- )
218
- expect(hookAdapterPath()).not.toContain('attacker-selected-home')
219
- } finally {
220
- if (original === undefined) delete process.env['HOME']
221
- else process.env['HOME'] = original
222
- }
223
- })
224
-
225
- it('repairs permissive adapter permissions without changing its path', () => {
226
- const { root, homeDir } = isolated()
227
- const script = path.join(root, 'target.js')
228
- writeFileSync(script, '')
229
- const first = installHookAdapter({ execPath: process.execPath, scriptPath: script }, homeDir)
230
- chmodSync(first.path, 0o755)
231
-
232
- const repaired = installHookAdapter({ execPath: process.execPath, scriptPath: script }, homeDir)
233
-
234
- expect(repaired).toEqual({ path: first.path, changed: true })
235
- expect(statSync(first.path).mode & 0o777).toBe(0o700)
236
- })
237
-
238
- it('refuses a symlinked adapter instead of reading or replacing its target', () => {
239
- const { root, homeDir } = isolated()
240
- const target = path.join(root, 'foreign')
241
- const adapter = hookAdapterPath(homeDir)
242
- mkdirSync(path.dirname(adapter), { recursive: true })
243
- writeFileSync(target, 'leave me')
244
- symlinkSync(target, adapter)
245
-
246
- expect(() =>
247
- installHookAdapter({ execPath: process.execPath, scriptPath: target }, homeDir),
248
- ).toThrow(/not a regular file/)
249
- expect(readFileSync(target, 'utf8')).toBe('leave me')
250
- })
251
-
252
- it('refuses a symlinked Notifai-owned parent directory', () => {
253
- const { root, homeDir } = isolated()
254
- const foreign = path.join(root, 'foreign-dir')
255
- const script = path.join(root, 'target.js')
256
- mkdirSync(homeDir, { recursive: true })
257
- mkdirSync(foreign)
258
- writeFileSync(script, '')
259
- symlinkSync(foreign, path.join(homeDir, '.notifai'))
260
-
261
- expect(() =>
262
- installHookAdapter({ execPath: process.execPath, scriptPath: script }, homeDir),
263
- ).toThrow(/not a regular directory/)
264
- expect(readFileSync(script, 'utf8')).toBe('')
265
- })
266
-
267
- it('diagnoses a stale registered runtime and CLI without executing either', () => {
268
- const { root, homeDir } = isolated()
269
- const missingRuntime = path.join(root, 'removed-node')
270
- const missingCli = path.join(root, 'removed-cli.js')
271
- writeFileSync(missingRuntime, '#!/bin/sh\n')
272
- chmodSync(missingRuntime, 0o700)
273
- writeFileSync(missingCli, '')
274
- installHookAdapter({ execPath: missingRuntime, scriptPath: missingCli }, homeDir)
275
- rmSync(missingRuntime)
276
- rmSync(missingCli)
277
-
278
- expect(inspectHookAdapter(homeDir).problems).toEqual([
279
- `registered runtime ${missingRuntime} is missing`,
280
- `registered CLI ${missingCli} is missing`,
281
- ])
282
- })
283
-
284
- it('keeps working when a node upgrade moved the pinned runtime but left the CLI', () => {
285
- const { root, homeDir } = isolated()
286
- const bin = path.join(root, 'fallback-bin')
287
- mkdirSync(bin, { recursive: true })
288
- const pathNode = path.join(bin, 'node')
289
- writeFileSync(pathNode, '#!/bin/sh\nprintf "ran %s" "$1"\n')
290
- chmodSync(pathNode, 0o700)
291
- const pinnedNode = path.join(root, 'pinned-node')
292
- const cli = path.join(root, 'cli.js')
293
- writeFileSync(pinnedNode, '#!/bin/sh\n')
294
- chmodSync(pinnedNode, 0o700)
295
- writeFileSync(cli, '')
296
- const installed = installHookAdapter({ execPath: pinnedNode, scriptPath: cli }, homeDir)
297
- // What `brew upgrade node` and `nvm uninstall` both do: the interpreter
298
- // moves, the installed CLI does not.
299
- rmSync(pinnedNode)
300
-
301
- const run = spawnSync(installed.path, ['hook', 'stop'], {
302
- encoding: 'utf8',
303
- env: { PATH: bin },
304
- })
305
-
306
- expect(run.status).toBe(0)
307
- expect(run.stdout).toBe(`ran ${cli}`)
308
- })
309
-
310
- it('fails closed instead of executing an unregistered PATH target after the registered target vanished', () => {
311
- const { root, homeDir } = isolated()
312
- const bin = path.join(root, 'new-bin')
313
- const replacement = path.join(bin, 'notifai')
314
- mkdirSync(bin, { recursive: true })
315
- writeFileSync(replacement, '#!/bin/sh\nprintf "%s" "$*"\n')
316
- chmodSync(replacement, 0o700)
317
- const oldNode = path.join(root, 'old-node')
318
- const oldCli = path.join(root, 'old-cli.js')
319
- writeFileSync(oldNode, '#!/bin/sh\n')
320
- chmodSync(oldNode, 0o700)
321
- writeFileSync(oldCli, '')
322
- const installed = installHookAdapter({ execPath: oldNode, scriptPath: oldCli }, homeDir)
323
- rmSync(oldNode)
324
- rmSync(oldCli)
325
-
326
- const run = spawnSync(installed.path, ['hook', 'stop'], {
327
- encoding: 'utf8',
328
- env: { PATH: bin },
329
- })
330
-
331
- expect(run.status).toBe(127)
332
- expect(run.stdout).toBe('')
333
- expect(run.stderr).toMatch(/target is stale/)
334
- })
335
- })
336
-
337
- describe('Windows hook adapter', () => {
338
- it('keeps POSIX adapter source bytes identical to the trusted /bin/sh form', () => {
339
- const target = {
340
- execPath: '/usr/local/bin/node',
341
- scriptPath: '/opt/notifai/dist/main.js',
342
- }
343
- expect(hookAdapterSource(target, 'posix')).toBe(`#!/bin/sh
344
- # notifai managed hook adapter
345
- # adapter-version: 2
346
- # target-exec-json: "/usr/local/bin/node"
347
- # target-script-json: "/opt/notifai/dist/main.js"
348
- set -eu
349
-
350
- NOTIFAI_HOOK_SOURCE_PID=$PPID
351
- export NOTIFAI_HOOK_SOURCE_PID
352
-
353
- registered_exec='/usr/local/bin/node'
354
- registered_script='/opt/notifai/dist/main.js'
355
-
356
- if [ -x "$registered_exec" ] && [ -f "$registered_script" ]; then
357
- exec "$registered_exec" "$registered_script" "$@"
358
- fi
359
-
360
- if [ -f "$registered_script" ] && command -v node >/dev/null 2>&1; then
361
- exec node "$registered_script" "$@"
362
- fi
363
-
364
- printf '%s
365
- ' 'Notifai hook adapter target is stale; run notifai hooks install.' >&2
366
- exit 127
367
- `)
368
- })
369
-
370
- it('generates a JavaScript adapter instead of a shebang script', () => {
371
- const { root, homeDir } = isolated()
372
- const script = path.join(root, 'target.js')
373
- writeFileSync(script, '')
374
- const installed = installHookAdapter(
375
- { execPath: process.execPath, scriptPath: script },
376
- homeDir,
377
- 'win32',
378
- )
379
- const source = readFileSync(installed.path, 'utf8')
380
-
381
- expect(source.startsWith('// notifai managed hook adapter\n')).toBe(true)
382
- expect(source).not.toContain('#!/bin/sh')
383
- expect(source).toContain('require("node:child_process")')
384
- expect(source).toContain(JSON.stringify(process.execPath))
385
- expect(source).toContain(JSON.stringify(script))
386
- })
387
-
388
- it('forwards argv through Node without depending on a shebang or executable bit', () => {
389
- const { root, homeDir } = isolated()
390
- const script = path.join(root, 'target script.js')
391
- writeFileSync(script, 'process.stdout.write(JSON.stringify(process.argv.slice(2)))\n')
392
- const runtime = path.join(root, 'node-runtime')
393
- writeFileSync(runtime, 'dummy-node')
394
- chmodSync(runtime, 0o644)
395
-
396
- const installed = installHookAdapter(
397
- { execPath: process.execPath, scriptPath: script },
398
- homeDir,
399
- 'win32',
400
- )
401
- const run = spawnSync(process.execPath, [installed.path, 'hook', 'stop', '--owner', 'notifai'], {
402
- encoding: 'utf8',
403
- })
404
-
405
- expect(run.status).toBe(0)
406
- expect(JSON.parse(run.stdout)).toEqual(['hook', 'stop', '--owner', 'notifai'])
407
- expect(() =>
408
- installHookAdapter({ execPath: runtime, scriptPath: script }, homeDir, 'win32'),
409
- ).not.toThrow()
410
- })
411
-
412
- it('carries the stable Windows harness parent into each CLI process', () => {
413
- const { root, homeDir } = isolated()
414
- const script = path.join(root, 'source-pid.js')
415
- writeFileSync(
416
- script,
417
- 'process.stdout.write(process.env.NOTIFAI_HOOK_SOURCE_PID ?? "missing")\n',
418
- )
419
- const installed = installHookAdapter(
420
- { execPath: process.execPath, scriptPath: script },
421
- homeDir,
422
- 'win32',
423
- )
424
-
425
- const run = spawnSync(process.execPath, [installed.path], { encoding: 'utf8' })
426
-
427
- expect(run.status).toBe(0)
428
- expect(run.stdout).toBe(String(process.pid))
429
- })
430
-
431
- it('does not treat 0666 as a protection or health failure', () => {
432
- const { root, homeDir } = isolated()
433
- const script = path.join(root, 'target.js')
434
- writeFileSync(script, '')
435
- const installed = installHookAdapter(
436
- { execPath: process.execPath, scriptPath: script },
437
- homeDir,
438
- 'win32',
439
- )
440
- chmodSync(installed.path, 0o666)
441
-
442
- expect(inspectHookAdapter(homeDir, 'win32').problems).toEqual([])
443
- expect(
444
- installHookAdapter({ execPath: process.execPath, scriptPath: script }, homeDir, 'win32'),
445
- ).toEqual({ path: installed.path, changed: false })
446
- })
447
-
448
- it('fails closed when the registered Windows target vanishes', () => {
449
- const { root, homeDir } = isolated()
450
- const oldNode = path.join(root, 'old-node')
451
- const oldCli = path.join(root, 'old-cli.js')
452
- writeFileSync(oldNode, '#!/bin/sh\n')
453
- writeFileSync(oldCli, '')
454
- const installed = installHookAdapter(
455
- { execPath: oldNode, scriptPath: oldCli },
456
- homeDir,
457
- 'win32',
458
- )
459
- rmSync(oldNode)
460
- rmSync(oldCli)
461
-
462
- const inspected = inspectHookAdapter(homeDir, 'win32')
463
- expect(inspected.problems).toEqual([
464
- `registered runtime ${oldNode} is missing`,
465
- `registered CLI ${oldCli} is missing`,
466
- ])
467
- const run = spawnSync(process.execPath, [installed.path, 'hook', 'stop'], {
468
- encoding: 'utf8',
469
- })
470
- expect(run.status).toBe(127)
471
- expect(run.stdout).toBe('')
472
- expect(run.stderr).toMatch(/target is stale/)
473
- })
474
-
475
- it('retargets the CLI without changing the adapter pathname', () => {
476
- const { root, homeDir } = isolated()
477
- const first = path.join(root, 'first.js')
478
- const second = path.join(root, 'second.js')
479
- writeFileSync(first, '')
480
- writeFileSync(second, '')
481
- const firstInstall = installHookAdapter(
482
- { execPath: process.execPath, scriptPath: first },
483
- homeDir,
484
- 'win32',
485
- )
486
- const secondInstall = installHookAdapter(
487
- { execPath: process.execPath, scriptPath: second },
488
- homeDir,
489
- 'win32',
490
- )
491
- const inspected = inspectHookAdapter(homeDir, 'win32').target
492
-
493
- expect(secondInstall.path).toBe(firstInstall.path)
494
- expect(inspected && !isNpxAdapterTarget(inspected) ? inspected.scriptPath : null).toBe(second)
495
- })
496
- })