@roj-ai/sdk 0.2.7 → 0.2.9

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 (65) hide show
  1. package/dist/bootstrap.d.ts +53 -2
  2. package/dist/bootstrap.d.ts.map +1 -1
  3. package/dist/bootstrap.js +3 -2
  4. package/dist/bootstrap.js.map +1 -1
  5. package/dist/bun-platform/shell.d.ts.map +1 -1
  6. package/dist/bun-platform/shell.js +1 -0
  7. package/dist/bun-platform/shell.js.map +1 -1
  8. package/dist/core/agents/retry.d.ts +2 -0
  9. package/dist/core/agents/retry.d.ts.map +1 -1
  10. package/dist/core/agents/retry.js +11 -1
  11. package/dist/core/agents/retry.js.map +1 -1
  12. package/dist/core/events/index.d.ts +2 -0
  13. package/dist/core/events/index.d.ts.map +1 -1
  14. package/dist/core/events/index.js +2 -0
  15. package/dist/core/events/index.js.map +1 -1
  16. package/dist/core/llm/anthropic.js +3 -3
  17. package/dist/core/llm/anthropic.js.map +1 -1
  18. package/dist/core/llm/openrouter.js +3 -3
  19. package/dist/core/llm/openrouter.js.map +1 -1
  20. package/dist/core/llm/provider-request.d.ts +2 -0
  21. package/dist/core/llm/provider-request.d.ts.map +1 -1
  22. package/dist/core/llm/provider-request.js +2 -0
  23. package/dist/core/llm/provider-request.js.map +1 -1
  24. package/dist/index.d.ts +3 -2
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +2 -2
  27. package/dist/index.js.map +1 -1
  28. package/dist/platform/index.d.ts +8 -0
  29. package/dist/platform/index.d.ts.map +1 -1
  30. package/dist/platform/index.js.map +1 -1
  31. package/dist/platform/shell.d.ts +3 -0
  32. package/dist/platform/shell.d.ts.map +1 -1
  33. package/dist/plugins/mailbox/state.d.ts +1 -1
  34. package/dist/plugins/services/plugin.d.ts.map +1 -1
  35. package/dist/plugins/services/plugin.js +6 -5
  36. package/dist/plugins/services/plugin.js.map +1 -1
  37. package/dist/plugins/services/service.d.ts +2 -0
  38. package/dist/plugins/services/service.d.ts.map +1 -1
  39. package/dist/plugins/services/service.js.map +1 -1
  40. package/dist/plugins/shell/executor.d.ts +1 -0
  41. package/dist/plugins/shell/executor.d.ts.map +1 -1
  42. package/dist/plugins/shell/executor.js +6 -14
  43. package/dist/plugins/shell/executor.js.map +1 -1
  44. package/dist/testing/conformance.d.ts +3 -2
  45. package/dist/testing/conformance.d.ts.map +1 -1
  46. package/dist/testing/conformance.js +36 -27
  47. package/dist/testing/conformance.js.map +1 -1
  48. package/dist/transport/http/context.d.ts +2 -2
  49. package/dist/transport/http/context.d.ts.map +1 -1
  50. package/package.json +2 -2
  51. package/src/bootstrap.ts +3 -2
  52. package/src/bun-platform/shell.ts +1 -0
  53. package/src/core/agents/retry.ts +15 -1
  54. package/src/core/events/index.ts +2 -0
  55. package/src/core/llm/anthropic.ts +3 -3
  56. package/src/core/llm/openrouter.ts +3 -3
  57. package/src/core/llm/provider-request.ts +3 -0
  58. package/src/index.ts +3 -2
  59. package/src/platform/index.ts +9 -0
  60. package/src/platform/shell.ts +3 -0
  61. package/src/plugins/services/plugin.ts +7 -5
  62. package/src/plugins/services/service.ts +16 -0
  63. package/src/plugins/shell/executor.ts +13 -24
  64. package/src/testing/conformance.ts +75 -59
  65. package/src/transport/http/context.ts +2 -2
@@ -357,6 +357,7 @@ export function createBunShellRunner(
357
357
  ): ShellRunner {
358
358
  return {
359
359
  confinement: 'paths',
360
+ supportsTimeout: true,
360
361
  run: (options) => runCommand(processRunner, options, warn),
361
362
  }
362
363
  }
@@ -27,6 +27,8 @@ export const DEFAULT_RETRY_OPTIONS: Required<RetryOptions> = {
27
27
  export interface WithRetryOptions<E> extends RetryOptions {
28
28
  isRetryable: (error: E) => boolean
29
29
  getRetryDelay?: (error: E) => number | undefined
30
+ /** Lower attempt cap for the error just returned; `undefined` keeps `maxAttempts`. */
31
+ getMaxAttempts?: (error: E) => number | undefined
30
32
  /** Error to return when the caller's signal aborts, whichever attempt that happens on. */
31
33
  abortError?: E
32
34
  logger?: Logger
@@ -83,7 +85,8 @@ export async function withRetry<T, E>(
83
85
  return Err(options.abortError ?? lastError)
84
86
  }
85
87
 
86
- if (attempt >= opts.maxAttempts || !options.isRetryable(lastError)) {
88
+ const maxAttempts = Math.min(opts.maxAttempts, options.getMaxAttempts?.(lastError) ?? opts.maxAttempts)
89
+ if (attempt >= maxAttempts || !options.isRetryable(lastError)) {
87
90
  return result
88
91
  }
89
92
 
@@ -139,6 +142,16 @@ export function isRetryableLLMError(error: LLMError): boolean {
139
142
  )
140
143
  }
141
144
 
145
+ /**
146
+ * A timed-out request already cost a full timeout, and a response too long to finish
147
+ * in time times out again on the identical retry.
148
+ */
149
+ const MAX_LLM_TIMEOUT_ATTEMPTS = 2
150
+
151
+ function getLLMMaxAttempts(error: LLMError): number | undefined {
152
+ return error.type === 'timeout' ? MAX_LLM_TIMEOUT_ATTEMPTS : undefined
153
+ }
154
+
142
155
  /**
143
156
  * Gets retry delay from LLM error if available (e.g., rate limit retry-after).
144
157
  */
@@ -157,6 +170,7 @@ export async function withLLMRetry<T>(
157
170
  ...options,
158
171
  isRetryable: isRetryableLLMError,
159
172
  getRetryDelay: getLLMRetryDelay,
173
+ getMaxAttempts: getLLMMaxAttempts,
160
174
  abortError: { type: 'aborted', message: 'Request was aborted' },
161
175
  context: 'LLM inference',
162
176
  })
@@ -1,3 +1,5 @@
1
+ export { BaseEventStore } from './base-event-store.js'
1
2
  export type { EventStore, LoadRangeOptions, LoadRangeResult } from './event-store.js'
3
+ export { EventAppendError, EventStoreError, SessionNotFoundError } from './event-store.js'
2
4
  export { FileEventStore } from './file.js'
3
5
  export { MemoryEventStore } from './memory.js'
@@ -18,7 +18,7 @@ import type {
18
18
  RawToolSpec,
19
19
  } from './provider.js'
20
20
  import { mapProviderError } from './provider.js'
21
- import { ProviderRequestAbortError, runProviderRequest } from './provider-request.js'
21
+ import { DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS, ProviderRequestAbortError, runProviderRequest } from './provider-request.js'
22
22
  import { sanitizeProviderMessages } from './message-sanitization.js'
23
23
  import type { RoutableLLMProvider } from './routing-provider.js'
24
24
 
@@ -252,8 +252,8 @@ export class AnthropicProvider implements RoutableLLMProvider {
252
252
  this.logger = config.logger
253
253
  this.imageProcessor = config.imageProcessor
254
254
  this.thinkingBudget = config.thinkingBudget
255
- this.timeout = config.timeout ?? 120000
256
- this.fetchFn = config.fetch ?? globalThis.fetch
255
+ this.timeout = config.timeout ?? DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS
256
+ this.fetchFn = config.fetch ?? globalThis.fetch.bind(globalThis)
257
257
  }
258
258
 
259
259
  /**
@@ -19,7 +19,7 @@ import type {
19
19
  RawInferenceRequest,
20
20
  } from './provider.js'
21
21
  import { mapProviderError } from './provider.js'
22
- import { ProviderRequestAbortError, runProviderRequest } from './provider-request.js'
22
+ import { DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS, ProviderRequestAbortError, runProviderRequest } from './provider-request.js'
23
23
  import { sanitizeProviderMessages } from './message-sanitization.js'
24
24
 
25
25
  // ============================================================================
@@ -197,8 +197,8 @@ export class OpenRouterProvider implements LLMProvider {
197
197
  this.defaultModel = config.defaultModel ?? 'anthropic/claude-sonnet-4.5'
198
198
  this.logger = config.logger
199
199
  this.imageProcessor = config.imageProcessor
200
- this.timeout = config.timeout ?? 120000
201
- this.fetchFn = config.fetch ?? globalThis.fetch
200
+ this.timeout = config.timeout ?? DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS
201
+ this.fetchFn = config.fetch ?? globalThis.fetch.bind(globalThis)
202
202
  }
203
203
 
204
204
  async inference(request: InferenceRequest, context?: InferenceContext): Promise<Result<InferenceResponse, LLMError>> {
@@ -1,5 +1,8 @@
1
1
  export type ProviderRequestAbortCause = 'caller' | 'timeout'
2
2
 
3
+ /** Bounds the whole non-streamed response, so it caps how many output tokens one call can produce. */
4
+ export const DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS = 300_000
5
+
3
6
  export class ProviderRequestAbortError extends Error {
4
7
  constructor(readonly abortCause: ProviderRequestAbortCause) {
5
8
  super(abortCause === 'caller' ? 'Request was aborted' : 'Request timed out')
package/src/index.ts CHANGED
@@ -21,7 +21,8 @@ export { createSystem } from './core/system.js'
21
21
  export type { CreateSystemOptions, System } from './core/system.js'
22
22
 
23
23
  // Lib exports
24
- export { FileEventStore, MemoryEventStore } from '~/core/events/index.js'
24
+ export { BaseEventStore, EventAppendError, EventStoreError, FileEventStore, MemoryEventStore, SessionNotFoundError } from '~/core/events/index.js'
25
+ export type { EventStore, LoadRangeOptions, LoadRangeResult } from '~/core/events/index.js'
25
26
  export { MockLLMProvider, OpenRouterProvider, RequestMatchers } from '~/core/llm/index.js'
26
27
  export type { MockInferenceHandler, OpenRouterConfig } from '~/core/llm/index.js'
27
28
  export { ConsoleLogger, JsonLogger } from '~/lib/logger/index.js'
@@ -74,7 +75,7 @@ export type { InferenceCompletedEvent, InferenceFailedEvent, InferenceStartedEve
74
75
  export { estimateTokens } from '~/core/llm/tokens.js'
75
76
  export { applyEvent } from '~/core/sessions/apply-event.js'
76
77
  export { selectPluginState } from '~/core/sessions/reducer.js'
77
- export { isValidSessionId, parseSessionId, SessionId } from '~/core/sessions/schema.js'
78
+ export { domainEventSchema, isValidSessionId, parseSessionId, SessionId } from '~/core/sessions/schema.js'
78
79
  export type { RuntimeLeaseRelease, SessionRuntimeActivity, SessionRuntimeActivitySnapshot } from '~/core/sessions/runtime-activity.js'
79
80
  export type { SessionMetadata } from '~/core/sessions/schema.js'
80
81
  // Exported so a consumer can validate a callManagerMethod('sessions.list')
@@ -14,6 +14,9 @@ import type { ProcessRunner } from './process.js'
14
14
  import type { Scheduler } from './scheduler.js'
15
15
  import type { SessionLogStore } from './session-log.js'
16
16
  import type { ShellRunner } from './shell.js'
17
+ import type { Logger } from '../lib/logger/logger.js'
18
+ import type { PortPool } from '../plugins/services/port-pool.js'
19
+ import type { ServiceExecutorPort } from '../plugins/services/service.js'
17
20
 
18
21
  export type {
19
22
  Dirent,
@@ -36,6 +39,10 @@ export type { LiveScheduler, Scheduler, WakeHandler } from './scheduler.js'
36
39
  export type { SessionLogPage, SessionLogStore } from './session-log.js'
37
40
  export type { ShellConfinement, ShellGrant, ShellLimits, ShellRunner, ShellRunOptions, ShellRunResult } from './shell.js'
38
41
 
42
+ export type { ServiceExecutorPort } from '../plugins/services/service.js'
43
+ export type { PortPool } from '../plugins/services/port-pool.js'
44
+ export type { ServiceConfig, ServiceStatus, ServiceStopSource } from '../plugins/services/schema.js'
45
+
39
46
  /**
40
47
  * Aggregate platform capabilities passed through the system at bootstrap.
41
48
  *
@@ -53,6 +60,8 @@ export interface Platform {
53
60
  shell?: ShellRunner
54
61
  /** Git over the host's repositories. Absent on hosts that cannot run git. */
55
62
  git?: GitClient
63
+ /** Session-scoped service lifecycle for hosts whose services are not OS processes. */
64
+ createServiceExecutor?: (logger: Logger, portPool: PortPool) => ServiceExecutorPort
56
65
  /**
57
66
  * Cheap "has the filesystem changed" counter. Absent on hosts that cannot
58
67
  * answer it, which then recompute whatever they would have gated on.
@@ -37,6 +37,7 @@ export interface ShellRunOptions {
37
37
  cwd: string
38
38
  env?: Record<string, string>
39
39
  stdin?: string
40
+ /** Wall-clock deadline; ignored when `supportsTimeout` is false. */
40
41
  timeoutMs: number
41
42
  /**
42
43
  * What the command may reach. Meaningful only under `paths` confinement, and
@@ -78,6 +79,8 @@ export interface ShellRunResult {
78
79
  export interface ShellRunner {
79
80
  /** Declared, not assumed: a caller that needs confinement checks it before running. */
80
81
  readonly confinement: ShellConfinement
82
+ /** Whether `timeoutMs` terminates the run before it resolves with `timedOut: true`. Omitted means true for legacy runners. */
83
+ readonly supportsTimeout?: boolean
81
84
 
82
85
  /**
83
86
  * Run one command line to completion and buffer its output.
@@ -201,11 +201,13 @@ export const servicePlugin = definePlugin('services')
201
201
  const statusEffects = new Set<Promise<void>>()
202
202
  let statusEffectTail = Promise.resolve()
203
203
  let publicationEnabled = true
204
- const executor = new ServiceExecutor(logger, pluginConfig.portPool, {
205
- fs: ctx.platform.fs,
206
- process: ctx.platform.process,
207
- pidRegistry: pluginConfig.pidRegistry,
208
- })
204
+ const executor =
205
+ ctx.platform.createServiceExecutor?.(logger, pluginConfig.portPool) ??
206
+ new ServiceExecutor(logger, pluginConfig.portPool, {
207
+ fs: ctx.platform.fs,
208
+ process: ctx.platform.process,
209
+ pidRegistry: pluginConfig.pidRegistry,
210
+ })
209
211
  const beginLifecycle = (serviceType: string): ServiceLease | undefined => {
210
212
  const existing = serviceLeases.get(serviceType)
211
213
  if (existing && !existing.terminalPending) return existing
@@ -149,6 +149,22 @@ export interface ServiceExecutorDeps {
149
149
  hookTimeoutMs?: number
150
150
  }
151
151
 
152
+ /** Lifecycle used by the services plugin, independently of how a host runs it. */
153
+ export type ServiceExecutorPort = Pick<
154
+ ServiceExecutor,
155
+ | 'onStatusChanged'
156
+ | 'onStartSettled'
157
+ | 'start'
158
+ | 'stop'
159
+ | 'restart'
160
+ | 'close'
161
+ | 'getStatus'
162
+ | 'getLogs'
163
+ | 'waitForReady'
164
+ | 'hasScheduledRestart'
165
+ | 'reapProcessGroup'
166
+ >
167
+
152
168
  type ProcessGroupProbe = { state: 'alive' | 'gone' } | { state: 'error'; error: Error }
153
169
 
154
170
  let serviceExecutorObserverForTesting: ((executor: ServiceExecutor) => void) | undefined
@@ -92,9 +92,7 @@ async function resolveAgentPath(
92
92
  }
93
93
  return Ok(absolutePath)
94
94
  }
95
- const validPrefixes = workspaceDir
96
- ? `${VIRTUAL_SESSION}/ or ${VIRTUAL_WORKSPACE}/`
97
- : `${VIRTUAL_SESSION}/`
95
+ const validPrefixes = workspaceDir ? `${VIRTUAL_SESSION}/ or ${VIRTUAL_WORKSPACE}/` : `${VIRTUAL_SESSION}/`
98
96
  return Err({ message: `Path must start with ${validPrefixes}. Got: '${agentPath}'`, recoverable: false })
99
97
  }
100
98
  // Non-sandboxed: validate within allowed dirs
@@ -102,8 +100,7 @@ async function resolveAgentPath(
102
100
  const normalizedSession = resolve(sessionDir)
103
101
  const normalizedWorkspace = workspaceDir ? resolve(workspaceDir) : null
104
102
  const isInSession = absolutePath === normalizedSession || absolutePath.startsWith(normalizedSession + '/')
105
- const isInWorkspace = normalizedWorkspace
106
- && (absolutePath === normalizedWorkspace || absolutePath.startsWith(normalizedWorkspace + '/'))
103
+ const isInWorkspace = normalizedWorkspace && (absolutePath === normalizedWorkspace || absolutePath.startsWith(normalizedWorkspace + '/'))
107
104
  if (!isInSession && !isInWorkspace) {
108
105
  return Err({ message: `Path '${agentPath}' is outside allowed directories`, recoverable: false })
109
106
  }
@@ -174,6 +171,7 @@ export interface ShellResult {
174
171
  exitCode: number
175
172
  signal?: string
176
173
  timedOut: boolean
174
+ truncated?: boolean
177
175
  durationMs: number
178
176
  }
179
177
 
@@ -187,19 +185,17 @@ export class ShellExecutor {
187
185
  private readonly fs: FileSystem
188
186
  private readonly shell?: ShellRunner
189
187
 
190
- constructor(private config: ShellConfig, deps: ShellExecutorDeps) {
188
+ constructor(
189
+ private config: ShellConfig,
190
+ deps: ShellExecutorDeps,
191
+ ) {
191
192
  this.fs = deps.fs
192
193
  this.shell = deps.shell
193
194
  }
194
195
 
195
- async execute(
196
- input: RunCommandInput,
197
- environment: SessionEnvironment,
198
- ): Promise<Result<ShellResult, ToolError>> {
196
+ async execute(input: RunCommandInput, environment: SessionEnvironment): Promise<Result<ShellResult, ToolError>> {
199
197
  const args = typeof input.args === 'string' ? [input.args] : input.args
200
- const fullCommand = args
201
- ? `${shellEscape(input.command)} ${args.map(shellEscape).join(' ')}`
202
- : input.command
198
+ const fullCommand = args ? `${shellEscape(input.command)} ${args.map(shellEscape).join(' ')}` : input.command
203
199
 
204
200
  const timeout = input.timeout ?? this.config.timeout ?? 30000
205
201
  const startTime = Date.now()
@@ -279,6 +275,7 @@ export class ShellExecutor {
279
275
  exitCode: result.exitCode,
280
276
  signal: result.signal,
281
277
  timedOut: result.timedOut,
278
+ ...(result.truncated === undefined ? {} : { truncated: result.truncated }),
282
279
  durationMs: Date.now() - startTime,
283
280
  })
284
281
  } catch (error) {
@@ -297,11 +294,7 @@ export class ShellExecutor {
297
294
  * Paths under the hidden home roots exist only where a grant mounts them back; the rest of
298
295
  * the tree the sandbox binds read-only, so a directory there grants nothing.
299
296
  */
300
- private async resolveSandboxCwd(
301
- agentCwd: string,
302
- sessionDir: string,
303
- workspaceDir: string | undefined,
304
- ): Promise<Result<string, ToolError>> {
297
+ private async resolveSandboxCwd(agentCwd: string, sessionDir: string, workspaceDir: string | undefined): Promise<Result<string, ToolError>> {
305
298
  if (!isAbsolute(agentCwd)) {
306
299
  return Err({ message: `Working directory '${agentCwd}' must be an absolute path`, recoverable: false })
307
300
  }
@@ -323,11 +316,7 @@ export class ShellExecutor {
323
316
  }
324
317
 
325
318
  if (HIDDEN_ROOTS.some((root) => isWithin(normalized, root))) {
326
- const mounted = [
327
- VIRTUAL_SESSION,
328
- ...(workspaceDir ? [VIRTUAL_WORKSPACE] : []),
329
- ...this.boundRoots().map((root) => root.seen),
330
- ]
319
+ const mounted = [VIRTUAL_SESSION, ...(workspaceDir ? [VIRTUAL_WORKSPACE] : []), ...this.boundRoots().map((root) => root.seen)]
331
320
  return Err({
332
321
  message: `Path '${agentCwd}' is not mounted in this sandbox. Mounted roots: ${mounted.join(', ')}`,
333
322
  recoverable: false,
@@ -338,7 +327,7 @@ export class ShellExecutor {
338
327
  }
339
328
 
340
329
  private async requireExists(hostPath: string, agentCwd: string, seen: string): Promise<Result<string, ToolError>> {
341
- if (await this.fs.exists(hostPath)) return Ok(seen)
330
+ if (await this.fs.exists(hostPath)) return Ok(this.shell?.confinement === 'paths' ? seen : hostPath)
342
331
  return Err({ message: `Working directory '${agentCwd}' does not exist`, recoverable: false })
343
332
  }
344
333
 
@@ -93,7 +93,8 @@ export const GIT_FIXTURE: {
93
93
  /**
94
94
  * What the suite reports on. Most are ports; the dotted ones are facets a port
95
95
  * either has or does not — a scheduler that delivers its own wakes, a shell that
96
- * confines by paths — and they are named so that not exercising one is visible.
96
+ * terminates on timeout or confines by paths — and they are named so that not
97
+ * exercising one is visible.
97
98
  */
98
99
  export type ConformancePort =
99
100
  | 'fs'
@@ -106,6 +107,7 @@ export type ConformancePort =
106
107
  | 'scheduler'
107
108
  | 'scheduler.live'
108
109
  | 'shell'
110
+ | 'shell.timeout'
109
111
  | 'shell.paths'
110
112
  | 'shell.host'
111
113
  | 'git'
@@ -126,6 +128,7 @@ export const CONFORMANCE_PORTS: readonly ConformancePort[] = [
126
128
  'scheduler',
127
129
  'scheduler.live',
128
130
  'shell',
131
+ 'shell.timeout',
129
132
  'shell.paths',
130
133
  'shell.host',
131
134
  'git',
@@ -230,9 +233,7 @@ interface WalkShape {
230
233
  }
231
234
 
232
235
  function walkShapes(base: string, entries: readonly WalkEntry[]): WalkShape[] {
233
- return entries
234
- .map((entry) => ({ path: relativeTo(base, entry.path), type: entry.type, size: entry.size }))
235
- .sort((a, b) => a.path.localeCompare(b.path))
236
+ return entries.map((entry) => ({ path: relativeTo(base, entry.path), type: entry.type, size: entry.size })).sort((a, b) => a.path.localeCompare(b.path))
236
237
  }
237
238
 
238
239
  /**
@@ -251,16 +252,13 @@ async function walkWithReaddir(fs: FileSystem, dir: string, options?: WalkOption
251
252
  if (options?.excludeHidden && dirent.name.startsWith('.')) continue
252
253
 
253
254
  const path = joinPath(current, [dirent.name])
254
- const type: WalkEntry['type'] = dirent.isSymbolicLink()
255
- ? 'symlink'
256
- : dirent.isDirectory()
257
- ? 'directory'
258
- : dirent.isFile()
259
- ? 'file'
260
- : 'other'
261
-
262
- const stats = await fs.stat(path).then((value) => value, () => undefined)
263
- const size = type === 'directory' ? 0 : stats?.size ?? 0
255
+ const type: WalkEntry['type'] = dirent.isSymbolicLink() ? 'symlink' : dirent.isDirectory() ? 'directory' : dirent.isFile() ? 'file' : 'other'
256
+
257
+ const stats = await fs.stat(path).then(
258
+ (value) => value,
259
+ () => undefined,
260
+ )
261
+ const size = type === 'directory' ? 0 : (stats?.size ?? 0)
264
262
  const mtime = stats?.mtimeMs ?? 0
265
263
 
266
264
  out.push({ path, type, size, mtime })
@@ -274,13 +272,15 @@ async function walkWithReaddir(fs: FileSystem, dir: string, options?: WalkOption
274
272
 
275
273
  /** The per-path `readFile` loop `FileSystem.readFiles` replaces. */
276
274
  async function readFilesWithLoop(fs: FileSystem, paths: readonly string[]): Promise<ReadFilesEntry[]> {
277
- return Promise.all(paths.map(async (path): Promise<ReadFilesEntry> => {
278
- try {
279
- return { path, content: await fs.readFile(path) }
280
- } catch (error) {
281
- return { path, error: errorCode(error) ?? String(error) }
282
- }
283
- }))
275
+ return Promise.all(
276
+ paths.map(async (path): Promise<ReadFilesEntry> => {
277
+ try {
278
+ return { path, content: await fs.readFile(path) }
279
+ } catch (error) {
280
+ return { path, error: errorCode(error) ?? String(error) }
281
+ }
282
+ }),
283
+ )
284
284
  }
285
285
 
286
286
  /**
@@ -335,12 +335,15 @@ function contextFor(target: ConformanceTarget, instance: PlatformInstance): Conf
335
335
  if (!repo) {
336
336
  const dir = joinPath(instance.root, ['fixture-repo'])
337
337
  const build = target.buildGitRepo ?? defaultBuildGitRepo
338
- repo = build(instance, dir).then(() => dir, (error) => {
339
- throw new Error(
340
- `could not build the fixture repository: ${error instanceof Error ? error.message : String(error)}. `
341
- + 'A host that cannot run `git` supplies ConformanceTarget.buildGitRepo.',
342
- )
343
- })
338
+ repo = build(instance, dir).then(
339
+ () => dir,
340
+ (error) => {
341
+ throw new Error(
342
+ `could not build the fixture repository: ${error instanceof Error ? error.message : String(error)}. ` +
343
+ 'A host that cannot run `git` supplies ConformanceTarget.buildGitRepo.',
344
+ )
345
+ },
346
+ )
344
347
  }
345
348
  return repo
346
349
  },
@@ -416,9 +419,7 @@ const fsChecks: ConformanceCheck[] = [
416
419
  expect((await fs.readdir(path())).sort()).toEqual(['a.txt', 'b.txt', 'dir'])
417
420
 
418
421
  const dirents = await fs.readdir(path(), { withFileTypes: true })
419
- const classified = dirents
420
- .map((dirent) => [dirent.name, dirent.isDirectory(), dirent.isFile()])
421
- .sort()
422
+ const classified = dirents.map((dirent) => [dirent.name, dirent.isDirectory(), dirent.isFile()]).sort()
422
423
  expect(classified).toEqual([
423
424
  ['a.txt', false, true],
424
425
  ['b.txt', false, true],
@@ -763,8 +764,9 @@ const readFilesChecks: ConformanceCheck[] = [
763
764
  const looped = await readFilesWithLoop(fs, paths)
764
765
 
765
766
  expect(batched.map((entry) => entry.path)).toEqual(paths)
766
- expect(batched.map((entry) => (entry.content ? textOf(entry.content) : undefined)))
767
- .toEqual(looped.map((entry) => (entry.content ? textOf(entry.content) : undefined)))
767
+ expect(batched.map((entry) => (entry.content ? textOf(entry.content) : undefined))).toEqual(
768
+ looped.map((entry) => (entry.content ? textOf(entry.content) : undefined)),
769
+ )
768
770
  expect(batched.map((entry) => entry.error)).toEqual([undefined, undefined, undefined])
769
771
  },
770
772
  },
@@ -869,10 +871,7 @@ const rmFilesChecks: ConformanceCheck[] = [
869
871
  const fs = platform.fs
870
872
  await fs.writeFile(path('a.txt'), 'a')
871
873
 
872
- await rejection(
873
- Promise.resolve(fs.rmFiles?.([path('missing.txt')])),
874
- 'rmFiles of a missing path without force',
875
- )
874
+ await rejection(Promise.resolve(fs.rmFiles?.([path('missing.txt')])), 'rmFiles of a missing path without force')
876
875
  await fs.rmFiles?.([path('missing.txt'), path('a.txt')], { force: true })
877
876
  expect(await fs.exists(path('a.txt'))).toBe(false)
878
877
  },
@@ -885,10 +884,7 @@ const rmFilesChecks: ConformanceCheck[] = [
885
884
  await fs.mkdir(path('tree', 'nested'), { recursive: true })
886
885
  await fs.writeFile(path('tree', 'nested', 'leaf.txt'), 'leaf')
887
886
 
888
- await rejection(
889
- Promise.resolve(fs.rmFiles?.([path('tree')])),
890
- 'rmFiles of a populated directory without recursive',
891
- )
887
+ await rejection(Promise.resolve(fs.rmFiles?.([path('tree')])), 'rmFiles of a populated directory without recursive')
892
888
  expect(await fs.exists(path('tree', 'nested', 'leaf.txt'))).toBe(true)
893
889
 
894
890
  await fs.rmFiles?.([path('tree')], { recursive: true })
@@ -908,10 +904,7 @@ const scopeReadsChecks: ConformanceCheck[] = [
908
904
  await fs.writeFile(path('sub', 'b.txt'), 'bb')
909
905
 
910
906
  const outside = [await fs.readFile(path('a.txt'), 'utf-8'), (await fs.readdir(path())).sort().join(',')]
911
- const inside = await fs.scopeReads?.(async () => [
912
- await fs.readFile(path('a.txt'), 'utf-8'),
913
- (await fs.readdir(path())).sort().join(','),
914
- ])
907
+ const inside = await fs.scopeReads?.(async () => [await fs.readFile(path('a.txt'), 'utf-8'), (await fs.readdir(path())).sort().join(',')])
915
908
 
916
909
  expect(inside).toEqual(outside)
917
910
  },
@@ -922,9 +915,11 @@ const scopeReadsChecks: ConformanceCheck[] = [
922
915
  async run({ platform }) {
923
916
  const marker = new Error('block failed')
924
917
  const caught = await rejection(
925
- Promise.resolve(platform.fs.scopeReads?.(async () => {
926
- throw marker
927
- })),
918
+ Promise.resolve(
919
+ platform.fs.scopeReads?.(async () => {
920
+ throw marker
921
+ }),
922
+ ),
928
923
  'scopeReads over a block that throws',
929
924
  )
930
925
  expect(caught).toBe(marker)
@@ -1069,6 +1064,14 @@ const shellChecks: ConformanceCheck[] = [
1069
1064
  expect(declared).toContain(platform.shell?.confinement)
1070
1065
  },
1071
1066
  },
1067
+ {
1068
+ port: 'shell',
1069
+ name: 'declares a boolean timeout capability or omits it for legacy support',
1070
+ async run({ platform }) {
1071
+ const capability = platform.shell?.supportsTimeout
1072
+ expect(capability === undefined || typeof capability === 'boolean').toBe(true)
1073
+ },
1074
+ },
1072
1075
  {
1073
1076
  port: 'shell',
1074
1077
  name: 'a non-zero exit resolves rather than rejects',
@@ -1129,7 +1132,7 @@ const shellChecks: ConformanceCheck[] = [
1129
1132
  },
1130
1133
  },
1131
1134
  {
1132
- port: 'shell',
1135
+ port: 'shell.timeout',
1133
1136
  name: 'a timeout terminates the command and reports timedOut',
1134
1137
  async run({ platform, root }) {
1135
1138
  const started = Date.now()
@@ -1139,6 +1142,21 @@ const shellChecks: ConformanceCheck[] = [
1139
1142
  expect(result.exitCode).not.toBe(0)
1140
1143
  },
1141
1144
  },
1145
+ {
1146
+ port: 'shell.timeout',
1147
+ name: 'a timed-out run cannot keep mutating after it resolves',
1148
+ async run({ platform, path, root }) {
1149
+ const marker = path('late-after-timeout.txt')
1150
+ const result = await shellRun(platform, {
1151
+ command: 'sleep 1; printf late > late-after-timeout.txt',
1152
+ cwd: root,
1153
+ timeoutMs: 100,
1154
+ })
1155
+ expect(result.timedOut).toBe(true)
1156
+ await sleep(1_200)
1157
+ expect(await platform.fs.exists(marker)).toBe(false)
1158
+ },
1159
+ },
1142
1160
  {
1143
1161
  port: 'shell.paths',
1144
1162
  name: 'a granted path is reachable and one outside the grants is not',
@@ -1281,10 +1299,7 @@ const gitChecks: ConformanceCheck[] = [
1281
1299
 
1282
1300
  await rejection(Promise.resolve(platform.git?.status({ dir })), 'status outside a repository')
1283
1301
  await rejection(Promise.resolve(platform.git?.log({ dir })), 'log outside a repository')
1284
- await rejection(
1285
- Promise.resolve(platform.git?.countAhead({ dir, base: GIT_FIXTURE.base })),
1286
- 'countAhead outside a repository',
1287
- )
1302
+ await rejection(Promise.resolve(platform.git?.countAhead({ dir, base: GIT_FIXTURE.base })), 'countAhead outside a repository')
1288
1303
 
1289
1304
  // `defaultBranch` says undefined means unknown, so outside a repo it may answer either way.
1290
1305
  const branch = await platform.git?.defaultBranch({ dir }).catch(() => undefined)
@@ -1605,11 +1620,16 @@ async function probeInstance(target: ConformanceTarget, instance: PlatformInstan
1605
1620
 
1606
1621
  const confinement = platform.shell?.confinement
1607
1622
  add('shell', platform.shell !== undefined)
1623
+ add(
1624
+ 'shell.timeout',
1625
+ platform.shell !== undefined && platform.shell.supportsTimeout !== false,
1626
+ platform.shell === undefined ? 'port absent' : 'the runner declares no wall-clock timeout support',
1627
+ )
1608
1628
  const confines = await probeConfinement(instance)
1609
1629
  add('shell.paths', confines.ok, confines.note)
1610
1630
  add('shell.host', confinement === 'host', `confinement is ${confinement ?? 'absent'}`)
1611
1631
 
1612
- const buildable = platform.git !== undefined && await canBuildGitRepo(target, platform)
1632
+ const buildable = platform.git !== undefined && (await canBuildGitRepo(target, platform))
1613
1633
  add('git', buildable, platform.git === undefined ? undefined : 'no way to build a fixture repository — declare ConformanceTarget.buildGitRepo')
1614
1634
 
1615
1635
  add('fsRevision', platform.fsRevision !== undefined)
@@ -1698,9 +1718,7 @@ export async function runConformanceCheck(target: ConformanceTarget, check: Conf
1698
1718
  /** The checks a host answering `answered` should be held to. */
1699
1719
  export function checksFor(answered: Iterable<ConformancePort>): ConformanceCheck[] {
1700
1720
  const ports = new Set(answered)
1701
- return platformConformanceChecks.filter((check) =>
1702
- ports.has(check.port) && (check.needs ?? []).every((port) => ports.has(port))
1703
- )
1721
+ return platformConformanceChecks.filter((check) => ports.has(check.port) && (check.needs ?? []).every((port) => ports.has(port)))
1704
1722
  }
1705
1723
 
1706
1724
  function describeSkip(support: PortSupport): string {
@@ -1722,9 +1740,7 @@ export function runPlatformConformance(target: ConformanceTarget): void {
1722
1740
  const skipped = support.filter((entry) => !entry.answered)
1723
1741
 
1724
1742
  console.log(
1725
- `[conformance] ${target.name}\n`
1726
- + ` exercised: ${exercised.join(', ') || 'none'}\n`
1727
- + ` skipped: ${skipped.map(describeSkip).join(', ') || 'none'}`,
1743
+ `[conformance] ${target.name}\n` + ` exercised: ${exercised.join(', ') || 'none'}\n` + ` skipped: ${skipped.map(describeSkip).join(', ') || 'none'}`,
1728
1744
  )
1729
1745
 
1730
1746
  for (const port of CONFORMANCE_PORTS) {
@@ -9,13 +9,13 @@
9
9
  */
10
10
 
11
11
  import type { PreprocessorRegistry } from '~/plugins/uploads/preprocessor.js'
12
- import type { Services } from '../../bootstrap.js'
12
+ import type { PluginProfile, Services } from '../../bootstrap.js'
13
13
  import type { SessionManager } from '../../core/sessions/session-manager.js'
14
14
 
15
15
  /**
16
16
  * Extended services with SessionManager for HTTP routes.
17
17
  */
18
- export type AppServices = Services & {
18
+ export type AppServices = Services<PluginProfile> & {
19
19
  sessionRuntime: SessionManager
20
20
  /** Bearer token for authenticating HTTP requests. Optional - only used in worker mode. */
21
21
  agentToken?: string