open-in-editor-bridge 0.1.1 → 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 +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +99 -17
- data/lib/open_in_editor_bridge/authentication.rb +29 -0
- data/lib/open_in_editor_bridge/client.rb +172 -0
- data/lib/open_in_editor_bridge/configuration.rb +96 -0
- data/lib/open_in_editor_bridge/server.rb +209 -0
- data/lib/open_in_editor_bridge/version.rb +1 -1
- data/lib/open_in_editor_bridge.rb +35 -313
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 894d087228ad0690acd8e32f1f2284240055e9b934965fd181556adc7aba116b
|
|
4
|
+
data.tar.gz: f5fd6f10dc3110132077b781224d9c1e67eb46d9c6c83d85f0f083cca89b1391
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 00a3e25e014adfdff6d33fa389e4eb11d75ba364fb89536d83b83128154854322dc367938544d34ccad2ffab7a5487ad3f7a3d4f135c35ec5694ebb15f2c315b
|
|
7
|
+
data.tar.gz: 1b6a96b2710f51240f152028b49d0b9ac573dc844d7bb6c8177586faaf3c4a59cd18cf6c09aba920fafd688e22350be1403d6ddf102a606a6cb81af2bdb47260
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 — 2026-10-09
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Shared host broker with checkout-specific editor sessions and simultaneous-start coordination.
|
|
8
|
+
- `OPEN_IN_EDITOR_BRIDGE_BIND_ADDRESS` for explicit Docker-reachable binding; loopback remains the default.
|
|
9
|
+
- Stable checkout IDs through `OpenInEditorBridge.session_id`, `--session-id`, and the `with_running` block argument.
|
|
10
|
+
- Independent foreground leases and idempotent detached registration for Compose `up -d` / `down`.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Shutdown releases only the calling checkout's detached registration; the last active lease stops the broker.
|
|
15
|
+
- Shared private runtime state replaces checkout-local PID/log files. Stop 0.1.x bridges before upgrading.
|
|
16
|
+
- Multi-checkout editor links require an explicit `session` query parameter; single-checkout links remain valid.
|
|
17
|
+
- Registration/release use nonce-bound HMAC-authenticated requests/responses and verify protocol/PID identity without transmitting the control secret or signaling disk-sourced PIDs.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Incorrect host-checkout routing and duplicate-port startup failures for concurrent worktrees.
|
|
22
|
+
- Premature detached-session cleanup when using the documented persistent lifecycle.
|
|
23
|
+
- Path-prefix collisions and double decoding of percent-encoded filenames.
|
|
24
|
+
- Relative editor commands now execute in the requesting checkout, not the checkout that first started the broker.
|
|
25
|
+
- Failed health probes preserve live shared-broker credentials instead of orphaning active sessions.
|
|
26
|
+
- Malformed authentication bytes are rejected without terminating other checkout sessions.
|
data/README.md
CHANGED
|
@@ -1,49 +1,131 @@
|
|
|
1
1
|
# OpenInEditorBridge
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A Ruby gem that runs a shared local HTTP bridge for opening container files in the correct host checkout's editor. Requires Ruby 3.2 or newer; no runtime gem dependencies.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
7
|
```ruby
|
|
8
|
-
gem "open-in-editor-bridge"
|
|
8
|
+
gem "open-in-editor-bridge", "~> 0.2"
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
```sh
|
|
12
|
+
gem install open-in-editor-bridge
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Foreground Ruby API
|
|
12
16
|
|
|
13
|
-
Wrap
|
|
17
|
+
Wrap commands whose lifetime matches the application, such as foreground Compose:
|
|
14
18
|
|
|
15
19
|
```ruby
|
|
16
20
|
require "open_in_editor_bridge"
|
|
17
21
|
|
|
18
|
-
OpenInEditorBridge.with_running do
|
|
19
|
-
|
|
22
|
+
OpenInEditorBridge.with_running do |session_id|
|
|
23
|
+
ENV["OPEN_IN_EDITOR_SESSION_ID"] = session_id
|
|
24
|
+
system("docker", "compose", "up", exception: true)
|
|
20
25
|
end
|
|
21
26
|
```
|
|
22
27
|
|
|
23
|
-
|
|
28
|
+
The block returns its normal result. Its checkout lease is released in `ensure`, including when the block raises; cleanup does not replace the original exception. Overlapping wrappers have independent leases, even in the same checkout. Finishing one wrapper never releases another wrapper or an existing detached registration.
|
|
29
|
+
|
|
30
|
+
`OpenInEditorBridge.session_id` returns the stable ID for the configured checkout without starting the bridge. `OpenInEditorBridge.new(env: ..., project_root: ...)` supports explicit configuration for library consumers.
|
|
31
|
+
|
|
32
|
+
## Detached Compose
|
|
33
|
+
|
|
34
|
+
`up -d` returns before the application stops. Use persistent registration, **not** `with_running` around detached startup:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
require "open_in_editor_bridge"
|
|
38
|
+
|
|
39
|
+
OpenInEditorBridge.call("--ensure-running")
|
|
40
|
+
ENV["OPEN_IN_EDITOR_SESSION_ID"] = OpenInEditorBridge.session_id
|
|
41
|
+
system("docker", "compose", "up", "-d", exception: true)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Release that checkout's persistent registration when taking it down:
|
|
24
45
|
|
|
25
46
|
```ruby
|
|
26
|
-
OpenInEditorBridge.with_running(ensure_running: false)
|
|
47
|
+
OpenInEditorBridge.with_running(ensure_running: false) do
|
|
48
|
+
system("docker", "compose", "down", exception: true)
|
|
49
|
+
end
|
|
27
50
|
```
|
|
28
51
|
|
|
29
|
-
|
|
52
|
+
`ensure_running: false` does not register a new lease and releases only the detached registration after the block. You can alternatively call `OpenInEditorBridge.call("--shutdown")` directly. Repeated `--ensure-running` calls are idempotent for a checkout; one `--shutdown` releases its detached registration. Foreground leases and registrations for other checkouts remain active. The listener exits when the last lease is released.
|
|
30
53
|
|
|
31
54
|
## CLI
|
|
32
55
|
|
|
56
|
+
Run lifecycle commands from the host checkout (or set `OPEN_IN_EDITOR_PROJECT_ROOT` explicitly):
|
|
57
|
+
|
|
33
58
|
```sh
|
|
59
|
+
export OPEN_IN_EDITOR_COMMAND='code'
|
|
60
|
+
# Explicit opt-in for Docker bridge-network access; read the security section.
|
|
61
|
+
export OPEN_IN_EDITOR_BRIDGE_BIND_ADDRESS=0.0.0.0
|
|
62
|
+
export OPEN_IN_EDITOR_SESSION_ID="$(open-in-editor-bridge --session-id)"
|
|
34
63
|
open-in-editor-bridge --ensure-running
|
|
64
|
+
docker compose up -d
|
|
65
|
+
|
|
66
|
+
# Later, with the same port, bind address, runtime directory, and project root:
|
|
67
|
+
docker compose down
|
|
35
68
|
open-in-editor-bridge --shutdown
|
|
36
|
-
open-in-editor-bridge --serve
|
|
37
69
|
```
|
|
38
70
|
|
|
39
|
-
|
|
71
|
+
`--serve` runs the broker in the foreground and registers the current checkout. It requires a free port. Prefer the managed lifecycle above for sharing: manually interrupting a foreground broker stops all its sessions.
|
|
72
|
+
|
|
73
|
+
## Docker and Checkout Routing
|
|
74
|
+
|
|
75
|
+
All worktrees using one listener must agree on port, bind address, and runtime directory. Each checkout registers its own host-root mapping and editor command. Simultaneous startup is serialized by a shared per-port lock, not by checkout-local pidfiles.
|
|
76
|
+
|
|
77
|
+
On Linux, make the host accessible from Compose and pass the checkout ID into the web service:
|
|
78
|
+
|
|
79
|
+
```yaml
|
|
80
|
+
services:
|
|
81
|
+
web:
|
|
82
|
+
extra_hosts:
|
|
83
|
+
- "host.docker.internal:host-gateway"
|
|
84
|
+
environment:
|
|
85
|
+
OPEN_IN_EDITOR_SESSION_ID: "${OPEN_IN_EDITOR_SESSION_ID:?Set the host checkout session ID}"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The container's dev-server proxy must forward editor requests to `http://host.docker.internal:3333` and add its `OPEN_IN_EDITOR_SESSION_ID` as the `session` query parameter. For example:
|
|
89
|
+
|
|
90
|
+
```text
|
|
91
|
+
GET /__open-in-editor?session=<checkout-id>&file=%2Fusr%2Fsrc%2Fweb%2Fapp.rb%3A12%3A4
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`GET /health` reports the broker PID, protocol, bind address, and active checkout IDs. `GET /__open-in-editor` spawns the registered editor with `--goto <translated-target>`, preserving optional `:line:column`. Container paths equal to or below `/usr/src/web` map to that checkout's host `web` directory. Other paths pass through unchanged. Query encoding is decoded exactly once, preserving literal percent sequences in filenames.
|
|
95
|
+
|
|
96
|
+
With exactly one checkout, existing links without `session` still work. With multiple checkouts, missing `session` returns HTTP 400 rather than guessing a checkout; unknown IDs return HTTP 404. Registering the same checkout with different roots or editor configuration while it is active is rejected; stop its existing leases before changing configuration.
|
|
40
97
|
|
|
41
98
|
## Configuration
|
|
42
99
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
100
|
+
| Variable | Default | Purpose |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| `OPEN_IN_EDITOR_BRIDGE_PORT` | `3333` | Shared host listener port |
|
|
103
|
+
| `OPEN_IN_EDITOR_BRIDGE_BIND_ADDRESS` | `127.0.0.1` | Listener address; `0.0.0.0` allows Docker IPv4 access |
|
|
104
|
+
| `OPEN_IN_EDITOR_PROJECT_ROOT` | Current directory | Checkout identity and default host mapping |
|
|
105
|
+
| `OPEN_IN_EDITOR_CONTAINER_WEB_ROOT` | `/usr/src/web` | Container directory to translate |
|
|
106
|
+
| `OPEN_IN_EDITOR_HOST_WEB_ROOT` | `<project-root>/web` | Corresponding host directory |
|
|
107
|
+
| `OPEN_IN_EDITOR_COMMAND` | `EDITOR` | Editor command, parsed with `Shellwords` |
|
|
108
|
+
| `OPEN_IN_EDITOR_BRIDGE_RUNTIME_DIR` | `<system-tmp>/open-in-editor-bridge-<uid>` | Shared private host runtime state |
|
|
109
|
+
| `OPEN_IN_EDITOR_BRIDGE_STARTUP_TIMEOUT` | `5` seconds | Startup/shutdown deadline |
|
|
110
|
+
|
|
111
|
+
The editor command is required when registering a checkout, but not for `--session-id` or `--shutdown`. Editors run in their registered checkout's project root, so relative commands such as `./bin/editor` resolve in the correct checkout. Runtime directories must be owned by the current user and private (`0700`). Broker state and its control token use `0600`; runtime files are keyed by port. Checkout IDs derive from the canonical absolute project root.
|
|
112
|
+
|
|
113
|
+
## Security and Upgrade Notes
|
|
114
|
+
|
|
115
|
+
Loopback binding is the default. `0.0.0.0` also exposes the listener to other reachable network peers, not only Docker. Use it only on a trusted development network with host firewall restrictions; a specific Docker-reachable host interface can narrow exposure. Editor requests are intentionally unauthenticated and health exposes checkout IDs: anyone who can reach the listener can invoke configured editors. Do not expose it on an untrusted network or forward it publicly.
|
|
116
|
+
|
|
117
|
+
Registration and release use nonce-bound HMAC signatures derived from a random control token held in the private host runtime directory; the token is never sent over HTTP. Clients authenticate health and control responses and verify broker protocol/PID against shared state. The broker rejects replayed control requests. Shutdown uses authenticated HTTP control, never signals a PID read from disk. Unknown listeners, old bridge versions, mismatched PIDs, and mismatched bind configurations are not reused.
|
|
118
|
+
|
|
119
|
+
A failed or timed-out health probe does not discard a still-listening broker's credentials. Lifecycle operations fail explicitly until its identity can be verified; retry after the listener becomes responsive.
|
|
120
|
+
|
|
121
|
+
Version 0.2 replaces checkout-local PID/log files with shared runtime state. Stop any 0.1.x bridge using its original installed version before upgrading; it cannot share the new protocol. Sessions live in the broker's memory. After a broker crash or deliberate manual stop, rerun `--ensure-running` for detached applications. Abruptly killed foreground clients cannot execute `ensure`; stop that broker manually and re-register surviving checkouts if their leases become orphaned.
|
|
122
|
+
|
|
123
|
+
## Development and Release
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
bundle install
|
|
127
|
+
bundle exec rake test
|
|
128
|
+
gem build open-in-editor-bridge.gemspec
|
|
129
|
+
```
|
|
48
130
|
|
|
49
|
-
|
|
131
|
+
Release changes on an issue-linked branch and pull request, including the version and changelog. Self-review and exercise real host/container lifecycle and editor invocation before merging. From synchronized `main`, build and push the gem with the maintainer's RubyGems credentials, then create a matching GitHub tag/release. Install the published version from RubyGems into a clean `GEM_HOME`, confirm `OpenInEditorBridge::VERSION`, and repeat foreground, detached, routing, and Docker-reachability checks. A recorded editor process proves invocation and arguments, not that an editor window rendered.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
class OpenInEditorBridge
|
|
6
|
+
module Authentication
|
|
7
|
+
def self.signature(token, direction, nonce, method_or_status, path, body)
|
|
8
|
+
digest = OpenSSL::HMAC.new(token, "SHA256")
|
|
9
|
+
[direction, nonce, method_or_status, path, body].each do |part|
|
|
10
|
+
bytes = part.to_s
|
|
11
|
+
digest << [bytes.bytesize].pack("Q>") << bytes
|
|
12
|
+
end
|
|
13
|
+
digest.hexdigest
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.valid_nonce?(nonce)
|
|
17
|
+
nonce.is_a?(String) && nonce.ascii_only? && nonce.match?(/\A[0-9a-f]{32}\z/)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.valid?(provided, token, *message)
|
|
21
|
+
return false unless provided.is_a?(String) && token.is_a?(String) && !token.empty?
|
|
22
|
+
|
|
23
|
+
expected = signature(token, *message)
|
|
24
|
+
return false unless provided.bytesize == expected.bytesize
|
|
25
|
+
|
|
26
|
+
OpenSSL.fixed_length_secure_compare(expected, provided)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
class OpenInEditorBridge
|
|
8
|
+
class Client
|
|
9
|
+
def initialize(configuration)
|
|
10
|
+
@configuration = configuration
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def register(lease)
|
|
14
|
+
session = @configuration.session
|
|
15
|
+
@configuration.synchronize do
|
|
16
|
+
state = compatible_state
|
|
17
|
+
state ||= start_broker
|
|
18
|
+
payload = control_request("/sessions", state, "session" => session, "lease" => lease)
|
|
19
|
+
result = payload.fetch("added") ? :started : :reused
|
|
20
|
+
puts "Editor session #{session.fetch("id")} #{result} on port #{@configuration.port}."
|
|
21
|
+
result
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def release(lease)
|
|
26
|
+
@configuration.synchronize do
|
|
27
|
+
state = compatible_state
|
|
28
|
+
return unless state
|
|
29
|
+
|
|
30
|
+
payload = control_request("/release", state, "session_id" => @configuration.session_id, "lease" => lease)
|
|
31
|
+
wait_until_stopped(state.fetch("pid")) if payload.fetch("stopping")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def serve
|
|
36
|
+
server = @configuration.synchronize do
|
|
37
|
+
raise StartupError, "Editor bridge is already running" if compatible_state
|
|
38
|
+
token = SecureRandom.hex(32)
|
|
39
|
+
broker = Server.new(@configuration, token: token)
|
|
40
|
+
broker.start(initial_session: @configuration.session)
|
|
41
|
+
broker
|
|
42
|
+
end
|
|
43
|
+
server.serve
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def health(state: nil)
|
|
49
|
+
nonce = SecureRandom.hex(16)
|
|
50
|
+
request = Net::HTTP::Get.new("/health")
|
|
51
|
+
request["X-Open-In-Editor-Nonce"] = nonce
|
|
52
|
+
response = http_request(request)
|
|
53
|
+
return nil unless response && response.code == "200" && response["X-Open-In-Editor-Bridge"] == Configuration::PROTOCOL.to_s
|
|
54
|
+
if state
|
|
55
|
+
return nil unless Authentication.valid?(response["X-Open-In-Editor-Signature"], state["token"],
|
|
56
|
+
"response", nonce, response.code, "/health", response.body)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
payload = JSON.parse(response.body)
|
|
60
|
+
return nil unless payload.is_a?(Hash) && payload["ok"] == true && payload["protocol"] == Configuration::PROTOCOL
|
|
61
|
+
payload
|
|
62
|
+
rescue JSON::ParserError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def compatible_state
|
|
67
|
+
state = @configuration.read_state
|
|
68
|
+
current_health = health(state: state)
|
|
69
|
+
if current_health
|
|
70
|
+
unless state && state["pid"] == current_health["pid"] && state["protocol"] == Configuration::PROTOCOL &&
|
|
71
|
+
state["bind_address"] == @configuration.bind_address && current_health["bind_address"] == @configuration.bind_address &&
|
|
72
|
+
state["token"].is_a?(String) && !state["token"].empty?
|
|
73
|
+
raise StartupError, "Listener identity or bind address does not match shared runtime state"
|
|
74
|
+
end
|
|
75
|
+
return state
|
|
76
|
+
end
|
|
77
|
+
if listener_present?
|
|
78
|
+
raise StartupError, "Port is occupied by an unknown or unresponsive listener; shared state preserved"
|
|
79
|
+
end
|
|
80
|
+
if state
|
|
81
|
+
# Never signal a PID read from disk. Only authenticated broker control can stop it.
|
|
82
|
+
@configuration.delete_state(expected_pid: state["pid"])
|
|
83
|
+
end
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def listener_present?
|
|
88
|
+
Socket.tcp(@configuration.connect_address, @configuration.port, connect_timeout: 0.5) { true }
|
|
89
|
+
rescue Errno::ECONNREFUSED
|
|
90
|
+
false
|
|
91
|
+
rescue SystemCallError, Timeout::Error
|
|
92
|
+
# An inconclusive connection failure is not proof that shared state is stale.
|
|
93
|
+
true
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def start_broker
|
|
97
|
+
token = SecureRandom.hex(32)
|
|
98
|
+
library_directory = File.expand_path("..", __dir__)
|
|
99
|
+
script = "configuration = OpenInEditorBridge::Configuration.new(env: ENV); " \
|
|
100
|
+
"server = OpenInEditorBridge::Server.new(configuration, token: ENV.fetch('OPEN_IN_EDITOR_BRIDGE_CONTROL_TOKEN')); " \
|
|
101
|
+
"server.start; server.serve"
|
|
102
|
+
pid = Process.spawn(@configuration.server_environment(token), RbConfig.ruby, "-I", library_directory,
|
|
103
|
+
"-ropen_in_editor_bridge", "-e", script, out: @configuration.log_file, err: @configuration.log_file, pgroup: true)
|
|
104
|
+
# Keep the child waitable until readiness proves we own this live PID.
|
|
105
|
+
deadline = monotonic_time + @configuration.startup_timeout
|
|
106
|
+
loop do
|
|
107
|
+
raise StartupError, "Editor bridge failed to start. See #{@configuration.log_file}." if Process.waitpid(pid, Process::WNOHANG)
|
|
108
|
+
state = @configuration.read_state
|
|
109
|
+
current_health = health(state: state)
|
|
110
|
+
if state && state["pid"] == pid && state["token"] == token && current_health && current_health["pid"] == pid
|
|
111
|
+
Process.detach(pid)
|
|
112
|
+
return state
|
|
113
|
+
end
|
|
114
|
+
raise StartupError, "Editor bridge did not become ready. See #{@configuration.log_file}." if monotonic_time >= deadline
|
|
115
|
+
sleep 0.05
|
|
116
|
+
end
|
|
117
|
+
rescue StandardError
|
|
118
|
+
if pid
|
|
119
|
+
begin
|
|
120
|
+
unless Process.waitpid(pid, Process::WNOHANG)
|
|
121
|
+
Process.kill("TERM", pid)
|
|
122
|
+
Process.waitpid(pid)
|
|
123
|
+
end
|
|
124
|
+
rescue Errno::ECHILD, Errno::ESRCH
|
|
125
|
+
# An exited child is not a PID we still own.
|
|
126
|
+
end
|
|
127
|
+
@configuration.delete_state(expected_pid: pid)
|
|
128
|
+
end
|
|
129
|
+
raise
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def control_request(path, state, payload)
|
|
133
|
+
nonce = SecureRandom.hex(16)
|
|
134
|
+
request = Net::HTTP::Post.new(path)
|
|
135
|
+
request["Content-Type"] = "application/json"
|
|
136
|
+
request.body = JSON.generate(payload)
|
|
137
|
+
request["X-Open-In-Editor-Nonce"] = nonce
|
|
138
|
+
request["X-Open-In-Editor-Signature"] = Authentication.signature(state.fetch("token"),
|
|
139
|
+
"request", nonce, "POST", path, request.body)
|
|
140
|
+
response = http_request(request)
|
|
141
|
+
unless response && Authentication.valid?(response["X-Open-In-Editor-Signature"], state.fetch("token"),
|
|
142
|
+
"response", nonce, response.code, path, response.body)
|
|
143
|
+
raise StartupError, "Bridge control response failed authentication"
|
|
144
|
+
end
|
|
145
|
+
unless response && response.code == "200"
|
|
146
|
+
raise StartupError, "Bridge session request failed: #{response&.code} #{response&.body}"
|
|
147
|
+
end
|
|
148
|
+
JSON.parse(response.body)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def http_request(request)
|
|
152
|
+
Net::HTTP.start(@configuration.connect_address, @configuration.port, nil,
|
|
153
|
+
open_timeout: 0.5, read_timeout: 0.5, write_timeout: 0.5) { |http| http.request(request) }
|
|
154
|
+
rescue IOError, SystemCallError, Timeout::Error
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def wait_until_stopped(pid)
|
|
159
|
+
deadline = monotonic_time + @configuration.startup_timeout
|
|
160
|
+
loop do
|
|
161
|
+
state = @configuration.read_state
|
|
162
|
+
return unless state && state["pid"] == pid
|
|
163
|
+
raise StartupError, "Editor bridge did not stop" if monotonic_time >= deadline
|
|
164
|
+
sleep 0.05
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def monotonic_time
|
|
169
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "shellwords"
|
|
6
|
+
require "tmpdir"
|
|
7
|
+
|
|
8
|
+
class OpenInEditorBridge
|
|
9
|
+
class Configuration
|
|
10
|
+
PROTOCOL = 2
|
|
11
|
+
|
|
12
|
+
attr_reader :port, :bind_address, :connect_address, :runtime_directory, :startup_timeout, :session_id
|
|
13
|
+
|
|
14
|
+
def initialize(env:, project_root: nil)
|
|
15
|
+
@env = env
|
|
16
|
+
@project_root = File.expand_path(project_root || env.fetch("OPEN_IN_EDITOR_PROJECT_ROOT", Dir.pwd))
|
|
17
|
+
@project_root = File.realpath(@project_root) if File.directory?(@project_root)
|
|
18
|
+
@port = Integer(env.fetch("OPEN_IN_EDITOR_BRIDGE_PORT", "3333"))
|
|
19
|
+
@bind_address = env.fetch("OPEN_IN_EDITOR_BRIDGE_BIND_ADDRESS", "127.0.0.1")
|
|
20
|
+
@connect_address = { "0.0.0.0" => "127.0.0.1", "::" => "::1" }.fetch(@bind_address, @bind_address)
|
|
21
|
+
@runtime_directory = env.fetch("OPEN_IN_EDITOR_BRIDGE_RUNTIME_DIR", File.join(Dir.tmpdir, "open-in-editor-bridge-#{Process.uid}"))
|
|
22
|
+
@startup_timeout = Float(env.fetch("OPEN_IN_EDITOR_BRIDGE_STARTUP_TIMEOUT", "5"))
|
|
23
|
+
@session_id = Digest::SHA256.hexdigest(@project_root)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def session
|
|
27
|
+
command = @env.fetch("OPEN_IN_EDITOR_COMMAND", @env.fetch("EDITOR", "")).to_s
|
|
28
|
+
raise ArgumentError, "No editor configured. Set OPEN_IN_EDITOR_COMMAND or EDITOR." if Shellwords.split(command).empty?
|
|
29
|
+
|
|
30
|
+
{
|
|
31
|
+
"id" => session_id,
|
|
32
|
+
"working_directory" => @project_root,
|
|
33
|
+
"container_root" => @env.fetch("OPEN_IN_EDITOR_CONTAINER_WEB_ROOT", "/usr/src/web").delete_suffix("/"),
|
|
34
|
+
"host_root" => File.expand_path(@env.fetch("OPEN_IN_EDITOR_HOST_WEB_ROOT", File.join(@project_root, "web"))),
|
|
35
|
+
"editor_command" => command,
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def state_file
|
|
40
|
+
File.join(runtime_directory, "#{port}.json")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def log_file
|
|
44
|
+
File.join(runtime_directory, "#{port}.log")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def synchronize
|
|
48
|
+
ensure_runtime_directory
|
|
49
|
+
File.open(File.join(runtime_directory, "#{port}.lock"), File::RDWR | File::CREAT, 0o600) do |lock|
|
|
50
|
+
lock.flock(File::LOCK_EX)
|
|
51
|
+
yield
|
|
52
|
+
ensure
|
|
53
|
+
lock.flock(File::LOCK_UN)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def ensure_runtime_directory
|
|
58
|
+
begin
|
|
59
|
+
Dir.mkdir(runtime_directory, 0o700)
|
|
60
|
+
rescue Errno::EEXIST
|
|
61
|
+
# Shared by all checkout clients of this user.
|
|
62
|
+
end
|
|
63
|
+
stat = File.lstat(runtime_directory)
|
|
64
|
+
unless stat.directory? && stat.uid == Process.uid && (stat.mode & 0o077).zero?
|
|
65
|
+
raise StartupError, "Bridge runtime directory must be owned by this user and private (0700): #{runtime_directory}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def read_state
|
|
70
|
+
state = JSON.parse(File.read(state_file))
|
|
71
|
+
state if state.is_a?(Hash)
|
|
72
|
+
rescue Errno::ENOENT, JSON::ParserError
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def write_state(token)
|
|
77
|
+
state = { "pid" => Process.pid, "token" => token, "protocol" => PROTOCOL, "bind_address" => bind_address }
|
|
78
|
+
File.open(state_file, File::WRONLY | File::CREAT | File::TRUNC, 0o600) { |file| file.write(JSON.generate(state)) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def delete_state(expected_pid:)
|
|
82
|
+
File.delete(state_file) if read_state&.fetch("pid", nil) == expected_pid
|
|
83
|
+
rescue Errno::ENOENT
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def server_environment(token)
|
|
88
|
+
{
|
|
89
|
+
"OPEN_IN_EDITOR_BRIDGE_PORT" => port.to_s,
|
|
90
|
+
"OPEN_IN_EDITOR_BRIDGE_BIND_ADDRESS" => bind_address,
|
|
91
|
+
"OPEN_IN_EDITOR_BRIDGE_RUNTIME_DIR" => runtime_directory,
|
|
92
|
+
"OPEN_IN_EDITOR_BRIDGE_CONTROL_TOKEN" => token,
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "timeout"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
class OpenInEditorBridge
|
|
8
|
+
class Server
|
|
9
|
+
RESPONSE_PHRASES = {
|
|
10
|
+
200 => "OK", 400 => "Bad Request", 403 => "Forbidden", 404 => "Not Found",
|
|
11
|
+
405 => "Method Not Allowed", 409 => "Conflict", 500 => "Internal Server Error",
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def initialize(configuration, token:)
|
|
15
|
+
@configuration = configuration
|
|
16
|
+
@token = token
|
|
17
|
+
# Only this broker mutates sessions; each lease represents one live client.
|
|
18
|
+
@sessions = {}
|
|
19
|
+
# Accepted control nonces cannot be replayed during this broker's lifetime.
|
|
20
|
+
@used_nonces = {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def start(initial_session: nil)
|
|
24
|
+
@listener = TCPServer.new(@configuration.bind_address, @configuration.port)
|
|
25
|
+
@sessions[initial_session.fetch("id")] = initial_session.merge("leases" => ["detached"]) if initial_session
|
|
26
|
+
@configuration.write_state(@token)
|
|
27
|
+
@running = true
|
|
28
|
+
rescue StandardError
|
|
29
|
+
@listener&.close
|
|
30
|
+
raise
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def serve
|
|
34
|
+
trap("INT") { stop }
|
|
35
|
+
trap("TERM") { stop }
|
|
36
|
+
while @running
|
|
37
|
+
begin
|
|
38
|
+
socket = @listener.accept
|
|
39
|
+
handle_request(socket)
|
|
40
|
+
rescue IOError, Errno::EBADF
|
|
41
|
+
break unless @running
|
|
42
|
+
raise
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
ensure
|
|
46
|
+
@listener&.close unless @listener&.closed?
|
|
47
|
+
@configuration.delete_state(expected_pid: Process.pid)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def stop
|
|
53
|
+
@running = false
|
|
54
|
+
@listener.close unless @listener.closed?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def handle_request(socket)
|
|
58
|
+
Timeout.timeout(2) do
|
|
59
|
+
method, target, headers, body = read_request(socket)
|
|
60
|
+
path, query = target.split("?", 2)
|
|
61
|
+
params = URI.decode_www_form(query.to_s).to_h
|
|
62
|
+
case path
|
|
63
|
+
when "/health"
|
|
64
|
+
if method == "GET"
|
|
65
|
+
nonce = headers["x-open-in-editor-nonce"]
|
|
66
|
+
if nonce && !Authentication.valid_nonce?(nonce)
|
|
67
|
+
return respond(socket, 400, { "error" => "Invalid health nonce" })
|
|
68
|
+
end
|
|
69
|
+
respond(socket, 200, { "ok" => true, "pid" => Process.pid, "protocol" => Configuration::PROTOCOL,
|
|
70
|
+
"bind_address" => @configuration.bind_address, "sessions" => @sessions.keys },
|
|
71
|
+
nonce: nonce, path: path)
|
|
72
|
+
else
|
|
73
|
+
respond(socket, 405, { "error" => "Method not allowed" })
|
|
74
|
+
end
|
|
75
|
+
when "/__open-in-editor"
|
|
76
|
+
method == "GET" ? open_in_editor(socket, params) : respond(socket, 405, { "error" => "Method not allowed" })
|
|
77
|
+
when "/sessions", "/release"
|
|
78
|
+
control_request(socket, method, path, headers, body)
|
|
79
|
+
else
|
|
80
|
+
respond(socket, 404, { "error" => "Not found" })
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
rescue ArgumentError, JSON::ParserError, EOFError => error
|
|
84
|
+
respond(socket, 400, { "error" => "Bad request: #{error.message}" })
|
|
85
|
+
rescue Timeout::Error, Errno::EPIPE, Errno::ECONNRESET
|
|
86
|
+
nil
|
|
87
|
+
ensure
|
|
88
|
+
socket.close unless socket.closed?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def read_request(socket)
|
|
92
|
+
line = socket.gets("\n", 8192)
|
|
93
|
+
raise EOFError, "Missing request line" unless line
|
|
94
|
+
method, target, = line.split(" ")
|
|
95
|
+
raise ArgumentError, "Missing request target" unless target
|
|
96
|
+
|
|
97
|
+
headers = {}
|
|
98
|
+
header_bytes = 0
|
|
99
|
+
loop do
|
|
100
|
+
line = socket.gets("\n", 8192)
|
|
101
|
+
raise EOFError, "Incomplete headers" unless line
|
|
102
|
+
header_bytes += line.bytesize
|
|
103
|
+
raise ArgumentError, "Headers too large" if header_bytes > 65_536
|
|
104
|
+
break if line == "\r\n"
|
|
105
|
+
|
|
106
|
+
name, value = line.split(":", 2)
|
|
107
|
+
raise ArgumentError, "Invalid header" unless value
|
|
108
|
+
headers[name.downcase] = value.strip
|
|
109
|
+
end
|
|
110
|
+
length = Integer(headers.fetch("content-length", "0"))
|
|
111
|
+
raise ArgumentError, "Invalid content length" unless (0..65_536).cover?(length)
|
|
112
|
+
body = length.zero? ? "" : socket.read(length)
|
|
113
|
+
raise EOFError, "Incomplete body" unless body && body.bytesize == length
|
|
114
|
+
[method, target, headers, body]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def control_request(socket, method, path, headers, body)
|
|
118
|
+
return respond(socket, 405, { "error" => "Method not allowed" }) unless method == "POST"
|
|
119
|
+
nonce = headers["x-open-in-editor-nonce"]
|
|
120
|
+
valid_nonce = Authentication.valid_nonce?(nonce) && !@used_nonces.key?(nonce)
|
|
121
|
+
return respond(socket, 403, { "error" => "Forbidden" }) unless valid_nonce
|
|
122
|
+
|
|
123
|
+
valid_signature = Authentication.valid?(headers["x-open-in-editor-signature"], @token,
|
|
124
|
+
"request", nonce, method, path, body)
|
|
125
|
+
return respond(socket, 403, { "error" => "Forbidden" }) unless valid_signature
|
|
126
|
+
|
|
127
|
+
@used_nonces[nonce] = true
|
|
128
|
+
payload = JSON.parse(body)
|
|
129
|
+
path == "/sessions" ? register(socket, payload, nonce) : release(socket, payload, nonce)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def register(socket, payload, nonce)
|
|
133
|
+
session = payload.fetch("session")
|
|
134
|
+
id = session.fetch("id")
|
|
135
|
+
lease = payload.fetch("lease")
|
|
136
|
+
existing = @sessions[id]
|
|
137
|
+
if existing && existing.reject { |key, _| key == "leases" } != session
|
|
138
|
+
return respond(socket, 409, { "error" => "Checkout already registered with different editor configuration" },
|
|
139
|
+
nonce: nonce, path: "/sessions")
|
|
140
|
+
end
|
|
141
|
+
existing ||= @sessions[id] = session.merge("leases" => [])
|
|
142
|
+
added = !existing.fetch("leases").include?(lease)
|
|
143
|
+
existing.fetch("leases") << lease if added
|
|
144
|
+
respond(socket, 200, { "ok" => true, "added" => added }, nonce: nonce, path: "/sessions")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def release(socket, payload, nonce)
|
|
148
|
+
id = payload.fetch("session_id")
|
|
149
|
+
session = @sessions[id]
|
|
150
|
+
removed = session && session.fetch("leases").delete(payload.fetch("lease"))
|
|
151
|
+
@sessions.delete(id) if session && session.fetch("leases").empty?
|
|
152
|
+
stopping = !!removed && @sessions.empty?
|
|
153
|
+
respond(socket, 200, { "ok" => true, "stopping" => stopping }, nonce: nonce, path: "/release")
|
|
154
|
+
stop if stopping
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def open_in_editor(socket, params)
|
|
158
|
+
file = params["file"]
|
|
159
|
+
return respond(socket, 400, { "error" => "Missing required query parameter: file" }) if file.nil? || file.empty?
|
|
160
|
+
|
|
161
|
+
id = params["session"]
|
|
162
|
+
if id.nil?
|
|
163
|
+
return respond(socket, 400, { "error" => "Specify session when multiple checkouts are registered" }) unless @sessions.size == 1
|
|
164
|
+
id = @sessions.keys.first
|
|
165
|
+
end
|
|
166
|
+
session = @sessions[id]
|
|
167
|
+
return respond(socket, 404, { "error" => "Unknown editor session" }) unless session
|
|
168
|
+
|
|
169
|
+
translated_target = translate_target(file, session)
|
|
170
|
+
editor_command = session.fetch("editor_command")
|
|
171
|
+
pid = Process.spawn(*Shellwords.split(editor_command), "--goto", translated_target,
|
|
172
|
+
chdir: session.fetch("working_directory"))
|
|
173
|
+
Process.detach(pid)
|
|
174
|
+
respond(socket, 200, { "ok" => true, "requestedFile" => file, "translatedTarget" => translated_target,
|
|
175
|
+
"editorCommand" => editor_command, "session" => id })
|
|
176
|
+
rescue StandardError => error
|
|
177
|
+
respond(socket, 500, { "ok" => false, "error" => "Failed to open editor: #{error.message}",
|
|
178
|
+
"requestedFile" => file, "translatedTarget" => translated_target, "editorCommand" => editor_command })
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def translate_target(target, session)
|
|
182
|
+
# Query parameters have already been decoded; literal percent signs are filenames.
|
|
183
|
+
match = target.match(/\A(.+?):(\d+)(?::(\d+))?\z/)
|
|
184
|
+
path = match ? match[1] : target
|
|
185
|
+
location = match ? ":#{[match[2], match[3]].compact.join(":")}" : ""
|
|
186
|
+
root = session.fetch("container_root")
|
|
187
|
+
if path == root || path.start_with?("#{root}/")
|
|
188
|
+
path = "#{session.fetch("host_root")}#{path.delete_prefix(root)}"
|
|
189
|
+
end
|
|
190
|
+
"#{path}#{location}"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def respond(socket, status, payload, nonce: nil, path: nil)
|
|
194
|
+
body = JSON.generate(payload)
|
|
195
|
+
signature_header = ""
|
|
196
|
+
if nonce
|
|
197
|
+
signature = Authentication.signature(@token, "response", nonce, status, path, body)
|
|
198
|
+
signature_header = "X-Open-In-Editor-Signature: #{signature}\r\n"
|
|
199
|
+
end
|
|
200
|
+
socket.write("HTTP/1.1 #{status} #{RESPONSE_PHRASES.fetch(status)}\r\n" \
|
|
201
|
+
"Content-Type: application/json\r\nAccess-Control-Allow-Origin: *\r\n" \
|
|
202
|
+
"X-Open-In-Editor-Bridge: #{Configuration::PROTOCOL}\r\n#{signature_header}" \
|
|
203
|
+
"Content-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}")
|
|
204
|
+
rescue Errno::EPIPE, Errno::ECONNRESET
|
|
205
|
+
# A disconnected HTTP client must not take down other checkout sessions.
|
|
206
|
+
nil
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -1,26 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "fileutils"
|
|
4
|
-
require "json"
|
|
5
|
-
require "rbconfig"
|
|
6
|
-
require "shellwords"
|
|
7
|
-
require "socket"
|
|
8
|
-
require "timeout"
|
|
9
|
-
require "uri"
|
|
10
3
|
require_relative "open_in_editor_bridge/version"
|
|
11
4
|
|
|
12
5
|
class OpenInEditorBridge
|
|
13
|
-
DEFAULT_PORT = 3333
|
|
14
|
-
HEALTH_PATH = "/health"
|
|
15
|
-
OPEN_IN_EDITOR_PATH = "/__open-in-editor"
|
|
16
|
-
RESPONSE_PHRASES = {
|
|
17
|
-
200 => "OK",
|
|
18
|
-
400 => "Bad Request",
|
|
19
|
-
404 => "Not Found",
|
|
20
|
-
405 => "Method Not Allowed",
|
|
21
|
-
500 => "Internal Server Error",
|
|
22
|
-
}.freeze
|
|
23
|
-
|
|
24
6
|
class StartupError < StandardError
|
|
25
7
|
end
|
|
26
8
|
|
|
@@ -28,324 +10,64 @@ class OpenInEditorBridge
|
|
|
28
10
|
new.call(*args)
|
|
29
11
|
end
|
|
30
12
|
|
|
13
|
+
def self.session_id
|
|
14
|
+
new.session_id
|
|
15
|
+
end
|
|
16
|
+
|
|
31
17
|
def self.with_running(ensure_running: true)
|
|
32
|
-
|
|
33
|
-
|
|
18
|
+
bridge = new
|
|
19
|
+
lease = SecureRandom.hex(16) if ensure_running
|
|
20
|
+
bridge.start_session(lease) if ensure_running
|
|
34
21
|
|
|
35
22
|
begin
|
|
36
|
-
yield
|
|
23
|
+
yield bridge.session_id
|
|
37
24
|
ensure
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
raise cleanup_error unless block_error
|
|
44
|
-
end
|
|
25
|
+
block_error = $!
|
|
26
|
+
begin
|
|
27
|
+
ensure_running ? bridge.stop_session(lease) : bridge.call("--shutdown")
|
|
28
|
+
rescue StandardError => cleanup_error
|
|
29
|
+
raise cleanup_error unless block_error
|
|
45
30
|
end
|
|
46
31
|
end
|
|
47
32
|
end
|
|
48
33
|
|
|
49
34
|
def initialize(env: ENV, project_root: nil)
|
|
50
|
-
@
|
|
51
|
-
@
|
|
35
|
+
@configuration = Configuration.new(env: env, project_root: project_root)
|
|
36
|
+
@client = Client.new(@configuration)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def session_id
|
|
40
|
+
@configuration.session_id
|
|
52
41
|
end
|
|
53
42
|
|
|
54
43
|
def call(*args)
|
|
55
44
|
case args.first || "--serve"
|
|
56
45
|
when "--ensure-running"
|
|
57
|
-
|
|
46
|
+
start_session("detached")
|
|
58
47
|
when "--shutdown"
|
|
59
|
-
|
|
48
|
+
stop_session("detached")
|
|
49
|
+
when "--session-id"
|
|
50
|
+
puts session_id
|
|
51
|
+
session_id
|
|
60
52
|
when "--serve"
|
|
61
|
-
serve
|
|
53
|
+
@client.serve
|
|
62
54
|
else
|
|
63
|
-
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
private
|
|
68
|
-
|
|
69
|
-
def ensure_running
|
|
70
|
-
pid = running_pid(validate_health: true)
|
|
71
|
-
if pid
|
|
72
|
-
puts "Editor bridge already running on port #{config[:port]} (pid #{pid})."
|
|
73
|
-
return :reused
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
ensure_runtime_directory
|
|
77
|
-
pid = Process.spawn(
|
|
78
|
-
*server_process_command,
|
|
79
|
-
out: config[:log_file],
|
|
80
|
-
err: config[:log_file],
|
|
81
|
-
pgroup: true
|
|
82
|
-
)
|
|
83
|
-
Process.detach(pid)
|
|
84
|
-
|
|
85
|
-
wait_until_ready(pid)
|
|
86
|
-
write_pid_file(pid)
|
|
87
|
-
puts "Started editor bridge on port #{config[:port]}."
|
|
88
|
-
:started
|
|
89
|
-
rescue StandardError
|
|
90
|
-
if pid && process_running?(pid)
|
|
91
|
-
begin
|
|
92
|
-
Process.kill("TERM", -pid)
|
|
93
|
-
rescue Errno::ESRCH
|
|
94
|
-
end
|
|
55
|
+
raise ArgumentError, "Unknown command: #{args.first}"
|
|
95
56
|
end
|
|
96
|
-
delete_pid_file
|
|
97
|
-
raise
|
|
98
57
|
end
|
|
99
58
|
|
|
100
|
-
def
|
|
101
|
-
|
|
102
|
-
return unless pid
|
|
103
|
-
|
|
104
|
-
Process.kill("TERM", -pid)
|
|
105
|
-
wait_until_stopped(pid)
|
|
106
|
-
delete_pid_file
|
|
107
|
-
puts "Stopped editor bridge."
|
|
108
|
-
rescue Errno::ESRCH
|
|
109
|
-
delete_pid_file
|
|
59
|
+
def start_session(lease)
|
|
60
|
+
@client.register(lease)
|
|
110
61
|
end
|
|
111
62
|
|
|
112
|
-
def
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
ensure_runtime_directory
|
|
116
|
-
write_pid_file(Process.pid)
|
|
117
|
-
@server = TCPServer.new("127.0.0.1", config[:port])
|
|
118
|
-
@running = true
|
|
119
|
-
install_signal_handlers
|
|
120
|
-
|
|
121
|
-
while @running
|
|
122
|
-
begin
|
|
123
|
-
socket = @server.accept
|
|
124
|
-
handle_request(socket)
|
|
125
|
-
rescue IOError, Errno::EBADF
|
|
126
|
-
break
|
|
127
|
-
end
|
|
128
|
-
end
|
|
129
|
-
ensure
|
|
130
|
-
@server&.close
|
|
131
|
-
delete_pid_file
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
def handle_request(socket)
|
|
135
|
-
request = read_request(socket)
|
|
136
|
-
return if request.nil?
|
|
137
|
-
|
|
138
|
-
method = request.fetch(:method)
|
|
139
|
-
consume_headers(socket)
|
|
140
|
-
return write_json_response(socket, status: 405, payload: { error: "Method not allowed" }) unless method == "GET"
|
|
141
|
-
|
|
142
|
-
respond_to_get_request(socket, request.fetch(:path), request.fetch(:params))
|
|
143
|
-
ensure
|
|
144
|
-
socket.close unless socket.closed?
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
def open_in_editor(socket, params)
|
|
148
|
-
file = params["file"]
|
|
149
|
-
if file.nil? || file.empty?
|
|
150
|
-
return write_json_response(
|
|
151
|
-
socket,
|
|
152
|
-
status: 400,
|
|
153
|
-
payload: { error: "Missing required query parameter: file" }
|
|
154
|
-
)
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
translated_target = translate_target(file)
|
|
158
|
-
pid = Process.spawn(*editor_command_args, "--goto", translated_target)
|
|
159
|
-
Process.detach(pid)
|
|
160
|
-
|
|
161
|
-
write_json_response(
|
|
162
|
-
socket,
|
|
163
|
-
status: 200,
|
|
164
|
-
payload: {
|
|
165
|
-
ok: true,
|
|
166
|
-
requestedFile: file,
|
|
167
|
-
translatedTarget: translated_target,
|
|
168
|
-
editorCommand: editor_command,
|
|
169
|
-
}
|
|
170
|
-
)
|
|
171
|
-
rescue StandardError => error
|
|
172
|
-
write_json_response(
|
|
173
|
-
socket,
|
|
174
|
-
status: 500,
|
|
175
|
-
payload: {
|
|
176
|
-
ok: false,
|
|
177
|
-
error: "Failed to open editor: #{error.message}",
|
|
178
|
-
requestedFile: file,
|
|
179
|
-
translatedTarget: translated_target,
|
|
180
|
-
editorCommand: editor_command,
|
|
181
|
-
}
|
|
182
|
-
)
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
def write_json_response(socket, status: 200, payload:)
|
|
186
|
-
body = JSON.generate(payload)
|
|
187
|
-
socket.write("HTTP/1.1 #{status} #{RESPONSE_PHRASES.fetch(status)}\r\n")
|
|
188
|
-
socket.write("Content-Type: application/json\r\n")
|
|
189
|
-
socket.write("Access-Control-Allow-Origin: *\r\n")
|
|
190
|
-
socket.write("X-Open-In-Editor-Bridge: 1\r\n")
|
|
191
|
-
socket.write("Content-Length: #{body.bytesize}\r\n")
|
|
192
|
-
socket.write("Connection: close\r\n")
|
|
193
|
-
socket.write("\r\n")
|
|
194
|
-
socket.write(body)
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
def stop_server
|
|
198
|
-
@running = false
|
|
199
|
-
@server&.close
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
def ensure_runtime_directory
|
|
203
|
-
FileUtils.mkdir_p(File.dirname(config[:pid_file]))
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
def write_pid_file(pid)
|
|
207
|
-
File.write(config[:pid_file], "#{pid}\n")
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
def delete_pid_file
|
|
211
|
-
File.delete(config[:pid_file]) if File.exist?(config[:pid_file])
|
|
212
|
-
end
|
|
213
|
-
|
|
214
|
-
def server_process_command
|
|
215
|
-
[RbConfig.ruby, File.expand_path("../exe/open-in-editor-bridge", __dir__), "--serve"]
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
def editor_command
|
|
219
|
-
@env.fetch("OPEN_IN_EDITOR_COMMAND", @env.fetch("EDITOR", "")).to_s
|
|
220
|
-
end
|
|
221
|
-
|
|
222
|
-
def editor_command_args
|
|
223
|
-
Shellwords.split(editor_command)
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
def install_signal_handlers
|
|
227
|
-
trap("INT") { stop_server }
|
|
228
|
-
trap("TERM") { stop_server }
|
|
229
|
-
end
|
|
230
|
-
|
|
231
|
-
def read_request(socket)
|
|
232
|
-
request_line = socket.gets
|
|
233
|
-
return nil if request_line.nil?
|
|
234
|
-
|
|
235
|
-
method, raw_target, = request_line.split(" ")
|
|
236
|
-
return nil if method.nil? || raw_target.nil?
|
|
237
|
-
|
|
238
|
-
path, query = raw_target.split("?", 2)
|
|
239
|
-
params = URI.decode_www_form(query.to_s).to_h
|
|
240
|
-
{ method: method, path: path, params: params }
|
|
241
|
-
rescue ArgumentError
|
|
242
|
-
write_json_response(socket, status: 400, payload: { error: "Bad request" })
|
|
243
|
-
nil
|
|
244
|
-
end
|
|
245
|
-
|
|
246
|
-
def consume_headers(socket)
|
|
247
|
-
loop do
|
|
248
|
-
line = socket.gets
|
|
249
|
-
break if line.nil? || line == "\r\n"
|
|
250
|
-
end
|
|
251
|
-
end
|
|
252
|
-
|
|
253
|
-
def respond_to_get_request(socket, path, params)
|
|
254
|
-
case path
|
|
255
|
-
when HEALTH_PATH
|
|
256
|
-
write_json_response(socket, payload: { ok: true, pid: Process.pid })
|
|
257
|
-
when OPEN_IN_EDITOR_PATH
|
|
258
|
-
open_in_editor(socket, params)
|
|
259
|
-
else
|
|
260
|
-
write_json_response(socket, status: 404, payload: { error: "Not found" })
|
|
261
|
-
end
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
def translate_target(target)
|
|
265
|
-
path, location = split_target(target)
|
|
266
|
-
translated_path = path.sub(/\A#{Regexp.escape(config[:container_web_root])}/, config[:host_web_root])
|
|
267
|
-
return translated_path if location.nil?
|
|
268
|
-
|
|
269
|
-
"#{translated_path}:#{location}"
|
|
270
|
-
end
|
|
271
|
-
|
|
272
|
-
def split_target(target)
|
|
273
|
-
decoded_target = URI.decode_www_form_component(target)
|
|
274
|
-
match = decoded_target.match(/\A(.+?):(\d+)(?::(\d+))?\z/)
|
|
275
|
-
return [decoded_target, nil] if match.nil?
|
|
276
|
-
|
|
277
|
-
[match[1], [match[2], match[3]].compact.join(":")]
|
|
278
|
-
end
|
|
279
|
-
|
|
280
|
-
def running_pid(validate_health: false)
|
|
281
|
-
return nil unless File.exist?(config[:pid_file])
|
|
282
|
-
|
|
283
|
-
pid = Integer(File.read(config[:pid_file]).strip)
|
|
284
|
-
return pid if process_running?(pid) && (!validate_health || bridge_healthy?(expected_pid: pid))
|
|
285
|
-
|
|
286
|
-
delete_pid_file
|
|
287
|
-
nil
|
|
288
|
-
rescue ArgumentError
|
|
289
|
-
delete_pid_file
|
|
290
|
-
nil
|
|
291
|
-
end
|
|
292
|
-
|
|
293
|
-
def process_running?(pid)
|
|
294
|
-
Process.kill(0, pid)
|
|
295
|
-
true
|
|
296
|
-
rescue Errno::ESRCH, Errno::EPERM
|
|
297
|
-
false
|
|
298
|
-
end
|
|
299
|
-
|
|
300
|
-
def wait_until_ready(pid)
|
|
301
|
-
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + config[:startup_timeout]
|
|
302
|
-
loop do
|
|
303
|
-
raise StartupError, "Editor bridge failed to start. See #{config[:log_file]}." unless process_running?(pid)
|
|
304
|
-
return if bridge_healthy?(expected_pid: pid)
|
|
305
|
-
|
|
306
|
-
raise StartupError, "Editor bridge did not become ready." if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
307
|
-
sleep 0.05
|
|
308
|
-
end
|
|
309
|
-
end
|
|
310
|
-
|
|
311
|
-
def wait_until_stopped(pid)
|
|
312
|
-
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + config[:startup_timeout]
|
|
313
|
-
until !process_running?(pid) && !bridge_healthy?
|
|
314
|
-
break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
315
|
-
|
|
316
|
-
sleep 0.05
|
|
317
|
-
end
|
|
318
|
-
end
|
|
319
|
-
|
|
320
|
-
def bridge_healthy?(expected_pid: nil)
|
|
321
|
-
socket = TCPSocket.new("127.0.0.1", config[:port])
|
|
322
|
-
response = Timeout.timeout(0.25) do
|
|
323
|
-
socket.write("GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
|
|
324
|
-
socket.read
|
|
325
|
-
end
|
|
326
|
-
headers, body = response.split("\r\n\r\n", 2)
|
|
327
|
-
payload = JSON.parse(body.to_s)
|
|
328
|
-
headers&.start_with?("HTTP/1.1 200 ") &&
|
|
329
|
-
headers.lines.any? { |line| line.casecmp("X-Open-In-Editor-Bridge: 1\r\n").zero? } &&
|
|
330
|
-
payload.fetch("ok") == true &&
|
|
331
|
-
(expected_pid.nil? || payload.fetch("pid") == expected_pid)
|
|
332
|
-
rescue Errno::ECONNREFUSED, JSON::ParserError, KeyError, Timeout::Error
|
|
333
|
-
false
|
|
334
|
-
ensure
|
|
335
|
-
socket&.close unless socket&.closed?
|
|
336
|
-
end
|
|
337
|
-
|
|
338
|
-
def config
|
|
339
|
-
root = @project_root || @env.fetch("OPEN_IN_EDITOR_PROJECT_ROOT", Dir.pwd)
|
|
340
|
-
{
|
|
341
|
-
port: Integer(@env.fetch("OPEN_IN_EDITOR_BRIDGE_PORT", DEFAULT_PORT)),
|
|
342
|
-
pid_file: File.join(root, "tmp", "open-in-editor-bridge.pid"),
|
|
343
|
-
log_file: File.join(root, "tmp", "open-in-editor-bridge.log"),
|
|
344
|
-
container_web_root: @env.fetch("OPEN_IN_EDITOR_CONTAINER_WEB_ROOT", "/usr/src/web"),
|
|
345
|
-
host_web_root: @env.fetch("OPEN_IN_EDITOR_HOST_WEB_ROOT", File.join(root, "web")),
|
|
346
|
-
startup_timeout: Float(@env.fetch("OPEN_IN_EDITOR_BRIDGE_STARTUP_TIMEOUT", "5")),
|
|
347
|
-
}
|
|
63
|
+
def stop_session(lease)
|
|
64
|
+
@client.release(lease)
|
|
348
65
|
end
|
|
349
66
|
end
|
|
350
67
|
|
|
68
|
+
require_relative "open_in_editor_bridge/configuration"
|
|
69
|
+
require_relative "open_in_editor_bridge/authentication"
|
|
70
|
+
require_relative "open_in_editor_bridge/server"
|
|
71
|
+
require_relative "open_in_editor_bridge/client"
|
|
72
|
+
|
|
351
73
|
OpenInEditorBridge.call(*ARGV) if $PROGRAM_NAME == __FILE__
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: open-in-editor-bridge
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Marlen Brunner
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-10-09 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: Ruby API and CLI for serving local open-in-editor requests during development.
|
|
14
14
|
email:
|
|
@@ -18,16 +18,22 @@ executables:
|
|
|
18
18
|
extensions: []
|
|
19
19
|
extra_rdoc_files: []
|
|
20
20
|
files:
|
|
21
|
+
- CHANGELOG.md
|
|
21
22
|
- LICENSE.txt
|
|
22
23
|
- README.md
|
|
23
24
|
- exe/open-in-editor-bridge
|
|
24
25
|
- lib/open_in_editor_bridge.rb
|
|
26
|
+
- lib/open_in_editor_bridge/authentication.rb
|
|
27
|
+
- lib/open_in_editor_bridge/client.rb
|
|
28
|
+
- lib/open_in_editor_bridge/configuration.rb
|
|
29
|
+
- lib/open_in_editor_bridge/server.rb
|
|
25
30
|
- lib/open_in_editor_bridge/version.rb
|
|
26
31
|
homepage: https://github.com/klondikemarlen/open-in-editor-bridge
|
|
27
32
|
licenses:
|
|
28
33
|
- MIT
|
|
29
34
|
metadata:
|
|
30
35
|
bug_tracker_uri: https://github.com/klondikemarlen/open-in-editor-bridge/issues
|
|
36
|
+
changelog_uri: https://github.com/klondikemarlen/open-in-editor-bridge/blob/main/CHANGELOG.md
|
|
31
37
|
source_code_uri: https://github.com/klondikemarlen/open-in-editor-bridge
|
|
32
38
|
post_install_message:
|
|
33
39
|
rdoc_options: []
|