@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/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
|
-
}
|
package/src/hook-adapter.test.ts
DELETED
|
@@ -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
|
-
})
|