Compare commits

..

6 Commits

Author SHA1 Message Date
dependabot[bot] 511504c2eb
Bump the all-updates group across 1 directory with 4 updates (#54)
* Bump the all-updates group across 1 directory with 4 updates

Bumps the all-updates group with 4 updates in the / directory: [golang.design/x/clipboard](https://github.com/golang-design/clipboard), [golang.org/x/sync](https://github.com/golang/sync), [golang.org/x/sys](https://github.com/golang/sys) and [google.golang.org/grpc](https://github.com/grpc/grpc-go).


Updates `golang.design/x/clipboard` from 0.7.1 to 0.8.0
- [Release notes](https://github.com/golang-design/clipboard/releases)
- [Commits](https://github.com/golang-design/clipboard/compare/v0.7.1...v0.8.0)

Updates `golang.org/x/sync` from 0.20.0 to 0.22.0
- [Commits](https://github.com/golang/sync/compare/v0.20.0...v0.22.0)

Updates `golang.org/x/sys` from 0.44.0 to 0.47.0
- [Commits](https://github.com/golang/sys/compare/v0.44.0...v0.47.0)

Updates `google.golang.org/grpc` from 1.81.0 to 1.83.0
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.81.0...v1.83.0)

---
updated-dependencies:
- dependency-name: golang.design/x/clipboard
  dependency-version: 0.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-updates
- dependency-name: golang.org/x/sync
  dependency-version: 0.22.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-updates
- dependency-name: golang.org/x/sys
  dependency-version: 0.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-updates
- dependency-name: google.golang.org/grpc
  dependency-version: 1.82.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-updates
...

Signed-off-by: dependabot[bot] <support@github.com>

* Adapt to clipboard watcher data

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nikolay Edigaryev <edi@openai.com>
2026-08-11 00:20:33 +01:00
edi-oai 721b111c0c
Exec as user, out-of-band signalling, and stdin closure when the request stream ends (#58)
* Support running exec commands as a selected user

* Move exec signaling out of band

* Close standard input when the exec request stream ends
2026-08-10 23:23:07 +01:00
edi-oai d4ebcbde41
Emit an explicit "Started" message and support sending signals (#56)
* Emit an explicit "Started" message and support sending signals

Co-authored-by: Fedor Korotkov <fkorotkov@openai.com>

* Run exec commands in dedicated process groups

* Disable wsl_v5 linter

* Clean up exec processes on stream failures

* Fix gosec linter error

* Wrap exec signals in a message

---------

Co-authored-by: Fedor Korotkov <fkorotkov@openai.com>
2026-08-10 21:08:27 +01:00
Fedor Kororkov df114a194a
Migrate CI from Cirrus CI to GitHub Actions (#52)
* Migrate CI to GitHub Actions

* Publish Homebrew formula with release app

* Keep release dry runs outside publish environment

* Use GitHub-hosted macOS 26 runners

* Address CI migration review feedback
2026-07-16 11:02:55 -04:00
Fedor Kororkov 4cb69eb583
Update license copyright holder (#51)
* Update license copyright holder

* Use project lifetime in copyright notice

* Use ALv2 license terminology

* Update LICENSE

Co-authored-by: edi-oai <edi@openai.com>

---------

Co-authored-by: edi-oai <edi@openai.com>
2026-07-16 10:01:47 -04:00
dependabot[bot] 87b0e2d9c8
Bump the all-updates group across 1 directory with 5 updates (#46)
Bumps the all-updates group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [github.com/Masterminds/semver/v3](https://github.com/Masterminds/semver) | `3.4.0` | `3.5.0` |
| [github.com/hashicorp/go-version](https://github.com/hashicorp/go-version) | `1.8.0` | `1.9.0` |
| [go.uber.org/zap](https://github.com/uber-go/zap) | `1.27.1` | `1.28.0` |
| [golang.org/x/sys](https://github.com/golang/sys) | `0.42.0` | `0.44.0` |
| [google.golang.org/grpc](https://github.com/grpc/grpc-go) | `1.79.3` | `1.81.0` |



Updates `github.com/Masterminds/semver/v3` from 3.4.0 to 3.5.0
- [Release notes](https://github.com/Masterminds/semver/releases)
- [Changelog](https://github.com/Masterminds/semver/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Masterminds/semver/compare/v3.4.0...v3.5.0)

Updates `github.com/hashicorp/go-version` from 1.8.0 to 1.9.0
- [Release notes](https://github.com/hashicorp/go-version/releases)
- [Changelog](https://github.com/hashicorp/go-version/blob/main/CHANGELOG.md)
- [Commits](https://github.com/hashicorp/go-version/compare/v1.8.0...v1.9.0)

Updates `go.uber.org/zap` from 1.27.1 to 1.28.0
- [Release notes](https://github.com/uber-go/zap/releases)
- [Changelog](https://github.com/uber-go/zap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/uber-go/zap/compare/v1.27.1...v1.28.0)

Updates `golang.org/x/sys` from 0.42.0 to 0.44.0
- [Commits](https://github.com/golang/sys/compare/v0.42.0...v0.44.0)

Updates `google.golang.org/grpc` from 1.79.3 to 1.81.0
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.79.3...v1.81.0)

---
updated-dependencies:
- dependency-name: github.com/hashicorp/go-version
  dependency-version: 1.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-updates
- dependency-name: github.com/Masterminds/semver/v3
  dependency-version: 3.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-updates
- dependency-name: go.uber.org/zap
  dependency-version: 1.28.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-updates
- dependency-name: golang.org/x/sys
  dependency-version: 0.43.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-updates
- dependency-name: google.golang.org/grpc
  dependency-version: 1.81.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-12 22:27:32 +02:00
15 changed files with 1123 additions and 242 deletions

View File

@ -1,91 +0,0 @@
task:
name: Lint
container:
image: golangci/golangci-lint:latest
cpu: 2
memory: 4096
prepare_script:
- apt-get update
- apt-get install -y libx11-dev
lint_script:
- golangci-lint run -v --output.json.path golangci.json
always:
report_artifacts:
path: golangci.json
type: text/json
format: golangci
task:
name: Test (Linux)
alias: Tests
container:
image: golang:latest
prepare_script:
- apt-get update
- apt-get install -y libx11-dev
test_script: go test -v ./...
task:
name: Test (macOS)
alias: Tests
macos_instance:
image: ghcr.io/cirruslabs/macos-runner:sequoia
prepare_script: brew install go
test_script: go test -v ./...
task:
name: Check for lacking "buf generate" invocation
container:
image: golang:latest
install_buf_script: go install github.com/bufbuild/buf/cmd/buf@v1.50.0
generate_script: buf generate
check_script: git diff --exit-code
task:
only_if: $CIRRUS_BRANCH != '' && $CIRRUS_PR == '' && $CIRRUS_REPO_OWNER == 'cirruslabs'
name: buf push
container:
image: bufbuild/buf
login_script: echo "$BUF_TOKEN" | buf registry login --token-stdin
push_script: buf push --git-metadata
env:
BUF_TOKEN: ENCRYPTED[!8ee7eb2504cc84b08d4a7c0dacbe103640b1feaa26d06f0df010784e872d39e65a0cdea3fc7c09b065a917a77113b96b!]
task:
name: Release (Dry Run)
only_if: $CIRRUS_TAG == ''
macos_instance:
image: ghcr.io/cirruslabs/macos-runner:sequoia
depends_on:
- Lint
- Tests
install_script:
- brew install go
- brew install --cask goreleaser/tap/goreleaser-pro
release_script: goreleaser release --clean --snapshot
goreleaser_artifacts:
path: "dist/**"
task:
name: Release
only_if: $CIRRUS_TAG != ''
macos_instance:
image: ghcr.io/cirruslabs/macos-runner:sequoia
depends_on:
- Lint
- Tests
env:
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
FURY_TOKEN: ENCRYPTED[!97fe4497d9aca60a3d64904883b81e21f19706c6aedda625c97f62f67ec46b8efa74c55699956158bbf0a23726e7d9f6!]
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
install_script:
- brew install go
- brew install --cask goreleaser/tap/goreleaser-pro
release_script: goreleaser

59
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,59 @@
name: CI
on:
merge_group:
pull_request:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
pull-requests: read
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install --yes libx11-dev
- uses: golangci/golangci-lint-action@v9
with:
version: v2.12.0
only-new-issues: true
test-linux:
name: Test (Linux)
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install --yes libx11-dev
- name: Run tests
run: go test -v ./...
test-macos:
name: Test (macOS)
runs-on: macos-26
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Run tests
run: go test -v ./...

82
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,82 @@
name: Release
on:
push:
tags:
- "*"
workflow_dispatch:
permissions:
contents: read
jobs:
release:
name: Release
if: github.ref_type == 'tag'
runs-on: macos-26
timeout-minutes: 60
environment: publish
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Create release app token for this repository
id: release-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
permission-contents: write
- name: Create release app token for homebrew-tools
id: tap-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
owner: openai
repositories: homebrew-tools
permission-contents: write
permission-pull-requests: write
- name: Release
uses: goreleaser/goreleaser-action@v7
with:
distribution: goreleaser-pro
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ steps.release-token.outputs.token }}
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
HOMEBREW_TAP_GITHUB_TOKEN: ${{ steps.tap-token.outputs.token }}
dry-run:
name: Release (Dry Run)
if: github.ref_type != 'tag'
runs-on: macos-26
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Release dry run
uses: goreleaser/goreleaser-action@v7
with:
distribution: goreleaser-pro
version: "~> v2"
args: release --skip=publish --snapshot --clean
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Upload dry-run artifacts
uses: actions/upload-artifact@v6
with:
name: tart-guest-agent-snapshot
path: dist/**

View File

@ -1,21 +1,21 @@
version: 2
version: "2"
run:
timeout: 5m
linters-settings:
# Even in Rust you can get away with partial matching,
# so make sure that the linter respects the programmer's
# intent expressed in the form of "default" case.
exhaustive:
default-signifies-exhaustive: true
gosec:
excludes:
- G115
linters:
enable-all: true
default: all
settings:
# Even in Rust you can get away with partial matching,
# so make sure that the linter respects the programmer's
# intent expressed in the form of "default" case.
exhaustive:
default-signifies-exhaustive: true
gosec:
excludes:
- G115
disable:
# We don't have high-performance requirements at this moment, so sacrificing
@ -32,6 +32,7 @@ linters:
# Style linters that are total nuts.
- wsl
- wsl_v5
- funlen
# Enough parallelism for now.
@ -61,6 +62,15 @@ linters:
# Not all errors need to be checked
- errcheck
# It's OK to not initialize some struct fields
- exhaustruct
# This is not a library, so it's OK to use dynamic errors
- err113
# Inline error handling keeps assignment and checking together
- noinlineerr
issues:
# Don't hide multiple issues that belong to one class since GitHub annotations can handle them all nicely.
max-issues-per-linter: 0

View File

@ -50,10 +50,15 @@ release:
brews:
- name: "{{ .ProjectName }}"
directory: Formula
repository:
owner: cirruslabs
name: homebrew-cli
homepage: https://github.com/cirruslabs/tart-guest-agent
owner: openai
name: homebrew-tools
token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
branch: "{{ .ProjectName }}-{{ .Version }}"
pull_request:
enabled: true
homepage: https://github.com/openai/tart-guest-agent
license: FSL-1.1-Apache-2.0
description: Guest agent for Tart VMs
skip_upload: auto
@ -79,6 +84,3 @@ nfpms:
postinstall: packaging/postinstall.sh
preremove: packaging/preremove.sh
postremove: packaging/postremove.sh
furies:
- account: cirruslabs

View File

@ -1,12 +1,12 @@
# Functional Source License, Version 1.1, Apache 2.0 Future License
# Functional Source License, Version 1.1, ALv2 Future License
## Abbreviation
FSL-1.1-Apache-2.0
FSL-1.1-ALv2
## Notice
Copyright 2025 Cirrus Labs, Inc.
Copyright 2025-2026 OpenAI
## Terms and Conditions

24
go.mod
View File

@ -3,33 +3,37 @@ module github.com/cirruslabs/tart-guest-agent
go 1.25.0
require (
github.com/Masterminds/semver/v3 v3.4.0
github.com/Masterminds/semver/v3 v3.5.0
github.com/cenkalti/backoff/v5 v5.0.3
github.com/creack/pty v1.1.24
github.com/hashicorp/go-version v1.8.0
github.com/google/uuid v1.6.0
github.com/hashicorp/go-version v1.9.0
github.com/puzpuzpuz/xsync/v4 v4.5.0
github.com/samber/lo v1.53.0
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
go.uber.org/zap v1.27.1
golang.design/x/clipboard v0.7.1
golang.org/x/sync v0.20.0
golang.org/x/sys v0.42.0
google.golang.org/grpc v1.79.3
go.uber.org/zap v1.28.0
golang.design/x/clipboard v0.8.0
golang.org/x/sync v0.22.0
golang.org/x/sys v0.47.0
google.golang.org/grpc v1.83.0
google.golang.org/protobuf v1.36.11
howett.net/plist v1.0.1
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/ebitengine/purego v0.10.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.design/x/x11 v0.2.0 // indirect
golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 // indirect
golang.org/x/image v0.28.0 // indirect
golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/text v0.37.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

71
go.sum
View File

@ -1,6 +1,6 @@
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@ -10,6 +10,8 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@ -20,13 +22,15 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/puzpuzpuz/xsync/v4 v4.5.0 h1:vOSWu6b57/emh+L/Cw0BeQfvxa/cogFywXHeGUxQxAg=
github.com/puzpuzpuz/xsync/v4 v4.5.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
@ -38,25 +42,28 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.design/x/clipboard v0.7.1 h1:OEG3CmcYRBNnRwpDp7+uWLiZi3hrMRJpE9JkkkYtz2c=
golang.design/x/clipboard v0.7.1/go.mod h1:i5SiIqj0wLFw9P/1D7vfILFK0KHMk7ydE72HRrUIgkg=
golang.design/x/clipboard v0.8.0 h1:6VEcH28wwcSgKc+vnxHHDWiRjrakTQIAJnPUrt3aOgg=
golang.design/x/clipboard v0.8.0/go.mod h1:s0pwrtA3Q9fgnVtGDmP5ZK/pp55cQKB23esKsjwWhWM=
golang.design/x/x11 v0.2.0 h1:Uiwu2guGihsJX/ZCzpoDPFz5gR/Qntm08mvoBCmRydo=
golang.design/x/x11 v0.2.0/go.mod h1:/5q1mFkdc1rL8mvB7DsQFi6as4tIkBv4FXjcP07mrkE=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 h1:estk1glOnSVeJ9tdEZZc5mAMDZk5lNJNyJ6DvrBkTEU=
@ -70,24 +77,24 @@ golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f/go.mod h1:ESkJ836Z6LpG6mT
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc (unknown)
// protoc v7.35.1
// source: rpc/agent.proto
package rpc
@ -9,7 +9,7 @@ package rpc
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
_ "google.golang.org/protobuf/types/known/emptypb"
emptypb "google.golang.org/protobuf/types/known/emptypb"
reflect "reflect"
sync "sync"
unsafe "unsafe"
@ -22,6 +22,55 @@ const (
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type SignalRequest_Signal int32
const (
SignalRequest_SIGNAL_UNSPECIFIED SignalRequest_Signal = 0
SignalRequest_SIGNAL_SIGTERM SignalRequest_Signal = 1
SignalRequest_SIGNAL_SIGKILL SignalRequest_Signal = 2
)
// Enum value maps for SignalRequest_Signal.
var (
SignalRequest_Signal_name = map[int32]string{
0: "SIGNAL_UNSPECIFIED",
1: "SIGNAL_SIGTERM",
2: "SIGNAL_SIGKILL",
}
SignalRequest_Signal_value = map[string]int32{
"SIGNAL_UNSPECIFIED": 0,
"SIGNAL_SIGTERM": 1,
"SIGNAL_SIGKILL": 2,
}
)
func (x SignalRequest_Signal) Enum() *SignalRequest_Signal {
p := new(SignalRequest_Signal)
*p = x
return p
}
func (x SignalRequest_Signal) String() string {
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
}
func (SignalRequest_Signal) Descriptor() protoreflect.EnumDescriptor {
return file_rpc_agent_proto_enumTypes[0].Descriptor()
}
func (SignalRequest_Signal) Type() protoreflect.EnumType {
return &file_rpc_agent_proto_enumTypes[0]
}
func (x SignalRequest_Signal) Number() protoreflect.EnumNumber {
return protoreflect.EnumNumber(x)
}
// Deprecated: Use SignalRequest_Signal.Descriptor instead.
func (SignalRequest_Signal) EnumDescriptor() ([]byte, []int) {
return file_rpc_agent_proto_rawDescGZIP(), []int{6, 0}
}
type ExecRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Types that are valid to be assigned to Type:
@ -127,6 +176,7 @@ type ExecResponse struct {
// *ExecResponse_Exit_
// *ExecResponse_StandardOutput
// *ExecResponse_StandardError
// *ExecResponse_Started_
Type isExecResponse_Type `protobuf_oneof:"type"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
@ -196,6 +246,15 @@ func (x *ExecResponse) GetStandardError() *IOChunk {
return nil
}
func (x *ExecResponse) GetStarted() *ExecResponse_Started {
if x != nil {
if x, ok := x.Type.(*ExecResponse_Started_); ok {
return x.Started
}
}
return nil
}
type isExecResponse_Type interface {
isExecResponse_Type()
}
@ -212,12 +271,18 @@ type ExecResponse_StandardError struct {
StandardError *IOChunk `protobuf:"bytes,3,opt,name=standard_error,json=standardError,proto3,oneof"`
}
type ExecResponse_Started_ struct {
Started *ExecResponse_Started `protobuf:"bytes,4,opt,name=started,proto3,oneof"`
}
func (*ExecResponse_Exit_) isExecResponse_Type() {}
func (*ExecResponse_StandardOutput) isExecResponse_Type() {}
func (*ExecResponse_StandardError) isExecResponse_Type() {}
func (*ExecResponse_Started_) isExecResponse_Type() {}
type TerminalSize struct {
state protoimpl.MessageState `protogen:"open.v1"`
Rows uint32 `protobuf:"varint,1,opt,name=rows,proto3" json:"rows,omitempty"`
@ -394,6 +459,58 @@ func (x *ResolveIPResponse) GetIp() string {
return ""
}
type SignalRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
ExecId string `protobuf:"bytes,1,opt,name=exec_id,json=execId,proto3" json:"exec_id,omitempty"`
Signal SignalRequest_Signal `protobuf:"varint,2,opt,name=signal,proto3,enum=SignalRequest_Signal" json:"signal,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SignalRequest) Reset() {
*x = SignalRequest{}
mi := &file_rpc_agent_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SignalRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SignalRequest) ProtoMessage() {}
func (x *SignalRequest) ProtoReflect() protoreflect.Message {
mi := &file_rpc_agent_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SignalRequest.ProtoReflect.Descriptor instead.
func (*SignalRequest) Descriptor() ([]byte, []int) {
return file_rpc_agent_proto_rawDescGZIP(), []int{6}
}
func (x *SignalRequest) GetExecId() string {
if x != nil {
return x.ExecId
}
return ""
}
func (x *SignalRequest) GetSignal() SignalRequest_Signal {
if x != nil {
return x.Signal
}
return SignalRequest_SIGNAL_UNSPECIFIED
}
type ExecRequest_Command struct {
state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
@ -404,13 +521,14 @@ type ExecRequest_Command struct {
Detach bool `protobuf:"varint,6,opt,name=detach,proto3" json:"detach,omitempty"`
Env map[string]string `protobuf:"bytes,7,rep,name=env,proto3" json:"env,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
Workdir string `protobuf:"bytes,8,opt,name=workdir,proto3" json:"workdir,omitempty"`
User string `protobuf:"bytes,9,opt,name=user,proto3" json:"user,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ExecRequest_Command) Reset() {
*x = ExecRequest_Command{}
mi := &file_rpc_agent_proto_msgTypes[6]
mi := &file_rpc_agent_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -422,7 +540,7 @@ func (x *ExecRequest_Command) String() string {
func (*ExecRequest_Command) ProtoMessage() {}
func (x *ExecRequest_Command) ProtoReflect() protoreflect.Message {
mi := &file_rpc_agent_proto_msgTypes[6]
mi := &file_rpc_agent_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -494,6 +612,13 @@ func (x *ExecRequest_Command) GetWorkdir() string {
return ""
}
func (x *ExecRequest_Command) GetUser() string {
if x != nil {
return x.User
}
return ""
}
type ExecResponse_Exit struct {
state protoimpl.MessageState `protogen:"open.v1"`
Code int32 `protobuf:"varint,1,opt,name=code,proto3" json:"code,omitempty"`
@ -503,7 +628,7 @@ type ExecResponse_Exit struct {
func (x *ExecResponse_Exit) Reset() {
*x = ExecResponse_Exit{}
mi := &file_rpc_agent_proto_msgTypes[8]
mi := &file_rpc_agent_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -515,7 +640,7 @@ func (x *ExecResponse_Exit) String() string {
func (*ExecResponse_Exit) ProtoMessage() {}
func (x *ExecResponse_Exit) ProtoReflect() protoreflect.Message {
mi := &file_rpc_agent_proto_msgTypes[8]
mi := &file_rpc_agent_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -538,15 +663,59 @@ func (x *ExecResponse_Exit) GetCode() int32 {
return 0
}
type ExecResponse_Started struct {
state protoimpl.MessageState `protogen:"open.v1"`
ExecId string `protobuf:"bytes,1,opt,name=exec_id,json=execId,proto3" json:"exec_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ExecResponse_Started) Reset() {
*x = ExecResponse_Started{}
mi := &file_rpc_agent_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ExecResponse_Started) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ExecResponse_Started) ProtoMessage() {}
func (x *ExecResponse_Started) ProtoReflect() protoreflect.Message {
mi := &file_rpc_agent_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ExecResponse_Started.ProtoReflect.Descriptor instead.
func (*ExecResponse_Started) Descriptor() ([]byte, []int) {
return file_rpc_agent_proto_rawDescGZIP(), []int{1, 1}
}
func (x *ExecResponse_Started) GetExecId() string {
if x != nil {
return x.ExecId
}
return ""
}
var File_rpc_agent_proto protoreflect.FileDescriptor
const file_rpc_agent_proto_rawDesc = "" +
"\n" +
"\x0frpc/agent.proto\x1a\x1bgoogle/protobuf/empty.proto\"\xeb\x03\n" +
"\x0frpc/agent.proto\x1a\x1bgoogle/protobuf/empty.proto\"\xff\x03\n" +
"\vExecRequest\x120\n" +
"\acommand\x18\x01 \x01(\v2\x14.ExecRequest.CommandH\x00R\acommand\x121\n" +
"\x0estandard_input\x18\x02 \x01(\v2\b.IOChunkH\x00R\rstandardInput\x128\n" +
"\x0fterminal_resize\x18\x03 \x01(\v2\r.TerminalSizeH\x00R\x0eterminalResize\x1a\xb4\x02\n" +
"\x0fterminal_resize\x18\x03 \x01(\v2\r.TerminalSizeH\x00R\x0eterminalResize\x1a\xc8\x02\n" +
"\aCommand\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12\x12\n" +
"\x04args\x18\x02 \x03(\tR\x04args\x12 \n" +
@ -555,17 +724,21 @@ const file_rpc_agent_proto_rawDesc = "" +
"\rterminal_size\x18\x05 \x01(\v2\r.TerminalSizeR\fterminalSize\x12\x16\n" +
"\x06detach\x18\x06 \x01(\bR\x06detach\x12/\n" +
"\x03env\x18\a \x03(\v2\x1d.ExecRequest.Command.EnvEntryR\x03env\x12\x18\n" +
"\aworkdir\x18\b \x01(\tR\aworkdir\x1a6\n" +
"\aworkdir\x18\b \x01(\tR\aworkdir\x12\x12\n" +
"\x04user\x18\t \x01(\tR\x04user\x1a6\n" +
"\bEnvEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x06\n" +
"\x04type\"\xc4\x01\n" +
"\x04type\"\x9b\x02\n" +
"\fExecResponse\x12(\n" +
"\x04exit\x18\x01 \x01(\v2\x12.ExecResponse.ExitH\x00R\x04exit\x123\n" +
"\x0fstandard_output\x18\x02 \x01(\v2\b.IOChunkH\x00R\x0estandardOutput\x121\n" +
"\x0estandard_error\x18\x03 \x01(\v2\b.IOChunkH\x00R\rstandardError\x1a\x1a\n" +
"\x0estandard_error\x18\x03 \x01(\v2\b.IOChunkH\x00R\rstandardError\x121\n" +
"\astarted\x18\x04 \x01(\v2\x15.ExecResponse.StartedH\x00R\astarted\x1a\x1a\n" +
"\x04Exit\x12\x12\n" +
"\x04code\x18\x01 \x01(\x05R\x04codeB\x06\n" +
"\x04code\x18\x01 \x01(\x05R\x04code\x1a\"\n" +
"\aStarted\x12\x17\n" +
"\aexec_id\x18\x01 \x01(\tR\x06execIdB\x06\n" +
"\x04type\"6\n" +
"\fTerminalSize\x12\x12\n" +
"\x04rows\x18\x01 \x01(\rR\x04rows\x12\x12\n" +
@ -574,9 +747,17 @@ const file_rpc_agent_proto_rawDesc = "" +
"\x04data\x18\x01 \x01(\fR\x04data\"\x12\n" +
"\x10ResolveIPRequest\"#\n" +
"\x11ResolveIPResponse\x12\x0e\n" +
"\x02ip\x18\x01 \x01(\tR\x02ip2d\n" +
"\x02ip\x18\x01 \x01(\tR\x02ip\"\xa1\x01\n" +
"\rSignalRequest\x12\x17\n" +
"\aexec_id\x18\x01 \x01(\tR\x06execId\x12-\n" +
"\x06signal\x18\x02 \x01(\x0e2\x15.SignalRequest.SignalR\x06signal\"H\n" +
"\x06Signal\x12\x16\n" +
"\x12SIGNAL_UNSPECIFIED\x10\x00\x12\x12\n" +
"\x0eSIGNAL_SIGTERM\x10\x01\x12\x12\n" +
"\x0eSIGNAL_SIGKILL\x10\x022\x96\x01\n" +
"\x05Agent\x12'\n" +
"\x04Exec\x12\f.ExecRequest\x1a\r.ExecResponse(\x010\x01\x122\n" +
"\x04Exec\x12\f.ExecRequest\x1a\r.ExecResponse(\x010\x01\x120\n" +
"\x06Signal\x12\x0e.SignalRequest\x1a\x16.google.protobuf.Empty\x122\n" +
"\tResolveIP\x12\x11.ResolveIPRequest\x1a\x12.ResolveIPResponseB5Z3github.com/cirruslabs/tart-guest-agent/internal/rpcb\x06proto3"
var (
@ -591,36 +772,45 @@ func file_rpc_agent_proto_rawDescGZIP() []byte {
return file_rpc_agent_proto_rawDescData
}
var file_rpc_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
var file_rpc_agent_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_rpc_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 11)
var file_rpc_agent_proto_goTypes = []any{
(*ExecRequest)(nil), // 0: ExecRequest
(*ExecResponse)(nil), // 1: ExecResponse
(*TerminalSize)(nil), // 2: TerminalSize
(*IOChunk)(nil), // 3: IOChunk
(*ResolveIPRequest)(nil), // 4: ResolveIPRequest
(*ResolveIPResponse)(nil), // 5: ResolveIPResponse
(*ExecRequest_Command)(nil), // 6: ExecRequest.Command
nil, // 7: ExecRequest.Command.EnvEntry
(*ExecResponse_Exit)(nil), // 8: ExecResponse.Exit
(SignalRequest_Signal)(0), // 0: SignalRequest.Signal
(*ExecRequest)(nil), // 1: ExecRequest
(*ExecResponse)(nil), // 2: ExecResponse
(*TerminalSize)(nil), // 3: TerminalSize
(*IOChunk)(nil), // 4: IOChunk
(*ResolveIPRequest)(nil), // 5: ResolveIPRequest
(*ResolveIPResponse)(nil), // 6: ResolveIPResponse
(*SignalRequest)(nil), // 7: SignalRequest
(*ExecRequest_Command)(nil), // 8: ExecRequest.Command
nil, // 9: ExecRequest.Command.EnvEntry
(*ExecResponse_Exit)(nil), // 10: ExecResponse.Exit
(*ExecResponse_Started)(nil), // 11: ExecResponse.Started
(*emptypb.Empty)(nil), // 12: google.protobuf.Empty
}
var file_rpc_agent_proto_depIdxs = []int32{
6, // 0: ExecRequest.command:type_name -> ExecRequest.Command
3, // 1: ExecRequest.standard_input:type_name -> IOChunk
2, // 2: ExecRequest.terminal_resize:type_name -> TerminalSize
8, // 3: ExecResponse.exit:type_name -> ExecResponse.Exit
3, // 4: ExecResponse.standard_output:type_name -> IOChunk
3, // 5: ExecResponse.standard_error:type_name -> IOChunk
2, // 6: ExecRequest.Command.terminal_size:type_name -> TerminalSize
7, // 7: ExecRequest.Command.env:type_name -> ExecRequest.Command.EnvEntry
0, // 8: Agent.Exec:input_type -> ExecRequest
4, // 9: Agent.ResolveIP:input_type -> ResolveIPRequest
1, // 10: Agent.Exec:output_type -> ExecResponse
5, // 11: Agent.ResolveIP:output_type -> ResolveIPResponse
10, // [10:12] is the sub-list for method output_type
8, // [8:10] is the sub-list for method input_type
8, // [8:8] is the sub-list for extension type_name
8, // [8:8] is the sub-list for extension extendee
0, // [0:8] is the sub-list for field type_name
8, // 0: ExecRequest.command:type_name -> ExecRequest.Command
4, // 1: ExecRequest.standard_input:type_name -> IOChunk
3, // 2: ExecRequest.terminal_resize:type_name -> TerminalSize
10, // 3: ExecResponse.exit:type_name -> ExecResponse.Exit
4, // 4: ExecResponse.standard_output:type_name -> IOChunk
4, // 5: ExecResponse.standard_error:type_name -> IOChunk
11, // 6: ExecResponse.started:type_name -> ExecResponse.Started
0, // 7: SignalRequest.signal:type_name -> SignalRequest.Signal
3, // 8: ExecRequest.Command.terminal_size:type_name -> TerminalSize
9, // 9: ExecRequest.Command.env:type_name -> ExecRequest.Command.EnvEntry
1, // 10: Agent.Exec:input_type -> ExecRequest
7, // 11: Agent.Signal:input_type -> SignalRequest
5, // 12: Agent.ResolveIP:input_type -> ResolveIPRequest
2, // 13: Agent.Exec:output_type -> ExecResponse
12, // 14: Agent.Signal:output_type -> google.protobuf.Empty
6, // 15: Agent.ResolveIP:output_type -> ResolveIPResponse
13, // [13:16] is the sub-list for method output_type
10, // [10:13] is the sub-list for method input_type
10, // [10:10] is the sub-list for extension type_name
10, // [10:10] is the sub-list for extension extendee
0, // [0:10] is the sub-list for field type_name
}
func init() { file_rpc_agent_proto_init() }
@ -637,19 +827,21 @@ func file_rpc_agent_proto_init() {
(*ExecResponse_Exit_)(nil),
(*ExecResponse_StandardOutput)(nil),
(*ExecResponse_StandardError)(nil),
(*ExecResponse_Started_)(nil),
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_rpc_agent_proto_rawDesc), len(file_rpc_agent_proto_rawDesc)),
NumEnums: 0,
NumMessages: 9,
NumEnums: 1,
NumMessages: 11,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_rpc_agent_proto_goTypes,
DependencyIndexes: file_rpc_agent_proto_depIdxs,
EnumInfos: file_rpc_agent_proto_enumTypes,
MessageInfos: file_rpc_agent_proto_msgTypes,
}.Build()
File_rpc_agent_proto = out.File

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc (unknown)
// - protoc v7.35.1
// source: rpc/agent.proto
package rpc
@ -11,6 +11,7 @@ import (
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
emptypb "google.golang.org/protobuf/types/known/emptypb"
)
// This is a compile-time assertion to ensure that this generated file
@ -20,6 +21,7 @@ const _ = grpc.SupportPackageIsVersion9
const (
Agent_Exec_FullMethodName = "/Agent/Exec"
Agent_Signal_FullMethodName = "/Agent/Signal"
Agent_ResolveIP_FullMethodName = "/Agent/ResolveIP"
)
@ -28,6 +30,7 @@ const (
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type AgentClient interface {
Exec(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ExecRequest, ExecResponse], error)
Signal(ctx context.Context, in *SignalRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
ResolveIP(ctx context.Context, in *ResolveIPRequest, opts ...grpc.CallOption) (*ResolveIPResponse, error)
}
@ -52,6 +55,16 @@ func (c *agentClient) Exec(ctx context.Context, opts ...grpc.CallOption) (grpc.B
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type Agent_ExecClient = grpc.BidiStreamingClient[ExecRequest, ExecResponse]
func (c *agentClient) Signal(ctx context.Context, in *SignalRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(emptypb.Empty)
err := c.cc.Invoke(ctx, Agent_Signal_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *agentClient) ResolveIP(ctx context.Context, in *ResolveIPRequest, opts ...grpc.CallOption) (*ResolveIPResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ResolveIPResponse)
@ -67,6 +80,7 @@ func (c *agentClient) ResolveIP(ctx context.Context, in *ResolveIPRequest, opts
// for forward compatibility.
type AgentServer interface {
Exec(grpc.BidiStreamingServer[ExecRequest, ExecResponse]) error
Signal(context.Context, *SignalRequest) (*emptypb.Empty, error)
ResolveIP(context.Context, *ResolveIPRequest) (*ResolveIPResponse, error)
mustEmbedUnimplementedAgentServer()
}
@ -81,6 +95,9 @@ type UnimplementedAgentServer struct{}
func (UnimplementedAgentServer) Exec(grpc.BidiStreamingServer[ExecRequest, ExecResponse]) error {
return status.Errorf(codes.Unimplemented, "method Exec not implemented")
}
func (UnimplementedAgentServer) Signal(context.Context, *SignalRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method Signal not implemented")
}
func (UnimplementedAgentServer) ResolveIP(context.Context, *ResolveIPRequest) (*ResolveIPResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ResolveIP not implemented")
}
@ -112,6 +129,24 @@ func _Agent_Exec_Handler(srv interface{}, stream grpc.ServerStream) error {
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type Agent_ExecServer = grpc.BidiStreamingServer[ExecRequest, ExecResponse]
func _Agent_Signal_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SignalRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AgentServer).Signal(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Agent_Signal_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AgentServer).Signal(ctx, req.(*SignalRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Agent_ResolveIP_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ResolveIPRequest)
if err := dec(in); err != nil {
@ -137,6 +172,10 @@ var Agent_ServiceDesc = grpc.ServiceDesc{
ServiceName: "Agent",
HandlerType: (*AgentServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "Signal",
Handler: _Agent_Signal_Handler,
},
{
MethodName: "ResolveIP",
Handler: _Agent_ResolveIP_Handler,

View File

@ -4,23 +4,34 @@ import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
userpkg "os/user"
"slices"
"strconv"
"strings"
"sync"
"syscall"
"github.com/creack/pty"
"github.com/google/uuid"
"github.com/samber/lo"
"go.uber.org/zap"
"golang.org/x/sync/errgroup"
"google.golang.org/grpc"
"io"
"os"
"os/exec"
"slices"
"strings"
"syscall"
"google.golang.org/protobuf/types/known/emptypb"
)
const (
standardStreamsBufferSize = 4096
eofChar = 0x04
// execRuntimeFailureExitCode matches Docker's exit code for runtime failures before a process starts.
execRuntimeFailureExitCode = 125
// signalExitCodeOffset is the base for shell-style exit codes of processes terminated by signals.
signalExitCodeOffset = 128
)
func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse]) error {
@ -50,20 +61,38 @@ func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse])
cmd := exec.CommandContext(execCtx, firstExecRequestCommand.Command.Name,
firstExecRequestCommand.Command.Args...)
applyExecOverrides(cmd, firstExecRequestCommand.Command)
cmd.SysProcAttr = &syscall.SysProcAttr{}
if err := applyExecOverrides(cmd, firstExecRequestCommand.Command); err != nil {
zap.S().Warnf("failed to configure %s: %v", formatCommandAndArgs(firstExecRequestCommand.Command.GetName(),
firstExecRequestCommand.Command.GetArgs()), err)
return sendStartFailure(stream)
}
if firstExecRequestCommand.Command.Detach {
cmd.Stdout = io.Discard
cmd.Stderr = io.Discard
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
cmd.SysProcAttr.Setsid = true
if err := cmd.Start(); err != nil {
zap.S().Warnf("failed to start %s: %v", formatCommandAndArgs(firstExecRequestCommand.Command.GetName(),
firstExecRequestCommand.Command.GetArgs()), err)
return sendStartFailure(stream)
}
// Release ownership before sending responses so failures do not leak the process handle
if err := cmd.Process.Release(); err != nil {
return err
}
if cmd.Process != nil {
if err := cmd.Process.Release(); err != nil {
return err
}
// Explicitly notify the client that the process was started,
// but don't provide an exec ID since it's a detached process
err = sendStartSuccess(stream, "")
if err != nil {
return err
}
if err := stream.Send(&ExecResponse{
@ -79,6 +108,11 @@ func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse])
return nil
}
// Kill the whole process group when the exec stream is canceled
cmd.Cancel = func() error {
return signalProcessGroup(cmd.Process, syscall.SIGKILL)
}
var stdin io.WriteCloser
var stdout, stderr io.ReadCloser
var ptmx *os.File
@ -95,6 +129,9 @@ func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse])
stdout = ptmx
stderr = ptmx
} else {
// Start the command in its own process group so signals reach all descendants
cmd.SysProcAttr.Setpgid = true
if firstExecRequestCommand.Command.Interactive {
stdin, err = cmd.StdinPipe()
if err != nil {
@ -114,22 +151,57 @@ func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse])
err = cmd.Start()
}
if err != nil {
return err
zap.S().Warnf("failed to start %s: %v", formatCommandAndArgs(firstExecRequestCommand.Command.GetName(),
firstExecRequestCommand.Command.GetArgs()), err)
return sendStartFailure(stream)
}
// Ensure the PTY is closed if sending the Started response fails
if ptmx != nil {
defer ptmx.Close()
}
execID := uuid.NewString()
rpc.execs.Store(execID, cmd.Process)
defer rpc.execs.Delete(execID)
// Explicitly notify the client that the process was started
err = sendStartSuccess(stream, execID)
if err != nil {
// Output readers have not started yet, so cancel and reap directly
_ = cmd.Cancel()
_ = cmd.Wait()
return err
}
// Handle standard input and terminal resize events from the client
fromClientErrCh := make(chan error, 1)
reportClientError := func(err error) {
fromClientErrCh <- err
_ = cmd.Cancel()
}
go func() {
var stdinClosed bool
for {
request, err := stream.Recv()
if err != nil {
// Allow the client to close its sending side while continuing to receive responses
if errors.Is(err, io.EOF) {
if err := closeStdin(stdin, firstExecRequestCommand.Command.GetTty(), &stdinClosed); err != nil {
reportClientError(err)
}
return
}
if !errors.Is(err, context.Canceled) {
fromClientErrCh <- err
reportClientError(err)
}
return
@ -143,30 +215,19 @@ func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse])
continue
}
dataToWrite := typedAction.StandardInput.Data
// Check if the remote client has received EOF on their standard input
if len(typedAction.StandardInput.Data) == 0 {
if firstExecRequestCommand.Command.Tty {
// When using pseudo-terminal, we can't simply close the
// standard input, as the file descriptor is shared for
// standard output and standard error too, so we send
// an EOF character instead
dataToWrite = []byte{eofChar}
} else {
// Close the standard input
if err := stdin.Close(); err != nil {
fromClientErrCh <- err
if err := closeStdin(stdin, firstExecRequestCommand.Command.GetTty(), &stdinClosed); err != nil {
reportClientError(err)
return
}
continue
return
}
continue
}
if _, err := stdin.Write(dataToWrite); err != nil {
fromClientErrCh <- err
if _, err := stdin.Write(typedAction.StandardInput.GetData()); err != nil {
reportClientError(err)
return
}
@ -181,7 +242,7 @@ func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse])
Rows: uint16(typedAction.TerminalResize.GetRows()),
Cols: uint16(typedAction.TerminalResize.GetCols()),
}); err != nil {
fromClientErrCh <- err
reportClientError(err)
return
}
@ -189,6 +250,16 @@ func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse])
}
}()
// Serialize responses from the stdout and stderr goroutines
var sendMutex sync.Mutex
sendResponse := func(response *ExecResponse) error {
sendMutex.Lock()
defer sendMutex.Unlock()
return stream.Send(response)
}
group, _ := errgroup.WithContext(stream.Context())
// Handle standard output from the command
@ -210,7 +281,7 @@ func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse])
return err
}
if err := stream.Send(&ExecResponse{
if err := sendResponse(&ExecResponse{
Type: &ExecResponse_StandardOutput{
StandardOutput: &IOChunk{
Data: slices.Clone(buf[:n]),
@ -240,7 +311,7 @@ func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse])
return err
}
if err := stream.Send(&ExecResponse{
if err := sendResponse(&ExecResponse{
Type: &ExecResponse_StandardError{
StandardError: &IOChunk{
Data: slices.Clone(buf[:n]),
@ -258,15 +329,32 @@ func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse])
}
// Wait for the command to finish
err = cmd.Wait()
// Minimize the window in which a finished exec can still be signaled
rpc.execs.Delete(execID)
// Prefer a client error over the command exit result
select {
case err := <-fromClientErrCh:
return err
default:
}
exitCode := 0
if err := cmd.Wait(); err != nil {
if err != nil {
var exitError *exec.ExitError
if errors.As(err, &exitError) {
exitCode = exitError.ExitCode()
} else {
if !errors.As(err, &exitError) {
return err
}
// ExitCode returns -1 for signals; report the containerd-compatible 128 + signal instead
exitCode = exitError.ExitCode()
if waitStatus, ok := exitError.Sys().(syscall.WaitStatus); ok && waitStatus.Signaled() {
exitCode = signalExitCodeOffset + int(waitStatus.Signal())
}
}
return stream.Send(&ExecResponse{
@ -278,7 +366,91 @@ func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse])
})
}
func applyExecOverrides(cmd *exec.Cmd, command *ExecRequest_Command) {
func signalProcessGroup(process *os.Process, signal syscall.Signal) error {
if err := syscall.Kill(-process.Pid, signal); err != nil {
// Translate a missing process group into the process-finished error expected by os/exec
if errors.Is(err, syscall.ESRCH) {
return os.ErrProcessDone
}
return err
}
return nil
}
func closeStdin(stdin io.WriteCloser, tty bool, closed *bool) error {
if stdin == nil || *closed {
return nil
}
if tty {
// When using pseudo-terminal, we can't simply close the
// standard input, as the file descriptor is shared for
// standard output and standard error too, so we send
// an EOF character instead
if _, err := stdin.Write([]byte{eofChar}); err != nil {
return err
}
} else if err := stdin.Close(); err != nil {
return err
}
*closed = true
return nil
}
func (rpc *RPC) Signal(_ context.Context, request *SignalRequest) (*emptypb.Empty, error) {
process, ok := rpc.execs.Load(request.GetExecId())
if !ok {
return nil, fmt.Errorf("exec %q is not running", request.GetExecId())
}
var signal syscall.Signal
switch request.GetSignal() {
case SignalRequest_SIGNAL_SIGTERM:
signal = syscall.SIGTERM
case SignalRequest_SIGNAL_SIGKILL:
signal = syscall.SIGKILL
default:
return nil, fmt.Errorf("unsupported exec signal %q", request.GetSignal().String())
}
if err := signalProcessGroup(process, signal); err != nil {
// The process may exit after lookup, so treat the missing process as a no-op
if errors.Is(err, os.ErrProcessDone) {
return &emptypb.Empty{}, nil
}
return nil, err
}
return &emptypb.Empty{}, nil
}
func sendStartSuccess(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse], execID string) error {
return stream.Send(&ExecResponse{
Type: &ExecResponse_Started_{
Started: &ExecResponse_Started{
ExecId: execID,
},
},
})
}
func sendStartFailure(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse]) error {
return stream.Send(&ExecResponse{
Type: &ExecResponse_Exit_{
Exit: &ExecResponse_Exit{
Code: execRuntimeFailureExitCode,
},
},
})
}
func applyExecOverrides(cmd *exec.Cmd, command *ExecRequest_Command) error {
if command.Workdir != "" {
cmd.Dir = command.Workdir
}
@ -286,6 +458,37 @@ func applyExecOverrides(cmd *exec.Cmd, command *ExecRequest_Command) {
if len(command.Env) > 0 {
cmd.Env = mergeEnv(command.Env)
}
if user := command.GetUser(); user != "" {
selectedUser, err := userpkg.Lookup(user)
if err != nil {
return fmt.Errorf("failed to resolve user %q: %w", user, err)
}
uid, err := strconv.ParseUint(selectedUser.Uid, 10, 32)
if err != nil {
return fmt.Errorf("failed to parse UID %q for user %q: %w",
selectedUser.Uid, user, err)
}
gid, err := strconv.ParseUint(selectedUser.Gid, 10, 32)
if err != nil {
return fmt.Errorf("failed to parse GID %q for user %q: %w",
selectedUser.Gid, user, err)
}
if uint32(uid) == uint32(os.Geteuid()) && uint32(gid) == uint32(os.Getegid()) {
return nil
}
// Avoid changing credentials when the requested user is the same as guest agen't user
cmd.SysProcAttr.Credential = &syscall.Credential{
Uid: uint32(uid),
Gid: uint32(gid),
}
}
return nil
}
func mergeEnv(overrides map[string]string) []string {

351
internal/rpc/exec_test.go Normal file
View File

@ -0,0 +1,351 @@
// In-process stream scaffolding intentionally favors direct test construction.
//
//nolint:containedctx,testpackage,wsl_v5
package rpc
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strconv"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
)
const (
execTestShell = "/bin/sh"
execTestTimeout = 5 * time.Second
)
type execTestStream struct {
grpc.ServerStream
ctx context.Context
requests chan *ExecRequest
responses chan *ExecResponse
sendHook func(*ExecResponse) error
}
var _ grpc.BidiStreamingServer[ExecRequest, ExecResponse] = (*execTestStream)(nil)
func newExecTestStream(ctx context.Context) *execTestStream {
return &execTestStream{
ctx: ctx,
requests: make(chan *ExecRequest, 8),
responses: make(chan *ExecResponse, 8),
}
}
func (stream *execTestStream) Send(response *ExecResponse) error {
if stream.sendHook != nil {
if err := stream.sendHook(response); err != nil {
return err
}
}
select {
case stream.responses <- response:
return nil
case <-stream.ctx.Done():
return stream.ctx.Err()
}
}
func (stream *execTestStream) Recv() (*ExecRequest, error) {
select {
case request, ok := <-stream.requests:
if !ok {
return nil, io.EOF
}
return request, nil
case <-stream.ctx.Done():
return nil, stream.ctx.Err()
}
}
func (stream *execTestStream) Context() context.Context { return stream.ctx }
func TestExecSendsStartedBeforeOutputAndExit(t *testing.T) {
_, stream, result := startExecTest(t, &ExecRequest_Command{
Name: execTestShell,
Args: []string{"-c", "printf hello"},
})
first := receiveExecResponse(t, stream)
require.NotNil(t, first.GetStarted())
var output []byte
for {
response := receiveExecResponse(t, stream)
switch response := response.GetType().(type) {
case *ExecResponse_StandardOutput:
output = append(output, response.StandardOutput.GetData()...)
case *ExecResponse_Exit_:
require.EqualValues(t, 0, response.Exit.GetCode())
require.Equal(t, []byte("hello"), output)
require.NoError(t, receiveExecResult(t, result))
return
default:
t.Fatalf("unexpected exec response %T", response)
}
}
}
func TestExecClosesStandardInputOnRequestStreamEOF(t *testing.T) {
_, stream, result := startExecTest(t, &ExecRequest_Command{
Name: "/bin/cat",
Interactive: true,
})
require.NotNil(t, receiveExecResponse(t, stream).GetStarted())
stream.requests <- &ExecRequest{
Type: &ExecRequest_StandardInput{
StandardInput: &IOChunk{Data: []byte("hello")},
},
}
close(stream.requests)
response := receiveExecResponse(t, stream)
require.Equal(t, []byte("hello"), response.GetStandardOutput().GetData())
response = receiveExecResponse(t, stream)
require.EqualValues(t, 0, response.GetExit().GetCode())
require.NoError(t, receiveExecResult(t, result))
}
func TestExecClosesStandardInputOnEmptyChunk(t *testing.T) {
_, stream, result := startExecTest(t, &ExecRequest_Command{
Name: "/bin/cat",
Interactive: true,
})
require.NotNil(t, receiveExecResponse(t, stream).GetStarted())
stream.requests <- &ExecRequest{
Type: &ExecRequest_StandardInput{
StandardInput: &IOChunk{Data: []byte("hello")},
},
}
stream.requests <- &ExecRequest{
Type: &ExecRequest_StandardInput{
StandardInput: &IOChunk{},
},
}
response := receiveExecResponse(t, stream)
require.Equal(t, []byte("hello"), response.GetStandardOutput().GetData())
response = receiveExecResponse(t, stream)
require.EqualValues(t, 0, response.GetExit().GetCode())
require.NoError(t, receiveExecResult(t, result))
}
func TestExecReportsStartFailureBeforeStarted(t *testing.T) {
tests := []struct {
name string
command *ExecRequest_Command
}{
{
name: "missing executable",
command: &ExecRequest_Command{
Name: "/definitely/missing/tart-guest-agent-test-command",
},
},
{
name: "missing workdir",
command: &ExecRequest_Command{
Name: execTestShell,
Workdir: "/definitely/missing/tart-guest-agent-test-workdir",
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, stream, result := startExecTest(t, test.command)
response := receiveExecResponse(t, stream)
require.Nil(t, response.GetStarted())
require.EqualValues(t, execRuntimeFailureExitCode, response.GetExit().GetCode())
require.NoError(t, receiveExecResult(t, result))
})
}
}
func TestExecSignalsProcess(t *testing.T) {
tests := []struct {
name string
signal SignalRequest_Signal
code int32
err string
}{
{
name: "SIGTERM",
signal: SignalRequest_SIGNAL_SIGTERM,
code: int32(signalExitCodeOffset + syscall.SIGTERM),
},
{
name: "SIGKILL",
signal: SignalRequest_SIGNAL_SIGKILL,
code: int32(signalExitCodeOffset + syscall.SIGKILL),
},
{
name: "unsupported",
signal: SignalRequest_SIGNAL_UNSPECIFIED,
err: `unsupported exec signal "SIGNAL_UNSPECIFIED"`,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
rpc, stream, result := startExecTest(t, &ExecRequest_Command{
Name: "/bin/sleep",
Args: []string{"30"},
})
started := receiveExecResponse(t, stream).GetStarted()
require.NotNil(t, started)
_, err := rpc.Signal(context.Background(), &SignalRequest{
ExecId: started.GetExecId(),
Signal: test.signal,
})
if test.err != "" {
require.EqualError(t, err, test.err)
_, err = rpc.Signal(context.Background(), &SignalRequest{
ExecId: started.GetExecId(),
Signal: SignalRequest_SIGNAL_SIGKILL,
})
require.NoError(t, err)
receiveExecResponse(t, stream)
require.NoError(t, receiveExecResult(t, result))
return
}
require.NoError(t, err)
response := receiveExecResponse(t, stream)
require.NotNil(t, response.GetExit())
require.Equal(t, test.code, response.GetExit().GetCode())
require.NoError(t, receiveExecResult(t, result))
})
}
}
func TestExecSignalsProcessGroup(t *testing.T) {
rpc, stream, result := startExecTest(t, &ExecRequest_Command{
Name: execTestShell,
Args: []string{"-c", "sleep 30 & printf ready; wait"},
})
started := receiveExecResponse(t, stream).GetStarted()
require.NotNil(t, started)
require.Equal(t, []byte("ready"), receiveExecResponse(t, stream).GetStandardOutput().GetData())
_, err := rpc.Signal(context.Background(), &SignalRequest{
ExecId: started.GetExecId(),
Signal: SignalRequest_SIGNAL_SIGTERM,
})
require.NoError(t, err)
response := receiveExecResponse(t, stream)
require.EqualValues(t, signalExitCodeOffset+syscall.SIGTERM, response.GetExit().GetCode())
require.NoError(t, receiveExecResult(t, result))
}
func TestExecReapsProcessWhenStartedCannotBeSent(t *testing.T) {
pidPath := filepath.Join(t.TempDir(), "pid")
sendErr := errors.New("failed to send Started")
var processPID int
_, _, result := startExecTest(t, &ExecRequest_Command{
Name: execTestShell,
Args: []string{"-c", `printf %d "$$" > "$PID_FILE"; exec sleep 30`},
Env: map[string]string{"PID_FILE": pidPath},
}, func(stream *execTestStream) {
stream.sendHook = func(response *ExecResponse) error {
if response.GetStarted() == nil {
return nil
}
var err error
processPID, err = waitForExecTestPID(pidPath)
if err != nil {
return err
}
return sendErr
}
})
require.ErrorIs(t, receiveExecResult(t, result), sendErr)
require.ErrorIs(t, syscall.Kill(processPID, 0), syscall.ESRCH)
}
func startExecTest(
t *testing.T,
command *ExecRequest_Command,
configure ...func(*execTestStream),
) (*RPC, *execTestStream, <-chan error) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
stream := newExecTestStream(ctx)
for _, configureStream := range configure {
configureStream(stream)
}
rpc, err := New(nil)
require.NoError(t, err)
result := make(chan error, 1)
go func() {
result <- rpc.Exec(stream)
}()
stream.requests <- &ExecRequest{
Type: &ExecRequest_Command_{Command: command},
}
return rpc, stream, result
}
func receiveExecResponse(t *testing.T, stream *execTestStream) *ExecResponse {
t.Helper()
select {
case response := <-stream.responses:
return response
case <-time.After(execTestTimeout):
t.Fatal("timed out waiting for exec response")
return nil
}
}
func receiveExecResult(t *testing.T, result <-chan error) error {
t.Helper()
select {
case err := <-result:
return err
case <-time.After(execTestTimeout):
t.Fatal("timed out waiting for Exec to return")
return nil
}
}
func waitForExecTestPID(path string) (int, error) {
deadline := time.Now().Add(execTestTimeout)
for time.Now().Before(deadline) {
//nolint:gosec // path is created under t.TempDir by the test
data, err := os.ReadFile(path)
if err == nil {
return strconv.Atoi(string(data))
}
if !errors.Is(err, os.ErrNotExist) {
return 0, err
}
time.Sleep(10 * time.Millisecond)
}
return 0, context.DeadlineExceeded
}

View File

@ -2,13 +2,17 @@ package rpc
import (
"context"
"google.golang.org/grpc"
"net"
"os"
"github.com/puzpuzpuz/xsync/v4"
"google.golang.org/grpc"
)
type RPC struct {
grpcServer *grpc.Server
listener net.Listener
execs *xsync.Map[string, *os.Process]
UnimplementedAgentServer
}
@ -17,6 +21,7 @@ func New(listener net.Listener) (*RPC, error) {
rpc := &RPC{
grpcServer: grpc.NewServer(),
listener: listener,
execs: xsync.NewMap[string, *os.Process](),
}
RegisterAgentServer(rpc.grpcServer, rpc)

View File

@ -48,10 +48,10 @@ func (agent *VDAgent) Run(ctx context.Context) error {
case <-ctx.Done():
return ctx.Err()
case newClipboardState := <-clipboardCh:
if err := agent.processClipboardState(newClipboardState); err != nil {
if err := agent.processClipboardState(newClipboardState.Bytes); err != nil {
return err
}
agent.lastClipboardState = newClipboardState
agent.lastClipboardState = newClipboardState.Bytes
default:
// Nothing, proceed
}

View File

@ -6,6 +6,7 @@ option go_package = "github.com/cirruslabs/tart-guest-agent/internal/rpc";
service Agent {
rpc Exec(stream ExecRequest) returns (stream ExecResponse);
rpc Signal(SignalRequest) returns (google.protobuf.Empty);
rpc ResolveIP(ResolveIPRequest) returns (ResolveIPResponse);
}
@ -19,6 +20,7 @@ message ExecRequest {
bool detach = 6;
map<string, string> env = 7;
string workdir = 8;
string user = 9;
}
oneof type {
@ -33,10 +35,15 @@ message ExecResponse {
int32 code = 1;
}
message Started {
string exec_id = 1;
}
oneof type {
Exit exit = 1;
IOChunk standard_output = 2;
IOChunk standard_error = 3;
Started started = 4;
}
}
@ -56,3 +63,14 @@ message ResolveIPRequest {
message ResolveIPResponse {
string ip = 1;
}
message SignalRequest {
enum Signal {
SIGNAL_UNSPECIFIED = 0;
SIGNAL_SIGTERM = 1;
SIGNAL_SIGKILL = 2;
}
string exec_id = 1;
Signal signal = 2;
}