open-in-editor-bridge 0.1.1 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0ec2cde5e04f7296d806dcfeff4926492de642d4b09983478295b3bc45a68f08
4
- data.tar.gz: 60e87f9f41f6207bd75bb58fce1c0daa4118af0b3bbbd4cb64030eedd83a3f56
3
+ metadata.gz: 7d25bb9c59762f68ee9b164865d324147c717b4b1d5c13bb3163dc57b2c65763
4
+ data.tar.gz: 85c973a0426c36ece6f5daec5b767380bc4621d0c39e28ccb01e572279c58db0
5
5
  SHA512:
6
- metadata.gz: 8e767cca6409335c11dee8e522f1ba6b04e973ec638bad090a089ac2e998544ac347287805f4475a9739cd61448d6dbe3cd13577a95dde1733b4def30b040959
7
- data.tar.gz: 5b48df59f4d402e62c3cff453ba6523b4c775c4744c00c48324f0cbb145f258a8d97bae825542fd2b44967614c9dfa880955793a5356545107a5a646c4c7e6b8
6
+ metadata.gz: d35441d1493bfda18b675cacf05e94baa1c2872543d4464492bc8271b4032213b8aa8826e9b567334aa19cc6cdf7f7c1fbfed1bccda78e60a3bcfb66b09d9d1c
7
+ data.tar.gz: 7d8a2e2aaf8641d415ea9f8cd6a4d78f67735506bcb38a3095d9dca4b1123635661995e2358767d496be9ef13c3d2d81412ca777cb1d815f393c1854c633cd83
data/CHANGELOG.md ADDED
@@ -0,0 +1,41 @@
1
+ # Changelog
2
+
3
+ ## 0.3.0 — 2026-10-09
4
+
5
+ ### Added
6
+
7
+ - `OpenInEditorBridge.compose` for library-owned Docker/Vite integration: generated service overrides mount a bundled Vite plugin and checkout identity, without session environment/query plumbing in applications.
8
+ - Development-only Vite plugin that routes editor requests to the mounted checkout, replacing duplicate caller selectors while preserving encoded file paths and line/column.
9
+ - Minimal adoption instructions and an ELCC migration path for removing its bespoke session transport.
10
+
11
+ ### Security and Lifecycle
12
+
13
+ - Docker startup still requires explicit network-exposure opt-in. Only non-secret identity/target data and plugin code are mounted read-only; lifecycle credentials stay on the host.
14
+ - Missing/malformed Vite identity fails startup safely; only the editor endpoint is proxied.
15
+ - Foreground cleanup and idempotent detached registration reuse the existing lease protocol. Failed startup releases only a new registration, and failed shutdown retains the active registration.
16
+ - Unrelated Compose commands and production Vite builds do not require editor setup.
17
+
18
+ ## 0.2.0 — 2026-10-09
19
+
20
+ ### Added
21
+
22
+ - Shared host broker with checkout-specific editor sessions and simultaneous-start coordination.
23
+ - `OPEN_IN_EDITOR_BRIDGE_BIND_ADDRESS` for explicit Docker-reachable binding; loopback remains the default.
24
+ - Stable checkout IDs through `OpenInEditorBridge.session_id`, `--session-id`, and the `with_running` block argument.
25
+ - Independent foreground leases and idempotent detached registration for Compose `up -d` / `down`.
26
+
27
+ ### Changed
28
+
29
+ - Shutdown releases only the calling checkout's detached registration; the last active lease stops the broker.
30
+ - Shared private runtime state replaces checkout-local PID/log files. Stop 0.1.x bridges before upgrading.
31
+ - Multi-checkout editor links require an explicit `session` query parameter; single-checkout links remain valid.
32
+ - Registration/release use nonce-bound HMAC-authenticated requests/responses and verify protocol/PID identity without transmitting the control secret or signaling disk-sourced PIDs.
33
+
34
+ ### Fixed
35
+
36
+ - Incorrect host-checkout routing and duplicate-port startup failures for concurrent worktrees.
37
+ - Premature detached-session cleanup when using the documented persistent lifecycle.
38
+ - Path-prefix collisions and double decoding of percent-encoded filenames.
39
+ - Relative editor commands now execute in the requesting checkout, not the checkout that first started the broker.
40
+ - Failed health probes preserve live shared-broker credentials instead of orphaning active sessions.
41
+ - Malformed authentication bytes are rejected without terminating other checkout sessions.
data/README.md CHANGED
@@ -1,49 +1,146 @@
1
1
  # OpenInEditorBridge
2
2
 
3
- A small Ruby gem that runs a local HTTP bridge for opening files from a container in the host editor.
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.3"
9
9
  ```
10
10
 
11
- ## Ruby API
11
+ ```sh
12
+ gem install open-in-editor-bridge
13
+ ```
14
+
15
+ ## Docker and Vite Integration
12
16
 
13
- Wrap the local development command that needs the bridge:
17
+ Use the host-side Compose adapter and the gem's mounted Vite plugin. The library owns checkout identity transport, the host-gateway entry, and the editor proxy; **do not export a session variable or write a request rewrite**.
18
+
19
+ Replace the development Compose startup/shutdown call:
14
20
 
15
21
  ```ruby
16
22
  require "open_in_editor_bridge"
17
23
 
18
- OpenInEditorBridge.with_running do
19
- # Run the command that emits open-in-editor links.
20
- end
24
+ # Explicit network-exposure opt-in; use only on a trusted development network.
25
+ ENV["OPEN_IN_EDITOR_BRIDGE_BIND_ADDRESS"] = "0.0.0.0"
26
+ ENV["OPEN_IN_EDITOR_COMMAND"] = "code"
27
+
28
+ OpenInEditorBridge.compose("up", service: "web")
29
+ # Detached startup: OpenInEditorBridge.compose("up", "-d", service: "web")
30
+ # Shutdown: OpenInEditorBridge.compose("down", service: "web")
31
+ ```
32
+
33
+ Add the plugin to your existing Vite configuration for Docker development:
34
+
35
+ ```js
36
+ import { defineConfig } from "vite";
37
+
38
+ export default defineConfig(async ({ command, mode }) => {
39
+ const plugins = [];
40
+ if (command === "serve" && mode === "development") {
41
+ const integrationPath = "/open-in-editor-bridge/vite.mjs";
42
+ const { default: openInEditorBridge } = await import(integrationPath);
43
+ plugins.push(openInEditorBridge());
44
+ }
45
+
46
+ return { plugins, server: { host: "0.0.0.0" } };
47
+ });
21
48
  ```
22
49
 
23
- Use `ensure_running: false` when the wrapped command is responsible for stopping the bridge:
50
+ Keep your application's existing plugins and other Vite settings. The variable-based dynamic import is intentional: production builds and test-mode configuration can load without Docker's development mounts. This path supports Docker-hosted Vite development; native-host Vite and custom development modes need their own explicit opt-in condition. The plugin requires a valid mounted manifest when enabled and fails startup if it is missing or malformed. Vite 6 is exercised by release QA.
51
+
52
+ ### Application Configuration That Remains
53
+
54
+ - Select the Vite service (`service: "web"` by default); retain its image, command, source mounts, ports, and normal environment.
55
+ - Select Compose files/project options when necessary, for example `compose_options: ["-f", "docker-compose.development.yaml", "-p", "my-checkout"]`. Pass subcommand options after `"up"` / `"down"`, not in `compose_options`.
56
+ - Select checkout/container/host roots and the editor through the existing bridge configuration below. `project_root:` defaults to the current checkout; `env:` supplies bridge configuration and child-process environment overrides.
57
+ - Explicitly select a Docker-reachable bind address. The adapter rejects loopback startup and never changes the default binding itself.
58
+
59
+ When no explicit `-f` is supplied, the adapter respects `COMPOSE_FILE` / `COMPOSE_PATH_SEPARATOR`, or discovers a standard Compose file and its default override from the project directory upward. `--project-directory` is supported. Compose runs in `project_root:` and inherits normal process input/output. Failures raise; successful commands return `true`.
60
+
61
+ Only `up` adds editor setup. Foreground `up` owns an independent lease and releases it even on failure. `up -d` / `up --detach` use the existing idempotent detached registration. Failed detached startup releases a newly acquired registration but preserves one that was already active. Successful `down` releases that checkout's detached registration; failed `down` keeps it. Other commands (`config`, `build`, `exec`, `run`, `logs`, and so on) execute ordinary Compose without editor setup or requiring an editor. `stop` does not release detached registration; use `down` or `--shutdown` when done. If a new container needs the editor mounts, create it through `up`.
62
+
63
+ The generated override mounts just two read-only files at `/open-in-editor-bridge`: the packaged `vite.mjs` and a checkout-specific `session.json` containing identity and target URL. It never mounts broker state, the whole private runtime directory, or lifecycle control credentials. Temporary Compose overrides are removed after each invocation; non-secret manifests remain in the private host runtime directory for existing detached containers.
64
+
65
+ All checkouts share the standard host port `3333`, bind address, and private runtime directory. Each may use the same container directory `/usr/src/web`: the plugin selects its own host checkout from its mounted identity. It replaces all caller-supplied `session` selectors, including duplicates and encoded selector names, without rewriting other query bytes. Only `/__open-in-editor` is proxied; health and lifecycle endpoints are not. Existing exact editor proxy entries are replaced, and editor routing takes precedence over broader application proxy entries.
66
+
67
+ ### Adopting in ELCC
68
+
69
+ After installing 0.3.0, ELCC can replace the editor lifecycle/Compose `up` and `down` path in `bin/dev` with `OpenInEditorBridge.compose`, passing its existing development/gateway `-f` options, project name, host user/group variables, and gateway hostname. Keep gateway lifecycle ownership and pipe-input handling for unrelated commands unchanged.
70
+
71
+ Remove the `OPEN_IN_EDITOR_SESSION_ID` export from `dynamic_environment_variables`, its web-service pass-through in `docker-compose.development.yaml`, and the handwritten `/__open-in-editor` target/rewrite in `web/vite.config.js`. Add the mounted plugin to the existing Vue/Vuetify/gateway plugin list under the development-only condition above. The adapter supplies the host-gateway entry, so an editor-only `extra_hosts` entry is no longer necessary. Keep the explicit trusted-network bind opt-in and existing root/editor settings. No consumer files are changed automatically.
72
+
73
+ ## Low-Level Ruby API
74
+
75
+ For non-Compose applications or custom integrations:
24
76
 
25
77
  ```ruby
26
- OpenInEditorBridge.with_running(ensure_running: false) { run_down_command }
78
+ require "open_in_editor_bridge"
79
+
80
+ OpenInEditorBridge.with_running do |session_id|
81
+ # A custom integration may use session_id to select this checkout.
82
+ system("./bin/app", exception: true)
83
+ end
27
84
  ```
28
85
 
29
- The bridge is stopped in the wrapper's `ensure` path. A block exception remains the raised exception.
86
+ 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.
87
+
88
+ `OpenInEditorBridge.session_id` returns the stable ID without starting the bridge. `OpenInEditorBridge.new(env: ..., project_root: ...)` supports explicit configuration. `call("--ensure-running")` registers a detached checkout; `call("--shutdown")` releases it. Repeated registration is idempotent. `with_running(ensure_running: false)` runs its block without acquiring a lease and releases detached registration afterward. Do not wrap detached startup in a foreground lease.
89
+
90
+ ## CLI and Session Protocol
30
91
 
31
- ## CLI
92
+ Run lifecycle commands from the host checkout (or set `OPEN_IN_EDITOR_PROJECT_ROOT`):
32
93
 
33
94
  ```sh
95
+ open-in-editor-bridge --session-id
34
96
  open-in-editor-bridge --ensure-running
97
+ # Later, with the same configuration:
35
98
  open-in-editor-bridge --shutdown
36
- open-in-editor-bridge --serve
37
99
  ```
38
100
 
39
- The server provides `GET /health` and `GET /__open-in-editor?file=...`. Container paths beginning with `/usr/src/web` are mapped to the host `web` directory, preserving optional `:line:column` locations.
101
+ The CLI is the low-level lifecycle interface, not a substitute for the Docker/Vite adapter. `--serve` runs a foreground broker and registers the current checkout; it requires a free port. Manually interrupting it stops all sessions. Prefer the managed API for sharing.
102
+
103
+ `GET /health` reports PID, protocol, bind address, and active checkout IDs. `GET /__open-in-editor?file=<encoded-target>&session=<checkout-id>` spawns that checkout's editor with `--goto <translated-target>`, preserving optional `:line:column`. Paths equal to or below `/usr/src/web` map to the host `web` directory; other paths pass through unchanged. Query encoding is decoded exactly once, preserving literal percent sequences in filenames.
104
+
105
+ Custom integrations may use the same session protocol. With exactly one checkout, links without `session` work. With multiple checkouts, missing identity returns HTTP 400 rather than guessing; unknown IDs return HTTP 404. Conflicting roots/editor settings for an active checkout are rejected. Foreground and detached leases are independent; the last released lease shuts down the listener.
40
106
 
41
107
  ## Configuration
42
108
 
43
- - `OPEN_IN_EDITOR_BRIDGE_PORT` (default `3333`)
44
- - `OPEN_IN_EDITOR_PROJECT_ROOT` (default current directory)
45
- - `OPEN_IN_EDITOR_CONTAINER_WEB_ROOT` (default `/usr/src/web`)
46
- - `OPEN_IN_EDITOR_HOST_WEB_ROOT` (default `<project-root>/web`)
47
- - `OPEN_IN_EDITOR_COMMAND` (or `EDITOR`)
109
+ | Variable | Default | Purpose |
110
+ |---|---|---|
111
+ | `OPEN_IN_EDITOR_BRIDGE_PORT` | `3333` | Shared host listener port |
112
+ | `OPEN_IN_EDITOR_BRIDGE_BIND_ADDRESS` | `127.0.0.1` | Listener address; `0.0.0.0` allows Docker IPv4 access |
113
+ | `OPEN_IN_EDITOR_PROJECT_ROOT` | Current directory | Checkout identity and default host mapping |
114
+ | `OPEN_IN_EDITOR_CONTAINER_WEB_ROOT` | `/usr/src/web` | Container directory to translate |
115
+ | `OPEN_IN_EDITOR_HOST_WEB_ROOT` | `<project-root>/web` | Corresponding host directory |
116
+ | `OPEN_IN_EDITOR_COMMAND` | `EDITOR` | Editor command, parsed with `Shellwords` |
117
+ | `OPEN_IN_EDITOR_BRIDGE_RUNTIME_DIR` | `<system-tmp>/open-in-editor-bridge-<uid>` | Shared private host runtime state |
118
+ | `OPEN_IN_EDITOR_BRIDGE_STARTUP_TIMEOUT` | `5` seconds | Startup/shutdown deadline |
119
+
120
+ 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.
121
+
122
+ ## Security and Upgrade Notes
123
+
124
+ 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.
125
+
126
+ 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.
127
+
128
+ 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.
129
+
130
+ 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.
131
+
132
+ ## Development and Release
133
+
134
+ Commit the root `Gemfile.lock` to keep development and release-test dependencies reproducible. Regenerate it with Bundler after changing `Gemfile`, and commit both files together when both change. This development lockfile is not included in the gem package and does not constrain applications installing this gem; those applications resolve the gemspec's dependencies using their own lockfiles.
135
+
136
+ Keep generated `*.gem` packages, `.ruby-lsp/` editor state, and local `pkg/` / `tmp/` artifacts out of version control.
137
+
138
+ Development checks require Node.js 18+ for the bundled Vite plugin's dependency-free tests; the Ruby library itself remains dependency-free. `rake test` runs both Ruby and Node checks.
139
+
140
+ ```sh
141
+ bundle install
142
+ bundle exec rake test
143
+ gem build open-in-editor-bridge.gemspec
144
+ ```
48
145
 
49
- The editor command is required and is parsed with `Shellwords`.
146
+ 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