@roj-ai/sdk 0.2.8 → 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.
- package/dist/bootstrap.d.ts +53 -2
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +3 -2
- package/dist/bootstrap.js.map +1 -1
- package/dist/bun-platform/shell.d.ts.map +1 -1
- package/dist/bun-platform/shell.js +1 -0
- package/dist/bun-platform/shell.js.map +1 -1
- package/dist/core/events/index.d.ts +2 -0
- package/dist/core/events/index.d.ts.map +1 -1
- package/dist/core/events/index.js +2 -0
- package/dist/core/events/index.js.map +1 -1
- package/dist/core/llm/anthropic.js +1 -1
- package/dist/core/llm/anthropic.js.map +1 -1
- package/dist/core/llm/openrouter.js +1 -1
- package/dist/core/llm/openrouter.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/platform/index.d.ts +8 -0
- package/dist/platform/index.d.ts.map +1 -1
- package/dist/platform/index.js.map +1 -1
- package/dist/platform/shell.d.ts +3 -0
- package/dist/platform/shell.d.ts.map +1 -1
- package/dist/plugins/mailbox/state.d.ts +1 -1
- package/dist/plugins/services/plugin.d.ts.map +1 -1
- package/dist/plugins/services/plugin.js +6 -5
- package/dist/plugins/services/plugin.js.map +1 -1
- package/dist/plugins/services/service.d.ts +2 -0
- package/dist/plugins/services/service.d.ts.map +1 -1
- package/dist/plugins/services/service.js.map +1 -1
- package/dist/plugins/shell/executor.d.ts +1 -0
- package/dist/plugins/shell/executor.d.ts.map +1 -1
- package/dist/plugins/shell/executor.js +6 -14
- package/dist/plugins/shell/executor.js.map +1 -1
- package/dist/testing/conformance.d.ts +3 -2
- package/dist/testing/conformance.d.ts.map +1 -1
- package/dist/testing/conformance.js +36 -27
- package/dist/testing/conformance.js.map +1 -1
- package/dist/transport/http/context.d.ts +2 -2
- package/dist/transport/http/context.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/bootstrap.ts +3 -2
- package/src/bun-platform/shell.ts +1 -0
- package/src/core/events/index.ts +2 -0
- package/src/core/llm/anthropic.ts +1 -1
- package/src/core/llm/openrouter.ts +1 -1
- package/src/index.ts +3 -2
- package/src/platform/index.ts +9 -0
- package/src/platform/shell.ts +3 -0
- package/src/plugins/services/plugin.ts +7 -5
- package/src/plugins/services/service.ts +16 -0
- package/src/plugins/shell/executor.ts +13 -24
- package/src/testing/conformance.ts +75 -59
- package/src/transport/http/context.ts +2 -2
package/src/platform/shell.ts
CHANGED
|
@@ -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 =
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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(
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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(
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
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(
|
|
926
|
-
|
|
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
|