@naxodev/apnea 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +18 -1
  2. package/SECURITY.md +36 -0
  3. package/briefs/orchestrator.md +4 -3
  4. package/dist/cli.js +8375 -15093
  5. package/docs/protocol/artifacts.md +18 -2
  6. package/docs/protocol/config.md +15 -3
  7. package/docs/protocol/manual-gate.md +8 -8
  8. package/docs/protocol/overview.md +17 -4
  9. package/extension/adapters/commit.ts +5 -1
  10. package/extension/adapters/dispatch.ts +9 -1
  11. package/extension/adapters/setup.ts +15 -1
  12. package/extension/adapters/start.ts +5 -1
  13. package/extension/adapters/status.ts +17 -2
  14. package/extension/adapters/wait.ts +6 -1
  15. package/extension/api.ts +7 -1
  16. package/extension/cli/main.ts +67 -7
  17. package/extension/cli/parse.ts +172 -5
  18. package/extension/domain/paths.ts +2 -11
  19. package/extension/domain/timeouts.ts +4 -0
  20. package/extension/domain/types.ts +65 -3
  21. package/extension/errors.ts +51 -16
  22. package/extension/operation-hooks.ts +6 -0
  23. package/extension/registry.ts +29 -15
  24. package/extension/run-tool.ts +19 -2
  25. package/extension/schema/config.ts +58 -16
  26. package/extension/schema/frontmatter.ts +57 -0
  27. package/extension/schema/state.ts +210 -13
  28. package/extension/services/app-live.ts +2 -1
  29. package/extension/services/config.ts +6 -4
  30. package/extension/services/file-system.ts +346 -75
  31. package/extension/services/herdr.ts +466 -260
  32. package/extension/services/operation-lock.ts +452 -0
  33. package/extension/services/process.ts +477 -0
  34. package/extension/services/run-store.ts +38 -16
  35. package/extension/services/vcs.ts +1258 -328
  36. package/extension/workflows/commit.ts +214 -13
  37. package/extension/workflows/dispatch.ts +305 -67
  38. package/extension/workflows/setup.ts +59 -32
  39. package/extension/workflows/start.ts +6 -4
  40. package/extension/workflows/status.ts +2 -2
  41. package/extension/workflows/wait.ts +62 -77
  42. package/package.json +2 -2
  43. package/schemas/config.schema.json +5 -1
  44. package/schemas/state.schema.json +165 -11
@@ -4,6 +4,8 @@ import { randomUUID } from "node:crypto"
4
4
  import { Context, Effect, Layer } from "effect"
5
5
  import { ConfigError } from "../errors.ts"
6
6
 
7
+ export const PERSISTED_INPUT_MAX_BYTES = 1024 * 1024
8
+
7
9
  export interface FileSystemService {
8
10
  readonly readFile: (path: string) => Effect.Effect<string>
9
11
  readonly writeFile: (path: string, content: string) => Effect.Effect<void>
@@ -12,6 +14,38 @@ export interface FileSystemService {
12
14
  destination: string,
13
15
  content: string,
14
16
  ) => Effect.Effect<void, ConfigError>
17
+ readonly writeTrustedGlobalFile: (
18
+ accountHome: string,
19
+ destination: string,
20
+ content: string,
21
+ ) => Effect.Effect<void, ConfigError>
22
+ readonly readTrustedGlobalFile: (
23
+ accountHome: string,
24
+ source: string,
25
+ limit?: number,
26
+ ) => Effect.Effect<string, ConfigError>
27
+ readonly readProjectFile: (
28
+ root: string,
29
+ source: string,
30
+ limit?: number,
31
+ ) => Effect.Effect<string, ConfigError>
32
+ readonly projectPathExists: (
33
+ root: string,
34
+ destination: string,
35
+ ) => Effect.Effect<boolean, ConfigError>
36
+ readonly mkdirProject: (
37
+ root: string,
38
+ destination: string,
39
+ ) => Effect.Effect<void, ConfigError>
40
+ readonly renameProjectFile: (
41
+ root: string,
42
+ from: string,
43
+ to: string,
44
+ ) => Effect.Effect<void, ConfigError>
45
+ readonly removeProjectFile: (
46
+ root: string,
47
+ destination: string,
48
+ ) => Effect.Effect<void, ConfigError>
15
49
  readonly rename: (from: string, to: string) => Effect.Effect<void>
16
50
  readonly exists: (path: string) => Effect.Effect<boolean>
17
51
  readonly mkdir: (
@@ -31,6 +65,228 @@ export class FileSystem extends Context.Service<
31
65
  FileSystemService
32
66
  >()("apnea/FileSystem") {}
33
67
 
68
+ function secureProjectPath(root: string, destination: string): string {
69
+ const lexicalRoot = path.resolve(root)
70
+ const lexicalTarget = path.resolve(destination)
71
+ const relative = path.relative(lexicalRoot, lexicalTarget)
72
+ if (
73
+ relative === "" ||
74
+ relative === ".." ||
75
+ relative.startsWith(`..${path.sep}`) ||
76
+ path.isAbsolute(relative)
77
+ ) {
78
+ throw new ConfigError({
79
+ message: "project file destination must be below project root",
80
+ path: lexicalTarget,
81
+ })
82
+ }
83
+
84
+ const projectRoot = fs.realpathSync(lexicalRoot)
85
+ const target = path.join(projectRoot, relative)
86
+ let current = projectRoot
87
+ const components = relative.split(path.sep)
88
+ for (const [index, component] of components.entries()) {
89
+ current = path.join(current, component)
90
+ try {
91
+ const stat = fs.lstatSync(current)
92
+ if (stat.isSymbolicLink()) {
93
+ throw new ConfigError({
94
+ message: `refusing symlink below project root: ${current}`,
95
+ path: current,
96
+ })
97
+ }
98
+ if (index < components.length - 1 && !stat.isDirectory()) {
99
+ throw new ConfigError({
100
+ message: `project path component is not a safe directory: ${current}`,
101
+ path: current,
102
+ })
103
+ }
104
+ } catch (error) {
105
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error
106
+ }
107
+ }
108
+ return target
109
+ }
110
+
111
+ function createProjectDirectories(root: string, destination: string): void {
112
+ const target = secureProjectPath(root, destination)
113
+ const projectRoot = fs.realpathSync(path.resolve(root))
114
+ const relative = path.relative(projectRoot, target)
115
+ let current = projectRoot
116
+ for (const component of relative.split(path.sep)) {
117
+ current = path.join(current, component)
118
+ try {
119
+ const stat = fs.lstatSync(current)
120
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
121
+ throw new ConfigError({
122
+ message: `project path component is not a safe directory: ${current}`,
123
+ path: current,
124
+ })
125
+ }
126
+ } catch (error) {
127
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error
128
+ fs.mkdirSync(current, { mode: 0o700 })
129
+ }
130
+ }
131
+ }
132
+
133
+ function trustedGlobalPath(accountHome: string, destination: string): string {
134
+ const lexicalHome = path.resolve(accountHome)
135
+ const lexicalTarget = path.resolve(destination)
136
+ const relative = path.relative(lexicalHome, lexicalTarget)
137
+ if (
138
+ relative === "" ||
139
+ relative === ".." ||
140
+ relative.startsWith(`..${path.sep}`) ||
141
+ path.isAbsolute(relative)
142
+ ) {
143
+ throw new ConfigError({
144
+ message: "trusted global file destination must be below account home",
145
+ path: lexicalTarget,
146
+ })
147
+ }
148
+
149
+ const home = fs.realpathSync(lexicalHome)
150
+ const target = path.join(home, relative)
151
+ let current = home
152
+ const components = relative.split(path.sep)
153
+ for (const [index, component] of components.entries()) {
154
+ current = path.join(current, component)
155
+ try {
156
+ const stat = fs.lstatSync(current)
157
+ if (stat.isSymbolicLink()) {
158
+ throw new ConfigError({
159
+ message: `refusing symlink below trusted account home: ${current}`,
160
+ path: current,
161
+ })
162
+ }
163
+ if (index < components.length - 1 && !stat.isDirectory()) {
164
+ throw new ConfigError({
165
+ message: `trusted global path component is not a safe directory: ${current}`,
166
+ path: current,
167
+ })
168
+ }
169
+ } catch (error) {
170
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error
171
+ }
172
+ }
173
+ return target
174
+ }
175
+
176
+ function createTrustedGlobalDirectories(
177
+ accountHome: string,
178
+ destination: string,
179
+ ): void {
180
+ const target = trustedGlobalPath(accountHome, destination)
181
+ const home = fs.realpathSync(path.resolve(accountHome))
182
+ const relative = path.relative(home, target)
183
+ let current = home
184
+ for (const component of relative.split(path.sep)) {
185
+ current = path.join(current, component)
186
+ try {
187
+ const stat = fs.lstatSync(current)
188
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
189
+ throw new ConfigError({
190
+ message: `trusted global path component is not a safe directory: ${current}`,
191
+ path: current,
192
+ })
193
+ }
194
+ } catch (error) {
195
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error
196
+ fs.mkdirSync(current)
197
+ }
198
+ }
199
+ }
200
+
201
+ function projectEffect<A>(operation: () => A): Effect.Effect<A, ConfigError> {
202
+ return Effect.try({ try: operation, catch: (error) => error }).pipe(
203
+ Effect.catch((error) =>
204
+ error instanceof ConfigError ? Effect.fail(error) : Effect.die(error),
205
+ ),
206
+ )
207
+ }
208
+
209
+ function readRegularUtf8(target: string, limit: number): string {
210
+ if (
211
+ !Number.isSafeInteger(limit) ||
212
+ limit < 0 ||
213
+ limit > PERSISTED_INPUT_MAX_BYTES
214
+ ) {
215
+ throw new ConfigError({
216
+ message: `persisted input byte limit must be between 0 and ${PERSISTED_INPUT_MAX_BYTES}`,
217
+ path: target,
218
+ })
219
+ }
220
+ let descriptor: number | undefined
221
+ try {
222
+ const noFollow = fs.constants.O_NOFOLLOW ?? 0
223
+ const nonBlocking = fs.constants.O_NONBLOCK ?? 0
224
+ descriptor = fs.openSync(
225
+ target,
226
+ fs.constants.O_RDONLY | noFollow | nonBlocking,
227
+ )
228
+ if (!fs.fstatSync(descriptor).isFile()) {
229
+ throw new ConfigError({
230
+ message: `persisted input is not a regular file: ${target}`,
231
+ path: target,
232
+ })
233
+ }
234
+
235
+ const bytes = Buffer.allocUnsafe(limit + 1)
236
+ let offset = 0
237
+ while (offset < bytes.length) {
238
+ const count = fs.readSync(
239
+ descriptor,
240
+ bytes,
241
+ offset,
242
+ bytes.length - offset,
243
+ null,
244
+ )
245
+ if (count === 0) break
246
+ offset += count
247
+ }
248
+ if (offset > limit) {
249
+ throw new ConfigError({
250
+ message: `persisted input exceeds ${limit} byte limit: ${target}`,
251
+ path: target,
252
+ details: { limit_bytes: limit },
253
+ })
254
+ }
255
+ try {
256
+ return new TextDecoder("utf-8", { fatal: true }).decode(
257
+ bytes.subarray(0, offset),
258
+ )
259
+ } catch {
260
+ throw new ConfigError({
261
+ message: `persisted input is not valid UTF-8: ${target}`,
262
+ path: target,
263
+ })
264
+ }
265
+ } finally {
266
+ if (descriptor !== undefined) fs.closeSync(descriptor)
267
+ }
268
+ }
269
+
270
+ function fsyncParentDirectory(target: string): void {
271
+ let descriptor: number | undefined
272
+ try {
273
+ descriptor = fs.openSync(path.dirname(target), fs.constants.O_RDONLY)
274
+ fs.fsyncSync(descriptor)
275
+ } catch (error) {
276
+ const code = (error as NodeJS.ErrnoException).code
277
+ // POSIX permits EINVAL/ENOTSUP for unsupported directory fsync. Windows
278
+ // additionally rejects directory descriptors with EISDIR/EPERM.
279
+ const unsupported =
280
+ ["EINVAL", "ENOTSUP", "EOPNOTSUPP"].includes(code ?? "") ||
281
+ (process.platform === "win32" && ["EISDIR", "EPERM"].includes(code ?? ""))
282
+ if (!unsupported) {
283
+ throw error
284
+ }
285
+ } finally {
286
+ if (descriptor !== undefined) fs.closeSync(descriptor)
287
+ }
288
+ }
289
+
34
290
  export const FileSystemLive = Layer.succeed(
35
291
  FileSystem,
36
292
  FileSystem.of({
@@ -49,90 +305,105 @@ export const FileSystemLive = Layer.succeed(
49
305
  }).pipe(Effect.orDie),
50
306
 
51
307
  writeProjectFile: (root, destination, content) =>
52
- Effect.try({
53
- try: () => {
54
- const projectRoot = path.resolve(root)
55
- const target = path.resolve(destination)
56
- const relative = path.relative(projectRoot, target)
57
- if (
58
- relative === "" ||
59
- relative === ".." ||
60
- relative.startsWith(`..${path.sep}`) ||
61
- path.isAbsolute(relative)
62
- ) {
308
+ projectEffect(() => {
309
+ const target = secureProjectPath(root, destination)
310
+ if (path.resolve(path.dirname(destination)) !== path.resolve(root)) {
311
+ createProjectDirectories(root, path.dirname(destination))
312
+ }
313
+
314
+ const temporary = path.join(
315
+ path.dirname(target),
316
+ `.${path.basename(target)}.${randomUUID()}.tmp`,
317
+ )
318
+ let descriptor: number | undefined
319
+ try {
320
+ descriptor = fs.openSync(temporary, "wx", 0o600)
321
+ fs.writeFileSync(descriptor, content, "utf8")
322
+ fs.fsyncSync(descriptor)
323
+ fs.closeSync(descriptor)
324
+ descriptor = undefined
325
+ fs.renameSync(temporary, target)
326
+ fsyncParentDirectory(target)
327
+ } finally {
328
+ if (descriptor !== undefined) fs.closeSync(descriptor)
329
+ fs.rmSync(temporary, { force: true })
330
+ }
331
+ }),
332
+
333
+ writeTrustedGlobalFile: (accountHome, destination, content) =>
334
+ projectEffect(() => {
335
+ const target = trustedGlobalPath(accountHome, destination)
336
+ createTrustedGlobalDirectories(accountHome, path.dirname(destination))
337
+
338
+ let mode = 0o666
339
+ let preserveMode = false
340
+ try {
341
+ const existing = fs.lstatSync(target)
342
+ if (existing.isSymbolicLink() || !existing.isFile()) {
63
343
  throw new ConfigError({
64
- message: "project file destination must be below project root",
344
+ message: `trusted global destination is not a safe regular file: ${target}`,
65
345
  path: target,
66
346
  })
67
347
  }
348
+ mode = existing.mode & 0o777
349
+ preserveMode = true
350
+ } catch (error) {
351
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error
352
+ }
68
353
 
69
- const components = relative.split(path.sep)
70
- let current = projectRoot
71
- for (const component of components.slice(0, -1)) {
72
- current = path.join(current, component)
73
- try {
74
- const stat = fs.lstatSync(current)
75
- if (stat.isSymbolicLink()) {
76
- throw new ConfigError({
77
- message: `refusing symlink below project root: ${current}`,
78
- path: current,
79
- })
80
- }
81
- if (!stat.isDirectory()) {
82
- throw new ConfigError({
83
- message: `project path component is not a directory: ${current}`,
84
- path: current,
85
- })
86
- }
87
- } catch (error) {
88
- if ((error as NodeJS.ErrnoException).code !== "ENOENT")
89
- throw error
90
- fs.mkdirSync(current)
91
- }
92
- }
354
+ const temporary = path.join(
355
+ path.dirname(target),
356
+ `.${path.basename(target)}.${randomUUID()}.tmp`,
357
+ )
358
+ let descriptor: number | undefined
359
+ try {
360
+ descriptor = fs.openSync(temporary, "wx", mode)
361
+ if (preserveMode) fs.fchmodSync(descriptor, mode)
362
+ fs.writeFileSync(descriptor, content, "utf8")
363
+ fs.fsyncSync(descriptor)
364
+ fs.closeSync(descriptor)
365
+ descriptor = undefined
366
+ fs.renameSync(temporary, target)
367
+ fsyncParentDirectory(target)
368
+ } finally {
369
+ if (descriptor !== undefined) fs.closeSync(descriptor)
370
+ fs.rmSync(temporary, { force: true })
371
+ }
372
+ }),
93
373
 
94
- try {
95
- if (fs.lstatSync(target).isSymbolicLink()) {
96
- throw new ConfigError({
97
- message: `refusing symlink destination: ${target}`,
98
- path: target,
99
- })
100
- }
101
- } catch (error) {
102
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error
103
- }
374
+ readTrustedGlobalFile: (
375
+ accountHome,
376
+ source,
377
+ limit = PERSISTED_INPUT_MAX_BYTES,
378
+ ) =>
379
+ projectEffect(() =>
380
+ readRegularUtf8(trustedGlobalPath(accountHome, source), limit),
381
+ ),
104
382
 
105
- const temporary = path.join(
106
- path.dirname(target),
107
- `.${path.basename(target)}.${randomUUID()}.tmp`,
108
- )
109
- let descriptor: number | undefined
110
- try {
111
- descriptor = fs.openSync(
112
- temporary,
113
- fs.constants.O_WRONLY |
114
- fs.constants.O_CREAT |
115
- fs.constants.O_EXCL |
116
- fs.constants.O_NOFOLLOW,
117
- 0o600,
118
- )
119
- fs.writeFileSync(descriptor, content, "utf8")
120
- fs.fsyncSync(descriptor)
121
- fs.closeSync(descriptor)
122
- descriptor = undefined
123
- fs.renameSync(temporary, target)
124
- } finally {
125
- if (descriptor !== undefined) fs.closeSync(descriptor)
126
- fs.rmSync(temporary, { force: true })
127
- }
128
- },
129
- catch: (e) => e,
130
- }).pipe(
131
- Effect.catch((error) =>
132
- error instanceof ConfigError ? Effect.fail(error) : Effect.die(error),
133
- ),
383
+ readProjectFile: (root, source, limit = PERSISTED_INPUT_MAX_BYTES) =>
384
+ projectEffect(() =>
385
+ readRegularUtf8(secureProjectPath(root, source), limit),
134
386
  ),
135
387
 
388
+ projectPathExists: (root, destination) =>
389
+ projectEffect(() => fs.existsSync(secureProjectPath(root, destination))),
390
+
391
+ mkdirProject: (root, destination) =>
392
+ projectEffect(() => createProjectDirectories(root, destination)),
393
+
394
+ renameProjectFile: (root, from, to) =>
395
+ projectEffect(() => {
396
+ const source = secureProjectPath(root, from)
397
+ const destination = secureProjectPath(root, to)
398
+ createProjectDirectories(root, path.dirname(to))
399
+ fs.renameSync(source, destination)
400
+ }),
401
+
402
+ removeProjectFile: (root, destination) =>
403
+ projectEffect(() => {
404
+ fs.rmSync(secureProjectPath(root, destination), { force: true })
405
+ }),
406
+
136
407
  rename: (from, to) =>
137
408
  Effect.try({
138
409
  try: () => {