diff --git a/packages/host-kit/src/session-paths.test.ts b/packages/host-kit/src/session-paths.test.ts new file mode 100644 index 0000000000..0b443d3452 --- /dev/null +++ b/packages/host-kit/src/session-paths.test.ts @@ -0,0 +1,18 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +// oxlint-disable-next-line no-restricted-imports -- asserts a path under os.homedir +import os from 'node:os'; +import path from 'node:path'; +import { expandSessionPath } from './session-paths.ts'; + +test('expandSessionPath resolves tilde, relative-with-cwd, and absolute paths', () => { + const homePath = expandSessionPath('~/flows/replay.ad'); + assert.equal(homePath, path.join(os.homedir(), 'flows', 'replay.ad')); + + const relativePath = expandSessionPath('workflows/replay.ad', '/tmp/agent-device-cwd'); + assert.equal(relativePath, path.resolve('/tmp/agent-device-cwd', 'workflows/replay.ad')); + + const absoluteInput = path.resolve('/tmp', 'agent-device-absolute.ad'); + const absolutePath = expandSessionPath(absoluteInput, '/tmp/ignored-cwd'); + assert.equal(absolutePath, absoluteInput); +}); diff --git a/src/daemon/__tests__/session-artifact-paths.test.ts b/src/daemon/__tests__/session-artifact-paths.test.ts new file mode 100644 index 0000000000..c76caa9ffb --- /dev/null +++ b/src/daemon/__tests__/session-artifact-paths.test.ts @@ -0,0 +1,29 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { AppError } from '@agent-device/kernel/errors'; +import { resolveSessionDir } from '../session-artifact-paths.ts'; +import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; + +test('resolveSessionDir keeps every session dir beneath the sessions dir', () => { + const sessionsDir = path.join( + mkdtempForTestSync('agent-device-tests'), + 'agent-device-tests', + 'sessions', + ); + assert.equal(resolveSessionDir(sessionsDir, 'a/b:c d'), path.join(sessionsDir, 'a_b_c_d')); + // `.` and `..` survive `safeSessionName` unchanged, so without an explicit + // refusal `path.join` resolves them to the sessions dir itself and its parent + // (the daemon state dir): a remote caller's `--session ..` would then land + // app.log / runner.log / requests/*.ndjson outside the sessions tree. + for (const name of ['.', '..', '']) { + assert.throws( + () => resolveSessionDir(sessionsDir, name), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + /session name/i.test(error.message), + `expected resolveSessionDir(${JSON.stringify(name)}) to reject`, + ); + } +}); diff --git a/src/daemon/__tests__/session-store.test.ts b/src/daemon/__tests__/session-store.test.ts index 05fe386324..5eddff721d 100644 --- a/src/daemon/__tests__/session-store.test.ts +++ b/src/daemon/__tests__/session-store.test.ts @@ -1,10 +1,7 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; -// oxlint-disable-next-line no-restricted-imports -- asserts a path under os.homedir -import os from 'node:os'; import path from 'node:path'; -import { AppError } from '@agent-device/kernel/errors'; import { SessionStore } from '../session-store.ts'; import type { SessionState } from '../session-state.ts'; import { buildRequestFinishedEvent } from '@agent-device/session-journal/session-event-log'; @@ -96,19 +93,6 @@ function assertScriptMatches(script: string, patterns: RegExp[]): void { } } -test('expandHome resolves tilde, relative-with-cwd, and absolute paths', () => { - const homePath = SessionStore.expandHome('~/flows/replay.ad'); - assert.equal(homePath.startsWith(os.homedir()), true); - assert.equal(homePath.endsWith(path.join('flows', 'replay.ad')), true); - - const relativePath = SessionStore.expandHome('workflows/replay.ad', '/tmp/agent-device-cwd'); - assert.equal(relativePath, path.resolve('/tmp/agent-device-cwd', 'workflows/replay.ad')); - - const absoluteInput = path.resolve('/tmp', 'agent-device-absolute.ad'); - const absolutePath = SessionStore.expandHome(absoluteInput, '/tmp/ignored-cwd'); - assert.equal(absolutePath, absoluteInput); -}); - test('defaultTracePath sanitizes session name', () => { const store = new SessionStore( path.join(mkdtempForTestSync('agent-device-tests'), 'agent-device-tests'), @@ -119,30 +103,6 @@ test('defaultTracePath sanitizes session name', () => { assert.match(tracePath, /\.trace\.log$/); }); -test('resolveSessionDir keeps every session dir beneath the sessions dir', () => { - const sessionsDir = path.join( - mkdtempForTestSync('agent-device-tests'), - 'agent-device-tests', - 'sessions', - ); - const store = new SessionStore(sessionsDir); - assert.equal(store.resolveSessionDir('a/b:c d'), path.join(sessionsDir, 'a_b_c_d')); - // `.` and `..` survive `safeSessionName` unchanged, so without an explicit - // refusal `path.join` resolves them to the sessions dir itself and its parent - // (the daemon state dir): a remote caller's `--session ..` would then land - // app.log / runner.log / requests/*.ndjson outside the sessions tree. - for (const name of ['.', '..', '']) { - assert.throws( - () => store.resolveSessionDir(name), - (error: unknown) => - error instanceof AppError && - error.code === 'INVALID_ARGS' && - /session name/i.test(error.message), - `expected resolveSessionDir(${JSON.stringify(name)}) to reject`, - ); - } -}); - test('session lease metadata round-trips through the store', () => { const { store, session } = makeFixture('agent-device-session-lease-'); session.lease = { diff --git a/src/daemon/device/device-claim-owner-recovery.ts b/src/daemon/device/device-claim-owner-recovery.ts index c8b60069ee..f57a6c641f 100644 --- a/src/daemon/device/device-claim-owner-recovery.ts +++ b/src/daemon/device/device-claim-owner-recovery.ts @@ -4,7 +4,11 @@ import { createPlatformRuntimeGateway } from '../../platform-runtime.ts'; import { resolveDaemonPaths } from '../../daemon-resolution.ts'; import { createDeviceClaimReconciler } from './device-claim-reconciliation.ts'; import type { DeviceClaimReconciler } from './device-claims.ts'; -import { SessionStore } from '../session-store.ts'; +import { + resolveSessionDir, + resolveSessionAppLogPath, + resolveSessionAppLogPidPath, +} from '../session-artifact-paths.ts'; export type OwnerScopedClaimRecovery = { reconcile: DeviceClaimReconciler; @@ -49,17 +53,16 @@ function composeOwnerScopedClaimRecovery( scope: PlatformRequestScope, ): OwnerScopedClaimRecovery { const daemonPaths = resolveDaemonPaths(stateDir); - const sessionStore = new SessionStore(daemonPaths.sessionsDir); const gateway = createPlatformRuntimeGateway({ sessionsDir: daemonPaths.sessionsDir, ownedProcesses: createOwnedProcessRecordStore({ stateDir: daemonPaths.baseDir, sessionsDir: daemonPaths.sessionsDir, - resolveSessionDir: (sessionId) => sessionStore.resolveSessionDir(sessionId), + resolveSessionDir: (sessionId) => resolveSessionDir(daemonPaths.sessionsDir, sessionId), }), resolveSessionArtifacts: (sessionId) => ({ - outputPath: sessionStore.resolveAppLogPath(sessionId), - pidPath: sessionStore.resolveAppLogPidPath(sessionId), + outputPath: resolveSessionAppLogPath(daemonPaths.sessionsDir, sessionId), + pidPath: resolveSessionAppLogPidPath(daemonPaths.sessionsDir, sessionId), }), }); return { diff --git a/src/daemon/handlers/session-app-deployment.ts b/src/daemon/handlers/session-app-deployment.ts index edf468e306..79768d7158 100644 --- a/src/daemon/handlers/session-app-deployment.ts +++ b/src/daemon/handlers/session-app-deployment.ts @@ -1,3 +1,4 @@ +import { expandSessionPath } from '@agent-device/host-kit/session-paths'; import fs from 'node:fs'; import type { AppDeploymentResult } from '@agent-device/contracts/app-deployment-runtime'; import { @@ -9,7 +10,7 @@ import { readNotificationPayload } from '../dispatch-payload.ts'; import { cleanupUploadedArtifact, prepareUploadedArtifact } from '../artifact-tracking.ts'; import { expireRefFrame } from '../ref-frame.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; -import { SessionStore } from '../session-store.ts'; +import type { SessionStore } from '../session-store.ts'; import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; import type { SessionState } from '../session-state.ts'; import { resolvePayloadInput } from '../payload-input.ts'; @@ -62,7 +63,7 @@ export async function handleAppDeploymentCommand(params: { try { const appPath = uploadedArtifactId ? prepareUploadedArtifact(uploadedArtifactId, req.meta?.tenantId) - : SessionStore.expandHome(target.appPathInput); + : expandSessionPath(target.appPathInput); if (!fs.existsSync(appPath)) { return errorResponse('INVALID_ARGS', `App binary not found: ${appPath}`); } @@ -242,7 +243,7 @@ function resolvePushPayload(payloadArg: string, cwd?: string): string { const resolved = resolvePayloadInput(payloadArg, { subject: 'Push payload', cwd, - expandPath: (value, currentCwd) => SessionStore.expandHome(value, currentCwd), + expandPath: (value, currentCwd) => expandSessionPath(value, currentCwd), }); return resolved.kind === 'file' ? resolved.path : resolved.text; } diff --git a/src/daemon/handlers/trace-runtime.ts b/src/daemon/handlers/trace-runtime.ts index 43b8ee86c6..64f804f0b5 100644 --- a/src/daemon/handlers/trace-runtime.ts +++ b/src/daemon/handlers/trace-runtime.ts @@ -1,7 +1,8 @@ +import { expandSessionPath } from '@agent-device/host-kit/session-paths'; import fs from 'node:fs'; import path from 'node:path'; import type { TraceCommandResult } from '@agent-device/contracts/recording'; -import { SessionStore } from '../session-store.ts'; +import type { SessionStore } from '../session-store.ts'; import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; import type { SessionState } from '../session-state.ts'; import { recordSessionAction } from '../session-action-recorder.ts'; @@ -29,9 +30,7 @@ function startTrace( session: SessionState, ): DaemonResponse { if (session.trace) return errorResponse('INVALID_ARGS', 'trace already in progress'); - const outPath = SessionStore.expandHome( - req.positionals?.[1] ?? sessionStore.defaultTracePath(session), - ); + const outPath = expandSessionPath(req.positionals?.[1] ?? sessionStore.defaultTracePath(session)); fs.mkdirSync(path.dirname(outPath), { recursive: true }); fs.appendFileSync(outPath, ''); session.trace = { outPath, startedAt: Date.now() }; @@ -72,7 +71,7 @@ function stopTrace( function relocateTraceOutput(currentPath: string, requestedPath: string | undefined): string { if (!requestedPath) return currentPath; - const resolved = SessionStore.expandHome(requestedPath); + const resolved = expandSessionPath(requestedPath); fs.mkdirSync(path.dirname(resolved), { recursive: true }); if (fs.existsSync(currentPath)) fs.renameSync(currentPath, resolved); else fs.appendFileSync(resolved, ''); diff --git a/src/daemon/screenshot-runtime.ts b/src/daemon/screenshot-runtime.ts index f0e4c76477..f10f44ab42 100644 --- a/src/daemon/screenshot-runtime.ts +++ b/src/daemon/screenshot-runtime.ts @@ -1,3 +1,4 @@ +import { expandSessionPath } from '@agent-device/host-kit/session-paths'; import type { CommandFlags } from '@agent-device/contracts/command'; import { retiredScreenshotMaxSizeFlagError, @@ -38,7 +39,6 @@ import { type ScreenshotRuntimeBindings, } from './screenshot-runtime-binding.ts'; import { setSessionSnapshot } from './session-snapshot.ts'; -import { SessionStore } from './session-store.ts'; import type { DaemonRequest } from './daemon-request.ts'; import type { SessionState } from './session-state.ts'; @@ -321,7 +321,7 @@ function readScreenshotRequest( const positionals = req.positionals ?? []; const flags = req.flags ?? {}; const expand = (value: string | undefined) => - value === undefined ? undefined : SessionStore.expandHome(value, req.meta?.cwd); + value === undefined ? undefined : expandSessionPath(value, req.meta?.cwd); const positionalPath = expand(positionals[0]); const outFlag = expand(flags.out); return { diff --git a/src/daemon/session-artifact-paths.ts b/src/daemon/session-artifact-paths.ts index d456d4d199..b445ed1ec1 100644 --- a/src/daemon/session-artifact-paths.ts +++ b/src/daemon/session-artifact-paths.ts @@ -1,6 +1,7 @@ import path from 'node:path'; import type { DiagnosticsRecordRef } from '@agent-device/kernel/errors'; -import { safeSessionName } from '@agent-device/host-kit/session-paths'; +import { AppError } from '@agent-device/kernel/errors'; +import { isSafeSessionSegment, safeSessionName } from '@agent-device/host-kit/session-paths'; /** Path to session-scoped platform subprocess output, such as Apple runner xcodebuild logs. */ export function resolveSessionRunnerLogPath(sessionDir: string): string { @@ -49,3 +50,27 @@ export function resolveRemoteRequestDiagnosticsPath( ref.requestId, ); } + +/** + * The one place a session name becomes a directory, so the invariant that every + * session dir lies beneath `sessionsDir` is enforced here rather than by each + * caller: `.` and `..` survive `safeSessionName` and would resolve to the + * sessions dir itself or the daemon state dir above it. + */ +export function resolveSessionDir(sessionsDir: string, sessionName: string): string { + if (!isSafeSessionSegment(sessionName)) { + throw new AppError( + 'INVALID_ARGS', + `Invalid session name ${JSON.stringify(sessionName)}: a session name cannot be empty, ".", or "..".`, + ); + } + return path.join(sessionsDir, safeSessionName(sessionName)); +} + +export function resolveSessionAppLogPath(sessionsDir: string, address: string): string { + return path.join(resolveSessionDir(sessionsDir, address), 'app.log'); +} + +export function resolveSessionAppLogPidPath(sessionsDir: string, address: string): string { + return path.join(resolveSessionDir(sessionsDir, address), 'app-log.pid'); +} diff --git a/src/daemon/session-observability/internal/session-perf-runtime.ts b/src/daemon/session-observability/internal/session-perf-runtime.ts index 210528eea6..cb6acdb0a7 100644 --- a/src/daemon/session-observability/internal/session-perf-runtime.ts +++ b/src/daemon/session-observability/internal/session-perf-runtime.ts @@ -1,3 +1,4 @@ +import { expandSessionPath } from '@agent-device/host-kit/session-paths'; import path from 'node:path'; import { type PerfCaptureAdmissionLedger } from '@agent-device/capture-kit/perf-capture-admission-ledger'; import { @@ -28,7 +29,7 @@ import type { BindDeviceRuntime, InspectDeviceRuntimeFacts, } from '../../request-runtime-binding.ts'; -import { SessionStore } from '../../session-store.ts'; +import type { SessionStore } from '../../session-store.ts'; import type { DaemonRequest, DaemonResponse } from '../../daemon-request.ts'; import type { SessionState } from '../../session-state.ts'; import { recordSessionAction } from '../../session-action-recorder.ts'; @@ -138,7 +139,7 @@ async function executeAdmittedPerfPlan( appId: session.appBundleId, kind: plan.request.kind, outPath: plan.request.outPath - ? SessionStore.expandHome(plan.request.outPath, params.req.meta?.cwd) + ? expandSessionPath(plan.request.outPath, params.req.meta?.cwd) : undefined, artifactsDir: path.join( params.sessionStore.ensureSessionDir(params.sessionName), @@ -171,7 +172,7 @@ async function executeAdmittedPerfPlan( const data = await runtime.operations.perfProfileReport({ appId: session.appBundleId, kind: plan.request.kind, - tracePath: SessionStore.expandHome(tracePath, params.req.meta?.cwd), + tracePath: expandSessionPath(tracePath, params.req.meta?.cwd), outPath, template: plan.request.template ?? (last?.kind === 'xctrace' ? last.template : undefined), profile: last, @@ -257,7 +258,7 @@ async function stopPerfCapture( const mismatchMessage = perfCaptureStopMismatch(snapshot, request); if (mismatchMessage) return errorResponse('INVALID_ARGS', mismatchMessage); if (request.outPath) { - capture.handle.setOutputPath(SessionStore.expandHome(request.outPath, params.req.meta?.cwd)); + capture.handle.setOutputPath(expandSessionPath(request.outPath, params.req.meta?.cwd)); } const completion = await finishLivePerfCapture({ intent: 'capture', @@ -395,7 +396,7 @@ function resolveNativeOutPath( requestedPath: string | undefined, fallbackFileName: string, ): string { - if (requestedPath) return SessionStore.expandHome(requestedPath, params.req.meta?.cwd); + if (requestedPath) return expandSessionPath(requestedPath, params.req.meta?.cwd); return path.join( params.sessionStore.ensureSessionDir(params.sessionName), `${timestampToken()}-${fallbackFileName}`, diff --git a/src/daemon/session-store.ts b/src/daemon/session-store.ts index 1598dd9414..e31c66403a 100644 --- a/src/daemon/session-store.ts +++ b/src/daemon/session-store.ts @@ -1,14 +1,14 @@ import path from 'node:path'; import fs from 'node:fs'; -import { AppError } from '@agent-device/kernel/errors'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import type { SessionRef, SessionRuntimeHints, SessionState } from './session-state.ts'; import { recordActionEntry, type RecordActionEntry } from './session-action-recorder.ts'; +import { isSafeSessionSegment, safeSessionName } from '@agent-device/host-kit/session-paths'; import { - expandSessionPath, - isSafeSessionSegment, - safeSessionName, -} from '@agent-device/host-kit/session-paths'; + resolveSessionDir, + resolveSessionAppLogPath, + resolveSessionAppLogPidPath, +} from './session-artifact-paths.ts'; import { readRepairTombstoneFile, resolveRepairTombstonePath, @@ -380,20 +380,8 @@ export class SessionStore { return path.join(this.sessionsDir, `${safeName}-${timestamp}.trace.log`); } - /** - * The one place a session name becomes a directory, so the invariant that every - * session dir lies beneath `sessionsDir` is enforced here rather than by each - * caller: `.` and `..` survive `safeSessionName` and would resolve to the - * sessions dir itself or the daemon state dir above it. - */ resolveSessionDir(sessionName: string): string { - if (!isSafeSessionSegment(sessionName)) { - throw new AppError( - 'INVALID_ARGS', - `Invalid session name ${JSON.stringify(sessionName)}: a session name cannot be empty, ".", or "..".`, - ); - } - return path.join(this.sessionsDir, safeSessionName(sessionName)); + return resolveSessionDir(this.sessionsDir, sessionName); } // Daemon state dir (parent of the `sessions/` dir), matching daemonPaths.baseDir. Called via @@ -411,21 +399,17 @@ export class SessionStore { /** Path to session-scoped app log file. Agent can grep this for token-efficient debugging. */ resolveAppLogPath(sessionName: string): string { - return path.join(this.resolveSessionDir(sessionName), 'app.log'); + return resolveSessionAppLogPath(this.sessionsDir, sessionName); } resolveAppLogPidPath(sessionName: string): string { - return path.join(this.resolveSessionDir(sessionName), 'app-log.pid'); + return resolveSessionAppLogPidPath(this.sessionsDir, sessionName); } resolveEventLogPath(sessionName: string): string { return resolveSessionEventLogPath(this.resolveSessionDir(sessionName)); } - static expandHome(filePath: string, cwd?: string): string { - return expandSessionPath(filePath, cwd); - } - /** * Resolve the map key for a live session object. SessionState.name is the * public session name, while the map key may include cwd/tenant isolation.