@raidiant/notifai 10.1.1 → 10.1.3
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 +35 -0
- package/dist/commands-core.d.ts +4 -1
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-doctor.js +2 -7
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-hooks.d.ts +12 -0
- package/dist/commands-hooks.js +15 -1
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.js +9 -6
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.js +10 -11
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-skill.js +72 -54
- package/dist/commands-skill.js.map +1 -1
- package/dist/harness-session-title.d.ts +22 -0
- package/dist/harness-session-title.js +74 -0
- package/dist/harness-session-title.js.map +1 -0
- package/dist/invocation-context.d.ts +1 -0
- package/dist/invocation-context.js +15 -1
- package/dist/invocation-context.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 +2 -1
- package/dist/node-floor.js +9 -5
- package/dist/node-floor.js.map +1 -1
- 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/release.d.ts +5 -5
- package/dist/release.js +5 -5
- package/dist/session-labels.d.ts +2 -0
- package/dist/session-labels.js +20 -9
- package/dist/session-labels.js.map +1 -1
- package/dist/setup-destinations.js +1 -1
- package/dist/setup-destinations.js.map +1 -1
- 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 -176
- package/src/cli-bin.ts +0 -233
- package/src/cli-release.test.ts +0 -46
- package/src/cli-release.ts +0 -78
- package/src/cli-update-contract.ts +0 -9
- package/src/cli-update.test.ts +0 -125
- package/src/client.test.ts +0 -390
- package/src/client.ts +0 -352
- package/src/codex-wake.test.ts +0 -254
- package/src/codex-wake.ts +0 -301
- package/src/command-session.ts +0 -38
- package/src/commands-agent-sessions.ts +0 -72
- package/src/commands-auth.ts +0 -238
- package/src/commands-config.ts +0 -336
- package/src/commands-core.ts +0 -312
- package/src/commands-devices.ts +0 -180
- package/src/commands-doctor.ts +0 -1501
- 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 -2318
- package/src/commands-init.ts +0 -1055
- 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 -875
- 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-update.ts +0 -180
- package/src/commands.test.ts +0 -8297
- package/src/commands.ts +0 -77
- 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 -424
- package/src/hook-input.test.ts +0 -39
- package/src/hook-input.ts +0 -72
- package/src/hooks.test.ts +0 -5824
- package/src/hooks.ts +0 -4011
- 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-run.ts +0 -45
- package/src/main.ts +0 -12
- package/src/native-skills.test.ts +0 -115
- package/src/native-skills.ts +0 -162
- package/src/node-floor.test.ts +0 -27
- package/src/node-floor.ts +0 -37
- 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 -317
- package/src/program.ts +0 -842
- 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-settlement-process.ts +0 -47
- package/src/question-timing.ts +0 -56
- package/src/readiness.test.ts +0 -25
- package/src/readiness.ts +0 -212
- 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/setup-destinations.test.ts +0 -41
- package/src/setup-destinations.ts +0 -50
- package/src/skill.test.ts +0 -426
- 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
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, writeFileSync } 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 { NotificationDraftT } from '@raidiant/notifai-protocol'
|
|
6
|
-
import type { MachineCredential } from './credentials.js'
|
|
7
|
-
import {
|
|
8
|
-
SEND_ATTEMPT_TTL_MS,
|
|
9
|
-
beginSendAttempt,
|
|
10
|
-
semanticMediaIds,
|
|
11
|
-
sendDraftFingerprint,
|
|
12
|
-
settleSendAttempt,
|
|
13
|
-
} from './send-attempts.js'
|
|
14
|
-
|
|
15
|
-
const roots: string[] = []
|
|
16
|
-
afterEach(() => {
|
|
17
|
-
while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true })
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
function fixture() {
|
|
21
|
-
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-send-attempt-'))
|
|
22
|
-
roots.push(root)
|
|
23
|
-
const env = { XDG_STATE_HOME: path.join(root, 'state') }
|
|
24
|
-
const credential: MachineCredential = {
|
|
25
|
-
machineId: 'mac_attempt',
|
|
26
|
-
machineName: 'Attempt Machine',
|
|
27
|
-
baseUrl: 'https://app.notifai.test',
|
|
28
|
-
secret: 'private-machine-secret',
|
|
29
|
-
}
|
|
30
|
-
const draft = {
|
|
31
|
-
schema_version: 1,
|
|
32
|
-
kind: 'done',
|
|
33
|
-
project: 'private-project',
|
|
34
|
-
presentation: { title: 'Sensitive result', body: 'Private body text' },
|
|
35
|
-
targets: { mode: 'all' },
|
|
36
|
-
} as NotificationDraftT
|
|
37
|
-
return { root, env, credential, draft }
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
describe('opaque interrupted-send recovery', () => {
|
|
41
|
-
it('matches one exact semantic retry without storing notification content', () => {
|
|
42
|
-
const { env, credential, draft } = fixture()
|
|
43
|
-
const fingerprint = sendDraftFingerprint(draft, credential)
|
|
44
|
-
const first = beginSendAttempt({ env, credential, fingerprint, retry: false, now: 10 })
|
|
45
|
-
expect(first.ok).toBe(true)
|
|
46
|
-
if (!first.ok) return
|
|
47
|
-
|
|
48
|
-
const files = readdirSync(path.join(env.XDG_STATE_HOME, 'notifai', 'send-attempts'))
|
|
49
|
-
expect(files).toHaveLength(1)
|
|
50
|
-
const file = path.join(env.XDG_STATE_HOME, 'notifai', 'send-attempts', files[0]!)
|
|
51
|
-
const stored = readFileSync(file, 'utf8')
|
|
52
|
-
expect(stored).not.toContain('Sensitive result')
|
|
53
|
-
expect(stored).not.toContain('Private body text')
|
|
54
|
-
if (process.platform !== 'win32') expect(statSync(file).mode & 0o777).toBe(0o600)
|
|
55
|
-
if (process.platform !== 'win32') {
|
|
56
|
-
expect(statSync(path.dirname(file)).mode & 0o777).toBe(0o700)
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const retry = beginSendAttempt({ env, credential, fingerprint, retry: true, now: 11 })
|
|
60
|
-
expect(retry).toMatchObject({
|
|
61
|
-
ok: true,
|
|
62
|
-
attemptId: first.attemptId,
|
|
63
|
-
idempotencyKey: first.idempotencyKey,
|
|
64
|
-
replay: true,
|
|
65
|
-
})
|
|
66
|
-
settleSendAttempt(env, first.attemptId)
|
|
67
|
-
expect(beginSendAttempt({ env, credential, fingerprint, retry: true, now: 12 })).toMatchObject({
|
|
68
|
-
ok: false,
|
|
69
|
-
code: 'retry_not_found',
|
|
70
|
-
})
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
it('does not follow a symlink presented as an attempt record', () => {
|
|
74
|
-
const { root, env, credential, draft } = fixture()
|
|
75
|
-
if (process.platform === 'win32') return
|
|
76
|
-
const fingerprint = sendDraftFingerprint(draft, credential)
|
|
77
|
-
const first = beginSendAttempt({ env, credential, fingerprint, retry: false, now: 10 })
|
|
78
|
-
expect(first.ok).toBe(true)
|
|
79
|
-
if (!first.ok) return
|
|
80
|
-
const directory = path.join(env.XDG_STATE_HOME, 'notifai', 'send-attempts')
|
|
81
|
-
const file = path.join(directory, `${first.attemptId}.json`)
|
|
82
|
-
const target = path.join(root, 'foreign.json')
|
|
83
|
-
writeFileSync(target, readFileSync(file, 'utf8'))
|
|
84
|
-
rmSync(file)
|
|
85
|
-
symlinkSync(target, file)
|
|
86
|
-
|
|
87
|
-
expect(beginSendAttempt({ env, credential, fingerprint, retry: true, now: 11 })).toMatchObject({
|
|
88
|
-
ok: false,
|
|
89
|
-
code: 'retry_not_found',
|
|
90
|
-
})
|
|
91
|
-
expect(readFileSync(target, 'utf8')).toContain(first.idempotencyKey)
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
it('refuses ambiguity instead of choosing between identical unresolved events', () => {
|
|
95
|
-
const { env, credential, draft } = fixture()
|
|
96
|
-
const fingerprint = sendDraftFingerprint(draft, credential)
|
|
97
|
-
beginSendAttempt({ env, credential, fingerprint, retry: false, now: 10 })
|
|
98
|
-
beginSendAttempt({ env, credential, fingerprint, retry: false, now: 11 })
|
|
99
|
-
expect(beginSendAttempt({ env, credential, fingerprint, retry: true, now: 12 })).toMatchObject({
|
|
100
|
-
ok: false,
|
|
101
|
-
code: 'retry_ambiguous',
|
|
102
|
-
})
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
it('invalidates attempts after expiry or credential rotation', () => {
|
|
106
|
-
const { env, credential, draft } = fixture()
|
|
107
|
-
const fingerprint = sendDraftFingerprint(draft, credential)
|
|
108
|
-
beginSendAttempt({ env, credential, fingerprint, retry: false, now: 10 })
|
|
109
|
-
expect(
|
|
110
|
-
beginSendAttempt({
|
|
111
|
-
env,
|
|
112
|
-
credential,
|
|
113
|
-
fingerprint,
|
|
114
|
-
retry: true,
|
|
115
|
-
now: 10 + SEND_ATTEMPT_TTL_MS + 1,
|
|
116
|
-
}),
|
|
117
|
-
).toMatchObject({ ok: false, code: 'retry_not_found' })
|
|
118
|
-
|
|
119
|
-
beginSendAttempt({ env, credential, fingerprint, retry: false, now: 20 })
|
|
120
|
-
const rotated = { ...credential, secret: 'rotated-secret' }
|
|
121
|
-
expect(
|
|
122
|
-
beginSendAttempt({
|
|
123
|
-
env,
|
|
124
|
-
credential: rotated,
|
|
125
|
-
fingerprint: sendDraftFingerprint(draft, rotated),
|
|
126
|
-
retry: true,
|
|
127
|
-
now: 21,
|
|
128
|
-
}),
|
|
129
|
-
).toMatchObject({ ok: false, code: 'retry_not_found' })
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
it('fingerprints local media bytes without retaining paths or bytes', () => {
|
|
133
|
-
const { root } = fixture()
|
|
134
|
-
const file = path.join(root, 'private.png')
|
|
135
|
-
writeFileSync(file, 'first bytes')
|
|
136
|
-
const first = semanticMediaIds([file], root)
|
|
137
|
-
writeFileSync(file, 'different bytes')
|
|
138
|
-
const second = semanticMediaIds([file], root)
|
|
139
|
-
expect(first).not.toEqual(second)
|
|
140
|
-
expect(first[0]).not.toContain(file)
|
|
141
|
-
expect(first[0]).not.toContain('first bytes')
|
|
142
|
-
})
|
|
143
|
-
})
|
package/src/send-attempts.ts
DELETED
|
@@ -1,188 +0,0 @@
|
|
|
1
|
-
import { createHash, createHmac, randomBytes } from 'node:crypto'
|
|
2
|
-
import {
|
|
3
|
-
closeSync,
|
|
4
|
-
constants,
|
|
5
|
-
existsSync,
|
|
6
|
-
fstatSync,
|
|
7
|
-
lstatSync,
|
|
8
|
-
openSync,
|
|
9
|
-
readFileSync,
|
|
10
|
-
readdirSync,
|
|
11
|
-
rmSync,
|
|
12
|
-
} from 'node:fs'
|
|
13
|
-
import path from 'node:path'
|
|
14
|
-
import type { NotificationDraftT } from '@raidiant/notifai-protocol'
|
|
15
|
-
import { atomicWriteFileSync } from './atomic-file.js'
|
|
16
|
-
import { stateDir } from './config.js'
|
|
17
|
-
import type { MachineCredential } from './credentials.js'
|
|
18
|
-
|
|
19
|
-
interface SendAttemptRecord {
|
|
20
|
-
version: 1
|
|
21
|
-
attempt_id: string
|
|
22
|
-
fingerprint: string
|
|
23
|
-
idempotency_key: string
|
|
24
|
-
credential_tag: string
|
|
25
|
-
machine_id: string
|
|
26
|
-
service: string
|
|
27
|
-
created_at: string
|
|
28
|
-
expires_at: string
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export const SEND_ATTEMPT_TTL_MS = 24 * 60 * 60 * 1000
|
|
32
|
-
|
|
33
|
-
/** Stable pre-upload media identities used only inside the keyed draft digest. */
|
|
34
|
-
export function semanticMediaIds(images: readonly string[], cwd: string): string[] {
|
|
35
|
-
return images.map((image) => {
|
|
36
|
-
if (image.startsWith('med_')) return image
|
|
37
|
-
const digest = createHash('sha256')
|
|
38
|
-
if (/^https?:\/\//i.test(image)) digest.update(`url\0${image}`)
|
|
39
|
-
else digest.update('file\0').update(readFileSync(path.resolve(cwd, image)))
|
|
40
|
-
return `med_retry_${digest.digest('base64url')}`
|
|
41
|
-
})
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function directory(env: NodeJS.ProcessEnv): string {
|
|
45
|
-
return path.join(stateDir(env), 'send-attempts')
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function credentialTag(credential: MachineCredential): string {
|
|
49
|
-
return createHmac('sha256', credential.secret)
|
|
50
|
-
.update('notifai-send-attempt-credential-v1')
|
|
51
|
-
.digest('base64url')
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** A keyed digest proves equality without persisting notification content. */
|
|
55
|
-
export function sendDraftFingerprint(
|
|
56
|
-
draft: NotificationDraftT,
|
|
57
|
-
credential: MachineCredential,
|
|
58
|
-
): string {
|
|
59
|
-
return createHmac('sha256', credential.secret)
|
|
60
|
-
.update('notifai-send-draft-v1\0')
|
|
61
|
-
.update(JSON.stringify(draft))
|
|
62
|
-
.digest('base64url')
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function parseRecord(file: string): SendAttemptRecord | null {
|
|
66
|
-
let descriptor: number | undefined
|
|
67
|
-
try {
|
|
68
|
-
const before = lstatSync(file)
|
|
69
|
-
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
|
|
70
|
-
if (before.isSymbolicLink() || !before.isFile() || (uid !== undefined && before.uid !== uid)) {
|
|
71
|
-
return null
|
|
72
|
-
}
|
|
73
|
-
const flags = constants.O_RDONLY | (process.platform === 'win32' ? 0 : constants.O_NOFOLLOW)
|
|
74
|
-
descriptor = openSync(file, flags)
|
|
75
|
-
const opened = fstatSync(descriptor)
|
|
76
|
-
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino) return null
|
|
77
|
-
const value = JSON.parse(readFileSync(descriptor, 'utf8')) as Partial<SendAttemptRecord>
|
|
78
|
-
return value.version === 1 &&
|
|
79
|
-
typeof value.attempt_id === 'string' &&
|
|
80
|
-
typeof value.fingerprint === 'string' &&
|
|
81
|
-
typeof value.idempotency_key === 'string' &&
|
|
82
|
-
typeof value.credential_tag === 'string' &&
|
|
83
|
-
typeof value.machine_id === 'string' &&
|
|
84
|
-
typeof value.service === 'string' &&
|
|
85
|
-
typeof value.created_at === 'string' &&
|
|
86
|
-
typeof value.expires_at === 'string'
|
|
87
|
-
? value as SendAttemptRecord
|
|
88
|
-
: null
|
|
89
|
-
} catch {
|
|
90
|
-
return null
|
|
91
|
-
} finally {
|
|
92
|
-
if (descriptor !== undefined) closeSync(descriptor)
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function liveRecords(
|
|
97
|
-
env: NodeJS.ProcessEnv,
|
|
98
|
-
credential: MachineCredential,
|
|
99
|
-
now: number,
|
|
100
|
-
): SendAttemptRecord[] {
|
|
101
|
-
const dir = directory(env)
|
|
102
|
-
if (!existsSync(dir)) return []
|
|
103
|
-
const directoryStat = lstatSync(dir)
|
|
104
|
-
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
|
|
105
|
-
if (
|
|
106
|
-
directoryStat.isSymbolicLink() ||
|
|
107
|
-
!directoryStat.isDirectory() ||
|
|
108
|
-
(uid !== undefined && directoryStat.uid !== uid)
|
|
109
|
-
) return []
|
|
110
|
-
const tag = credentialTag(credential)
|
|
111
|
-
const records: SendAttemptRecord[] = []
|
|
112
|
-
for (const name of readdirSync(dir)) {
|
|
113
|
-
if (!name.endsWith('.json')) continue
|
|
114
|
-
const file = path.join(dir, name)
|
|
115
|
-
const record = parseRecord(file)
|
|
116
|
-
const expired = record === null || Date.parse(record.expires_at) <= now
|
|
117
|
-
const rotated = record !== null && (
|
|
118
|
-
record.machine_id !== credential.machineId ||
|
|
119
|
-
record.service !== credential.baseUrl ||
|
|
120
|
-
record.credential_tag !== tag
|
|
121
|
-
)
|
|
122
|
-
if (expired || rotated) {
|
|
123
|
-
rmSync(file, { force: true })
|
|
124
|
-
continue
|
|
125
|
-
}
|
|
126
|
-
records.push(record)
|
|
127
|
-
}
|
|
128
|
-
return records
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export type BeginAttempt =
|
|
132
|
-
| { ok: true; attemptId: string; idempotencyKey: string; replay: boolean }
|
|
133
|
-
| { ok: false; code: 'retry_not_found' | 'retry_ambiguous'; message: string }
|
|
134
|
-
|
|
135
|
-
export function beginSendAttempt(options: {
|
|
136
|
-
env: NodeJS.ProcessEnv
|
|
137
|
-
credential: MachineCredential
|
|
138
|
-
fingerprint: string
|
|
139
|
-
retry: boolean
|
|
140
|
-
idempotencyKey?: string
|
|
141
|
-
now?: number
|
|
142
|
-
}): BeginAttempt {
|
|
143
|
-
const now = options.now ?? Date.now()
|
|
144
|
-
const live = liveRecords(options.env, options.credential, now)
|
|
145
|
-
if (options.retry) {
|
|
146
|
-
const matches = live.filter((record) => record.fingerprint === options.fingerprint)
|
|
147
|
-
if (matches.length === 0) {
|
|
148
|
-
return {
|
|
149
|
-
ok: false,
|
|
150
|
-
code: 'retry_not_found',
|
|
151
|
-
message: 'No unresolved opaque attempt matches this exact send on the current Approved Machine.',
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
if (matches.length > 1) {
|
|
155
|
-
return {
|
|
156
|
-
ok: false,
|
|
157
|
-
code: 'retry_ambiguous',
|
|
158
|
-
message: 'More than one unresolved opaque attempt matches this exact send; refusing to guess or create another notification.',
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
const match = matches[0]!
|
|
162
|
-
return { ok: true, attemptId: match.attempt_id, idempotencyKey: match.idempotency_key, replay: true }
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const attemptId = `sat_${randomBytes(12).toString('base64url')}`
|
|
166
|
-
const idempotencyKey = options.idempotencyKey ?? `cli-${randomBytes(12).toString('base64url')}`
|
|
167
|
-
const record: SendAttemptRecord = {
|
|
168
|
-
version: 1,
|
|
169
|
-
attempt_id: attemptId,
|
|
170
|
-
fingerprint: options.fingerprint,
|
|
171
|
-
idempotency_key: idempotencyKey,
|
|
172
|
-
credential_tag: credentialTag(options.credential),
|
|
173
|
-
machine_id: options.credential.machineId,
|
|
174
|
-
service: options.credential.baseUrl,
|
|
175
|
-
created_at: new Date(now).toISOString(),
|
|
176
|
-
expires_at: new Date(now + SEND_ATTEMPT_TTL_MS).toISOString(),
|
|
177
|
-
}
|
|
178
|
-
atomicWriteFileSync(
|
|
179
|
-
path.join(directory(options.env), `${attemptId}.json`),
|
|
180
|
-
`${JSON.stringify(record)}\n`,
|
|
181
|
-
{ mode: 0o600, directoryMode: 0o700, requireCurrentUserOwner: true },
|
|
182
|
-
)
|
|
183
|
-
return { ok: true, attemptId, idempotencyKey, replay: false }
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
export function settleSendAttempt(env: NodeJS.ProcessEnv, attemptId: string): void {
|
|
187
|
-
rmSync(path.join(directory(env), `${attemptId}.json`), { force: true })
|
|
188
|
-
}
|
package/src/send.test.ts
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import {
|
|
3
|
-
accidentalEscapedNewlineMessage,
|
|
4
|
-
hasAccidentalEscapedNewlines,
|
|
5
|
-
rejectAccidentalEscapedNewlines,
|
|
6
|
-
} from './send.js'
|
|
7
|
-
|
|
8
|
-
describe('accidental escaped newlines', () => {
|
|
9
|
-
it('catches a shell-quoted backslash-n body', () => {
|
|
10
|
-
expect(hasAccidentalEscapedNewlines('line1\\nline2')).toBe(true)
|
|
11
|
-
expect(rejectAccidentalEscapedNewlines('line1\\nline2', false)).toBe(
|
|
12
|
-
accidentalEscapedNewlineMessage(),
|
|
13
|
-
)
|
|
14
|
-
})
|
|
15
|
-
|
|
16
|
-
it('preserves real multiline Markdown', () => {
|
|
17
|
-
expect(hasAccidentalEscapedNewlines('line1\nline2')).toBe(false)
|
|
18
|
-
expect(rejectAccidentalEscapedNewlines('line1\nline2', false)).toBeNull()
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
it('lets an explicit flag keep visible backslash-n text', () => {
|
|
22
|
-
expect(rejectAccidentalEscapedNewlines('use \\n in the sample', true)).toBeNull()
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
it('does not treat an escaped backslash plus n as accidental', () => {
|
|
26
|
-
expect(hasAccidentalEscapedNewlines('escape as \\\\n')).toBe(false)
|
|
27
|
-
})
|
|
28
|
-
})
|