agentboard 0.2.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 +7 -0
- data/CHANGELOG.md +62 -0
- data/MIT-LICENSE +20 -0
- data/README.md +316 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/agentboard/application.css +510 -0
- data/app/controllers/agentboard/agents_controller.rb +10 -0
- data/app/controllers/agentboard/analytics_controller.rb +37 -0
- data/app/controllers/agentboard/application_controller.rb +90 -0
- data/app/controllers/agentboard/approvals_controller.rb +15 -0
- data/app/controllers/agentboard/assets_controller.rb +32 -0
- data/app/controllers/agentboard/board_controller.rb +46 -0
- data/app/controllers/agentboard/captures_controller.rb +17 -0
- data/app/controllers/agentboard/comments_controller.rb +9 -0
- data/app/controllers/agentboard/criteria_controller.rb +17 -0
- data/app/controllers/agentboard/dependencies_controller.rb +20 -0
- data/app/controllers/agentboard/executions_controller.rb +48 -0
- data/app/controllers/agentboard/milestones_controller.rb +23 -0
- data/app/controllers/agentboard/pulse_controller.rb +12 -0
- data/app/controllers/agentboard/sessions_controller.rb +19 -0
- data/app/controllers/agentboard/settings_controller.rb +9 -0
- data/app/controllers/agentboard/tickets_controller.rb +139 -0
- data/app/controllers/agentboard/workflow_runs_controller.rb +16 -0
- data/app/controllers/agentboard/workflows_controller.rb +62 -0
- data/app/helpers/agentboard/application_helper.rb +196 -0
- data/app/javascript/agentboard/application.js +227 -0
- data/app/javascript/agentboard/capture.js +299 -0
- data/app/jobs/agentboard/application_job.rb +4 -0
- data/app/models/agentboard/acceptance_criterion.rb +23 -0
- data/app/models/agentboard/agent_profile.rb +16 -0
- data/app/models/agentboard/agent_session.rb +30 -0
- data/app/models/agentboard/application_record.rb +13 -0
- data/app/models/agentboard/approval.rb +29 -0
- data/app/models/agentboard/artifact.rb +11 -0
- data/app/models/agentboard/comment.rb +9 -0
- data/app/models/agentboard/event.rb +37 -0
- data/app/models/agentboard/execution.rb +51 -0
- data/app/models/agentboard/idempotency_key.rb +5 -0
- data/app/models/agentboard/milestone.rb +7 -0
- data/app/models/agentboard/outbox_message.rb +11 -0
- data/app/models/agentboard/project.rb +43 -0
- data/app/models/agentboard/repository_snapshot.rb +5 -0
- data/app/models/agentboard/session_ticket_link.rb +18 -0
- data/app/models/agentboard/ticket.rb +81 -0
- data/app/models/agentboard/ticket_dependency.rb +34 -0
- data/app/models/agentboard/ticket_lease.rb +11 -0
- data/app/models/agentboard/usage_record.rb +10 -0
- data/app/models/agentboard/verification_result.rb +12 -0
- data/app/models/agentboard/webhook_delivery.rb +7 -0
- data/app/models/agentboard/workflow_definition.rb +20 -0
- data/app/models/agentboard/workflow_run.rb +21 -0
- data/app/models/agentboard/workflow_step.rb +12 -0
- data/app/services/agentboard/approvals.rb +76 -0
- data/app/services/agentboard/captures.rb +113 -0
- data/app/services/agentboard/completion_policy.rb +117 -0
- data/app/services/agentboard/current.rb +5 -0
- data/app/services/agentboard/data_governance.rb +68 -0
- data/app/services/agentboard/doctor.rb +133 -0
- data/app/services/agentboard/event_store.rb +88 -0
- data/app/services/agentboard/evidence.rb +52 -0
- data/app/services/agentboard/executions.rb +198 -0
- data/app/services/agentboard/hook_ingestion.rb +100 -0
- data/app/services/agentboard/idempotency.rb +28 -0
- data/app/services/agentboard/lease_service.rb +107 -0
- data/app/services/agentboard/mcp_server.rb +138 -0
- data/app/services/agentboard/mcp_tools.rb +341 -0
- data/app/services/agentboard/outbox.rb +44 -0
- data/app/services/agentboard/pull_requests.rb +65 -0
- data/app/services/agentboard/runtime/execution_runner.rb +352 -0
- data/app/services/agentboard/runtime/ipc_handler.rb +55 -0
- data/app/services/agentboard/runtime/reconciler.rb +42 -0
- data/app/services/agentboard/runtime/supervisor.rb +96 -0
- data/app/services/agentboard/session_association.rb +44 -0
- data/app/services/agentboard/session_correlation.rb +56 -0
- data/app/services/agentboard/session_credential.rb +44 -0
- data/app/services/agentboard/ticket_advisor.rb +60 -0
- data/app/services/agentboard/tickets.rb +218 -0
- data/app/services/agentboard/verification_service.rb +60 -0
- data/app/services/agentboard/workflow_engine.rb +261 -0
- data/app/views/agentboard/agents/index.html.erb +51 -0
- data/app/views/agentboard/analytics/show.html.erb +78 -0
- data/app/views/agentboard/approvals/_approval.html.erb +27 -0
- data/app/views/agentboard/approvals/index.html.erb +31 -0
- data/app/views/agentboard/board/_card.html.erb +36 -0
- data/app/views/agentboard/board/show.html.erb +73 -0
- data/app/views/agentboard/executions/index.html.erb +33 -0
- data/app/views/agentboard/executions/show.html.erb +126 -0
- data/app/views/agentboard/milestones/index.html.erb +28 -0
- data/app/views/agentboard/settings/show.html.erb +33 -0
- data/app/views/agentboard/shared/_dag.html.erb +24 -0
- data/app/views/agentboard/tickets/_form.html.erb +38 -0
- data/app/views/agentboard/tickets/edit.html.erb +5 -0
- data/app/views/agentboard/tickets/index.html.erb +51 -0
- data/app/views/agentboard/tickets/new.html.erb +6 -0
- data/app/views/agentboard/tickets/show.html.erb +343 -0
- data/app/views/agentboard/tickets/suggestions.html.erb +21 -0
- data/app/views/agentboard/workflow_runs/show.html.erb +28 -0
- data/app/views/agentboard/workflows/index.html.erb +49 -0
- data/app/views/agentboard/workflows/show.html.erb +15 -0
- data/app/views/layouts/agentboard/application.html.erb +93 -0
- data/config/routes.rb +49 -0
- data/db/migrate/20261114000001_create_agentboard_core.rb +144 -0
- data/db/migrate/20261114000002_create_agentboard_runtime.rb +213 -0
- data/db/migrate/20261115000001_create_agentboard_webhook_deliveries.rb +18 -0
- data/exe/agentboard +7 -0
- data/lib/agentboard/agent_container.rb +124 -0
- data/lib/agentboard/authorization.rb +58 -0
- data/lib/agentboard/capture/middleware.rb +54 -0
- data/lib/agentboard/capture.rb +57 -0
- data/lib/agentboard/cli.rb +818 -0
- data/lib/agentboard/command_policy.rb +44 -0
- data/lib/agentboard/configuration.rb +160 -0
- data/lib/agentboard/container_sandbox.rb +65 -0
- data/lib/agentboard/context_builder.rb +243 -0
- data/lib/agentboard/egress_proxy.rb +95 -0
- data/lib/agentboard/engine.rb +27 -0
- data/lib/agentboard/errors.rb +42 -0
- data/lib/agentboard/event_schema.rb +37 -0
- data/lib/agentboard/extensions.rb +58 -0
- data/lib/agentboard/git/repository.rb +92 -0
- data/lib/agentboard/git/worktree_manager.rb +107 -0
- data/lib/agentboard/git.rb +4 -0
- data/lib/agentboard/ids.rb +33 -0
- data/lib/agentboard/json_log_formatter.rb +28 -0
- data/lib/agentboard/path_guard.rb +27 -0
- data/lib/agentboard/process_ancestry.rb +40 -0
- data/lib/agentboard/providers/base.rb +107 -0
- data/lib/agentboard/providers/claude_code.rb +219 -0
- data/lib/agentboard/providers/codex.rb +177 -0
- data/lib/agentboard/providers/compatibility/claude.yml +13 -0
- data/lib/agentboard/providers/compatibility/codex.yml +10 -0
- data/lib/agentboard/providers/config_editor.rb +106 -0
- data/lib/agentboard/providers/cursor.rb +40 -0
- data/lib/agentboard/providers/fake.rb +59 -0
- data/lib/agentboard/providers/fake_agent_script.rb +47 -0
- data/lib/agentboard/providers/generic_cli.rb +35 -0
- data/lib/agentboard/providers/registry.rb +31 -0
- data/lib/agentboard/providers/types.rb +32 -0
- data/lib/agentboard/providers.rb +26 -0
- data/lib/agentboard/rails_introspection.rb +49 -0
- data/lib/agentboard/redactor.rb +62 -0
- data/lib/agentboard/result.rb +46 -0
- data/lib/agentboard/runtime/ipc.rb +193 -0
- data/lib/agentboard/runtime.rb +1 -0
- data/lib/agentboard/state_machines.rb +71 -0
- data/lib/agentboard/subprocess.rb +168 -0
- data/lib/agentboard/templates/Dockerfile.agent +21 -0
- data/lib/agentboard/verification.rb +79 -0
- data/lib/agentboard/version.rb +3 -0
- data/lib/agentboard/workflows/condition.rb +166 -0
- data/lib/agentboard/workflows/definition.rb +208 -0
- data/lib/agentboard/workflows.rb +2 -0
- data/lib/agentboard.rb +73 -0
- data/lib/generators/agentboard/install/install_generator.rb +67 -0
- data/lib/generators/agentboard/install/templates/config.yml +81 -0
- data/lib/generators/agentboard/install/templates/feature_delivery.yml +25 -0
- data/lib/generators/agentboard/install/templates/initializer.rb +23 -0
- data/lib/tasks/agentboard_tasks.rake +6 -0
- metadata +285 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Deterministic fake coding agent used by AgentBoard's Fake provider.
|
|
2
|
+
# Reads the prompt from stdin, emits JSONL on stdout and edits the current directory.
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
$stdout.sync = true
|
|
7
|
+
args = ARGV.each_slice(2).to_h
|
|
8
|
+
scenario = args.fetch("--scenario", "success")
|
|
9
|
+
prompt = $stdin.read.to_s
|
|
10
|
+
ticket = ENV.fetch("AGENTBOARD_TICKET", "UNKNOWN")
|
|
11
|
+
|
|
12
|
+
emit = ->(h) { puts(JSON.generate(h)) }
|
|
13
|
+
trap("TERM") { emit.call(type: "message", text: "received SIGTERM, stopping"); exit 143 }
|
|
14
|
+
|
|
15
|
+
emit.call(type: "session", session_id: args["--session"] || "fake-#{Process.pid}", model: "fake-1")
|
|
16
|
+
emit.call(type: "message", text: "Read #{prompt.bytesize} bytes of context for #{ticket}.")
|
|
17
|
+
|
|
18
|
+
case scenario
|
|
19
|
+
when "malformed"
|
|
20
|
+
puts "this is not json {"
|
|
21
|
+
emit.call(type: "mystery", foo: 1)
|
|
22
|
+
when "secret"
|
|
23
|
+
emit.call(type: "message", text: "Found key sk-ant-api03-ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 in env")
|
|
24
|
+
when "permission"
|
|
25
|
+
emit.call(type: "permission", tool: "Bash", reason: "wants to run rm -rf tmp/")
|
|
26
|
+
when "env"
|
|
27
|
+
# Simulates an agent that obeys a prompt-injected instruction to dump its environment.
|
|
28
|
+
ENV.sort.each { |k, v| emit.call(type: "message", text: "#{k}=#{v}") }
|
|
29
|
+
when "resume"
|
|
30
|
+
emit.call(type: "message", text: "Resumed session #{args["--session"]}: #{prompt.strip[0, 80]}")
|
|
31
|
+
when "slow"
|
|
32
|
+
20.times { |i| emit.call(type: "message", text: "working… #{i}"); sleep 0.5 }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
unless scenario == "noop"
|
|
36
|
+
emit.call(type: "tool_start", tool: "Edit", target: "AGENTBOARD_NOTES.md")
|
|
37
|
+
File.open("AGENTBOARD_NOTES.md", "a") { |f| f.puts("- #{ticket}: change by fake agent (#{scenario})") }
|
|
38
|
+
emit.call(type: "tool_end", tool: "Edit", target: "AGENTBOARD_NOTES.md", ok: true)
|
|
39
|
+
end
|
|
40
|
+
emit.call(type: "usage", input_tokens: prompt.bytesize / 4, output_tokens: 42)
|
|
41
|
+
|
|
42
|
+
if scenario == "fail"
|
|
43
|
+
emit.call(type: "result", ok: false, summary: "Could not complete the task")
|
|
44
|
+
exit 1
|
|
45
|
+
end
|
|
46
|
+
emit.call(type: "result", ok: true, summary: "Implemented #{ticket} (fake)")
|
|
47
|
+
exit 0
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
module Providers
|
|
3
|
+
# Placeholder adapters for P2 providers (Gemini CLI, OpenCode, Copilot). They detect the
|
|
4
|
+
# binary and report MCP-only capability; active execution is unavailable until a
|
|
5
|
+
# fixture-backed adapter is written. This keeps the UI honest about support levels.
|
|
6
|
+
class GenericCli < Base
|
|
7
|
+
class << self
|
|
8
|
+
attr_reader :provider_key, :binary_name, :display
|
|
9
|
+
|
|
10
|
+
def for(key:, binary:, display:)
|
|
11
|
+
Class.new(self) do
|
|
12
|
+
@provider_key = key
|
|
13
|
+
@binary_name = binary
|
|
14
|
+
@display = display
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def key = provider_key.to_sym
|
|
19
|
+
def display_name = display
|
|
20
|
+
def binary = binary_name
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def probe(environment: ENV)
|
|
24
|
+
bin = which(self.class.binary)
|
|
25
|
+
version = bin && run_version([ bin, "--version" ]).to_s[/\d+\.\d+(?:\.\d+)?/]
|
|
26
|
+
ProbeResult.new(provider: key, installed: !bin.nil?, version: version, authenticated: nil,
|
|
27
|
+
supported_capabilities: bin ? %i[mcp] : [], validation: bin ? "detected (P2, MCP only)" : "not installed",
|
|
28
|
+
diagnostics: [ "#{display_name}: only MCP configuration is supported in this release." ])
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def launch_spec(**) = raise(UnsupportedCapability.new(:active_execution, provider: key))
|
|
32
|
+
def parse_event(raw_event:) = [ NormalizedEvent.build("agent.execution.output", { "text" => raw_event.to_s[0, 4000] }) ]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
module Providers
|
|
3
|
+
class Registry
|
|
4
|
+
REQUIRED_METHODS = %i[probe parse_event launch_spec].freeze
|
|
5
|
+
|
|
6
|
+
def initialize
|
|
7
|
+
@adapters = {}
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def register(key, adapter)
|
|
11
|
+
key = key.to_s
|
|
12
|
+
raise ArgumentError, "provider key must match /\\A[a-z][a-z0-9_]*\\z/" unless key.match?(/\A[a-z][a-z0-9_]*\z/)
|
|
13
|
+
missing = REQUIRED_METHODS.reject { |m| adapter.method_defined?(m) }
|
|
14
|
+
raise ArgumentError, "#{adapter} must implement #{missing.join(", ")}" if missing.any?
|
|
15
|
+
|
|
16
|
+
@adapters[key] = adapter
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def keys = @adapters.keys
|
|
20
|
+
def registered?(key) = @adapters.key?(key.to_s)
|
|
21
|
+
|
|
22
|
+
def adapter_class(key)
|
|
23
|
+
@adapters.fetch(key.to_s) { raise Error.not_found("Unknown provider #{key}") }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def fetch(key, settings: nil) = adapter_class(key).new(settings: settings)
|
|
27
|
+
def each(&) = @adapters.each_key.map { |k| fetch(k) }.each(&)
|
|
28
|
+
def all = @adapters.keys.map { |k| fetch(k) }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
module Providers
|
|
3
|
+
CAPABILITIES = %i[mcp passive_hooks active_execution structured_events streaming cancellation session_resume permission_requests].freeze
|
|
4
|
+
|
|
5
|
+
# Typed results (spec §26.1). Handles never carry provider secrets.
|
|
6
|
+
ProbeResult = Struct.new(:provider, :installed, :version, :authenticated, :supported_capabilities, :validation, :diagnostics, keyword_init: true) do
|
|
7
|
+
def supports?(cap) = Array(supported_capabilities).include?(cap.to_sym)
|
|
8
|
+
|
|
9
|
+
def to_h
|
|
10
|
+
{ provider: provider, installed: installed, version: version, authenticated: authenticated,
|
|
11
|
+
supported_capabilities: Array(supported_capabilities).map(&:to_s), validation: validation, diagnostics: diagnostics }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# What the supervisor needs to spawn a provider process safely.
|
|
16
|
+
# mounts: extra host paths mounted read-only when the agent runs in a container.
|
|
17
|
+
# credential_aliases: { "NAME_IN_CONTAINER" => "HOST_VARIABLE" } — forwarded by name only.
|
|
18
|
+
LaunchSpec = Struct.new(:argv, :env, :stdin_data, :chdir, :passthrough_env, :mounts, :credential_aliases, keyword_init: true)
|
|
19
|
+
|
|
20
|
+
# Normalized event produced by `parse_event`.
|
|
21
|
+
NormalizedEvent = Struct.new(:type, :payload, :external_session_id, :usage, :terminal, :status, :provenance, keyword_init: true) do
|
|
22
|
+
def self.build(type, payload = {}, **opts)
|
|
23
|
+
new(type: type, payload: payload, provenance: "provider_reported", terminal: false, **opts)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
Usage = Struct.new(:input_tokens, :output_tokens, :reported_cost, :currency, :metering_kind, keyword_init: true)
|
|
28
|
+
|
|
29
|
+
# Passive setup outcome: exact changes for display and the reversal manifest.
|
|
30
|
+
SetupResult = Struct.new(:provider, :changes, :manifest, :backups, :warnings, keyword_init: true)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
require "agentboard/subprocess"
|
|
2
|
+
require "agentboard/providers/types"
|
|
3
|
+
require "agentboard/providers/base"
|
|
4
|
+
require "agentboard/providers/registry"
|
|
5
|
+
require "agentboard/providers/config_editor"
|
|
6
|
+
require "agentboard/providers/fake"
|
|
7
|
+
require "agentboard/providers/claude_code"
|
|
8
|
+
require "agentboard/providers/codex"
|
|
9
|
+
require "agentboard/providers/cursor"
|
|
10
|
+
require "agentboard/providers/generic_cli"
|
|
11
|
+
|
|
12
|
+
module Agentboard
|
|
13
|
+
module Providers
|
|
14
|
+
def self.registry
|
|
15
|
+
@registry ||= Registry.new.tap do |r|
|
|
16
|
+
r.register(:fake, Fake)
|
|
17
|
+
r.register(:claude, ClaudeCode)
|
|
18
|
+
r.register(:codex, Codex)
|
|
19
|
+
r.register(:cursor, Cursor)
|
|
20
|
+
r.register(:gemini, GenericCli.for(key: "gemini", binary: "gemini", display: "Gemini CLI"))
|
|
21
|
+
r.register(:opencode, GenericCli.for(key: "opencode", binary: "opencode", display: "OpenCode"))
|
|
22
|
+
r.register(:copilot, GenericCli.for(key: "copilot", binary: "copilot", display: "GitHub Copilot CLI"))
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
# Static Rails metadata via Prism — never boots or executes repository code (spec §10).
|
|
3
|
+
module RailsIntrospection
|
|
4
|
+
HTTP_VERBS = %w[get post put patch delete match].freeze
|
|
5
|
+
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# Returns [{ verb:, path:, kind: }] from config/routes.rb.
|
|
9
|
+
def routes(root)
|
|
10
|
+
path = File.join(root.to_s, "config", "routes.rb")
|
|
11
|
+
return [] unless File.file?(path)
|
|
12
|
+
|
|
13
|
+
require "prism"
|
|
14
|
+
found = []
|
|
15
|
+
walk = ->(node) do
|
|
16
|
+
if node.is_a?(Prism::CallNode) && node.receiver.nil?
|
|
17
|
+
name = node.name.to_s
|
|
18
|
+
first = node.arguments&.arguments&.first
|
|
19
|
+
label = case first
|
|
20
|
+
when Prism::SymbolNode then first.unescaped
|
|
21
|
+
when Prism::StringNode then first.unescaped
|
|
22
|
+
when Prism::KeywordHashNode then first.elements.first&.key&.slice
|
|
23
|
+
end
|
|
24
|
+
if %w[resources resource namespace scope].include?(name) && label
|
|
25
|
+
found << { verb: name, path: label.to_s, kind: "resource" }
|
|
26
|
+
elsif HTTP_VERBS.include?(name) && label
|
|
27
|
+
found << { verb: name.upcase, path: label.to_s, kind: "route" }
|
|
28
|
+
elsif name == "root"
|
|
29
|
+
found << { verb: "GET", path: "/", kind: "root" }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
node.compact_child_nodes.each { |c| walk.(c) }
|
|
33
|
+
end
|
|
34
|
+
walk.(Prism.parse_file(path).value)
|
|
35
|
+
found.first(300)
|
|
36
|
+
rescue StandardError, LoadError
|
|
37
|
+
[]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Table names created by migrations (static scan).
|
|
41
|
+
def migration_tables(root)
|
|
42
|
+
Dir[File.join(root.to_s, "db", "migrate", "*.rb")].sort.last(200).flat_map do |f|
|
|
43
|
+
File.read(f, 200_000).scan(/create_table\s+[:"']([a-z0-9_]+)/).flatten
|
|
44
|
+
end.uniq
|
|
45
|
+
rescue StandardError
|
|
46
|
+
[]
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
# Redacts secrets from logs, hook payloads, event fields and exports (spec §14, AT-13).
|
|
3
|
+
# Patterns are intentionally conservative; projects can add their own via configuration.
|
|
4
|
+
module Redactor
|
|
5
|
+
MASK = "[REDACTED]".freeze
|
|
6
|
+
|
|
7
|
+
DEFAULT_PATTERNS = [
|
|
8
|
+
/\bsk-ant-[A-Za-z0-9_\-]{16,}/, # Anthropic
|
|
9
|
+
/\bsk-(?:proj-)?[A-Za-z0-9_\-]{20,}/, # OpenAI
|
|
10
|
+
/\bgh[pousr]_[A-Za-z0-9]{30,}/, # GitHub tokens
|
|
11
|
+
/\bgithub_pat_[A-Za-z0-9_]{30,}/,
|
|
12
|
+
/\bxox[abposr]-[A-Za-z0-9\-]{10,}/, # Slack
|
|
13
|
+
/\bAKIA[0-9A-Z]{16}\b/, # AWS access key id
|
|
14
|
+
/\bAIza[0-9A-Za-z_\-]{35}\b/, # Google API key
|
|
15
|
+
/\beyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}/, # JWT
|
|
16
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/,
|
|
17
|
+
/(?<=:\/\/)[^\/\s:@]+:[^\/\s@]+(?=@)/ # credentials in URLs
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
# key=value / "key": "value" assignments for sensitive-looking keys
|
|
21
|
+
ASSIGNMENT = /((?:api[_-]?key|secret|token|password|passwd|authorization|bearer|private[_-]?key|access[_-]?key|lease_token)["']?\s*[:=]\s*["']?)([^\s"',;}]{6,})/i
|
|
22
|
+
|
|
23
|
+
SENSITIVE_KEYS = /\A(?:.*(?:password|passwd|secret|token|api[_-]?key|authorization|cookie|credential|private[_-]?key).*)\z/i
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def patterns
|
|
28
|
+
DEFAULT_PATTERNS + Array(Agentboard.config.redaction_patterns)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Returns [redacted_string, count_of_redactions]
|
|
32
|
+
def redact_with_count(text)
|
|
33
|
+
return [ text, 0 ] unless text.is_a?(String)
|
|
34
|
+
|
|
35
|
+
count = 0
|
|
36
|
+
out = text.dup
|
|
37
|
+
patterns.each do |re|
|
|
38
|
+
out = out.gsub(re) { count += 1; MASK }
|
|
39
|
+
end
|
|
40
|
+
out = out.gsub(ASSIGNMENT) { count += 1; "#{$1}#{MASK}" }
|
|
41
|
+
[ out, count ]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def redact(text) = redact_with_count(text).first
|
|
45
|
+
|
|
46
|
+
def secret?(text) = redact_with_count(text).last.positive?
|
|
47
|
+
|
|
48
|
+
# Deep redaction of hashes/arrays: sensitive keys are masked entirely,
|
|
49
|
+
# strings are pattern-scanned.
|
|
50
|
+
def redact_value(value)
|
|
51
|
+
case value
|
|
52
|
+
when Hash
|
|
53
|
+
value.each_with_object({}) do |(k, v), h|
|
|
54
|
+
h[k] = (k.to_s.match?(SENSITIVE_KEYS) && !v.is_a?(Hash) && !v.is_a?(Array) && !v.nil?) ? MASK : redact_value(v)
|
|
55
|
+
end
|
|
56
|
+
when Array then value.map { |v| redact_value(v) }
|
|
57
|
+
when String then redact(value)
|
|
58
|
+
else value
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
# Command result envelope (spec §24.3):
|
|
3
|
+
# { ok: true, data: {...}, correlation_id: "c-123" }
|
|
4
|
+
# { ok: false, error: { code:, message:, retryable:, details: }, correlation_id: "c-123" }
|
|
5
|
+
class Result
|
|
6
|
+
attr_reader :data, :error, :correlation_id
|
|
7
|
+
|
|
8
|
+
def self.success(data = {}, correlation_id: Ids.correlation) = new(data: data, correlation_id: correlation_id)
|
|
9
|
+
def self.failure(error, correlation_id: Ids.correlation) = new(error: error, correlation_id: correlation_id)
|
|
10
|
+
|
|
11
|
+
# Runs the block, converting Agentboard::Error into a failure envelope and any other
|
|
12
|
+
# exception into an opaque INTERNAL_ERROR (never leaking the original message).
|
|
13
|
+
def self.capture(correlation_id: Ids.correlation)
|
|
14
|
+
success(yield, correlation_id: correlation_id)
|
|
15
|
+
rescue Agentboard::Error => e
|
|
16
|
+
failure(e, correlation_id: correlation_id)
|
|
17
|
+
rescue ActiveRecord::StaleObjectError => e
|
|
18
|
+
failure(Error.version_conflict("Record was modified concurrently; reload and retry"), correlation_id: correlation_id)
|
|
19
|
+
rescue ActiveRecord::RecordNotFound
|
|
20
|
+
failure(Error.not_found("Record not found"), correlation_id: correlation_id)
|
|
21
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
22
|
+
failure(Error.validation_failed(e.record.errors.full_messages.to_sentence), correlation_id: correlation_id)
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
Agentboard.logger.error("[agentboard] #{correlation_id} #{e.class}: #{Redactor.redact(e.message)}")
|
|
25
|
+
failure(Error.internal_error("Internal error (correlation #{correlation_id})"), correlation_id: correlation_id)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(data: nil, error: nil, correlation_id:)
|
|
29
|
+
@data = data
|
|
30
|
+
@error = error
|
|
31
|
+
@correlation_id = correlation_id
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def ok? = error.nil?
|
|
35
|
+
|
|
36
|
+
def to_h
|
|
37
|
+
if ok?
|
|
38
|
+
{ ok: true, data: data, correlation_id: correlation_id }
|
|
39
|
+
else
|
|
40
|
+
{ ok: false, error: error.to_h, correlation_id: correlation_id }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def to_json(*args) = to_h.to_json(*args)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
require "socket"
|
|
2
|
+
|
|
3
|
+
module Agentboard
|
|
4
|
+
module Runtime
|
|
5
|
+
# Local runtime IPC (spec §27.2): Unix domain socket in a user-private directory (0700),
|
|
6
|
+
# socket file 0600, plus a per-daemon bearer token (0600) checked on every request.
|
|
7
|
+
# Frames are 4-byte big-endian length + JSON, with a protocol version and size limit.
|
|
8
|
+
module IPC
|
|
9
|
+
PROTOCOL_VERSION = 1
|
|
10
|
+
MAX_MESSAGE_BYTES = 1024 * 1024
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def paths(config = Agentboard.config)
|
|
15
|
+
dir = config.runtime_dir
|
|
16
|
+
id = Digest::SHA256.hexdigest(config.root_path.to_s)[0, 12]
|
|
17
|
+
{ dir: dir, socket: dir.join("#{id}.sock"), token: dir.join("#{id}.token"), pid: dir.join("#{id}.pid"), log: dir.join("#{id}.log") }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def ensure_private_dir!(dir)
|
|
21
|
+
FileUtils.mkdir_p(dir, mode: 0o700)
|
|
22
|
+
stat = File.stat(dir)
|
|
23
|
+
raise Error.forbidden("Runtime directory #{dir} is not owned by the current user") unless stat.owned?
|
|
24
|
+
|
|
25
|
+
File.chmod(0o700, dir)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def write_frame(io, obj)
|
|
29
|
+
data = JSON.generate(obj)
|
|
30
|
+
raise Error.validation_failed("IPC message too large") if data.bytesize > MAX_MESSAGE_BYTES
|
|
31
|
+
|
|
32
|
+
io.write([ data.bytesize ].pack("N") + data)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def read_frame(io, timeout: 10)
|
|
36
|
+
header = read_exact(io, 4, timeout)
|
|
37
|
+
return nil unless header
|
|
38
|
+
|
|
39
|
+
size = header.unpack1("N")
|
|
40
|
+
raise Error.validation_failed("IPC message too large") if size > MAX_MESSAGE_BYTES
|
|
41
|
+
|
|
42
|
+
JSON.parse(read_exact(io, size, timeout) || raise(Error.validation_failed("truncated IPC frame")))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def read_exact(io, n, timeout)
|
|
46
|
+
buf = +""
|
|
47
|
+
while buf.bytesize < n
|
|
48
|
+
return nil unless IO.select([ io ], nil, nil, timeout)
|
|
49
|
+
|
|
50
|
+
chunk = io.read_nonblock(n - buf.bytesize, exception: false)
|
|
51
|
+
return nil if chunk.nil?
|
|
52
|
+
next if chunk == :wait_readable
|
|
53
|
+
|
|
54
|
+
buf << chunk
|
|
55
|
+
end
|
|
56
|
+
buf
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class Server
|
|
60
|
+
def initialize(handler:, config: Agentboard.config, logger: Agentboard.logger)
|
|
61
|
+
@handler = handler
|
|
62
|
+
@paths = IPC.paths(config)
|
|
63
|
+
@logger = logger
|
|
64
|
+
@running = false
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def start
|
|
68
|
+
IPC.ensure_private_dir!(@paths[:dir])
|
|
69
|
+
File.delete(@paths[:socket]) if File.socket?(@paths[:socket])
|
|
70
|
+
@token = Ids.token
|
|
71
|
+
File.write(@paths[:token], @token, perm: 0o600)
|
|
72
|
+
File.chmod(0o600, @paths[:token])
|
|
73
|
+
old = File.umask(0o177)
|
|
74
|
+
@server = UNIXServer.new(@paths[:socket].to_s)
|
|
75
|
+
File.umask(old)
|
|
76
|
+
File.chmod(0o600, @paths[:socket])
|
|
77
|
+
@running = true
|
|
78
|
+
@thread = Thread.new { accept_loop }
|
|
79
|
+
self
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def stop
|
|
83
|
+
@running = false
|
|
84
|
+
@server&.close
|
|
85
|
+
File.delete(@paths[:socket]) if File.socket?(@paths[:socket])
|
|
86
|
+
File.delete(@paths[:token]) if File.exist?(@paths[:token])
|
|
87
|
+
rescue SystemCallError, IOError
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def accept_loop
|
|
94
|
+
while @running
|
|
95
|
+
client = @server.accept
|
|
96
|
+
Thread.new(client) { |c| serve(c) }
|
|
97
|
+
end
|
|
98
|
+
rescue IOError, Errno::EBADF
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def serve(client)
|
|
103
|
+
request = IPC.read_frame(client)
|
|
104
|
+
return unless request
|
|
105
|
+
|
|
106
|
+
response = dispatch(request)
|
|
107
|
+
IPC.write_frame(client, response)
|
|
108
|
+
rescue StandardError => e
|
|
109
|
+
IPC.write_frame(client, { "ok" => false, "error" => { "code" => "INTERNAL_ERROR", "message" => e.class.name } }) rescue nil
|
|
110
|
+
ensure
|
|
111
|
+
client.close
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def dispatch(request)
|
|
115
|
+
unless request["protocol"] == PROTOCOL_VERSION
|
|
116
|
+
return { "ok" => false, "error" => { "code" => "VALIDATION_FAILED", "message" => "unsupported protocol #{request["protocol"].inspect}" } }
|
|
117
|
+
end
|
|
118
|
+
presented = request["token"].to_s
|
|
119
|
+
unless presented.bytesize == @token.bytesize && OpenSSL.fixed_length_secure_compare(presented, @token)
|
|
120
|
+
@logger.warn("[agentboard] rejected unauthenticated IPC request")
|
|
121
|
+
return { "ok" => false, "error" => { "code" => "UNAUTHORIZED", "message" => "invalid runtime credential" } }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
result = Result.capture(correlation_id: request["correlation_id"].presence || Ids.correlation) do
|
|
125
|
+
@handler.call(request["op"].to_s, request["params"] || {})
|
|
126
|
+
end
|
|
127
|
+
JSON.parse(result.to_json)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
class Client
|
|
132
|
+
def initialize(config: Agentboard.config)
|
|
133
|
+
@paths = IPC.paths(config)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def available? = File.socket?(@paths[:socket]) && File.exist?(@paths[:token])
|
|
137
|
+
|
|
138
|
+
def call(op, params = {}, timeout: 30)
|
|
139
|
+
raise Error.provider_unavailable("Runtime daemon is not running (agentboard daemon start)") unless available?
|
|
140
|
+
|
|
141
|
+
token = File.read(@paths[:token]).strip
|
|
142
|
+
UNIXSocket.open(@paths[:socket].to_s) do |sock|
|
|
143
|
+
IPC.write_frame(sock, { "protocol" => PROTOCOL_VERSION, "op" => op, "params" => params, "token" => token, "correlation_id" => Ids.correlation })
|
|
144
|
+
IPC.read_frame(sock, timeout: timeout) || raise(Error.provider_unavailable("No response from runtime"))
|
|
145
|
+
end
|
|
146
|
+
rescue Errno::ECONNREFUSED, Errno::ENOENT
|
|
147
|
+
raise Error.provider_unavailable("Runtime daemon is not reachable")
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Daemon lifecycle helpers: PID record with liveness validation (never trust a PID file alone).
|
|
153
|
+
module DaemonControl
|
|
154
|
+
module_function
|
|
155
|
+
|
|
156
|
+
def status(config = Agentboard.config)
|
|
157
|
+
paths = IPC.paths(config)
|
|
158
|
+
pid = File.exist?(paths[:pid]) ? File.read(paths[:pid]).to_i : nil
|
|
159
|
+
alive = pid && pid.positive? && Subprocess.alive?(pid)
|
|
160
|
+
health = nil
|
|
161
|
+
if alive && IPC::Client.new(config: config).available?
|
|
162
|
+
response = IPC::Client.new(config: config).call("runtime.health", {}, timeout: 3) rescue nil
|
|
163
|
+
health = response && response["ok"] ? response["data"] : nil
|
|
164
|
+
end
|
|
165
|
+
{ running: !!(alive && health), pid: alive ? pid : nil, stale_pid_file: pid && !alive, health: health, socket: paths[:socket].to_s, log: paths[:log].to_s }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def write_pid!(config = Agentboard.config)
|
|
169
|
+
paths = IPC.paths(config)
|
|
170
|
+
IPC.ensure_private_dir!(paths[:dir])
|
|
171
|
+
lock = File.open(paths[:pid], File::RDWR | File::CREAT, 0o600)
|
|
172
|
+
raise Error.workspace_conflict("Another AgentBoard daemon is running for this project") unless lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
173
|
+
|
|
174
|
+
lock.truncate(0)
|
|
175
|
+
lock.write(::Process.pid.to_s)
|
|
176
|
+
lock.flush
|
|
177
|
+
lock # keep open to hold the lock for the daemon lifetime
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def stop(config = Agentboard.config, timeout: 20)
|
|
181
|
+
st = status(config)
|
|
182
|
+
return false unless st[:pid]
|
|
183
|
+
|
|
184
|
+
::Process.kill("TERM", st[:pid])
|
|
185
|
+
deadline = Subprocess.monotonic + timeout
|
|
186
|
+
sleep 0.1 while Subprocess.alive?(st[:pid]) && Subprocess.monotonic < deadline
|
|
187
|
+
!Subprocess.alive?(st[:pid])
|
|
188
|
+
rescue Errno::ESRCH
|
|
189
|
+
true
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
require "agentboard/runtime/ipc"
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
# Pure transition tables. Guards that need persistence (leases, evidence, approvals)
|
|
3
|
+
# are enforced by the domain services in app/services/agentboard.
|
|
4
|
+
module StateMachines
|
|
5
|
+
module Ticket
|
|
6
|
+
STATES = %w[backlog ready claimed in_progress testing review done blocked cancelled reopened].freeze
|
|
7
|
+
TERMINAL = %w[done cancelled].freeze
|
|
8
|
+
NONTERMINAL = (STATES - TERMINAL).freeze
|
|
9
|
+
BOARD_COLUMNS = %w[backlog ready claimed in_progress testing review done].freeze
|
|
10
|
+
|
|
11
|
+
# spec §24.1 — anything not listed is INVALID_TRANSITION
|
|
12
|
+
TRANSITIONS = {
|
|
13
|
+
"backlog" => %w[ready blocked cancelled],
|
|
14
|
+
"ready" => %w[claimed blocked cancelled backlog],
|
|
15
|
+
"claimed" => %w[in_progress ready blocked cancelled],
|
|
16
|
+
"in_progress" => %w[testing blocked cancelled],
|
|
17
|
+
"testing" => %w[review in_progress blocked cancelled],
|
|
18
|
+
"review" => %w[done in_progress blocked cancelled],
|
|
19
|
+
"blocked" => %w[ready cancelled],
|
|
20
|
+
"reopened" => %w[ready blocked cancelled],
|
|
21
|
+
"done" => %w[reopened],
|
|
22
|
+
"cancelled" => []
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
LABELS = {
|
|
26
|
+
"backlog" => "Backlog", "ready" => "Ready", "claimed" => "Claimed", "in_progress" => "In progress",
|
|
27
|
+
"testing" => "Testing", "review" => "Review", "done" => "Done", "blocked" => "Blocked",
|
|
28
|
+
"cancelled" => "Cancelled", "reopened" => "Reopened"
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
def allowed?(from, to) = TRANSITIONS.fetch(from.to_s, []).include?(to.to_s)
|
|
34
|
+
def next_states(from) = TRANSITIONS.fetch(from.to_s, [])
|
|
35
|
+
|
|
36
|
+
def assert!(from, to)
|
|
37
|
+
return if allowed?(from, to)
|
|
38
|
+
|
|
39
|
+
raise Error.invalid_transition("Ticket cannot move from #{from} to #{to}", from: from, to: to)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
module Execution
|
|
44
|
+
STATES = %w[queued preparing running waiting_for_input waiting_for_permission verifying completed failed cancelled interrupted].freeze
|
|
45
|
+
TERMINAL = %w[completed failed cancelled interrupted].freeze
|
|
46
|
+
ACTIVE = (STATES - TERMINAL).freeze
|
|
47
|
+
|
|
48
|
+
# spec §24.2
|
|
49
|
+
TRANSITIONS = {
|
|
50
|
+
"queued" => %w[preparing cancelled failed],
|
|
51
|
+
"preparing" => %w[running failed cancelled interrupted],
|
|
52
|
+
"running" => %w[verifying waiting_for_input waiting_for_permission failed cancelled interrupted],
|
|
53
|
+
"waiting_for_input" => %w[running failed cancelled interrupted],
|
|
54
|
+
"waiting_for_permission" => %w[running failed cancelled interrupted],
|
|
55
|
+
"verifying" => %w[completed failed cancelled interrupted],
|
|
56
|
+
"completed" => [], "failed" => [], "cancelled" => [], "interrupted" => []
|
|
57
|
+
}.freeze
|
|
58
|
+
|
|
59
|
+
module_function
|
|
60
|
+
|
|
61
|
+
def allowed?(from, to) = TRANSITIONS.fetch(from.to_s, []).include?(to.to_s)
|
|
62
|
+
def terminal?(state) = TERMINAL.include?(state.to_s)
|
|
63
|
+
|
|
64
|
+
def assert!(from, to)
|
|
65
|
+
return if allowed?(from, to)
|
|
66
|
+
|
|
67
|
+
raise Error.invalid_transition("Execution cannot move from #{from} to #{to}", from: from, to: to)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|