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 +4 -4
- data/CHANGELOG.md +41 -0
- data/README.md +116 -19
- 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/docker.rb +151 -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/vite.mjs +79 -0
- data/lib/open_in_editor_bridge.rb +40 -313
- metadata +10 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7d25bb9c59762f68ee9b164865d324147c717b4b1d5c13bb3163dc57b2c65763
|
|
4
|
+
data.tar.gz: 85c973a0426c36ece6f5daec5b767380bc4621d0c39e28ccb01e572279c58db0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
11
|
+
```sh
|
|
12
|
+
gem install open-in-editor-bridge
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Docker and Vite Integration
|
|
12
16
|
|
|
13
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|