File size: 6,640 Bytes
b1df986 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 | import path from 'node:path'
import { createLogger } from '../../../shared/logging/logger'
import type { StudioScene, StudioSceneStore, StudioSession } from '../../domain/types'
import {
createLegacyRunExecutionScope,
createSceneRunExecutionScope,
hasSessionRelativeTraversalSegment,
normalizeSessionRelativePath,
StudioRunExecutionScopeError,
type StudioRunExecutionScope,
type StudioSceneDirectoryEntry
} from '../../domain/run-execution-scope'
import {
StudioWorkspaceWriteDeniedError,
verifyExactWorkspaceFile,
type StudioWorkspaceWriteDenialReason
} from '../../tools/workspace-access-policy'
const logger = createLogger('StudioRunExecutionScope')
/**
* Assembles the immutable execution scope of one Run.
*
* Reads the persisted Scene store only; the caller (the prepared Run context) has already proved
* that the Session and the Run exist. A Scene Run fails closed before the model is invoked when its
* current Scene is missing, foreign, or carries a source path that is not a real workspace file: the
* current source is validated with the same filesystem validator the mutating Tools use, so the Run
* can never start against a path that would later be rejected for writing. An unusable sibling path
* is dropped with a sanitized log line instead, because one broken neighbour must not stop the Run.
*/
export async function loadStudioRunExecutionScope(input: {
session: StudioSession
sceneId?: string
sceneStore?: StudioSceneStore
}): Promise<StudioRunExecutionScope> {
const rootDirectory = input.session.directory
if (!input.sceneId) {
return createLegacyRunExecutionScope({ rootDirectory })
}
if (!input.sceneStore) {
throw new StudioRunExecutionScopeError('scene_store_unavailable', input.sceneId)
}
const scene = await input.sceneStore.getById(input.session.ownerId, input.sceneId)
if (!scene || scene.sessionId !== input.session.id) {
throw new StudioRunExecutionScopeError('scene_not_found', input.sceneId)
}
const verifiedCurrentSource = await verifyCurrentSceneSource({
rootDirectory,
sessionId: input.session.id,
scene
})
const siblings = await input.sceneStore.listBySessionId(input.session.ownerId, input.session.id)
const scenes = buildSceneDirectoryEntries({
rootDirectory,
scenes: siblings,
currentSceneId: scene.id,
sessionId: input.session.id
})
return createSceneRunExecutionScope({
rootDirectory,
sceneId: scene.id,
currentSourcePath: verifiedCurrentSource.absolutePath,
currentSourceRelativePath: verifiedCurrentSource.relativePath,
scenes,
relativePath: verifiedCurrentSource.relativePath
})
}
/**
* Validates the current Scene source as a real workspace file before the provider is invoked.
*
* `verifyExactWorkspaceFile` is the single filesystem validator of the workspace access policy, so
* preflight and mutation authorization cannot drift into two algorithms. Every failure — unusable
* persisted path, missing file, directory, symbolic link, symlink ancestor, escaping path — becomes
* one sanitized `unsafe_scene_source` failure.
*/
async function verifyCurrentSceneSource(input: {
rootDirectory: string
sessionId: string
scene: StudioScene
}): Promise<{ absolutePath: string; relativePath: string }> {
const fail = (denialReason: StudioWorkspaceWriteDenialReason | 'unusable_source_path'): never => {
// Sanitized by design: the reason is a closed enum and the absolute path stays out of the log.
logger.warn('Studio scene current source rejected', {
sessionId: input.sessionId,
sceneId: input.scene.id,
reason: 'unsafe_scene_source',
denialReason
})
throw new StudioRunExecutionScopeError('unsafe_scene_source', input.scene.id)
}
const relativePath = readSceneSourceRelativePath(input.rootDirectory, input.scene)
if (!relativePath) {
return fail('unusable_source_path')
}
try {
const verified = await verifyExactWorkspaceFile({
baseDirectory: input.rootDirectory,
relativePath,
policy: 'exact-file'
})
return { absolutePath: verified.absolutePath, relativePath: verified.relativePath }
} catch (error) {
return fail(error instanceof StudioWorkspaceWriteDeniedError ? error.reason : 'unusable_source_path')
}
}
function buildSceneDirectoryEntries(input: {
rootDirectory: string
scenes: readonly StudioScene[]
currentSceneId: string
sessionId: string
}): StudioSceneDirectoryEntry[] {
const entries: StudioSceneDirectoryEntry[] = []
for (const scene of input.scenes) {
const sourceRelativePath = readSceneSourceRelativePath(input.rootDirectory, scene)
if (!sourceRelativePath) {
// Sanitized by design: the absolute path stays out of the log payload.
logger.warn('Studio scene directory entry omitted', {
sessionId: input.sessionId,
sceneId: scene.id,
reason: 'unsafe_scene_source'
})
continue
}
entries.push({
id: scene.id,
position: scene.position,
sourceRelativePath,
isCurrent: scene.id === input.currentSceneId
})
}
return entries
}
/**
* Session-relative form of a Scene source path, or `null` when it is not a usable workspace path.
* Pure string work: the store keeps absolute paths, and only the relative form may leave this
* module.
*
* The raw persisted value is checked for a parent segment *before* any resolve or normalize, because
* `path.resolve` erases `..` and would let `scenes/../scenes/scene_0001.py` masquerade as the
* legitimate file it happens to normalize onto. The separator rule is shared with the mutation
* policy, so both reject `..` and `\..\` identically on every platform. An absolute persisted path
* stays supported; only a parent segment inside it is refused.
*/
function readSceneSourceRelativePath(rootDirectory: string, scene: StudioScene): string | null {
if (typeof scene.sourcePath !== 'string' || !scene.sourcePath.trim()) {
return null
}
if (typeof rootDirectory !== 'string' || !rootDirectory.trim()) {
return null
}
if (hasSessionRelativeTraversalSegment(scene.sourcePath)) {
return null
}
const root = path.resolve(rootDirectory)
const absoluteSource = path.resolve(root, scene.sourcePath)
const relative = path.relative(root, absoluteSource)
// The resolved form is checked as well: an absolute persisted path outside the Session root only
// becomes visible as a parent segment once it is made relative to the root.
if (hasSessionRelativeTraversalSegment(relative)) {
return null
}
return normalizeSessionRelativePath(relative)
}
|