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.
Files changed (159) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +62 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +316 -0
  5. data/Rakefile +6 -0
  6. data/app/assets/stylesheets/agentboard/application.css +510 -0
  7. data/app/controllers/agentboard/agents_controller.rb +10 -0
  8. data/app/controllers/agentboard/analytics_controller.rb +37 -0
  9. data/app/controllers/agentboard/application_controller.rb +90 -0
  10. data/app/controllers/agentboard/approvals_controller.rb +15 -0
  11. data/app/controllers/agentboard/assets_controller.rb +32 -0
  12. data/app/controllers/agentboard/board_controller.rb +46 -0
  13. data/app/controllers/agentboard/captures_controller.rb +17 -0
  14. data/app/controllers/agentboard/comments_controller.rb +9 -0
  15. data/app/controllers/agentboard/criteria_controller.rb +17 -0
  16. data/app/controllers/agentboard/dependencies_controller.rb +20 -0
  17. data/app/controllers/agentboard/executions_controller.rb +48 -0
  18. data/app/controllers/agentboard/milestones_controller.rb +23 -0
  19. data/app/controllers/agentboard/pulse_controller.rb +12 -0
  20. data/app/controllers/agentboard/sessions_controller.rb +19 -0
  21. data/app/controllers/agentboard/settings_controller.rb +9 -0
  22. data/app/controllers/agentboard/tickets_controller.rb +139 -0
  23. data/app/controllers/agentboard/workflow_runs_controller.rb +16 -0
  24. data/app/controllers/agentboard/workflows_controller.rb +62 -0
  25. data/app/helpers/agentboard/application_helper.rb +196 -0
  26. data/app/javascript/agentboard/application.js +227 -0
  27. data/app/javascript/agentboard/capture.js +299 -0
  28. data/app/jobs/agentboard/application_job.rb +4 -0
  29. data/app/models/agentboard/acceptance_criterion.rb +23 -0
  30. data/app/models/agentboard/agent_profile.rb +16 -0
  31. data/app/models/agentboard/agent_session.rb +30 -0
  32. data/app/models/agentboard/application_record.rb +13 -0
  33. data/app/models/agentboard/approval.rb +29 -0
  34. data/app/models/agentboard/artifact.rb +11 -0
  35. data/app/models/agentboard/comment.rb +9 -0
  36. data/app/models/agentboard/event.rb +37 -0
  37. data/app/models/agentboard/execution.rb +51 -0
  38. data/app/models/agentboard/idempotency_key.rb +5 -0
  39. data/app/models/agentboard/milestone.rb +7 -0
  40. data/app/models/agentboard/outbox_message.rb +11 -0
  41. data/app/models/agentboard/project.rb +43 -0
  42. data/app/models/agentboard/repository_snapshot.rb +5 -0
  43. data/app/models/agentboard/session_ticket_link.rb +18 -0
  44. data/app/models/agentboard/ticket.rb +81 -0
  45. data/app/models/agentboard/ticket_dependency.rb +34 -0
  46. data/app/models/agentboard/ticket_lease.rb +11 -0
  47. data/app/models/agentboard/usage_record.rb +10 -0
  48. data/app/models/agentboard/verification_result.rb +12 -0
  49. data/app/models/agentboard/webhook_delivery.rb +7 -0
  50. data/app/models/agentboard/workflow_definition.rb +20 -0
  51. data/app/models/agentboard/workflow_run.rb +21 -0
  52. data/app/models/agentboard/workflow_step.rb +12 -0
  53. data/app/services/agentboard/approvals.rb +76 -0
  54. data/app/services/agentboard/captures.rb +113 -0
  55. data/app/services/agentboard/completion_policy.rb +117 -0
  56. data/app/services/agentboard/current.rb +5 -0
  57. data/app/services/agentboard/data_governance.rb +68 -0
  58. data/app/services/agentboard/doctor.rb +133 -0
  59. data/app/services/agentboard/event_store.rb +88 -0
  60. data/app/services/agentboard/evidence.rb +52 -0
  61. data/app/services/agentboard/executions.rb +198 -0
  62. data/app/services/agentboard/hook_ingestion.rb +100 -0
  63. data/app/services/agentboard/idempotency.rb +28 -0
  64. data/app/services/agentboard/lease_service.rb +107 -0
  65. data/app/services/agentboard/mcp_server.rb +138 -0
  66. data/app/services/agentboard/mcp_tools.rb +341 -0
  67. data/app/services/agentboard/outbox.rb +44 -0
  68. data/app/services/agentboard/pull_requests.rb +65 -0
  69. data/app/services/agentboard/runtime/execution_runner.rb +352 -0
  70. data/app/services/agentboard/runtime/ipc_handler.rb +55 -0
  71. data/app/services/agentboard/runtime/reconciler.rb +42 -0
  72. data/app/services/agentboard/runtime/supervisor.rb +96 -0
  73. data/app/services/agentboard/session_association.rb +44 -0
  74. data/app/services/agentboard/session_correlation.rb +56 -0
  75. data/app/services/agentboard/session_credential.rb +44 -0
  76. data/app/services/agentboard/ticket_advisor.rb +60 -0
  77. data/app/services/agentboard/tickets.rb +218 -0
  78. data/app/services/agentboard/verification_service.rb +60 -0
  79. data/app/services/agentboard/workflow_engine.rb +261 -0
  80. data/app/views/agentboard/agents/index.html.erb +51 -0
  81. data/app/views/agentboard/analytics/show.html.erb +78 -0
  82. data/app/views/agentboard/approvals/_approval.html.erb +27 -0
  83. data/app/views/agentboard/approvals/index.html.erb +31 -0
  84. data/app/views/agentboard/board/_card.html.erb +36 -0
  85. data/app/views/agentboard/board/show.html.erb +73 -0
  86. data/app/views/agentboard/executions/index.html.erb +33 -0
  87. data/app/views/agentboard/executions/show.html.erb +126 -0
  88. data/app/views/agentboard/milestones/index.html.erb +28 -0
  89. data/app/views/agentboard/settings/show.html.erb +33 -0
  90. data/app/views/agentboard/shared/_dag.html.erb +24 -0
  91. data/app/views/agentboard/tickets/_form.html.erb +38 -0
  92. data/app/views/agentboard/tickets/edit.html.erb +5 -0
  93. data/app/views/agentboard/tickets/index.html.erb +51 -0
  94. data/app/views/agentboard/tickets/new.html.erb +6 -0
  95. data/app/views/agentboard/tickets/show.html.erb +343 -0
  96. data/app/views/agentboard/tickets/suggestions.html.erb +21 -0
  97. data/app/views/agentboard/workflow_runs/show.html.erb +28 -0
  98. data/app/views/agentboard/workflows/index.html.erb +49 -0
  99. data/app/views/agentboard/workflows/show.html.erb +15 -0
  100. data/app/views/layouts/agentboard/application.html.erb +93 -0
  101. data/config/routes.rb +49 -0
  102. data/db/migrate/20261114000001_create_agentboard_core.rb +144 -0
  103. data/db/migrate/20261114000002_create_agentboard_runtime.rb +213 -0
  104. data/db/migrate/20261115000001_create_agentboard_webhook_deliveries.rb +18 -0
  105. data/exe/agentboard +7 -0
  106. data/lib/agentboard/agent_container.rb +124 -0
  107. data/lib/agentboard/authorization.rb +58 -0
  108. data/lib/agentboard/capture/middleware.rb +54 -0
  109. data/lib/agentboard/capture.rb +57 -0
  110. data/lib/agentboard/cli.rb +818 -0
  111. data/lib/agentboard/command_policy.rb +44 -0
  112. data/lib/agentboard/configuration.rb +160 -0
  113. data/lib/agentboard/container_sandbox.rb +65 -0
  114. data/lib/agentboard/context_builder.rb +243 -0
  115. data/lib/agentboard/egress_proxy.rb +95 -0
  116. data/lib/agentboard/engine.rb +27 -0
  117. data/lib/agentboard/errors.rb +42 -0
  118. data/lib/agentboard/event_schema.rb +37 -0
  119. data/lib/agentboard/extensions.rb +58 -0
  120. data/lib/agentboard/git/repository.rb +92 -0
  121. data/lib/agentboard/git/worktree_manager.rb +107 -0
  122. data/lib/agentboard/git.rb +4 -0
  123. data/lib/agentboard/ids.rb +33 -0
  124. data/lib/agentboard/json_log_formatter.rb +28 -0
  125. data/lib/agentboard/path_guard.rb +27 -0
  126. data/lib/agentboard/process_ancestry.rb +40 -0
  127. data/lib/agentboard/providers/base.rb +107 -0
  128. data/lib/agentboard/providers/claude_code.rb +219 -0
  129. data/lib/agentboard/providers/codex.rb +177 -0
  130. data/lib/agentboard/providers/compatibility/claude.yml +13 -0
  131. data/lib/agentboard/providers/compatibility/codex.yml +10 -0
  132. data/lib/agentboard/providers/config_editor.rb +106 -0
  133. data/lib/agentboard/providers/cursor.rb +40 -0
  134. data/lib/agentboard/providers/fake.rb +59 -0
  135. data/lib/agentboard/providers/fake_agent_script.rb +47 -0
  136. data/lib/agentboard/providers/generic_cli.rb +35 -0
  137. data/lib/agentboard/providers/registry.rb +31 -0
  138. data/lib/agentboard/providers/types.rb +32 -0
  139. data/lib/agentboard/providers.rb +26 -0
  140. data/lib/agentboard/rails_introspection.rb +49 -0
  141. data/lib/agentboard/redactor.rb +62 -0
  142. data/lib/agentboard/result.rb +46 -0
  143. data/lib/agentboard/runtime/ipc.rb +193 -0
  144. data/lib/agentboard/runtime.rb +1 -0
  145. data/lib/agentboard/state_machines.rb +71 -0
  146. data/lib/agentboard/subprocess.rb +168 -0
  147. data/lib/agentboard/templates/Dockerfile.agent +21 -0
  148. data/lib/agentboard/verification.rb +79 -0
  149. data/lib/agentboard/version.rb +3 -0
  150. data/lib/agentboard/workflows/condition.rb +166 -0
  151. data/lib/agentboard/workflows/definition.rb +208 -0
  152. data/lib/agentboard/workflows.rb +2 -0
  153. data/lib/agentboard.rb +73 -0
  154. data/lib/generators/agentboard/install/install_generator.rb +67 -0
  155. data/lib/generators/agentboard/install/templates/config.yml +81 -0
  156. data/lib/generators/agentboard/install/templates/feature_delivery.yml +25 -0
  157. data/lib/generators/agentboard/install/templates/initializer.rb +23 -0
  158. data/lib/tasks/agentboard_tasks.rake +6 -0
  159. metadata +285 -0
@@ -0,0 +1,37 @@
1
+ module Agentboard
2
+ # Versioned event payloads (spec §11, §23.4). Historical events are never rewritten:
3
+ # readers call `EventSchema.upcast` to see old payloads in the current shape.
4
+ #
5
+ # EventSchema.register("agent.execution.usage", from: 1) { |p| … returns v2 payload … }
6
+ module EventSchema
7
+ @current = Hash.new(1)
8
+ @upcasters = {}
9
+
10
+ class << self
11
+ def current_version(type) = @current[type.to_s]
12
+
13
+ def register(type, from:, &block)
14
+ @upcasters[[ type.to_s, from ]] = block
15
+ @current[type.to_s] = [ @current[type.to_s], from + 1 ].max
16
+ end
17
+
18
+ # Applies successive upcasters from `version` to the current version.
19
+ def upcast(type, version, payload)
20
+ version = version.to_i
21
+ data = payload.is_a?(Hash) ? payload.dup : {}
22
+ while version < current_version(type)
23
+ step = @upcasters[[ type.to_s, version ]] or break
24
+ data = step.call(data)
25
+ version += 1
26
+ end
27
+ data
28
+ end
29
+ end
30
+
31
+ # v2 (AgentBoard 0.2): monetary amounts carry an explicit currency; unknown stays nil.
32
+ register("agent.execution.usage", from: 1) do |p|
33
+ usd = p.delete("reported_cost_usd")
34
+ p.merge("reported_cost" => usd, "currency" => usd.nil? ? nil : "USD")
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,58 @@
1
+ module Agentboard
2
+ # Open-core extension points (spec §16). The MIT core works fully offline for a single
3
+ # local user. Commercial extensions (agentboard-pro) register here and unlock features
4
+ # according to their own licence; the core never phones home or needs an account.
5
+ module Extensions
6
+ FEATURES = {
7
+ team_mode: "Team mode: host-app authentication, roles and separation of duties",
8
+ advanced_workflows: "Advanced workflows: conditions, retries, per-step timeouts, external and pull-request steps",
9
+ governance: "Analytics & governance: operational metrics, support bundle, scheduled retention, audit export",
10
+ capture: "Capture widget beyond development (team use on staging) and region screenshots",
11
+ git_hosting: "Git hosting integrations (GitHub, GitLab, Bitbucket): signed webhooks, merge-aware completion, pull-request creation"
12
+ }.freeze
13
+
14
+ Extension = Struct.new(:name, :version, :features, :status, keyword_init: true) do
15
+ def enabled_features = Array(features.respond_to?(:call) ? features.call : features).map(&:to_sym)
16
+ def status_info = status.respond_to?(:call) ? status.call : status
17
+ end
18
+
19
+ @extensions = {}
20
+ @hooks = Hash.new { |h, k| h[k] = [] }
21
+ @step_handlers = {}
22
+
23
+ class << self
24
+ attr_reader :extensions, :step_handlers
25
+
26
+ def register(name, version:, features:, status: nil)
27
+ @extensions[name.to_s] = Extension.new(name: name.to_s, version: version, features: features, status: status)
28
+ end
29
+
30
+ def unregister(name) = @extensions.delete(name.to_s)
31
+
32
+ def feature?(key) = @extensions.values.any? { |e| e.enabled_features.include?(key.to_sym) }
33
+
34
+ def require!(key)
35
+ return true if feature?(key)
36
+
37
+ raise Error.policy_denied("#{FEATURES.fetch(key.to_sym)} requires AgentBoard Pro with a valid licence", feature: key.to_s)
38
+ end
39
+
40
+ # Hooks: on(:event) { |*args| … }; run(:event, *args) calls every subscriber.
41
+ def on(event, &block) = (@hooks[event.to_sym] << block)
42
+ def run(event, *args, **kwargs) = @hooks[event.to_sym].map { |h| h.call(*args, **kwargs) }
43
+ def hooks?(event) = @hooks[event.to_sym].any?
44
+
45
+ # Workflow step types provided by extensions.
46
+ def register_step_handler(type, &block) = (@step_handlers[type.to_s] = block)
47
+ def step_handler(type) = @step_handlers[type.to_s]
48
+
49
+ def reset_hooks!
50
+ @hooks.clear
51
+ @step_handlers.clear
52
+ end
53
+ end
54
+ end
55
+
56
+ def self.feature?(key) = Extensions.feature?(key)
57
+ def self.require_feature!(key) = Extensions.require!(key)
58
+ end
@@ -0,0 +1,92 @@
1
+ module Agentboard
2
+ module Git
3
+ # Thin, argv-only wrapper over the git CLI. Never interpolates into a shell.
4
+ class Repository
5
+ class CommandFailed < StandardError; end
6
+
7
+ attr_reader :root
8
+
9
+ def initialize(root)
10
+ @root = File.expand_path(root.to_s)
11
+ end
12
+
13
+ def git(*args, chdir: root, allow_failure: false, env: {})
14
+ out, err, status = Open3.capture3(safe_env.merge(env), "git", *args.map(&:to_s), chdir: chdir)
15
+ unless status.success? || allow_failure
16
+ raise CommandFailed, "git #{args.first} failed: #{Redactor.redact(err.strip)[0, 500]}"
17
+ end
18
+
19
+ allow_failure ? [ out, err, status ] : out
20
+ end
21
+
22
+ def repository? = git("rev-parse", "--is-inside-work-tree", allow_failure: true)[2].success?
23
+ def toplevel = git("rev-parse", "--show-toplevel").strip
24
+ def head_sha(chdir: root) = git("rev-parse", "HEAD", chdir: chdir).strip
25
+ def current_branch(chdir: root) = git("rev-parse", "--abbrev-ref", "HEAD", chdir: chdir).strip
26
+
27
+ def default_branch
28
+ out, _e, st = git("symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD", allow_failure: true)
29
+ return out.strip.sub(%r{\Aorigin/}, "") if st.success? && out.present?
30
+
31
+ %w[main master trunk].find { |b| git("rev-parse", "--verify", "--quiet", "refs/heads/#{b}", allow_failure: true)[2].success? } || current_branch
32
+ rescue CommandFailed
33
+ "main"
34
+ end
35
+
36
+ def clean?(chdir: root) = git("status", "--porcelain", chdir: chdir).strip.empty?
37
+
38
+ # Returns the full SHA if the object exists as a commit in this repository.
39
+ def resolve_commit(sha)
40
+ return nil unless sha.to_s.match?(/\A[0-9a-fA-F]{4,64}\z/)
41
+
42
+ out, _e, st = git("rev-parse", "--verify", "--quiet", "#{sha}^{commit}", allow_failure: true)
43
+ st.success? ? out.strip : nil
44
+ end
45
+
46
+ def commit_subject(sha) = git("log", "-1", "--format=%s", sha).strip
47
+ def ancestor?(ancestor, descendant) = git("merge-base", "--is-ancestor", ancestor, descendant, allow_failure: true)[2].success?
48
+
49
+ def tracked_files(chdir: root) = git("ls-files", "-z", chdir: chdir).split("\0")
50
+
51
+ def changed_files(base, head, chdir: root)
52
+ git("diff", "--name-only", "-z", "#{base}..#{head}", chdir: chdir).split("\0")
53
+ end
54
+
55
+ def diff_stat(base, head, chdir: root)
56
+ out = git("diff", "--shortstat", "#{base}..#{head}", chdir: chdir)
57
+ {
58
+ files: out[/(\d+) files? changed/, 1].to_i,
59
+ insertions: out[/(\d+) insertions?/, 1].to_i,
60
+ deletions: out[/(\d+) deletions?/, 1].to_i
61
+ }
62
+ end
63
+
64
+ def log(limit: 20, chdir: root, range: nil)
65
+ args = [ "log", "-#{limit.to_i}", "--format=%H%x1f%an%x1f%aI%x1f%s" ]
66
+ args << range if range
67
+ git(*args, chdir: chdir).lines.map do |line|
68
+ sha, author, date, subject = line.chomp.split("\x1f", 4)
69
+ { sha: sha, author: author, date: date, subject: subject }
70
+ end
71
+ rescue CommandFailed
72
+ []
73
+ end
74
+
75
+ def worktrees
76
+ git("worktree", "list", "--porcelain").split("\n\n").map do |block|
77
+ block.lines.each_with_object({}) do |line, h|
78
+ k, v = line.chomp.split(" ", 2)
79
+ h[k] = v || true
80
+ end
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ # Prevent user/global hooks or pagers from altering behaviour.
87
+ def safe_env
88
+ { "GIT_TERMINAL_PROMPT" => "0", "GIT_PAGER" => "cat", "LC_ALL" => "C" }
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,107 @@
1
+ module Agentboard
2
+ module Git
3
+ # Creates one dedicated branch + worktree per execution (spec §8, §29.2):
4
+ # .agentboard/worktrees/AB-142-exec-001 on branch agentboard/AB-142/exec-001
5
+ # Worktrees are source-control isolation only — they are NOT security sandboxes.
6
+ class WorktreeManager
7
+ Worktree = Struct.new(:path, :branch, :base_sha, keyword_init: true)
8
+
9
+ attr_reader :repository, :workspace
10
+
11
+ def initialize(repository:, workspace:)
12
+ @repository = repository
13
+ @workspace = File.expand_path(workspace.to_s)
14
+ end
15
+
16
+ def self.name_for(ticket_key, attempt)
17
+ raise Error.validation_failed("Invalid ticket key") unless ticket_key.to_s.match?(/\A[A-Z][A-Z0-9_-]*-\d+\z/)
18
+
19
+ suffix = "exec-#{attempt.to_i.to_s.rjust(3, "0")}"
20
+ [ "#{ticket_key}-#{suffix}", "agentboard/#{ticket_key}/#{suffix}" ]
21
+ end
22
+
23
+ def create!(ticket_key:, attempt:, base_ref: "HEAD")
24
+ dir_name, branch = self.class.name_for(ticket_key, attempt)
25
+ FileUtils.mkdir_p(workspace, mode: 0o700)
26
+ ensure_ignored!
27
+ path = PathGuard.within!(workspace, dir_name)
28
+ raise Error.workspace_conflict("Worktree #{dir_name} already exists") if File.exist?(path)
29
+
30
+ base_sha = repository.git("rev-parse", "--verify", "#{base_ref}^{commit}").strip
31
+ repository.git("worktree", "add", "-b", branch, path, base_sha)
32
+ Worktree.new(path: path, branch: branch, base_sha: base_sha)
33
+ rescue Repository::CommandFailed => e
34
+ raise Error.workspace_conflict(e.message)
35
+ end
36
+
37
+ def head_sha(path) = repository.head_sha(chdir: guard(path))
38
+ def clean?(path) = repository.clean?(chdir: guard(path))
39
+
40
+ # Commits any pending work in the worktree on behalf of the execution, so the result is
41
+ # bound to an immutable SHA. Returns the new head.
42
+ def snapshot!(path, message:)
43
+ dir = guard(path)
44
+ return head_sha(dir) if repository.clean?(chdir: dir)
45
+
46
+ repository.git("add", "-A", chdir: dir)
47
+ repository.git("-c", "user.name=AgentBoard", "-c", "user.email=agentboard@localhost", "commit", "--no-verify", "-m", message, chdir: dir)
48
+ head_sha(dir)
49
+ end
50
+
51
+ def changes(path, base_sha)
52
+ dir = guard(path)
53
+ head = head_sha(dir)
54
+ { head_sha: head, files: repository.changed_files(base_sha, head, chdir: dir), stat: repository.diff_stat(base_sha, head, chdir: dir) }
55
+ end
56
+
57
+ # Removal requires: no uncommitted changes unless force (explicit confirmation).
58
+ def remove!(path, force: false, delete_branch: false)
59
+ dir = guard(path)
60
+ return false unless File.directory?(dir)
61
+ if !force && !repository.clean?(chdir: dir)
62
+ raise Error.workspace_conflict("Worktree has uncommitted changes; confirm with --force")
63
+ end
64
+
65
+ branch = repository.current_branch(chdir: dir)
66
+ args = [ "worktree", "remove" ]
67
+ args << "--force" if force
68
+ repository.git(*args, dir)
69
+ repository.git("branch", "-D", branch, allow_failure: true) if delete_branch && branch.start_with?("agentboard/")
70
+ true
71
+ end
72
+
73
+ def prune! = repository.git("worktree", "prune")
74
+
75
+ def list
76
+ # Compare real paths: on macOS /var and /tmp are symlinks into /private.
77
+ real = File.exist?(workspace) ? File.realpath(workspace) : workspace
78
+ repository.worktrees.select { |w| w["worktree"].to_s.start_with?(real) || w["worktree"].to_s.start_with?(workspace) }
79
+ end
80
+
81
+ private
82
+
83
+ def guard(path) = PathGuard.within!(workspace, path)
84
+
85
+ def ensure_ignored!
86
+ exclude = File.join(repository.git("rev-parse", "--git-common-dir").strip.then { |d| File.expand_path(d, repository.root) }, "info", "exclude")
87
+ top = File.realpath(repository.toplevel)
88
+ ws = File.realpath(workspace)
89
+ return unless ws.start_with?(top + "/") # workspace outside repo
90
+ rel = ws.delete_prefix(top + "/")
91
+
92
+ # Local AgentBoard state never shows up as untracked files in the main checkout.
93
+ wanted = [ "/#{rel}/" ]
94
+ state_dir = File.dirname(ws)
95
+ if File.basename(state_dir) == ".agentboard"
96
+ base = state_dir.delete_prefix(top + "/")
97
+ wanted += %w[logs cache spool manifests].map { |d| "/#{base}/#{d}/" }
98
+ end
99
+ FileUtils.mkdir_p(File.dirname(exclude))
100
+ lines = File.exist?(exclude) ? File.read(exclude).lines.map(&:strip) : []
101
+ missing = wanted - lines
102
+ File.open(exclude, "a") { |f| missing.each { |l| f.puts(l) } } if missing.any?
103
+ end
104
+ end
105
+ end
106
+ end
107
+ require "fileutils"
@@ -0,0 +1,4 @@
1
+ require "open3"
2
+ require "agentboard/git/repository"
3
+ require "agentboard/git/worktree_manager"
4
+ require "agentboard/path_guard"
@@ -0,0 +1,33 @@
1
+ module Agentboard
2
+ # Application-generated identifiers. UUIDv7 is time-ordered, portable across
3
+ # PostgreSQL and SQLite, and safe to generate outside the database (spec §23.1).
4
+ module Ids
5
+ module_function
6
+
7
+ def uuid = SecureRandom.uuid_v7
8
+ def correlation = "c-#{SecureRandom.hex(8)}"
9
+ def token(bytes = 32) = SecureRandom.urlsafe_base64(bytes)
10
+ def digest(value) = Digest::SHA256.hexdigest(value.to_s)
11
+
12
+ # Constant-time comparison of a presented secret with a stored digest.
13
+ def secure_match?(presented, stored_digest)
14
+ return false if presented.nil? || stored_digest.nil?
15
+
16
+ a = digest(presented)
17
+ a.bytesize == stored_digest.bytesize && OpenSSL.fixed_length_secure_compare(a, stored_digest)
18
+ end
19
+
20
+ # Canonical JSON (sorted keys) for digests of definitions and approval actions.
21
+ def canonical_json(value)
22
+ case value
23
+ when Hash then "{" + value.map { |k, v| [ k.to_s, v ] }.sort_by(&:first).map { |k, v| "#{k.to_json}:#{canonical_json(v)}" }.join(",") + "}"
24
+ when Array then "[" + value.map { |v| canonical_json(v) }.join(",") + "]"
25
+ when Symbol then value.to_s.to_json
26
+ else value.to_json
27
+ end
28
+ end
29
+
30
+ def canonical_digest(value) = digest(canonical_json(value))
31
+ end
32
+ end
33
+ require "openssl"
@@ -0,0 +1,28 @@
1
+ require "logger"
2
+
3
+ module Agentboard
4
+ # Structured log records (spec §32.1): timestamp, level, component, correlation and
5
+ # execution ids, event name. Messages are redacted; payloads are never logged.
6
+ class JsonLogFormatter < ::Logger::Formatter
7
+ def initialize(component: "runtime")
8
+ super()
9
+ @component = component
10
+ end
11
+
12
+ def call(severity, time, _progname, msg)
13
+ record = { ts: time.utc.iso8601(3), level: severity.downcase, component: @component, pid: ::Process.pid }
14
+ if defined?(Agentboard::Current)
15
+ record[:correlation_id] = Agentboard::Current.correlation_id if Agentboard::Current.correlation_id
16
+ end
17
+ record[:thread] = Thread.current.name if Thread.current.name
18
+ text = msg.is_a?(Exception) ? "#{msg.class}: #{msg.message}" : msg.to_s
19
+ if (m = text.match(/\A\[agentboard(?:\.(\w+))?\]\s*/))
20
+ record[:component] = m[1] if m[1]
21
+ text = m.post_match
22
+ end
23
+ record[:execution_id] = text[/\b([0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12})\b/, 1] if text.include?("execution")
24
+ record[:message] = Redactor.redact(text)[0, 4000]
25
+ JSON.generate(record.compact) + "\n"
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,27 @@
1
+ module Agentboard
2
+ # Canonicalizes paths and rejects traversal or symlink escape outside an allowed root
3
+ # (spec §29.2, §30.2). Used for every AgentBoard-managed file operation.
4
+ module PathGuard
5
+ module_function
6
+
7
+ def within!(root, path)
8
+ root_real = File.realpath(root.to_s)
9
+ candidate = File.expand_path(path.to_s, root_real)
10
+ # Resolve the deepest existing ancestor so symlinks anywhere in the chain are followed.
11
+ probe = candidate
12
+ probe = File.dirname(probe) until File.exist?(probe) || probe == "/"
13
+ resolved = File.join(File.realpath(probe), candidate.delete_prefix(probe))
14
+ resolved = File.expand_path(resolved)
15
+ unless resolved == root_real || resolved.start_with?(root_real + File::SEPARATOR)
16
+ raise Error.validation_failed("Path escapes the allowed workspace", path: path.to_s)
17
+ end
18
+
19
+ resolved
20
+ end
21
+
22
+ def safe_relative?(path)
23
+ p = path.to_s
24
+ !p.empty? && !p.start_with?("/") && !p.split(%r{[/\\]}).include?("..") && !p.include?("\0")
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,40 @@
1
+ module Agentboard
2
+ # Finds the agent process (claude, codex, cursor-agent…) that spawned the current process.
3
+ # MCP servers and hook commands are both children of the same agent process, which lets
4
+ # AgentBoard correlate an MCP session with the provider's hook-reported session without
5
+ # trusting any identifier supplied by the agent. Works on macOS and Linux (`ps`).
6
+ module ProcessAncestry
7
+ AGENT_COMMANDS = /\A(claude|codex|cursor-agent|cursor|gemini|opencode|copilot)\z/i
8
+ MAX_DEPTH = 12
9
+
10
+ Agent = Struct.new(:pid, :command, :fingerprint, keyword_init: true) do
11
+ def to_h = { "agent_pid" => pid, "agent_command" => command, "agent_fingerprint" => fingerprint }
12
+ end
13
+
14
+ module_function
15
+
16
+ def find_agent(start_pid = ::Process.ppid)
17
+ pid = start_pid
18
+ MAX_DEPTH.times do
19
+ break if pid.nil? || pid <= 1
20
+
21
+ ppid, comm = info(pid)
22
+ name = File.basename(comm.to_s)
23
+ return Agent.new(pid: pid, command: name, fingerprint: Subprocess.fingerprint(pid)) if name.match?(AGENT_COMMANDS)
24
+
25
+ pid = ppid
26
+ end
27
+ nil
28
+ end
29
+
30
+ def info(pid)
31
+ out, _e, st = Open3.capture3("ps", "-o", "ppid=,comm=", "-p", pid.to_s)
32
+ return [ nil, nil ] unless st.success?
33
+
34
+ ppid, comm = out.strip.split(/\s+/, 2)
35
+ [ ppid.to_i, comm ]
36
+ rescue StandardError
37
+ [ nil, nil ]
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,107 @@
1
+ module Agentboard
2
+ module Providers
3
+ # Provider adapter contract. Passive (MCP/hooks/event ingestion) and active
4
+ # (launch/stream/cancel/resume) are separate concerns so partial support is explicit.
5
+ # Unsupported features raise UnsupportedCapability (CAPABILITY_UNAVAILABLE).
6
+ class Base
7
+ class << self
8
+ def key = raise(NotImplementedError)
9
+ def display_name = key.to_s.capitalize
10
+ def binary = nil
11
+
12
+ # Compatibility manifest: lib/agentboard/providers/compatibility/<key>.yml
13
+ def compatibility
14
+ @compatibility ||= begin
15
+ path = File.expand_path("compatibility/#{key}.yml", __dir__)
16
+ File.exist?(path) ? YAML.safe_load(File.read(path), aliases: false) : { "provider" => key.to_s, "validated" => [] }
17
+ end
18
+ end
19
+ end
20
+
21
+ attr_reader :settings
22
+
23
+ def initialize(settings: nil)
24
+ @settings = settings || Agentboard.config.provider_settings(self.class.key)
25
+ end
26
+
27
+ def key = self.class.key.to_s
28
+ def display_name = self.class.display_name
29
+
30
+ # -- discovery -----------------------------------------------------------------
31
+ def probe(environment: ENV) = raise(NotImplementedError)
32
+ def capabilities(environment: ENV) = probe(environment: environment).supported_capabilities
33
+ def detect = probe.installed
34
+ def supports?(cap) = Array(capabilities).include?(cap.to_sym)
35
+
36
+ # -- passive -------------------------------------------------------------------
37
+ def install_passive_hooks(project:, root:) = raise(UnsupportedCapability.new(:passive_hooks, provider: key))
38
+ def remove_passive_hooks(project:, root:, manifest:) = raise(UnsupportedCapability.new(:passive_hooks, provider: key))
39
+ def configure_passive(context:) = install_passive_hooks(project: context[:project], root: context[:root])
40
+
41
+ # Maps a raw hook payload to [NormalizedEvent] (hook ingestion).
42
+ def parse_hook(event_name:, payload:) = raise(UnsupportedCapability.new(:passive_hooks, provider: key))
43
+
44
+ # -- active --------------------------------------------------------------------
45
+ def launch_spec(execution:, worktree:, context_path:, prompt:) = raise(UnsupportedCapability.new(:active_execution, provider: key))
46
+
47
+ # Parses one line of provider stdout into zero or more NormalizedEvents.
48
+ def parse_event(raw_event:) = raise(UnsupportedCapability.new(:structured_events, provider: key))
49
+
50
+ def resume_spec(execution:, worktree:, input:) = raise(UnsupportedCapability.new(:session_resume, provider: key))
51
+
52
+ # Cancellation is performed by the supervisor via the process group; providers that
53
+ # need protocol-level interruption override this.
54
+ def interrupt(handle:)
55
+ raise UnsupportedCapability.new(:cancellation, provider: key) unless supports?(:cancellation)
56
+
57
+ Subprocess.terminate(handle.fetch(:pgid), grace: Agentboard.config.runtime["cancel_grace_seconds"].to_i)
58
+ end
59
+
60
+ # Back-compat names from spec §5.
61
+ def start(execution:) = raise(UnsupportedCapability.new(:active_execution, provider: key))
62
+ def events(session:, cursor: nil) = raise(UnsupportedCapability.new(:structured_events, provider: key))
63
+ def cancel(session:) = raise(UnsupportedCapability.new(:cancellation, provider: key))
64
+ def resume(handle:, input: nil) = raise(UnsupportedCapability.new(:session_resume, provider: key))
65
+
66
+ protected
67
+
68
+ def run_version(argv)
69
+ out, _err, status = Open3.capture3(Subprocess.scrubbed_env, *argv)
70
+ status.success? ? out.strip : nil
71
+ rescue Errno::ENOENT, Errno::EACCES
72
+ nil
73
+ end
74
+
75
+ def which(bin)
76
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |dir|
77
+ path = File.join(dir, bin)
78
+ return path if File.file?(path) && File.executable?(path)
79
+ end
80
+ nil
81
+ end
82
+
83
+ # Conservative capability negotiation (AT-14): capabilities listed for a validated
84
+ # version are enabled; unknown versions only get passive, low-risk capabilities unless
85
+ # the operator explicitly opts in with `allow_unvalidated_versions: true`.
86
+ def negotiate(version, all_caps, safe_caps: %i[mcp])
87
+ entry = Array(self.class.compatibility["validated"]).find { |v| version.to_s.include?(v["binary_version"].to_s) }
88
+ if entry
89
+ caps = Array(entry["capabilities"]).map(&:to_sym) & all_caps
90
+ [ caps, entry["status"] || "validated", [ entry["smoke_test"] ].compact ]
91
+ elsif settings["allow_unvalidated_versions"]
92
+ [ all_caps, "unvalidated (operator opt-in)", [ "#{display_name} #{version} is not in the compatibility manifest; running with operator opt-in." ] ]
93
+ else
94
+ [ safe_caps & all_caps, "unknown version",
95
+ [ "#{display_name} #{version || "(unknown)"} is not in the compatibility manifest. Active execution is disabled; " \
96
+ "set providers.#{key}.allow_unvalidated_versions: true to opt in after testing." ] ]
97
+ end
98
+ end
99
+
100
+ def safe_json(line)
101
+ JSON.parse(line)
102
+ rescue JSON::ParserError
103
+ nil
104
+ end
105
+ end
106
+ end
107
+ end