mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 05:02:11 +00:00
refactor: rename sandbox-daemon to sandbox-agent
This commit is contained in:
parent
f92ecd9b9a
commit
a49ea094f3
41 changed files with 808 additions and 134 deletions
20
.github/workflows/release.yaml
vendored
20
.github/workflows/release.yaml
vendored
|
|
@ -29,6 +29,13 @@ jobs:
|
|||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install tsx
|
||||
run: npm install -g tsx
|
||||
|
||||
- name: Resolve version
|
||||
id: vars
|
||||
run: |
|
||||
|
|
@ -44,7 +51,7 @@ jobs:
|
|||
- name: Determine latest
|
||||
id: latest
|
||||
run: |
|
||||
node scripts/release/main.js --version "${{ steps.vars.outputs.version }}" --print-latest --output "$GITHUB_OUTPUT"
|
||||
./scripts/release/main.ts --version "${{ steps.vars.outputs.version }}" --print-latest --output "$GITHUB_OUTPUT"
|
||||
|
||||
binaries:
|
||||
name: "Build & Upload Binaries"
|
||||
|
|
@ -97,11 +104,11 @@ jobs:
|
|||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_RELEASES_SECRET_ACCESS_KEY }}
|
||||
run: |
|
||||
VERSION="${{ needs.setup.outputs.version }}"
|
||||
BINARY_NAME="sandbox-daemon-${{ matrix.target }}${{ matrix.binary_ext }}"
|
||||
BINARY_NAME="sandbox-agent-${{ matrix.target }}${{ matrix.binary_ext }}"
|
||||
|
||||
aws s3 cp \
|
||||
"dist/${BINARY_NAME}" \
|
||||
"s3://rivet-releases/sandbox-daemon/${VERSION}/${BINARY_NAME}" \
|
||||
"s3://rivet-releases/sandbox-agent/${VERSION}/${BINARY_NAME}" \
|
||||
--region auto \
|
||||
--endpoint-url https://2a94c6a0ced8d35ea63cddc86c2681e7.r2.cloudflarestorage.com \
|
||||
--checksum-algorithm CRC32
|
||||
|
|
@ -109,7 +116,7 @@ jobs:
|
|||
if [ "${{ needs.setup.outputs.latest }}" = "true" ]; then
|
||||
aws s3 cp \
|
||||
"dist/${BINARY_NAME}" \
|
||||
"s3://rivet-releases/sandbox-daemon/latest/${BINARY_NAME}" \
|
||||
"s3://rivet-releases/sandbox-agent/latest/${BINARY_NAME}" \
|
||||
--region auto \
|
||||
--endpoint-url https://2a94c6a0ced8d35ea63cddc86c2681e7.r2.cloudflarestorage.com \
|
||||
--checksum-algorithm CRC32
|
||||
|
|
@ -128,6 +135,9 @@ jobs:
|
|||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install tsx
|
||||
run: npm install -g tsx
|
||||
|
||||
- name: Install AWS CLI
|
||||
run: |
|
||||
sudo apt-get update
|
||||
|
|
@ -149,4 +159,4 @@ jobs:
|
|||
LATEST_FLAG="--no-latest"
|
||||
fi
|
||||
|
||||
node scripts/release/main.js --version "$VERSION" $LATEST_FLAG --upload-typescript --upload-install
|
||||
./scripts/release/main.ts --version "$VERSION" $LATEST_FLAG --upload-typescript --upload-install
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -7,7 +7,6 @@ build/
|
|||
target/
|
||||
|
||||
# Package manager
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["engine/packages/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["Sandbox Agent Contributors"]
|
||||
license = "Apache-2.0"
|
||||
|
|
|
|||
190
LICENSE
Normal file
190
LICENSE
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2026 Sandbox Agent Contributors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# Sandbox Daemon
|
||||
# Sandbox Agent SDK
|
||||
|
||||
Run inside sandboxes to provide support
|
||||
|
||||
|
|
@ -81,9 +81,9 @@ TODO
|
|||
|
||||
This project aims to solve 3 problems with agents:
|
||||
|
||||
- **Universal Agent API**: Claude Code, Codex, Amp, and OpenCode all have put a lot of work in to the agent scaffold. Each have respective pros and cons and need to be easy to be swapped between.
|
||||
- **Agent Transcript**: Maintaining agent transcripts is difficult since the agent manages its own sessions. This provides a simpler way to read and retrieve agent transcripts in your system.
|
||||
- **Universal Coding Agent API**: Claude Code, Codex, Amp, and OpenCode all have put a lot of work in to the agent scaffold. Each have respective pros and cons and need to be easy to be swapped between.
|
||||
- **Agents In Sandboxes**: There are many complications with running agents inside of sandbox providers. This lets you run a simple curl command to spawn an HTTP server for using any agent from within the sandbox.
|
||||
- **Agent Transcript**: Maintaining agent transcripts is difficult since the agent manages its own sessions. This provides a simpler way to read and retrieve agent transcripts in your system.
|
||||
|
||||
Features out of scope:
|
||||
|
||||
|
|
|
|||
|
|
@ -8,25 +8,25 @@ case $TARGET in
|
|||
echo "Building for Linux x86_64 musl"
|
||||
DOCKERFILE="linux-x86_64.Dockerfile"
|
||||
TARGET_STAGE="builder"
|
||||
BINARY="sandbox-daemon-$TARGET"
|
||||
BINARY="sandbox-agent-$TARGET"
|
||||
;;
|
||||
x86_64-pc-windows-gnu)
|
||||
echo "Building for Windows x86_64"
|
||||
DOCKERFILE="windows.Dockerfile"
|
||||
TARGET_STAGE=""
|
||||
BINARY="sandbox-daemon-$TARGET.exe"
|
||||
BINARY="sandbox-agent-$TARGET.exe"
|
||||
;;
|
||||
x86_64-apple-darwin)
|
||||
echo "Building for macOS x86_64"
|
||||
DOCKERFILE="macos-x86_64.Dockerfile"
|
||||
TARGET_STAGE="x86_64-builder"
|
||||
BINARY="sandbox-daemon-$TARGET"
|
||||
BINARY="sandbox-agent-$TARGET"
|
||||
;;
|
||||
aarch64-apple-darwin)
|
||||
echo "Building for macOS aarch64"
|
||||
DOCKERFILE="macos-aarch64.Dockerfile"
|
||||
TARGET_STAGE="aarch64-builder"
|
||||
BINARY="sandbox-daemon-$TARGET"
|
||||
BINARY="sandbox-agent-$TARGET"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported target: $TARGET"
|
||||
|
|
@ -36,12 +36,12 @@ case $TARGET in
|
|||
|
||||
DOCKER_BUILDKIT=1
|
||||
if [ -n "$TARGET_STAGE" ]; then
|
||||
docker build --target "$TARGET_STAGE" -f "docker/release/$DOCKERFILE" -t "sandbox-daemon-builder-$TARGET" .
|
||||
docker build --target "$TARGET_STAGE" -f "docker/release/$DOCKERFILE" -t "sandbox-agent-builder-$TARGET" .
|
||||
else
|
||||
docker build -f "docker/release/$DOCKERFILE" -t "sandbox-daemon-builder-$TARGET" .
|
||||
docker build -f "docker/release/$DOCKERFILE" -t "sandbox-agent-builder-$TARGET" .
|
||||
fi
|
||||
|
||||
CONTAINER_ID=$(docker create "sandbox-daemon-builder-$TARGET")
|
||||
CONTAINER_ID=$(docker create "sandbox-agent-builder-$TARGET")
|
||||
mkdir -p dist
|
||||
|
||||
docker cp "$CONTAINER_ID:/artifacts/$BINARY" "dist/"
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
|||
--mount=type=cache,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,target=/build/target \
|
||||
RUSTFLAGS="-C target-feature=+crt-static" \
|
||||
cargo build -p sandbox-daemon-core --release --target x86_64-unknown-linux-musl && \
|
||||
cargo build -p sandbox-agent-core --release --target x86_64-unknown-linux-musl && \
|
||||
mkdir -p /artifacts && \
|
||||
cp target/x86_64-unknown-linux-musl/release/sandbox-daemon /artifacts/sandbox-daemon-x86_64-unknown-linux-musl
|
||||
cp target/x86_64-unknown-linux-musl/release/sandbox-agent /artifacts/sandbox-agent-x86_64-unknown-linux-musl
|
||||
|
||||
CMD ["ls", "-la", "/artifacts"]
|
||||
|
|
|
|||
|
|
@ -55,8 +55,8 @@ COPY . .
|
|||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,target=/build/target \
|
||||
cargo build -p sandbox-daemon-core --release --target aarch64-apple-darwin && \
|
||||
cargo build -p sandbox-agent-core --release --target aarch64-apple-darwin && \
|
||||
mkdir -p /artifacts && \
|
||||
cp target/aarch64-apple-darwin/release/sandbox-daemon /artifacts/sandbox-daemon-aarch64-apple-darwin
|
||||
cp target/aarch64-apple-darwin/release/sandbox-agent /artifacts/sandbox-agent-aarch64-apple-darwin
|
||||
|
||||
CMD ["ls", "-la", "/artifacts"]
|
||||
|
|
|
|||
|
|
@ -55,8 +55,8 @@ COPY . .
|
|||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,target=/build/target \
|
||||
cargo build -p sandbox-daemon-core --release --target x86_64-apple-darwin && \
|
||||
cargo build -p sandbox-agent-core --release --target x86_64-apple-darwin && \
|
||||
mkdir -p /artifacts && \
|
||||
cp target/x86_64-apple-darwin/release/sandbox-daemon /artifacts/sandbox-daemon-x86_64-apple-darwin
|
||||
cp target/x86_64-apple-darwin/release/sandbox-agent /artifacts/sandbox-agent-x86_64-apple-darwin
|
||||
|
||||
CMD ["ls", "-la", "/artifacts"]
|
||||
|
|
|
|||
|
|
@ -42,8 +42,8 @@ COPY . .
|
|||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,target=/build/target \
|
||||
cargo build -p sandbox-daemon-core --release --target x86_64-pc-windows-gnu && \
|
||||
cargo build -p sandbox-agent-core --release --target x86_64-pc-windows-gnu && \
|
||||
mkdir -p /artifacts && \
|
||||
cp target/x86_64-pc-windows-gnu/release/sandbox-daemon.exe /artifacts/sandbox-daemon-x86_64-pc-windows-gnu.exe
|
||||
cp target/x86_64-pc-windows-gnu/release/sandbox-agent.exe /artifacts/sandbox-agent-x86_64-pc-windows-gnu.exe
|
||||
|
||||
CMD ["ls", "-la", "/artifacts"]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
[package]
|
||||
name = "sandbox-daemon-agent-credentials"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
name = "sandbox-agent-agent-credentials"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
[package]
|
||||
name = "sandbox-daemon-agent-management"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
name = "sandbox-agent-agent-management"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
thiserror = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
sandbox-daemon-agent-credentials = { path = "../agent-credentials" }
|
||||
sandbox-agent-agent-credentials = { path = "../agent-credentials" }
|
||||
reqwest = { version = "0.11", features = ["blocking", "json", "rustls-tls"] }
|
||||
flate2 = "1.0"
|
||||
tar = "0.4"
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
pub use sandbox_daemon_agent_credentials::*;
|
||||
pub use sandbox_agent_agent_credentials::*;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
[package]
|
||||
name = "sandbox-daemon-agent-schema"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
name = "sandbox-agent-agent-schema"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use std::fs;
|
||||
use std::io::{self, Write};
|
||||
use std::path::Path;
|
||||
|
||||
fn main() {
|
||||
|
|
@ -16,10 +17,16 @@ fn main() {
|
|||
let schema_path = schema_dir.join(file);
|
||||
|
||||
// Tell cargo to rerun if schema changes
|
||||
println!("cargo:rerun-if-changed={}", schema_path.display());
|
||||
emit_stdout(&format!(
|
||||
"cargo:rerun-if-changed={}",
|
||||
schema_path.display()
|
||||
));
|
||||
|
||||
if !schema_path.exists() {
|
||||
eprintln!("Warning: Schema file not found: {}", schema_path.display());
|
||||
emit_stdout(&format!(
|
||||
"cargo:warning=Schema file not found: {}",
|
||||
schema_path.display()
|
||||
));
|
||||
// Write empty module
|
||||
let out_path = Path::new(&out_dir).join(format!("{}.rs", name));
|
||||
fs::write(&out_path, "// Schema not found\n").unwrap();
|
||||
|
|
@ -49,6 +56,16 @@ fn main() {
|
|||
fs::write(&out_path, formatted)
|
||||
.unwrap_or_else(|e| panic!("Failed to write {}: {}", out_path.display(), e));
|
||||
|
||||
println!("cargo:warning=Generated {} types from {}", name, file);
|
||||
emit_stdout(&format!(
|
||||
"cargo:warning=Generated {} types from {}",
|
||||
name, file
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_stdout(message: &str) {
|
||||
let mut out = io::stdout();
|
||||
let _ = out.write_all(message.as_bytes());
|
||||
let _ = out.write_all(b"\n");
|
||||
let _ = out.flush();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
[package]
|
||||
name = "sandbox-daemon-error"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
name = "sandbox-agent-error"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
|
|
|
|||
|
|
@ -24,18 +24,18 @@ pub enum ErrorType {
|
|||
impl ErrorType {
|
||||
pub fn as_urn(&self) -> &'static str {
|
||||
match self {
|
||||
Self::InvalidRequest => "urn:sandbox-daemon:error:invalid_request",
|
||||
Self::UnsupportedAgent => "urn:sandbox-daemon:error:unsupported_agent",
|
||||
Self::AgentNotInstalled => "urn:sandbox-daemon:error:agent_not_installed",
|
||||
Self::InstallFailed => "urn:sandbox-daemon:error:install_failed",
|
||||
Self::AgentProcessExited => "urn:sandbox-daemon:error:agent_process_exited",
|
||||
Self::TokenInvalid => "urn:sandbox-daemon:error:token_invalid",
|
||||
Self::PermissionDenied => "urn:sandbox-daemon:error:permission_denied",
|
||||
Self::SessionNotFound => "urn:sandbox-daemon:error:session_not_found",
|
||||
Self::SessionAlreadyExists => "urn:sandbox-daemon:error:session_already_exists",
|
||||
Self::ModeNotSupported => "urn:sandbox-daemon:error:mode_not_supported",
|
||||
Self::StreamError => "urn:sandbox-daemon:error:stream_error",
|
||||
Self::Timeout => "urn:sandbox-daemon:error:timeout",
|
||||
Self::InvalidRequest => "urn:sandbox-agent:error:invalid_request",
|
||||
Self::UnsupportedAgent => "urn:sandbox-agent:error:unsupported_agent",
|
||||
Self::AgentNotInstalled => "urn:sandbox-agent:error:agent_not_installed",
|
||||
Self::InstallFailed => "urn:sandbox-agent:error:install_failed",
|
||||
Self::AgentProcessExited => "urn:sandbox-agent:error:agent_process_exited",
|
||||
Self::TokenInvalid => "urn:sandbox-agent:error:token_invalid",
|
||||
Self::PermissionDenied => "urn:sandbox-agent:error:permission_denied",
|
||||
Self::SessionNotFound => "urn:sandbox-agent:error:session_not_found",
|
||||
Self::SessionAlreadyExists => "urn:sandbox-agent:error:session_already_exists",
|
||||
Self::ModeNotSupported => "urn:sandbox-agent:error:mode_not_supported",
|
||||
Self::StreamError => "urn:sandbox-agent:error:stream_error",
|
||||
Self::Timeout => "urn:sandbox-agent:error:timeout",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
[package]
|
||||
name = "sandbox-daemon-openapi-gen"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
name = "sandbox-agent-openapi-gen"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
build = "build.rs"
|
||||
|
||||
[dependencies]
|
||||
tracing = "0.1"
|
||||
tracing-logfmt = "0.3"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
[build-dependencies]
|
||||
sandbox-daemon-core = { path = "../sandbox-daemon" }
|
||||
sandbox-agent-core = { path = "../sandbox-agent" }
|
||||
serde_json = "1.0"
|
||||
utoipa = "4.2"
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
use std::fs;
|
||||
use std::io::{self, Write};
|
||||
use std::path::Path;
|
||||
|
||||
use sandbox_daemon_core::router::ApiDoc;
|
||||
use sandbox_agent_core::router::ApiDoc;
|
||||
use utoipa::OpenApi;
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=../sandbox-daemon/src/router.rs");
|
||||
println!("cargo:rerun-if-changed=../sandbox-daemon/src/lib.rs");
|
||||
emit_stdout("cargo:rerun-if-changed=../sandbox-agent/src/router.rs");
|
||||
emit_stdout("cargo:rerun-if-changed=../sandbox-agent/src/lib.rs");
|
||||
|
||||
let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR not set");
|
||||
let out_path = Path::new(&out_dir).join("openapi.json");
|
||||
|
|
@ -16,5 +17,15 @@ fn main() {
|
|||
.expect("Failed to serialize OpenAPI spec");
|
||||
|
||||
fs::write(&out_path, json).expect("Failed to write OpenAPI spec");
|
||||
println!("cargo:warning=Generated OpenAPI spec at {}", out_path.display());
|
||||
emit_stdout(&format!(
|
||||
"cargo:warning=Generated OpenAPI spec at {}",
|
||||
out_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
fn emit_stdout(message: &str) {
|
||||
let mut out = io::stdout();
|
||||
let _ = out.write_all(message.as_bytes());
|
||||
let _ = out.write_all(b"\n");
|
||||
let _ = out.flush();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||
|
||||
fn main() {
|
||||
init_logging();
|
||||
let mut out: Option<PathBuf> = None;
|
||||
let mut stdout = false;
|
||||
let mut args = env::args().skip(1).peekable();
|
||||
|
|
@ -26,15 +30,30 @@ fn main() {
|
|||
}
|
||||
}
|
||||
|
||||
let schema = sandbox_daemon_openapi_gen::OPENAPI_JSON;
|
||||
let schema = sandbox_agent_openapi_gen::OPENAPI_JSON;
|
||||
if stdout {
|
||||
println!("{schema}");
|
||||
write_stdout(schema);
|
||||
return;
|
||||
}
|
||||
|
||||
let out = out.unwrap_or_else(|| PathBuf::from("openapi.json"));
|
||||
if let Err(err) = fs::write(&out, schema) {
|
||||
eprintln!("failed to write {}: {err}", out.display());
|
||||
tracing::error!(path = %out.display(), error = %err, "failed to write openapi schema");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn init_logging() {
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(tracing_logfmt::builder().layer().with_writer(std::io::stderr))
|
||||
.init();
|
||||
}
|
||||
|
||||
fn write_stdout(text: &str) {
|
||||
let mut out = std::io::stdout();
|
||||
let _ = out.write_all(text.as_bytes());
|
||||
let _ = out.write_all(b"\n");
|
||||
let _ = out.flush();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
[package]
|
||||
name = "sandbox-daemon-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
name = "sandbox-agent-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "sandbox-daemon"
|
||||
name = "sandbox-agent"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
|
|
@ -14,10 +16,10 @@ serde_json = "1.0"
|
|||
axum = "0.7"
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
futures = "0.3"
|
||||
sandbox-daemon-error = { path = "../error" }
|
||||
sandbox-daemon-agent-management = { path = "../agent-management" }
|
||||
sandbox-daemon-agent-credentials = { path = "../agent-credentials" }
|
||||
sandbox-daemon-universal-agent-schema = { path = "../universal-agent-schema" }
|
||||
sandbox-agent-error = { path = "../error" }
|
||||
sandbox-agent-agent-management = { path = "../agent-management" }
|
||||
sandbox-agent-agent-credentials = { path = "../agent-credentials" }
|
||||
sandbox-agent-universal-agent-schema = { path = "../universal-agent-schema" }
|
||||
reqwest = { version = "0.11", features = ["blocking", "json", "rustls-tls", "stream"] }
|
||||
dirs = "5.0"
|
||||
time = { version = "0.3", features = ["parsing", "formatting"] }
|
||||
|
|
@ -26,6 +28,9 @@ tokio-stream = { version = "0.1", features = ["sync"] }
|
|||
tower-http = { version = "0.5", features = ["cors"] }
|
||||
utoipa = { version = "4.2", features = ["axum_extras"] }
|
||||
schemars = "0.8"
|
||||
tracing = "0.1"
|
||||
tracing-logfmt = "0.3"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.10"
|
||||
1
engine/packages/sandbox-agent/src/credentials.rs
Normal file
1
engine/packages/sandbox-agent/src/credentials.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub use sandbox_agent_agent_credentials::*;
|
||||
|
|
@ -4,23 +4,24 @@ use std::path::PathBuf;
|
|||
use clap::{Args, Parser, Subcommand};
|
||||
use reqwest::blocking::Client as HttpClient;
|
||||
use reqwest::Method;
|
||||
use sandbox_daemon_agent_management::agents::AgentManager;
|
||||
use sandbox_daemon_core::router::{
|
||||
use sandbox_agent_agent_management::agents::AgentManager;
|
||||
use sandbox_agent_core::router::{
|
||||
AgentInstallRequest, AppState, AuthConfig, CreateSessionRequest, MessageRequest,
|
||||
PermissionReply, PermissionReplyRequest, QuestionReplyRequest,
|
||||
};
|
||||
use sandbox_daemon_core::router::{AgentListResponse, AgentModesResponse, CreateSessionResponse, EventsResponse};
|
||||
use sandbox_daemon_core::router::build_router;
|
||||
use sandbox_agent_core::router::{AgentListResponse, AgentModesResponse, CreateSessionResponse, EventsResponse};
|
||||
use sandbox_agent_core::router::build_router;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||
|
||||
const API_PREFIX: &str = "/v1";
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "sandbox-daemon")]
|
||||
#[command(about = "Sandbox daemon for managing coding agents", version)]
|
||||
#[command(name = "sandbox-agent")]
|
||||
#[command(about = "Sandbox agent for managing coding agents", version)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Option<Command>,
|
||||
|
|
@ -215,6 +216,7 @@ enum CliError {
|
|||
}
|
||||
|
||||
fn main() {
|
||||
init_logging();
|
||||
let cli = Cli::parse();
|
||||
|
||||
let result = match &cli.command {
|
||||
|
|
@ -223,11 +225,19 @@ fn main() {
|
|||
};
|
||||
|
||||
if let Err(err) = result {
|
||||
eprintln!("{err}");
|
||||
tracing::error!(error = %err, "sandbox-agent failed");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn init_logging() {
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(tracing_logfmt::builder().layer().with_writer(std::io::stderr))
|
||||
.init();
|
||||
}
|
||||
|
||||
fn run_server(cli: &Cli) -> Result<(), CliError> {
|
||||
let auth = if cli.no_token {
|
||||
AuthConfig::disabled()
|
||||
|
|
@ -262,8 +272,8 @@ fn run_server(cli: &Cli) -> Result<(), CliError> {
|
|||
|
||||
fn default_install_dir() -> PathBuf {
|
||||
dirs::data_dir()
|
||||
.map(|dir| dir.join("sandbox-daemon").join("bin"))
|
||||
.unwrap_or_else(|| PathBuf::from(".").join(".sandbox-daemon").join("bin"))
|
||||
.map(|dir| dir.join("sandbox-agent").join("bin"))
|
||||
.unwrap_or_else(|| PathBuf::from(".").join(".sandbox-agent").join("bin"))
|
||||
}
|
||||
|
||||
fn run_client(command: &Command, cli: &Cli) -> Result<(), CliError> {
|
||||
|
|
@ -502,7 +512,7 @@ fn print_json_response<T: serde::de::DeserializeOwned + Serialize>(
|
|||
|
||||
let parsed: T = serde_json::from_str(&text)?;
|
||||
let pretty = serde_json::to_string_pretty(&parsed)?;
|
||||
println!("{pretty}");
|
||||
write_stdout_line(&pretty)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -515,8 +525,7 @@ fn print_text_response(response: reqwest::blocking::Response) -> Result<(), CliE
|
|||
return Err(CliError::HttpStatus(status));
|
||||
}
|
||||
|
||||
print!("{text}");
|
||||
std::io::stdout().flush()?;
|
||||
write_stdout(&text)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -533,9 +542,32 @@ fn print_empty_response(response: reqwest::blocking::Response) -> Result<(), Cli
|
|||
fn print_error_body(text: &str) -> Result<(), CliError> {
|
||||
if let Ok(json) = serde_json::from_str::<Value>(text) {
|
||||
let pretty = serde_json::to_string_pretty(&json)?;
|
||||
eprintln!("{pretty}");
|
||||
write_stderr_line(&pretty)?;
|
||||
} else {
|
||||
eprintln!("{text}");
|
||||
write_stderr_line(text)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_stdout(text: &str) -> Result<(), CliError> {
|
||||
let mut out = std::io::stdout();
|
||||
out.write_all(text.as_bytes())?;
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_stdout_line(text: &str) -> Result<(), CliError> {
|
||||
let mut out = std::io::stdout();
|
||||
out.write_all(text.as_bytes())?;
|
||||
out.write_all(b"\n")?;
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_stderr_line(text: &str) -> Result<(), CliError> {
|
||||
let mut out = std::io::stderr();
|
||||
out.write_all(text.as_bytes())?;
|
||||
out.write_all(b"\n")?;
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -16,8 +16,8 @@ use axum::Json;
|
|||
use axum::Router;
|
||||
use futures::{stream, StreamExt};
|
||||
use reqwest::Client;
|
||||
use sandbox_daemon_error::{AgentError, ErrorType, ProblemDetails, SandboxError};
|
||||
use sandbox_daemon_universal_agent_schema::{
|
||||
use sandbox_agent_error::{AgentError, ErrorType, ProblemDetails, SandboxError};
|
||||
use sandbox_agent_universal_agent_schema::{
|
||||
convert_amp, convert_claude, convert_codex, convert_opencode, AttachmentSource, CrashInfo,
|
||||
EventConversion, PermissionRequest, PermissionToolRef, QuestionInfo, QuestionOption,
|
||||
QuestionRequest, QuestionToolRef, Started, UniversalEvent, UniversalEventData,
|
||||
|
|
@ -31,10 +31,10 @@ use tokio_stream::wrappers::BroadcastStream;
|
|||
use tokio::time::sleep;
|
||||
use utoipa::{OpenApi, ToSchema};
|
||||
|
||||
use sandbox_daemon_agent_management::agents::{
|
||||
use sandbox_agent_agent_management::agents::{
|
||||
AgentError as ManagerError, AgentId, AgentManager, InstallOptions, SpawnOptions, StreamingSpawn,
|
||||
};
|
||||
use sandbox_daemon_agent_management::credentials::{
|
||||
use sandbox_agent_agent_management::credentials::{
|
||||
extract_all_credentials, CredentialExtractionOptions, ExtractedCredentials,
|
||||
};
|
||||
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use sandbox_daemon_agent_management::agents::{
|
||||
use sandbox_agent_agent_management::agents::{
|
||||
AgentError, AgentId, AgentManager, InstallOptions, SpawnOptions,
|
||||
};
|
||||
use sandbox_daemon_agent_management::credentials::{
|
||||
use sandbox_agent_agent_management::credentials::{
|
||||
extract_all_credentials, CredentialExtractionOptions,
|
||||
};
|
||||
|
||||
|
|
@ -1 +0,0 @@
|
|||
pub use sandbox_daemon_agent_credentials::*;
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
[package]
|
||||
name = "sandbox-daemon-universal-agent-schema"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
name = "sandbox-agent-universal-agent-schema"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
sandbox-daemon-agent-schema = { path = "../agent-schema" }
|
||||
sandbox-agent-agent-schema = { path = "../agent-schema" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
schemars = "0.8"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use schemars::JsonSchema;
|
|||
use thiserror::Error;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
pub use sandbox_daemon_agent_schema::{amp, claude, codex, opencode};
|
||||
pub use sandbox_agent_agent_schema::{amp, claude, codex, opencode};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sandbox Daemon Console</title>
|
||||
<title>Sandbox Agent Console</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
{
|
||||
"name": "@sandbox-daemon/web",
|
||||
"name": "@sandbox-agent/web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build": "pnpm --filter @sandbox-agent/typescript-sdk build && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sandbox-daemon/typescript-sdk": "workspace:*",
|
||||
"@sandbox-agent/typescript-sdk": "workspace:*",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
|
|
|
|||
|
|
@ -582,7 +582,7 @@ export default function App() {
|
|||
<header className="app-header">
|
||||
<div className="brand">
|
||||
<span className="brand-mark" />
|
||||
Sandbox Daemon Console
|
||||
Sandbox Agent Console
|
||||
</div>
|
||||
<div className="inline-row">
|
||||
<span className={`status-pill ${connected ? "success" : "warning"}`}>
|
||||
|
|
@ -602,11 +602,11 @@ export default function App() {
|
|||
<section className="connect-hero reveal">
|
||||
<div className="hero-title">Bring the agent fleet online.</div>
|
||||
<div className="hero-subtitle">
|
||||
Point this console at a running sandbox-daemon, then manage sessions, messages, and approvals in
|
||||
Point this console at a running sandbox-agent, then manage sessions, messages, and approvals in
|
||||
one place.
|
||||
</div>
|
||||
<div className="callout mono">
|
||||
sandbox-daemon --host 0.0.0.0 --port 8787 --token <token> --cors-allowed-origin
|
||||
sandbox-agent --host 0.0.0.0 --port 8787 --token <token> --cors-allowed-origin
|
||||
http://localhost:5173 --cors-allowed-methods GET,POST --cors-allowed-headers Authorization,x-sandbox-token
|
||||
</div>
|
||||
<div className="tag-list">
|
||||
|
|
|
|||
42
justfile
Normal file
42
justfile
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
set dotenv-load := true
|
||||
|
||||
# Build a single target via Docker
|
||||
release-build target="x86_64-unknown-linux-musl":
|
||||
./docker/release/build.sh {{target}}
|
||||
|
||||
# Build all release binaries
|
||||
release-build-all:
|
||||
./docker/release/build.sh x86_64-unknown-linux-musl
|
||||
./docker/release/build.sh x86_64-pc-windows-gnu
|
||||
./docker/release/build.sh x86_64-apple-darwin
|
||||
./docker/release/build.sh aarch64-apple-darwin
|
||||
|
||||
# Upload binaries from dist/ (requires AWS creds + aws cli)
|
||||
release-upload-binaries version latest="auto":
|
||||
{{~ if latest == "auto" ~}}
|
||||
npx tsx scripts/release/main.ts --version {{version}} --upload-binaries
|
||||
{{~ else if latest == "true" ~}}
|
||||
npx tsx scripts/release/main.ts --version {{version}} --latest --upload-binaries
|
||||
{{~ else if latest == "false" ~}}
|
||||
npx tsx scripts/release/main.ts --version {{version}} --no-latest --upload-binaries
|
||||
{{~ else ~}}
|
||||
@echo "latest must be auto|true|false" && exit 1
|
||||
{{~ endif ~}}
|
||||
|
||||
# Upload TypeScript artifacts + install.sh
|
||||
release-upload-artifacts version latest="auto":
|
||||
{{~ if latest == "auto" ~}}
|
||||
npx tsx scripts/release/main.ts --version {{version}} --upload-typescript --upload-install
|
||||
{{~ else if latest == "true" ~}}
|
||||
npx tsx scripts/release/main.ts --version {{version}} --latest --upload-typescript --upload-install
|
||||
{{~ else if latest == "false" ~}}
|
||||
npx tsx scripts/release/main.ts --version {{version}} --no-latest --upload-typescript --upload-install
|
||||
{{~ else ~}}
|
||||
@echo "latest must be auto|true|false" && exit 1
|
||||
{{~ endif ~}}
|
||||
|
||||
# Full local release test: build all, then upload binaries + artifacts
|
||||
release-test version latest="auto":
|
||||
just release-build-all
|
||||
just release-upload-binaries {{version}} {{latest}}
|
||||
just release-upload-artifacts {{version}} {{latest}}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"name": "sandbox-daemon-workspace",
|
||||
"name": "sandbox-agent-workspace",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"packageManager": "pnpm@9.15.0",
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"name": "agent-schemas",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"extract": "tsx src/index.ts",
|
||||
"extract:opencode": "tsx src/index.ts --agent=opencode",
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ export function createNormalizedSchema(
|
|||
): NormalizedSchema {
|
||||
return {
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
$id: `https://sandbox-daemon/schemas/${id}.json`,
|
||||
$id: `https://sandbox-agent/schemas/${id}.json`,
|
||||
title,
|
||||
definitions,
|
||||
};
|
||||
|
|
|
|||
324
scripts/release/main.ts
Executable file
324
scripts/release/main.ts
Executable file
|
|
@ -0,0 +1,324 @@
|
|||
#!/usr/bin/env tsx
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync, execFileSync } from "node:child_process";
|
||||
|
||||
const ENDPOINT_URL =
|
||||
"https://2a94c6a0ced8d35ea63cddc86c2681e7.r2.cloudflarestorage.com";
|
||||
const BUCKET = "rivet-releases";
|
||||
const PREFIX = "sandbox-agent";
|
||||
|
||||
const BINARY_FILES = [
|
||||
"sandbox-agent-x86_64-unknown-linux-musl",
|
||||
"sandbox-agent-x86_64-pc-windows-gnu.exe",
|
||||
"sandbox-agent-x86_64-apple-darwin",
|
||||
"sandbox-agent-aarch64-apple-darwin",
|
||||
];
|
||||
|
||||
function parseArgs(argv: string[]) {
|
||||
const args = new Map<string, string>();
|
||||
const flags = new Set<string>();
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (!arg.startsWith("--")) continue;
|
||||
if (arg.includes("=")) {
|
||||
const [key, value] = arg.split("=");
|
||||
args.set(key, value ?? "");
|
||||
continue;
|
||||
}
|
||||
const next = argv[i + 1];
|
||||
if (next && !next.startsWith("--")) {
|
||||
args.set(arg, next);
|
||||
i += 1;
|
||||
} else {
|
||||
flags.add(arg);
|
||||
}
|
||||
}
|
||||
return { args, flags };
|
||||
}
|
||||
|
||||
function run(cmd: string, cmdArgs: string[], options: Record<string, any> = {}) {
|
||||
const result = spawnSync(cmd, cmdArgs, { stdio: "inherit", ...options });
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
function runCapture(
|
||||
cmd: string,
|
||||
cmdArgs: string[],
|
||||
options: Record<string, any> = {},
|
||||
) {
|
||||
const result = spawnSync(cmd, cmdArgs, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf8",
|
||||
...options,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
const stderr = result.stderr ? String(result.stderr).trim() : "";
|
||||
throw new Error(`${cmd} failed: ${stderr}`);
|
||||
}
|
||||
return (result.stdout || "").toString().trim();
|
||||
}
|
||||
|
||||
interface ParsedSemver {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
prerelease: string[];
|
||||
}
|
||||
|
||||
function parseSemver(version: string): ParsedSemver {
|
||||
const match = version.match(
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/,
|
||||
);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid semantic version: ${version}`);
|
||||
}
|
||||
return {
|
||||
major: Number(match[1]),
|
||||
minor: Number(match[2]),
|
||||
patch: Number(match[3]),
|
||||
prerelease: match[4] ? match[4].split(".") : [],
|
||||
};
|
||||
}
|
||||
|
||||
function compareSemver(a: ParsedSemver, b: ParsedSemver) {
|
||||
if (a.major !== b.major) return a.major - b.major;
|
||||
if (a.minor !== b.minor) return a.minor - b.minor;
|
||||
return a.patch - b.patch;
|
||||
}
|
||||
|
||||
function isStable(version: string) {
|
||||
return parseSemver(version).prerelease.length === 0;
|
||||
}
|
||||
|
||||
function getAllGitVersions() {
|
||||
try {
|
||||
execFileSync("git", ["fetch", "--tags", "--force", "--quiet"], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
|
||||
const output = runCapture("git", ["tag", "-l", "v*"]);
|
||||
if (!output) return [];
|
||||
|
||||
return output
|
||||
.split("\n")
|
||||
.map((tag) => tag.replace(/^v/, ""))
|
||||
.filter((tag) => {
|
||||
try {
|
||||
parseSemver(tag);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.sort((a, b) => compareSemver(parseSemver(b), parseSemver(a)));
|
||||
}
|
||||
|
||||
function getLatestStableVersion() {
|
||||
const versions = getAllGitVersions();
|
||||
const stable = versions.filter((version) => isStable(version));
|
||||
return stable[0] || null;
|
||||
}
|
||||
|
||||
function shouldTagAsLatest(version: string) {
|
||||
const parsed = parseSemver(version);
|
||||
if (parsed.prerelease.length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const latestStable = getLatestStableVersion();
|
||||
if (!latestStable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return compareSemver(parsed, parseSemver(latestStable)) > 0;
|
||||
}
|
||||
|
||||
function getAwsEnv() {
|
||||
const accessKey =
|
||||
process.env.AWS_ACCESS_KEY_ID || process.env.R2_RELEASES_ACCESS_KEY_ID;
|
||||
const secretKey =
|
||||
process.env.AWS_SECRET_ACCESS_KEY ||
|
||||
process.env.R2_RELEASES_SECRET_ACCESS_KEY;
|
||||
|
||||
if (!accessKey || !secretKey) {
|
||||
throw new Error("Missing AWS credentials for releases bucket");
|
||||
}
|
||||
|
||||
return {
|
||||
AWS_ACCESS_KEY_ID: accessKey,
|
||||
AWS_SECRET_ACCESS_KEY: secretKey,
|
||||
AWS_DEFAULT_REGION: "auto",
|
||||
};
|
||||
}
|
||||
|
||||
function uploadDir(localPath: string, remotePath: string) {
|
||||
const env = { ...process.env, ...getAwsEnv() };
|
||||
run(
|
||||
"aws",
|
||||
[
|
||||
"s3",
|
||||
"cp",
|
||||
localPath,
|
||||
`s3://${BUCKET}/${remotePath}`,
|
||||
"--recursive",
|
||||
"--checksum-algorithm",
|
||||
"CRC32",
|
||||
"--endpoint-url",
|
||||
ENDPOINT_URL,
|
||||
],
|
||||
{ env },
|
||||
);
|
||||
}
|
||||
|
||||
function uploadFile(localPath: string, remotePath: string) {
|
||||
const env = { ...process.env, ...getAwsEnv() };
|
||||
run(
|
||||
"aws",
|
||||
[
|
||||
"s3",
|
||||
"cp",
|
||||
localPath,
|
||||
`s3://${BUCKET}/${remotePath}`,
|
||||
"--checksum-algorithm",
|
||||
"CRC32",
|
||||
"--endpoint-url",
|
||||
ENDPOINT_URL,
|
||||
],
|
||||
{ env },
|
||||
);
|
||||
}
|
||||
|
||||
function uploadContent(content: string, remotePath: string) {
|
||||
const env = { ...process.env, ...getAwsEnv() };
|
||||
const result = spawnSync(
|
||||
"aws",
|
||||
[
|
||||
"s3",
|
||||
"cp",
|
||||
"-",
|
||||
`s3://${BUCKET}/${remotePath}`,
|
||||
"--endpoint-url",
|
||||
ENDPOINT_URL,
|
||||
],
|
||||
{
|
||||
env,
|
||||
input: content,
|
||||
stdio: ["pipe", "inherit", "inherit"],
|
||||
},
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
function buildTypescript(rootDir: string) {
|
||||
const sdkDir = path.join(rootDir, "sdks", "typescript");
|
||||
if (!fs.existsSync(sdkDir)) {
|
||||
throw new Error(`TypeScript SDK not found at ${sdkDir}`);
|
||||
}
|
||||
run("npm", ["install"], { cwd: sdkDir });
|
||||
run("npm", ["run", "build"], { cwd: sdkDir });
|
||||
return path.join(sdkDir, "dist");
|
||||
}
|
||||
|
||||
function uploadTypescriptArtifacts(rootDir: string, version: string, latest: boolean) {
|
||||
console.log("==> Building TypeScript SDK");
|
||||
const distPath = buildTypescript(rootDir);
|
||||
|
||||
console.log("==> Uploading TypeScript artifacts");
|
||||
uploadDir(distPath, `${PREFIX}/${version}/typescript/`);
|
||||
if (latest) {
|
||||
uploadDir(distPath, `${PREFIX}/latest/typescript/`);
|
||||
}
|
||||
}
|
||||
|
||||
function uploadInstallScript(rootDir: string, version: string, latest: boolean) {
|
||||
const installPath = path.join(
|
||||
rootDir,
|
||||
"scripts",
|
||||
"release",
|
||||
"static",
|
||||
"install.sh",
|
||||
);
|
||||
let installContent = fs.readFileSync(installPath, "utf8");
|
||||
|
||||
const uploadForVersion = (versionValue: string, remoteVersion: string) => {
|
||||
const content = installContent.replace(/__VERSION__/g, versionValue);
|
||||
uploadContent(content, `${PREFIX}/${remoteVersion}/install.sh`);
|
||||
};
|
||||
|
||||
uploadForVersion(version, version);
|
||||
if (latest) {
|
||||
uploadForVersion("latest", "latest");
|
||||
}
|
||||
}
|
||||
|
||||
function uploadBinaries(rootDir: string, version: string, latest: boolean) {
|
||||
const distDir = path.join(rootDir, "dist");
|
||||
if (!fs.existsSync(distDir)) {
|
||||
throw new Error(`dist directory not found at ${distDir}`);
|
||||
}
|
||||
|
||||
for (const fileName of BINARY_FILES) {
|
||||
const localPath = path.join(distDir, fileName);
|
||||
if (!fs.existsSync(localPath)) {
|
||||
throw new Error(`Missing binary: ${localPath}`);
|
||||
}
|
||||
|
||||
uploadFile(localPath, `${PREFIX}/${version}/${fileName}`);
|
||||
if (latest) {
|
||||
uploadFile(localPath, `${PREFIX}/latest/${fileName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { args, flags } = parseArgs(process.argv.slice(2));
|
||||
const versionArg = args.get("--version");
|
||||
if (!versionArg) {
|
||||
console.error("--version is required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const version = versionArg.replace(/^v/, "");
|
||||
parseSemver(version);
|
||||
|
||||
let latest: boolean;
|
||||
if (flags.has("--latest")) {
|
||||
latest = true;
|
||||
} else if (flags.has("--no-latest")) {
|
||||
latest = false;
|
||||
} else {
|
||||
latest = shouldTagAsLatest(version);
|
||||
}
|
||||
|
||||
const outputPath = args.get("--output");
|
||||
if (flags.has("--print-latest")) {
|
||||
if (outputPath) {
|
||||
fs.appendFileSync(outputPath, `latest=${latest}\n`);
|
||||
} else {
|
||||
process.stdout.write(latest ? "true" : "false");
|
||||
}
|
||||
}
|
||||
|
||||
if (flags.has("--upload-typescript")) {
|
||||
uploadTypescriptArtifacts(process.cwd(), version, latest);
|
||||
}
|
||||
|
||||
if (flags.has("--upload-install")) {
|
||||
uploadInstallScript(process.cwd(), version, latest);
|
||||
}
|
||||
|
||||
if (flags.has("--upload-binaries")) {
|
||||
uploadBinaries(process.cwd(), version, latest);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -7,28 +7,28 @@
|
|||
# shellcheck enable=require-variable-braces
|
||||
set -eu
|
||||
|
||||
WORK_DIR="/tmp/sandbox_daemon_install"
|
||||
WORK_DIR="/tmp/sandbox_agent_install"
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$WORK_DIR"
|
||||
cd "$WORK_DIR"
|
||||
|
||||
SANDBOX_DAEMON_VERSION="${SANDBOX_DAEMON_VERSION:-__VERSION__}"
|
||||
SANDBOX_DAEMON_BASE_URL="${SANDBOX_DAEMON_BASE_URL:-https://releases.rivet.dev}"
|
||||
SANDBOX_AGENT_VERSION="${SANDBOX_AGENT_VERSION:-__VERSION__}"
|
||||
SANDBOX_AGENT_BASE_URL="${SANDBOX_AGENT_BASE_URL:-https://releases.rivet.dev}"
|
||||
UNAME="$(uname -s)"
|
||||
ARCH="$(uname -m)"
|
||||
|
||||
if [ "$(printf '%s' "$UNAME" | cut -c 1-6)" = "Darwin" ]; then
|
||||
if [ "$ARCH" = "x86_64" ]; then
|
||||
FILE_NAME="sandbox-daemon-x86_64-apple-darwin"
|
||||
FILE_NAME="sandbox-agent-x86_64-apple-darwin"
|
||||
elif [ "$ARCH" = "arm64" ]; then
|
||||
FILE_NAME="sandbox-daemon-aarch64-apple-darwin"
|
||||
FILE_NAME="sandbox-agent-aarch64-apple-darwin"
|
||||
else
|
||||
echo "Unknown arch $ARCH" 1>&2
|
||||
exit 1
|
||||
fi
|
||||
elif [ "$(printf '%s' "$UNAME" | cut -c 1-5)" = "Linux" ]; then
|
||||
if [ "$ARCH" = "x86_64" ]; then
|
||||
FILE_NAME="sandbox-daemon-x86_64-unknown-linux-musl"
|
||||
FILE_NAME="sandbox-agent-x86_64-unknown-linux-musl"
|
||||
else
|
||||
echo "Unsupported Linux arch $ARCH" 1>&2
|
||||
exit 1
|
||||
|
|
@ -44,7 +44,7 @@ if [ -z "$BIN_DIR" ]; then
|
|||
fi
|
||||
set -u
|
||||
|
||||
INSTALL_PATH="$BIN_DIR/sandbox-daemon"
|
||||
INSTALL_PATH="$BIN_DIR/sandbox-agent"
|
||||
|
||||
if [ ! -d "$BIN_DIR" ]; then
|
||||
CHECK_DIR="$BIN_DIR"
|
||||
|
|
@ -61,18 +61,18 @@ if [ ! -d "$BIN_DIR" ]; then
|
|||
fi
|
||||
fi
|
||||
|
||||
URL="$SANDBOX_DAEMON_BASE_URL/sandbox-daemon/${SANDBOX_DAEMON_VERSION}/${FILE_NAME}"
|
||||
URL="$SANDBOX_AGENT_BASE_URL/sandbox-agent/${SANDBOX_AGENT_VERSION}/${FILE_NAME}"
|
||||
echo "> Downloading $URL"
|
||||
|
||||
curl -fsSL "$URL" -o sandbox-daemon
|
||||
chmod +x sandbox-daemon
|
||||
curl -fsSL "$URL" -o sandbox-agent
|
||||
chmod +x sandbox-agent
|
||||
|
||||
if [ ! -w "$BIN_DIR" ]; then
|
||||
echo "> Installing sandbox-daemon to $INSTALL_PATH (requires sudo)"
|
||||
sudo mv ./sandbox-daemon "$INSTALL_PATH"
|
||||
echo "> Installing sandbox-agent to $INSTALL_PATH (requires sudo)"
|
||||
sudo mv ./sandbox-agent "$INSTALL_PATH"
|
||||
else
|
||||
echo "> Installing sandbox-daemon to $INSTALL_PATH"
|
||||
mv ./sandbox-daemon "$INSTALL_PATH"
|
||||
echo "> Installing sandbox-agent to $INSTALL_PATH"
|
||||
mv ./sandbox-agent "$INSTALL_PATH"
|
||||
fi
|
||||
|
||||
case ":$PATH:" in
|
||||
|
|
@ -84,4 +84,4 @@ case ":$PATH:" in
|
|||
;;
|
||||
esac
|
||||
|
||||
echo "sandbox-daemon installed successfully."
|
||||
echo "sandbox-agent installed successfully."
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
{
|
||||
"name": "sandbox-daemon-typescript",
|
||||
"name": "@sandbox-agent/typescript-sdk",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"generate:openapi": "cargo run -p sandbox-daemon-openapi-gen -- --out src/generated/openapi.json",
|
||||
"generate:openapi": "cargo check -p sandbox-agent-openapi-gen && cargo run -p sandbox-agent-openapi-gen -- --out src/generated/openapi.json",
|
||||
"generate:types": "openapi-typescript src/generated/openapi.json -o src/generated/openapi.ts",
|
||||
"generate": "npm run generate:openapi && npm run generate:types",
|
||||
"build": "tsc -p tsconfig.json"
|
||||
"generate": "pnpm run generate:openapi && pnpm run generate:types",
|
||||
"build": "pnpm run generate && tsc -p tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
|
|
|
|||
8
spec.md
8
spec.md
|
|
@ -50,14 +50,14 @@ we need a standard thiserror for error responses. return errors as RFC 7807 Prob
|
|||
|
||||
it's ran with a token like this using clap:
|
||||
|
||||
sandbox-daemon --token <token> --host xxxx --port xxxx
|
||||
sandbox-agent --token <token> --host xxxx --port xxxx
|
||||
|
||||
(you can specify --no-token too)
|
||||
(also add cors flags to the cli to configure cors, default to no cors)
|
||||
|
||||
also expose a CLI endpoint for every http endpoint we have (specify this in claude.md to keep this to date) so we can do:
|
||||
|
||||
sandbox-daemon sessions get-messages --endpoint xxxx --token xxxx
|
||||
sandbox-agent sessions get-messages --endpoint xxxx --token xxxx
|
||||
|
||||
### http api
|
||||
|
||||
|
|
@ -154,7 +154,7 @@ type AgentError = { tokenError: ... } | { processExisted: ... } | { installFaile
|
|||
|
||||
### error taxonomy
|
||||
|
||||
All error responses use RFC 7807 Problem Details and map to a Rust `thiserror` enum. Canonical `type` values should be stable strings (e.g. `urn:sandbox-daemon:error:agent_not_installed`).
|
||||
All error responses use RFC 7807 Problem Details and map to a Rust `thiserror` enum. Canonical `type` values should be stable strings (e.g. `urn:sandbox-agent:error:agent_not_installed`).
|
||||
|
||||
Required error types:
|
||||
|
||||
|
|
@ -439,7 +439,7 @@ this machine is already authenticated with codex & claude & opencode (for codex)
|
|||
in frontend/packages/web/ build a vite + react app that:
|
||||
|
||||
- connect screen: prompts the user to provide an endpoint & optional token
|
||||
- shows instructions on how to run the sandbox-daemon (including cors)
|
||||
- shows instructions on how to run the sandbox-agent (including cors)
|
||||
- if gets error or cors error, instruct the user to ensure they have cors flags enabled
|
||||
- agent screen: provides a full agent ui covering all of the features. also includes a log of all http requests in the ui with a copy button for the curl command
|
||||
|
||||
|
|
|
|||
8
todo.md
8
todo.md
|
|
@ -44,8 +44,8 @@
|
|||
- [x] Parse JSONL output for subprocess agents and extract session/result metadata
|
||||
- [x] Map permissionMode to agent CLI flags (Claude/Codex/Amp)
|
||||
- [x] Implement session resume flags for Claude/OpenCode/Amp (Codex unsupported)
|
||||
- [x] Replace sandbox-daemon core agent modules with new agent-management crate (delete originals)
|
||||
- [x] Stabilize agent-management crate API and fix build issues (sandbox-daemon currently wired to WIP crate)
|
||||
- [x] Replace sandbox-agent core agent modules with new agent-management crate (delete originals)
|
||||
- [x] Stabilize agent-management crate API and fix build issues (sandbox-agent currently wired to WIP crate)
|
||||
- [x] Implement OpenCode shared server lifecycle (`opencode serve`, health, restart)
|
||||
- [x] Implement OpenCode HTTP session APIs + SSE event stream integration
|
||||
- [x] Implement JSONL parsing for subprocess agents and map to `UniversalEvent`
|
||||
|
|
@ -71,7 +71,7 @@
|
|||
|
||||
## Frontend (frontend/packages/web)
|
||||
- [x] Build Vite + React app with connect screen (endpoint + optional token)
|
||||
- [x] Add instructions to run sandbox-daemon (including CORS)
|
||||
- [x] Add instructions to run sandbox-agent (including CORS)
|
||||
- [x] Implement full agent UI covering all features
|
||||
- [x] Add HTTP request log with copyable curl command
|
||||
|
||||
|
|
@ -93,6 +93,6 @@
|
|||
|
||||
---
|
||||
|
||||
- implement release pipeline
|
||||
- [x] implement release pipeline
|
||||
- implement e2b example
|
||||
- implement typescript "start locally" by pulling form server using version
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue