@raidiant/notifai 9.0.0 → 9.2.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 (68) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/command-session.d.ts +14 -0
  3. package/dist/command-session.js +30 -0
  4. package/dist/command-session.js.map +1 -0
  5. package/dist/commands-doctor.js +17 -15
  6. package/dist/commands-doctor.js.map +1 -1
  7. package/dist/commands-guidance.js +3 -29
  8. package/dist/commands-guidance.js.map +1 -1
  9. package/dist/commands-harness-context.js +21 -1
  10. package/dist/commands-harness-context.js.map +1 -1
  11. package/dist/commands-hooks.d.ts +11 -1
  12. package/dist/commands-hooks.js +98 -184
  13. package/dist/commands-hooks.js.map +1 -1
  14. package/dist/commands-init.d.ts +2 -0
  15. package/dist/commands-init.js +41 -21
  16. package/dist/commands-init.js.map +1 -1
  17. package/dist/commands-send.d.ts +1 -0
  18. package/dist/commands-send.js +62 -14
  19. package/dist/commands-send.js.map +1 -1
  20. package/dist/commands-setup-proof.d.ts +4 -3
  21. package/dist/commands-setup-proof.js +29 -14
  22. package/dist/commands-setup-proof.js.map +1 -1
  23. package/dist/commands.d.ts +1 -1
  24. package/dist/commands.js +1 -1
  25. package/dist/commands.js.map +1 -1
  26. package/dist/guidance-render.d.ts +20 -0
  27. package/dist/guidance-render.js +42 -0
  28. package/dist/guidance-render.js.map +1 -0
  29. package/dist/hooks.d.ts +16 -13
  30. package/dist/hooks.js +57 -32
  31. package/dist/hooks.js.map +1 -1
  32. package/dist/opencode-plugin.js +32 -5
  33. package/dist/opencode-plugin.js.map +1 -1
  34. package/dist/program.js +21 -12
  35. package/dist/program.js.map +1 -1
  36. package/dist/readiness.d.ts +6 -0
  37. package/dist/readiness.js +37 -0
  38. package/dist/readiness.js.map +1 -1
  39. package/dist/send-attempts.d.ts +26 -0
  40. package/dist/send-attempts.js +139 -0
  41. package/dist/send-attempts.js.map +1 -0
  42. package/dist/session-activation.d.ts +4 -8
  43. package/dist/session-activation.js +25 -14
  44. package/dist/session-activation.js.map +1 -1
  45. package/package.json +1 -1
  46. package/src/command-session.ts +38 -0
  47. package/src/commands-doctor.ts +18 -28
  48. package/src/commands-guidance.ts +3 -38
  49. package/src/commands-harness-context.ts +20 -1
  50. package/src/commands-hooks.ts +219 -220
  51. package/src/commands-init.ts +46 -24
  52. package/src/commands-send.ts +70 -14
  53. package/src/commands-setup-proof.test.ts +72 -0
  54. package/src/commands-setup-proof.ts +33 -14
  55. package/src/commands.test.ts +209 -67
  56. package/src/commands.ts +1 -0
  57. package/src/guidance-render.ts +56 -0
  58. package/src/guidance.test.ts +28 -0
  59. package/src/hooks.test.ts +42 -137
  60. package/src/hooks.ts +67 -46
  61. package/src/install-hooks.test.ts +41 -16
  62. package/src/opencode-plugin.ts +35 -5
  63. package/src/program.ts +22 -12
  64. package/src/readiness.ts +44 -0
  65. package/src/send-attempts.test.ts +143 -0
  66. package/src/send-attempts.ts +188 -0
  67. package/src/session-activation.ts +35 -18
  68. package/src/skill.test.ts +22 -23
@@ -13,7 +13,7 @@ import { ApiCallError, NetworkError, type ApiClient } from './client.js'
13
13
  import { personalProjectConfigPath, type CliConfig } from './config.js'
14
14
  import { projectSlugFrom as inferredProjectSlugFrom } from './invocation-context.js'
15
15
  import { SKILLS_INSTALLER_SPEC, type SkillScope } from './native-skills.js'
16
- import { firstBlocker, type Readiness, type ReadinessRefresh, type ReadinessState } from './readiness.js'
16
+ import { firstBlocker, readinessJson, type Readiness, type ReadinessRefresh, type ReadinessState } from './readiness.js'
17
17
  import { buildDraft, formatReceipt } from './send.js'
18
18
  import { loginCommand } from './commands-auth.js'
19
19
  import {
@@ -30,6 +30,8 @@ import { hooksInstallCommand, pickHarnessesToInstall } from './commands-hooks.js
30
30
  import {
31
31
  observedCompanionReceipt,
32
32
  readSetupProof,
33
+ setupProofProject,
34
+ setupProofApplies,
33
35
  setupProofIsStale,
34
36
  writeSetupProof,
35
37
  } from './commands-setup-proof.js'
@@ -49,6 +51,8 @@ export function projectSlugFrom(name: string): string {
49
51
  export type SetupScope = SkillScope
50
52
 
51
53
  export interface InitFlags {
54
+ /** Emit the final shared readiness model and never prompt. */
55
+ json?: boolean
52
56
  projectId?: string
53
57
  /**
54
58
  * Install the agent guidance skill. Tri-state on purpose:
@@ -397,19 +401,23 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
397
401
  return 'failed'
398
402
  }
399
403
  const candidates = readyCompanionDevices(devices)
400
- const existing = readSetupProof(deps)
401
- const target =
402
- candidates.find(
403
- (device) =>
404
- device.device_id === existing?.device_id && existing.project === config.project.value,
405
- ) ?? candidates[0]
404
+ const project = setupProofProject(deps, config.project.value)
405
+ const existing = readSetupProof(deps, project)
406
+ const existingApplies = setupProofApplies(
407
+ existing,
408
+ project,
409
+ candidates.map((device) => device.device_id),
410
+ )
411
+ const target = (existingApplies
412
+ ? candidates.find((device) => device.device_id === existing.device_id)
413
+ : undefined) ?? candidates[0]
406
414
  if (!target) {
407
415
  deps.io.err('Setup proof needs a receipt-capable iPhone or Android Companion App.')
408
416
  return 'pending'
409
417
  }
410
418
 
411
419
  let proof =
412
- existing?.device_id === target.device_id && existing.project === config.project.value
420
+ existingApplies && existing.device_id === target.device_id
413
421
  ? existing
414
422
  : null
415
423
  const nowMs = (deps.now ?? Date.now)()
@@ -438,7 +446,7 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
438
446
  proof = {
439
447
  request_id: receipt.request_id,
440
448
  device_id: target.device_id,
441
- project: config.project.value,
449
+ project,
442
450
  started_at: new Date((deps.now ?? Date.now)()).toISOString(),
443
451
  }
444
452
  if (!writeSetupProof(deps, proof)) return 'failed'
@@ -486,7 +494,7 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
486
494
  proof = {
487
495
  request_id: receipt.request_id,
488
496
  device_id: target.device_id,
489
- project: config.project.value,
497
+ project,
490
498
  started_at: new Date(now()).toISOString(),
491
499
  }
492
500
  if (!writeSetupProof(deps, proof)) return 'failed'
@@ -621,19 +629,32 @@ async function resolveSetupScope(
621
629
  }
622
630
 
623
631
  export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<number> {
624
- const setupScope = await resolveSetupScope(deps, flags)
632
+ const workingDeps: CommandDeps = flags.json === true
633
+ ? {
634
+ ...deps,
635
+ io: {
636
+ interactive: false,
637
+ out: () => {},
638
+ err: (line) => deps.io.err(line),
639
+ confirm: async () => false,
640
+ openUrl: (url) => deps.io.openUrl(url),
641
+ },
642
+ }
643
+ : deps
644
+ const setupScope = await resolveSetupScope(workingDeps, flags)
625
645
  if (setupScope === 'usage') return EXIT.usage
626
646
  const resolved: InitFlags = {
627
647
  ...flags,
628
648
  ...(setupScope === undefined ? {} : { setupScope, skillsScope: flags.skillsScope ?? setupScope }),
629
649
  }
630
- await deps.io.intro?.('Notifai setup')
650
+ await workingDeps.io.intro?.('Notifai setup')
631
651
 
632
652
  const skillOpts = resolved.setupScope === undefined ? {} : { skillScope: resolved.setupScope }
633
- let readiness = await assessReadiness(deps, skillOpts)
653
+ let readiness = await assessReadiness(workingDeps, { ...skillOpts, ...(flags.json === true ? { json: true } : {}) })
634
654
  const reassess = (refresh?: readonly ReadinessRefresh[]) =>
635
- assessReadiness(deps, {
655
+ assessReadiness(workingDeps, {
636
656
  ...skillOpts,
657
+ ...(flags.json === true ? { json: true } : {}),
637
658
  ...(refresh === undefined ? {} : { previous: readiness, refresh }),
638
659
  })
639
660
  let failed = false
@@ -661,9 +682,9 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
661
682
  }
662
683
 
663
684
  if (state.status === 'optional-gap') {
664
- if (remedy.by !== 'cli' || !(await wantsOptional(deps, state, resolved))) continue
685
+ if (remedy.by !== 'cli' || !(await wantsOptional(workingDeps, state, resolved))) continue
665
686
  attempted.add(state.id)
666
- const result = await closeGap(deps, state, resolved)
687
+ const result = await closeGap(workingDeps, state, resolved)
667
688
  if (result === 'failed') failed = true
668
689
  if (result === 'failed' && state.status === 'optional-gap') {
669
690
  readiness = await reassess(refreshAfterClose(state.id))
@@ -681,7 +702,7 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
681
702
 
682
703
  if (remedy.by === 'cli') {
683
704
  attempted.add(state.id)
684
- const result = await closeGap(deps, state, resolved)
705
+ const result = await closeGap(workingDeps, state, resolved)
685
706
  if (result === 'failed') failed = true
686
707
  if (result !== 'closed') {
687
708
  stop = true
@@ -697,11 +718,11 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
697
718
  if (
698
719
  remedy.by === 'user-here' &&
699
720
  remedy.interactive === true &&
700
- deps.io.interactive === true
721
+ workingDeps.io.interactive === true
701
722
  ) {
702
723
  attempted.add(state.id)
703
- deps.io.out('Opening your browser to approve this machine — Ctrl-C to stop.')
704
- if ((await loginCommand(deps, {})) !== EXIT.ok) {
724
+ workingDeps.io.out('Opening your browser to approve this machine — Ctrl-C to stop.')
725
+ if ((await loginCommand(workingDeps, {})) !== EXIT.ok) {
705
726
  failed = true
706
727
  stop = true
707
728
  break
@@ -714,10 +735,10 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
714
735
  if (
715
736
  state.id === 'devices' &&
716
737
  remedy.by === 'user-elsewhere' &&
717
- deps.io.interactive === true
738
+ workingDeps.io.interactive === true
718
739
  ) {
719
740
  attempted.add(state.id)
720
- const result = await waitForReadyDevice(deps, state)
741
+ const result = await waitForReadyDevice(workingDeps, state)
721
742
  if (result === 'failed') failed = true
722
743
  if (result !== 'closed') {
723
744
  stop = true
@@ -736,10 +757,11 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
736
757
  if (stop || !advanced) break
737
758
  }
738
759
 
739
- await printInitClose(deps, readiness, resolved)
760
+ if (flags.json === true) deps.io.out(JSON.stringify(readinessJson(readiness), null, 2))
761
+ else await printInitClose(deps, readiness, resolved)
740
762
  const blocker = firstBlocker(readiness)
741
763
  if (blocker === null) return failed ? EXIT.failed : EXIT.ok
742
- return failed || deps.io.interactive !== true ? EXIT.failed : EXIT.ok
764
+ return failed || workingDeps.io.interactive !== true ? EXIT.failed : EXIT.ok
743
765
  }
744
766
 
745
767
  function isHookSubstate(id: string): boolean {
@@ -6,10 +6,9 @@ import {
6
6
  type ReplyView,
7
7
  type SubmissionReceipt,
8
8
  } from '@raidiant/notifai-protocol'
9
- import { randomBytes } from 'node:crypto'
10
9
  import { ApiCallError, NetworkError } from './client.js'
11
10
  import type { FlagOverrides, loadConfig } from './config.js'
12
- import { MIN_REPLY_WINDOW_SECONDS, readProjectSession, readSessionState } from './hooks.js'
11
+ import { MIN_REPLY_WINDOW_SECONDS, readSessionState } from './hooks.js'
13
12
  import {
14
13
  buildDraft,
15
14
  formatReceipt,
@@ -18,7 +17,7 @@ import {
18
17
  validateMediaInputs,
19
18
  type SendFlags,
20
19
  } from './send.js'
21
- import { recordObservedDeliveryProof } from './commands-setup-proof.js'
20
+ import { recordObservedDeliveryProof, setupProofProject } from './commands-setup-proof.js'
22
21
  import {
23
22
  EXIT,
24
23
  authedClient,
@@ -28,6 +27,13 @@ import {
28
27
  type CommandDeps,
29
28
  } from './commands-core.js'
30
29
  import { activeHarnessSession } from './commands-harness-context.js'
30
+ import { resolveCommandSession } from './command-session.js'
31
+ import {
32
+ beginSendAttempt,
33
+ semanticMediaIds,
34
+ sendDraftFingerprint,
35
+ settleSendAttempt,
36
+ } from './send-attempts.js'
31
37
  import {
32
38
  acknowledgementCommand,
33
39
  printAcknowledgementStatus,
@@ -49,9 +55,14 @@ export async function sendCommand(
49
55
  noWait?: boolean
50
56
  replyTimeout?: number
51
57
  idempotencyKey?: string
58
+ retry?: boolean
52
59
  baseUrl?: string
53
60
  },
54
61
  ): Promise<number> {
62
+ if (flags.retry === true && flags.idempotencyKey !== undefined) {
63
+ deps.io.err('Pass --retry or --idempotency-key, not both.')
64
+ return EXIT.usage
65
+ }
55
66
  const hasChoice = Array.isArray(flags.choice)
56
67
  ? flags.choice.length > 0
57
68
  : flags.choice !== undefined
@@ -139,6 +150,35 @@ export async function sendCommand(
139
150
  }
140
151
  const authed = authedClient(deps, config)
141
152
  if (!authed) return EXIT.auth
153
+ const credential = deps.store.load()!
154
+ let semanticImages: string[]
155
+ try {
156
+ semanticImages = semanticMediaIds(flags.image ?? [], deps.cwd)
157
+ } catch (err) {
158
+ deps.io.err(`Could not fingerprint an image for safe retry: ${String(err)}`)
159
+ return EXIT.usage
160
+ }
161
+ const semanticBuild = buildDraft(
162
+ config,
163
+ { ...flags, ...(semanticImages.length > 0 ? { image: semanticImages } : {}) },
164
+ source.invocation,
165
+ )
166
+ if (!semanticBuild.ok) {
167
+ deps.io.err(semanticBuild.error)
168
+ return EXIT.usage
169
+ }
170
+ const attempt = beginSendAttempt({
171
+ env: deps.env,
172
+ credential,
173
+ fingerprint: sendDraftFingerprint(semanticBuild.draft, credential),
174
+ retry: flags.retry === true,
175
+ ...(flags.idempotencyKey === undefined ? {} : { idempotencyKey: flags.idempotencyKey }),
176
+ now: (deps.now ?? Date.now)(),
177
+ })
178
+ if (!attempt.ok) {
179
+ deps.io.err(`${attempt.code}: ${attempt.message}`)
180
+ return EXIT.usage
181
+ }
142
182
  const mediaIds: string[] = []
143
183
  for (const image of flags.image ?? []) {
144
184
  if (image.startsWith('med_')) {
@@ -147,6 +187,7 @@ export async function sendCommand(
147
187
  }
148
188
  const uploaded = await uploadImage(deps, authed.client, image, config.media_origins.value)
149
189
  if (!uploaded.ok) {
190
+ settleSendAttempt(deps.env, attempt.attemptId)
150
191
  if (uploaded.error !== null) deps.io.err(uploaded.error)
151
192
  return uploaded.exit
152
193
  }
@@ -155,16 +196,19 @@ export async function sendCommand(
155
196
  flags = { ...flags, image: mediaIds }
156
197
  const build = buildDraft(config, flags, source.invocation)
157
198
  if (!build.ok) {
199
+ settleSendAttempt(deps.env, attempt.attemptId)
158
200
  deps.io.err(build.error)
159
201
  return EXIT.usage
160
202
  }
161
203
  const capabilities = CAPABILITIES_V1.describe(build.platform)
162
204
  if (!capabilities) {
205
+ settleSendAttempt(deps.env, attempt.attemptId)
163
206
  deps.io.err(`No capability contract is available for ${build.platform}.`)
164
207
  return EXIT.usage
165
208
  }
166
209
  const validation = validateDraft(build.draft, capabilities)
167
210
  if (!validation.ok) {
211
+ settleSendAttempt(deps.env, attempt.attemptId)
168
212
  for (const issue of validation.errors) deps.io.err(`${issue.path}: ${issue.message}`)
169
213
  return EXIT.usage
170
214
  }
@@ -181,15 +225,14 @@ export async function sendCommand(
181
225
  )
182
226
  }
183
227
  const waitSeconds = flags.noWait ? 0 : config.wait_seconds.value
184
- const idempotencyKey = flags.idempotencyKey ?? `cli-${randomBytes(12).toString('base64url')}`
228
+ const idempotencyKey = attempt.idempotencyKey
185
229
  // Persist the retry identity before entering the ambiguous network boundary.
186
230
  // If the process is killed after the server accepts but before a response,
187
231
  // this is the only durable way to reconstruct the same Agent Event key.
188
232
  log(deps).info('send.attempt', {
233
+ attempt_id: attempt.attemptId,
189
234
  idempotency_key: idempotencyKey,
190
- kind: flags.reply ? 'question' : (flags.kind ?? 'update'),
191
- title: flags.title,
192
- session_id: source.invocation.source?.session_id ?? null,
235
+ replay: attempt.replay,
193
236
  })
194
237
  let receipt: SubmissionReceipt
195
238
  try {
@@ -202,17 +245,23 @@ export async function sendCommand(
202
245
  if (exit === EXIT.network) {
203
246
  deps.io.err(
204
247
  'The server may have accepted this Notification Request before the connection failed. ' +
205
- `Retry the same send with \`--idempotency-key ${idempotencyKey}\` so this Agent Event cannot create a duplicate.`,
248
+ 'Re-run the exact semantic send with `--retry`; the CLI will reuse its opaque attempt identity.',
206
249
  )
207
250
  }
208
251
  if (err instanceof ApiCallError && err.code === 'no_active_devices') {
209
252
  deps.io.err(
210
- 'After setup succeeds, repeat this original Notification Request with ' +
211
- `\`--idempotency-key ${idempotencyKey}\`; the setup verification request does not replace this Agent Event.`,
253
+ 'After setup succeeds, repeat this exact original Notification Request with `--retry`; ' +
254
+ 'the setup verification request does not replace this Agent Event.',
212
255
  )
213
256
  }
257
+ if (!(exit === EXIT.network || (err instanceof ApiCallError && err.code === 'no_active_devices'))) {
258
+ settleSendAttempt(deps.env, attempt.attemptId)
259
+ } else {
260
+ deps.io.err(`Opaque retry attempt: ${attempt.attemptId}. Re-run the exact semantic send with \`--retry\`; do not retry automatically.`)
261
+ }
214
262
  return exit
215
263
  }
264
+ settleSendAttempt(deps.env, attempt.attemptId)
216
265
  const receiptExit = receiptExitCode(receipt)
217
266
  // The single most useful line in the log: it ties the local invocation to
218
267
  // the server-side request id, which is what every later question about this
@@ -383,12 +432,15 @@ export async function repliesCommand(
383
432
  return EXIT.usage
384
433
  }
385
434
  let requestIds = requestedId === undefined ? [] : [requestedId]
435
+ let lifecycleSessionId: string | undefined
386
436
  if (flags.pending === true) {
387
- const sessionId = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)())
388
- if (sessionId === null) {
437
+ const session = resolveCommandSession(deps)
438
+ if (session === null) {
389
439
  deps.io.err('No active session pointer is available in this directory.')
390
440
  return EXIT.noReply
391
441
  }
442
+ const sessionId = session.sessionId
443
+ lifecycleSessionId = sessionId
392
444
  // Every delivered question in the session's queue, in registration order —
393
445
  // an agent may have several outstanding at once.
394
446
  const state = readSessionState(sessionId, deps.env)
@@ -445,7 +497,11 @@ export async function repliesCommand(
445
497
  return EXIT.usage
446
498
  }
447
499
 
448
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
500
+ const config = loadLoggedConfig(deps, {
501
+ cwd: deps.cwd,
502
+ env: deps.env,
503
+ ...(lifecycleSessionId === undefined ? {} : { sessionId: lifecycleSessionId }),
504
+ })
449
505
  const authed = authedClient(deps, config)
450
506
  if (!authed) return EXIT.auth
451
507
  try {
@@ -649,7 +705,7 @@ export async function statusCommand(
649
705
  if (!authed) return EXIT.auth
650
706
  try {
651
707
  const snapshot = await authed.client.evidence(requestId)
652
- recordObservedDeliveryProof(deps, snapshot, config.project.value)
708
+ recordObservedDeliveryProof(deps, snapshot, setupProofProject(deps, config.project.value))
653
709
  if (flags.json) {
654
710
  deps.io.out(JSON.stringify(snapshot, null, 2))
655
711
  return EXIT.ok
@@ -0,0 +1,72 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+ import type { CommandDeps } from './commands-core.js'
6
+ import type { MachineCredential } from './credentials.js'
7
+ import { readSetupProof, setupProofApplies, writeSetupProof } from './commands-setup-proof.js'
8
+
9
+ const roots: string[] = []
10
+ afterEach(() => {
11
+ while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true })
12
+ })
13
+
14
+ function deps(root: string, cwd: string, credential: MachineCredential): CommandDeps {
15
+ return {
16
+ cwd,
17
+ env: { XDG_STATE_HOME: path.join(root, 'state'), XDG_CONFIG_HOME: path.join(root, 'config') },
18
+ io: { out: () => {}, err: () => {}, confirm: async () => false, openUrl: () => {} },
19
+ store: {
20
+ load: () => credential,
21
+ save: () => {},
22
+ clear: () => {},
23
+ describe: () => 'test',
24
+ },
25
+ }
26
+ }
27
+
28
+ describe('stable setup delivery proof identity', () => {
29
+ it('is shared by linked checkouts of one Project and separated by Project', () => {
30
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-proof-identity-'))
31
+ roots.push(root)
32
+ const credential = {
33
+ machineId: 'mac_one',
34
+ machineName: 'One',
35
+ baseUrl: 'https://app.notifai.test',
36
+ secret: 'approval-one',
37
+ }
38
+ const first = deps(root, path.join(root, 'worktree-a'), credential)
39
+ const second = deps(root, path.join(root, 'worktree-b'), credential)
40
+ const proof = {
41
+ request_id: 'req_proof',
42
+ device_id: 'dev_one',
43
+ project: 'shared-project',
44
+ started_at: '2026-08-26T00:00:00.000Z',
45
+ }
46
+ expect(writeSetupProof(first, proof)).toBe(true)
47
+ expect(readSetupProof(second, 'shared-project')).toEqual(proof)
48
+ expect(readSetupProof(second, 'different-project')).toBeNull()
49
+ })
50
+
51
+ it('invalidates on service, machine approval, machine, or device change', () => {
52
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-proof-invalidation-'))
53
+ roots.push(root)
54
+ const credential = {
55
+ machineId: 'mac_one',
56
+ machineName: 'One',
57
+ baseUrl: 'https://app.notifai.test',
58
+ secret: 'approval-one',
59
+ }
60
+ const original = deps(root, root, credential)
61
+ expect(writeSetupProof(original, {
62
+ request_id: 'req_proof',
63
+ device_id: 'dev_one',
64
+ project: 'project',
65
+ started_at: '2026-08-26T00:00:00.000Z',
66
+ })).toBe(true)
67
+ expect(readSetupProof(deps(root, root, { ...credential, baseUrl: 'https://other.test' }), 'project')).toBeNull()
68
+ expect(readSetupProof(deps(root, root, { ...credential, machineId: 'mac_two' }), 'project')).toBeNull()
69
+ expect(readSetupProof(deps(root, root, { ...credential, secret: 'approval-two' }), 'project')).toBeNull()
70
+ expect(setupProofApplies(readSetupProof(original, 'project'), 'project', ['dev_two'])).toBe(false)
71
+ })
72
+ })
@@ -4,34 +4,41 @@ import {
4
4
  existsSync,
5
5
  mkdirSync,
6
6
  readFileSync,
7
- realpathSync,
8
7
  writeFileSync,
9
8
  } from 'node:fs'
10
9
  import path from 'node:path'
11
10
  import { stateDir } from './config.js'
12
11
  import type { CommandDeps } from './commands-core.js'
12
+ import { inferInvocationContext } from './invocation-context.js'
13
13
 
14
- interface SetupProofRecord {
14
+ export interface SetupProofRecord {
15
15
  request_id: string
16
16
  device_id: string
17
17
  project: string | null
18
18
  started_at: string
19
19
  }
20
20
 
21
- function setupProofPath(deps: CommandDeps): string {
22
- let projectDir = path.resolve(deps.cwd)
23
- try {
24
- projectDir = realpathSync(projectDir)
25
- } catch {
26
- // A deleted or not-yet-created cwd cannot collide with a real directory:
27
- // the resolved absolute path is still a stable local identity for it.
28
- }
29
- const digest = createHash('sha256').update(projectDir).digest('hex').slice(0, 32)
21
+ export function setupProofProject(deps: CommandDeps, configured: string | null): string | null {
22
+ return configured ?? inferInvocationContext(deps.cwd).project
23
+ }
24
+
25
+ function setupProofPath(deps: CommandDeps, project: string | null): string | null {
26
+ const credential = deps.store.load()
27
+ if (credential === null) return null
28
+ const approval = createHash('sha256')
29
+ .update('notifai-setup-proof-approval-v1\0')
30
+ .update(credential.secret)
31
+ .digest('base64url')
32
+ const digest = createHash('sha256')
33
+ .update(JSON.stringify({ project, machine_id: credential.machineId, service: credential.baseUrl, approval }))
34
+ .digest('hex')
35
+ .slice(0, 32)
30
36
  return path.join(stateDir(deps.env), 'setup-proofs', `${digest}.json`)
31
37
  }
32
38
 
33
- export function readSetupProof(deps: CommandDeps): SetupProofRecord | null {
34
- const file = setupProofPath(deps)
39
+ export function readSetupProof(deps: CommandDeps, project: string | null): SetupProofRecord | null {
40
+ const file = setupProofPath(deps, project)
41
+ if (file === null) return null
35
42
  if (!existsSync(file)) return null
36
43
  try {
37
44
  const parsed = JSON.parse(readFileSync(file, 'utf8')) as Partial<SetupProofRecord>
@@ -48,7 +55,11 @@ export function readSetupProof(deps: CommandDeps): SetupProofRecord | null {
48
55
  }
49
56
 
50
57
  export function writeSetupProof(deps: CommandDeps, proof: SetupProofRecord): boolean {
51
- const file = setupProofPath(deps)
58
+ const file = setupProofPath(deps, proof.project)
59
+ if (file === null) {
60
+ deps.io.err('Could not save setup proof without an Approved Machine credential.')
61
+ return false
62
+ }
52
63
  try {
53
64
  mkdirSync(path.dirname(file), { recursive: true })
54
65
  writeFileSync(file, `${JSON.stringify(proof, null, 2)}\n`, { mode: 0o600 })
@@ -82,6 +93,14 @@ export function setupProofIsStale(proof: SetupProofRecord, now: number): boolean
82
93
  return Number.isFinite(started) && now - started > SETUP_PROOF_STALE_MS
83
94
  }
84
95
 
96
+ export function setupProofApplies(
97
+ proof: SetupProofRecord | null,
98
+ project: string | null,
99
+ deviceIds: readonly string[],
100
+ ): proof is SetupProofRecord {
101
+ return proof !== null && proof.project === project && deviceIds.includes(proof.device_id)
102
+ }
103
+
85
104
  /**
86
105
  * Persist ordinary send/status Companion Receipts as the project's delivery
87
106
  * proof so doctor does not demand a second verification request.