lex-exec 0.1.2 → 0.1.3
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/.github/workflows/ci.yml +16 -0
- data/.gitignore +11 -0
- data/.rspec +3 -0
- data/.rubocop.yml +56 -0
- data/CHANGELOG.md +23 -0
- data/CLAUDE.md +159 -0
- data/Gemfile +13 -0
- data/LICENSE +21 -0
- data/README.md +204 -0
- data/lex-exec.gemspec +29 -0
- data/lib/legion/extensions/exec/client.rb +60 -0
- data/lib/legion/extensions/exec/helpers/audit_log.rb +53 -0
- data/lib/legion/extensions/exec/helpers/checkpoint.rb +67 -0
- data/lib/legion/extensions/exec/helpers/constants.rb +32 -0
- data/lib/legion/extensions/exec/helpers/result_parser.rb +69 -0
- data/lib/legion/extensions/exec/helpers/sandbox.rb +39 -0
- data/lib/legion/extensions/exec/helpers/worktree.rb +66 -0
- data/lib/legion/extensions/exec/runners/bundler.rb +39 -0
- data/lib/legion/extensions/exec/runners/git.rb +48 -0
- data/lib/legion/extensions/exec/runners/shell.rb +73 -0
- data/lib/legion/extensions/exec/version.rb +9 -0
- data/lib/legion/extensions/exec.rb +22 -0
- metadata +24 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ce39d50a81ab331806b98ec1790aac9581d34425ea262d2beba00b88d0871536
|
|
4
|
+
data.tar.gz: d9c0e98ac019d2989d786444bf6b18175f64a811d8e543b5800e8800738693ea
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b41990118730fea1147ee66c270b49a34d1fedab7fdb347dfe0f71716a4959aecbacebf79c63fecd5e63dcf184b8f44ca99c7aafcc6c1267b749aed856d56550
|
|
7
|
+
data.tar.gz: 4346cb561525e40ad698a2f66746714f331cbb1296bc87af18c796eb08c079fce0db6a463bb57a3b4bfc0ec4281c9411d39d97e06e67d80c1e6fae15f8bc6d55
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
branches: [main]
|
|
5
|
+
pull_request:
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
ci:
|
|
9
|
+
uses: LegionIO/.github/.github/workflows/ci.yml@main
|
|
10
|
+
|
|
11
|
+
release:
|
|
12
|
+
needs: ci
|
|
13
|
+
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
14
|
+
uses: LegionIO/.github/.github/workflows/release.yml@main
|
|
15
|
+
secrets:
|
|
16
|
+
rubygems-api-key: ${{ secrets.RUBYGEMS_API_KEY }}
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
AllCops:
|
|
2
|
+
TargetRubyVersion: 3.4
|
|
3
|
+
NewCops: enable
|
|
4
|
+
SuggestExtensions: false
|
|
5
|
+
|
|
6
|
+
Layout/LineLength:
|
|
7
|
+
Max: 160
|
|
8
|
+
|
|
9
|
+
Layout/SpaceAroundEqualsInParameterDefault:
|
|
10
|
+
EnforcedStyle: space
|
|
11
|
+
|
|
12
|
+
Layout/HashAlignment:
|
|
13
|
+
EnforcedHashRocketStyle: table
|
|
14
|
+
EnforcedColonStyle: table
|
|
15
|
+
|
|
16
|
+
Metrics/MethodLength:
|
|
17
|
+
Max: 50
|
|
18
|
+
|
|
19
|
+
Metrics/ClassLength:
|
|
20
|
+
Max: 1500
|
|
21
|
+
|
|
22
|
+
Metrics/ModuleLength:
|
|
23
|
+
Max: 1500
|
|
24
|
+
|
|
25
|
+
Metrics/BlockLength:
|
|
26
|
+
Max: 40
|
|
27
|
+
Exclude:
|
|
28
|
+
- 'spec/**/*'
|
|
29
|
+
|
|
30
|
+
Metrics/AbcSize:
|
|
31
|
+
Max: 60
|
|
32
|
+
|
|
33
|
+
Metrics/CyclomaticComplexity:
|
|
34
|
+
Max: 15
|
|
35
|
+
|
|
36
|
+
Metrics/PerceivedComplexity:
|
|
37
|
+
Max: 17
|
|
38
|
+
|
|
39
|
+
Style/Documentation:
|
|
40
|
+
Enabled: false
|
|
41
|
+
|
|
42
|
+
Style/SymbolArray:
|
|
43
|
+
Enabled: true
|
|
44
|
+
|
|
45
|
+
Style/FrozenStringLiteralComment:
|
|
46
|
+
Enabled: true
|
|
47
|
+
EnforcedStyle: always
|
|
48
|
+
|
|
49
|
+
Naming/FileName:
|
|
50
|
+
Enabled: false
|
|
51
|
+
|
|
52
|
+
Naming/PredicateMethod:
|
|
53
|
+
Enabled: false
|
|
54
|
+
|
|
55
|
+
Naming/PredicatePrefix:
|
|
56
|
+
Enabled: false
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.3] - 2026-03-20
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- Gemspec missing `spec.files` declaration — gem build previously produced an empty gem with no files
|
|
7
|
+
- Entry point missing `require_relative` for `Helpers::Checkpoint` and `Helpers::Worktree`
|
|
8
|
+
|
|
9
|
+
## [0.1.2] - 2026-03-20
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `Helpers::Worktree` for git worktree creation, removal, and listing
|
|
13
|
+
- `Helpers::Checkpoint` for hidden-ref-based state snapshots and restore
|
|
14
|
+
|
|
15
|
+
## [0.1.1] - 2026-03-18
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- `Git.push` default branch changed from `'master'` to `'main'` to match GitHub's default since 2020
|
|
19
|
+
|
|
20
|
+
## [0.1.0] - 2026-03-13
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- Initial release
|
data/CLAUDE.md
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# lex-exec: Sandboxed Shell Execution for LegionIO
|
|
2
|
+
|
|
3
|
+
**Repository Level 3 Documentation**
|
|
4
|
+
- **Parent**: `/Users/miverso2/rubymine/legion/extensions-core/CLAUDE.md`
|
|
5
|
+
- **Grandparent**: `/Users/miverso2/rubymine/legion/CLAUDE.md`
|
|
6
|
+
|
|
7
|
+
## Purpose
|
|
8
|
+
|
|
9
|
+
Legion Extension that provides sandboxed shell execution within a LegionIO cluster. Runs shell commands, git operations, and bundler workflows with allowlist enforcement and a thread-safe in-memory audit log. Used by agentic swarm pipelines (e.g., `lex-swarm-github`) to validate and publish generated extensions.
|
|
10
|
+
|
|
11
|
+
**GitHub**: https://github.com/LegionIO/lex-exec
|
|
12
|
+
**License**: Apache-2.0
|
|
13
|
+
**Version**: 0.1.1
|
|
14
|
+
|
|
15
|
+
## Architecture
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
Legion::Extensions::Exec
|
|
19
|
+
├── Runners/
|
|
20
|
+
│ ├── Shell # execute, audit — allowlist + blocked pattern enforcement, Open3 subprocess
|
|
21
|
+
│ ├── Git # init, add, commit, push, status, create_repo — git + gh CLI wrappers
|
|
22
|
+
│ └── Bundler # install, exec_rspec, exec_rubocop — structured output parsing
|
|
23
|
+
├── Helpers/
|
|
24
|
+
│ ├── Sandbox # allowlist check, blocked pattern check
|
|
25
|
+
│ ├── AuditLog # thread-safe ring buffer (1000 entries max)
|
|
26
|
+
│ ├── ResultParser # parses RSpec and RuboCop stdout into structured hashes
|
|
27
|
+
│ └── Constants # ALLOWED_COMMANDS array, BLOCKED_PATTERNS array
|
|
28
|
+
└── Client # includes Shell + Git + Bundler; stores base_path
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
No explicit actors directory. The framework auto-generates subscription actors for each runner.
|
|
32
|
+
|
|
33
|
+
## Gem Info
|
|
34
|
+
|
|
35
|
+
| Field | Value |
|
|
36
|
+
|-------|-------|
|
|
37
|
+
| Gem name | `lex-exec` |
|
|
38
|
+
| Module | `Legion::Extensions::Exec` |
|
|
39
|
+
| Version | `0.1.1` |
|
|
40
|
+
| Ruby | `>= 3.4` |
|
|
41
|
+
| Runtime deps | `open3`, `timeout` (stdlib only) |
|
|
42
|
+
| License | Apache-2.0 |
|
|
43
|
+
|
|
44
|
+
## File Structure
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
lex-exec/
|
|
48
|
+
├── lex-exec.gemspec
|
|
49
|
+
├── Gemfile
|
|
50
|
+
├── lib/
|
|
51
|
+
│ └── legion/
|
|
52
|
+
│ └── extensions/
|
|
53
|
+
│ ├── exec.rb # Entry point; requires all helpers/runners/client
|
|
54
|
+
│ └── exec/
|
|
55
|
+
│ ├── version.rb
|
|
56
|
+
│ ├── client.rb # Client class; includes Shell + Git + Bundler
|
|
57
|
+
│ ├── helpers/
|
|
58
|
+
│ │ ├── sandbox.rb # Allowlist + blocked pattern enforcement
|
|
59
|
+
│ │ ├── audit_log.rb # Thread-safe ring buffer (1000 entries)
|
|
60
|
+
│ │ ├── result_parser.rb # Parses RSpec/RuboCop stdout into structured hashes
|
|
61
|
+
│ │ └── constants.rb # ALLOWED_COMMANDS and BLOCKED_PATTERNS
|
|
62
|
+
│ └── runners/
|
|
63
|
+
│ ├── shell.rb # execute, audit
|
|
64
|
+
│ ├── git.rb # init, add, commit, push, status, create_repo
|
|
65
|
+
│ └── bundler.rb # install, exec_rspec, exec_rubocop
|
|
66
|
+
└── spec/
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Security Model
|
|
70
|
+
|
|
71
|
+
### Allowlisted Commands
|
|
72
|
+
|
|
73
|
+
Only the following base commands are permitted:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
bundle git gh ruby rspec rubocop
|
|
77
|
+
ls cat mkdir cp mv rm touch echo wc head tail
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Blocked Patterns
|
|
81
|
+
|
|
82
|
+
Always rejected regardless of allowlist:
|
|
83
|
+
- `rm -rf /` — root deletion
|
|
84
|
+
- `rm -rf ~` — home deletion
|
|
85
|
+
- `rm -rf ..` — parent directory deletion
|
|
86
|
+
- `sudo` — privilege escalation
|
|
87
|
+
- `chmod 777` — world-writable permissions
|
|
88
|
+
- `curl | sh` — pipe-to-shell download execution
|
|
89
|
+
- Redirects to `/etc` or `/usr`
|
|
90
|
+
|
|
91
|
+
### Limits
|
|
92
|
+
|
|
93
|
+
| Parameter | Default | Maximum |
|
|
94
|
+
|-----------|---------|---------|
|
|
95
|
+
| Timeout | 120,000 ms | 600,000 ms (10 min) |
|
|
96
|
+
| Output size (stdout + stderr) | — | 1,048,576 bytes (1 MB, truncated with flag) |
|
|
97
|
+
| Audit log entries | — | 1,000 (ring buffer, oldest evicted) |
|
|
98
|
+
|
|
99
|
+
## Runner Details
|
|
100
|
+
|
|
101
|
+
### Shell (`Runners::Shell`)
|
|
102
|
+
|
|
103
|
+
`extend self` — all methods callable on the module directly.
|
|
104
|
+
|
|
105
|
+
**`execute(command:, cwd: nil, timeout: 120_000, **)`**
|
|
106
|
+
- Validates command against allowlist and blocked patterns
|
|
107
|
+
- Runs with `Open3.capture3` under a `Timeout::timeout` guard
|
|
108
|
+
- Returns `{ success:, stdout:, stderr:, exit_code:, duration_ms:, truncated: }`
|
|
109
|
+
- On failure: `{ success: false, error: :blocked/:timeout/:exception }`
|
|
110
|
+
|
|
111
|
+
**`audit(limit: 100, **)`**
|
|
112
|
+
- Returns entries from the ring buffer
|
|
113
|
+
- Returns `{ success: true, entries: [], stats: { total:, success:, failure:, avg_duration_ms: } }`
|
|
114
|
+
|
|
115
|
+
### Git (`Runners::Git`)
|
|
116
|
+
|
|
117
|
+
`extend self`.
|
|
118
|
+
|
|
119
|
+
| Method | Notes |
|
|
120
|
+
|--------|-------|
|
|
121
|
+
| `init(path:)` | `git init` |
|
|
122
|
+
| `add(path:, files:)` | `git add` with string or array |
|
|
123
|
+
| `commit(path:, message:)` | `git commit -m` |
|
|
124
|
+
| `push(path:, remote: 'origin', branch: 'main', set_upstream: false)` | `-u` flag when `set_upstream: true` |
|
|
125
|
+
| `status(path:)` | Parses `--porcelain` output into structured form |
|
|
126
|
+
| `create_repo(name:, org:, description: '', public: true)` | `gh repo create --clone`; defaults branch to `main` |
|
|
127
|
+
|
|
128
|
+
### Bundler (`Runners::Bundler`)
|
|
129
|
+
|
|
130
|
+
`extend self`.
|
|
131
|
+
|
|
132
|
+
| Method | Notes |
|
|
133
|
+
|--------|-------|
|
|
134
|
+
| `install(path:)` | 5 min timeout |
|
|
135
|
+
| `exec_rspec(path:, format: 'progress')` | `result[:parsed]` => `{ examples:, failures:, pending:, passed: }` |
|
|
136
|
+
| `exec_rubocop(path:, autocorrect: false)` | `result[:parsed]` => `{ offenses:, files_inspected: }` |
|
|
137
|
+
|
|
138
|
+
## Client
|
|
139
|
+
|
|
140
|
+
`Client.new(base_path: '/path/to/project')` stores `@base_path`. All runner methods delegate to the corresponding `extend self` modules with `path: @base_path`.
|
|
141
|
+
|
|
142
|
+
## Integration Points
|
|
143
|
+
|
|
144
|
+
- **`lex-codegen`**: Natural companion. Codegen produces the file tree; exec runs `bundle install`, `bundle exec rspec`, `bundle exec rubocop`, then commits and pushes.
|
|
145
|
+
- **`lex-swarm-github`**: Swarm pipeline calls exec to validate and publish generated extensions.
|
|
146
|
+
|
|
147
|
+
## Testing
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
bundle install
|
|
151
|
+
bundle exec rspec # 127 examples, 0 failures
|
|
152
|
+
bundle exec rubocop # 0 offenses
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Specs mock `Open3.capture3` and `Timeout::timeout` — no real shell commands execute in tests.
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
**Maintained By**: Matthew Iverson (@Esity)
|
data/Gemfile
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Esity
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# lex-exec
|
|
2
|
+
|
|
3
|
+
Sandboxed shell execution extension for LegionIO. Runs shell commands, git operations, and bundler workflows with allowlist enforcement and an in-memory audit log. Used by agentic swarm pipelines (e.g., `lex-swarm-github`) to validate and publish generated extensions.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add to your `Gemfile`:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem 'lex-exec'
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or install directly:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
gem install lex-exec
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Overview
|
|
20
|
+
|
|
21
|
+
`lex-exec` provides three runners:
|
|
22
|
+
|
|
23
|
+
- **Shell** - Execute arbitrary shell commands against an allowlist
|
|
24
|
+
- **Git** - Common git operations (init, add, commit, push, status, create_repo)
|
|
25
|
+
- **Bundler** - Run `bundle install`, `rspec`, and `rubocop` with structured output parsing
|
|
26
|
+
|
|
27
|
+
All shell execution goes through a `Sandbox` that checks the base command against an allowlist and rejects commands matching blocked patterns. Every execution is recorded in a thread-safe in-memory `AuditLog`.
|
|
28
|
+
|
|
29
|
+
## Allowlisted Commands
|
|
30
|
+
|
|
31
|
+
Only the following base commands are permitted:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
bundle git gh ruby rspec rubocop
|
|
35
|
+
ls cat mkdir cp mv rm touch echo wc head tail
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Commands not in this list are rejected before execution with `success: false, error: :blocked`.
|
|
39
|
+
|
|
40
|
+
## Blocked Patterns
|
|
41
|
+
|
|
42
|
+
The following patterns are always rejected regardless of allowlist membership:
|
|
43
|
+
|
|
44
|
+
- `rm -rf /` (root deletion)
|
|
45
|
+
- `rm -rf ~` (home deletion)
|
|
46
|
+
- `rm -rf ..` (parent directory deletion)
|
|
47
|
+
- `sudo` (privilege escalation)
|
|
48
|
+
- `chmod 777` (world-writable permissions)
|
|
49
|
+
- `curl | sh` (pipe-to-shell download execution)
|
|
50
|
+
- Redirects to `/etc` or `/usr`
|
|
51
|
+
|
|
52
|
+
## Limits
|
|
53
|
+
|
|
54
|
+
| Parameter | Default | Maximum |
|
|
55
|
+
|-----------|---------|---------|
|
|
56
|
+
| Timeout | 120,000 ms | 600,000 ms (10 min) |
|
|
57
|
+
| Output size (stdout/stderr) | — | 1,048,576 bytes (1 MB) |
|
|
58
|
+
| Audit log entries | — | 1,000 (ring buffer) |
|
|
59
|
+
|
|
60
|
+
Output exceeding 1 MB is truncated; `truncated: true` is set in the result and recorded in the audit log.
|
|
61
|
+
|
|
62
|
+
## Usage
|
|
63
|
+
|
|
64
|
+
### Direct runner calls
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
# Shell runner
|
|
68
|
+
result = Legion::Extensions::Exec::Runners::Shell.execute(
|
|
69
|
+
command: 'bundle exec rspec',
|
|
70
|
+
cwd: '/path/to/project',
|
|
71
|
+
timeout: 120_000
|
|
72
|
+
)
|
|
73
|
+
# => { success: true, stdout: "...", stderr: "...", exit_code: 0, duration_ms: 1234, truncated: false }
|
|
74
|
+
|
|
75
|
+
# Retrieve audit log
|
|
76
|
+
audit = Legion::Extensions::Exec::Runners::Shell.audit(limit: 50)
|
|
77
|
+
# => { success: true, entries: [...], stats: { total:, success:, failure:, avg_duration_ms: } }
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Client interface
|
|
81
|
+
|
|
82
|
+
`Legion::Extensions::Exec::Client` provides a unified interface delegating to all three runners:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
client = Legion::Extensions::Exec::Client.new(base_path: '/path/to/project')
|
|
86
|
+
|
|
87
|
+
# Shell
|
|
88
|
+
client.execute(command: 'ls -la')
|
|
89
|
+
client.audit(limit: 25)
|
|
90
|
+
|
|
91
|
+
# Git
|
|
92
|
+
client.init
|
|
93
|
+
client.add(files: ['lib/foo.rb', 'spec/foo_spec.rb'])
|
|
94
|
+
client.commit(message: 'add foo runner')
|
|
95
|
+
client.push(remote: 'origin', branch: 'main', set_upstream: true)
|
|
96
|
+
client.status
|
|
97
|
+
client.create_repo(name: 'lex-foo', org: 'LegionIO', description: 'foo extension', public: true)
|
|
98
|
+
|
|
99
|
+
# Bundler
|
|
100
|
+
client.install
|
|
101
|
+
client.exec_rspec(format: 'progress')
|
|
102
|
+
client.exec_rubocop(autocorrect: false)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Git runner
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
# Initialize a new repo
|
|
109
|
+
Legion::Extensions::Exec::Runners::Git.init(path: '/path/to/dir')
|
|
110
|
+
|
|
111
|
+
# Stage files
|
|
112
|
+
Legion::Extensions::Exec::Runners::Git.add(path: '/path/to/dir', files: '.')
|
|
113
|
+
Legion::Extensions::Exec::Runners::Git.add(path: '/path/to/dir', files: ['file1.rb', 'file2.rb'])
|
|
114
|
+
|
|
115
|
+
# Commit
|
|
116
|
+
Legion::Extensions::Exec::Runners::Git.commit(path: '/path/to/dir', message: 'initial commit')
|
|
117
|
+
|
|
118
|
+
# Push (set_upstream: true adds -u flag)
|
|
119
|
+
Legion::Extensions::Exec::Runners::Git.push(path: '/path/to/dir', remote: 'origin', branch: 'main', set_upstream: true)
|
|
120
|
+
|
|
121
|
+
# Status (parses --porcelain output into structured form)
|
|
122
|
+
Legion::Extensions::Exec::Runners::Git.status(path: '/path/to/dir')
|
|
123
|
+
|
|
124
|
+
# Create GitHub repo via gh CLI
|
|
125
|
+
Legion::Extensions::Exec::Runners::Git.create_repo(
|
|
126
|
+
name: 'lex-myext',
|
|
127
|
+
org: 'LegionIO',
|
|
128
|
+
description: 'my extension',
|
|
129
|
+
public: true
|
|
130
|
+
)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Bundler runner
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
# Install dependencies (5 min timeout)
|
|
137
|
+
Legion::Extensions::Exec::Runners::Bundler.install(path: '/path/to/project')
|
|
138
|
+
|
|
139
|
+
# Run RSpec with parsed output
|
|
140
|
+
result = Legion::Extensions::Exec::Runners::Bundler.exec_rspec(path: '/path/to/project', format: 'progress')
|
|
141
|
+
# result[:parsed] => { examples:, failures:, pending:, passed: }
|
|
142
|
+
|
|
143
|
+
# Run RuboCop with parsed output
|
|
144
|
+
result = Legion::Extensions::Exec::Runners::Bundler.exec_rubocop(path: '/path/to/project')
|
|
145
|
+
# result[:parsed] => { offenses:, files_inspected: }
|
|
146
|
+
|
|
147
|
+
# Run RuboCop with autocorrect
|
|
148
|
+
Legion::Extensions::Exec::Runners::Bundler.exec_rubocop(path: '/path/to/project', autocorrect: true)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Return Value Shape
|
|
152
|
+
|
|
153
|
+
All runners return a hash with at minimum:
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
{
|
|
157
|
+
success: true | false,
|
|
158
|
+
stdout: "...", # present on success
|
|
159
|
+
stderr: "...", # present on success
|
|
160
|
+
exit_code: 0, # present on success
|
|
161
|
+
duration_ms: 123, # present on success
|
|
162
|
+
truncated: false # true if stdout exceeded 1 MB
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
On failure:
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
{ success: false, error: :blocked, reason: "command 'sudo' is not in the allowlist" }
|
|
170
|
+
{ success: false, error: :timeout, timeout_ms: 120_000 }
|
|
171
|
+
{ success: false, error: "invalid argument message" }
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Agentic Pipeline Integration
|
|
175
|
+
|
|
176
|
+
`lex-exec` is designed to work alongside `lex-codegen` in the agentic swarm pipeline:
|
|
177
|
+
|
|
178
|
+
```
|
|
179
|
+
lex-codegen (scaffold_extension) # generates file tree from ERB templates
|
|
180
|
+
|
|
|
181
|
+
v
|
|
182
|
+
lex-exec (Bundler.install) # installs gem dependencies
|
|
183
|
+
|
|
|
184
|
+
v
|
|
185
|
+
lex-exec (Bundler.exec_rspec) # runs test suite, returns pass/fail counts
|
|
186
|
+
|
|
|
187
|
+
v
|
|
188
|
+
lex-exec (Bundler.exec_rubocop) # lints code, returns offense count
|
|
189
|
+
|
|
|
190
|
+
v
|
|
191
|
+
lex-exec (Git.commit + Git.push) # commits and pushes validated extension
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Development
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
bundle install
|
|
198
|
+
bundle exec rspec
|
|
199
|
+
bundle exec rubocop
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## License
|
|
203
|
+
|
|
204
|
+
Apache-2.0
|
data/lex-exec.gemspec
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/legion/extensions/exec/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'lex-exec'
|
|
7
|
+
spec.version = Legion::Extensions::Exec::VERSION
|
|
8
|
+
spec.authors = ['Esity']
|
|
9
|
+
spec.email = ['matthewdiverson@gmail.com']
|
|
10
|
+
|
|
11
|
+
spec.summary = 'LEX::Exec'
|
|
12
|
+
spec.description = 'Safe sandboxed shell execution for LegionIO'
|
|
13
|
+
spec.homepage = 'https://github.com/LegionIO/lex-exec'
|
|
14
|
+
spec.license = 'MIT'
|
|
15
|
+
spec.required_ruby_version = '>= 3.4'
|
|
16
|
+
|
|
17
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
|
18
|
+
spec.metadata['source_code_uri'] = 'https://github.com/LegionIO/lex-exec'
|
|
19
|
+
spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-exec'
|
|
20
|
+
spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-exec'
|
|
21
|
+
spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-exec/issues'
|
|
22
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
23
|
+
|
|
24
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
25
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
spec.require_paths = ['lib']
|
|
29
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Exec
|
|
6
|
+
class Client
|
|
7
|
+
def initialize(base_path: Dir.pwd)
|
|
8
|
+
@base_path = base_path
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Shell delegation
|
|
12
|
+
def execute(command:, cwd: @base_path, **)
|
|
13
|
+
Runners::Shell.execute(command: command, cwd: cwd, **)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def audit(**)
|
|
17
|
+
Runners::Shell.audit(**)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Git delegation
|
|
21
|
+
def init(path: @base_path, **)
|
|
22
|
+
Runners::Git.init(path: path)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def add(path: @base_path, **)
|
|
26
|
+
Runners::Git.add(path: path, **)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def commit(path: @base_path, **)
|
|
30
|
+
Runners::Git.commit(path: path, **)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def push(path: @base_path, **)
|
|
34
|
+
Runners::Git.push(path: path, **)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def status(path: @base_path, **)
|
|
38
|
+
Runners::Git.status(path: path)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def create_repo(**)
|
|
42
|
+
Runners::Git.create_repo(**)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Bundler delegation
|
|
46
|
+
def install(path: @base_path, **)
|
|
47
|
+
Runners::Bundler.install(path: path)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def exec_rspec(path: @base_path, **)
|
|
51
|
+
Runners::Bundler.exec_rspec(path: path, **)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def exec_rubocop(path: @base_path, **)
|
|
55
|
+
Runners::Bundler.exec_rubocop(path: path, **)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Exec
|
|
6
|
+
module Helpers
|
|
7
|
+
class AuditLog
|
|
8
|
+
MAX_ENTRIES = 1000
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@entries = []
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def record(command:, cwd:, exit_code:, duration_ms:, truncated: false)
|
|
16
|
+
entry = {
|
|
17
|
+
command: command,
|
|
18
|
+
cwd: cwd,
|
|
19
|
+
exit_code: exit_code,
|
|
20
|
+
duration_ms: duration_ms,
|
|
21
|
+
truncated: truncated,
|
|
22
|
+
executed_at: Time.now.utc.iso8601
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@mutex.synchronize do
|
|
26
|
+
@entries << entry
|
|
27
|
+
@entries.shift while @entries.size > MAX_ENTRIES
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def entries(limit: 50)
|
|
32
|
+
@mutex.synchronize { @entries.last(limit) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def stats
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
total = @entries.size
|
|
38
|
+
success = @entries.count { |e| e[:exit_code].zero? }
|
|
39
|
+
failure = total - success
|
|
40
|
+
avg_dur = total.zero? ? 0 : (@entries.sum { |e| e[:duration_ms] } / total.to_f).round(2)
|
|
41
|
+
|
|
42
|
+
{ total: total, success: success, failure: failure, avg_duration_ms: avg_dur }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def clear
|
|
47
|
+
@mutex.synchronize { @entries.clear }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Exec
|
|
8
|
+
module Helpers
|
|
9
|
+
module Checkpoint
|
|
10
|
+
class << self
|
|
11
|
+
def save(worktree_path:, label:, task_id:)
|
|
12
|
+
Dir.chdir(worktree_path) do
|
|
13
|
+
Open3.capture3('git', 'add', '-A')
|
|
14
|
+
|
|
15
|
+
tree_sha, _err, tree_status = Open3.capture3('git', 'write-tree')
|
|
16
|
+
return { success: false, reason: :write_tree_failed } unless tree_status.success?
|
|
17
|
+
|
|
18
|
+
tree_sha = tree_sha.strip
|
|
19
|
+
commit_sha, _err, commit_status = Open3.capture3(
|
|
20
|
+
'git', 'commit-tree', tree_sha, '-m', "checkpoint: #{label}"
|
|
21
|
+
)
|
|
22
|
+
return { success: false, reason: :commit_tree_failed } unless commit_status.success?
|
|
23
|
+
|
|
24
|
+
commit_sha = commit_sha.strip
|
|
25
|
+
ref = "refs/checkpoints/#{task_id}/#{label}"
|
|
26
|
+
_out, _err, ref_status = Open3.capture3('git', 'update-ref', ref, commit_sha)
|
|
27
|
+
return { success: false, reason: :update_ref_failed } unless ref_status.success?
|
|
28
|
+
|
|
29
|
+
Open3.capture3('git', 'reset', 'HEAD')
|
|
30
|
+
{ success: true, ref: ref, commit: commit_sha }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def restore(worktree_path:, label:, task_id:)
|
|
35
|
+
ref = "refs/checkpoints/#{task_id}/#{label}"
|
|
36
|
+
Dir.chdir(worktree_path) do
|
|
37
|
+
_stdout, stderr, status = Open3.capture3('git', 'checkout', ref, '--', '.')
|
|
38
|
+
status.success? ? { success: true, ref: ref } : { success: false, message: stderr.strip }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def list_checkpoints(task_id:)
|
|
43
|
+
pattern = "refs/checkpoints/#{task_id}/"
|
|
44
|
+
stdout, = Open3.capture3('git', 'for-each-ref', '--format=%(refname) %(creatordate:iso8601)', pattern)
|
|
45
|
+
checkpoints = stdout.strip.split("\n").filter_map do |line|
|
|
46
|
+
next if line.strip.empty?
|
|
47
|
+
|
|
48
|
+
parts = line.split(' ', 2)
|
|
49
|
+
label = parts[0].sub(pattern, '')
|
|
50
|
+
{ label: label, created_at: parts[1] }
|
|
51
|
+
end
|
|
52
|
+
{ success: true, checkpoints: checkpoints }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def prune(task_id:)
|
|
56
|
+
pattern = "refs/checkpoints/#{task_id}/"
|
|
57
|
+
stdout, = Open3.capture3('git', 'for-each-ref', '--format=%(refname)', pattern)
|
|
58
|
+
refs = stdout.strip.split("\n").reject(&:empty?)
|
|
59
|
+
refs.each { |ref| Open3.capture3('git', 'update-ref', '-d', ref) }
|
|
60
|
+
{ success: true, pruned: refs.size }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Exec
|
|
6
|
+
module Helpers
|
|
7
|
+
module Constants
|
|
8
|
+
DEFAULT_TIMEOUT = 120_000 # 120 seconds in ms
|
|
9
|
+
MAX_TIMEOUT = 600_000 # 10 minutes in ms
|
|
10
|
+
MAX_OUTPUT_BYTES = 1_048_576 # 1 MB
|
|
11
|
+
|
|
12
|
+
ALLOWED_COMMANDS = %w[
|
|
13
|
+
bundle git gh ruby rspec rubocop ls cat mkdir cp mv rm touch echo wc head tail
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
BLOCKED_PATTERNS = [
|
|
17
|
+
%r{rm\s+-rf\s+/},
|
|
18
|
+
/rm\s+-rf\s+~/,
|
|
19
|
+
/rm\s+-rf\s+\.\./,
|
|
20
|
+
/sudo/,
|
|
21
|
+
/chmod\s+777/,
|
|
22
|
+
/curl.*\|.*sh/,
|
|
23
|
+
%r{>\s*/etc},
|
|
24
|
+
%r{>\s*/usr}
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
AUDIT_FIELDS = %i[command cwd exit_code duration_ms executed_at truncated].freeze
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Exec
|
|
6
|
+
module Helpers
|
|
7
|
+
module ResultParser
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def parse_rspec(output)
|
|
11
|
+
examples = 0
|
|
12
|
+
failures = 0
|
|
13
|
+
pending = 0
|
|
14
|
+
|
|
15
|
+
if (match = output.match(/(\d+)\s+examples?,\s+(\d+)\s+failures?/))
|
|
16
|
+
examples = match[1].to_i
|
|
17
|
+
failures = match[2].to_i
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
if (match = output.match(/(\d+)\s+pending/))
|
|
21
|
+
pending = match[1].to_i
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
{ examples: examples, failures: failures, pending: pending, passed: failures.zero? }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def parse_rubocop(output)
|
|
28
|
+
files = 0
|
|
29
|
+
offenses = 0
|
|
30
|
+
|
|
31
|
+
if (match = output.match(/(\d+)\s+files?\s+inspected,\s+(\d+)\s+offenses?\s+detected/))
|
|
32
|
+
files = match[1].to_i
|
|
33
|
+
offenses = match[2].to_i
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
{ files: files, offenses: offenses, clean: offenses.zero? }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def parse_git_status(output)
|
|
40
|
+
modified = []
|
|
41
|
+
untracked = []
|
|
42
|
+
deleted = []
|
|
43
|
+
|
|
44
|
+
output.each_line do |line|
|
|
45
|
+
code = line[0..1].strip
|
|
46
|
+
file = line[3..].strip
|
|
47
|
+
|
|
48
|
+
case code
|
|
49
|
+
when 'M', 'MM', 'AM'
|
|
50
|
+
modified << file
|
|
51
|
+
when '??'
|
|
52
|
+
untracked << file
|
|
53
|
+
when 'D', 'MD'
|
|
54
|
+
deleted << file
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
{
|
|
59
|
+
clean: modified.empty? && untracked.empty? && deleted.empty?,
|
|
60
|
+
modified: modified,
|
|
61
|
+
untracked: untracked,
|
|
62
|
+
deleted: deleted
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Exec
|
|
6
|
+
module Helpers
|
|
7
|
+
class Sandbox
|
|
8
|
+
def initialize(allowed_commands: Helpers::Constants::ALLOWED_COMMANDS,
|
|
9
|
+
blocked_patterns: Helpers::Constants::BLOCKED_PATTERNS)
|
|
10
|
+
@allowed_commands = allowed_commands
|
|
11
|
+
@blocked_patterns = blocked_patterns
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def allowed?(command)
|
|
15
|
+
base = base_command(command)
|
|
16
|
+
|
|
17
|
+
return { allowed: false, reason: "command '#{base}' is not in the allowlist" } unless @allowed_commands.include?(base)
|
|
18
|
+
|
|
19
|
+
@blocked_patterns.each do |pattern|
|
|
20
|
+
return { allowed: false, reason: "command matches blocked pattern: #{pattern.source}" } if pattern.match?(command)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
{ allowed: true, reason: nil }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def sanitize(command)
|
|
27
|
+
command.gsub(/[`$()]/, '')
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def base_command(command)
|
|
33
|
+
command.strip.split(/\s+/).first.to_s
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
module Extensions
|
|
8
|
+
module Exec
|
|
9
|
+
module Helpers
|
|
10
|
+
module Worktree
|
|
11
|
+
class << self
|
|
12
|
+
def create(task_id:, branch: nil, base_ref: 'HEAD')
|
|
13
|
+
branch ||= "legion/#{task_id}"
|
|
14
|
+
path = worktree_path(task_id)
|
|
15
|
+
return { success: false, reason: :already_exists } if Dir.exist?(path)
|
|
16
|
+
|
|
17
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
18
|
+
_stdout, stderr, status = Open3.capture3('git', 'worktree', 'add', path, '-b', branch, base_ref)
|
|
19
|
+
if status.success?
|
|
20
|
+
{ success: true, path: path, branch: branch }
|
|
21
|
+
else
|
|
22
|
+
{ success: false, reason: :git_error, message: stderr.strip }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def remove(task_id:)
|
|
27
|
+
path = worktree_path(task_id)
|
|
28
|
+
return { success: false, reason: :not_found } unless Dir.exist?(path)
|
|
29
|
+
|
|
30
|
+
_stdout, stderr, status = Open3.capture3('git', 'worktree', 'remove', path, '--force')
|
|
31
|
+
if status.success?
|
|
32
|
+
{ success: true }
|
|
33
|
+
else
|
|
34
|
+
{ success: false, reason: :git_error, message: stderr.strip }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def list
|
|
39
|
+
stdout, _stderr, _status = Open3.capture3('git', 'worktree', 'list', '--porcelain')
|
|
40
|
+
worktrees = parse_worktree_list(stdout)
|
|
41
|
+
{ success: true, worktrees: worktrees }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def worktree_path(task_id)
|
|
45
|
+
base = Legion::Settings.dig(:worktree, :base_dir) if defined?(Legion::Settings)
|
|
46
|
+
File.join(base || File.join(Dir.pwd, '.legion-worktrees'), task_id.to_s)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def parse_worktree_list(output)
|
|
52
|
+
output.split("\n\n").filter_map do |block|
|
|
53
|
+
lines = block.strip.split("\n")
|
|
54
|
+
next if lines.empty?
|
|
55
|
+
|
|
56
|
+
path = lines.find { |l| l.start_with?('worktree ') }&.sub('worktree ', '')
|
|
57
|
+
branch = lines.find { |l| l.start_with?('branch ') }&.sub('branch ', '')
|
|
58
|
+
{ path: path, branch: branch } if path
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Exec
|
|
6
|
+
module Runners
|
|
7
|
+
module Bundler
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def install(path:, **)
|
|
11
|
+
Runners::Shell.execute(command: 'bundle install', cwd: path, timeout: 300_000)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def exec_rspec(path:, format: 'progress', **)
|
|
15
|
+
result = Runners::Shell.execute(
|
|
16
|
+
command: "bundle exec rspec --format #{format}",
|
|
17
|
+
cwd: path,
|
|
18
|
+
timeout: 300_000
|
|
19
|
+
)
|
|
20
|
+
return result unless result[:stdout] || result[:stderr]
|
|
21
|
+
|
|
22
|
+
raw = result[:stdout] || result[:stderr] || ''
|
|
23
|
+
parsed = Helpers::ResultParser.parse_rspec(raw)
|
|
24
|
+
result.merge(parsed: parsed)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def exec_rubocop(path:, autocorrect: false, **)
|
|
28
|
+
cmd = autocorrect ? 'bundle exec rubocop -A' : 'bundle exec rubocop'
|
|
29
|
+
result = Runners::Shell.execute(command: cmd, cwd: path, timeout: 120_000)
|
|
30
|
+
return result unless result[:stdout]
|
|
31
|
+
|
|
32
|
+
parsed = Helpers::ResultParser.parse_rubocop(result[:stdout] || '')
|
|
33
|
+
result.merge(parsed: parsed)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Exec
|
|
6
|
+
module Runners
|
|
7
|
+
module Git
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def init(path:, **)
|
|
11
|
+
Runners::Shell.execute(command: 'git init', cwd: path)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def add(path:, files: '.', **)
|
|
15
|
+
cmd = files == '.' ? 'git add -A' : "git add #{Array(files).join(' ')}"
|
|
16
|
+
Runners::Shell.execute(command: cmd, cwd: path)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def commit(path:, message:, **)
|
|
20
|
+
safe_msg = message.gsub("'", "\\'")
|
|
21
|
+
Runners::Shell.execute(command: "git commit -m '#{safe_msg}'", cwd: path)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def push(path:, remote: 'origin', branch: 'main', set_upstream: false, **)
|
|
25
|
+
cmd = set_upstream ? "git push -u #{remote} #{branch}" : 'git push'
|
|
26
|
+
Runners::Shell.execute(command: cmd, cwd: path)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def status(path:, **)
|
|
30
|
+
result = Runners::Shell.execute(command: 'git status --porcelain', cwd: path)
|
|
31
|
+
return result unless result[:success]
|
|
32
|
+
|
|
33
|
+
parsed = Helpers::ResultParser.parse_git_status(result[:stdout] || '')
|
|
34
|
+
result.merge(parsed: parsed)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def create_repo(name:, org: 'LegionIO', description: '', public: true, **)
|
|
38
|
+
visibility = public ? '--public' : '--private'
|
|
39
|
+
Runners::Shell.execute(
|
|
40
|
+
command: "gh repo create #{org}/#{name} #{visibility} --description '#{description}' --clone",
|
|
41
|
+
cwd: Dir.pwd
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'timeout'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
module Extensions
|
|
8
|
+
module Exec
|
|
9
|
+
module Runners
|
|
10
|
+
module Shell
|
|
11
|
+
extend self
|
|
12
|
+
|
|
13
|
+
def execute(command:, cwd: Dir.pwd, timeout: Helpers::Constants::DEFAULT_TIMEOUT, env: {}, **)
|
|
14
|
+
check = default_sandbox.allowed?(command)
|
|
15
|
+
return { success: false, error: :blocked, reason: check[:reason] } unless check[:allowed]
|
|
16
|
+
|
|
17
|
+
start_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
18
|
+
timeout_secs = [timeout, Helpers::Constants::MAX_TIMEOUT].min / 1000.0
|
|
19
|
+
|
|
20
|
+
begin
|
|
21
|
+
stdout, stderr, status = Timeout.timeout(timeout_secs) do
|
|
22
|
+
Open3.capture3(env, command, chdir: cwd)
|
|
23
|
+
end
|
|
24
|
+
rescue Timeout::Error
|
|
25
|
+
return { success: false, error: :timeout, timeout_ms: timeout }
|
|
26
|
+
rescue ArgumentError => e
|
|
27
|
+
return { success: false, error: e.message }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
duration_ms = ((::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start_time) * 1000).round
|
|
31
|
+
exit_code = status.exitstatus.to_i
|
|
32
|
+
truncated = false
|
|
33
|
+
|
|
34
|
+
if stdout.bytesize > Helpers::Constants::MAX_OUTPUT_BYTES
|
|
35
|
+
stdout = stdout.byteslice(0, Helpers::Constants::MAX_OUTPUT_BYTES)
|
|
36
|
+
truncated = true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
stderr = stderr.byteslice(0, Helpers::Constants::MAX_OUTPUT_BYTES) if stderr.bytesize > Helpers::Constants::MAX_OUTPUT_BYTES
|
|
40
|
+
|
|
41
|
+
audit_log.record(command: command, cwd: cwd, exit_code: exit_code,
|
|
42
|
+
duration_ms: duration_ms, truncated: truncated)
|
|
43
|
+
|
|
44
|
+
Legion::Logging.debug("[lex-exec] exit=#{exit_code} duration=#{duration_ms}ms cmd=#{command}")
|
|
45
|
+
|
|
46
|
+
{
|
|
47
|
+
success: exit_code.zero?,
|
|
48
|
+
stdout: stdout,
|
|
49
|
+
stderr: stderr,
|
|
50
|
+
exit_code: exit_code,
|
|
51
|
+
duration_ms: duration_ms,
|
|
52
|
+
truncated: truncated
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def audit(limit: 50, **)
|
|
57
|
+
{ success: true, entries: audit_log.entries(limit: limit), stats: audit_log.stats }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def default_sandbox
|
|
63
|
+
@default_sandbox ||= Helpers::Sandbox.new
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def audit_log
|
|
67
|
+
@audit_log ||= Helpers::AuditLog.new
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require_relative 'exec/version'
|
|
5
|
+
require_relative 'exec/helpers/constants'
|
|
6
|
+
require_relative 'exec/helpers/sandbox'
|
|
7
|
+
require_relative 'exec/helpers/result_parser'
|
|
8
|
+
require_relative 'exec/helpers/audit_log'
|
|
9
|
+
require_relative 'exec/helpers/checkpoint'
|
|
10
|
+
require_relative 'exec/helpers/worktree'
|
|
11
|
+
require_relative 'exec/runners/shell'
|
|
12
|
+
require_relative 'exec/runners/git'
|
|
13
|
+
require_relative 'exec/runners/bundler'
|
|
14
|
+
require_relative 'exec/client'
|
|
15
|
+
|
|
16
|
+
module Legion
|
|
17
|
+
module Extensions
|
|
18
|
+
module Exec
|
|
19
|
+
extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lex-exec
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -15,7 +15,29 @@ email:
|
|
|
15
15
|
executables: []
|
|
16
16
|
extensions: []
|
|
17
17
|
extra_rdoc_files: []
|
|
18
|
-
files:
|
|
18
|
+
files:
|
|
19
|
+
- ".github/workflows/ci.yml"
|
|
20
|
+
- ".gitignore"
|
|
21
|
+
- ".rspec"
|
|
22
|
+
- ".rubocop.yml"
|
|
23
|
+
- CHANGELOG.md
|
|
24
|
+
- CLAUDE.md
|
|
25
|
+
- Gemfile
|
|
26
|
+
- LICENSE
|
|
27
|
+
- README.md
|
|
28
|
+
- lex-exec.gemspec
|
|
29
|
+
- lib/legion/extensions/exec.rb
|
|
30
|
+
- lib/legion/extensions/exec/client.rb
|
|
31
|
+
- lib/legion/extensions/exec/helpers/audit_log.rb
|
|
32
|
+
- lib/legion/extensions/exec/helpers/checkpoint.rb
|
|
33
|
+
- lib/legion/extensions/exec/helpers/constants.rb
|
|
34
|
+
- lib/legion/extensions/exec/helpers/result_parser.rb
|
|
35
|
+
- lib/legion/extensions/exec/helpers/sandbox.rb
|
|
36
|
+
- lib/legion/extensions/exec/helpers/worktree.rb
|
|
37
|
+
- lib/legion/extensions/exec/runners/bundler.rb
|
|
38
|
+
- lib/legion/extensions/exec/runners/git.rb
|
|
39
|
+
- lib/legion/extensions/exec/runners/shell.rb
|
|
40
|
+
- lib/legion/extensions/exec/version.rb
|
|
19
41
|
homepage: https://github.com/LegionIO/lex-exec
|
|
20
42
|
licenses:
|
|
21
43
|
- MIT
|