Class: LittleGhost::Workspace

Inherits:
Object
  • Object
show all
Defined in:
lib/little_ghost/workspace.rb

Overview

A Workspace names the host paths associated with a Run. Pair it with a Sandbox to decide how those paths may be read, changed, or used by commands.

workspace = LittleGhost::Workspace.new(root: "./tmp/support-run")
workspace.root # => an absolute path ending in "/tmp/support-run"

Workspaces participate in the Run resource lifecycle, but object lifetime and file lifetime are separate. Opening creates root and relative named paths, but does not delete them by default. Absolute named paths are trusted references that must already exist. Setup and teardown callbacks let trusted application configuration provision run-scoped resources without a Workspace subclass. Applications that share a writable root between Runs must provide their own concurrency and tenant isolation.

See the Workspaces and Sandboxes guide for logical paths, Sandbox policy, process ownership, and networking.

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(root:, paths: {}, setup: nil, teardown: nil) ⇒ Workspace

Expands root and every named path to absolute paths. Relative named paths must remain beneath root; absolute named paths deliberately refer outside it. setup receives workspace: and run: when the Run opens. teardown receives the same values when it closes, including after partial setup.



52
53
54
55
56
57
58
59
60
61
# File 'lib/little_ghost/workspace.rb', line 52

def initialize(root:, paths: {}, setup: nil, teardown: nil)
  @root = File.expand_path(root)
  @paths = normalize_paths(paths)
  @setup = validate_callback(setup, :setup)
  @teardown = validate_callback(teardown, :teardown)
  @opened = false
  @active = false
  @run = nil
  @identities = nil
end

Instance Attribute Details

#pathsObject (readonly)

Immutable named absolute paths owned by this workspace declaration.



67
68
69
# File 'lib/little_ghost/workspace.rb', line 67

def paths
  @paths
end

#rootObject (readonly)

Absolute filesystem root assigned to this workspace.



64
65
66
# File 'lib/little_ghost/workspace.rb', line 64

def root
  @root
end

Class Method Details

.providersObject

:nodoc:



43
44
45
# File 'lib/little_ghost/workspace.rb', line 43

def providers # :nodoc:
  @providers ||= {}
end

.register_provider(name, implementation) ⇒ Object

Registers a trusted workspace provider under a configuration symbol.



28
29
30
31
32
33
34
# File 'lib/little_ghost/workspace.rb', line 28

def register_provider(name, implementation)
  unless implementation.is_a?(Class) && implementation <= Workspace
    raise ArgumentError, "workspace provider must be a Workspace class"
  end

  Workspace.providers[name.to_sym] = implementation
end

.resolve_provider(name) ⇒ Object

Resolves an explicitly selected provider without changing its meaning.



37
38
39
40
41
# File 'lib/little_ghost/workspace.rb', line 37

def resolve_provider(name)
  Workspace.providers.fetch(name.to_sym) do
    raise DependencyError, "workspace provider :#{name} is not available"
  end
end

Instance Method Details

#closeObject

Calls the application teardown callback once. The default does not remove files or directories.



156
157
158
159
160
161
162
163
164
165
166
# File 'lib/little_ghost/workspace.rb', line 156

def close
  return nil unless @active

  @teardown&.call(workspace: self, run: @run)
  nil
ensure
  @opened = false
  @active = false
  @run = nil
  @identities = nil
end

#environmentObject

Environment variables supplied to sandboxed programs. These values are trusted process configuration and are never returned by filesystem tools.



115
116
117
118
119
# File 'lib/little_ghost/workspace.rb', line 115

def environment
  {"LITTLE_GHOST_WORKSPACE_ROOT" => root}.merge(paths.to_h do |name, path|
    ["LITTLE_GHOST_WORKSPACE_#{name.to_s.upcase.gsub(/[^A-Z0-9]/, "_")}", path]
  end).freeze
end

#open(run: nil) ⇒ Object

Calls the application setup callback once and returns this workspace.



139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/little_ghost/workspace.rb', line 139

def open(run: nil)
  return self if @opened

  @run = run
  @active = true
  @setup&.call(workspace: self, run:)
  materialize!
  capture_identities!
  @opened = true
  self
rescue
  close
  raise
end

#path(name) ⇒ Object

Returns a configured named path, raising KeyError when it is absent.



70
71
72
# File 'lib/little_ghost/workspace.rb', line 70

def path(name)
  paths.fetch(name.to_sym)
end

#reference(physical_path) ⇒ Object

Returns the stable logical reference for a physical workspace path.

Raises:

  • (ArgumentError)


100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/little_ghost/workspace.rb', line 100

def reference(physical_path)
  candidate = File.expand_path(physical_path)
  named = paths.sort_by { |_, path| -path.length }.find { |_, path| beneath?(candidate, path) }
  if named
    name, base = named
    relative = candidate.delete_prefix(base).delete_prefix(File::SEPARATOR)
    return relative.empty? ? "workspace://#{name}" : "workspace://#{name}/#{relative}"
  end
  raise ArgumentError, "path is outside the workspace" unless beneath?(candidate, root)

  candidate.delete_prefix(root).delete_prefix(File::SEPARATOR).then { |value| value.empty? ? "." : value }
end

#resolve(reference) ⇒ Object

Converts a logical path to its physical workspace path. This method checks lexical containment but does not make direct filesystem access safe for untrusted input. Pass model-selected paths through Sandbox file operations, which reject symlinks while opening each path component.

Relative paths belong to root; named paths use workspace://name/path. Physical absolute paths are deliberately rejected so brokered tools do not teach callers host filesystem layout.

Raises:

  • (ArgumentError)


82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'lib/little_ghost/workspace.rb', line 82

def resolve(reference)
  validate! if @identities
  value = String(reference)
  raise ArgumentError, "workspace paths must be relative or use workspace://" if Pathname.new(value).absolute?

  base, relative = if value.start_with?("workspace://")
    logical = value.delete_prefix("workspace://")
    name, separator, child = logical.partition("/")
    raise ArgumentError, "workspace path must name a configured path" if name.empty?

    [path(name), separator.empty? ? "." : child]
  else
    [root, value]
  end
  resolve_beneath(base, relative)
end

#validate!Object

Verifies that no configured directory was replaced after #open.



122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
# File 'lib/little_ghost/workspace.rb', line 122

def validate!
  return self unless @identities

  {root: root}.merge(paths).each do |name, path|
    realpath = File.realpath(path)
    stat = File.stat(realpath)
    expected = @identities.fetch(name)
    unless [realpath, stat.dev, stat.ino] == expected
      raise ToolError, "workspace path changed after opening: #{name}"
    end
  end
  self
rescue Errno::ENOENT
  raise ToolError, "workspace path changed after opening: #{name}"
end