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.
- checksums.yaml +4 -4
- data/README.md +28 -6
- data/docs/guides/assemblies.md +143 -17
- data/docs/guides/code_mode.md +276 -0
- data/docs/guides/core_concepts.md +46 -30
- data/docs/guides/getting_started.md +17 -6
- data/docs/guides/integrations.md +217 -0
- data/docs/guides/models_and_providers.md +125 -0
- data/docs/guides/production.md +190 -17
- data/docs/guides/prompt_views.md +14 -7
- data/docs/guides/sandboxing.md +282 -0
- data/docs/guides/skills.md +141 -0
- data/docs/guides/structured_outputs_and_content.md +135 -0
- data/docs/guides/tools.md +355 -0
- data/lib/little_ghost/ag_ui/adapter.rb +2 -2
- data/lib/little_ghost/agent/delegation.rb +2 -2
- data/lib/little_ghost/agent/skills.rb +6 -1
- data/lib/little_ghost/agent/tool_loop.rb +6 -2
- data/lib/little_ghost/agent.rb +250 -48
- data/lib/little_ghost/agent_builder.rb +61 -22
- data/lib/little_ghost/agent_stream_source.rb +262 -0
- data/lib/little_ghost/artifact.rb +182 -0
- data/lib/little_ghost/artifacts/presentation_budget.rb +44 -0
- data/lib/little_ghost/artifacts/workspace_store.rb +370 -0
- data/lib/little_ghost/artifacts.rb +11 -0
- data/lib/little_ghost/assembly.rb +64 -20
- data/lib/little_ghost/assembly_builder.rb +26 -26
- data/lib/little_ghost/assembly_execution.rb +23 -11
- data/lib/little_ghost/code_mode/broker.rb +168 -0
- data/lib/little_ghost/code_mode/catalog.rb +54 -0
- data/lib/little_ghost/code_mode/engine.rb +58 -0
- data/lib/little_ghost/code_mode/javascript/catalog.rb +118 -0
- data/lib/little_ghost/code_mode/javascript/client.rb +550 -0
- data/lib/little_ghost/code_mode/javascript/host.rb +347 -0
- data/lib/little_ghost/code_mode/javascript/session.rb +578 -0
- data/lib/little_ghost/code_mode/javascript_engine.rb +172 -0
- data/lib/little_ghost/code_mode/protocol.rb +78 -0
- data/lib/little_ghost/code_mode/ruby/catalog.rb +80 -0
- data/lib/little_ghost/code_mode/ruby/host.rb +102 -0
- data/lib/little_ghost/code_mode/ruby/session.rb +508 -0
- data/lib/little_ghost/code_mode/ruby_engine.rb +86 -0
- data/lib/little_ghost/code_mode/runtime.rb +335 -0
- data/lib/little_ghost/code_mode/session.rb +61 -0
- data/lib/little_ghost/code_mode/types.rb +58 -0
- data/lib/little_ghost/code_mode.rb +53 -0
- data/lib/little_ghost/configuration.rb +208 -21
- data/lib/little_ghost/content.rb +19 -8
- data/lib/little_ghost/data_map.rb +2 -2
- data/lib/little_ghost/errors.rb +26 -7
- data/lib/little_ghost/execution.rb +32 -27
- data/lib/little_ghost/execution_state.rb +3 -3
- data/lib/little_ghost/graph.rb +373 -210
- data/lib/little_ghost/instrumentation.rb +2 -1
- data/lib/little_ghost/mcp/client.rb +487 -88
- data/lib/little_ghost/mcp/toolset.rb +210 -0
- data/lib/little_ghost/mcp/types.rb +216 -0
- data/lib/little_ghost/mcp.rb +3 -0
- data/lib/little_ghost/model_capabilities.rb +9 -6
- data/lib/little_ghost/model_request.rb +0 -12
- data/lib/little_ghost/model_resolver.rb +13 -3
- data/lib/little_ghost/model_response.rb +3 -7
- data/lib/little_ghost/network/authorizer_server.rb +162 -0
- data/lib/little_ghost/network/certificate_authority.rb +104 -0
- data/lib/little_ghost/network/envoy_config.rb +362 -0
- data/lib/little_ghost/network/envoy_gateway.rb +418 -0
- data/lib/little_ghost/network/external_gateway.rb +68 -0
- data/lib/little_ghost/network.rb +96 -0
- data/lib/little_ghost/prompt_resolver.rb +7 -7
- data/lib/little_ghost/provider_registry.rb +3 -3
- data/lib/little_ghost/providers/anthropic.rb +8 -1
- data/lib/little_ghost/providers/gemini.rb +10 -1
- data/lib/little_ghost/providers/vertex_ai.rb +6 -1
- data/lib/little_ghost/run.rb +89 -25
- data/lib/little_ghost/run_context.rb +11 -1
- data/lib/little_ghost/run_result.rb +0 -7
- data/lib/little_ghost/runtime/hook.rb +5 -0
- data/lib/little_ghost/runtime/hooks/artifacts.rb +338 -0
- data/lib/little_ghost/runtime.rb +111 -34
- data/lib/little_ghost/sandbox/capabilities.rb +68 -0
- data/lib/little_ghost/sandbox/environment_policy.rb +41 -0
- data/lib/little_ghost/sandbox/filesystem.rb +377 -0
- data/lib/little_ghost/sandbox/isolated_backend.rb +163 -0
- data/lib/little_ghost/sandbox/limits.rb +48 -0
- data/lib/little_ghost/sandbox/mount.rb +129 -0
- data/lib/little_ghost/sandbox/network_policy.rb +108 -0
- data/lib/little_ghost/sandbox/policy.rb +88 -0
- data/lib/little_ghost/sandbox/process_runner.rb +103 -0
- data/lib/little_ghost/sandbox/process_session.rb +335 -0
- data/lib/little_ghost/sandbox/scope.rb +304 -0
- data/lib/little_ghost/sandbox.rb +158 -32
- data/lib/little_ghost/sandboxes/bubblewrap.rb +351 -0
- data/lib/little_ghost/sandboxes/native.rb +51 -0
- data/lib/little_ghost/sandboxes/seatbelt.rb +261 -0
- data/lib/little_ghost/sandboxes/unrestricted.rb +240 -0
- data/lib/little_ghost/session.rb +30 -6
- data/lib/little_ghost/session_stores/filesystem.rb +29 -13
- data/lib/little_ghost/skills/catalog.rb +94 -14
- data/lib/little_ghost/skills/resource_root.rb +42 -0
- data/lib/little_ghost/skills/skill.rb +0 -3
- data/lib/little_ghost/stream_event.rb +8 -13
- data/lib/little_ghost/subagents/control_tool.rb +8 -0
- data/lib/little_ghost/subagents/manager.rb +182 -79
- data/lib/little_ghost/support/callbacks.rb +3 -1
- data/lib/little_ghost/support/cancellation_token.rb +2 -2
- data/lib/little_ghost/support/content_capture.rb +3 -3
- data/lib/little_ghost/support/executor.rb +84 -15
- data/lib/little_ghost/support/http_client.rb +11 -4
- data/lib/little_ghost/support/interruptible_stream.rb +4 -2
- data/lib/little_ghost/support/pooled_thread_runner.rb +126 -0
- data/lib/little_ghost/support/redactor.rb +1 -1
- data/lib/little_ghost/support/serialized_dispatcher.rb +84 -0
- data/lib/little_ghost/support/task.rb +126 -0
- data/lib/little_ghost/support/task_runner.rb +74 -0
- data/lib/little_ghost/support.rb +4 -0
- data/lib/little_ghost/tool.rb +104 -53
- data/lib/little_ghost/tool_registry.rb +1 -1
- data/lib/little_ghost/tools/filesystem.rb +1 -1
- data/lib/little_ghost/tools/shell.rb +4 -3
- data/lib/little_ghost/version.rb +1 -1
- data/lib/little_ghost/workflow.rb +3 -3
- data/lib/little_ghost/workspace.rb +222 -8
- data/lib/little_ghost.rb +38 -12
- metadata +116 -2
- data/lib/little_ghost/unrestricted_sandbox.rb +0 -306
data/lib/little_ghost/tool.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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(
|
|
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
|
-
#
|
|
143
|
-
#
|
|
144
|
-
|
|
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(
|
|
145
|
+
# new(value:, artifacts: []) -> Result
|
|
152
146
|
#
|
|
153
|
-
#
|
|
147
|
+
# Creates a result from the Ruby +value+ and an Array of Artifact objects.
|
|
154
148
|
|
|
155
149
|
##
|
|
156
|
-
# :attr_reader:
|
|
157
|
-
# The
|
|
150
|
+
# :attr_reader: value
|
|
151
|
+
# The ordinary Ruby value returned to application callers and code mode.
|
|
158
152
|
|
|
159
153
|
##
|
|
160
|
-
# :attr_reader:
|
|
161
|
-
#
|
|
154
|
+
# :attr_reader: artifacts
|
|
155
|
+
# The frozen Array of Artifact objects produced by the Tool.
|
|
156
|
+
end
|
|
162
157
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
|
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(
|
|
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(
|
|
400
|
-
ExecutionResult.new(content:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
# ===
|
|
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.
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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:
|
data/lib/little_ghost/version.rb
CHANGED
|
@@ -318,8 +318,8 @@ module LittleGhost
|
|
|
318
318
|
|
|
319
319
|
token = @cancellation_token.child
|
|
320
320
|
queue = SizedQueue.new(1_000)
|
|
321
|
-
worker =
|
|
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&.
|
|
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
|
|
5
|
-
# to decide how those
|
|
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
|
|
11
|
-
#
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|