@luxorai/agentbox-provider 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +220 -2
- package/dist/attach-helper.cjs +181 -0
- package/dist/attach-helper.cjs.map +1 -0
- package/dist/index.d.ts +333 -0
- package/dist/index.js +1634 -0
- package/dist/index.js.map +1 -0
- package/package.json +78 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Luxor Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,221 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @tenkicloud/agentbox-provider
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Run [AgentBox](https://agent-box.sh) coding agents in [Tenki](https://tenki.cloud) VMs — a community
|
|
4
|
+
provider plugin, published as its own package and registered with `agentbox plugin add`.
|
|
5
|
+
|
|
6
|
+
## What Tenki is
|
|
7
|
+
|
|
8
|
+
[Tenki](https://tenki.cloud) provisions VMs on demand, driven by the official
|
|
9
|
+
[`@tenkicloud/sandbox`](https://www.npmjs.com/package/@tenkicloud/sandbox) TypeScript SDK — a
|
|
10
|
+
ConnectRPC control plane plus a per-session data plane for command execution and file transfer.
|
|
11
|
+
Each AgentBox box becomes one Tenki session: a full VM with its own kernel, not a shared container.
|
|
12
|
+
|
|
13
|
+
## What you get
|
|
14
|
+
|
|
15
|
+
| Feature | What it means |
|
|
16
|
+
| ------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
|
17
|
+
| **A VM per box** | Every box is its own VM — real kernel isolation, not a shared container |
|
|
18
|
+
| **Your project, ready** | The workspace arrives as a git clone on a per-box branch, carrying your uncommitted and untracked changes |
|
|
19
|
+
| **Agents preinstalled** | `claude`, `codex` and `opencode` are in the image; `agentbox tenki claude` drops you straight into one |
|
|
20
|
+
| **Your setup travels** | Settings, MCP servers and plugins come from your host; credentials are seeded per box, never baked in |
|
|
21
|
+
| **Interactive terminal** | Attach over SSH into a tmux session — detach and come back, and resizing your window works |
|
|
22
|
+
| **Public preview URLs** | Every exposed port gets an HTTPS URL with no tunnel or token to manage; signed URLs for private ports |
|
|
23
|
+
| **Files in and out** | `agentbox cp` moves files between your machine and the box, in either direction |
|
|
24
|
+
| **Docker inside the box** | `docker build` and `docker run` work in the box, so containerised test suites and compose stacks run there |
|
|
25
|
+
| **Free pause and resume** | Pausing costs nothing and keeps running processes; resuming picks up where you left off |
|
|
26
|
+
| **Checkpoints** | Snapshot a warm box and start new ones from it, dependencies and all |
|
|
27
|
+
| **Long sessions** | A box you are actively using has its deadline extended for you |
|
|
28
|
+
| **Seconds to a new box** | A one-time `agentbox prepare` bakes the base image, so every box after that boots ready |
|
|
29
|
+
|
|
30
|
+
## Requirements
|
|
31
|
+
|
|
32
|
+
- Node.js 20 or newer
|
|
33
|
+
- The `agentbox` CLI (0.27.1 or newer — it must support provider SDK v2)
|
|
34
|
+
- An OpenSSH client (`ssh` and `ssh-keygen`) on the host, used for interactive attach
|
|
35
|
+
- A Tenki account with a workspace and an auth token
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npm i -g @tenkicloud/agentbox-provider
|
|
41
|
+
agentbox plugin add @tenkicloud/agentbox-provider
|
|
42
|
+
agentbox plugin list # -> tenki … (SDK v2)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
AgentBox does not know about `tenki` until `plugin add` records it. A plugin runs in-process with
|
|
46
|
+
full host and credential access, so `plugin add` is the trust boundary — see
|
|
47
|
+
[Security](#security-considerations).
|
|
48
|
+
|
|
49
|
+
## Authentication
|
|
50
|
+
|
|
51
|
+
On a terminal, the first `agentbox create --provider tenki` prompts for a token and saves it. To set
|
|
52
|
+
it up ahead of time, expose it as `TENKI_AUTH_TOKEN`:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
export TENKI_AUTH_TOKEN=... # or add TENKI_AUTH_TOKEN=… to ~/.agentbox/secrets.env
|
|
56
|
+
agentbox doctor # shows the `tenki:` group
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`TENKI_API_TOKEN` is accepted as an alias, and each `TENKI_*` variable has a `LUXORAI_*` equivalent
|
|
60
|
+
(for example `LUXORAI_AUTH_TOKEN`) that is read first. `--provider luxor` is an alias of `--provider tenki`. Credentials are read from the environment first, then
|
|
61
|
+
from `~/.agentbox/secrets.env` (mode `0600`); project `.env` files are never harvested. The token is
|
|
62
|
+
never logged and never included in the published package.
|
|
63
|
+
|
|
64
|
+
## Basic usage
|
|
65
|
+
|
|
66
|
+
One-time: bake the AgentBox base image in your Tenki workspace, then create a box. `prepare` boots a
|
|
67
|
+
throwaway builder from Tenki's `sandbox` base, installs tmux, the AgentBox runtime and the coding
|
|
68
|
+
agents, snapshots it, and pins the snapshot id. Every box then boots from that snapshot.
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
agentbox prepare --provider tenki # bakes the base image; pins the snapshot id locally
|
|
72
|
+
agentbox create --provider tenki
|
|
73
|
+
agentbox tenki claude # provider-prefix sugar also works
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Pin it project-wide with `box.provider: tenki` in `agentbox.yaml`, or per box with
|
|
77
|
+
`--provider tenki`.
|
|
78
|
+
|
|
79
|
+
## Resource configuration
|
|
80
|
+
|
|
81
|
+
Because AgentBox does not add config keys for plugin providers, Tenki reads its settings from the
|
|
82
|
+
generic keys plus its own environment variables:
|
|
83
|
+
|
|
84
|
+
| Setting | Where | Notes |
|
|
85
|
+
| ------------------ | ----------------------------- | ---------------------------------------------------------- |
|
|
86
|
+
| VM size | `--size` / `box.size` | `cpu-memory[-disk]` in GB, e.g. `4-8-20` |
|
|
87
|
+
| Default checkpoint | `box.defaultCheckpoint` | Generic key; no per-provider variant |
|
|
88
|
+
| Base image | `AGENTBOX_TENKI_BASE_IMAGE` | Tenki base image `prepare` layers onto (default `sandbox`) |
|
|
89
|
+
| Workspace | `AGENTBOX_TENKI_WORKSPACE_ID` | Defaults to the token's own scope |
|
|
90
|
+
| Session lifetime | `AGENTBOX_TENKI_TIMEOUT_MS` | Seeds the host keepalive loop |
|
|
91
|
+
|
|
92
|
+
The baked base snapshot id lives in `~/.agentbox/tenki-prepared.json`, managed by this plugin.
|
|
93
|
+
|
|
94
|
+
## Agent configuration and credentials
|
|
95
|
+
|
|
96
|
+
`prepare` bakes your host's **static** agent config into the base snapshot — settings, MCP servers,
|
|
97
|
+
plugin registries, and `/workspace` pre-trust — so boxes start with your own setup rather than a
|
|
98
|
+
blank one. It is best-effort: an agent whose config can't be read is skipped with a warning rather
|
|
99
|
+
than failing the bake.
|
|
100
|
+
|
|
101
|
+
One host-side wrinkle is handled for you: codex leaves a live Unix socket at `~/.codex/ipc/ipc.sock`
|
|
102
|
+
whenever it has run, and the upstream staging helper's `rsync -a` cannot recreate a socket (it exits
|
|
103
|
+
23). `prepare` stages codex through a sanitized shadow of that directory, so its config bakes whether
|
|
104
|
+
or not codex is running.
|
|
105
|
+
|
|
106
|
+
Credentials are handled separately, and deliberately not baked. AgentBox seeds them per box at
|
|
107
|
+
create time into `~/.agentbox-creds/<agent>/`, which the base image pivots into place with symlinks
|
|
108
|
+
(`~/.claude/.credentials.json`, `~/.codex/auth.json`, `~/.local/share/opencode/auth.json`). That
|
|
109
|
+
keeps auth tokens out of an image that may be long-lived or shared, and lets a refreshed token
|
|
110
|
+
propagate without re-baking.
|
|
111
|
+
|
|
112
|
+
That seeding only has something to push when AgentBox has host-side credential backups
|
|
113
|
+
(`~/.agentbox/<agent>-credentials.json`), which are captured from boxes you have already used. With
|
|
114
|
+
no backup yet, the symlinks are dangling by design: you log into the agent once inside the box, the
|
|
115
|
+
login lands in `~/.agentbox-creds/`, and AgentBox captures it from there for later boxes.
|
|
116
|
+
|
|
117
|
+
The base image also creates a `vscode` account as an alias of the box user (same uid, same home).
|
|
118
|
+
The credential seed shipped in the current provider SDK extracts with an in-shell `sudo -u vscode`,
|
|
119
|
+
so without that account the seed fails with `sudo: unknown user vscode` and boxes start logged out.
|
|
120
|
+
The alias keeps seeded files owned by the user the agents actually run as.
|
|
121
|
+
|
|
122
|
+
## Preview URLs
|
|
123
|
+
|
|
124
|
+
Every exposed port gets a public HTTPS URL through Tenki's WebProxy — no tunnel and no token
|
|
125
|
+
needed. `agentbox open` and the `expose:` entries in `agentbox.yaml` work as they do on any cloud
|
|
126
|
+
provider. Signed URLs are available for ports you don't want publicly reachable.
|
|
127
|
+
|
|
128
|
+
## Checkpoint support
|
|
129
|
+
|
|
130
|
+
Tenki snapshots are **id-addressed**: the platform returns an opaque id rather than a name you
|
|
131
|
+
choose. The plugin stores that id in AgentBox's cloud-checkpoint manifest, so
|
|
132
|
+
`agentbox checkpoint create` / `list` / restore behave the same as on Vercel and E2B. Snapshots are
|
|
133
|
+
taken live — the source box keeps running.
|
|
134
|
+
|
|
135
|
+
## Troubleshooting
|
|
136
|
+
|
|
137
|
+
- **`unknown provider "tenki"`** — the plugin isn't registered. Run `agentbox plugin add
|
|
138
|
+
@tenkicloud/agentbox-provider` and confirm with `agentbox plugin list`.
|
|
139
|
+
- **`plugin targets provider SDK vN`** — your `agentbox` CLI is too old. Update to 0.27.1 or newer.
|
|
140
|
+
- **Credentials not found** — check `agentbox doctor`. The token must be in the environment or in
|
|
141
|
+
`~/.agentbox/secrets.env`, not a project `.env`.
|
|
142
|
+
- **No base image** — run `agentbox prepare --provider tenki`. Boxes boot from a snapshot baked in
|
|
143
|
+
your own Tenki workspace, so this is required once per workspace. If that snapshot is later deleted
|
|
144
|
+
(or the token is pointed at another workspace), the next `prepare` notices and re-bakes.
|
|
145
|
+
- **Box expired mid-session** — raise `AGENTBOX_TENKI_TIMEOUT_MS`; the host keepalive extends a
|
|
146
|
+
working session, but it starts from the lifetime the box was created with.
|
|
147
|
+
- **Attach fails to open a shell** — attach needs `ssh` and `ssh-keygen` on the host. Per-box key
|
|
148
|
+
material lives in `~/.agentbox/boxes/<session-id>/ssh/`; the certificate is re-minted on every
|
|
149
|
+
attach, so deleting that directory is safe and simply regenerates it.
|
|
150
|
+
- **Attach as a different user** — set `AGENTBOX_TENKI_SSH_USER` if a custom base image does not run
|
|
151
|
+
as `tenki`.
|
|
152
|
+
|
|
153
|
+
## Security considerations
|
|
154
|
+
|
|
155
|
+
- An AgentBox provider plugin runs **in-process with the CLI**, with full host and credential
|
|
156
|
+
access. AgentBox does not sandbox plugin code — provisioning infrastructure and handling secrets
|
|
157
|
+
is the job. `agentbox plugin add` is the consent boundary; only add plugins you trust.
|
|
158
|
+
- The Tenki token is read from the environment or `~/.agentbox/secrets.env` (`0600`), never logged,
|
|
159
|
+
and never bundled into the published artifact.
|
|
160
|
+
- Each box is an isolated VM, so an agent cannot reach your host filesystem. Host-side
|
|
161
|
+
operations that need your credentials (`git push` in particular) are brokered by the AgentBox host
|
|
162
|
+
relay rather than by handing keys to the box.
|
|
163
|
+
- **The VM is the boundary, not the in-box user.** Inside a box the agent has passwordless
|
|
164
|
+
`sudo` and Docker access, which is root-equivalent by design — in-box Docker requires it.
|
|
165
|
+
- Interactive attach trusts the Tenki gateway's TLS rather than an SSH host key (the "host" is a
|
|
166
|
+
session id, not a stable endpoint). If you override `TENKI_BASE_URL` or `TENKI_GATEWAY_ADDRESS`,
|
|
167
|
+
point them only at endpoints you trust.
|
|
168
|
+
- Preview URLs are public: anyone holding one can reach the port, with no token. Use the signed
|
|
169
|
+
(expiring) variant for anything that should not stay durably reachable.
|
|
170
|
+
|
|
171
|
+
The full trust model, and how to report a vulnerability privately, are in
|
|
172
|
+
[SECURITY.md](./SECURITY.md).
|
|
173
|
+
|
|
174
|
+
## Uninstallation
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
agentbox plugin remove tenki # unregister (leaves the npm package installed)
|
|
178
|
+
npm uninstall -g @tenkicloud/agentbox-provider
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Destroy any remaining boxes with `agentbox destroy <name>` first — removing the plugin leaves
|
|
182
|
+
AgentBox unable to manage them. Base images and snapshots in your Tenki workspace are not deleted;
|
|
183
|
+
remove those through Tenki.
|
|
184
|
+
|
|
185
|
+
## Development
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
npm install
|
|
189
|
+
npm run build
|
|
190
|
+
npm test
|
|
191
|
+
npm run validate:package # packs, installs in isolation, asserts the plugin contract
|
|
192
|
+
|
|
193
|
+
agentbox plugin add . # register this working copy
|
|
194
|
+
agentbox doctor
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full gate set, the test conventions, and what to
|
|
198
|
+
know before running `prepare` against a real (billable) workspace. Release notes are in
|
|
199
|
+
[CHANGELOG.md](./CHANGELOG.md).
|
|
200
|
+
|
|
201
|
+
## Releasing
|
|
202
|
+
|
|
203
|
+
Releases run from the **Publish** workflow, and the version in `package.json` is the single source
|
|
204
|
+
of truth for both npm and the git tag:
|
|
205
|
+
|
|
206
|
+
1. Bump `version` in `package.json` on `main`, and add the matching section to
|
|
207
|
+
[CHANGELOG.md](./CHANGELOG.md). Both CI and the publish workflow fail if the declared version has
|
|
208
|
+
no changelog entry (`npm run check:changelog`).
|
|
209
|
+
2. Run the workflow with `dry_run` left on. It runs every CI gate plus `npm publish --dry-run`,
|
|
210
|
+
and changes nothing.
|
|
211
|
+
3. Run it again with `dry_run` off. It publishes to npm, tags the commit `v<version>`, and cuts a
|
|
212
|
+
GitHub release with generated notes.
|
|
213
|
+
|
|
214
|
+
Publishing is idempotent — an already-published version is skipped — so a re-run after a partial
|
|
215
|
+
failure is safe. Authentication is npm trusted publishing (OIDC), so there is no token to rotate.
|
|
216
|
+
|
|
217
|
+
## License
|
|
218
|
+
|
|
219
|
+
MIT — see [LICENSE](./LICENSE). Maintained by Luxor Labs. AgentBox itself is a separate project by
|
|
220
|
+
Marco D'Alia; this plugin builds on its public
|
|
221
|
+
[`@madarco/agentbox-provider-sdk`](https://www.npmjs.com/package/@madarco/agentbox-provider-sdk).
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// src/attach-helper.ts
|
|
4
|
+
var import_sandbox = require("@tenkicloud/sandbox");
|
|
5
|
+
|
|
6
|
+
// src/env-loader.ts
|
|
7
|
+
var import_node_fs = require("fs");
|
|
8
|
+
var import_node_os = require("os");
|
|
9
|
+
var import_node_path = require("path");
|
|
10
|
+
var ENV_SUFFIXES = ["AUTH_TOKEN", "API_TOKEN", "BASE_URL", "GATEWAY_ADDRESS"];
|
|
11
|
+
var TENKI_KEYS = [
|
|
12
|
+
...ENV_SUFFIXES.map((s) => `LUXORAI_${s}`),
|
|
13
|
+
...ENV_SUFFIXES.map((s) => `TENKI_${s}`)
|
|
14
|
+
];
|
|
15
|
+
var loaded = false;
|
|
16
|
+
function ensureTenkiEnvLoaded() {
|
|
17
|
+
if (loaded) return;
|
|
18
|
+
loaded = true;
|
|
19
|
+
importTenkiFromFile((0, import_node_path.resolve)((0, import_node_os.homedir)(), ".agentbox", "secrets.env"), TENKI_KEYS);
|
|
20
|
+
if (!process.env.TENKI_AUTH_TOKEN && process.env.TENKI_API_TOKEN) {
|
|
21
|
+
process.env.TENKI_AUTH_TOKEN = process.env.TENKI_API_TOKEN;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function importTenkiFromFile(path, keys) {
|
|
25
|
+
if (!(0, import_node_fs.existsSync)(path)) return;
|
|
26
|
+
let body;
|
|
27
|
+
try {
|
|
28
|
+
body = (0, import_node_fs.readFileSync)(path, "utf8");
|
|
29
|
+
} catch {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const parsed = parseEnvFile(body);
|
|
33
|
+
for (const key of keys) {
|
|
34
|
+
if (process.env[key] !== void 0) continue;
|
|
35
|
+
const value = parsed[key];
|
|
36
|
+
if (typeof value === "string") {
|
|
37
|
+
process.env[key] = value;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function parseEnvFile(body) {
|
|
42
|
+
const out = {};
|
|
43
|
+
for (const rawLine of body.split(/\r?\n/)) {
|
|
44
|
+
const line = rawLine.trim();
|
|
45
|
+
if (line.length === 0 || line.startsWith("#")) continue;
|
|
46
|
+
const stripped = line.startsWith("export ") ? line.slice("export ".length) : line;
|
|
47
|
+
const eq = stripped.indexOf("=");
|
|
48
|
+
if (eq <= 0) continue;
|
|
49
|
+
const key = stripped.slice(0, eq).trim();
|
|
50
|
+
let value = stripped.slice(eq + 1).trim();
|
|
51
|
+
if (value.length >= 2 && (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'"))) {
|
|
52
|
+
value = value.slice(1, -1);
|
|
53
|
+
}
|
|
54
|
+
out[key] = value;
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// src/attach-helper.ts
|
|
60
|
+
function parseArgs(argv) {
|
|
61
|
+
let sessionId;
|
|
62
|
+
let mode;
|
|
63
|
+
for (let i = 0; i < argv.length; i++) {
|
|
64
|
+
const a = argv[i];
|
|
65
|
+
if (a === "--session-id") {
|
|
66
|
+
sessionId = argv[i + 1];
|
|
67
|
+
i++;
|
|
68
|
+
} else if (a === "--proxy") {
|
|
69
|
+
mode = "proxy";
|
|
70
|
+
} else if (a === "--detached") {
|
|
71
|
+
mode = "detached";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (!sessionId) {
|
|
75
|
+
process.stderr.write("attach-helper: --session-id is required\n");
|
|
76
|
+
process.exit(2);
|
|
77
|
+
}
|
|
78
|
+
if (!mode) {
|
|
79
|
+
process.stderr.write("attach-helper: one of --proxy or --detached is required\n");
|
|
80
|
+
process.exit(2);
|
|
81
|
+
}
|
|
82
|
+
return { sessionId, mode };
|
|
83
|
+
}
|
|
84
|
+
function buildClient() {
|
|
85
|
+
const authToken = process.env.TENKI_AUTH_TOKEN;
|
|
86
|
+
if (!authToken) {
|
|
87
|
+
process.stderr.write("attach-helper: TENKI_AUTH_TOKEN env is required\n");
|
|
88
|
+
process.exit(2);
|
|
89
|
+
}
|
|
90
|
+
const opts = { authToken };
|
|
91
|
+
if (process.env.TENKI_BASE_URL) opts.baseUrl = process.env.TENKI_BASE_URL;
|
|
92
|
+
if (process.env.TENKI_GATEWAY_ADDRESS) opts.gatewayAddress = process.env.TENKI_GATEWAY_ADDRESS;
|
|
93
|
+
return new import_sandbox.TenkiSandbox(opts);
|
|
94
|
+
}
|
|
95
|
+
async function main() {
|
|
96
|
+
const { sessionId, mode } = parseArgs(process.argv.slice(2));
|
|
97
|
+
ensureTenkiEnvLoaded();
|
|
98
|
+
const client = buildClient();
|
|
99
|
+
let session;
|
|
100
|
+
try {
|
|
101
|
+
session = await client.get(sessionId);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
process.stderr.write(
|
|
104
|
+
`attach-helper: could not resolve session ${sessionId}: ${err instanceof Error ? err.message : String(err)}
|
|
105
|
+
`
|
|
106
|
+
);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
if (session.state !== "RUNNING") {
|
|
110
|
+
try {
|
|
111
|
+
await session.resume();
|
|
112
|
+
await session.waitReady(6e4);
|
|
113
|
+
} catch {
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (mode === "detached") {
|
|
117
|
+
const inner = process.env.AGENTBOX_TENKI_INNER_CMD;
|
|
118
|
+
if (!inner) {
|
|
119
|
+
process.stderr.write("attach-helper: AGENTBOX_TENKI_INNER_CMD env is required\n");
|
|
120
|
+
process.exit(2);
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const r = await session.run(["bash", "-c", inner], { cwd: "/workspace" });
|
|
124
|
+
process.exit(r.exitCode ?? 0);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
process.stderr.write(
|
|
127
|
+
`attach-helper: detached pre-start failed: ${err instanceof Error ? err.message : String(err)}
|
|
128
|
+
`
|
|
129
|
+
);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
let conn;
|
|
134
|
+
try {
|
|
135
|
+
conn = await session.ssh();
|
|
136
|
+
} catch (err) {
|
|
137
|
+
process.stderr.write(
|
|
138
|
+
`attach-helper: could not open the SSH transport: ${err instanceof Error ? err.message : String(err)}
|
|
139
|
+
`
|
|
140
|
+
);
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
process.stdin.on("data", (chunk) => {
|
|
144
|
+
conn.write(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength)).catch(() => {
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
process.stdin.on("end", () => {
|
|
148
|
+
try {
|
|
149
|
+
conn.close();
|
|
150
|
+
} catch {
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
let exitCode = 0;
|
|
154
|
+
try {
|
|
155
|
+
for (; ; ) {
|
|
156
|
+
const data = await conn.read();
|
|
157
|
+
if (data === null) break;
|
|
158
|
+
process.stdout.write(data);
|
|
159
|
+
}
|
|
160
|
+
} catch (err) {
|
|
161
|
+
process.stderr.write(
|
|
162
|
+
`attach-helper: SSH transport read failed: ${err instanceof Error ? err.message : String(err)}
|
|
163
|
+
`
|
|
164
|
+
);
|
|
165
|
+
exitCode = 1;
|
|
166
|
+
} finally {
|
|
167
|
+
try {
|
|
168
|
+
conn.close();
|
|
169
|
+
} catch {
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
process.exit(exitCode);
|
|
173
|
+
}
|
|
174
|
+
main().catch((err) => {
|
|
175
|
+
process.stderr.write(
|
|
176
|
+
`attach-helper: unhandled error: ${err instanceof Error ? err.message : String(err)}
|
|
177
|
+
`
|
|
178
|
+
);
|
|
179
|
+
process.exit(1);
|
|
180
|
+
});
|
|
181
|
+
//# sourceMappingURL=attach-helper.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/attach-helper.ts","../src/env-loader.ts"],"sourcesContent":["/**\n * Standalone helper spawned by `buildTenkiAttach`. Two modes, no interactive\n * shell handling of its own:\n *\n * --proxy An `ssh` ProxyCommand. Bridges this process's stdin/stdout to\n * the session's SSH transport (`session.ssh()`), so the host's\n * real `ssh` client speaks the SSH protocol end to end and owns\n * the PTY, auth, and window-resize handling.\n *\n * --detached Pre-start. Runs AGENTBOX_TENKI_INNER_CMD once over the\n * non-interactive data plane and exits. In detached mode the\n * inner command only CREATES the tmux session, so there is\n * nothing to attach to and opening a channel would hang the\n * host's `runDetached` await forever.\n *\n * Why the proxy mode is byte-shuffling and nothing more: `SSHConnection` wraps a\n * WebSocket carrying the RAW SSH wire protocol. It is a transport, not a shell —\n * an earlier version of this helper wrote shell commands into it and got back\n * only the gateway's version banner.\n *\n * Argv: `node attach-helper.cjs (--proxy|--detached) --session-id <id>`\n * Env:\n * TENKI_AUTH_TOKEN Tenki credentials (threaded in by build-attach).\n * AGENTBOX_TENKI_INNER_CMD Inner bash command (--detached only).\n * TENKI_BASE_URL,\n * TENKI_GATEWAY_ADDRESS Optional control-plane overrides.\n */\n\nimport { TenkiSandbox } from '@tenkicloud/sandbox';\nimport { ensureTenkiEnvLoaded } from './env-loader.js';\n\ninterface ParsedArgs {\n sessionId: string;\n mode: 'proxy' | 'detached';\n}\n\nfunction parseArgs(argv: string[]): ParsedArgs {\n let sessionId: string | undefined;\n let mode: 'proxy' | 'detached' | undefined;\n for (let i = 0; i < argv.length; i++) {\n const a = argv[i];\n if (a === '--session-id') {\n sessionId = argv[i + 1];\n i++;\n } else if (a === '--proxy') {\n mode = 'proxy';\n } else if (a === '--detached') {\n mode = 'detached';\n }\n }\n if (!sessionId) {\n process.stderr.write('attach-helper: --session-id is required\\n');\n process.exit(2);\n }\n if (!mode) {\n process.stderr.write('attach-helper: one of --proxy or --detached is required\\n');\n process.exit(2);\n }\n return { sessionId, mode };\n}\n\nfunction buildClient(): TenkiSandbox {\n const authToken = process.env.TENKI_AUTH_TOKEN;\n if (!authToken) {\n process.stderr.write('attach-helper: TENKI_AUTH_TOKEN env is required\\n');\n process.exit(2);\n }\n const opts: ConstructorParameters<typeof TenkiSandbox>[0] = { authToken };\n if (process.env.TENKI_BASE_URL) opts.baseUrl = process.env.TENKI_BASE_URL;\n if (process.env.TENKI_GATEWAY_ADDRESS) opts.gatewayAddress = process.env.TENKI_GATEWAY_ADDRESS;\n return new TenkiSandbox(opts);\n}\n\nasync function main(): Promise<void> {\n const { sessionId, mode } = parseArgs(process.argv.slice(2));\n ensureTenkiEnvLoaded();\n\n const client = buildClient();\n let session;\n try {\n session = await client.get(sessionId);\n } catch (err) {\n process.stderr.write(\n `attach-helper: could not resolve session ${sessionId}: ${err instanceof Error ? err.message : String(err)}\\n`,\n );\n process.exit(1);\n }\n\n // Defensive: an attach right after resume should already be RUNNING, but wake\n // a paused box rather than failing the connection.\n if (session.state !== 'RUNNING') {\n try {\n await session.resume();\n await session.waitReady(60_000);\n } catch {\n // fall through — the op below surfaces a clear transport error\n }\n }\n\n if (mode === 'detached') {\n const inner = process.env.AGENTBOX_TENKI_INNER_CMD;\n if (!inner) {\n process.stderr.write('attach-helper: AGENTBOX_TENKI_INNER_CMD env is required\\n');\n process.exit(2);\n }\n try {\n const r = await session.run(['bash', '-c', inner], { cwd: '/workspace' });\n process.exit(r.exitCode ?? 0);\n } catch (err) {\n process.stderr.write(\n `attach-helper: detached pre-start failed: ${err instanceof Error ? err.message : String(err)}\\n`,\n );\n process.exit(1);\n }\n }\n\n // --proxy: pure transport. stdin -> gateway, gateway -> stdout. `ssh` on the\n // other side of these pipes does the handshake and runs the remote command.\n let conn;\n try {\n conn = await session.ssh();\n } catch (err) {\n process.stderr.write(\n `attach-helper: could not open the SSH transport: ${err instanceof Error ? err.message : String(err)}\\n`,\n );\n process.exit(1);\n }\n\n process.stdin.on('data', (chunk: Buffer) => {\n conn.write(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength)).catch(() => {\n // transport gone; the read loop below exits and we follow it out\n });\n });\n // ssh closing its end of the pipe means the session is over.\n process.stdin.on('end', () => {\n try {\n conn.close();\n } catch {\n // already closed\n }\n });\n\n let exitCode = 0;\n try {\n for (;;) {\n const data = await conn.read();\n if (data === null) break; // transport closed\n process.stdout.write(data);\n }\n } catch (err) {\n process.stderr.write(\n `attach-helper: SSH transport read failed: ${err instanceof Error ? err.message : String(err)}\\n`,\n );\n exitCode = 1;\n } finally {\n try {\n conn.close();\n } catch {\n // ignore\n }\n }\n process.exit(exitCode);\n}\n\nmain().catch((err) => {\n process.stderr.write(\n `attach-helper: unhandled error: ${err instanceof Error ? err.message : String(err)}\\n`,\n );\n process.exit(1);\n});\n","/**\n * Tenki env auto-loader. The `@tenkicloud/sandbox` SDK reads `TENKI_AUTH_TOKEN`\n * from `process.env` (and we honor optional `TENKI_BASE_URL` /\n * `TENKI_GATEWAY_ADDRESS` overrides for non-default / self-hosted control\n * planes). We seed those from `~/.agentbox/secrets.env` — the store AgentBox's\n * built-in cloud providers use — so the SDK Just Works after a one-time setup.\n *\n * Lookup order (first wins; process.env is never overwritten):\n * 1. `process.env` (already set in the shell).\n * 2. `~/.agentbox/secrets.env` — written by the credential prompt.\n *\n * `TENKI_API_TOKEN` is accepted as an alias for `TENKI_AUTH_TOKEN`. Every key\n * also has a `LUXORAI_*` equivalent that wins over all `TENKI_*` keys; read them\n * through `readTenkiEnv` rather than `process.env`.\n *\n * Project-level `.env` / `.env.local` are intentionally NOT consulted: those\n * files belong to the app code being developed. Put host credentials in\n * `~/.agentbox/secrets.env` (or the shell env).\n *\n * Only TENKI- and LUXORAI-prefixed keys are imported; the rest of the file is left alone.\n * Idempotent and side-effect-free after the first call.\n */\n\nimport { existsSync, readFileSync } from 'node:fs';\nimport { homedir } from 'node:os';\nimport { resolve } from 'node:path';\n\nconst ENV_SUFFIXES = ['AUTH_TOKEN', 'API_TOKEN', 'BASE_URL', 'GATEWAY_ADDRESS'] as const;\nconst TENKI_KEYS = [\n ...ENV_SUFFIXES.map((s) => `LUXORAI_${s}`),\n ...ENV_SUFFIXES.map((s) => `TENKI_${s}`),\n];\n\n/** Every `LUXORAI_<suffix>` is tried (in order) before any legacy `TENKI_<suffix>`. */\nexport function readTenkiEnv(...suffixes: string[]): string | undefined {\n for (const prefix of ['LUXORAI_', 'TENKI_']) {\n for (const suffix of suffixes) {\n const value = process.env[`${prefix}${suffix}`];\n if (value) return value;\n }\n }\n return undefined;\n}\n\n/** `AGENTBOX_LUXORAI_<suffix>`, falling back to the legacy `AGENTBOX_TENKI_<suffix>`. */\nexport function readAgentboxEnv(suffix: string): string | undefined {\n return (\n process.env[`AGENTBOX_LUXORAI_${suffix}`]?.trim() ||\n process.env[`AGENTBOX_TENKI_${suffix}`]?.trim() ||\n undefined\n );\n}\n\n/** The configured auth token: `LUXORAI_AUTH_TOKEN` > `LUXORAI_API_TOKEN` > `TENKI_AUTH_TOKEN` > `TENKI_API_TOKEN`. */\nexport function readTenkiAuthToken(): string | undefined {\n return readTenkiEnv('AUTH_TOKEN', 'API_TOKEN');\n}\n\nlet loaded = false;\n\nexport function ensureTenkiEnvLoaded(): void {\n if (loaded) return;\n loaded = true;\n importTenkiFromFile(resolve(homedir(), '.agentbox', 'secrets.env'), TENKI_KEYS);\n // `TENKI_AUTH_TOKEN` is the name the Tenki SDK reads and the one we write.\n // `TENKI_API_TOKEN` is accepted as an alias because it's the more common\n // spelling to reach for; normalize it once here so nothing downstream has to\n // know about two names.\n if (!process.env.TENKI_AUTH_TOKEN && process.env.TENKI_API_TOKEN) {\n process.env.TENKI_AUTH_TOKEN = process.env.TENKI_API_TOKEN;\n }\n}\n\n/**\n * Force a re-read of `~/.agentbox/secrets.env`. Used by the interactive\n * credential flow after it persists the token, so the same process can pick it\n * up without a restart.\n */\nexport function reloadTenkiEnv(): void {\n loaded = false;\n ensureTenkiEnvLoaded();\n}\n\nfunction importTenkiFromFile(path: string, keys: readonly string[]): void {\n if (!existsSync(path)) return;\n let body: string;\n try {\n body = readFileSync(path, 'utf8');\n } catch {\n return;\n }\n const parsed = parseEnvFile(body);\n for (const key of keys) {\n if (process.env[key] !== undefined) continue;\n const value = parsed[key];\n if (typeof value === 'string') {\n process.env[key] = value;\n }\n }\n}\n\n/**\n * Minimal `.env` parser: handles `KEY=value`, `KEY=\"value\"`, `KEY='value'`,\n * `export KEY=value`, blank lines, and `#` comments. No variable interpolation\n * — predictable over feature-complete (matches the daytona / vercel / e2b\n * loaders).\n */\nexport function parseEnvFile(body: string): Record<string, string> {\n const out: Record<string, string> = {};\n for (const rawLine of body.split(/\\r?\\n/)) {\n const line = rawLine.trim();\n if (line.length === 0 || line.startsWith('#')) continue;\n const stripped = line.startsWith('export ') ? line.slice('export '.length) : line;\n const eq = stripped.indexOf('=');\n if (eq <= 0) continue;\n const key = stripped.slice(0, eq).trim();\n let value = stripped.slice(eq + 1).trim();\n if (\n value.length >= 2 &&\n ((value.startsWith('\"') && value.endsWith('\"')) ||\n (value.startsWith(\"'\") && value.endsWith(\"'\")))\n ) {\n value = value.slice(1, -1);\n }\n out[key] = value;\n }\n return out;\n}\n"],"mappings":"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","names":[]}
|