superkick 0.1.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/CLA.md +91 -0
- data/CLAUDE.md +2226 -0
- data/CONTRIBUTING.md +104 -0
- data/LICENSE +108 -0
- data/LICENSE-COMMERCIAL.md +39 -0
- data/PLAN.md +161 -0
- data/README.md +1155 -0
- data/exe/superkick +6 -0
- data/lib/superkick/agent/runtime.rb +82 -0
- data/lib/superkick/agent/runtimes/local.rb +74 -0
- data/lib/superkick/agent/runtimes.rb +4 -0
- data/lib/superkick/agent.rb +209 -0
- data/lib/superkick/agent_store.rb +85 -0
- data/lib/superkick/attach/client.rb +245 -0
- data/lib/superkick/attach/protocol.rb +71 -0
- data/lib/superkick/attach/server.rb +371 -0
- data/lib/superkick/budget_checker.rb +120 -0
- data/lib/superkick/buffer/client.rb +91 -0
- data/lib/superkick/buffer/server.rb +127 -0
- data/lib/superkick/cli/agent.rb +524 -0
- data/lib/superkick/cli/completion.rb +591 -0
- data/lib/superkick/cli/goal.rb +71 -0
- data/lib/superkick/cli/mcp.rb +34 -0
- data/lib/superkick/cli/monitor.rb +47 -0
- data/lib/superkick/cli/notifier.rb +39 -0
- data/lib/superkick/cli/repository.rb +46 -0
- data/lib/superkick/cli/server.rb +106 -0
- data/lib/superkick/cli/setup.rb +166 -0
- data/lib/superkick/cli/spawner.rb +85 -0
- data/lib/superkick/cli/team.rb +407 -0
- data/lib/superkick/cli.rb +175 -0
- data/lib/superkick/client_registry.rb +30 -0
- data/lib/superkick/configuration.rb +178 -0
- data/lib/superkick/connection.rb +56 -0
- data/lib/superkick/control/client.rb +78 -0
- data/lib/superkick/control/reply.rb +43 -0
- data/lib/superkick/control/server.rb +1271 -0
- data/lib/superkick/cost_accumulator.rb +53 -0
- data/lib/superkick/cost_extractor.rb +65 -0
- data/lib/superkick/cost_poller.rb +70 -0
- data/lib/superkick/driver/profile_source.rb +134 -0
- data/lib/superkick/driver.rb +179 -0
- data/lib/superkick/drivers/claude_code.rb +110 -0
- data/lib/superkick/drivers/codex.rb +57 -0
- data/lib/superkick/drivers/copilot.rb +75 -0
- data/lib/superkick/drivers/gemini.rb +86 -0
- data/lib/superkick/drivers/goose.rb +74 -0
- data/lib/superkick/drivers.rb +16 -0
- data/lib/superkick/drop.rb +80 -0
- data/lib/superkick/drops.rb +76 -0
- data/lib/superkick/environment_executor.rb +90 -0
- data/lib/superkick/goal.rb +95 -0
- data/lib/superkick/goals/agent_exit.rb +41 -0
- data/lib/superkick/goals/agent_signal.rb +42 -0
- data/lib/superkick/goals/command.rb +103 -0
- data/lib/superkick/history_buffer.rb +38 -0
- data/lib/superkick/hosted/attach/bridge.rb +52 -0
- data/lib/superkick/hosted/attach/client.rb +208 -0
- data/lib/superkick/hosted/attach/relay.rb +313 -0
- data/lib/superkick/hosted/attach/relay_store.rb +48 -0
- data/lib/superkick/hosted/bridge.rb +263 -0
- data/lib/superkick/hosted/buffer/bridge.rb +42 -0
- data/lib/superkick/hosted/buffer/client.rb +63 -0
- data/lib/superkick/hosted/buffer/relay.rb +126 -0
- data/lib/superkick/hosted/buffer/relay_store.rb +42 -0
- data/lib/superkick/hosted/control/client.rb +84 -0
- data/lib/superkick/hosted/mcp_proxy.rb +144 -0
- data/lib/superkick/inject_handler.rb +24 -0
- data/lib/superkick/injection_guard.rb +26 -0
- data/lib/superkick/injection_queue.rb +177 -0
- data/lib/superkick/injector.rb +65 -0
- data/lib/superkick/input_buffer.rb +171 -0
- data/lib/superkick/integrations/bugsnag/README.md +98 -0
- data/lib/superkick/integrations/bugsnag/spawner.rb +307 -0
- data/lib/superkick/integrations/bugsnag/templates/error_opened.liquid +17 -0
- data/lib/superkick/integrations/bugsnag.rb +7 -0
- data/lib/superkick/integrations/circleci/README.md +75 -0
- data/lib/superkick/integrations/circleci/monitor.rb +185 -0
- data/lib/superkick/integrations/circleci/probe.rb +36 -0
- data/lib/superkick/integrations/circleci/templates/ci_failure.liquid +8 -0
- data/lib/superkick/integrations/circleci/templates/ci_success.liquid +1 -0
- data/lib/superkick/integrations/circleci.rb +8 -0
- data/lib/superkick/integrations/datadog/README.md +253 -0
- data/lib/superkick/integrations/datadog/alert_goal.rb +94 -0
- data/lib/superkick/integrations/datadog/alert_monitor.rb +163 -0
- data/lib/superkick/integrations/datadog/alert_spawner.rb +201 -0
- data/lib/superkick/integrations/datadog/notification_templates/default.liquid +10 -0
- data/lib/superkick/integrations/datadog/notifier.rb +294 -0
- data/lib/superkick/integrations/datadog/spawner.rb +201 -0
- data/lib/superkick/integrations/datadog/templates/alert_changed.liquid +8 -0
- data/lib/superkick/integrations/datadog/templates/alert_escalated.liquid +8 -0
- data/lib/superkick/integrations/datadog/templates/alert_recovered.liquid +14 -0
- data/lib/superkick/integrations/datadog/templates/alert_triggered.liquid +29 -0
- data/lib/superkick/integrations/datadog/templates/error_opened.liquid +15 -0
- data/lib/superkick/integrations/datadog.rb +14 -0
- data/lib/superkick/integrations/docker/README.md +256 -0
- data/lib/superkick/integrations/docker/client.rb +295 -0
- data/lib/superkick/integrations/docker/runtime.rb +218 -0
- data/lib/superkick/integrations/docker.rb +4 -0
- data/lib/superkick/integrations/git/repository_source.rb +66 -0
- data/lib/superkick/integrations/git/version_control.rb +119 -0
- data/lib/superkick/integrations/git.rb +8 -0
- data/lib/superkick/integrations/github/README.md +300 -0
- data/lib/superkick/integrations/github/check_failed_spawner.rb +199 -0
- data/lib/superkick/integrations/github/drops.rb +114 -0
- data/lib/superkick/integrations/github/goal.rb +135 -0
- data/lib/superkick/integrations/github/issue_goal.rb +104 -0
- data/lib/superkick/integrations/github/issue_spawner.rb +160 -0
- data/lib/superkick/integrations/github/monitor.rb +251 -0
- data/lib/superkick/integrations/github/probe.rb +30 -0
- data/lib/superkick/integrations/github/repository_source.rb +228 -0
- data/lib/superkick/integrations/github/templates/check_failed.liquid +10 -0
- data/lib/superkick/integrations/github/templates/ci_failure.liquid +5 -0
- data/lib/superkick/integrations/github/templates/ci_success.liquid +1 -0
- data/lib/superkick/integrations/github/templates/issue_opened.liquid +20 -0
- data/lib/superkick/integrations/github/templates/pr_comment.liquid +2 -0
- data/lib/superkick/integrations/github/templates/pr_review.liquid +4 -0
- data/lib/superkick/integrations/github.rb +16 -0
- data/lib/superkick/integrations/honeybadger/README.md +97 -0
- data/lib/superkick/integrations/honeybadger/notification_templates/default.liquid +8 -0
- data/lib/superkick/integrations/honeybadger/notifier.rb +250 -0
- data/lib/superkick/integrations/honeybadger/spawner.rb +214 -0
- data/lib/superkick/integrations/honeybadger/templates/error_opened.liquid +17 -0
- data/lib/superkick/integrations/honeybadger.rb +9 -0
- data/lib/superkick/integrations/shell/README.md +83 -0
- data/lib/superkick/integrations/shell/monitor.rb +87 -0
- data/lib/superkick/integrations/shell/templates/shell_alert.liquid +6 -0
- data/lib/superkick/integrations/shell/templates/shell_success.liquid +6 -0
- data/lib/superkick/integrations/shell.rb +7 -0
- data/lib/superkick/integrations/shortcut/README.md +193 -0
- data/lib/superkick/integrations/shortcut/drops.rb +91 -0
- data/lib/superkick/integrations/shortcut/monitor.rb +582 -0
- data/lib/superkick/integrations/shortcut/probe.rb +34 -0
- data/lib/superkick/integrations/shortcut/spawner.rb +264 -0
- data/lib/superkick/integrations/shortcut/templates/related_story_changed.liquid +6 -0
- data/lib/superkick/integrations/shortcut/templates/story_blocker.liquid +8 -0
- data/lib/superkick/integrations/shortcut/templates/story_comment.liquid +5 -0
- data/lib/superkick/integrations/shortcut/templates/story_description_changed.liquid +19 -0
- data/lib/superkick/integrations/shortcut/templates/story_owner_changed.liquid +10 -0
- data/lib/superkick/integrations/shortcut/templates/story_ready.liquid +41 -0
- data/lib/superkick/integrations/shortcut/templates/story_state_changed.liquid +9 -0
- data/lib/superkick/integrations/shortcut/templates/story_unblocked.liquid +5 -0
- data/lib/superkick/integrations/shortcut.rb +11 -0
- data/lib/superkick/integrations/slack/README.md +297 -0
- data/lib/superkick/integrations/slack/drops.rb +70 -0
- data/lib/superkick/integrations/slack/notifier.rb +426 -0
- data/lib/superkick/integrations/slack/spawner.rb +251 -0
- data/lib/superkick/integrations/slack/templates/default.liquid +17 -0
- data/lib/superkick/integrations/slack/templates/slack_reply.liquid +3 -0
- data/lib/superkick/integrations/slack/templates/spawn/slack_message.liquid +10 -0
- data/lib/superkick/integrations/slack/thread_monitor.rb +161 -0
- data/lib/superkick/integrations/slack.rb +12 -0
- data/lib/superkick/liquid.rb +129 -0
- data/lib/superkick/local/repository_source.rb +148 -0
- data/lib/superkick/mcp_server.rb +596 -0
- data/lib/superkick/monitor.rb +215 -0
- data/lib/superkick/notification_dispatcher.rb +280 -0
- data/lib/superkick/notifier.rb +173 -0
- data/lib/superkick/notifier_state_store.rb +55 -0
- data/lib/superkick/notifier_template.rb +121 -0
- data/lib/superkick/notifiers/command.rb +124 -0
- data/lib/superkick/notifiers/terminal_bell.rb +41 -0
- data/lib/superkick/output_logger.rb +54 -0
- data/lib/superkick/poller.rb +126 -0
- data/lib/superkick/process_runner.rb +87 -0
- data/lib/superkick/pty_proxy.rb +403 -0
- data/lib/superkick/registry.rb +75 -0
- data/lib/superkick/repository_source.rb +195 -0
- data/lib/superkick/server.rb +211 -0
- data/lib/superkick/session_recorder.rb +154 -0
- data/lib/superkick/setup.rb +160 -0
- data/lib/superkick/spawn/agent_spawner.rb +311 -0
- data/lib/superkick/spawn/approval_store.rb +113 -0
- data/lib/superkick/spawn/handler.rb +144 -0
- data/lib/superkick/spawn/injector.rb +119 -0
- data/lib/superkick/spawn/workflow_executor.rb +196 -0
- data/lib/superkick/spawn/workflow_validator.rb +77 -0
- data/lib/superkick/spawner.rb +67 -0
- data/lib/superkick/supervisor.rb +516 -0
- data/lib/superkick/team/artifact_store.rb +92 -0
- data/lib/superkick/team/log.rb +140 -0
- data/lib/superkick/team/log_entry_drop.rb +34 -0
- data/lib/superkick/team/log_monitor.rb +84 -0
- data/lib/superkick/team/log_notifier.rb +96 -0
- data/lib/superkick/team/log_store.rb +40 -0
- data/lib/superkick/template_filters.rb +24 -0
- data/lib/superkick/template_renderer.rb +223 -0
- data/lib/superkick/templates/team_log/planning_agent.liquid +38 -0
- data/lib/superkick/templates/team_log/team_digest.liquid +45 -0
- data/lib/superkick/templates/team_log/teammate_message.liquid +7 -0
- data/lib/superkick/templates/team_log/worker_kickoff.liquid +37 -0
- data/lib/superkick/templates/workflow/workflow_triggered.liquid +22 -0
- data/lib/superkick/version.rb +5 -0
- data/lib/superkick/version_control.rb +135 -0
- data/lib/superkick/yaml_config.rb +302 -0
- data/lib/superkick.rb +198 -0
- data/plan.md +267 -0
- metadata +404 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
module Goals
|
|
5
|
+
# Goal that runs a shell command periodically. Exit code determines
|
|
6
|
+
# the returned status:
|
|
7
|
+
#
|
|
8
|
+
# exit 0 → :completed (terminal — task succeeded)
|
|
9
|
+
# exit 1 → :pending (keep checking)
|
|
10
|
+
# exit 2 → :in_progress (actively working, stored for observability)
|
|
11
|
+
# exit 3 → :errored (recoverable issue, stored for observability)
|
|
12
|
+
# exit 4 → :failed (irrecoverable — task cannot be completed)
|
|
13
|
+
# exit 5-125 → :errored (reserved for future use, treated as recoverable)
|
|
14
|
+
# exit 126+ → :failed (shell/OS errors: 126=not executable, 127=not found, 128+=signal)
|
|
15
|
+
#
|
|
16
|
+
# The command is re-run each check cycle until a terminal status is
|
|
17
|
+
# reached or the agent is terminated.
|
|
18
|
+
#
|
|
19
|
+
# Configuration:
|
|
20
|
+
# goal:
|
|
21
|
+
# type: command
|
|
22
|
+
# run: "gh pr list --head $BRANCH --json number -q '.[0].number'"
|
|
23
|
+
# timeout: 30 # seconds, default 30
|
|
24
|
+
#
|
|
25
|
+
# Environment variables passed to the command:
|
|
26
|
+
# SUPERKICK_AGENT_ID — the agent ID
|
|
27
|
+
# SUPERKICK_GOAL_COMPLETED — exit code for :completed (0)
|
|
28
|
+
# SUPERKICK_GOAL_PENDING — exit code for :pending (1)
|
|
29
|
+
# SUPERKICK_GOAL_IN_PROGRESS — exit code for :in_progress (2)
|
|
30
|
+
# SUPERKICK_GOAL_ERRORED — exit code for :errored (3)
|
|
31
|
+
# SUPERKICK_GOAL_FAILED — exit code for :failed (4)
|
|
32
|
+
#
|
|
33
|
+
# Use the env vars instead of literal exit codes so scripts don't
|
|
34
|
+
# depend on the numeric mapping:
|
|
35
|
+
#
|
|
36
|
+
# exit $SUPERKICK_GOAL_IN_PROGRESS
|
|
37
|
+
#
|
|
38
|
+
class Command < Goal
|
|
39
|
+
DEFAULT_TIMEOUT = 30
|
|
40
|
+
|
|
41
|
+
# Canonical exit codes — the env vars are the stable API.
|
|
42
|
+
EXIT_COMPLETED = 0
|
|
43
|
+
EXIT_PENDING = 1
|
|
44
|
+
EXIT_IN_PROGRESS = 2
|
|
45
|
+
EXIT_ERRORED = 3
|
|
46
|
+
EXIT_FAILED = 4
|
|
47
|
+
|
|
48
|
+
GOAL_ENV = {
|
|
49
|
+
"SUPERKICK_GOAL_COMPLETED" => EXIT_COMPLETED.to_s,
|
|
50
|
+
"SUPERKICK_GOAL_PENDING" => EXIT_PENDING.to_s,
|
|
51
|
+
"SUPERKICK_GOAL_IN_PROGRESS" => EXIT_IN_PROGRESS.to_s,
|
|
52
|
+
"SUPERKICK_GOAL_ERRORED" => EXIT_ERRORED.to_s,
|
|
53
|
+
"SUPERKICK_GOAL_FAILED" => EXIT_FAILED.to_s
|
|
54
|
+
}.freeze
|
|
55
|
+
|
|
56
|
+
def self.type = :command
|
|
57
|
+
|
|
58
|
+
def self.description
|
|
59
|
+
"Runs a shell command periodically. Exit code 0 means completed, " \
|
|
60
|
+
"1 means pending (keep checking), 4 means failed. " \
|
|
61
|
+
"Environment variables SUPERKICK_GOAL_* provide the exit codes."
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.required_config = %i[run]
|
|
65
|
+
|
|
66
|
+
def check
|
|
67
|
+
cmd = config[:run]
|
|
68
|
+
raise ArgumentError, "Goal::Command requires a :run config key" unless cmd
|
|
69
|
+
|
|
70
|
+
timeout = config[:timeout] || DEFAULT_TIMEOUT
|
|
71
|
+
env = GOAL_ENV.merge("SUPERKICK_AGENT_ID" => agent_id)
|
|
72
|
+
|
|
73
|
+
result = ProcessRunner.run(cmd, timeout:, env:, chdir: Dir.pwd)
|
|
74
|
+
|
|
75
|
+
if result[:timed_out]
|
|
76
|
+
Superkick.logger.warn("goal:command") { "Command timed out for #{agent_id}" }
|
|
77
|
+
:pending
|
|
78
|
+
else
|
|
79
|
+
exit_code_to_status(result[:status].exitstatus)
|
|
80
|
+
end
|
|
81
|
+
rescue => e
|
|
82
|
+
Superkick.logger.error("goal:command") { "Check failed for #{agent_id}: #{e.message}" }
|
|
83
|
+
:pending
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def exit_code_to_status(code)
|
|
89
|
+
case code
|
|
90
|
+
when EXIT_COMPLETED then :completed
|
|
91
|
+
when EXIT_PENDING then :pending
|
|
92
|
+
when EXIT_IN_PROGRESS then :in_progress
|
|
93
|
+
when EXIT_ERRORED then :errored
|
|
94
|
+
when EXIT_FAILED then :failed
|
|
95
|
+
when 5..125 then :errored
|
|
96
|
+
else :failed # 126+ (shell/OS errors)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
Superkick::Goal.register(Superkick::Goals::Command)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
# Thread-safe ring buffer that stores the most recent N bytes of PTY output.
|
|
5
|
+
# When a new attach client connects, the entire buffer is replayed so they
|
|
6
|
+
# see context rather than a blank screen.
|
|
7
|
+
class HistoryBuffer
|
|
8
|
+
DEFAULT_CAPACITY = 100 * 1024 # 100 KB
|
|
9
|
+
|
|
10
|
+
def initialize(capacity: DEFAULT_CAPACITY)
|
|
11
|
+
@capacity = capacity
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
@buffer = "".b
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Append PTY output data. Trims from the front if capacity is exceeded.
|
|
17
|
+
def write(data)
|
|
18
|
+
@mutex.synchronize do
|
|
19
|
+
@buffer << data.b
|
|
20
|
+
if @buffer.bytesize > @capacity
|
|
21
|
+
excess = @buffer.bytesize - @capacity
|
|
22
|
+
@buffer = @buffer.byteslice(excess..)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Return a copy of the current buffer contents.
|
|
28
|
+
# @return [String] binary string of recent PTY output
|
|
29
|
+
def snapshot
|
|
30
|
+
@mutex.synchronize { @buffer.dup }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Current size in bytes.
|
|
34
|
+
def size
|
|
35
|
+
@mutex.synchronize { @buffer.bytesize }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
module Hosted
|
|
5
|
+
module Attach
|
|
6
|
+
# Agent-side bridge that connects the local Attach::Server to the hosted
|
|
7
|
+
# server over a persistent WebSocket. Unlike the Buffer bridge, this
|
|
8
|
+
# channel uses binary frames for streaming PTY output.
|
|
9
|
+
#
|
|
10
|
+
# Subclass of Superkick::Hosted::Bridge — inherits the WebSocket lifecycle,
|
|
11
|
+
# auth handshake, read loop, and reconnection with exponential backoff.
|
|
12
|
+
#
|
|
13
|
+
# Output forwarding: registers a broadcast callback on Attach::Server so
|
|
14
|
+
# that PTY output is forwarded to the relay as OUTPUT frames.
|
|
15
|
+
#
|
|
16
|
+
# Remote input: binary WebSocket messages from the relay (INPUT/RESIZE
|
|
17
|
+
# frames from remote users) are dispatched to the Attach::Server.
|
|
18
|
+
class Bridge < Superkick::Hosted::Bridge
|
|
19
|
+
def initialize(agent_id:, server_url:, api_key:, attach_server:)
|
|
20
|
+
super(agent_id:, server_url:, api_key:)
|
|
21
|
+
@attach_server = attach_server
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def websocket_path
|
|
27
|
+
"/api/v1/agents/#{@agent_id}/attach/agent/ws"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def channel_name
|
|
31
|
+
"attach:ws"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def on_authenticated
|
|
35
|
+
@attach_server.on_broadcast do |data|
|
|
36
|
+
frame = Superkick::Attach::Protocol.build_frame(Superkick::Attach::Protocol::OUTPUT, data)
|
|
37
|
+
send_binary(frame)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def handle_server_message(message)
|
|
42
|
+
# Text messages from server (besides auth/ping) are not expected
|
|
43
|
+
# for the attach channel — all data is binary
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def handle_binary_message(data)
|
|
47
|
+
@attach_server.handle_remote_input(data)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
module Hosted
|
|
5
|
+
module Attach
|
|
6
|
+
# WebSocket transport for attach sessions.
|
|
7
|
+
# Connects to the hosted server, authenticates, then streams
|
|
8
|
+
# Attach::Protocol binary frames over WebSocket messages.
|
|
9
|
+
class Client < Superkick::Attach::Client
|
|
10
|
+
CONNECT_TIMEOUT = 10 # seconds
|
|
11
|
+
|
|
12
|
+
def self.from(agent_id:, config: Superkick.config, mode: :ro, escape_key: "\x01", force: false)
|
|
13
|
+
server_config = config.server
|
|
14
|
+
new(
|
|
15
|
+
server_url: server_config[:url],
|
|
16
|
+
api_key: server_config[:api_key],
|
|
17
|
+
agent_id:,
|
|
18
|
+
mode:, escape_key:, force:
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(server_url:, api_key:, agent_id:, **session_args)
|
|
23
|
+
super(**session_args)
|
|
24
|
+
@server_url = server_url.chomp("/")
|
|
25
|
+
@api_key = api_key
|
|
26
|
+
@agent_id = agent_id
|
|
27
|
+
@frame_queue = Queue.new
|
|
28
|
+
@reader_thread = nil
|
|
29
|
+
|
|
30
|
+
connect!
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def read_frame
|
|
34
|
+
data = @frame_queue.pop
|
|
35
|
+
return nil if data == :eof
|
|
36
|
+
|
|
37
|
+
# Parse Protocol frame from binary data
|
|
38
|
+
return nil if data.bytesize < 5
|
|
39
|
+
|
|
40
|
+
type = data.getbyte(0)
|
|
41
|
+
len = data[1, 4].unpack1("N")
|
|
42
|
+
payload = data[5, len]
|
|
43
|
+
return nil unless payload && payload.bytesize == len
|
|
44
|
+
|
|
45
|
+
[type, payload]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def write_frame(type, data)
|
|
49
|
+
frame = Superkick::Attach::Protocol.build_frame(type, data)
|
|
50
|
+
@driver.binary(frame)
|
|
51
|
+
flush
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def write_json_frame(type, hash)
|
|
55
|
+
write_frame(type, JSON.generate(hash))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def close
|
|
59
|
+
@driver&.close
|
|
60
|
+
@socket&.close
|
|
61
|
+
@reader_thread&.kill
|
|
62
|
+
rescue IOError, Errno::EBADF
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def connect!
|
|
69
|
+
uri = URI.parse("#{@server_url}/api/v1/agents/#{@agent_id}/attach/ws")
|
|
70
|
+
@socket = open_socket(uri)
|
|
71
|
+
@driver = create_driver(uri)
|
|
72
|
+
|
|
73
|
+
setup_handlers
|
|
74
|
+
@driver.start
|
|
75
|
+
flush
|
|
76
|
+
|
|
77
|
+
# Send auth
|
|
78
|
+
auth = {type: "auth", token: @api_key}
|
|
79
|
+
@driver.text(JSON.generate(auth))
|
|
80
|
+
flush
|
|
81
|
+
|
|
82
|
+
# Start background reader
|
|
83
|
+
@reader_thread = Thread.new { reader_loop }
|
|
84
|
+
|
|
85
|
+
# Wait for welcome
|
|
86
|
+
wait_for_welcome
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def open_socket(uri)
|
|
90
|
+
default_port = (uri.scheme == "wss") ? 443 : 80
|
|
91
|
+
port = uri.port || default_port
|
|
92
|
+
tcp = TCPSocket.new(uri.host, port)
|
|
93
|
+
|
|
94
|
+
if uri.scheme == "wss"
|
|
95
|
+
require "openssl"
|
|
96
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
|
97
|
+
ctx.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
|
|
98
|
+
ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
|
|
99
|
+
ssl.hostname = uri.host
|
|
100
|
+
ssl.connect
|
|
101
|
+
ssl
|
|
102
|
+
else
|
|
103
|
+
tcp
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def create_driver(uri)
|
|
108
|
+
require "websocket/driver"
|
|
109
|
+
|
|
110
|
+
wrapper = SocketWrapper.new(@socket, uri.to_s)
|
|
111
|
+
driver = WebSocket::Driver.client(wrapper)
|
|
112
|
+
driver.set_header("Authorization", "Bearer #{@api_key}")
|
|
113
|
+
driver
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def setup_handlers
|
|
117
|
+
@welcome_received = false
|
|
118
|
+
|
|
119
|
+
@driver.on :message do |event|
|
|
120
|
+
handle_text_message(event.data)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
@driver.on :binary do |event|
|
|
124
|
+
@frame_queue << event.data.b
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
@driver.on :close do
|
|
128
|
+
@frame_queue << :eof
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
@driver.on :error do |event|
|
|
132
|
+
Superkick.logger.error("attach:client:hosted") { "WebSocket error: #{event.message}" }
|
|
133
|
+
@frame_queue << :eof
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def handle_text_message(data)
|
|
138
|
+
message = JSON.parse(data, symbolize_names: true)
|
|
139
|
+
|
|
140
|
+
case message[:type]
|
|
141
|
+
when "welcome"
|
|
142
|
+
@welcome_received = true
|
|
143
|
+
when "error"
|
|
144
|
+
Superkick.logger.error("attach:client:hosted") { "Server error: #{message[:message]}" }
|
|
145
|
+
@frame_queue << :eof
|
|
146
|
+
when "ping"
|
|
147
|
+
@driver.text(JSON.generate({type: "pong"}))
|
|
148
|
+
flush
|
|
149
|
+
end
|
|
150
|
+
rescue JSON::ParserError
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def reader_loop
|
|
155
|
+
loop do
|
|
156
|
+
data = begin
|
|
157
|
+
@socket.read_nonblock(4096)
|
|
158
|
+
rescue IO::WaitReadable
|
|
159
|
+
IO.select([@socket], nil, nil, 30)
|
|
160
|
+
retry
|
|
161
|
+
rescue IOError, Errno::ECONNRESET, Errno::EBADF
|
|
162
|
+
break
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
break unless data && !data.empty?
|
|
166
|
+
|
|
167
|
+
@driver.parse(data)
|
|
168
|
+
flush
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
@frame_queue << :eof
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def wait_for_welcome
|
|
175
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + CONNECT_TIMEOUT
|
|
176
|
+
until @welcome_received
|
|
177
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
178
|
+
raise Errno::ETIMEDOUT, "WebSocket auth timeout" if remaining <= 0
|
|
179
|
+
sleep 0.05
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def flush
|
|
184
|
+
# websocket-driver writes directly via SocketWrapper
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Minimal wrapper for websocket-driver.
|
|
188
|
+
class SocketWrapper
|
|
189
|
+
attr_reader :url
|
|
190
|
+
|
|
191
|
+
def initialize(socket, url)
|
|
192
|
+
@socket = socket
|
|
193
|
+
@url = url
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def write(data)
|
|
197
|
+
@socket.write(data)
|
|
198
|
+
@socket.flush
|
|
199
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
|
|
200
|
+
nil
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
Superkick::Attach.register_client(:hosted, Superkick::Hosted::Attach::Client)
|