little_ghost 0.4.0 → 0.6.0

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.
Files changed (124) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +28 -6
  3. data/docs/guides/assemblies.md +143 -17
  4. data/docs/guides/code_mode.md +276 -0
  5. data/docs/guides/core_concepts.md +46 -30
  6. data/docs/guides/getting_started.md +17 -6
  7. data/docs/guides/integrations.md +217 -0
  8. data/docs/guides/models_and_providers.md +125 -0
  9. data/docs/guides/production.md +190 -17
  10. data/docs/guides/prompt_views.md +14 -7
  11. data/docs/guides/sandboxing.md +282 -0
  12. data/docs/guides/skills.md +141 -0
  13. data/docs/guides/structured_outputs_and_content.md +135 -0
  14. data/docs/guides/tools.md +355 -0
  15. data/lib/little_ghost/ag_ui/adapter.rb +2 -2
  16. data/lib/little_ghost/agent/delegation.rb +2 -2
  17. data/lib/little_ghost/agent/skills.rb +6 -1
  18. data/lib/little_ghost/agent/tool_loop.rb +6 -2
  19. data/lib/little_ghost/agent.rb +250 -48
  20. data/lib/little_ghost/agent_builder.rb +61 -22
  21. data/lib/little_ghost/agent_stream_source.rb +262 -0
  22. data/lib/little_ghost/artifact.rb +182 -0
  23. data/lib/little_ghost/artifacts/presentation_budget.rb +44 -0
  24. data/lib/little_ghost/artifacts/workspace_store.rb +370 -0
  25. data/lib/little_ghost/artifacts.rb +11 -0
  26. data/lib/little_ghost/assembly.rb +64 -20
  27. data/lib/little_ghost/assembly_builder.rb +26 -26
  28. data/lib/little_ghost/assembly_execution.rb +23 -11
  29. data/lib/little_ghost/code_mode/broker.rb +168 -0
  30. data/lib/little_ghost/code_mode/catalog.rb +54 -0
  31. data/lib/little_ghost/code_mode/engine.rb +58 -0
  32. data/lib/little_ghost/code_mode/javascript/catalog.rb +118 -0
  33. data/lib/little_ghost/code_mode/javascript/client.rb +550 -0
  34. data/lib/little_ghost/code_mode/javascript/host.rb +347 -0
  35. data/lib/little_ghost/code_mode/javascript/session.rb +578 -0
  36. data/lib/little_ghost/code_mode/javascript_engine.rb +172 -0
  37. data/lib/little_ghost/code_mode/protocol.rb +78 -0
  38. data/lib/little_ghost/code_mode/ruby/catalog.rb +80 -0
  39. data/lib/little_ghost/code_mode/ruby/host.rb +102 -0
  40. data/lib/little_ghost/code_mode/ruby/session.rb +508 -0
  41. data/lib/little_ghost/code_mode/ruby_engine.rb +86 -0
  42. data/lib/little_ghost/code_mode/runtime.rb +335 -0
  43. data/lib/little_ghost/code_mode/session.rb +61 -0
  44. data/lib/little_ghost/code_mode/types.rb +58 -0
  45. data/lib/little_ghost/code_mode.rb +53 -0
  46. data/lib/little_ghost/configuration.rb +208 -21
  47. data/lib/little_ghost/content.rb +19 -8
  48. data/lib/little_ghost/data_map.rb +2 -2
  49. data/lib/little_ghost/errors.rb +26 -7
  50. data/lib/little_ghost/execution.rb +32 -27
  51. data/lib/little_ghost/execution_state.rb +3 -3
  52. data/lib/little_ghost/graph.rb +373 -210
  53. data/lib/little_ghost/instrumentation.rb +2 -1
  54. data/lib/little_ghost/mcp/client.rb +487 -88
  55. data/lib/little_ghost/mcp/toolset.rb +210 -0
  56. data/lib/little_ghost/mcp/types.rb +216 -0
  57. data/lib/little_ghost/mcp.rb +3 -0
  58. data/lib/little_ghost/model_capabilities.rb +9 -6
  59. data/lib/little_ghost/model_request.rb +0 -12
  60. data/lib/little_ghost/model_resolver.rb +13 -3
  61. data/lib/little_ghost/model_response.rb +3 -7
  62. data/lib/little_ghost/network/authorizer_server.rb +162 -0
  63. data/lib/little_ghost/network/certificate_authority.rb +104 -0
  64. data/lib/little_ghost/network/envoy_config.rb +362 -0
  65. data/lib/little_ghost/network/envoy_gateway.rb +418 -0
  66. data/lib/little_ghost/network/external_gateway.rb +68 -0
  67. data/lib/little_ghost/network.rb +96 -0
  68. data/lib/little_ghost/prompt_resolver.rb +7 -7
  69. data/lib/little_ghost/provider_registry.rb +3 -3
  70. data/lib/little_ghost/providers/anthropic.rb +8 -1
  71. data/lib/little_ghost/providers/gemini.rb +10 -1
  72. data/lib/little_ghost/providers/vertex_ai.rb +6 -1
  73. data/lib/little_ghost/run.rb +89 -25
  74. data/lib/little_ghost/run_context.rb +11 -1
  75. data/lib/little_ghost/run_result.rb +0 -7
  76. data/lib/little_ghost/runtime/hook.rb +5 -0
  77. data/lib/little_ghost/runtime/hooks/artifacts.rb +338 -0
  78. data/lib/little_ghost/runtime.rb +111 -34
  79. data/lib/little_ghost/sandbox/capabilities.rb +68 -0
  80. data/lib/little_ghost/sandbox/environment_policy.rb +41 -0
  81. data/lib/little_ghost/sandbox/filesystem.rb +377 -0
  82. data/lib/little_ghost/sandbox/isolated_backend.rb +163 -0
  83. data/lib/little_ghost/sandbox/limits.rb +48 -0
  84. data/lib/little_ghost/sandbox/mount.rb +129 -0
  85. data/lib/little_ghost/sandbox/network_policy.rb +108 -0
  86. data/lib/little_ghost/sandbox/policy.rb +88 -0
  87. data/lib/little_ghost/sandbox/process_runner.rb +103 -0
  88. data/lib/little_ghost/sandbox/process_session.rb +335 -0
  89. data/lib/little_ghost/sandbox/scope.rb +304 -0
  90. data/lib/little_ghost/sandbox.rb +158 -32
  91. data/lib/little_ghost/sandboxes/bubblewrap.rb +351 -0
  92. data/lib/little_ghost/sandboxes/native.rb +51 -0
  93. data/lib/little_ghost/sandboxes/seatbelt.rb +261 -0
  94. data/lib/little_ghost/sandboxes/unrestricted.rb +240 -0
  95. data/lib/little_ghost/session.rb +30 -6
  96. data/lib/little_ghost/session_stores/filesystem.rb +29 -13
  97. data/lib/little_ghost/skills/catalog.rb +94 -14
  98. data/lib/little_ghost/skills/resource_root.rb +42 -0
  99. data/lib/little_ghost/skills/skill.rb +0 -3
  100. data/lib/little_ghost/stream_event.rb +8 -13
  101. data/lib/little_ghost/subagents/control_tool.rb +8 -0
  102. data/lib/little_ghost/subagents/manager.rb +182 -79
  103. data/lib/little_ghost/support/callbacks.rb +3 -1
  104. data/lib/little_ghost/support/cancellation_token.rb +2 -2
  105. data/lib/little_ghost/support/content_capture.rb +3 -3
  106. data/lib/little_ghost/support/executor.rb +84 -15
  107. data/lib/little_ghost/support/http_client.rb +11 -4
  108. data/lib/little_ghost/support/interruptible_stream.rb +4 -2
  109. data/lib/little_ghost/support/pooled_thread_runner.rb +126 -0
  110. data/lib/little_ghost/support/redactor.rb +1 -1
  111. data/lib/little_ghost/support/serialized_dispatcher.rb +84 -0
  112. data/lib/little_ghost/support/task.rb +126 -0
  113. data/lib/little_ghost/support/task_runner.rb +74 -0
  114. data/lib/little_ghost/support.rb +4 -0
  115. data/lib/little_ghost/tool.rb +104 -53
  116. data/lib/little_ghost/tool_registry.rb +1 -1
  117. data/lib/little_ghost/tools/filesystem.rb +1 -1
  118. data/lib/little_ghost/tools/shell.rb +4 -3
  119. data/lib/little_ghost/version.rb +1 -1
  120. data/lib/little_ghost/workflow.rb +3 -3
  121. data/lib/little_ghost/workspace.rb +222 -8
  122. data/lib/little_ghost.rb +38 -12
  123. metadata +116 -2
  124. data/lib/little_ghost/unrestricted_sandbox.rb +0 -306
@@ -43,13 +43,17 @@ module LittleGhost
43
43
  # end
44
44
  # end
45
45
  #
46
+ # class OrderSupportAgent < LittleGhost::Agent
47
+ # tools OrderStatusTool
48
+ # end
49
+ #
46
50
  # OrderSupportAgent.ask(
47
51
  # "Where is order 481?",
48
52
  # actor_id: authenticated_user.id,
49
53
  # context: {account_id: authenticated_user.account_id}
50
54
  # )
51
55
  #
52
- # These values cross different trust boundaries:
56
+ # Each value comes from a different part of the run:
53
57
  #
54
58
  # [<tt>input</tt>]
55
59
  # Arguments selected by the model. The schema checks their shape, not their
@@ -67,8 +71,8 @@ module LittleGhost
67
71
  # The class DSL produces the specification sent to models. During an Agent
68
72
  # run, the tool registry creates and binds one Tool instance. Tests and custom
69
73
  # integrations may call +execute+ directly; it validates the arguments, calls
70
- # +call+, and returns an ExecutionResult. Tool.define offers the same contract
71
- # for an embedded implementation.
74
+ # +call+, and returns a normalized internal result. Tool.define offers the
75
+ # same contract for an embedded implementation.
72
76
  #
73
77
  # Mutable Tool instance state belongs to one Agent run. Registries close tool
74
78
  # instances that implement +close+; <tt>exclusive true</tt> prevents that tool
@@ -80,7 +84,13 @@ module LittleGhost
80
84
  # Cancellation, deadlines, and cleanup errors propagate instead of becoming
81
85
  # ordinary tool output. The configured sandbox, not Tool itself, enforces
82
86
  # filesystem and process isolation.
87
+ #
88
+ # See the {Tools guide}[rdoc-ref:docs/guides/tools.md] for the complete path
89
+ # from model-selected input to application context, sandbox
90
+ # delegation, concurrency, and code mode.
83
91
  class Tool
92
+ UNSET_RESULT_VALUE = Object.new.freeze # :nodoc:
93
+
84
94
  # Supply run-scoped collaborators when tools are instantiated outside an agent.
85
95
  # A binding can be copied with selected collaborators replaced.
86
96
  #
@@ -115,67 +125,84 @@ module LittleGhost
115
125
  end
116
126
  end
117
127
 
118
- # Report the caller-safe outcome of one tool execution.
119
- # The result retains model-facing content, status, and the original exception
120
- # for application-side inspection.
121
- ExecutionResult = Data.define(:content, :status, :error, :companion_content) do # :nodoc:
122
- def initialize(content:, status:, error: nil, companion_content: [])
123
- companions = Array(companion_content)
124
- unless companions.all? do |block|
125
- block.is_a?(Content::Text) || block.is_a?(Content::Image) || block.is_a?(Content::Document)
126
- end
127
- raise ArgumentError, "companion content must contain only text, images, or documents"
128
+ Result = Data.define(:value, :artifacts) do # :nodoc:
129
+ def initialize(value:, artifacts: [])
130
+ values = Array(artifacts)
131
+ unless values.all? { |artifact| artifact.is_a?(Artifact) }
132
+ raise ArgumentError, "artifacts must contain only LittleGhost::Artifact values"
128
133
  end
129
134
 
130
- super(content:, status:, error:, companion_content: companions.dup.freeze)
131
- end
132
-
133
- def success?
134
- status == :success
135
- end
136
-
137
- def error?
138
- status == :error
135
+ super(value:, artifacts: values.dup.freeze)
139
136
  end
140
137
  end
141
138
 
142
- # Reports the caller-safe outcome of one tool execution. It keeps
143
- # model-facing content and status beside the original exception retained for
144
- # application-side inspection. Companion content lets a tool place trusted
145
- # text, images, or documents in the next model request without putting those
146
- # blocks inside a provider-specific tool-result shape.
147
- class ExecutionResult < Data # :doc:
139
+ # Returns a Ruby value together with files or media produced by a Tool.
140
+ # Tools without artifacts should return their Ruby value directly.
141
+ class Result < Data # :doc:
148
142
  ##
149
143
  # :singleton-method: new
150
144
  # :call-seq:
151
- # new(content:, status:, error: nil, companion_content: []) -> ExecutionResult
145
+ # new(value:, artifacts: []) -> Result
152
146
  #
153
- # Collects the model-facing and application-facing parts of one result.
147
+ # Creates a result from the Ruby +value+ and an Array of Artifact objects.
154
148
 
155
149
  ##
156
- # :attr_reader: content
157
- # The normalized, caller-safe text returned to the model.
150
+ # :attr_reader: value
151
+ # The ordinary Ruby value returned to application callers and code mode.
158
152
 
159
153
  ##
160
- # :attr_reader: status
161
- # Either +:success+ or +:error+.
154
+ # :attr_reader: artifacts
155
+ # The frozen Array of Artifact objects produced by the Tool.
156
+ end
162
157
 
163
- ##
164
- # :attr_reader: error
165
- # The original exception for application-side inspection, when present.
158
+ # Internal normalized outcome used between Tool, Agent, and code mode.
159
+ ExecutionResult = Data.define(:value, :content, :status, :error, :artifacts, :presentation_content) do # :nodoc:
160
+ def initialize(
161
+ status:,
162
+ content: UNSET_RESULT_VALUE,
163
+ value: UNSET_RESULT_VALUE,
164
+ error: nil,
165
+ artifacts: [],
166
+ presentation_content: []
167
+ )
168
+ if content.equal?(UNSET_RESULT_VALUE) && value.equal?(UNSET_RESULT_VALUE)
169
+ raise ArgumentError, "tool result requires content or value"
170
+ end
171
+ content = value if content.equal?(UNSET_RESULT_VALUE)
172
+ value = content if value.equal?(UNSET_RESULT_VALUE)
166
173
 
167
- ##
168
- # :attr_reader: companion_content
169
- # Frozen Text, Image, and Document blocks appended as a transient user
170
- # message after the ordinary tool result.
174
+ presentations = Array(presentation_content)
175
+ unless presentations.all? do |block|
176
+ block.is_a?(Content::Text) || block.is_a?(Content::Image) || block.is_a?(Content::Document)
177
+ end
178
+ raise ArgumentError, "artifact presentation must contain only text, images, or documents"
179
+ end
171
180
 
172
- ##
173
- # :method: success?
174
- # Indicates that +status+ is +:success+.
181
+ artifact_values = Array(artifacts)
182
+ unless artifact_values.all? { |artifact| artifact.is_a?(Artifact) }
183
+ raise ArgumentError, "artifacts must contain only LittleGhost::Artifact values"
184
+ end
175
185
 
176
- ##
177
- # :method: error?
178
- # Indicates that +status+ is +:error+.
186
+ status = status.to_sym
187
+ raise ArgumentError, "tool result status must be success or error" unless %i[success error].include?(status)
188
+
189
+ super(
190
+ value:,
191
+ content:,
192
+ status:,
193
+ error:,
194
+ artifacts: artifact_values.dup.freeze,
195
+ presentation_content: presentations.dup.freeze
196
+ )
197
+ end
198
+
199
+ def success?
200
+ status == :success
201
+ end
202
+
203
+ def error?
204
+ status == :error
205
+ end
179
206
  end
180
207
 
181
208
  extend Support::ClassAttributes
@@ -184,6 +211,7 @@ module LittleGhost
184
211
  class_attribute :description_value
185
212
  class_attribute :input_schema_value
186
213
  class_attribute :exclusive_value, default: false
214
+ class_attribute :availability_value
187
215
 
188
216
  class << self
189
217
  # :call-seq:
@@ -238,6 +266,20 @@ module LittleGhost
238
266
  self.exclusive_value = !!values.fetch(0)
239
267
  end
240
268
 
269
+ # Declares whether this Tool is available for a run-scoped +binding+.
270
+ # With no block, returns the configured predicate or nil. ToolRegistry
271
+ # omits a Tool whose predicate returns false before constructing it.
272
+ def available_if(&predicate)
273
+ return availability_value unless predicate
274
+
275
+ self.availability_value = predicate
276
+ end
277
+
278
+ # Returns whether this Tool should be registered for +binding+.
279
+ def available?(binding)
280
+ !availability_value || !!availability_value.call(binding)
281
+ end
282
+
241
283
  # Creates an anonymous Tool subclass backed by +implementation+.
242
284
  # The block receives +input+ and may also accept the +context:+ keyword.
243
285
  #
@@ -335,7 +377,7 @@ module LittleGhost
335
377
  # Bound sandbox, when available.
336
378
  def sandbox = binding.sandbox
337
379
 
338
- # Validates +input+ and invokes the tool, returning an ExecutionResult.
380
+ # Validates +input+ and invokes the Tool, returning its normalized outcome.
339
381
  #
340
382
  # Cancellation, deadline, and cleanup exceptions remain control-flow
341
383
  # exceptions. ToolError and unexpected failures become sanitized error
@@ -350,8 +392,9 @@ module LittleGhost
350
392
 
351
393
  value = bound_for(context).call(input)
352
394
  return normalize_execution_result(value) if value.is_a?(ExecutionResult)
395
+ return success(value.value, artifacts: value.artifacts) if value.is_a?(Result)
353
396
 
354
- success(sanitize(value))
397
+ success(value)
355
398
  rescue CancelledError, DeadlineExceededError, CleanupError
356
399
  raise
357
400
  rescue ToolError => error
@@ -396,8 +439,8 @@ module LittleGhost
396
439
  raise ToolError, "Tool returned content that cannot be serialized"
397
440
  end
398
441
 
399
- def success(content)
400
- ExecutionResult.new(content: content.freeze, status: :success)
442
+ def success(value, artifacts: [])
443
+ ExecutionResult.new(value:, content: sanitize(value).freeze, status: :success, artifacts:)
401
444
  end
402
445
 
403
446
  def failure(content, error:)
@@ -406,14 +449,18 @@ module LittleGhost
406
449
 
407
450
  def normalize_execution_result(result)
408
451
  ExecutionResult.new(
452
+ value: result.value,
409
453
  content: sanitize(result.content).freeze,
410
454
  status: result.status,
411
455
  error: result.error,
412
- companion_content: result.companion_content
456
+ artifacts: result.artifacts,
457
+ presentation_content: result.presentation_content
413
458
  )
414
459
  end
415
460
 
416
461
  class SchemaValidator # :nodoc:
462
+ REGEXP_TIMEOUT = 0.05
463
+
417
464
  def initialize(schema)
418
465
  @schema = schema
419
466
  end
@@ -482,7 +529,11 @@ module LittleGhost
482
529
  pattern = schema["pattern"]
483
530
  errors << "#{path} must have at least #{minimum} characters" if minimum && value.length < minimum
484
531
  errors << "#{path} must have at most #{maximum} characters" if maximum && value.length > maximum
485
- errors << "#{path} has an invalid format" if pattern && !Regexp.new(pattern).match?(value)
532
+ if pattern && !Regexp.new(pattern, timeout: REGEXP_TIMEOUT).match?(value)
533
+ errors << "#{path} has an invalid format"
534
+ end
535
+ rescue Regexp::TimeoutError
536
+ errors << "#{path} has an invalid format"
486
537
  rescue RegexpError
487
538
  errors << "#{path} has an invalid schema pattern"
488
539
  end
@@ -127,7 +127,7 @@ module LittleGhost
127
127
  if value.is_a?(Array)
128
128
  value.flatten.compact.each { |child| resolve(child, instances, binding:) }
129
129
  elsif value.is_a?(Class) && value <= Tool
130
- instances << value.new(binding:)
130
+ instances << value.new(binding:) if value.available?(binding)
131
131
  elsif value.is_a?(Class)
132
132
  resolve(value.tools(binding), instances, binding:)
133
133
  else
@@ -14,7 +14,7 @@ module LittleGhost
14
14
  # )
15
15
  # registry.names # => ["read_file", "list_files"]
16
16
  #
17
- # === Security and trust
17
+ # === Files follow the Sandbox
18
18
  #
19
19
  # These tools do not add isolation. The configured sandbox must enforce path
20
20
  # containment, permissions, size limits, and cancellation.
@@ -9,9 +9,10 @@ module LittleGhost
9
9
  # shell syntax.
10
10
  #
11
11
  # The child environment is cleared, runtime is limited to 30 seconds, and
12
- # each output stream is limited to 1 MB. These defaults reduce accidental
13
- # exposure but do not create an isolation boundary; the configured sandbox
14
- # remains responsible for security.
12
+ # each output stream is limited to 1 MB. Commands may spawn children when
13
+ # the backend supports them. These defaults reduce accidental exposure but
14
+ # do not create an isolation boundary; the configured sandbox remains
15
+ # responsible for security.
15
16
  class Shell < Tool
16
17
  DEFAULT_TIMEOUT = 30 # :nodoc:
17
18
  MAX_OUTPUT_BYTES = 1_000_000 # :nodoc:
@@ -2,5 +2,5 @@
2
2
 
3
3
  module LittleGhost
4
4
  # Current LittleGhost gem version.
5
- VERSION = "0.4.0"
5
+ VERSION = "0.6.0"
6
6
  end
@@ -318,8 +318,8 @@ module LittleGhost
318
318
 
319
319
  token = @cancellation_token.child
320
320
  queue = SizedQueue.new(1_000)
321
- worker = Thread.new do
322
- results = Support::Executor.new(max_concurrency:).map(
321
+ worker = task_runner.spawn do
322
+ results = Support::Executor.new(max_concurrency:, runner: task_runner).map(
323
323
  invocations,
324
324
  cancellation_token: token,
325
325
  on_result: ->(_index, execution) { record_workflow_steps(execution.fetch(:steps)) }
@@ -345,7 +345,7 @@ module LittleGhost
345
345
  executions.map { |execution| execution.fetch(:output) }
346
346
  ensure
347
347
  token&.cancel
348
- worker&.join
348
+ worker&.wait
349
349
  end
350
350
 
351
351
  def execute_workflow_invocation(reference:, participant:, input:, history:, context:, policies:, checkpoint:)
@@ -1,31 +1,245 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "pathname"
4
+ require "fileutils"
5
+
3
6
  module LittleGhost
4
- # A Workspace gives an agent a concrete home for files. Pair it with a Sandbox
5
- # to decide how those files may be read, changed, or used by commands.
7
+ # A Workspace names the host paths associated with a Run. Pair it with a
8
+ # Sandbox to decide how those paths may be read, changed, or used by commands.
6
9
  #
7
10
  # workspace = LittleGhost::Workspace.new(root: "./tmp/support-run")
8
11
  # workspace.root # => an absolute path ending in "/tmp/support-run"
9
12
  #
10
- # Workspaces participate in the run resource lifecycle. A custom workspace can
11
- # provision storage in #open and release it in #close.
13
+ # Workspaces participate in the Run resource lifecycle, but object lifetime
14
+ # and file lifetime are separate. Opening creates +root+ and relative named
15
+ # paths, but does not delete them by default. Absolute named paths are trusted
16
+ # references that must already exist. Setup and teardown callbacks let trusted
17
+ # application configuration provision run-scoped resources without a
18
+ # Workspace subclass. Applications that share a writable root between Runs
19
+ # must provide their own concurrency and tenant isolation.
20
+ #
21
+ # See the {Workspaces and Sandboxes guide}[rdoc-ref:docs/guides/sandboxing.md]
22
+ # for logical paths, Sandbox policy, process ownership, and networking.
12
23
  class Workspace
13
- # Expands +root+ to an absolute path.
14
- def initialize(root:)
24
+ @providers = {}
25
+
26
+ class << self
27
+ # Registers a trusted workspace provider under a configuration symbol.
28
+ def register_provider(name, implementation)
29
+ unless implementation.is_a?(Class) && implementation <= Workspace
30
+ raise ArgumentError, "workspace provider must be a Workspace class"
31
+ end
32
+
33
+ Workspace.providers[name.to_sym] = implementation
34
+ end
35
+
36
+ # Resolves an explicitly selected provider without changing its meaning.
37
+ def resolve_provider(name)
38
+ Workspace.providers.fetch(name.to_sym) do
39
+ raise DependencyError, "workspace provider :#{name} is not available"
40
+ end
41
+ end
42
+
43
+ def providers # :nodoc:
44
+ @providers ||= {}
45
+ end
46
+ end
47
+
48
+ # Expands +root+ and every named path to absolute paths. Relative named paths
49
+ # must remain beneath +root+; absolute named paths deliberately refer outside
50
+ # it. +setup+ receives +workspace:+ and +run:+ when the Run opens. +teardown+
51
+ # receives the same values when it closes, including after partial setup.
52
+ def initialize(root:, paths: {}, setup: nil, teardown: nil)
15
53
  @root = File.expand_path(root)
54
+ @paths = normalize_paths(paths)
55
+ @setup = validate_callback(setup, :setup)
56
+ @teardown = validate_callback(teardown, :teardown)
57
+ @opened = false
58
+ @active = false
59
+ @run = nil
60
+ @identities = nil
16
61
  end
17
62
 
18
63
  # Absolute filesystem root assigned to this workspace.
19
64
  attr_reader :root
20
65
 
21
- # Opens any run-scoped resources and yields this workspace to the run.
66
+ # Immutable named absolute paths owned by this workspace declaration.
67
+ attr_reader :paths
68
+
69
+ # Returns a configured named path, raising KeyError when it is absent.
70
+ def path(name)
71
+ paths.fetch(name.to_sym)
72
+ end
73
+
74
+ # Converts a logical path to its physical workspace path. This method checks
75
+ # lexical containment but does not make direct filesystem access safe for
76
+ # untrusted input. Pass model-selected paths through Sandbox file operations,
77
+ # which reject symlinks while opening each path component.
78
+ #
79
+ # Relative paths belong to +root+; named paths use
80
+ # <tt>workspace://name/path</tt>. Physical absolute paths are deliberately
81
+ # rejected so brokered tools do not teach callers host filesystem layout.
82
+ def resolve(reference)
83
+ validate! if @identities
84
+ value = String(reference)
85
+ raise ArgumentError, "workspace paths must be relative or use workspace://" if Pathname.new(value).absolute?
86
+
87
+ base, relative = if value.start_with?("workspace://")
88
+ logical = value.delete_prefix("workspace://")
89
+ name, separator, child = logical.partition("/")
90
+ raise ArgumentError, "workspace path must name a configured path" if name.empty?
91
+
92
+ [path(name), separator.empty? ? "." : child]
93
+ else
94
+ [root, value]
95
+ end
96
+ resolve_beneath(base, relative)
97
+ end
98
+
99
+ # Returns the stable logical reference for a physical workspace path.
100
+ def reference(physical_path)
101
+ candidate = File.expand_path(physical_path)
102
+ named = paths.sort_by { |_, path| -path.length }.find { |_, path| beneath?(candidate, path) }
103
+ if named
104
+ name, base = named
105
+ relative = candidate.delete_prefix(base).delete_prefix(File::SEPARATOR)
106
+ return relative.empty? ? "workspace://#{name}" : "workspace://#{name}/#{relative}"
107
+ end
108
+ raise ArgumentError, "path is outside the workspace" unless beneath?(candidate, root)
109
+
110
+ candidate.delete_prefix(root).delete_prefix(File::SEPARATOR).then { |value| value.empty? ? "." : value }
111
+ end
112
+
113
+ # Environment variables supplied to sandboxed programs. These values are
114
+ # trusted process configuration and are never returned by filesystem tools.
115
+ def environment
116
+ {"LITTLE_GHOST_WORKSPACE_ROOT" => root}.merge(paths.to_h do |name, path|
117
+ ["LITTLE_GHOST_WORKSPACE_#{name.to_s.upcase.gsub(/[^A-Z0-9]/, "_")}", path]
118
+ end).freeze
119
+ end
120
+
121
+ # Verifies that no configured directory was replaced after #open.
122
+ def validate!
123
+ return self unless @identities
124
+
125
+ {root: root}.merge(paths).each do |name, path|
126
+ realpath = File.realpath(path)
127
+ stat = File.stat(realpath)
128
+ expected = @identities.fetch(name)
129
+ unless [realpath, stat.dev, stat.ino] == expected
130
+ raise ToolError, "workspace path changed after opening: #{name}"
131
+ end
132
+ end
133
+ self
134
+ rescue Errno::ENOENT
135
+ raise ToolError, "workspace path changed after opening: #{name}"
136
+ end
137
+
138
+ # Calls the application setup callback once and returns this workspace.
22
139
  def open(run: nil)
140
+ return self if @opened
141
+
142
+ @run = run
143
+ @active = true
144
+ @setup&.call(workspace: self, run:)
145
+ materialize!
146
+ capture_identities!
147
+ @opened = true
23
148
  self
149
+ rescue
150
+ close
151
+ raise
24
152
  end
25
153
 
26
- # Releases workspace resources. The default workspace has nothing to close.
154
+ # Calls the application teardown callback once. The default does not remove
155
+ # files or directories.
27
156
  def close
157
+ return nil unless @active
158
+
159
+ @teardown&.call(workspace: self, run: @run)
28
160
  nil
161
+ ensure
162
+ @opened = false
163
+ @active = false
164
+ @run = nil
165
+ @identities = nil
166
+ end
167
+
168
+ private
169
+
170
+ def materialize!
171
+ FileUtils.mkdir_p(root)
172
+ paths.each do |name, directory|
173
+ if @relative_path_names.include?(name)
174
+ FileUtils.mkdir_p(directory)
175
+ elsif !File.directory?(directory)
176
+ raise ArgumentError, "absolute workspace path :#{name} must already exist"
177
+ end
178
+ end
179
+ end
180
+
181
+ def capture_identities!
182
+ entries = {root: root}.merge(paths)
183
+ identities = entries.to_h do |name, path|
184
+ realpath = File.realpath(path)
185
+ stat = File.stat(realpath)
186
+ [name, [realpath, stat.dev, stat.ino].freeze]
187
+ end
188
+ duplicates = identities.group_by { |_, identity| identity.drop(1) }.select { |_, values| values.length > 1 }
189
+ unless duplicates.empty?
190
+ names = duplicates.values.flat_map { |values| values.map(&:first) }
191
+ raise ArgumentError, "workspace paths must not alias the same directory: #{names.join(", ")}"
192
+ end
193
+ @identities = identities.freeze
194
+ end
195
+
196
+ def resolve_beneath(base, relative)
197
+ raise ArgumentError, "workspace path must not be empty" if relative.empty?
198
+ components = Pathname.new(relative).each_filename.to_a
199
+ raise ArgumentError, "workspace path traversal is not allowed" if components.include?("..")
200
+
201
+ expanded = File.expand_path(relative, base)
202
+ raise ArgumentError, "workspace path escapes its root" unless beneath?(expanded, base)
203
+
204
+ expanded
205
+ end
206
+
207
+ def beneath?(candidate, base)
208
+ candidate == base || candidate.start_with?(base.end_with?(File::SEPARATOR) ? base : "#{base}#{File::SEPARATOR}")
209
+ end
210
+
211
+ def normalize_paths(values)
212
+ unless values.respond_to?(:each_pair)
213
+ raise ArgumentError, "workspace paths must be a Hash"
214
+ end
215
+
216
+ relative_names = []
217
+ normalized = values.each_pair.to_h do |name, value|
218
+ key = name.to_sym
219
+ path = value.to_s
220
+ raise ArgumentError, "workspace path :#{key} must not be empty" if path.empty?
221
+
222
+ expanded = File.expand_path(path, root)
223
+ root_prefix = root.end_with?(File::SEPARATOR) ? root : "#{root}#{File::SEPARATOR}"
224
+ relative = !Pathname.new(path).absolute?
225
+ if relative && expanded != root && !expanded.start_with?(root_prefix)
226
+ raise ArgumentError, "relative workspace path :#{key} must remain beneath the workspace root"
227
+ end
228
+ relative_names << key if relative
229
+
230
+ [key, expanded.freeze]
231
+ end.freeze
232
+ @relative_path_names = relative_names.freeze
233
+ normalized
234
+ end
235
+
236
+ def validate_callback(callback, name)
237
+ return unless callback
238
+ raise ArgumentError, "workspace #{name} must be callable" unless callback.respond_to?(:call)
239
+
240
+ callback
29
241
  end
30
242
  end
243
+
244
+ Workspace.register_provider(:directory, Workspace)
31
245
  end