@raidiant/notifai 1.0.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +17 -7
  3. package/dist/client.d.ts +5 -1
  4. package/dist/client.js +2 -0
  5. package/dist/client.js.map +1 -1
  6. package/dist/commands.d.ts +10 -1
  7. package/dist/commands.js +151 -54
  8. package/dist/commands.js.map +1 -1
  9. package/dist/config.d.ts +2 -2
  10. package/dist/config.js +5 -9
  11. package/dist/config.js.map +1 -1
  12. package/dist/credentials.d.ts +54 -3
  13. package/dist/credentials.js +237 -18
  14. package/dist/credentials.js.map +1 -1
  15. package/dist/file-lock.js +15 -6
  16. package/dist/file-lock.js.map +1 -1
  17. package/dist/hook-adapter.d.ts +6 -2
  18. package/dist/hook-adapter.js +110 -29
  19. package/dist/hook-adapter.js.map +1 -1
  20. package/dist/hooks.d.ts +15 -1
  21. package/dist/hooks.js +183 -12
  22. package/dist/hooks.js.map +1 -1
  23. package/dist/install-hooks.d.ts +50 -21
  24. package/dist/install-hooks.js +141 -57
  25. package/dist/install-hooks.js.map +1 -1
  26. package/dist/logging.d.ts +1 -1
  27. package/dist/logging.js +4 -0
  28. package/dist/logging.js.map +1 -1
  29. package/dist/main.js +14 -3
  30. package/dist/main.js.map +1 -1
  31. package/dist/native-skills.js +5 -10
  32. package/dist/native-skills.js.map +1 -1
  33. package/dist/opencode-plugin.d.ts +7 -2
  34. package/dist/opencode-plugin.js +142 -4
  35. package/dist/opencode-plugin.js.map +1 -1
  36. package/dist/platform.d.ts +73 -0
  37. package/dist/platform.js +143 -0
  38. package/dist/platform.js.map +1 -0
  39. package/dist/ui/config-view.js +5 -1
  40. package/dist/ui/config-view.js.map +1 -1
  41. package/package.json +11 -10
  42. package/src/client.test.ts +53 -0
  43. package/src/client.ts +24 -0
  44. package/src/commands.test.ts +351 -30
  45. package/src/commands.ts +207 -49
  46. package/src/config.ts +11 -9
  47. package/src/credentials.test.ts +291 -0
  48. package/src/credentials.ts +274 -17
  49. package/src/file-lock.ts +34 -9
  50. package/src/hook-adapter.test.ts +136 -0
  51. package/src/hook-adapter.ts +120 -28
  52. package/src/hooks.test.ts +175 -1
  53. package/src/hooks.ts +241 -12
  54. package/src/install-hooks.test.ts +174 -33
  55. package/src/install-hooks.ts +186 -54
  56. package/src/logging.ts +4 -0
  57. package/src/main.ts +17 -2
  58. package/src/native-skills.ts +5 -10
  59. package/src/opencode-plugin.ts +148 -3
  60. package/src/platform.test.ts +152 -0
  61. package/src/platform.ts +173 -0
  62. package/src/skill.test.ts +32 -1
  63. package/src/ui/config-view.ts +5 -5
@@ -0,0 +1,291 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ mkdtempSync,
5
+ readFileSync,
6
+ readdirSync,
7
+ rmSync,
8
+ statSync,
9
+ writeFileSync,
10
+ } from 'node:fs'
11
+ import os from 'node:os'
12
+ import path from 'node:path'
13
+ import { afterAll, describe, expect, it } from 'vitest'
14
+ import {
15
+ FileStore,
16
+ WindowsDpapiStore,
17
+ defaultCredentialStore,
18
+ windowsDpapiOperation,
19
+ type MachineCredential,
20
+ type ProcessRunner,
21
+ type RunCommandSpec,
22
+ } from './credentials.js'
23
+
24
+ const tmp = mkdtempSync(path.join(os.tmpdir(), 'notifai-cred-'))
25
+ afterAll(() => rmSync(tmp, { recursive: true, force: true }))
26
+
27
+ const SAMPLE: MachineCredential = {
28
+ machineId: 'mach_fixture',
29
+ secret: 'fixture-secret',
30
+ baseUrl: 'https://example.test',
31
+ machineName: 'fixture-machine',
32
+ }
33
+
34
+ const OTHER: MachineCredential = {
35
+ machineId: 'mach_other',
36
+ secret: 'other-secret',
37
+ baseUrl: 'https://other.test',
38
+ machineName: 'other-machine',
39
+ }
40
+
41
+ function sandbox(name: string): {
42
+ env: NodeJS.ProcessEnv
43
+ local: string
44
+ roaming: string
45
+ config: string
46
+ } {
47
+ const root = path.join(tmp, name)
48
+ const local = path.join(root, 'local')
49
+ const roaming = path.join(root, 'roaming')
50
+ const config = path.join(root, 'config')
51
+ mkdirSync(local, { recursive: true })
52
+ mkdirSync(roaming, { recursive: true })
53
+ mkdirSync(config, { recursive: true })
54
+ return {
55
+ local,
56
+ roaming,
57
+ config,
58
+ env: {
59
+ LOCALAPPDATA: local,
60
+ APPDATA: roaming,
61
+ XDG_CONFIG_HOME: config,
62
+ SystemRoot: 'C:\\Windows',
63
+ },
64
+ }
65
+ }
66
+
67
+ function writeRaw(file: string, contents: string): void {
68
+ mkdirSync(path.dirname(file), { recursive: true })
69
+ writeFileSync(file, contents)
70
+ }
71
+
72
+ function mockDpapi(options: { failProtect?: boolean; failUnprotect?: boolean } = {}): {
73
+ runner: ProcessRunner
74
+ calls: RunCommandSpec[]
75
+ } {
76
+ const vault = new Map<string, string>()
77
+ const calls: RunCommandSpec[] = []
78
+ const runner: ProcessRunner = {
79
+ run(spec) {
80
+ calls.push({
81
+ command: spec.command,
82
+ args: spec.args,
83
+ input: spec.input,
84
+ ...(spec.timeoutMs === undefined ? {} : { timeoutMs: spec.timeoutMs }),
85
+ })
86
+ const operation = windowsDpapiOperation(spec.args)
87
+ if (operation === 'protect') {
88
+ if (options.failProtect) return { status: 1, stdout: Buffer.alloc(0) }
89
+ const token = Buffer.from(`dpapi:${vault.size}`, 'utf8').toString('base64')
90
+ vault.set(token, spec.input.toString('utf8'))
91
+ return { status: 0, stdout: Buffer.from(token, 'utf8') }
92
+ }
93
+ if (operation === 'unprotect') {
94
+ if (options.failUnprotect) return { status: 1, stdout: Buffer.alloc(0) }
95
+ const token = spec.input.toString('utf8').trim()
96
+ const plain = vault.get(token)
97
+ if (plain === undefined) return { status: 1, stdout: Buffer.alloc(0) }
98
+ return { status: 0, stdout: Buffer.from(plain, 'utf8') }
99
+ }
100
+ return { status: 1, stdout: Buffer.alloc(0) }
101
+ },
102
+ }
103
+ return { runner, calls }
104
+ }
105
+
106
+ function assertNoSecretInArgs(calls: RunCommandSpec[]): void {
107
+ for (const call of calls) {
108
+ const argv = [call.command, ...call.args].join('\0')
109
+ expect(argv).not.toContain(SAMPLE.secret)
110
+ expect(argv).not.toContain(OTHER.secret)
111
+ expect(argv).not.toContain(SAMPLE.machineId)
112
+ expect(argv).not.toContain('machineId')
113
+ }
114
+ }
115
+
116
+ describe('defaultCredentialStore', () => {
117
+ it('selects the DPAPI store on win32', () => {
118
+ const { env } = sandbox('default-win32')
119
+ const store = defaultCredentialStore(env, { platform: 'win32', run: mockDpapi().runner })
120
+ expect(store).toBeInstanceOf(WindowsDpapiStore)
121
+ expect(store.describe()).toMatch(/Windows DPAPI current-user/)
122
+ expect(store.describe()).toContain(path.join(env.LOCALAPPDATA!, 'notifai', 'credentials.dpapi'))
123
+ })
124
+
125
+ it('keeps the explicit file escape hatch on win32 under APPDATA', () => {
126
+ const { env } = sandbox('default-file-win32')
127
+ const store = defaultCredentialStore(
128
+ { ...env, NOTIFAI_CREDENTIALS: 'file', XDG_CONFIG_HOME: undefined },
129
+ { platform: 'win32' },
130
+ )
131
+ expect(store).toBeInstanceOf(FileStore)
132
+ expect(store.describe()).toContain(path.join(env.APPDATA!, 'notifai', 'credentials.json'))
133
+ expect(store.describe()).toMatch(/plaintext file/)
134
+ expect(store.describe()).toMatch(/POSIX mode bits are not protection on NTFS/)
135
+ expect(store.describe()).not.toMatch(/0600/)
136
+ })
137
+
138
+ it('preserves macOS Keychain when available', () => {
139
+ const store = defaultCredentialStore({}, { platform: 'darwin', keychainAvailable: true })
140
+ expect(store.describe()).toBe('macOS Keychain (io.notifai.cli)')
141
+ })
142
+
143
+ it('falls back to the 0600 file store on linux', () => {
144
+ const { env } = sandbox('default-linux')
145
+ const store = defaultCredentialStore(env, { platform: 'linux' })
146
+ expect(store).toBeInstanceOf(FileStore)
147
+ expect(store.describe()).toMatch(/plaintext file/)
148
+ expect(store.describe()).toMatch(/mode 0600/)
149
+ expect(store.describe()).not.toMatch(/NTFS/)
150
+ })
151
+
152
+ it('uses the file store on macOS when Keychain is unavailable', () => {
153
+ const { env } = sandbox('default-darwin-file')
154
+ const store = defaultCredentialStore(env, { platform: 'darwin', keychainAvailable: false })
155
+ expect(store).toBeInstanceOf(FileStore)
156
+ expect(store.describe()).toMatch(/mode 0600/)
157
+ })
158
+ })
159
+
160
+ describe('FileStore', () => {
161
+ it('saves, loads, overwrites, and clears atomically', () => {
162
+ const { env } = sandbox('file-roundtrip')
163
+ const store = new FileStore(env, { platform: 'linux' })
164
+ expect(store.load()).toBeNull()
165
+ store.save(SAMPLE)
166
+ const file = path.join(env.XDG_CONFIG_HOME!, 'notifai', 'credentials.json')
167
+ expect(existsSync(file)).toBe(true)
168
+ if (process.platform !== 'win32') {
169
+ expect(statSync(file).mode & 0o777).toBe(0o600)
170
+ }
171
+ expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual(SAMPLE)
172
+ expect(store.load()).toEqual(SAMPLE)
173
+ store.save(OTHER)
174
+ expect(store.load()).toEqual(OTHER)
175
+ store.clear()
176
+ expect(existsSync(file)).toBe(false)
177
+ expect(store.load()).toBeNull()
178
+ })
179
+
180
+ it('treats malformed JSON as missing', () => {
181
+ const { env } = sandbox('file-malformed')
182
+ const file = path.join(env.XDG_CONFIG_HOME!, 'notifai', 'credentials.json')
183
+ writeRaw(file, '{not-json')
184
+ expect(new FileStore(env).load()).toBeNull()
185
+ })
186
+
187
+ it('treats incomplete objects as missing', () => {
188
+ const { env } = sandbox('file-incomplete')
189
+ const file = path.join(env.XDG_CONFIG_HOME!, 'notifai', 'credentials.json')
190
+ writeRaw(file, `${JSON.stringify({ machineId: 'mach_fixture' })}\n`)
191
+ expect(new FileStore(env).load()).toBeNull()
192
+ })
193
+ })
194
+
195
+ describe('WindowsDpapiStore', () => {
196
+ it('protects under LOCALAPPDATA and never writes roaming or config trees', () => {
197
+ const { env, roaming, config } = sandbox('dpapi-location')
198
+ const { runner, calls } = mockDpapi()
199
+ const store = new WindowsDpapiStore(env, { run: runner })
200
+ store.save(SAMPLE)
201
+ const file = path.join(env.LOCALAPPDATA!, 'notifai', 'credentials.dpapi')
202
+ expect(existsSync(file)).toBe(true)
203
+ const onDisk = readFileSync(file, 'utf8')
204
+ expect(onDisk).not.toContain(SAMPLE.secret)
205
+ expect(onDisk).not.toContain(SAMPLE.machineId)
206
+ expect(JSON.parse(onDisk)).toMatchObject({ format: 'notifai.dpapi.current-user.v1' })
207
+ expect(readdirSync(roaming, { recursive: true })).toEqual([])
208
+ expect(existsSync(config) ? readdirSync(config, { recursive: true }) : []).toEqual([])
209
+ expect(store.load()).toEqual(SAMPLE)
210
+ expect(store.describe()).toBe(`Windows DPAPI current-user (${file})`)
211
+ expect(calls[0]?.command).toMatch(/powershell\.exe$/i)
212
+ expect(calls[0]?.args).toEqual(
213
+ expect.arrayContaining(['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass']),
214
+ )
215
+ assertNoSecretInArgs(calls)
216
+ })
217
+
218
+ it('overwrites and clears the protected blob', () => {
219
+ const { env } = sandbox('dpapi-overwrite')
220
+ const { runner, calls } = mockDpapi()
221
+ const store = new WindowsDpapiStore(env, { run: runner })
222
+ store.save(SAMPLE)
223
+ store.save(OTHER)
224
+ expect(store.load()).toEqual(OTHER)
225
+ store.clear()
226
+ expect(store.load()).toBeNull()
227
+ expect(
228
+ existsSync(path.join(env.LOCALAPPDATA!, 'notifai', 'credentials.dpapi')),
229
+ ).toBe(false)
230
+ assertNoSecretInArgs(calls)
231
+ })
232
+
233
+ it('returns null for missing, malformed, and corrupt envelopes', () => {
234
+ const { env } = sandbox('dpapi-corrupt')
235
+ const { runner } = mockDpapi()
236
+ const store = new WindowsDpapiStore(env, { run: runner })
237
+ expect(store.load()).toBeNull()
238
+ const file = path.join(env.LOCALAPPDATA!, 'notifai', 'credentials.dpapi')
239
+ writeRaw(file, 'not-json')
240
+ expect(store.load()).toBeNull()
241
+ writeRaw(file, `${JSON.stringify({ format: 'unknown', data: 'AAAA' })}\n`)
242
+ expect(store.load()).toBeNull()
243
+ writeRaw(
244
+ file,
245
+ `${JSON.stringify({ format: 'notifai.dpapi.current-user.v1', data: '@@@' })}\n`,
246
+ )
247
+ expect(store.load()).toBeNull()
248
+ })
249
+
250
+ it('leaves the existing blob in place when protection fails', () => {
251
+ const { env } = sandbox('dpapi-protect-fail')
252
+ const good = mockDpapi()
253
+ const store = new WindowsDpapiStore(env, { run: good.runner })
254
+ store.save(SAMPLE)
255
+ const failing = mockDpapi({ failProtect: true })
256
+ const broken = new WindowsDpapiStore(env, { run: failing.runner })
257
+ expect(() => broken.save(OTHER)).toThrow(/Windows credential protection failed/)
258
+ expect(store.load()).toEqual(SAMPLE)
259
+ const onDisk = readFileSync(path.join(env.LOCALAPPDATA!, 'notifai', 'credentials.dpapi'), 'utf8')
260
+ expect(onDisk).not.toContain(OTHER.secret)
261
+ assertNoSecretInArgs([...good.calls, ...failing.calls])
262
+ })
263
+
264
+ it('returns null when unprotect fails', () => {
265
+ const { env } = sandbox('dpapi-unprotect-fail')
266
+ const saved = mockDpapi()
267
+ new WindowsDpapiStore(env, { run: saved.runner }).save(SAMPLE)
268
+ const failing = mockDpapi({ failUnprotect: true })
269
+ expect(new WindowsDpapiStore(env, { run: failing.runner }).load()).toBeNull()
270
+ assertNoSecretInArgs([...saved.calls, ...failing.calls])
271
+ })
272
+
273
+ it('refuses to save when LOCALAPPDATA is missing', () => {
274
+ const store = new WindowsDpapiStore({}, { run: mockDpapi().runner })
275
+ expect(store.load()).toBeNull()
276
+ expect(() => store.save(SAMPLE)).toThrow(/LOCALAPPDATA is missing/)
277
+ expect(store.describe()).toBe('Windows DPAPI current-user (LOCALAPPDATA unset)')
278
+ store.clear()
279
+ })
280
+
281
+ it('does not invoke a process for a missing or corrupt file', () => {
282
+ const { env } = sandbox('dpapi-no-process')
283
+ const { runner, calls } = mockDpapi()
284
+ const store = new WindowsDpapiStore(env, { run: runner })
285
+ expect(store.load()).toBeNull()
286
+ const file = path.join(env.LOCALAPPDATA!, 'notifai', 'credentials.dpapi')
287
+ writeRaw(file, 'corrupt')
288
+ expect(store.load()).toBeNull()
289
+ expect(calls).toEqual([])
290
+ })
291
+ })
@@ -1,6 +1,7 @@
1
- import { execFileSync } from 'node:child_process'
2
- import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
1
+ import { execFileSync, spawnSync } from 'node:child_process'
2
+ import { existsSync, readFileSync, rmSync } from 'node:fs'
3
3
  import path from 'node:path'
4
+ import { atomicWriteFileSync } from './atomic-file.js'
4
5
  import { globalConfigDir } from './config.js'
5
6
 
6
7
  export interface MachineCredential {
@@ -18,7 +19,94 @@ export interface CredentialStore {
18
19
  describe(): string
19
20
  }
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
+
21
46
  const SERVICE = 'io.notifai.cli'
47
+ const DPAPI_FORMAT = 'notifai.dpapi.current-user.v1'
48
+ const DPAPI_ENTROPY = 'io.notifai.cli'
49
+ const DPAPI_TIMEOUT_MS = 15_000
50
+ const DPAPI_FILE = 'credentials.dpapi'
51
+
52
+ const PROTECT_SCRIPT = [
53
+ "$ErrorActionPreference = 'Stop'",
54
+ 'Add-Type -AssemblyName System.Security',
55
+ '$in = New-Object System.IO.MemoryStream',
56
+ '[Console]::OpenStandardInput().CopyTo($in)',
57
+ `$entropy = [System.Text.Encoding]::UTF8.GetBytes('${DPAPI_ENTROPY}')`,
58
+ '$protected = [System.Security.Cryptography.ProtectedData]::Protect($in.ToArray(), $entropy, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)',
59
+ '$bytes = [System.Text.Encoding]::ASCII.GetBytes([Convert]::ToBase64String($protected))',
60
+ '[Console]::OpenStandardOutput().Write($bytes, 0, $bytes.Length)',
61
+ ].join('; ')
62
+
63
+ const UNPROTECT_SCRIPT = [
64
+ "$ErrorActionPreference = 'Stop'",
65
+ 'Add-Type -AssemblyName System.Security',
66
+ '$b64 = [System.Text.Encoding]::ASCII.GetString((& { $in = New-Object System.IO.MemoryStream; [Console]::OpenStandardInput().CopyTo($in); $in.ToArray() })).Trim()',
67
+ '$protected = [Convert]::FromBase64String($b64)',
68
+ `$entropy = [System.Text.Encoding]::UTF8.GetBytes('${DPAPI_ENTROPY}')`,
69
+ '$plain = [System.Security.Cryptography.ProtectedData]::Unprotect($protected, $entropy, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)',
70
+ '[Console]::OpenStandardOutput().Write($plain, 0, $plain.Length)',
71
+ ].join('; ')
72
+
73
+ const PROTECT_ENCODED = encodePowerShell(PROTECT_SCRIPT)
74
+ const UNPROTECT_ENCODED = encodePowerShell(UNPROTECT_SCRIPT)
75
+
76
+ function encodePowerShell(script: string): string {
77
+ return Buffer.from(script, 'utf16le').toString('base64')
78
+ }
79
+
80
+ function powerShellInvocation(encoded: string, env: NodeJS.ProcessEnv): { command: string; args: string[] } {
81
+ const root = env['SystemRoot'] || env['SYSTEMROOT'] || 'C:\\Windows'
82
+ return {
83
+ command: path.win32.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
84
+ args: ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
85
+ }
86
+ }
87
+
88
+ /** Which DPAPI operation a process invocation asked for. Args never carry secrets. */
89
+ export function windowsDpapiOperation(args: readonly string[]): 'protect' | 'unprotect' | null {
90
+ const index = args.indexOf('-EncodedCommand')
91
+ const encoded = index >= 0 ? args[index + 1] : undefined
92
+ if (encoded === PROTECT_ENCODED) return 'protect'
93
+ if (encoded === UNPROTECT_ENCODED) return 'unprotect'
94
+ return null
95
+ }
96
+
97
+ export const defaultProcessRunner: ProcessRunner = {
98
+ run(spec) {
99
+ const result = spawnSync(spec.command, [...spec.args], {
100
+ input: spec.input,
101
+ timeout: spec.timeoutMs ?? DPAPI_TIMEOUT_MS,
102
+ maxBuffer: 1024 * 1024,
103
+ windowsHide: true,
104
+ })
105
+ const stdout = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.alloc(0)
106
+ if (result.error) return { status: 1, stdout: Buffer.alloc(0) }
107
+ return { status: result.status ?? 1, stdout }
108
+ },
109
+ }
22
110
 
23
111
  /** macOS Keychain via the `security` CLI — no native addons (proposal concern). */
24
112
  export class KeychainStore implements CredentialStore {
@@ -31,7 +119,7 @@ export class KeychainStore implements CredentialStore {
31
119
  )
32
120
  .toString()
33
121
  .trim()
34
- return JSON.parse(raw) as MachineCredential
122
+ return parseCredentialJson(raw)
35
123
  } catch {
36
124
  return null
37
125
  }
@@ -69,29 +157,43 @@ export class KeychainStore implements CredentialStore {
69
157
  }
70
158
  }
71
159
 
72
- /** 0600 file fallback for hosts without a keychain. */
160
+ /**
161
+ * Explicit plaintext file store.
162
+ *
163
+ * Default on Linux, where mode 0600 is a real ACL. Also the
164
+ * `NOTIFAI_CREDENTIALS=file` development and test escape hatch on every
165
+ * platform. POSIX mode bits are not an NTFS ACL, so this is not a protected
166
+ * Windows store.
167
+ */
73
168
  export class FileStore implements CredentialStore {
74
- constructor(private readonly env: NodeJS.ProcessEnv = process.env) {}
169
+ private readonly env: NodeJS.ProcessEnv
170
+ private readonly platform: NodeJS.Platform
171
+
172
+ constructor(env: NodeJS.ProcessEnv = process.env, options: CredentialStoreOptions = {}) {
173
+ this.env = env
174
+ this.platform = options.platform ?? process.platform
175
+ }
75
176
 
76
177
  private filePath(): string {
77
- return path.join(globalConfigDir(this.env), 'credentials.json')
178
+ return path.join(globalConfigDir(this.env, this.platform), 'credentials.json')
78
179
  }
79
180
 
80
181
  load(): MachineCredential | null {
81
182
  const file = this.filePath()
82
183
  if (!existsSync(file)) return null
83
184
  try {
84
- return JSON.parse(readFileSync(file, 'utf8')) as MachineCredential
185
+ return parseCredentialJson(readFileSync(file, 'utf8'))
85
186
  } catch {
86
187
  return null
87
188
  }
88
189
  }
89
190
 
90
191
  save(credential: MachineCredential): void {
91
- const file = this.filePath()
92
- mkdirSync(path.dirname(file), { recursive: true })
93
- writeFileSync(file, `${JSON.stringify(credential, null, 2)}\n`, { mode: 0o600 })
94
- chmodSync(file, 0o600)
192
+ atomicWriteFileSync(this.filePath(), `${JSON.stringify(credential, null, 2)}\n`, {
193
+ mode: 0o600,
194
+ preserveMode: false,
195
+ requireCurrentUserOwner: true,
196
+ })
95
197
  }
96
198
 
97
199
  clear(): void {
@@ -99,12 +201,157 @@ export class FileStore implements CredentialStore {
99
201
  }
100
202
 
101
203
  describe(): string {
102
- return this.filePath()
204
+ const file = this.filePath()
205
+ if (this.platform === 'win32') {
206
+ return `plaintext file ${file} (NOTIFAI_CREDENTIALS=file; POSIX mode bits are not protection on NTFS)`
207
+ }
208
+ return `plaintext file ${file} (mode 0600)`
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Windows default: DPAPI CurrentUser via first-party PowerShell.
214
+ *
215
+ * The protected blob lives under LOCALAPPDATA, never roaming APPDATA.
216
+ * Plaintext crosses the process boundary only on stdin/stdout — never as a
217
+ * command-line argument.
218
+ */
219
+ export class WindowsDpapiStore implements CredentialStore {
220
+ private readonly env: NodeJS.ProcessEnv
221
+ private readonly run: ProcessRunner
222
+
223
+ constructor(env: NodeJS.ProcessEnv = process.env, options: CredentialStoreOptions = {}) {
224
+ this.env = env
225
+ this.run = options.run ?? defaultProcessRunner
103
226
  }
227
+
228
+ private filePath(): string | null {
229
+ const local = this.env['LOCALAPPDATA']
230
+ if (typeof local !== 'string' || local.trim() === '') return null
231
+ return path.join(local, 'notifai', DPAPI_FILE)
232
+ }
233
+
234
+ load(): MachineCredential | null {
235
+ const file = this.filePath()
236
+ if (file === null || !existsSync(file)) return null
237
+ let text: string
238
+ try {
239
+ text = readFileSync(file, 'utf8')
240
+ } catch {
241
+ return null
242
+ }
243
+ const blob = parseDpapiEnvelope(text)
244
+ if (blob === null) return null
245
+ const plain = this.unprotect(blob)
246
+ return plain === null ? null : parseCredentialJson(plain)
247
+ }
248
+
249
+ save(credential: MachineCredential): void {
250
+ const file = this.filePath()
251
+ if (file === null) {
252
+ throw new Error('Windows credential store path is not configured (LOCALAPPDATA is missing)')
253
+ }
254
+ const blob = this.protect(Buffer.from(JSON.stringify(credential), 'utf8'))
255
+ atomicWriteFileSync(file, `${JSON.stringify({ format: DPAPI_FORMAT, data: blob })}\n`, {
256
+ mode: 0o600,
257
+ preserveMode: false,
258
+ requireCurrentUserOwner: true,
259
+ })
260
+ }
261
+
262
+ clear(): void {
263
+ const file = this.filePath()
264
+ if (file === null) return
265
+ rmSync(file, { force: true })
266
+ }
267
+
268
+ describe(): string {
269
+ const file = this.filePath()
270
+ if (file === null) return 'Windows DPAPI current-user (LOCALAPPDATA unset)'
271
+ return `Windows DPAPI current-user (${file})`
272
+ }
273
+
274
+ private protect(plaintext: Buffer): string {
275
+ const result = this.invoke('protect', plaintext)
276
+ if (result.status !== 0) {
277
+ throw new Error('Windows credential protection failed')
278
+ }
279
+ const encoded = result.stdout.toString('utf8').trim()
280
+ if (!isBase64(encoded)) {
281
+ throw new Error('Windows credential protection failed')
282
+ }
283
+ return encoded
284
+ }
285
+
286
+ private unprotect(blob: string): string | null {
287
+ const result = this.invoke('unprotect', Buffer.from(blob, 'utf8'))
288
+ if (result.status !== 0) return null
289
+ const plain = result.stdout.toString('utf8')
290
+ return plain === '' ? null : plain
291
+ }
292
+
293
+ private invoke(operation: 'protect' | 'unprotect', input: Buffer): RunCommandResult {
294
+ const encoded = operation === 'protect' ? PROTECT_ENCODED : UNPROTECT_ENCODED
295
+ const invocation = powerShellInvocation(encoded, this.env)
296
+ return this.run.run({
297
+ command: invocation.command,
298
+ args: invocation.args,
299
+ input,
300
+ timeoutMs: DPAPI_TIMEOUT_MS,
301
+ })
302
+ }
303
+ }
304
+
305
+ function parseCredentialJson(raw: string): MachineCredential | null {
306
+ try {
307
+ return parseCredential(JSON.parse(raw) as unknown)
308
+ } catch {
309
+ return null
310
+ }
311
+ }
312
+
313
+ function parseCredential(raw: unknown): MachineCredential | null {
314
+ if (raw === null || typeof raw !== 'object') return null
315
+ const value = raw as Record<string, unknown>
316
+ const machineId = value['machineId']
317
+ const secret = value['secret']
318
+ const baseUrl = value['baseUrl']
319
+ const machineName = value['machineName']
320
+ if (
321
+ typeof machineId !== 'string' ||
322
+ machineId === '' ||
323
+ typeof secret !== 'string' ||
324
+ secret === '' ||
325
+ typeof baseUrl !== 'string' ||
326
+ baseUrl === '' ||
327
+ typeof machineName !== 'string' ||
328
+ machineName === ''
329
+ ) {
330
+ return null
331
+ }
332
+ return { machineId, secret, baseUrl, machineName }
333
+ }
334
+
335
+ function parseDpapiEnvelope(text: string): string | null {
336
+ let parsed: unknown
337
+ try {
338
+ parsed = JSON.parse(text)
339
+ } catch {
340
+ return null
341
+ }
342
+ if (parsed === null || typeof parsed !== 'object') return null
343
+ const value = parsed as Record<string, unknown>
344
+ if (value['format'] !== DPAPI_FORMAT) return null
345
+ const data = value['data']
346
+ if (typeof data !== 'string' || !isBase64(data)) return null
347
+ return data
348
+ }
349
+
350
+ function isBase64(value: string): boolean {
351
+ return value.length > 0 && value.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(value)
104
352
  }
105
353
 
106
- function keychainAvailable(): boolean {
107
- if (process.platform !== 'darwin') return false
354
+ function keychainProbeSucceeds(): boolean {
108
355
  try {
109
356
  execFileSync('security', ['help'], { stdio: 'ignore' })
110
357
  return true
@@ -113,7 +360,17 @@ function keychainAvailable(): boolean {
113
360
  }
114
361
  }
115
362
 
116
- export function defaultCredentialStore(env: NodeJS.ProcessEnv = process.env): CredentialStore {
117
- if (env['NOTIFAI_CREDENTIALS'] === 'file') return new FileStore(env)
118
- return keychainAvailable() ? new KeychainStore() : new FileStore(env)
363
+ export function defaultCredentialStore(
364
+ env: NodeJS.ProcessEnv = process.env,
365
+ options: CredentialStoreOptions = {},
366
+ ): CredentialStore {
367
+ const platform = options.platform ?? process.platform
368
+ if (env['NOTIFAI_CREDENTIALS'] === 'file') return new FileStore(env, { platform })
369
+ if (platform === 'win32') return new WindowsDpapiStore(env, options)
370
+ const keychain =
371
+ options.keychainAvailable !== undefined
372
+ ? options.keychainAvailable
373
+ : platform === 'darwin' && keychainProbeSucceeds()
374
+ if (keychain) return new KeychainStore()
375
+ return new FileStore(env, { platform })
119
376
  }