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)
}