@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/credentials.ts
DELETED
|
@@ -1,403 +0,0 @@
|
|
|
1
|
-
import { execFileSync, spawnSync } from 'node:child_process'
|
|
2
|
-
import { existsSync, readFileSync, rmSync } from 'node:fs'
|
|
3
|
-
import path from 'node:path'
|
|
4
|
-
import { atomicWriteFileSync } from './atomic-file.js'
|
|
5
|
-
import { globalConfigDir } from './config.js'
|
|
6
|
-
|
|
7
|
-
export interface MachineCredential {
|
|
8
|
-
machineId: string
|
|
9
|
-
secret: string
|
|
10
|
-
baseUrl: string
|
|
11
|
-
machineName: string
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export interface CredentialStore {
|
|
15
|
-
load(): MachineCredential | null
|
|
16
|
-
save(credential: MachineCredential): void
|
|
17
|
-
clear(): void
|
|
18
|
-
/** Where the secret lives, for `doctor` and docs. */
|
|
19
|
-
describe(): string
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface RunCommandSpec {
|
|
23
|
-
command: string
|
|
24
|
-
args: readonly string[]
|
|
25
|
-
input: Buffer
|
|
26
|
-
timeoutMs?: number
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface RunCommandResult {
|
|
30
|
-
status: number
|
|
31
|
-
stdout: Buffer
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Injected process seam. Production uses `defaultProcessRunner`. */
|
|
35
|
-
export interface ProcessRunner {
|
|
36
|
-
run(spec: RunCommandSpec): RunCommandResult
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface CredentialStoreOptions {
|
|
40
|
-
env?: NodeJS.ProcessEnv
|
|
41
|
-
platform?: NodeJS.Platform
|
|
42
|
-
run?: ProcessRunner
|
|
43
|
-
keychainAvailable?: boolean
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const SERVICE = 'io.notifai.cli'
|
|
47
|
-
const CREDENTIAL_FORMAT = 'notifai.machine-credential.v1'
|
|
48
|
-
const DPAPI_FORMAT = 'notifai.dpapi.current-user.v1'
|
|
49
|
-
const DPAPI_ENTROPY = 'io.notifai.cli'
|
|
50
|
-
// Inbox Windows PowerShell can have a slow first start on a newly provisioned
|
|
51
|
-
// or heavily loaded machine. Keep the helper bounded without rejecting a
|
|
52
|
-
// valid DPAPI operation during that cold-start window.
|
|
53
|
-
const DPAPI_TIMEOUT_MS = 30_000
|
|
54
|
-
const DPAPI_FILE = 'credentials.dpapi'
|
|
55
|
-
const KEYCHAIN_TIMEOUT_MS = 15_000
|
|
56
|
-
|
|
57
|
-
const PROTECT_SCRIPT = [
|
|
58
|
-
"$ErrorActionPreference = 'Stop'",
|
|
59
|
-
'Add-Type -AssemblyName System.Security',
|
|
60
|
-
'$in = New-Object System.IO.MemoryStream',
|
|
61
|
-
'[Console]::OpenStandardInput().CopyTo($in)',
|
|
62
|
-
`$entropy = [System.Text.Encoding]::UTF8.GetBytes('${DPAPI_ENTROPY}')`,
|
|
63
|
-
'$protected = [System.Security.Cryptography.ProtectedData]::Protect($in.ToArray(), $entropy, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)',
|
|
64
|
-
'$bytes = [System.Text.Encoding]::ASCII.GetBytes([Convert]::ToBase64String($protected))',
|
|
65
|
-
'[Console]::OpenStandardOutput().Write($bytes, 0, $bytes.Length)',
|
|
66
|
-
].join('; ')
|
|
67
|
-
|
|
68
|
-
const UNPROTECT_SCRIPT = [
|
|
69
|
-
"$ErrorActionPreference = 'Stop'",
|
|
70
|
-
'Add-Type -AssemblyName System.Security',
|
|
71
|
-
'$b64 = [System.Text.Encoding]::ASCII.GetString((& { $in = New-Object System.IO.MemoryStream; [Console]::OpenStandardInput().CopyTo($in); $in.ToArray() })).Trim()',
|
|
72
|
-
'$protected = [Convert]::FromBase64String($b64)',
|
|
73
|
-
`$entropy = [System.Text.Encoding]::UTF8.GetBytes('${DPAPI_ENTROPY}')`,
|
|
74
|
-
'$plain = [System.Security.Cryptography.ProtectedData]::Unprotect($protected, $entropy, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)',
|
|
75
|
-
'[Console]::OpenStandardOutput().Write($plain, 0, $plain.Length)',
|
|
76
|
-
].join('; ')
|
|
77
|
-
|
|
78
|
-
const PROTECT_ENCODED = encodePowerShell(PROTECT_SCRIPT)
|
|
79
|
-
const UNPROTECT_ENCODED = encodePowerShell(UNPROTECT_SCRIPT)
|
|
80
|
-
|
|
81
|
-
function encodePowerShell(script: string): string {
|
|
82
|
-
return Buffer.from(script, 'utf16le').toString('base64')
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function powerShellInvocation(encoded: string, env: NodeJS.ProcessEnv): { command: string; args: string[] } {
|
|
86
|
-
const root = env['SystemRoot'] || env['SYSTEMROOT'] || 'C:\\Windows'
|
|
87
|
-
return {
|
|
88
|
-
command: path.win32.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
|
|
89
|
-
args: ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Which DPAPI operation a process invocation asked for. Args never carry secrets. */
|
|
94
|
-
export function windowsDpapiOperation(args: readonly string[]): 'protect' | 'unprotect' | null {
|
|
95
|
-
const index = args.indexOf('-EncodedCommand')
|
|
96
|
-
const encoded = index >= 0 ? args[index + 1] : undefined
|
|
97
|
-
if (encoded === PROTECT_ENCODED) return 'protect'
|
|
98
|
-
if (encoded === UNPROTECT_ENCODED) return 'unprotect'
|
|
99
|
-
return null
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export const defaultProcessRunner: ProcessRunner = {
|
|
103
|
-
run(spec) {
|
|
104
|
-
const result = spawnSync(spec.command, [...spec.args], {
|
|
105
|
-
input: spec.input,
|
|
106
|
-
timeout: spec.timeoutMs ?? DPAPI_TIMEOUT_MS,
|
|
107
|
-
maxBuffer: 1024 * 1024,
|
|
108
|
-
windowsHide: true,
|
|
109
|
-
})
|
|
110
|
-
const stdout = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.alloc(0)
|
|
111
|
-
if (result.error) return { status: 1, stdout: Buffer.alloc(0) }
|
|
112
|
-
return { status: result.status ?? 1, stdout }
|
|
113
|
-
},
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* macOS Keychain via the first-party `security` Security.framework client.
|
|
118
|
-
*
|
|
119
|
-
* `security add-generic-password -w <value>` puts the value in process argv.
|
|
120
|
-
* Interactive mode instead parses a command from stdin, so the credential
|
|
121
|
-
* crosses the helper boundary only through the pipe. `-X` keeps arbitrary
|
|
122
|
-
* serialized bytes out of the command parser's quoting rules.
|
|
123
|
-
*/
|
|
124
|
-
export class KeychainStore implements CredentialStore {
|
|
125
|
-
private readonly run: ProcessRunner
|
|
126
|
-
|
|
127
|
-
constructor(options: CredentialStoreOptions = {}) {
|
|
128
|
-
this.run = options.run ?? defaultProcessRunner
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
load(): MachineCredential | null {
|
|
132
|
-
try {
|
|
133
|
-
const raw = execFileSync(
|
|
134
|
-
'security',
|
|
135
|
-
['find-generic-password', '-s', SERVICE, '-a', 'machine', '-w'],
|
|
136
|
-
{ stdio: ['ignore', 'pipe', 'ignore'] },
|
|
137
|
-
)
|
|
138
|
-
.toString()
|
|
139
|
-
.trim()
|
|
140
|
-
return parseCredentialJson(raw)
|
|
141
|
-
} catch {
|
|
142
|
-
return null
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
save(credential: MachineCredential): void {
|
|
147
|
-
const serialized = Buffer.from(JSON.stringify(serializeCredential(credential)), 'utf8')
|
|
148
|
-
const command = Buffer.from(
|
|
149
|
-
`add-generic-password -U -s ${SERVICE} -a machine -X ${serialized.toString('hex')}\n`,
|
|
150
|
-
'ascii',
|
|
151
|
-
)
|
|
152
|
-
const result = this.run.run({
|
|
153
|
-
command: 'security',
|
|
154
|
-
args: ['-q', '-i'],
|
|
155
|
-
input: command,
|
|
156
|
-
timeoutMs: KEYCHAIN_TIMEOUT_MS,
|
|
157
|
-
})
|
|
158
|
-
if (result.status !== 0) {
|
|
159
|
-
throw new Error('macOS Keychain credential save failed')
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
clear(): void {
|
|
164
|
-
try {
|
|
165
|
-
execFileSync('security', ['delete-generic-password', '-s', SERVICE, '-a', 'machine'], {
|
|
166
|
-
stdio: 'ignore',
|
|
167
|
-
})
|
|
168
|
-
} catch {
|
|
169
|
-
// nothing stored
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
describe(): string {
|
|
174
|
-
return `macOS Keychain (${SERVICE})`
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* Explicit plaintext file store.
|
|
180
|
-
*
|
|
181
|
-
* Default on Linux, where mode 0600 is a real ACL. Also the
|
|
182
|
-
* `NOTIFAI_CREDENTIALS=file` development and test escape hatch on every
|
|
183
|
-
* platform. POSIX mode bits are not an NTFS ACL, so this is not a protected
|
|
184
|
-
* Windows store.
|
|
185
|
-
*/
|
|
186
|
-
export class FileStore implements CredentialStore {
|
|
187
|
-
private readonly env: NodeJS.ProcessEnv
|
|
188
|
-
private readonly platform: NodeJS.Platform
|
|
189
|
-
|
|
190
|
-
constructor(env: NodeJS.ProcessEnv = process.env, options: CredentialStoreOptions = {}) {
|
|
191
|
-
this.env = env
|
|
192
|
-
this.platform = options.platform ?? process.platform
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
private filePath(): string {
|
|
196
|
-
return path.join(globalConfigDir(this.env, this.platform), 'credentials.json')
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
load(): MachineCredential | null {
|
|
200
|
-
const file = this.filePath()
|
|
201
|
-
if (!existsSync(file)) return null
|
|
202
|
-
try {
|
|
203
|
-
return parseCredentialJson(readFileSync(file, 'utf8'))
|
|
204
|
-
} catch {
|
|
205
|
-
return null
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
save(credential: MachineCredential): void {
|
|
210
|
-
atomicWriteFileSync(this.filePath(), `${JSON.stringify(serializeCredential(credential), null, 2)}\n`, {
|
|
211
|
-
mode: 0o600,
|
|
212
|
-
preserveMode: false,
|
|
213
|
-
requireCurrentUserOwner: true,
|
|
214
|
-
})
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
clear(): void {
|
|
218
|
-
rmSync(this.filePath(), { force: true })
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
describe(): string {
|
|
222
|
-
const file = this.filePath()
|
|
223
|
-
if (this.platform === 'win32') {
|
|
224
|
-
return `plaintext file ${file} (NOTIFAI_CREDENTIALS=file; POSIX mode bits are not protection on NTFS)`
|
|
225
|
-
}
|
|
226
|
-
return `plaintext file ${file} (mode 0600)`
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* Windows default: DPAPI CurrentUser via first-party PowerShell.
|
|
232
|
-
*
|
|
233
|
-
* The protected blob lives under LOCALAPPDATA, never roaming APPDATA.
|
|
234
|
-
* Plaintext crosses the process boundary only on stdin/stdout — never as a
|
|
235
|
-
* command-line argument.
|
|
236
|
-
*/
|
|
237
|
-
export class WindowsDpapiStore implements CredentialStore {
|
|
238
|
-
private readonly env: NodeJS.ProcessEnv
|
|
239
|
-
private readonly run: ProcessRunner
|
|
240
|
-
|
|
241
|
-
constructor(env: NodeJS.ProcessEnv = process.env, options: CredentialStoreOptions = {}) {
|
|
242
|
-
this.env = env
|
|
243
|
-
this.run = options.run ?? defaultProcessRunner
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
private filePath(): string | null {
|
|
247
|
-
const local = this.env['LOCALAPPDATA']
|
|
248
|
-
if (typeof local !== 'string' || local.trim() === '') return null
|
|
249
|
-
return path.join(local, 'notifai', DPAPI_FILE)
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
load(): MachineCredential | null {
|
|
253
|
-
const file = this.filePath()
|
|
254
|
-
if (file === null || !existsSync(file)) return null
|
|
255
|
-
let text: string
|
|
256
|
-
try {
|
|
257
|
-
text = readFileSync(file, 'utf8')
|
|
258
|
-
} catch {
|
|
259
|
-
return null
|
|
260
|
-
}
|
|
261
|
-
const blob = parseDpapiEnvelope(text)
|
|
262
|
-
if (blob === null) return null
|
|
263
|
-
const plain = this.unprotect(blob)
|
|
264
|
-
return plain === null ? null : parseCredentialJson(plain)
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
save(credential: MachineCredential): void {
|
|
268
|
-
const file = this.filePath()
|
|
269
|
-
if (file === null) {
|
|
270
|
-
throw new Error('Windows credential store path is not configured (LOCALAPPDATA is missing)')
|
|
271
|
-
}
|
|
272
|
-
const blob = this.protect(Buffer.from(JSON.stringify(serializeCredential(credential)), 'utf8'))
|
|
273
|
-
atomicWriteFileSync(file, `${JSON.stringify({ format: DPAPI_FORMAT, data: blob })}\n`, {
|
|
274
|
-
mode: 0o600,
|
|
275
|
-
preserveMode: false,
|
|
276
|
-
requireCurrentUserOwner: true,
|
|
277
|
-
})
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
clear(): void {
|
|
281
|
-
const file = this.filePath()
|
|
282
|
-
if (file === null) return
|
|
283
|
-
rmSync(file, { force: true })
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
describe(): string {
|
|
287
|
-
const file = this.filePath()
|
|
288
|
-
if (file === null) return 'Windows DPAPI current-user (LOCALAPPDATA unset)'
|
|
289
|
-
return `Windows DPAPI current-user (${file})`
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
private protect(plaintext: Buffer): string {
|
|
293
|
-
const result = this.invoke('protect', plaintext)
|
|
294
|
-
if (result.status !== 0) {
|
|
295
|
-
throw new Error('Windows credential protection failed')
|
|
296
|
-
}
|
|
297
|
-
const encoded = result.stdout.toString('utf8').trim()
|
|
298
|
-
if (!isBase64(encoded)) {
|
|
299
|
-
throw new Error('Windows credential protection failed')
|
|
300
|
-
}
|
|
301
|
-
return encoded
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
private unprotect(blob: string): string | null {
|
|
305
|
-
const result = this.invoke('unprotect', Buffer.from(blob, 'utf8'))
|
|
306
|
-
if (result.status !== 0) return null
|
|
307
|
-
const plain = result.stdout.toString('utf8')
|
|
308
|
-
return plain === '' ? null : plain
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
private invoke(operation: 'protect' | 'unprotect', input: Buffer): RunCommandResult {
|
|
312
|
-
const encoded = operation === 'protect' ? PROTECT_ENCODED : UNPROTECT_ENCODED
|
|
313
|
-
const invocation = powerShellInvocation(encoded, this.env)
|
|
314
|
-
return this.run.run({
|
|
315
|
-
command: invocation.command,
|
|
316
|
-
args: invocation.args,
|
|
317
|
-
input,
|
|
318
|
-
timeoutMs: DPAPI_TIMEOUT_MS,
|
|
319
|
-
})
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function serializeCredential(credential: MachineCredential): Record<string, string> {
|
|
324
|
-
return { format: CREDENTIAL_FORMAT, ...credential }
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
function parseCredentialJson(raw: string): MachineCredential | null {
|
|
328
|
-
try {
|
|
329
|
-
return parseCredential(JSON.parse(raw) as unknown)
|
|
330
|
-
} catch {
|
|
331
|
-
return null
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
function parseCredential(raw: unknown): MachineCredential | null {
|
|
336
|
-
if (raw === null || typeof raw !== 'object') return null
|
|
337
|
-
const value = raw as Record<string, unknown>
|
|
338
|
-
const format = value['format']
|
|
339
|
-
// Unversioned credentials are the pre-epoch v1 representation. A future
|
|
340
|
-
// format is left untouched on disk/keychain and treated as unavailable;
|
|
341
|
-
// interpreting it as v1 could silently change authentication meaning.
|
|
342
|
-
if (format !== undefined && format !== CREDENTIAL_FORMAT) return null
|
|
343
|
-
const machineId = value['machineId']
|
|
344
|
-
const secret = value['secret']
|
|
345
|
-
const baseUrl = value['baseUrl']
|
|
346
|
-
const machineName = value['machineName']
|
|
347
|
-
if (
|
|
348
|
-
typeof machineId !== 'string' ||
|
|
349
|
-
machineId === '' ||
|
|
350
|
-
typeof secret !== 'string' ||
|
|
351
|
-
secret === '' ||
|
|
352
|
-
typeof baseUrl !== 'string' ||
|
|
353
|
-
baseUrl === '' ||
|
|
354
|
-
typeof machineName !== 'string' ||
|
|
355
|
-
machineName === ''
|
|
356
|
-
) {
|
|
357
|
-
return null
|
|
358
|
-
}
|
|
359
|
-
return { machineId, secret, baseUrl, machineName }
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
function parseDpapiEnvelope(text: string): string | null {
|
|
363
|
-
let parsed: unknown
|
|
364
|
-
try {
|
|
365
|
-
parsed = JSON.parse(text)
|
|
366
|
-
} catch {
|
|
367
|
-
return null
|
|
368
|
-
}
|
|
369
|
-
if (parsed === null || typeof parsed !== 'object') return null
|
|
370
|
-
const value = parsed as Record<string, unknown>
|
|
371
|
-
if (value['format'] !== DPAPI_FORMAT) return null
|
|
372
|
-
const data = value['data']
|
|
373
|
-
if (typeof data !== 'string' || !isBase64(data)) return null
|
|
374
|
-
return data
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
function isBase64(value: string): boolean {
|
|
378
|
-
return value.length > 0 && value.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(value)
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
function keychainProbeSucceeds(): boolean {
|
|
382
|
-
try {
|
|
383
|
-
execFileSync('security', ['help'], { stdio: 'ignore' })
|
|
384
|
-
return true
|
|
385
|
-
} catch {
|
|
386
|
-
return false
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
export function defaultCredentialStore(
|
|
391
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
392
|
-
options: CredentialStoreOptions = {},
|
|
393
|
-
): CredentialStore {
|
|
394
|
-
const platform = options.platform ?? process.platform
|
|
395
|
-
if (env['NOTIFAI_CREDENTIALS'] === 'file') return new FileStore(env, { platform })
|
|
396
|
-
if (platform === 'win32') return new WindowsDpapiStore(env, options)
|
|
397
|
-
const keychain =
|
|
398
|
-
options.keychainAvailable !== undefined
|
|
399
|
-
? options.keychainAvailable
|
|
400
|
-
: platform === 'darwin' && keychainProbeSucceeds()
|
|
401
|
-
if (keychain) return new KeychainStore(options)
|
|
402
|
-
return new FileStore(env, { platform })
|
|
403
|
-
}
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
-
import { describe, expect, it } from 'vitest'
|
|
3
|
-
import { withFileLock, type FileLockObservation } from './file-lock.js'
|
|
4
|
-
|
|
5
|
-
const worker = process.env['NOTIFAI_FILE_LOCK_WORKER']
|
|
6
|
-
const sleep = new Int32Array(new SharedArrayBuffer(4))
|
|
7
|
-
|
|
8
|
-
function waitFor(file: string): void {
|
|
9
|
-
const deadline = Date.now() + 15_000
|
|
10
|
-
while (!existsSync(file)) {
|
|
11
|
-
if (Date.now() >= deadline) throw new Error(`timed out waiting for ${file}`)
|
|
12
|
-
Atomics.wait(sleep, 0, 0, 2)
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
if (worker === undefined) {
|
|
17
|
-
describe('file lock process worker', () => {
|
|
18
|
-
it('stays dormant outside the cross-process regressions', () => {
|
|
19
|
-
expect(worker).toBeUndefined()
|
|
20
|
-
})
|
|
21
|
-
})
|
|
22
|
-
} else {
|
|
23
|
-
describe('file lock process worker', () => {
|
|
24
|
-
it('runs one instrumented lock contender', () => {
|
|
25
|
-
const pauseAt = process.env['NOTIFAI_FILE_LOCK_PAUSE_AT']
|
|
26
|
-
const pausePath = process.env['NOTIFAI_FILE_LOCK_PAUSED']!
|
|
27
|
-
const continuePath = process.env['NOTIFAI_FILE_LOCK_CONTINUE']!
|
|
28
|
-
const enteredPath = process.env['NOTIFAI_FILE_LOCK_ENTERED']!
|
|
29
|
-
const releasePath = process.env['NOTIFAI_FILE_LOCK_RELEASE']!
|
|
30
|
-
const waitPath = process.env['NOTIFAI_FILE_LOCK_WAITS']!
|
|
31
|
-
let waits = 0
|
|
32
|
-
|
|
33
|
-
withFileLock(
|
|
34
|
-
process.env['NOTIFAI_FILE_LOCK_PATH']!,
|
|
35
|
-
() => {
|
|
36
|
-
writeFileSync(enteredPath, worker)
|
|
37
|
-
if (process.env['NOTIFAI_FILE_LOCK_ACTION'] === 'crash') {
|
|
38
|
-
process.kill(process.pid, 'SIGKILL')
|
|
39
|
-
throw new Error('SIGKILL did not terminate the worker')
|
|
40
|
-
}
|
|
41
|
-
waitFor(releasePath)
|
|
42
|
-
},
|
|
43
|
-
{
|
|
44
|
-
waitMs: 15_000,
|
|
45
|
-
observe(observation: FileLockObservation) {
|
|
46
|
-
if (
|
|
47
|
-
observation.phase === 'choosing-published' ||
|
|
48
|
-
observation.phase === 'stale-entry'
|
|
49
|
-
) {
|
|
50
|
-
writeFileSync(pausePath, observation.entry)
|
|
51
|
-
if (pauseAt === observation.phase) waitFor(continuePath)
|
|
52
|
-
}
|
|
53
|
-
if (observation.phase === 'waiting') {
|
|
54
|
-
waits += 1
|
|
55
|
-
writeFileSync(waitPath, String(waits))
|
|
56
|
-
}
|
|
57
|
-
},
|
|
58
|
-
},
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
expect(readFileSync(enteredPath, 'utf8')).toBe(worker)
|
|
62
|
-
})
|
|
63
|
-
})
|
|
64
|
-
}
|