@raidiant/notifai 4.0.0 → 6.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.
- package/CHANGELOG.md +73 -0
- package/README.md +7 -2
- package/dist/client.d.ts +8 -3
- package/dist/client.js +24 -3
- package/dist/client.js.map +1 -1
- package/dist/commands-auth.d.ts +14 -0
- package/dist/commands-auth.js +154 -0
- package/dist/commands-auth.js.map +1 -0
- package/dist/commands-config.d.ts +31 -0
- package/dist/commands-config.js +246 -0
- package/dist/commands-config.js.map +1 -0
- package/dist/commands-core.d.ts +102 -0
- package/dist/commands-core.js +135 -0
- package/dist/commands-core.js.map +1 -0
- package/dist/commands-devices.d.ts +31 -0
- package/dist/commands-devices.js +124 -0
- package/dist/commands-devices.js.map +1 -0
- package/dist/commands-doctor.d.ts +24 -0
- package/dist/commands-doctor.js +1011 -0
- package/dist/commands-doctor.js.map +1 -0
- package/dist/commands-harness-context.d.ts +61 -0
- package/dist/commands-harness-context.js +93 -0
- package/dist/commands-harness-context.js.map +1 -0
- package/dist/commands-hook-shape.d.ts +17 -0
- package/dist/commands-hook-shape.js +37 -0
- package/dist/commands-hook-shape.js.map +1 -0
- package/dist/commands-hooks.d.ts +115 -0
- package/dist/commands-hooks.js +1225 -0
- package/dist/commands-hooks.js.map +1 -0
- package/dist/commands-init.d.ts +35 -0
- package/dist/commands-init.js +580 -0
- package/dist/commands-init.js.map +1 -0
- package/dist/commands-io.d.ts +2 -0
- package/dist/commands-io.js +110 -0
- package/dist/commands-io.js.map +1 -0
- package/dist/commands-logs.d.ts +30 -0
- package/dist/commands-logs.js +201 -0
- package/dist/commands-logs.js.map +1 -0
- package/dist/commands-send-support.d.ts +44 -0
- package/dist/commands-send-support.js +180 -0
- package/dist/commands-send-support.js.map +1 -0
- package/dist/commands-send.d.ts +33 -0
- package/dist/commands-send.js +519 -0
- package/dist/commands-send.js.map +1 -0
- package/dist/commands-setup-proof.d.ts +15 -0
- package/dist/commands-setup-proof.js +53 -0
- package/dist/commands-setup-proof.js.map +1 -0
- package/dist/commands-skill.d.ts +10 -0
- package/dist/commands-skill.js +71 -0
- package/dist/commands-skill.js.map +1 -0
- package/dist/commands.d.ts +12 -378
- package/dist/commands.js +12 -4420
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +15 -6
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +4 -2
- package/dist/config.js +13 -0
- package/dist/config.js.map +1 -1
- package/dist/credentials.js +13 -3
- package/dist/credentials.js.map +1 -1
- package/dist/file-lock.d.ts +6 -1
- package/dist/file-lock.js +55 -9
- package/dist/file-lock.js.map +1 -1
- package/dist/harnesses.d.ts +1 -1
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks.d.ts +74 -7
- package/dist/hooks.js +522 -166
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +91 -14
- package/dist/install-hooks.js +226 -34
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +5 -5
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +8 -0
- package/dist/logging.js +9 -0
- package/dist/logging.js.map +1 -1
- package/dist/main.js +45 -18
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.d.ts +8 -0
- package/dist/opencode-plugin.js +24 -162
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/readiness.d.ts +3 -1
- package/dist/readiness.js.map +1 -1
- package/dist/send.d.ts +6 -0
- package/dist/send.js +25 -14
- package/dist/send.js.map +1 -1
- package/dist/ui/help.d.ts +0 -1
- package/dist/ui/help.js +6 -3
- package/dist/ui/help.js.map +1 -1
- package/dist/ui/theme.d.ts +0 -15
- package/dist/ui/theme.js +2 -18
- package/dist/ui/theme.js.map +1 -1
- package/package.json +2 -2
- package/src/client.test.ts +90 -1
- package/src/client.ts +35 -2
- package/src/commands-auth.ts +174 -0
- package/src/commands-config.ts +307 -0
- package/src/commands-core.ts +240 -0
- package/src/commands-devices.ts +150 -0
- package/src/commands-doctor.ts +1209 -0
- package/src/commands-harness-context.ts +126 -0
- package/src/commands-hook-shape.ts +53 -0
- package/src/commands-hooks.ts +1586 -0
- package/src/commands-init.ts +721 -0
- package/src/commands-io.ts +108 -0
- package/src/commands-logs.ts +243 -0
- package/src/commands-send-support.ts +224 -0
- package/src/commands-send.ts +623 -0
- package/src/commands-setup-proof.ts +71 -0
- package/src/commands-skill.ts +84 -0
- package/src/commands.test.ts +677 -89
- package/src/commands.ts +58 -5472
- package/src/config-schema.test.ts +1 -1
- package/src/config-schema.ts +17 -6
- package/src/config.test.ts +50 -9
- package/src/config.ts +19 -1
- package/src/credentials.test.ts +25 -1
- package/src/credentials.ts +13 -3
- package/src/file-lock.test.ts +41 -0
- package/src/file-lock.ts +59 -9
- package/src/harnesses.test.ts +37 -29
- package/src/harnesses.ts +1 -4
- package/src/hooks.test.ts +703 -26
- package/src/hooks.ts +590 -182
- package/src/install-hooks.test.ts +232 -12
- package/src/install-hooks.ts +241 -37
- package/src/interactive.test.ts +19 -1
- package/src/interactive.ts +5 -5
- package/src/logging-process-worker.test.ts +8 -2
- package/src/logging.test.ts +48 -20
- package/src/logging.ts +12 -0
- package/src/main.ts +58 -24
- package/src/opencode-plugin.ts +24 -164
- package/src/question-caps.test.ts +49 -0
- package/src/readiness.ts +3 -1
- package/src/send.ts +32 -16
- package/src/skill.test.ts +199 -146
- package/src/ui/help.ts +8 -3
- package/src/ui/theme.ts +2 -16
|
@@ -86,7 +86,7 @@ describe('formatValue', () => {
|
|
|
86
86
|
it('explains what an unset value means instead of printing null', () => {
|
|
87
87
|
// `sound = null` is accurate and tells the reader nothing about what will
|
|
88
88
|
// happen; the whole complaint about the old output in one line.
|
|
89
|
-
expect(formatValue('sound', null)).toBe('not
|
|
89
|
+
expect(formatValue('sound', null)).toBe('not set; each kind brings its own sound')
|
|
90
90
|
expect(formatValue('devices', null)).toBe('every device that can receive')
|
|
91
91
|
})
|
|
92
92
|
|
package/src/config-schema.ts
CHANGED
|
@@ -83,9 +83,9 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
|
|
|
83
83
|
label: 'Send questions to my devices',
|
|
84
84
|
group: 'questions',
|
|
85
85
|
kind: 'boolean',
|
|
86
|
-
summary: 'Whether a registered question may reach your
|
|
86
|
+
summary: 'Whether a registered question may reach your iPhone at all',
|
|
87
87
|
detail:
|
|
88
|
-
'The master switch for question routing. When this is off, a question an agent registers with `notifai ask` stays in the terminal and never leaves this machine, whatever
|
|
88
|
+
'The master switch for question routing. When this is off, a question an agent registers with `notifai ask` stays in the terminal and never leaves this machine, whatever else is configured. Turn it off to stop being reached for a while without uninstalling the harness hooks.',
|
|
89
89
|
example: 'true',
|
|
90
90
|
},
|
|
91
91
|
ask_grace_seconds: {
|
|
@@ -99,6 +99,17 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
|
|
|
99
99
|
example: '0',
|
|
100
100
|
},
|
|
101
101
|
|
|
102
|
+
reply_window_seconds: {
|
|
103
|
+
label: 'How long an answer is accepted',
|
|
104
|
+
group: 'questions',
|
|
105
|
+
kind: 'integer',
|
|
106
|
+
unit: 's',
|
|
107
|
+
summary: 'How long the service keeps accepting your answer to a question',
|
|
108
|
+
detail:
|
|
109
|
+
'A question stays answerable for this long after it is asked. The default is a day, so a question that arrives while you are away is still yours to answer when you come back.\n\nThis is not how long anything waits on you. The agent ends its turn immediately; if it is still running when you answer, the answer reaches it directly, and otherwise it is delivered at that session\'s next turn. Shortening this only makes an answer arrive too late to be accepted.',
|
|
110
|
+
example: '86400',
|
|
111
|
+
},
|
|
112
|
+
|
|
102
113
|
sound: {
|
|
103
114
|
label: 'Sound',
|
|
104
115
|
group: 'delivery',
|
|
@@ -113,8 +124,8 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
|
|
|
113
124
|
},
|
|
114
125
|
summary: 'Set the sound notifications from here use',
|
|
115
126
|
detail:
|
|
116
|
-
'Setting this pins one sound for every notification sent from here
|
|
117
|
-
unsetMeans: 'not
|
|
127
|
+
'Setting this pins one sound for every notification sent from here, whatever kind it is.\n\nLeave it unset and each notification arrives with the sound its kind implies: the completion chime when work finished, the most insistent tone when it failed, a distinct attention tone for a question or blocked work, and the standard tone for ordinary news. A common explicit preference is `none`, to make a noisy project completely silent.',
|
|
128
|
+
unsetMeans: 'not set; each kind brings its own sound',
|
|
118
129
|
},
|
|
119
130
|
interruption_level: {
|
|
120
131
|
label: 'Interruption level',
|
|
@@ -124,11 +135,11 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
|
|
|
124
135
|
choiceHints: {
|
|
125
136
|
passive: 'no sound or wake — waits in the notification list',
|
|
126
137
|
active: 'wakes the screen and plays the sound',
|
|
127
|
-
time_sensitive: '
|
|
138
|
+
time_sensitive: 'marks the notification urgent; Focus breakthrough is not available yet',
|
|
128
139
|
},
|
|
129
140
|
summary: 'Set how insistently notifications from here arrive',
|
|
130
141
|
detail:
|
|
131
|
-
'How hard a notification tries to reach you, in the operating system\'s terms. Setting this pins one level for everything sent from here; kind never
|
|
142
|
+
'How hard a notification tries to reach you, in the operating system\'s terms. Setting this pins one level for everything sent from here; kind chooses the sound, never this.\n\nLeave this unset to omit the field and let the destination use its normal behavior. `time_sensitive` is accepted and marks the notification urgent, but Focus breakthrough is not available yet — run `notifai capabilities` for what a platform actually honours.',
|
|
132
143
|
unsetMeans: 'not specified; the destination uses its normal behavior',
|
|
133
144
|
},
|
|
134
145
|
devices: {
|
package/src/config.test.ts
CHANGED
|
@@ -177,6 +177,15 @@ describe('config precedence', () => {
|
|
|
177
177
|
expect(config.wait_seconds.value).toBe(2)
|
|
178
178
|
expect(config.wait_seconds.source).toBe('flag')
|
|
179
179
|
})
|
|
180
|
+
|
|
181
|
+
it('uses the prior default when a newer CLI wrote an unknown enum value', () => {
|
|
182
|
+
const { env, cwd } = setup({
|
|
183
|
+
globalToml: 'sound = "future-sound"\ninterruption_level = "future-level"\n',
|
|
184
|
+
})
|
|
185
|
+
const config = loadConfig({ cwd, env })
|
|
186
|
+
expect(config.sound).toEqual({ value: null, source: 'default' })
|
|
187
|
+
expect(config.interruption_level).toEqual({ value: null, source: 'default' })
|
|
188
|
+
})
|
|
180
189
|
})
|
|
181
190
|
|
|
182
191
|
describe('draft building', () => {
|
|
@@ -191,20 +200,32 @@ describe('draft building', () => {
|
|
|
191
200
|
presentation: { title: 'T', body: 'B' },
|
|
192
201
|
targets: { mode: 'all' },
|
|
193
202
|
delivery: { ttl_seconds: 86400, collapse_key: null },
|
|
203
|
+
// An unlabelled draft is ordinary news, and news has a sound.
|
|
204
|
+
platform: { ios: { sound: 'default' }, macos: { sound: 'default' } },
|
|
194
205
|
})
|
|
195
206
|
})
|
|
196
207
|
|
|
197
208
|
it.each([
|
|
198
|
-
{},
|
|
199
|
-
{ kind: '
|
|
200
|
-
{ kind: '
|
|
201
|
-
{ kind: '
|
|
202
|
-
{
|
|
203
|
-
|
|
209
|
+
{ flags: {}, sound: 'default' },
|
|
210
|
+
{ flags: { kind: 'update' }, sound: 'default' },
|
|
211
|
+
{ flags: { kind: 'done' }, sound: 'done' },
|
|
212
|
+
{ flags: { kind: 'failed' }, sound: 'alert' },
|
|
213
|
+
{ flags: { kind: 'blocked' }, sound: 'attention' },
|
|
214
|
+
{ flags: { kind: 'question' }, sound: 'attention' },
|
|
215
|
+
{ flags: { reply: true }, sound: 'attention' },
|
|
216
|
+
])('gives $flags the $sound sound when nobody chose one', ({ flags, sound }) => {
|
|
204
217
|
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
205
218
|
const build = buildDraft(config, { title: 'T', body: 'B', ...flags })
|
|
206
219
|
if (!build.ok) throw new Error(build.error)
|
|
207
|
-
expect(build.draft.platform).
|
|
220
|
+
expect(build.draft.platform?.ios?.sound).toBe(sound)
|
|
221
|
+
expect(build.draft.platform?.ios?.interruption_level).toBeUndefined()
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('lets an explicit sound outrank the kind it would have derived', () => {
|
|
225
|
+
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
226
|
+
const explicit = buildDraft(config, { title: 'T', body: 'B', kind: 'failed', sound: 'none' })
|
|
227
|
+
if (!explicit.ok) throw new Error(explicit.error)
|
|
228
|
+
expect(explicit.draft.platform?.ios?.sound).toBeNull()
|
|
208
229
|
})
|
|
209
230
|
|
|
210
231
|
it('orders explicit flags over saved user config', () => {
|
|
@@ -233,12 +254,32 @@ describe('draft building', () => {
|
|
|
233
254
|
})
|
|
234
255
|
})
|
|
235
256
|
|
|
236
|
-
it('keeps a
|
|
257
|
+
it('keeps a question answerable for a day by default', () => {
|
|
258
|
+
// A question asked before someone steps away should still be theirs to
|
|
259
|
+
// answer when they come back; the CLI used to cut the wire contract's own
|
|
260
|
+
// default down to an hour.
|
|
237
261
|
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
238
262
|
const build = buildDraft(config, { title: 'Question', body: 'Deploy?', reply: true })
|
|
239
263
|
if (!build.ok) throw new Error(build.error)
|
|
240
264
|
|
|
241
|
-
expect(build.draft.reply?.expires_in_seconds).toBe(
|
|
265
|
+
expect(build.draft.reply?.expires_in_seconds).toBe(86_400)
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('lets the user shorten or lengthen how long an answer is accepted', () => {
|
|
269
|
+
const { env, cwd } = setup({ projectToml: 'reply_window_seconds = 7200\n' })
|
|
270
|
+
const config = loadConfig({ cwd, env })
|
|
271
|
+
const build = buildDraft(config, { title: 'Question', body: 'Deploy?', reply: true })
|
|
272
|
+
if (!build.ok) throw new Error(build.error)
|
|
273
|
+
expect(build.draft.reply?.expires_in_seconds).toBe(7_200)
|
|
274
|
+
|
|
275
|
+
const explicit = buildDraft(config, {
|
|
276
|
+
title: 'Question',
|
|
277
|
+
body: 'Deploy?',
|
|
278
|
+
reply: true,
|
|
279
|
+
replyWindow: 600,
|
|
280
|
+
})
|
|
281
|
+
if (!explicit.ok) throw new Error(explicit.error)
|
|
282
|
+
expect(explicit.draft.reply?.expires_in_seconds).toBe(600)
|
|
242
283
|
})
|
|
243
284
|
|
|
244
285
|
it('orders explicit and configured Project identity over inference', () => {
|
package/src/config.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CLI_SOUNDS,
|
|
3
|
+
INTERRUPTION_LEVELS,
|
|
4
|
+
REPLY_MAX_WINDOW_SECONDS,
|
|
5
|
+
} from '@raidiant/notifai-protocol'
|
|
1
6
|
import { createHash } from 'node:crypto'
|
|
2
7
|
import { existsSync, readFileSync, realpathSync } from 'node:fs'
|
|
3
8
|
import path from 'node:path'
|
|
4
9
|
import { parse as parseToml } from 'smol-toml'
|
|
5
|
-
import type { CLI_SOUNDS, INTERRUPTION_LEVELS } from '@raidiant/notifai-protocol'
|
|
6
10
|
import { configHome, stateHome } from './platform.js'
|
|
7
11
|
|
|
8
12
|
/**
|
|
@@ -87,6 +91,8 @@ export interface CliConfig {
|
|
|
87
91
|
* usefulness.
|
|
88
92
|
*/
|
|
89
93
|
log_max_bytes: ResolvedValue<number>
|
|
94
|
+
/** How long the server keeps accepting an answer to a question. */
|
|
95
|
+
reply_window_seconds: ResolvedValue<number>
|
|
90
96
|
/** Log files kept in total, the active one included. */
|
|
91
97
|
log_max_files: ResolvedValue<number>
|
|
92
98
|
}
|
|
@@ -102,6 +108,7 @@ export const CONFIG_KEYS = [
|
|
|
102
108
|
'notify_criteria',
|
|
103
109
|
'ask_notifications',
|
|
104
110
|
'ask_grace_seconds',
|
|
111
|
+
'reply_window_seconds',
|
|
105
112
|
'log_level',
|
|
106
113
|
'log_max_bytes',
|
|
107
114
|
'log_max_files',
|
|
@@ -116,6 +123,7 @@ export const NUMERIC_CONFIG_KEYS: readonly ConfigKey[] = [
|
|
|
116
123
|
'wait_seconds',
|
|
117
124
|
'ttl_seconds',
|
|
118
125
|
'ask_grace_seconds',
|
|
126
|
+
'reply_window_seconds',
|
|
119
127
|
'log_max_bytes',
|
|
120
128
|
'log_max_files',
|
|
121
129
|
]
|
|
@@ -128,6 +136,8 @@ export const NUMERIC_CONFIG_KEYS: readonly ConfigKey[] = [
|
|
|
128
136
|
* is the same discipline the numeric bounds follow.
|
|
129
137
|
*/
|
|
130
138
|
export const ENUM_CONFIG_VALUES: Partial<Record<ConfigKey, readonly string[]>> = {
|
|
139
|
+
sound: CLI_SOUNDS,
|
|
140
|
+
interruption_level: INTERRUPTION_LEVELS,
|
|
131
141
|
log_level: LOG_LEVELS,
|
|
132
142
|
}
|
|
133
143
|
|
|
@@ -149,6 +159,9 @@ const DEFAULTS = {
|
|
|
149
159
|
// Questions reach devices as soon as the turn ends. A terminal-first grace
|
|
150
160
|
// window is an opt-in delay on top of that.
|
|
151
161
|
ask_grace_seconds: 0,
|
|
162
|
+
// A day. The wire contract's own default, and long enough that a question
|
|
163
|
+
// asked before someone steps away is still answerable when they come back.
|
|
164
|
+
reply_window_seconds: 86_400,
|
|
152
165
|
// On by default: the log is the only account of what a headless hook did, and
|
|
153
166
|
// a diagnostic nobody switched on before the thing went wrong is not one.
|
|
154
167
|
log_level: 'info',
|
|
@@ -302,6 +315,10 @@ const NUMERIC_BOUNDS: Partial<Record<ConfigKey, { min: number; max: number }>> =
|
|
|
302
315
|
// longest window a user can choose still leaves an answer window the server
|
|
303
316
|
// will accept — with room to spare for a timer that wakes late.
|
|
304
317
|
ask_grace_seconds: { min: 0, max: 360 },
|
|
318
|
+
// The floor is the shortest window the wire contract accepts; the ceiling is
|
|
319
|
+
// the point past which a question would outlive the retained content it
|
|
320
|
+
// refers to.
|
|
321
|
+
reply_window_seconds: { min: 60, max: REPLY_MAX_WINDOW_SECONDS },
|
|
305
322
|
// The floor keeps rotation from thrashing (a file smaller than a handful of
|
|
306
323
|
// records would rotate on nearly every write); the ceiling is the point past
|
|
307
324
|
// which "local diagnostics" has become "a database on your disk".
|
|
@@ -413,6 +430,7 @@ export function loadConfig(options: {
|
|
|
413
430
|
notify_criteria: resolve('notify_criteria'),
|
|
414
431
|
ask_notifications: resolve('ask_notifications'),
|
|
415
432
|
ask_grace_seconds: resolve('ask_grace_seconds'),
|
|
433
|
+
reply_window_seconds: resolve('reply_window_seconds'),
|
|
416
434
|
log_level: resolve('log_level'),
|
|
417
435
|
log_max_bytes: resolve('log_max_bytes'),
|
|
418
436
|
log_max_files: resolve('log_max_files'),
|
package/src/credentials.test.ts
CHANGED
|
@@ -168,7 +168,10 @@ describe('FileStore', () => {
|
|
|
168
168
|
if (process.platform !== 'win32') {
|
|
169
169
|
expect(statSync(file).mode & 0o777).toBe(0o600)
|
|
170
170
|
}
|
|
171
|
-
expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual(
|
|
171
|
+
expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual({
|
|
172
|
+
format: 'notifai.machine-credential.v1',
|
|
173
|
+
...SAMPLE,
|
|
174
|
+
})
|
|
172
175
|
expect(store.load()).toEqual(SAMPLE)
|
|
173
176
|
store.save(OTHER)
|
|
174
177
|
expect(store.load()).toEqual(OTHER)
|
|
@@ -190,6 +193,27 @@ describe('FileStore', () => {
|
|
|
190
193
|
writeRaw(file, `${JSON.stringify({ machineId: 'mach_fixture' })}\n`)
|
|
191
194
|
expect(new FileStore(env).load()).toBeNull()
|
|
192
195
|
})
|
|
196
|
+
|
|
197
|
+
it('reads the unversioned v1 shape while making every new save explicit', () => {
|
|
198
|
+
const { env } = sandbox('file-unversioned-v1')
|
|
199
|
+
const file = path.join(env.XDG_CONFIG_HOME!, 'notifai', 'credentials.json')
|
|
200
|
+
writeRaw(file, `${JSON.stringify(SAMPLE)}\n`)
|
|
201
|
+
const store = new FileStore(env)
|
|
202
|
+
expect(store.load()).toEqual(SAMPLE)
|
|
203
|
+
store.save(SAMPLE)
|
|
204
|
+
expect(JSON.parse(readFileSync(file, 'utf8'))).toMatchObject({
|
|
205
|
+
format: 'notifai.machine-credential.v1',
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('does not interpret or erase a credential from a future format epoch', () => {
|
|
210
|
+
const { env } = sandbox('file-future-format')
|
|
211
|
+
const file = path.join(env.XDG_CONFIG_HOME!, 'notifai', 'credentials.json')
|
|
212
|
+
const future = `${JSON.stringify({ format: 'notifai.machine-credential.v2', ...SAMPLE })}\n`
|
|
213
|
+
writeRaw(file, future)
|
|
214
|
+
expect(new FileStore(env).load()).toBeNull()
|
|
215
|
+
expect(readFileSync(file, 'utf8')).toBe(future)
|
|
216
|
+
})
|
|
193
217
|
})
|
|
194
218
|
|
|
195
219
|
describe('WindowsDpapiStore', () => {
|
package/src/credentials.ts
CHANGED
|
@@ -44,6 +44,7 @@ export interface CredentialStoreOptions {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
const SERVICE = 'io.notifai.cli'
|
|
47
|
+
const CREDENTIAL_FORMAT = 'notifai.machine-credential.v1'
|
|
47
48
|
const DPAPI_FORMAT = 'notifai.dpapi.current-user.v1'
|
|
48
49
|
const DPAPI_ENTROPY = 'io.notifai.cli'
|
|
49
50
|
const DPAPI_TIMEOUT_MS = 15_000
|
|
@@ -136,7 +137,7 @@ export class KeychainStore implements CredentialStore {
|
|
|
136
137
|
'-a',
|
|
137
138
|
'machine',
|
|
138
139
|
'-w',
|
|
139
|
-
JSON.stringify(credential),
|
|
140
|
+
JSON.stringify(serializeCredential(credential)),
|
|
140
141
|
],
|
|
141
142
|
{ stdio: 'ignore' },
|
|
142
143
|
)
|
|
@@ -189,7 +190,7 @@ export class FileStore implements CredentialStore {
|
|
|
189
190
|
}
|
|
190
191
|
|
|
191
192
|
save(credential: MachineCredential): void {
|
|
192
|
-
atomicWriteFileSync(this.filePath(), `${JSON.stringify(credential, null, 2)}\n`, {
|
|
193
|
+
atomicWriteFileSync(this.filePath(), `${JSON.stringify(serializeCredential(credential), null, 2)}\n`, {
|
|
193
194
|
mode: 0o600,
|
|
194
195
|
preserveMode: false,
|
|
195
196
|
requireCurrentUserOwner: true,
|
|
@@ -251,7 +252,7 @@ export class WindowsDpapiStore implements CredentialStore {
|
|
|
251
252
|
if (file === null) {
|
|
252
253
|
throw new Error('Windows credential store path is not configured (LOCALAPPDATA is missing)')
|
|
253
254
|
}
|
|
254
|
-
const blob = this.protect(Buffer.from(JSON.stringify(credential), 'utf8'))
|
|
255
|
+
const blob = this.protect(Buffer.from(JSON.stringify(serializeCredential(credential)), 'utf8'))
|
|
255
256
|
atomicWriteFileSync(file, `${JSON.stringify({ format: DPAPI_FORMAT, data: blob })}\n`, {
|
|
256
257
|
mode: 0o600,
|
|
257
258
|
preserveMode: false,
|
|
@@ -302,6 +303,10 @@ export class WindowsDpapiStore implements CredentialStore {
|
|
|
302
303
|
}
|
|
303
304
|
}
|
|
304
305
|
|
|
306
|
+
function serializeCredential(credential: MachineCredential): Record<string, string> {
|
|
307
|
+
return { format: CREDENTIAL_FORMAT, ...credential }
|
|
308
|
+
}
|
|
309
|
+
|
|
305
310
|
function parseCredentialJson(raw: string): MachineCredential | null {
|
|
306
311
|
try {
|
|
307
312
|
return parseCredential(JSON.parse(raw) as unknown)
|
|
@@ -313,6 +318,11 @@ function parseCredentialJson(raw: string): MachineCredential | null {
|
|
|
313
318
|
function parseCredential(raw: unknown): MachineCredential | null {
|
|
314
319
|
if (raw === null || typeof raw !== 'object') return null
|
|
315
320
|
const value = raw as Record<string, unknown>
|
|
321
|
+
const format = value['format']
|
|
322
|
+
// Unversioned credentials are the pre-epoch v1 representation. A future
|
|
323
|
+
// format is left untouched on disk/keychain and treated as unavailable;
|
|
324
|
+
// interpreting it as v1 could silently change authentication meaning.
|
|
325
|
+
if (format !== undefined && format !== CREDENTIAL_FORMAT) return null
|
|
316
326
|
const machineId = value['machineId']
|
|
317
327
|
const secret = value['secret']
|
|
318
328
|
const baseUrl = value['baseUrl']
|
package/src/file-lock.test.ts
CHANGED
|
@@ -206,6 +206,47 @@ describe('cross-process file lock', () => {
|
|
|
206
206
|
}, 30_000)
|
|
207
207
|
})
|
|
208
208
|
|
|
209
|
+
describe('rendezvous contention', () => {
|
|
210
|
+
// The last contender out removes the rendezvous directory, so a contender
|
|
211
|
+
// registering at that instant races the removal. Both tests drive that race
|
|
212
|
+
// from the registering observation, which is the window the racing releaser
|
|
213
|
+
// would otherwise have to hit by luck — on a loaded machine it does.
|
|
214
|
+
function raceAtRegistration(interfere: (lock: string) => void): void {
|
|
215
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-rendezvous-'))
|
|
216
|
+
const lock = path.join(root, 'shared.lock')
|
|
217
|
+
let raced = false
|
|
218
|
+
let entered = 0
|
|
219
|
+
|
|
220
|
+
withFileLock(lock, () => { entered += 1 }, {
|
|
221
|
+
observe(observation) {
|
|
222
|
+
if (observation.phase !== 'registering' || raced) return
|
|
223
|
+
raced = true
|
|
224
|
+
interfere(lock)
|
|
225
|
+
},
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
expect(raced).toBe(true)
|
|
229
|
+
expect(entered).toBe(1)
|
|
230
|
+
// Release still cleans up after itself, whichever rendezvous it ended in.
|
|
231
|
+
expect(existsSync(lock)).toBe(false)
|
|
232
|
+
rmSync(root, { recursive: true, force: true })
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
it('registers again when a releasing contender removed the rendezvous', () => {
|
|
236
|
+
raceAtRegistration((lock) => rmSync(lock, { recursive: true }))
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('holds the rendezvous it landed in, not the one it scanned', () => {
|
|
240
|
+
// A third contender recreates the directory before this one publishes, so
|
|
241
|
+
// the entry lands in a directory whose identity nobody has recorded yet.
|
|
242
|
+
// Treating that as tampering used to break the caller outright.
|
|
243
|
+
raceAtRegistration((lock) => {
|
|
244
|
+
rmSync(lock, { recursive: true })
|
|
245
|
+
mkdirSync(lock, { mode: 0o700 })
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
})
|
|
249
|
+
|
|
209
250
|
describe('target file transaction lock', () => {
|
|
210
251
|
it('refuses a symlinked target parent before publishing a contender', () => {
|
|
211
252
|
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-target-lock-'))
|
package/src/file-lock.ts
CHANGED
|
@@ -22,6 +22,14 @@ import path from 'node:path'
|
|
|
22
22
|
* process cannot remove a later owner's entry; because process liveness, rather
|
|
23
23
|
* than age, decides recovery, a delayed holder is never mistaken for a dead one.
|
|
24
24
|
*
|
|
25
|
+
* The rendezvous directory lives only as long as it has entries: the last
|
|
26
|
+
* contender out removes it, so an idle machine keeps no lock state. That makes
|
|
27
|
+
* the directory itself a contended resource. A contender registering while
|
|
28
|
+
* another releases can find it gone, half-deleted, or already replaced by a
|
|
29
|
+
* third contender, and none of those is a broken lock — so publication retries
|
|
30
|
+
* within the caller's deadline and pins its rendezvous only once its own entry
|
|
31
|
+
* is in the directory, which is the moment removal becomes impossible.
|
|
32
|
+
*
|
|
25
33
|
* Acquisition is deliberately bounded because both harness hooks and logging
|
|
26
34
|
* must fail open rather than hold an agent turn indefinitely.
|
|
27
35
|
*/
|
|
@@ -32,6 +40,8 @@ const ENTRY_PATTERN = /^(choosing|ticket-(\d+))-(\d+)-([0-9a-f]+)$/
|
|
|
32
40
|
const lockSleep = new Int32Array(new SharedArrayBuffer(4))
|
|
33
41
|
|
|
34
42
|
export type FileLockObservation =
|
|
43
|
+
/** The rendezvous has been scanned and this contender is about to publish. */
|
|
44
|
+
| { phase: 'registering'; entry: string }
|
|
35
45
|
| { phase: 'choosing-published'; entry: string }
|
|
36
46
|
| { phase: 'stale-entry'; entry: string }
|
|
37
47
|
| { phase: 'waiting'; blockers: string[] }
|
|
@@ -125,6 +135,16 @@ function assertOwnedDirectory(directory: string): FileIdentity {
|
|
|
125
135
|
return { dev: stat.dev, ino: stat.ino }
|
|
126
136
|
}
|
|
127
137
|
|
|
138
|
+
/**
|
|
139
|
+
* The rendezvous a contender was using is no longer the directory at that path.
|
|
140
|
+
*
|
|
141
|
+
* Fatal once an entry is published — a contender's own entry makes the
|
|
142
|
+
* directory non-empty, so no cooperating releaser can remove it. Before
|
|
143
|
+
* publication it means only that a releaser removed an empty rendezvous while
|
|
144
|
+
* this contender was registering in it, which is ordinary contention.
|
|
145
|
+
*/
|
|
146
|
+
class RendezvousReplaced extends Error {}
|
|
147
|
+
|
|
128
148
|
function assertSameDirectory(directory: string, expected: FileIdentity): void {
|
|
129
149
|
const current = lstatSync(directory)
|
|
130
150
|
if (
|
|
@@ -133,7 +153,7 @@ function assertSameDirectory(directory: string, expected: FileIdentity): void {
|
|
|
133
153
|
current.dev !== expected.dev ||
|
|
134
154
|
current.ino !== expected.ino
|
|
135
155
|
) {
|
|
136
|
-
throw new
|
|
156
|
+
throw new RendezvousReplaced(`${directory} changed while the file lock was active.`)
|
|
137
157
|
}
|
|
138
158
|
}
|
|
139
159
|
|
|
@@ -207,6 +227,24 @@ function publishEmpty(file: string): EntryIdentity {
|
|
|
207
227
|
}
|
|
208
228
|
}
|
|
209
229
|
|
|
230
|
+
/**
|
|
231
|
+
* True when a publication attempt lost its rendezvous rather than found a
|
|
232
|
+
* broken one.
|
|
233
|
+
*
|
|
234
|
+
* A releaser removes an empty rendezvous directory the moment the last entry
|
|
235
|
+
* goes, so a contender registering at that instant sees the removal from
|
|
236
|
+
* whichever side it reached first. All three are the same event: `ENOENT` when
|
|
237
|
+
* the directory is already gone, `EINVAL` when APFS reports a directory that is
|
|
238
|
+
* still being deleted, and a replaced identity when a third contender recreated
|
|
239
|
+
* it first. None of them says anything about the lock's integrity, because a
|
|
240
|
+
* contender with no published entry owns nothing yet.
|
|
241
|
+
*/
|
|
242
|
+
function lostTheRendezvous(err: unknown): boolean {
|
|
243
|
+
if (err instanceof RendezvousReplaced) return true
|
|
244
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
245
|
+
return code === 'ENOENT' || code === 'EINVAL'
|
|
246
|
+
}
|
|
247
|
+
|
|
210
248
|
function publishChoosing(
|
|
211
249
|
directory: string,
|
|
212
250
|
name: string,
|
|
@@ -217,21 +255,33 @@ function publishChoosing(
|
|
|
217
255
|
for (;;) {
|
|
218
256
|
try {
|
|
219
257
|
mkdirSync(directory, { recursive: true, mode: 0o700 })
|
|
220
|
-
const
|
|
258
|
+
const registrar = assertOwnedDirectory(directory)
|
|
221
259
|
chmodSync(directory, 0o700)
|
|
222
260
|
// Recover before registration so a delayed stale cleanup can only name an
|
|
223
261
|
// old owner's unique path, never this contender's future one.
|
|
224
|
-
liveEntries(directory,
|
|
262
|
+
liveEntries(directory, registrar, observe)
|
|
263
|
+
observe?.({ phase: 'registering', entry: name })
|
|
225
264
|
const fileIdentity = publishEmpty(file)
|
|
226
|
-
|
|
265
|
+
// Pin the rendezvous that holds the entry, not the one scanned before it
|
|
266
|
+
// existed. `open` resolves the path atomically, so the entry lands in
|
|
267
|
+
// whichever directory the path named at that instant — and from then on
|
|
268
|
+
// that directory is non-empty, so no cooperating releaser can remove it.
|
|
269
|
+
// Reading the identity here therefore names the contender's real
|
|
270
|
+
// rendezvous even when the scanned one was removed and recreated
|
|
271
|
+
// underneath it.
|
|
272
|
+
const directoryIdentity = assertOwnedDirectory(directory)
|
|
273
|
+
if (!sameEntryIdentity(file, fileIdentity)) {
|
|
274
|
+
throw new RendezvousReplaced(`${directory} no longer holds ${name}.`)
|
|
275
|
+
}
|
|
227
276
|
return { file, fileIdentity, directoryIdentity }
|
|
228
277
|
} catch (err) {
|
|
229
|
-
if ((err
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
// releaser can remove the non-empty directory underneath us.
|
|
278
|
+
if (!lostTheRendezvous(err)) throw err
|
|
279
|
+
// Recreate it and register again; the deadline, not the number of losses,
|
|
280
|
+
// decides when contention becomes a failure.
|
|
233
281
|
if (Date.now() >= deadline) {
|
|
234
|
-
throw new Error(
|
|
282
|
+
throw new Error(
|
|
283
|
+
`timed out publishing file lock contender in ${directory}: ${(err as Error).message}`,
|
|
284
|
+
)
|
|
235
285
|
}
|
|
236
286
|
Atomics.wait(lockSleep, 0, 0, FILE_LOCK_POLL_MS)
|
|
237
287
|
}
|
package/src/harnesses.test.ts
CHANGED
|
@@ -1,34 +1,42 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { HARNESS_CAPABILITIES, HARNESSES } from './harnesses.js'
|
|
2
|
+
import { HARNESS_CAPABILITIES, HARNESS_LABELS, HARNESSES } from './harnesses.js'
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* This file used to hold a verbatim copy of the table below it, which meant
|
|
6
|
+
* changing a harness contract required changing the same words twice and the
|
|
7
|
+
* test could never disagree with the code — only with whoever forgot the second
|
|
8
|
+
* edit. What is worth asserting is the shape the rest of the CLI assumes.
|
|
9
|
+
*/
|
|
10
|
+
describe('harness contract', () => {
|
|
11
|
+
it('describes every shipped harness, and only those', () => {
|
|
6
12
|
expect(Object.keys(HARNESS_CAPABILITIES).sort()).toEqual([...HARNESSES].sort())
|
|
7
|
-
expect(
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
13
|
+
expect(Object.keys(HARNESS_LABELS).sort()).toEqual([...HARNESSES].sort())
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('gives a harness that cannot continue a turn no route to pretend with', () => {
|
|
17
|
+
// `ask` fails closed on these, and the reason it can is that the two facts
|
|
18
|
+
// agree. A harness claiming a delivery route while declaring it cannot
|
|
19
|
+
// continue would accept an answer into a void.
|
|
20
|
+
for (const [harness, capability] of Object.entries(HARNESS_CAPABILITIES)) {
|
|
21
|
+
if (capability.stopContinuation !== 'unsupported') continue
|
|
22
|
+
expect(capability.deliveryRoutes, harness).toEqual(['unsupported'])
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('gives a harness that can continue a turn at least one real route', () => {
|
|
27
|
+
for (const [harness, capability] of Object.entries(HARNESS_CAPABILITIES)) {
|
|
28
|
+
if (capability.stopContinuation === 'unsupported') continue
|
|
29
|
+
expect(capability.deliveryRoutes.length, harness).toBeGreaterThan(0)
|
|
30
|
+
expect(capability.deliveryRoutes, harness).not.toContain('unsupported')
|
|
31
|
+
// The journal is the floor under every supported route: without it an
|
|
32
|
+
// answer that misses its turn has nowhere to wait.
|
|
33
|
+
expect(capability.deliveryRoutes, harness).toContain('hold-for-next-turn')
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('explains each harness in prose an operator can act on', () => {
|
|
38
|
+
for (const [harness, capability] of Object.entries(HARNESS_CAPABILITIES)) {
|
|
39
|
+
expect(capability.deliveryContract.length, harness).toBeGreaterThan(40)
|
|
40
|
+
}
|
|
33
41
|
})
|
|
34
42
|
})
|
package/src/harnesses.ts
CHANGED
|
@@ -18,10 +18,7 @@ export type DeliveryRoute =
|
|
|
18
18
|
| 'hold-for-next-turn'
|
|
19
19
|
| 'unsupported'
|
|
20
20
|
|
|
21
|
-
export type StopContinuation =
|
|
22
|
-
| 'decision-block'
|
|
23
|
-
| 'followup-message'
|
|
24
|
-
| 'unsupported'
|
|
21
|
+
export type StopContinuation = 'decision-block' | 'unsupported'
|
|
25
22
|
|
|
26
23
|
export interface HarnessCapability {
|
|
27
24
|
/** How an answer is admitted to another turn in the already-open session. */
|