1
0
forked from wrenn/wrenn

Merge pull request 'Terminal connection (PTY)' (#18) from feat/ssh-connection into dev

Reviewed-on: wrenn/wrenn#18
This commit is contained in:
2026-04-10 23:45:10 +00:00
20 changed files with 2582 additions and 86 deletions

View File

@ -25,7 +25,11 @@ func NewMultiplexedChannel[T any](buffer int) *MultiplexedChannel[T] {
c.mu.RLock()
for _, cons := range c.channels {
cons <- v
select {
case cons <- v:
default:
// Consumer not reading — skip to prevent deadlock
}
}
c.mu.RUnlock()
@ -52,7 +56,7 @@ func (m *MultiplexedChannel[T]) Fork() (chan T, func()) {
m.mu.Lock()
defer m.mu.Unlock()
consumer := make(chan T)
consumer := make(chan T, 4096)
m.channels = append(m.channels, consumer)

View File

@ -62,16 +62,15 @@ func (s *Service) getProcess(selector *rpc.ProcessSelector) (*handler.Handler, e
s.processes.Range(func(_ uint32, value *handler.Handler) bool {
if value.Tag == nil {
return true
return true // no tag, keep looking
}
if *value.Tag == tag {
proc = value
return true
return false // found, stop iterating
}
return false
return true // different tag, keep looking
})
if proc == nil {

View File

@ -28,6 +28,9 @@
"vite": "^7.3.1"
},
"dependencies": {
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"chart.js": "^4.5.1"
}
}

View File

@ -8,6 +8,15 @@ importers:
.:
dependencies:
'@xterm/addon-fit':
specifier: ^0.11.0
version: 0.11.0
'@xterm/addon-web-links':
specifier: ^0.12.0
version: 0.12.0
'@xterm/xterm':
specifier: ^6.0.0
version: 6.0.0
chart.js:
specifier: ^4.5.1
version: 4.5.1
@ -534,6 +543,15 @@ packages:
resolution: {integrity: sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@xterm/addon-fit@0.11.0':
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
'@xterm/addon-web-links@0.12.0':
resolution: {integrity: sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==}
'@xterm/xterm@6.0.0':
resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==}
acorn@8.16.0:
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
engines: {node: '>=0.4.0'}
@ -1197,6 +1215,12 @@ snapshots:
'@typescript-eslint/types@8.57.1': {}
'@xterm/addon-fit@0.11.0': {}
'@xterm/addon-web-links@0.12.0': {}
'@xterm/xterm@6.0.0': {}
acorn@8.16.0: {}
aria-query@5.3.1: {}

View File

@ -120,5 +120,6 @@ export async function downloadFile(sandboxId: string, path: string, filename: st
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
// Delay revocation so the browser has time to start the download
setTimeout(() => URL.revokeObjectURL(url), 5000);
}

View File

@ -27,6 +27,13 @@
let fileContent = $state<string | null>(null);
let fileLoading = $state(false);
let fileError = $state<string | null>(null);
let downloading = $state(false);
// Request generation counters — discard stale responses from rapid clicks
let dirGeneration = 0;
let fileGeneration = 0;
const MAX_PREVIEW_LINES = 5000;
// Path input
let pathInput = $state('~');
@ -98,7 +105,9 @@
if (!isRunning) return;
dirLoading = true;
dirError = null;
const gen = ++dirGeneration;
const result = await listDir(sandboxId, currentPath);
if (gen !== dirGeneration) return; // stale response
if (result.ok) {
entries = result.data.entries ?? [];
// Resolve actual path when envd expanded ~ or a relative path
@ -130,12 +139,12 @@
}
fileLoading = true;
const gen = ++fileGeneration;
const result = await readFile(sandboxId, entry.path);
if (gen !== fileGeneration) return; // stale response — user clicked another file
if (result.ok) {
// Check if content appears to be binary (contains null bytes or mostly non-printable)
if (looksLikeBinary(result.data)) {
fileContent = null;
// Will show download prompt
} else {
fileContent = result.data;
}
@ -158,12 +167,14 @@
}
async function handleDownload() {
if (!selectedFile) return;
if (!selectedFile || downloading) return;
downloading = true;
try {
await downloadFile(sandboxId, selectedFile.path, selectedFile.name);
} catch {
fileError = 'Download failed';
}
downloading = false;
}
function handlePathSubmit(e: SubmitEvent) {
@ -242,10 +253,11 @@
}
// Load initial directory on mount, falling back to / if home can't be resolved
let hasInitiallyLoaded = false;
$effect(() => {
if (isRunning) {
if (isRunning && !hasInitiallyLoaded) {
hasInitiallyLoaded = true;
loadDir().then(() => {
// If ~ couldn't be resolved (empty dir or error), fall back to /
if (!currentPath.startsWith('/')) {
currentPath = '/';
pathInput = '/';
@ -295,13 +307,18 @@
</style>
{#if !isRunning}
<div class="flex items-center gap-3 rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] px-5 py-4 m-8">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-muted)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<div class="flex flex-1 items-center justify-center">
<div class="flex flex-col items-center gap-4 text-center">
<div class="flex h-14 w-14 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)]" style="animation: iconFloat 3s ease-in-out infinite">
<svg class="text-[var(--color-text-muted)]" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
<span class="text-ui text-[var(--color-text-tertiary)]">
File browser requires a running capsule.
</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-ui font-medium text-[var(--color-text-secondary)]">File browser unavailable</span>
<span class="text-meta text-[var(--color-text-muted)]">Start the capsule to browse its filesystem</span>
</div>
</div>
</div>
{:else}
<div class="flex flex-1 min-h-0">
@ -532,13 +549,18 @@
<span class="font-mono text-badge text-[var(--color-text-muted)]">{formatFileSize(selectedFile.size)}</span>
<button
onclick={handleDownload}
class="flex items-center gap-1.5 rounded-[var(--radius-button)] border border-[var(--color-border)] bg-[var(--color-bg-3)] px-2.5 py-1 text-badge font-semibold uppercase tracking-[0.05em] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-bg-4)] hover:text-[var(--color-text-primary)]"
disabled={downloading}
class="flex items-center gap-1.5 rounded-[var(--radius-button)] border border-[var(--color-border)] bg-[var(--color-bg-3)] px-2.5 py-1 text-badge font-semibold uppercase tracking-[0.05em] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-bg-4)] hover:text-[var(--color-text-primary)] disabled:opacity-50 disabled:cursor-not-allowed"
>
{#if downloading}
<svg class="animate-spin" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
{:else}
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
{/if}
Download
</button>
</div>
@ -609,10 +631,24 @@
</div>
</div>
{:else if fileContent !== null}
<!-- Text preview with line numbers -->
<!-- Text preview with line numbers (capped at MAX_PREVIEW_LINES) -->
{@const allLines = fileContent.split('\n')}
{@const lines = allLines.length > MAX_PREVIEW_LINES ? allLines.slice(0, MAX_PREVIEW_LINES) : allLines}
{@const truncated = allLines.length > MAX_PREVIEW_LINES}
<div style="animation: fadeUp 0.15s ease both">
<pre class="preview-code p-0 m-0"><code class="block">{#each fileContent.split('\n') as line, i}<div class="code-line flex"><span class="line-num sticky left-0 inline-block w-[52px] shrink-0 select-none border-r border-[var(--color-border)] bg-[var(--color-bg-1)] px-3 py-0 text-right font-mono text-badge leading-[1.65rem] text-[var(--color-text-muted)] transition-colors duration-75">{i + 1}</span><span class="line-content flex-1 whitespace-pre-wrap break-all px-4 py-0 font-mono text-meta leading-[1.65rem] text-[var(--color-text-secondary)] transition-colors duration-75">{line || ' '}</span></div>{/each}</code></pre>
<pre class="preview-code p-0 m-0"><code class="block">{#each lines as line, i}<div class="code-line flex"><span class="line-num sticky left-0 inline-block w-[52px] shrink-0 select-none border-r border-[var(--color-border)] bg-[var(--color-bg-1)] px-3 py-0 text-right font-mono text-badge leading-[1.65rem] text-[var(--color-text-muted)] transition-colors duration-75">{i + 1}</span><span class="line-content flex-1 whitespace-pre-wrap break-all px-4 py-0 font-mono text-meta leading-[1.65rem] text-[var(--color-text-secondary)] transition-colors duration-75">{line || ' '}</span></div>{/each}</code></pre>
</div>
{#if truncated}
<div class="flex items-center justify-center gap-2 border-t border-[var(--color-border)] bg-[var(--color-bg-2)] px-4 py-3">
<span class="text-meta text-[var(--color-text-tertiary)]">
Showing {MAX_PREVIEW_LINES.toLocaleString()} of {allLines.length.toLocaleString()} lines
</span>
<button
onclick={handleDownload}
class="font-mono text-meta text-[var(--color-accent-mid)] transition-colors hover:text-[var(--color-accent-bright)]"
>Download full file</button>
</div>
{/if}
{/if}
</div>
{/if}

View File

@ -0,0 +1,595 @@
<script lang="ts">
import { onDestroy, tick } from 'svelte';
import { auth } from '$lib/auth.svelte';
type Props = {
sandboxId: string;
isRunning: boolean;
visible?: boolean;
};
let { sandboxId, isRunning, visible = true }: Props = $props();
type ConnectionState = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'error';
type SessionDisplay = {
id: number;
state: ConnectionState;
errorMessage: string | null;
ptyTag: string | null;
ptyPid: number | null;
};
type SessionInternal = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
term: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fitAddon: any;
ws: WebSocket | null;
resizeObserver: ResizeObserver | null;
fitDebounce: ReturnType<typeof setTimeout> | null;
inputFlushTimer: ReturnType<typeof setTimeout> | null;
inputBuffer: string;
};
const MAX_SESSIONS = 8;
let sessions = $state<SessionDisplay[]>([]);
const internals = new Map<number, SessionInternal>();
let activeSessionId = $state<number | null>(null);
let nextId = 0;
let cssLoaded = false;
let containerRef = $state<HTMLDivElement | undefined>(undefined);
let hasAutoCreated = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let TerminalClass: any = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let FitAddonClass: any = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let WebLinksAddonClass: any = null;
const activeSession = $derived(sessions.find(s => s.id === activeSessionId) ?? null);
const TERM_THEME = {
background: '#0a0c0b',
foreground: '#d0cdc6',
cursor: '#5e8c58',
cursorAccent: '#0a0c0b',
selectionBackground: 'rgba(94, 140, 88, 0.25)',
selectionForeground: '#eae7e2',
selectionInactiveBackground: 'rgba(94, 140, 88, 0.12)',
black: '#1a1e1c',
red: '#cf8172',
green: '#5e8c58',
yellow: '#d4a73c',
blue: '#5a9fd4',
magenta: '#b07ab8',
cyan: '#5aafb0',
white: '#d0cdc6',
brightBlack: '#454340',
brightRed: '#e09585',
brightGreen: '#89a785',
brightYellow: '#e0c070',
brightBlue: '#7ab8e0',
brightMagenta: '#c898cf',
brightCyan: '#7ac5c6',
brightWhite: '#eae7e2',
};
// Binary-safe base64 encode (handles multi-byte UTF-8 from xterm onData)
function toBase64(str: string): string {
return btoa(
Array.from(new TextEncoder().encode(str), (b) => String.fromCharCode(b)).join('')
);
}
// Binary-safe base64 decode (handles raw PTY bytes)
function fromBase64(b64: string): string {
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
function getWsUrl(): string {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = auth.token ? `?token=${encodeURIComponent(auth.token)}` : '';
return `${proto}//${window.location.host}/api/v1/sandboxes/${sandboxId}/pty${token}`;
}
function wsSend(ws: WebSocket | null, data: string) {
try {
if (ws?.readyState === WebSocket.OPEN) ws.send(data);
} catch {
// Connection closing — ignore
}
}
function updateSession(id: number, updates: Partial<SessionDisplay>) {
const idx = sessions.findIndex(s => s.id === id);
if (idx === -1) return;
Object.assign(sessions[idx], updates);
}
async function loadModules() {
if (TerminalClass) return;
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
import('@xterm/xterm'),
import('@xterm/addon-fit'),
import('@xterm/addon-web-links')
]);
TerminalClass = Terminal;
FitAddonClass = FitAddon;
WebLinksAddonClass = WebLinksAddon;
if (!cssLoaded) {
await import('@xterm/xterm/css/xterm.css');
cssLoaded = true;
}
}
// Create first session when the tab becomes visible for the first time
$effect(() => {
if (visible && isRunning && !hasAutoCreated && containerRef) {
hasAutoCreated = true;
createSession();
}
});
// Re-fit active terminal when tab becomes visible (after being hidden)
$effect(() => {
if (visible && activeSessionId !== null) {
const int = internals.get(activeSessionId);
if (int?.fitAddon && int.term) {
requestAnimationFrame(() => {
int.fitAddon.fit();
int.term.focus();
});
}
}
});
// Close all sessions when capsule stops running
$effect(() => {
if (!isRunning && sessions.length > 0) {
// Copy IDs to avoid mutating during iteration
const ids = sessions.map(s => s.id);
for (const id of ids) closeSession(id);
}
});
async function createSession() {
if (!isRunning || !containerRef) return;
if (sessions.length >= MAX_SESSIONS) return;
await loadModules();
const id = nextId++;
sessions = [...sessions, {
id,
state: 'connecting',
errorMessage: null,
ptyTag: null,
ptyPid: null,
}];
activeSessionId = id;
await tick();
const el = containerRef?.querySelector(`[data-session-id="${id}"]`) as HTMLDivElement | null;
if (!el) {
// DOM didn't render — clean up the orphaned display entry
sessions = sessions.filter(s => s.id !== id);
if (activeSessionId === id) activeSessionId = null;
return;
}
const fitAddon = new FitAddonClass();
const term = new TerminalClass({
cursorBlink: true,
cursorStyle: 'bar',
cursorInactiveStyle: 'outline',
fontFamily: "'JetBrains Mono Variable', 'JetBrains Mono', monospace",
fontSize: 14,
lineHeight: 1.35,
letterSpacing: 0,
theme: TERM_THEME,
allowProposedApi: true,
scrollback: 5000,
convertEol: true,
});
term.loadAddon(fitAddon);
term.loadAddon(new WebLinksAddonClass());
term.open(el);
const internal: SessionInternal = {
term,
fitAddon,
ws: null,
resizeObserver: null,
fitDebounce: null,
inputFlushTimer: null,
inputBuffer: '',
};
internals.set(id, internal);
requestAnimationFrame(() => fitAddon.fit());
internal.resizeObserver = new ResizeObserver(() => {
if (internal.fitDebounce) clearTimeout(internal.fitDebounce);
internal.fitDebounce = setTimeout(() => {
if (internal.fitAddon && internal.term && activeSessionId === id) {
internal.fitAddon.fit();
}
}, 50);
});
internal.resizeObserver.observe(el);
// Register input/resize handlers ONCE per terminal (not per connection).
function flushInput() {
const int = internals.get(id);
if (!int) return;
int.inputFlushTimer = null;
if (!int.inputBuffer) return;
wsSend(int.ws, JSON.stringify({ type: 'input', data: toBase64(int.inputBuffer) }));
int.inputBuffer = '';
}
term.onData((data: string) => {
const int = internals.get(id);
if (!int) return;
int.inputBuffer += data;
if (!int.inputFlushTimer) {
int.inputFlushTimer = setTimeout(flushInput, 50);
}
});
term.onResize(({ cols, rows }: { cols: number; rows: number }) => {
const i = internals.get(id);
wsSend(i?.ws ?? null, JSON.stringify({ type: 'resize', cols, rows }));
});
connectSession(id);
}
function connectSession(id: number, reconnectTag?: string) {
const int = internals.get(id);
if (!int) return;
const display = sessions.find(s => s.id === id);
const tag = reconnectTag ?? display?.ptyTag;
const ws = new WebSocket(getWsUrl());
int.ws = ws;
updateSession(id, { state: 'connecting', errorMessage: null });
ws.onopen = () => {
const { cols, rows } = int.term;
const msg: Record<string, unknown> = {
type: tag ? 'connect' : 'start',
cols,
rows,
};
if (tag) {
msg.tag = tag;
} else {
msg.cmd = '/bin/bash';
msg.envs = { TERM: 'xterm-256color' };
}
wsSend(ws, JSON.stringify(msg));
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
switch (msg.type) {
case 'started':
updateSession(id, {
state: 'connected',
ptyTag: msg.tag,
ptyPid: msg.pid ?? null,
});
if (activeSessionId === id) int.term.focus();
break;
case 'output':
if (msg.data) int.term.write(fromBase64(msg.data));
break;
case 'exit':
closeSession(id);
break;
case 'error':
if (msg.fatal) {
updateSession(id, { state: 'error', errorMessage: msg.data || 'Connection error' });
int.term.write(`\r\n\x1b[38;2;207;129;114m${msg.data}\x1b[0m\r\n`);
}
break;
case 'ping':
wsSend(ws, JSON.stringify({ type: 'pong' }));
break;
}
} catch {
// Ignore malformed messages
}
};
ws.onclose = (event) => {
const s = sessions.find(s => s.id === id);
if (!s) return;
// Abnormal close with a live session — auto-reconnect
if (!event.wasClean && s.state === 'connected' && s.ptyTag) {
updateSession(id, { state: 'connecting', errorMessage: null });
int.term.write('\r\n\x1b[38;2;107;104;98m[reconnecting...]\x1b[0m\r\n');
setTimeout(() => connectSession(id, s.ptyTag ?? undefined), 1000);
return;
}
if (s.state === 'connected') {
updateSession(id, { state: 'disconnected' });
}
};
ws.onerror = () => {
updateSession(id, { state: 'error', errorMessage: 'Connection lost — check that the capsule is running' });
};
}
function switchTo(id: number) {
activeSessionId = id;
requestAnimationFrame(() => {
const int = internals.get(id);
if (int?.fitAddon && int.term) {
int.fitAddon.fit();
int.term.focus();
}
});
}
function closeSession(id: number) {
const idx = sessions.findIndex(s => s.id === id);
if (idx === -1) return;
const int = internals.get(id);
if (int) {
if (int.fitDebounce) clearTimeout(int.fitDebounce);
if (int.inputFlushTimer) clearTimeout(int.inputFlushTimer);
int.resizeObserver?.disconnect();
wsSend(int.ws, JSON.stringify({ type: 'kill' }));
int.ws?.close();
int.term?.dispose();
internals.delete(id);
}
sessions = sessions.filter(s => s.id !== id);
if (activeSessionId === id) {
if (sessions.length === 0) {
activeSessionId = null;
} else {
const newIdx = Math.min(idx, sessions.length - 1);
switchTo(sessions[newIdx].id);
}
}
}
function reconnectSession(id: number) {
const int = internals.get(id);
const display = sessions.find(s => s.id === id);
if (!int || !display) return;
int.ws?.close();
connectSession(id, display.ptyTag ?? undefined);
}
function statusDot(state: ConnectionState): string {
switch (state) {
case 'connected': return 'bg-[var(--color-accent)]';
case 'connecting': return 'bg-[var(--color-text-tertiary)] animate-pulse';
case 'error': return 'bg-[var(--color-red)]';
default: return 'bg-[var(--color-text-muted)]';
}
}
onDestroy(() => {
for (const [, int] of internals) {
if (int.fitDebounce) clearTimeout(int.fitDebounce);
if (int.inputFlushTimer) clearTimeout(int.inputFlushTimer);
int.resizeObserver?.disconnect();
int.ws?.close();
int.term?.dispose();
}
internals.clear();
});
</script>
<style>
.terminal-container :global(.xterm) {
padding: 12px 4px 12px 16px;
height: 100%;
}
.terminal-container :global(.xterm-viewport),
.terminal-container :global(.xterm-screen) {
background-color: #0a0c0b !important;
}
.terminal-container :global(.xterm-viewport) {
scrollbar-width: thin;
scrollbar-color: rgba(94, 140, 88, 0.18) transparent;
}
.terminal-container :global(.xterm-viewport::-webkit-scrollbar) {
width: 6px;
}
.terminal-container :global(.xterm-viewport::-webkit-scrollbar-track) {
background: transparent;
}
.terminal-container :global(.xterm-viewport::-webkit-scrollbar-thumb) {
background: rgba(94, 140, 88, 0.18);
border-radius: 3px;
}
.terminal-container :global(.xterm-viewport::-webkit-scrollbar-thumb:hover) {
background: rgba(94, 140, 88, 0.32);
}
.tab-scroll {
scrollbar-width: none;
}
.tab-scroll::-webkit-scrollbar {
display: none;
}
.term-tab {
position: relative;
}
.term-tab::after {
content: '';
position: absolute;
right: 0;
top: 25%;
bottom: 25%;
width: 1px;
background: var(--color-border);
}
.term-tab:last-child::after {
display: none;
}
.term-tab-active::after {
display: none;
}
.term-tab:has(+ .term-tab-active)::after {
display: none;
}
</style>
<div class="flex flex-1 flex-col min-h-0">
{#if !isRunning}
<div class="flex flex-1 items-center justify-center">
<div class="flex flex-col items-center gap-5 text-center">
<div class="flex h-16 w-16 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)]" style="animation: iconFloat 3s ease-in-out infinite">
<svg class="text-[var(--color-text-muted)]" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="4 17 10 11 4 5" /><line x1="12" y1="19" x2="20" y2="19" />
</svg>
</div>
<div class="flex flex-col gap-1.5">
<span class="text-body font-medium text-[var(--color-text-secondary)]">Terminal unavailable</span>
<span class="text-ui text-[var(--color-text-muted)]">Start the capsule to connect</span>
</div>
</div>
</div>
{:else}
<!-- Unified session bar (hidden when no sessions) -->
<div class="flex items-stretch bg-[var(--color-bg-1)]" style:display={sessions.length === 0 ? 'none' : 'flex'}>
<div class="tab-scroll flex items-stretch overflow-x-auto">
{#each sessions as session (session.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
onclick={() => switchTo(session.id)}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') switchTo(session.id); }}
role="tab"
tabindex="0"
aria-selected={session.id === activeSessionId}
class="term-tab group flex shrink-0 cursor-pointer items-center gap-2.5 px-5 py-2.5 text-meta transition-colors
{session.id === activeSessionId
? 'term-tab-active bg-[var(--color-bg-0)] text-[var(--color-text-primary)]'
: 'bg-[var(--color-bg-1)] text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-2)] hover:text-[var(--color-text-secondary)] border-b border-b-[var(--color-border)]'}"
>
{#if session.state === 'connected'}
<span class="relative flex h-[7px] w-[7px] shrink-0">
<span class="animate-status-ping absolute inline-flex h-full w-full rounded-full bg-[var(--color-accent)]"></span>
<span class="relative inline-flex h-[7px] w-[7px] rounded-full bg-[var(--color-accent)]"></span>
</span>
{:else if session.state === 'connecting'}
<svg class="animate-spin shrink-0 text-[var(--color-text-tertiary)]" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
{:else if session.state === 'error'}
<span class="h-[7px] w-[7px] shrink-0 rounded-full bg-[var(--color-red)]"></span>
{:else}
<span class="h-[7px] w-[7px] shrink-0 rounded-full bg-[var(--color-text-muted)]"></span>
{/if}
<span class="font-mono">
bash{#if session.ptyPid}<span class="text-[var(--color-text-muted)]">:{session.ptyPid}</span>{/if}
</span>
<button
onclick={(e) => { e.stopPropagation(); closeSession(session.id); }}
class="ml-0.5 flex h-5 w-5 items-center justify-center rounded-[3px] text-[var(--color-text-muted)] opacity-0 transition-all group-hover:opacity-100 hover:bg-[var(--color-bg-4)] hover:text-[var(--color-text-secondary)]"
title="Close session"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{/each}
</div>
<button
onclick={createSession}
disabled={sessions.length >= MAX_SESSIONS}
class="flex shrink-0 items-center justify-center aspect-square self-stretch border-b border-[var(--color-border)] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-bg-2)] hover:text-[var(--color-text-primary)] disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-[var(--color-text-tertiary)]"
title={sessions.length >= MAX_SESSIONS ? `Maximum ${MAX_SESSIONS} sessions` : 'New terminal session'}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
<div class="flex-1 border-b border-[var(--color-border)] bg-[var(--color-bg-1)]"></div>
{#if activeSession}
<div class="flex items-center gap-3 border-b border-[var(--color-border)] bg-[var(--color-bg-1)] pr-4">
{#if activeSession.state === 'error' && activeSession.errorMessage}
<span class="text-meta text-[var(--color-red)]/70">{activeSession.errorMessage}</span>
{/if}
{#if (activeSession.state === 'disconnected' || activeSession.state === 'error') && activeSession.ptyTag}
<button
onclick={() => activeSession && reconnectSession(activeSession.id)}
class="flex items-center gap-1.5 rounded-[var(--radius-button)] border border-[var(--color-border)] bg-[var(--color-bg-3)] px-3 py-1 text-meta font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-bg-4)] hover:text-[var(--color-text-primary)]"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="1 4 1 10 7 10" /><polyline points="23 20 23 14 17 14" />
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
</svg>
Reconnect
</button>
{/if}
{#if activeSession.ptyTag}
<span class="font-mono text-label text-[var(--color-text-muted)]">{activeSession.ptyTag}</span>
{/if}
</div>
{/if}
</div>
<!-- Terminal surfaces -->
<div class="relative flex-1 min-h-0 bg-[var(--color-bg-0)]" bind:this={containerRef}>
{#each sessions as session (session.id)}
<div
data-session-id={session.id}
class="terminal-container absolute inset-0 bg-[var(--color-bg-0)]"
style:display={session.id === activeSessionId ? 'block' : 'none'}
></div>
{/each}
{#if sessions.length === 0}
<div class="flex h-full items-center justify-center">
<div class="flex flex-col items-center gap-5 text-center">
<div class="flex h-16 w-16 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)]" style="animation: iconFloat 3s ease-in-out infinite">
<svg class="text-[var(--color-text-muted)]" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="4 17 10 11 4 5" /><line x1="12" y1="19" x2="20" y2="19" />
</svg>
</div>
<div class="flex flex-col gap-1.5">
<span class="text-body font-medium text-[var(--color-text-secondary)]">No active sessions</span>
<span class="text-ui text-[var(--color-text-muted)]">All terminal sessions have been closed</span>
</div>
<button
onclick={createSession}
class="mt-1 flex items-center gap-2 rounded-[var(--radius-button)] border border-[var(--color-accent)]/30 bg-[var(--color-accent-glow-mid)] px-5 py-2.5 text-ui font-semibold text-[var(--color-accent-bright)] transition-all duration-150 hover:border-[var(--color-accent)]/50 hover:bg-[var(--color-accent)]/15 hover:-translate-y-px active:translate-y-0"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
New session
</button>
</div>
</div>
{/if}
</div>
{/if}
</div>

View File

@ -4,6 +4,7 @@
import { goto } from '$app/navigation';
import { getCapsule, type Capsule } from '$lib/api/capsules';
import FilesTab from '$lib/components/FilesTab.svelte';
import TerminalTab from '$lib/components/TerminalTab.svelte';
import {
fetchSandboxMetrics,
METRIC_RANGES,
@ -18,9 +19,21 @@
let capsuleLoading = $state(true);
let capsuleError = $state<string | null>(null);
type Tab = 'metrics' | 'files';
type Tab = 'metrics' | 'files' | 'terminal';
const VALID_TABS: Tab[] = ['metrics', 'files', 'terminal'];
let activeTab = $state<Tab>('metrics');
function setTab(tab: Tab) {
activeTab = tab;
const url = new URL(window.location.href);
if (tab === 'metrics') {
url.searchParams.delete('tab');
} else {
url.searchParams.set('tab', tab);
}
history.replaceState(null, '', url.toString());
}
let range = $state<MetricRange>('10m');
let points = $state<MetricPoint[]>([]);
let metricsLoading = $state(true);
@ -304,7 +317,14 @@
});
onMount(async () => {
const urlRange = new URLSearchParams(window.location.search).get('range');
const params = new URLSearchParams(window.location.search);
const urlTab = params.get('tab') as Tab | null;
if (urlTab && VALID_TABS.includes(urlTab)) {
activeTab = urlTab;
}
const urlRange = params.get('range');
if (urlRange && METRIC_RANGES.includes(urlRange as MetricRange)) {
range = urlRange as MetricRange;
}
@ -407,7 +427,7 @@
<!-- Tabs (matches Templates page pattern) -->
<div class="mt-5 flex gap-0 border-b border-[var(--color-border)] px-7">
<button
onclick={() => (activeTab = 'metrics')}
onclick={() => setTab('metrics')}
class="flex items-center gap-2 border-b-2 px-4 py-2.5 text-ui font-medium transition-colors duration-150
{activeTab === 'metrics'
? 'border-[var(--color-accent)] text-[var(--color-accent-bright)]'
@ -420,7 +440,7 @@
</button>
<button
onclick={() => (activeTab = 'files')}
onclick={() => setTab('files')}
class="flex items-center gap-2 border-b-2 px-4 py-2.5 text-ui font-medium transition-colors duration-150
{activeTab === 'files'
? 'border-[var(--color-accent)] text-[var(--color-accent-bright)]'
@ -431,9 +451,26 @@
</svg>
Files
</button>
<button
onclick={() => setTab('terminal')}
class="flex items-center gap-2 border-b-2 px-4 py-2.5 text-ui font-medium transition-colors duration-150
{activeTab === 'terminal'
? 'border-[var(--color-accent)] text-[var(--color-accent-bright)]'
: 'border-transparent text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'}"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="4 17 10 11 4 5" /><line x1="12" y1="19" x2="20" y2="19" />
</svg>
Terminal
</button>
</div>
<!-- Stats tab content -->
<!-- Tab content -->
<!-- Terminal stays mounted so sessions survive tab switches -->
<div class="flex flex-1 min-h-0" style:display={activeTab === 'terminal' ? 'flex' : 'none'}>
<TerminalTab sandboxId={sandboxId} isRunning={capsule.status === 'running'} visible={activeTab === 'terminal'} />
</div>
{#if activeTab === 'files'}
<div class="anim-in flex flex-1 min-h-0" style="animation-delay: 0.05s">
<FilesTab sandboxId={sandboxId} isRunning={capsule.status === 'running'} />

View File

@ -8,7 +8,8 @@ export default defineConfig({
proxy: {
'/api': {
target: 'http://localhost:8080',
rewrite: (path) => path.replace(/^\/api/, '')
rewrite: (path) => path.replace(/^\/api/, ''),
ws: true
}
}
}

View File

@ -0,0 +1,405 @@
package api
import (
"context"
"encoding/base64"
"encoding/json"
"log/slog"
"net/http"
"sync"
"time"
"connectrpc.com/connect"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/internal/auth"
"git.omukk.dev/wrenn/wrenn/internal/db"
"git.omukk.dev/wrenn/wrenn/internal/id"
"git.omukk.dev/wrenn/wrenn/internal/lifecycle"
pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen"
"git.omukk.dev/wrenn/wrenn/proto/hostagent/gen/hostagentv1connect"
)
const (
ptyInactivityTimeout = 120 * time.Second
ptyKeepaliveInterval = 30 * time.Second
ptyDefaultCmd = "/bin/bash"
ptyDefaultCols = 80
ptyDefaultRows = 24
)
type ptyHandler struct {
db *db.Queries
pool *lifecycle.HostClientPool
}
func newPtyHandler(db *db.Queries, pool *lifecycle.HostClientPool) *ptyHandler {
return &ptyHandler{db: db, pool: pool}
}
// --- WebSocket message types ---
// wsPtyIn is the inbound message from the client.
type wsPtyIn struct {
Type string `json:"type"` // "start", "connect", "input", "resize", "kill"
Cmd string `json:"cmd,omitempty"` // for "start"
Args []string `json:"args,omitempty"` // for "start"
Cols uint32 `json:"cols,omitempty"` // for "start", "resize"
Rows uint32 `json:"rows,omitempty"` // for "start", "resize"
Envs map[string]string `json:"envs,omitempty"` // for "start"
Cwd string `json:"cwd,omitempty"` // for "start"
User string `json:"user,omitempty"` // for "start"
Tag string `json:"tag,omitempty"` // for "connect"
Data string `json:"data,omitempty"` // for "input" (base64)
}
// wsPtyOut is the outbound message to the client.
type wsPtyOut struct {
Type string `json:"type"` // "started", "output", "exit", "error"
Tag string `json:"tag,omitempty"` // for "started"
PID uint32 `json:"pid,omitempty"` // for "started"
Data string `json:"data,omitempty"` // for "output" (base64), "error"
ExitCode *int32 `json:"exit_code,omitempty"` // for "exit"
Fatal bool `json:"fatal,omitempty"` // for "error"
}
// wsWriter wraps a websocket.Conn with a mutex for concurrent writes.
type wsWriter struct {
conn *websocket.Conn
mu sync.Mutex
}
func (w *wsWriter) writeJSON(v any) {
w.mu.Lock()
defer w.mu.Unlock()
if err := w.conn.WriteJSON(v); err != nil {
slog.Debug("pty websocket write error", "error", err)
}
}
// PtySession handles WS /v1/sandboxes/{id}/pty.
func (h *ptyHandler) PtySession(w http.ResponseWriter, r *http.Request) {
sandboxIDStr := chi.URLParam(r, "id")
ctx := r.Context()
ac := auth.MustFromContext(ctx)
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
return
}
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID})
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
return
}
if sb.Status != "running" {
writeError(w, http.StatusConflict, "invalid_state", "sandbox is not running (status: "+sb.Status+")")
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
slog.Error("pty websocket upgrade failed", "error", err)
return
}
defer conn.Close()
ws := &wsWriter{conn: conn}
// Read the first message to determine start vs connect.
var firstMsg wsPtyIn
if err := conn.ReadJSON(&firstMsg); err != nil {
ws.writeJSON(wsPtyOut{Type: "error", Data: "failed to read first message: " + err.Error(), Fatal: true})
return
}
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
if err != nil {
ws.writeJSON(wsPtyOut{Type: "error", Data: "sandbox host is not reachable", Fatal: true})
return
}
streamCtx, cancel := context.WithCancel(ctx)
defer cancel()
switch firstMsg.Type {
case "start":
h.handleStart(streamCtx, cancel, ws, agent, sandboxIDStr, firstMsg)
case "connect":
h.handleConnect(streamCtx, cancel, ws, agent, sandboxIDStr, firstMsg)
default:
ws.writeJSON(wsPtyOut{Type: "error", Data: "first message must be type 'start' or 'connect'", Fatal: true})
}
// Update last active using a fresh context.
updateCtx, updateCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer updateCancel()
if err := h.db.UpdateLastActive(updateCtx, db.UpdateLastActiveParams{
ID: sandboxID,
LastActiveAt: pgtype.Timestamptz{
Time: time.Now(),
Valid: true,
},
}); err != nil {
slog.Warn("failed to update last active after pty session", "sandbox_id", sandboxIDStr, "error", err)
}
}
func (h *ptyHandler) handleStart(
ctx context.Context,
cancel context.CancelFunc,
ws *wsWriter,
agent hostagentv1connect.HostAgentServiceClient,
sandboxIDStr string,
msg wsPtyIn,
) {
cmd := msg.Cmd
if cmd == "" {
cmd = ptyDefaultCmd
}
cols := msg.Cols
if cols == 0 {
cols = ptyDefaultCols
}
rows := msg.Rows
if rows == 0 {
rows = ptyDefaultRows
}
tag := newPtyTag()
stream, err := agent.PtyAttach(ctx, connect.NewRequest(&pb.PtyAttachRequest{
SandboxId: sandboxIDStr,
Tag: tag,
Cmd: cmd,
Args: msg.Args,
Cols: cols,
Rows: rows,
Envs: msg.Envs,
Cwd: msg.Cwd,
User: msg.User,
}))
if err != nil {
ws.writeJSON(wsPtyOut{Type: "error", Data: "failed to start pty: " + err.Error(), Fatal: true})
return
}
defer stream.Close()
// Wait for the started event and forward it.
if !stream.Receive() {
if err := stream.Err(); err != nil {
ws.writeJSON(wsPtyOut{Type: "error", Data: "pty stream failed: " + err.Error(), Fatal: true})
}
return
}
resp := stream.Msg()
started, ok := resp.Event.(*pb.PtyAttachResponse_Started)
if !ok {
ws.writeJSON(wsPtyOut{Type: "error", Data: "expected started event from host agent", Fatal: true})
return
}
ws.writeJSON(wsPtyOut{Type: "started", Tag: started.Started.Tag, PID: started.Started.Pid})
runPtyLoop(ctx, cancel, ws, stream, agent, sandboxIDStr, tag)
}
func (h *ptyHandler) handleConnect(
ctx context.Context,
cancel context.CancelFunc,
ws *wsWriter,
agent hostagentv1connect.HostAgentServiceClient,
sandboxIDStr string,
msg wsPtyIn,
) {
if msg.Tag == "" {
ws.writeJSON(wsPtyOut{Type: "error", Data: "connect requires a 'tag' field", Fatal: true})
return
}
stream, err := agent.PtyAttach(ctx, connect.NewRequest(&pb.PtyAttachRequest{
SandboxId: sandboxIDStr,
Tag: msg.Tag,
}))
if err != nil {
ws.writeJSON(wsPtyOut{Type: "error", Data: "failed to connect to pty: " + err.Error(), Fatal: true})
return
}
defer stream.Close()
runPtyLoop(ctx, cancel, ws, stream, agent, sandboxIDStr, msg.Tag)
}
// runPtyLoop drives the bidirectional communication between the WebSocket
// and the host agent PTY stream.
func runPtyLoop(
ctx context.Context,
cancel context.CancelFunc,
ws *wsWriter,
stream *connect.ServerStreamForClient[pb.PtyAttachResponse],
agent hostagentv1connect.HostAgentServiceClient,
sandboxID string,
tag string,
) {
var wg sync.WaitGroup
// Inactivity timer — reset on input/resize, fires kill after timeout.
timer := time.NewTimer(ptyInactivityTimeout)
defer timer.Stop()
// Output pump: read from Connect stream, write to WebSocket.
wg.Add(1)
go func() {
defer wg.Done()
defer cancel()
for stream.Receive() {
resp := stream.Msg()
switch ev := resp.Event.(type) {
case *pb.PtyAttachResponse_Started:
// Already handled before the loop for "start" mode.
// For "connect" mode this won't appear.
ws.writeJSON(wsPtyOut{Type: "started", Tag: ev.Started.Tag, PID: ev.Started.Pid})
case *pb.PtyAttachResponse_Output:
ws.writeJSON(wsPtyOut{
Type: "output",
Data: base64.StdEncoding.EncodeToString(ev.Output.Data),
})
case *pb.PtyAttachResponse_Exited:
exitCode := ev.Exited.ExitCode
ws.writeJSON(wsPtyOut{Type: "exit", ExitCode: &exitCode})
return
}
}
if err := stream.Err(); err != nil && ctx.Err() == nil {
ws.writeJSON(wsPtyOut{Type: "error", Data: err.Error()})
}
}()
// Input pump: read from WebSocket, dispatch to host agent.
wg.Add(1)
go func() {
defer wg.Done()
defer cancel()
for {
_, raw, err := ws.conn.ReadMessage()
if err != nil {
return
}
var msg wsPtyIn
if json.Unmarshal(raw, &msg) != nil {
continue
}
// Use a background context for unary RPCs so they complete
// even if the stream context is being cancelled.
rpcCtx, rpcCancel := context.WithTimeout(context.Background(), 5*time.Second)
switch msg.Type {
case "input":
data, err := base64.StdEncoding.DecodeString(msg.Data)
if err != nil {
rpcCancel()
continue
}
if _, err := agent.PtySendInput(rpcCtx, connect.NewRequest(&pb.PtySendInputRequest{
SandboxId: sandboxID,
Tag: tag,
Data: data,
})); err != nil {
slog.Debug("pty send input error", "error", err)
}
resetTimer(timer, ptyInactivityTimeout)
case "resize":
cols := msg.Cols
rows := msg.Rows
if cols > 0 && rows > 0 {
if _, err := agent.PtyResize(rpcCtx, connect.NewRequest(&pb.PtyResizeRequest{
SandboxId: sandboxID,
Tag: tag,
Cols: cols,
Rows: rows,
})); err != nil {
slog.Debug("pty resize error", "error", err)
}
resetTimer(timer, ptyInactivityTimeout)
}
case "kill":
if _, err := agent.PtyKill(rpcCtx, connect.NewRequest(&pb.PtyKillRequest{
SandboxId: sandboxID,
Tag: tag,
})); err != nil {
slog.Debug("pty kill error", "error", err)
}
}
rpcCancel()
}
}()
// Keepalive pump: send periodic pings to prevent idle WS closure.
wg.Add(1)
go func() {
defer wg.Done()
ticker := time.NewTicker(ptyKeepaliveInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
ws.writeJSON(wsPtyOut{Type: "ping"})
case <-ctx.Done():
return
}
}
}()
// Inactivity timeout goroutine.
wg.Add(1)
go func() {
defer wg.Done()
select {
case <-timer.C:
slog.Info("pty session timed out", "sandbox_id", sandboxID, "tag", tag)
rpcCtx, rpcCancel := context.WithTimeout(context.Background(), 5*time.Second)
if _, err := agent.PtyKill(rpcCtx, connect.NewRequest(&pb.PtyKillRequest{
SandboxId: sandboxID,
Tag: tag,
})); err != nil {
slog.Debug("pty timeout kill error", "error", err)
}
rpcCancel()
cancel()
case <-ctx.Done():
}
}()
wg.Wait()
}
// newPtyTag returns a PTY session tag: "pty-" + 8 random hex chars.
func newPtyTag() string {
return "pty-" + id.NewPtyTag()
}
// resetTimer safely resets a timer by stopping it and draining the channel
// before resetting, avoiding the race documented in time.Timer.Reset.
func resetTimer(t *time.Timer, d time.Duration) {
if !t.Stop() {
select {
case <-t.C:
default:
}
}
t.Reset(d)
}

View File

@ -38,9 +38,14 @@ func requireAPIKeyOrJWT(queries *db.Queries, jwtSecret []byte) func(http.Handler
return
}
// Try JWT bearer token.
// Try JWT bearer token (header or query param for WebSocket).
tokenStr := ""
if header := r.Header.Get("Authorization"); strings.HasPrefix(header, "Bearer ") {
tokenStr := strings.TrimPrefix(header, "Bearer ")
tokenStr = strings.TrimPrefix(header, "Bearer ")
} else if t := r.URL.Query().Get("token"); t != "" {
tokenStr = t
}
if tokenStr != "" {
claims, err := auth.VerifyJWT(jwtSecret, tokenStr)
if err != nil {
slog.Warn("jwt auth failed", "error", err, "ip", r.RemoteAddr)

View File

@ -1206,6 +1206,84 @@ paths:
schema:
$ref: "#/components/schemas/Error"
/v1/sandboxes/{id}/pty:
parameters:
- name: id
in: path
required: true
schema:
type: string
get:
summary: Interactive PTY session via WebSocket
operationId: ptySession
tags: [sandboxes]
security:
- apiKeyAuth: []
description: |
Opens a WebSocket connection for an interactive PTY (terminal) session.
Supports creating new sessions, sending input, resizing, killing, and
reconnecting to existing sessions.
**Client sends** (first message — start a new PTY):
```json
{
"type": "start",
"cmd": "/bin/bash",
"args": [],
"cols": 80,
"rows": 24,
"envs": {"TERM": "xterm-256color"},
"cwd": "/home/user",
"user": "user"
}
```
All fields except `type` are optional. Defaults: cmd="/bin/bash", cols=80, rows=24.
**Client sends** (first message — reconnect to existing PTY):
```json
{"type": "connect", "tag": "pty-abc123de"}
```
**Client sends** (after session is established):
```json
{"type": "input", "data": "<base64-encoded bytes>"}
{"type": "resize", "cols": 120, "rows": 40}
{"type": "kill"}
```
**Server sends**:
```json
{"type": "started", "tag": "pty-abc123de", "pid": 42}
{"type": "output", "data": "<base64-encoded PTY bytes>"}
{"type": "exit", "exit_code": 0}
{"type": "error", "data": "description", "fatal": true}
{"type": "ping"}
```
PTY data (input and output) is base64-encoded because it contains raw
terminal bytes (escape sequences, control codes) that are not valid UTF-8.
Sessions have a 120-second inactivity timeout (reset on input/resize).
Sessions persist across WebSocket disconnections — the process keeps
running in the sandbox. Use the `tag` from the "started" response to
reconnect later.
responses:
"101":
description: WebSocket upgrade
"404":
description: Sandbox not found
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"409":
description: Sandbox not running
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/v1/sandboxes/{id}/files/stream/write:
parameters:
- name: id

View File

@ -73,6 +73,7 @@ func New(
metricsH := newSandboxMetricsHandler(queries, pool)
buildH := newBuildHandler(buildSvc, queries, pool)
channelH := newChannelHandler(channelSvc, al)
ptyH := newPtyHandler(queries, pool)
// OpenAPI spec and docs.
r.Get("/openapi.yaml", serveOpenAPI)
@ -138,6 +139,7 @@ func New(
r.Post("/files/mkdir", fsH.MakeDir)
r.Post("/files/remove", fsH.Remove)
r.Get("/metrics", metricsH.GetMetrics)
r.Get("/pty", ptyH.PtySession)
})
})

220
internal/envdclient/pty.go Normal file
View File

@ -0,0 +1,220 @@
package envdclient
import (
"context"
"fmt"
"io"
"log/slog"
"connectrpc.com/connect"
envdpb "git.omukk.dev/wrenn/wrenn/proto/envd/gen"
)
// PtyEvent represents a single event from a PTY output stream.
type PtyEvent struct {
Type string // "started", "output", "end"
PID uint32
Data []byte
ExitCode int32
Error string
}
// PtyStart starts a new PTY process in the guest and returns a channel of events.
// The tag is the stable identifier used to reconnect via PtyConnect.
// The channel is closed when the process ends or ctx is cancelled.
// NOTE: The user parameter from PtyAttachRequest is not yet supported by envd's
// ProcessConfig proto. When envd adds user support, thread it through here.
func (c *Client) PtyStart(ctx context.Context, tag, cmd string, args []string, cols, rows uint32, envs map[string]string, cwd string) (<-chan PtyEvent, error) {
stdin := true
cfg := &envdpb.ProcessConfig{
Cmd: cmd,
Args: args,
Envs: envs,
}
if cwd != "" {
cfg.Cwd = &cwd
}
req := connect.NewRequest(&envdpb.StartRequest{
Process: cfg,
Pty: &envdpb.PTY{
Size: &envdpb.PTY_Size{
Cols: cols,
Rows: rows,
},
},
Tag: &tag,
Stdin: &stdin,
})
stream, err := c.process.Start(ctx, req)
if err != nil {
return nil, fmt.Errorf("pty start: %w", err)
}
return drainPtyStream(ctx, &startStream{s: stream}, true), nil
}
// PtyConnect re-attaches to an existing PTY process by tag.
// Returns a channel of output events starting from the current point.
func (c *Client) PtyConnect(ctx context.Context, tag string) (<-chan PtyEvent, error) {
req := connect.NewRequest(&envdpb.ConnectRequest{
Process: &envdpb.ProcessSelector{
Selector: &envdpb.ProcessSelector_Tag{Tag: tag},
},
})
stream, err := c.process.Connect(ctx, req)
if err != nil {
return nil, fmt.Errorf("pty connect: %w", err)
}
return drainPtyStream(ctx, &connectStream{s: stream}, false), nil
}
// PtySendInput sends raw bytes to the PTY process identified by tag.
func (c *Client) PtySendInput(ctx context.Context, tag string, data []byte) error {
req := connect.NewRequest(&envdpb.SendInputRequest{
Process: &envdpb.ProcessSelector{
Selector: &envdpb.ProcessSelector_Tag{Tag: tag},
},
Input: &envdpb.ProcessInput{
Input: &envdpb.ProcessInput_Pty{Pty: data},
},
})
if _, err := c.process.SendInput(ctx, req); err != nil {
return fmt.Errorf("pty send input: %w", err)
}
return nil
}
// PtyResize updates the terminal dimensions for the PTY process identified by tag.
func (c *Client) PtyResize(ctx context.Context, tag string, cols, rows uint32) error {
req := connect.NewRequest(&envdpb.UpdateRequest{
Process: &envdpb.ProcessSelector{
Selector: &envdpb.ProcessSelector_Tag{Tag: tag},
},
Pty: &envdpb.PTY{
Size: &envdpb.PTY_Size{
Cols: cols,
Rows: rows,
},
},
})
if _, err := c.process.Update(ctx, req); err != nil {
return fmt.Errorf("pty resize: %w", err)
}
return nil
}
// PtyKill sends SIGKILL to the PTY process identified by tag.
func (c *Client) PtyKill(ctx context.Context, tag string) error {
req := connect.NewRequest(&envdpb.SendSignalRequest{
Process: &envdpb.ProcessSelector{
Selector: &envdpb.ProcessSelector_Tag{Tag: tag},
},
Signal: envdpb.Signal_SIGNAL_SIGKILL,
})
if _, err := c.process.SendSignal(ctx, req); err != nil {
return fmt.Errorf("pty kill: %w", err)
}
return nil
}
// eventStream is an interface covering both StartResponse and ConnectResponse streams.
type eventStream interface {
Receive() bool
Err() error
Close() error
}
type startStream struct {
s *connect.ServerStreamForClient[envdpb.StartResponse]
}
func (s *startStream) Receive() bool { return s.s.Receive() }
func (s *startStream) Err() error { return s.s.Err() }
func (s *startStream) Close() error { return s.s.Close() }
func (s *startStream) Event() *envdpb.ProcessEvent {
return s.s.Msg().GetEvent()
}
type connectStream struct {
s *connect.ServerStreamForClient[envdpb.ConnectResponse]
}
func (s *connectStream) Receive() bool { return s.s.Receive() }
func (s *connectStream) Err() error { return s.s.Err() }
func (s *connectStream) Close() error { return s.s.Close() }
func (s *connectStream) Event() *envdpb.ProcessEvent {
return s.s.Msg().GetEvent()
}
type eventProvider interface {
eventStream
Event() *envdpb.ProcessEvent
}
// drainPtyStream reads events from either a Start or Connect stream and maps
// them into PtyEvent values on a channel.
func drainPtyStream(ctx context.Context, stream eventProvider, expectStart bool) <-chan PtyEvent {
ch := make(chan PtyEvent, 16)
go func() {
defer close(ch)
defer stream.Close()
for stream.Receive() {
event := stream.Event()
if event == nil {
continue
}
var ev PtyEvent
switch e := event.GetEvent().(type) {
case *envdpb.ProcessEvent_Start:
if expectStart {
ev = PtyEvent{Type: "started", PID: e.Start.GetPid()}
} else {
continue
}
case *envdpb.ProcessEvent_Data:
switch o := e.Data.GetOutput().(type) {
case *envdpb.ProcessEvent_DataEvent_Pty:
ev = PtyEvent{Type: "output", Data: o.Pty}
case *envdpb.ProcessEvent_DataEvent_Stdout:
ev = PtyEvent{Type: "output", Data: o.Stdout}
case *envdpb.ProcessEvent_DataEvent_Stderr:
ev = PtyEvent{Type: "output", Data: o.Stderr}
default:
continue
}
case *envdpb.ProcessEvent_End:
ev = PtyEvent{Type: "end", ExitCode: e.End.GetExitCode()}
if e.End.Error != nil {
ev.Error = e.End.GetError()
}
case *envdpb.ProcessEvent_Keepalive:
continue
}
select {
case ch <- ev:
case <-ctx.Done():
return
}
}
if err := stream.Err(); err != nil && err != io.EOF {
slog.Debug("pty stream error", "error", err)
}
}()
return ch
}

View File

@ -610,6 +610,83 @@ func metricPointsToPB(pts []sandbox.MetricPoint) []*pb.MetricPoint {
return out
}
func (s *Server) PtyAttach(
ctx context.Context,
req *connect.Request[pb.PtyAttachRequest],
stream *connect.ServerStream[pb.PtyAttachResponse],
) error {
msg := req.Msg
events, err := s.mgr.PtyAttach(ctx, msg.SandboxId, msg.Tag, msg.Cmd, msg.Args, msg.Cols, msg.Rows, msg.Envs, msg.Cwd)
if err != nil {
return connect.NewError(connect.CodeInternal, fmt.Errorf("pty attach: %w", err))
}
for ev := range events {
var resp pb.PtyAttachResponse
switch ev.Type {
case "started":
resp.Event = &pb.PtyAttachResponse_Started{
Started: &pb.PtyStarted{Pid: ev.PID, Tag: msg.Tag},
}
case "output":
resp.Event = &pb.PtyAttachResponse_Output{
Output: &pb.PtyOutput{Data: ev.Data},
}
case "end":
resp.Event = &pb.PtyAttachResponse_Exited{
Exited: &pb.PtyExited{ExitCode: ev.ExitCode, Error: ev.Error},
}
default:
continue
}
if err := stream.Send(&resp); err != nil {
return err
}
}
return nil
}
func (s *Server) PtySendInput(
ctx context.Context,
req *connect.Request[pb.PtySendInputRequest],
) (*connect.Response[pb.PtySendInputResponse], error) {
msg := req.Msg
if err := s.mgr.PtySendInput(ctx, msg.SandboxId, msg.Tag, msg.Data); err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("pty send input: %w", err))
}
return connect.NewResponse(&pb.PtySendInputResponse{}), nil
}
func (s *Server) PtyResize(
ctx context.Context,
req *connect.Request[pb.PtyResizeRequest],
) (*connect.Response[pb.PtyResizeResponse], error) {
msg := req.Msg
if err := s.mgr.PtyResize(ctx, msg.SandboxId, msg.Tag, msg.Cols, msg.Rows); err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("pty resize: %w", err))
}
return connect.NewResponse(&pb.PtyResizeResponse{}), nil
}
func (s *Server) PtyKill(
ctx context.Context,
req *connect.Request[pb.PtyKillRequest],
) (*connect.Response[pb.PtyKillResponse], error) {
msg := req.Msg
if err := s.mgr.PtyKill(ctx, msg.SandboxId, msg.Tag); err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("pty kill: %w", err))
}
return connect.NewResponse(&pb.PtyKillResponse{}), nil
}
// entryInfoToPB maps an envd EntryInfo to a hostagent FileEntry.
func entryInfoToPB(e *envdpb.EntryInfo) *pb.FileEntry {
if e == nil {

View File

@ -167,6 +167,11 @@ func UUIDString(id pgtype.UUID) string {
return uuid.UUID(id.Bytes).String()
}
// NewPtyTag generates a PTY session tag: 8 random hex characters.
func NewPtyTag() string {
return hex8()
}
// --- Helpers ---
func hex8() string {

View File

@ -1223,6 +1223,70 @@ func (m *Manager) GetClient(sandboxID string) (*envdclient.Client, error) {
return sb.client, nil
}
// PtyAttach starts a new PTY process or reconnects to an existing one.
// If cmd is non-empty, starts a new process. If empty, reconnects using tag.
func (m *Manager) PtyAttach(ctx context.Context, sandboxID, tag, cmd string, args []string, cols, rows uint32, envs map[string]string, cwd string) (<-chan envdclient.PtyEvent, error) {
sb, err := m.get(sandboxID)
if err != nil {
return nil, err
}
if sb.Status != models.StatusRunning {
return nil, fmt.Errorf("sandbox %s is not running (status: %s)", sandboxID, sb.Status)
}
m.mu.Lock()
sb.LastActiveAt = time.Now()
m.mu.Unlock()
if cmd != "" {
return sb.client.PtyStart(ctx, tag, cmd, args, cols, rows, envs, cwd)
}
return sb.client.PtyConnect(ctx, tag)
}
// PtySendInput sends raw bytes to a PTY process in a sandbox.
func (m *Manager) PtySendInput(ctx context.Context, sandboxID, tag string, data []byte) error {
sb, err := m.get(sandboxID)
if err != nil {
return err
}
if sb.Status != models.StatusRunning {
return fmt.Errorf("sandbox %s is not running (status: %s)", sandboxID, sb.Status)
}
m.mu.Lock()
sb.LastActiveAt = time.Now()
m.mu.Unlock()
return sb.client.PtySendInput(ctx, tag, data)
}
// PtyResize updates the terminal dimensions for a PTY process in a sandbox.
func (m *Manager) PtyResize(ctx context.Context, sandboxID, tag string, cols, rows uint32) error {
sb, err := m.get(sandboxID)
if err != nil {
return err
}
if sb.Status != models.StatusRunning {
return fmt.Errorf("sandbox %s is not running (status: %s)", sandboxID, sb.Status)
}
return sb.client.PtyResize(ctx, tag, cols, rows)
}
// PtyKill sends SIGKILL to a PTY process in a sandbox.
func (m *Manager) PtyKill(ctx context.Context, sandboxID, tag string) error {
sb, err := m.get(sandboxID)
if err != nil {
return err
}
if sb.Status != models.StatusRunning {
return fmt.Errorf("sandbox %s is not running (status: %s)", sandboxID, sb.Status)
}
return sb.client.PtyKill(ctx, tag)
}
// AcquireProxyConn atomically looks up a sandbox by ID and registers an
// in-flight proxy connection. Returns the sandbox's host-reachable IP, the
// connection tracker, and true on success. The caller must call

View File

@ -2776,6 +2776,655 @@ func (x *FlattenRootfsResponse) GetSizeBytes() int64 {
return 0
}
type PtyAttachRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"`
// Tag is the stable identifier for this PTY session (e.g. "pty-abc123de").
// Chosen by the caller and used to reconnect later.
Tag string `protobuf:"bytes,2,opt,name=tag,proto3" json:"tag,omitempty"`
// If cmd is non-empty, a new process is started. If empty, reconnects to
// the existing process identified by tag.
Cmd string `protobuf:"bytes,3,opt,name=cmd,proto3" json:"cmd,omitempty"`
Args []string `protobuf:"bytes,4,rep,name=args,proto3" json:"args,omitempty"`
Cols uint32 `protobuf:"varint,5,opt,name=cols,proto3" json:"cols,omitempty"`
Rows uint32 `protobuf:"varint,6,opt,name=rows,proto3" json:"rows,omitempty"`
// Environment variables for the process.
Envs map[string]string `protobuf:"bytes,7,rep,name=envs,proto3" json:"envs,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
// Working directory. Empty means default.
Cwd string `protobuf:"bytes,8,opt,name=cwd,proto3" json:"cwd,omitempty"`
// User to run as. Empty means default (root).
User string `protobuf:"bytes,9,opt,name=user,proto3" json:"user,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PtyAttachRequest) Reset() {
*x = PtyAttachRequest{}
mi := &file_hostagent_proto_msgTypes[49]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PtyAttachRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PtyAttachRequest) ProtoMessage() {}
func (x *PtyAttachRequest) ProtoReflect() protoreflect.Message {
mi := &file_hostagent_proto_msgTypes[49]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PtyAttachRequest.ProtoReflect.Descriptor instead.
func (*PtyAttachRequest) Descriptor() ([]byte, []int) {
return file_hostagent_proto_rawDescGZIP(), []int{49}
}
func (x *PtyAttachRequest) GetSandboxId() string {
if x != nil {
return x.SandboxId
}
return ""
}
func (x *PtyAttachRequest) GetTag() string {
if x != nil {
return x.Tag
}
return ""
}
func (x *PtyAttachRequest) GetCmd() string {
if x != nil {
return x.Cmd
}
return ""
}
func (x *PtyAttachRequest) GetArgs() []string {
if x != nil {
return x.Args
}
return nil
}
func (x *PtyAttachRequest) GetCols() uint32 {
if x != nil {
return x.Cols
}
return 0
}
func (x *PtyAttachRequest) GetRows() uint32 {
if x != nil {
return x.Rows
}
return 0
}
func (x *PtyAttachRequest) GetEnvs() map[string]string {
if x != nil {
return x.Envs
}
return nil
}
func (x *PtyAttachRequest) GetCwd() string {
if x != nil {
return x.Cwd
}
return ""
}
func (x *PtyAttachRequest) GetUser() string {
if x != nil {
return x.User
}
return ""
}
type PtyAttachResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Types that are valid to be assigned to Event:
//
// *PtyAttachResponse_Started
// *PtyAttachResponse_Output
// *PtyAttachResponse_Exited
Event isPtyAttachResponse_Event `protobuf_oneof:"event"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PtyAttachResponse) Reset() {
*x = PtyAttachResponse{}
mi := &file_hostagent_proto_msgTypes[50]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PtyAttachResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PtyAttachResponse) ProtoMessage() {}
func (x *PtyAttachResponse) ProtoReflect() protoreflect.Message {
mi := &file_hostagent_proto_msgTypes[50]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PtyAttachResponse.ProtoReflect.Descriptor instead.
func (*PtyAttachResponse) Descriptor() ([]byte, []int) {
return file_hostagent_proto_rawDescGZIP(), []int{50}
}
func (x *PtyAttachResponse) GetEvent() isPtyAttachResponse_Event {
if x != nil {
return x.Event
}
return nil
}
func (x *PtyAttachResponse) GetStarted() *PtyStarted {
if x != nil {
if x, ok := x.Event.(*PtyAttachResponse_Started); ok {
return x.Started
}
}
return nil
}
func (x *PtyAttachResponse) GetOutput() *PtyOutput {
if x != nil {
if x, ok := x.Event.(*PtyAttachResponse_Output); ok {
return x.Output
}
}
return nil
}
func (x *PtyAttachResponse) GetExited() *PtyExited {
if x != nil {
if x, ok := x.Event.(*PtyAttachResponse_Exited); ok {
return x.Exited
}
}
return nil
}
type isPtyAttachResponse_Event interface {
isPtyAttachResponse_Event()
}
type PtyAttachResponse_Started struct {
Started *PtyStarted `protobuf:"bytes,1,opt,name=started,proto3,oneof"`
}
type PtyAttachResponse_Output struct {
Output *PtyOutput `protobuf:"bytes,2,opt,name=output,proto3,oneof"`
}
type PtyAttachResponse_Exited struct {
Exited *PtyExited `protobuf:"bytes,3,opt,name=exited,proto3,oneof"`
}
func (*PtyAttachResponse_Started) isPtyAttachResponse_Event() {}
func (*PtyAttachResponse_Output) isPtyAttachResponse_Event() {}
func (*PtyAttachResponse_Exited) isPtyAttachResponse_Event() {}
type PtyStarted struct {
state protoimpl.MessageState `protogen:"open.v1"`
Pid uint32 `protobuf:"varint,1,opt,name=pid,proto3" json:"pid,omitempty"`
Tag string `protobuf:"bytes,2,opt,name=tag,proto3" json:"tag,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PtyStarted) Reset() {
*x = PtyStarted{}
mi := &file_hostagent_proto_msgTypes[51]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PtyStarted) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PtyStarted) ProtoMessage() {}
func (x *PtyStarted) ProtoReflect() protoreflect.Message {
mi := &file_hostagent_proto_msgTypes[51]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PtyStarted.ProtoReflect.Descriptor instead.
func (*PtyStarted) Descriptor() ([]byte, []int) {
return file_hostagent_proto_rawDescGZIP(), []int{51}
}
func (x *PtyStarted) GetPid() uint32 {
if x != nil {
return x.Pid
}
return 0
}
func (x *PtyStarted) GetTag() string {
if x != nil {
return x.Tag
}
return ""
}
type PtyOutput struct {
state protoimpl.MessageState `protogen:"open.v1"`
Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PtyOutput) Reset() {
*x = PtyOutput{}
mi := &file_hostagent_proto_msgTypes[52]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PtyOutput) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PtyOutput) ProtoMessage() {}
func (x *PtyOutput) ProtoReflect() protoreflect.Message {
mi := &file_hostagent_proto_msgTypes[52]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PtyOutput.ProtoReflect.Descriptor instead.
func (*PtyOutput) Descriptor() ([]byte, []int) {
return file_hostagent_proto_rawDescGZIP(), []int{52}
}
func (x *PtyOutput) GetData() []byte {
if x != nil {
return x.Data
}
return nil
}
type PtyExited struct {
state protoimpl.MessageState `protogen:"open.v1"`
ExitCode int32 `protobuf:"varint,1,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"`
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PtyExited) Reset() {
*x = PtyExited{}
mi := &file_hostagent_proto_msgTypes[53]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PtyExited) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PtyExited) ProtoMessage() {}
func (x *PtyExited) ProtoReflect() protoreflect.Message {
mi := &file_hostagent_proto_msgTypes[53]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PtyExited.ProtoReflect.Descriptor instead.
func (*PtyExited) Descriptor() ([]byte, []int) {
return file_hostagent_proto_rawDescGZIP(), []int{53}
}
func (x *PtyExited) GetExitCode() int32 {
if x != nil {
return x.ExitCode
}
return 0
}
func (x *PtyExited) GetError() string {
if x != nil {
return x.Error
}
return ""
}
type PtySendInputRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"`
Tag string `protobuf:"bytes,2,opt,name=tag,proto3" json:"tag,omitempty"`
Data []byte `protobuf:"bytes,3,opt,name=data,proto3" json:"data,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PtySendInputRequest) Reset() {
*x = PtySendInputRequest{}
mi := &file_hostagent_proto_msgTypes[54]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PtySendInputRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PtySendInputRequest) ProtoMessage() {}
func (x *PtySendInputRequest) ProtoReflect() protoreflect.Message {
mi := &file_hostagent_proto_msgTypes[54]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PtySendInputRequest.ProtoReflect.Descriptor instead.
func (*PtySendInputRequest) Descriptor() ([]byte, []int) {
return file_hostagent_proto_rawDescGZIP(), []int{54}
}
func (x *PtySendInputRequest) GetSandboxId() string {
if x != nil {
return x.SandboxId
}
return ""
}
func (x *PtySendInputRequest) GetTag() string {
if x != nil {
return x.Tag
}
return ""
}
func (x *PtySendInputRequest) GetData() []byte {
if x != nil {
return x.Data
}
return nil
}
type PtySendInputResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PtySendInputResponse) Reset() {
*x = PtySendInputResponse{}
mi := &file_hostagent_proto_msgTypes[55]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PtySendInputResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PtySendInputResponse) ProtoMessage() {}
func (x *PtySendInputResponse) ProtoReflect() protoreflect.Message {
mi := &file_hostagent_proto_msgTypes[55]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PtySendInputResponse.ProtoReflect.Descriptor instead.
func (*PtySendInputResponse) Descriptor() ([]byte, []int) {
return file_hostagent_proto_rawDescGZIP(), []int{55}
}
type PtyResizeRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"`
Tag string `protobuf:"bytes,2,opt,name=tag,proto3" json:"tag,omitempty"`
Cols uint32 `protobuf:"varint,3,opt,name=cols,proto3" json:"cols,omitempty"`
Rows uint32 `protobuf:"varint,4,opt,name=rows,proto3" json:"rows,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PtyResizeRequest) Reset() {
*x = PtyResizeRequest{}
mi := &file_hostagent_proto_msgTypes[56]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PtyResizeRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PtyResizeRequest) ProtoMessage() {}
func (x *PtyResizeRequest) ProtoReflect() protoreflect.Message {
mi := &file_hostagent_proto_msgTypes[56]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PtyResizeRequest.ProtoReflect.Descriptor instead.
func (*PtyResizeRequest) Descriptor() ([]byte, []int) {
return file_hostagent_proto_rawDescGZIP(), []int{56}
}
func (x *PtyResizeRequest) GetSandboxId() string {
if x != nil {
return x.SandboxId
}
return ""
}
func (x *PtyResizeRequest) GetTag() string {
if x != nil {
return x.Tag
}
return ""
}
func (x *PtyResizeRequest) GetCols() uint32 {
if x != nil {
return x.Cols
}
return 0
}
func (x *PtyResizeRequest) GetRows() uint32 {
if x != nil {
return x.Rows
}
return 0
}
type PtyResizeResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PtyResizeResponse) Reset() {
*x = PtyResizeResponse{}
mi := &file_hostagent_proto_msgTypes[57]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PtyResizeResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PtyResizeResponse) ProtoMessage() {}
func (x *PtyResizeResponse) ProtoReflect() protoreflect.Message {
mi := &file_hostagent_proto_msgTypes[57]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PtyResizeResponse.ProtoReflect.Descriptor instead.
func (*PtyResizeResponse) Descriptor() ([]byte, []int) {
return file_hostagent_proto_rawDescGZIP(), []int{57}
}
type PtyKillRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"`
Tag string `protobuf:"bytes,2,opt,name=tag,proto3" json:"tag,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PtyKillRequest) Reset() {
*x = PtyKillRequest{}
mi := &file_hostagent_proto_msgTypes[58]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PtyKillRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PtyKillRequest) ProtoMessage() {}
func (x *PtyKillRequest) ProtoReflect() protoreflect.Message {
mi := &file_hostagent_proto_msgTypes[58]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PtyKillRequest.ProtoReflect.Descriptor instead.
func (*PtyKillRequest) Descriptor() ([]byte, []int) {
return file_hostagent_proto_rawDescGZIP(), []int{58}
}
func (x *PtyKillRequest) GetSandboxId() string {
if x != nil {
return x.SandboxId
}
return ""
}
func (x *PtyKillRequest) GetTag() string {
if x != nil {
return x.Tag
}
return ""
}
type PtyKillResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PtyKillResponse) Reset() {
*x = PtyKillResponse{}
mi := &file_hostagent_proto_msgTypes[59]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PtyKillResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PtyKillResponse) ProtoMessage() {}
func (x *PtyKillResponse) ProtoReflect() protoreflect.Message {
mi := &file_hostagent_proto_msgTypes[59]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PtyKillResponse.ProtoReflect.Descriptor instead.
func (*PtyKillResponse) Descriptor() ([]byte, []int) {
return file_hostagent_proto_rawDescGZIP(), []int{59}
}
var File_hostagent_proto protoreflect.FileDescriptor
const file_hostagent_proto_rawDesc = "" +
@ -2981,7 +3630,53 @@ const file_hostagent_proto_rawDesc = "" +
"templateId\"6\n" +
"\x15FlattenRootfsResponse\x12\x1d\n" +
"\n" +
"size_bytes\x18\x01 \x01(\x03R\tsizeBytes2\xa9\x0e\n" +
"size_bytes\x18\x01 \x01(\x03R\tsizeBytes\"\xae\x02\n" +
"\x10PtyAttachRequest\x12\x1d\n" +
"\n" +
"sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x10\n" +
"\x03tag\x18\x02 \x01(\tR\x03tag\x12\x10\n" +
"\x03cmd\x18\x03 \x01(\tR\x03cmd\x12\x12\n" +
"\x04args\x18\x04 \x03(\tR\x04args\x12\x12\n" +
"\x04cols\x18\x05 \x01(\rR\x04cols\x12\x12\n" +
"\x04rows\x18\x06 \x01(\rR\x04rows\x12<\n" +
"\x04envs\x18\a \x03(\v2(.hostagent.v1.PtyAttachRequest.EnvsEntryR\x04envs\x12\x10\n" +
"\x03cwd\x18\b \x01(\tR\x03cwd\x12\x12\n" +
"\x04user\x18\t \x01(\tR\x04user\x1a7\n" +
"\tEnvsEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xb8\x01\n" +
"\x11PtyAttachResponse\x124\n" +
"\astarted\x18\x01 \x01(\v2\x18.hostagent.v1.PtyStartedH\x00R\astarted\x121\n" +
"\x06output\x18\x02 \x01(\v2\x17.hostagent.v1.PtyOutputH\x00R\x06output\x121\n" +
"\x06exited\x18\x03 \x01(\v2\x17.hostagent.v1.PtyExitedH\x00R\x06exitedB\a\n" +
"\x05event\"0\n" +
"\n" +
"PtyStarted\x12\x10\n" +
"\x03pid\x18\x01 \x01(\rR\x03pid\x12\x10\n" +
"\x03tag\x18\x02 \x01(\tR\x03tag\"\x1f\n" +
"\tPtyOutput\x12\x12\n" +
"\x04data\x18\x01 \x01(\fR\x04data\">\n" +
"\tPtyExited\x12\x1b\n" +
"\texit_code\x18\x01 \x01(\x05R\bexitCode\x12\x14\n" +
"\x05error\x18\x02 \x01(\tR\x05error\"Z\n" +
"\x13PtySendInputRequest\x12\x1d\n" +
"\n" +
"sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x10\n" +
"\x03tag\x18\x02 \x01(\tR\x03tag\x12\x12\n" +
"\x04data\x18\x03 \x01(\fR\x04data\"\x16\n" +
"\x14PtySendInputResponse\"k\n" +
"\x10PtyResizeRequest\x12\x1d\n" +
"\n" +
"sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x10\n" +
"\x03tag\x18\x02 \x01(\tR\x03tag\x12\x12\n" +
"\x04cols\x18\x03 \x01(\rR\x04cols\x12\x12\n" +
"\x04rows\x18\x04 \x01(\rR\x04rows\"\x13\n" +
"\x11PtyResizeResponse\"A\n" +
"\x0ePtyKillRequest\x12\x1d\n" +
"\n" +
"sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x10\n" +
"\x03tag\x18\x02 \x01(\tR\x03tag\"\x11\n" +
"\x0fPtyKillResponse2\xe6\x10\n" +
"\x10HostAgentService\x12X\n" +
"\rCreateSandbox\x12\".hostagent.v1.CreateSandboxRequest\x1a#.hostagent.v1.CreateSandboxResponse\x12[\n" +
"\x0eDestroySandbox\x12#.hostagent.v1.DestroySandboxRequest\x1a$.hostagent.v1.DestroySandboxResponse\x12U\n" +
@ -3005,7 +3700,11 @@ const file_hostagent_proto_rawDesc = "" +
"\tTerminate\x12\x1e.hostagent.v1.TerminateRequest\x1a\x1f.hostagent.v1.TerminateResponse\x12d\n" +
"\x11GetSandboxMetrics\x12&.hostagent.v1.GetSandboxMetricsRequest\x1a'.hostagent.v1.GetSandboxMetricsResponse\x12j\n" +
"\x13FlushSandboxMetrics\x12(.hostagent.v1.FlushSandboxMetricsRequest\x1a).hostagent.v1.FlushSandboxMetricsResponse\x12X\n" +
"\rFlattenRootfs\x12\".hostagent.v1.FlattenRootfsRequest\x1a#.hostagent.v1.FlattenRootfsResponseB\xae\x01\n" +
"\rFlattenRootfs\x12\".hostagent.v1.FlattenRootfsRequest\x1a#.hostagent.v1.FlattenRootfsResponse\x12N\n" +
"\tPtyAttach\x12\x1e.hostagent.v1.PtyAttachRequest\x1a\x1f.hostagent.v1.PtyAttachResponse0\x01\x12U\n" +
"\fPtySendInput\x12!.hostagent.v1.PtySendInputRequest\x1a\".hostagent.v1.PtySendInputResponse\x12L\n" +
"\tPtyResize\x12\x1e.hostagent.v1.PtyResizeRequest\x1a\x1f.hostagent.v1.PtyResizeResponse\x12F\n" +
"\aPtyKill\x12\x1c.hostagent.v1.PtyKillRequest\x1a\x1d.hostagent.v1.PtyKillResponseB\xae\x01\n" +
"\x10com.hostagent.v1B\x0eHostagentProtoP\x01Z9git.omukk.dev/wrenn/wrenn/proto/hostagent/gen;hostagentv1\xa2\x02\x03HXX\xaa\x02\fHostagent.V1\xca\x02\fHostagent\\V1\xe2\x02\x18Hostagent\\V1\\GPBMetadata\xea\x02\rHostagent::V1b\x06proto3"
var (
@ -3020,7 +3719,7 @@ func file_hostagent_proto_rawDescGZIP() []byte {
return file_hostagent_proto_rawDescData
}
var file_hostagent_proto_msgTypes = make([]protoimpl.MessageInfo, 49)
var file_hostagent_proto_msgTypes = make([]protoimpl.MessageInfo, 61)
var file_hostagent_proto_goTypes = []any{
(*CreateSandboxRequest)(nil), // 0: hostagent.v1.CreateSandboxRequest
(*CreateSandboxResponse)(nil), // 1: hostagent.v1.CreateSandboxResponse
@ -3071,6 +3770,18 @@ var file_hostagent_proto_goTypes = []any{
(*FlushSandboxMetricsResponse)(nil), // 46: hostagent.v1.FlushSandboxMetricsResponse
(*FlattenRootfsRequest)(nil), // 47: hostagent.v1.FlattenRootfsRequest
(*FlattenRootfsResponse)(nil), // 48: hostagent.v1.FlattenRootfsResponse
(*PtyAttachRequest)(nil), // 49: hostagent.v1.PtyAttachRequest
(*PtyAttachResponse)(nil), // 50: hostagent.v1.PtyAttachResponse
(*PtyStarted)(nil), // 51: hostagent.v1.PtyStarted
(*PtyOutput)(nil), // 52: hostagent.v1.PtyOutput
(*PtyExited)(nil), // 53: hostagent.v1.PtyExited
(*PtySendInputRequest)(nil), // 54: hostagent.v1.PtySendInputRequest
(*PtySendInputResponse)(nil), // 55: hostagent.v1.PtySendInputResponse
(*PtyResizeRequest)(nil), // 56: hostagent.v1.PtyResizeRequest
(*PtyResizeResponse)(nil), // 57: hostagent.v1.PtyResizeResponse
(*PtyKillRequest)(nil), // 58: hostagent.v1.PtyKillRequest
(*PtyKillResponse)(nil), // 59: hostagent.v1.PtyKillResponse
nil, // 60: hostagent.v1.PtyAttachRequest.EnvsEntry
}
var file_hostagent_proto_depIdxs = []int32{
16, // 0: hostagent.v1.ListSandboxesResponse.sandboxes:type_name -> hostagent.v1.SandboxInfo
@ -3084,53 +3795,65 @@ var file_hostagent_proto_depIdxs = []int32{
42, // 8: hostagent.v1.FlushSandboxMetricsResponse.points_10m:type_name -> hostagent.v1.MetricPoint
42, // 9: hostagent.v1.FlushSandboxMetricsResponse.points_2h:type_name -> hostagent.v1.MetricPoint
42, // 10: hostagent.v1.FlushSandboxMetricsResponse.points_24h:type_name -> hostagent.v1.MetricPoint
0, // 11: hostagent.v1.HostAgentService.CreateSandbox:input_type -> hostagent.v1.CreateSandboxRequest
2, // 12: hostagent.v1.HostAgentService.DestroySandbox:input_type -> hostagent.v1.DestroySandboxRequest
4, // 13: hostagent.v1.HostAgentService.PauseSandbox:input_type -> hostagent.v1.PauseSandboxRequest
6, // 14: hostagent.v1.HostAgentService.ResumeSandbox:input_type -> hostagent.v1.ResumeSandboxRequest
12, // 15: hostagent.v1.HostAgentService.Exec:input_type -> hostagent.v1.ExecRequest
14, // 16: hostagent.v1.HostAgentService.ListSandboxes:input_type -> hostagent.v1.ListSandboxesRequest
17, // 17: hostagent.v1.HostAgentService.WriteFile:input_type -> hostagent.v1.WriteFileRequest
19, // 18: hostagent.v1.HostAgentService.ReadFile:input_type -> hostagent.v1.ReadFileRequest
31, // 19: hostagent.v1.HostAgentService.ListDir:input_type -> hostagent.v1.ListDirRequest
34, // 20: hostagent.v1.HostAgentService.MakeDir:input_type -> hostagent.v1.MakeDirRequest
36, // 21: hostagent.v1.HostAgentService.RemovePath:input_type -> hostagent.v1.RemovePathRequest
8, // 22: hostagent.v1.HostAgentService.CreateSnapshot:input_type -> hostagent.v1.CreateSnapshotRequest
10, // 23: hostagent.v1.HostAgentService.DeleteSnapshot:input_type -> hostagent.v1.DeleteSnapshotRequest
21, // 24: hostagent.v1.HostAgentService.ExecStream:input_type -> hostagent.v1.ExecStreamRequest
26, // 25: hostagent.v1.HostAgentService.WriteFileStream:input_type -> hostagent.v1.WriteFileStreamRequest
29, // 26: hostagent.v1.HostAgentService.ReadFileStream:input_type -> hostagent.v1.ReadFileStreamRequest
38, // 27: hostagent.v1.HostAgentService.PingSandbox:input_type -> hostagent.v1.PingSandboxRequest
40, // 28: hostagent.v1.HostAgentService.Terminate:input_type -> hostagent.v1.TerminateRequest
43, // 29: hostagent.v1.HostAgentService.GetSandboxMetrics:input_type -> hostagent.v1.GetSandboxMetricsRequest
45, // 30: hostagent.v1.HostAgentService.FlushSandboxMetrics:input_type -> hostagent.v1.FlushSandboxMetricsRequest
47, // 31: hostagent.v1.HostAgentService.FlattenRootfs:input_type -> hostagent.v1.FlattenRootfsRequest
1, // 32: hostagent.v1.HostAgentService.CreateSandbox:output_type -> hostagent.v1.CreateSandboxResponse
3, // 33: hostagent.v1.HostAgentService.DestroySandbox:output_type -> hostagent.v1.DestroySandboxResponse
5, // 34: hostagent.v1.HostAgentService.PauseSandbox:output_type -> hostagent.v1.PauseSandboxResponse
7, // 35: hostagent.v1.HostAgentService.ResumeSandbox:output_type -> hostagent.v1.ResumeSandboxResponse
13, // 36: hostagent.v1.HostAgentService.Exec:output_type -> hostagent.v1.ExecResponse
15, // 37: hostagent.v1.HostAgentService.ListSandboxes:output_type -> hostagent.v1.ListSandboxesResponse
18, // 38: hostagent.v1.HostAgentService.WriteFile:output_type -> hostagent.v1.WriteFileResponse
20, // 39: hostagent.v1.HostAgentService.ReadFile:output_type -> hostagent.v1.ReadFileResponse
32, // 40: hostagent.v1.HostAgentService.ListDir:output_type -> hostagent.v1.ListDirResponse
35, // 41: hostagent.v1.HostAgentService.MakeDir:output_type -> hostagent.v1.MakeDirResponse
37, // 42: hostagent.v1.HostAgentService.RemovePath:output_type -> hostagent.v1.RemovePathResponse
9, // 43: hostagent.v1.HostAgentService.CreateSnapshot:output_type -> hostagent.v1.CreateSnapshotResponse
11, // 44: hostagent.v1.HostAgentService.DeleteSnapshot:output_type -> hostagent.v1.DeleteSnapshotResponse
22, // 45: hostagent.v1.HostAgentService.ExecStream:output_type -> hostagent.v1.ExecStreamResponse
28, // 46: hostagent.v1.HostAgentService.WriteFileStream:output_type -> hostagent.v1.WriteFileStreamResponse
30, // 47: hostagent.v1.HostAgentService.ReadFileStream:output_type -> hostagent.v1.ReadFileStreamResponse
39, // 48: hostagent.v1.HostAgentService.PingSandbox:output_type -> hostagent.v1.PingSandboxResponse
41, // 49: hostagent.v1.HostAgentService.Terminate:output_type -> hostagent.v1.TerminateResponse
44, // 50: hostagent.v1.HostAgentService.GetSandboxMetrics:output_type -> hostagent.v1.GetSandboxMetricsResponse
46, // 51: hostagent.v1.HostAgentService.FlushSandboxMetrics:output_type -> hostagent.v1.FlushSandboxMetricsResponse
48, // 52: hostagent.v1.HostAgentService.FlattenRootfs:output_type -> hostagent.v1.FlattenRootfsResponse
32, // [32:53] is the sub-list for method output_type
11, // [11:32] is the sub-list for method input_type
11, // [11:11] is the sub-list for extension type_name
11, // [11:11] is the sub-list for extension extendee
0, // [0:11] is the sub-list for field type_name
60, // 11: hostagent.v1.PtyAttachRequest.envs:type_name -> hostagent.v1.PtyAttachRequest.EnvsEntry
51, // 12: hostagent.v1.PtyAttachResponse.started:type_name -> hostagent.v1.PtyStarted
52, // 13: hostagent.v1.PtyAttachResponse.output:type_name -> hostagent.v1.PtyOutput
53, // 14: hostagent.v1.PtyAttachResponse.exited:type_name -> hostagent.v1.PtyExited
0, // 15: hostagent.v1.HostAgentService.CreateSandbox:input_type -> hostagent.v1.CreateSandboxRequest
2, // 16: hostagent.v1.HostAgentService.DestroySandbox:input_type -> hostagent.v1.DestroySandboxRequest
4, // 17: hostagent.v1.HostAgentService.PauseSandbox:input_type -> hostagent.v1.PauseSandboxRequest
6, // 18: hostagent.v1.HostAgentService.ResumeSandbox:input_type -> hostagent.v1.ResumeSandboxRequest
12, // 19: hostagent.v1.HostAgentService.Exec:input_type -> hostagent.v1.ExecRequest
14, // 20: hostagent.v1.HostAgentService.ListSandboxes:input_type -> hostagent.v1.ListSandboxesRequest
17, // 21: hostagent.v1.HostAgentService.WriteFile:input_type -> hostagent.v1.WriteFileRequest
19, // 22: hostagent.v1.HostAgentService.ReadFile:input_type -> hostagent.v1.ReadFileRequest
31, // 23: hostagent.v1.HostAgentService.ListDir:input_type -> hostagent.v1.ListDirRequest
34, // 24: hostagent.v1.HostAgentService.MakeDir:input_type -> hostagent.v1.MakeDirRequest
36, // 25: hostagent.v1.HostAgentService.RemovePath:input_type -> hostagent.v1.RemovePathRequest
8, // 26: hostagent.v1.HostAgentService.CreateSnapshot:input_type -> hostagent.v1.CreateSnapshotRequest
10, // 27: hostagent.v1.HostAgentService.DeleteSnapshot:input_type -> hostagent.v1.DeleteSnapshotRequest
21, // 28: hostagent.v1.HostAgentService.ExecStream:input_type -> hostagent.v1.ExecStreamRequest
26, // 29: hostagent.v1.HostAgentService.WriteFileStream:input_type -> hostagent.v1.WriteFileStreamRequest
29, // 30: hostagent.v1.HostAgentService.ReadFileStream:input_type -> hostagent.v1.ReadFileStreamRequest
38, // 31: hostagent.v1.HostAgentService.PingSandbox:input_type -> hostagent.v1.PingSandboxRequest
40, // 32: hostagent.v1.HostAgentService.Terminate:input_type -> hostagent.v1.TerminateRequest
43, // 33: hostagent.v1.HostAgentService.GetSandboxMetrics:input_type -> hostagent.v1.GetSandboxMetricsRequest
45, // 34: hostagent.v1.HostAgentService.FlushSandboxMetrics:input_type -> hostagent.v1.FlushSandboxMetricsRequest
47, // 35: hostagent.v1.HostAgentService.FlattenRootfs:input_type -> hostagent.v1.FlattenRootfsRequest
49, // 36: hostagent.v1.HostAgentService.PtyAttach:input_type -> hostagent.v1.PtyAttachRequest
54, // 37: hostagent.v1.HostAgentService.PtySendInput:input_type -> hostagent.v1.PtySendInputRequest
56, // 38: hostagent.v1.HostAgentService.PtyResize:input_type -> hostagent.v1.PtyResizeRequest
58, // 39: hostagent.v1.HostAgentService.PtyKill:input_type -> hostagent.v1.PtyKillRequest
1, // 40: hostagent.v1.HostAgentService.CreateSandbox:output_type -> hostagent.v1.CreateSandboxResponse
3, // 41: hostagent.v1.HostAgentService.DestroySandbox:output_type -> hostagent.v1.DestroySandboxResponse
5, // 42: hostagent.v1.HostAgentService.PauseSandbox:output_type -> hostagent.v1.PauseSandboxResponse
7, // 43: hostagent.v1.HostAgentService.ResumeSandbox:output_type -> hostagent.v1.ResumeSandboxResponse
13, // 44: hostagent.v1.HostAgentService.Exec:output_type -> hostagent.v1.ExecResponse
15, // 45: hostagent.v1.HostAgentService.ListSandboxes:output_type -> hostagent.v1.ListSandboxesResponse
18, // 46: hostagent.v1.HostAgentService.WriteFile:output_type -> hostagent.v1.WriteFileResponse
20, // 47: hostagent.v1.HostAgentService.ReadFile:output_type -> hostagent.v1.ReadFileResponse
32, // 48: hostagent.v1.HostAgentService.ListDir:output_type -> hostagent.v1.ListDirResponse
35, // 49: hostagent.v1.HostAgentService.MakeDir:output_type -> hostagent.v1.MakeDirResponse
37, // 50: hostagent.v1.HostAgentService.RemovePath:output_type -> hostagent.v1.RemovePathResponse
9, // 51: hostagent.v1.HostAgentService.CreateSnapshot:output_type -> hostagent.v1.CreateSnapshotResponse
11, // 52: hostagent.v1.HostAgentService.DeleteSnapshot:output_type -> hostagent.v1.DeleteSnapshotResponse
22, // 53: hostagent.v1.HostAgentService.ExecStream:output_type -> hostagent.v1.ExecStreamResponse
28, // 54: hostagent.v1.HostAgentService.WriteFileStream:output_type -> hostagent.v1.WriteFileStreamResponse
30, // 55: hostagent.v1.HostAgentService.ReadFileStream:output_type -> hostagent.v1.ReadFileStreamResponse
39, // 56: hostagent.v1.HostAgentService.PingSandbox:output_type -> hostagent.v1.PingSandboxResponse
41, // 57: hostagent.v1.HostAgentService.Terminate:output_type -> hostagent.v1.TerminateResponse
44, // 58: hostagent.v1.HostAgentService.GetSandboxMetrics:output_type -> hostagent.v1.GetSandboxMetricsResponse
46, // 59: hostagent.v1.HostAgentService.FlushSandboxMetrics:output_type -> hostagent.v1.FlushSandboxMetricsResponse
48, // 60: hostagent.v1.HostAgentService.FlattenRootfs:output_type -> hostagent.v1.FlattenRootfsResponse
50, // 61: hostagent.v1.HostAgentService.PtyAttach:output_type -> hostagent.v1.PtyAttachResponse
55, // 62: hostagent.v1.HostAgentService.PtySendInput:output_type -> hostagent.v1.PtySendInputResponse
57, // 63: hostagent.v1.HostAgentService.PtyResize:output_type -> hostagent.v1.PtyResizeResponse
59, // 64: hostagent.v1.HostAgentService.PtyKill:output_type -> hostagent.v1.PtyKillResponse
40, // [40:65] is the sub-list for method output_type
15, // [15:40] is the sub-list for method input_type
15, // [15:15] is the sub-list for extension type_name
15, // [15:15] is the sub-list for extension extendee
0, // [0:15] is the sub-list for field type_name
}
func init() { file_hostagent_proto_init() }
@ -3152,13 +3875,18 @@ func file_hostagent_proto_init() {
(*WriteFileStreamRequest_Chunk)(nil),
}
file_hostagent_proto_msgTypes[33].OneofWrappers = []any{}
file_hostagent_proto_msgTypes[50].OneofWrappers = []any{
(*PtyAttachResponse_Started)(nil),
(*PtyAttachResponse_Output)(nil),
(*PtyAttachResponse_Exited)(nil),
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_hostagent_proto_rawDesc), len(file_hostagent_proto_rawDesc)),
NumEnums: 0,
NumMessages: 49,
NumMessages: 61,
NumExtensions: 0,
NumServices: 1,
},

View File

@ -95,6 +95,18 @@ const (
// HostAgentServiceFlattenRootfsProcedure is the fully-qualified name of the HostAgentService's
// FlattenRootfs RPC.
HostAgentServiceFlattenRootfsProcedure = "/hostagent.v1.HostAgentService/FlattenRootfs"
// HostAgentServicePtyAttachProcedure is the fully-qualified name of the HostAgentService's
// PtyAttach RPC.
HostAgentServicePtyAttachProcedure = "/hostagent.v1.HostAgentService/PtyAttach"
// HostAgentServicePtySendInputProcedure is the fully-qualified name of the HostAgentService's
// PtySendInput RPC.
HostAgentServicePtySendInputProcedure = "/hostagent.v1.HostAgentService/PtySendInput"
// HostAgentServicePtyResizeProcedure is the fully-qualified name of the HostAgentService's
// PtyResize RPC.
HostAgentServicePtyResizeProcedure = "/hostagent.v1.HostAgentService/PtyResize"
// HostAgentServicePtyKillProcedure is the fully-qualified name of the HostAgentService's PtyKill
// RPC.
HostAgentServicePtyKillProcedure = "/hostagent.v1.HostAgentService/PtyKill"
)
// HostAgentServiceClient is a client for the hostagent.v1.HostAgentService service.
@ -149,6 +161,17 @@ type HostAgentServiceClient interface {
// cleans up all sandbox resources. Used by the template build system to
// produce image-only templates (no memory/CPU state).
FlattenRootfs(context.Context, *connect.Request[gen.FlattenRootfsRequest]) (*connect.Response[gen.FlattenRootfsResponse], error)
// PtyAttach starts a new PTY process or reconnects to an existing one.
// If cmd is non-empty, starts a new process with the given PTY dimensions.
// If tag is set and cmd is empty, reconnects to the existing process with that tag.
// Returns a stream of output events (started, output data, exit).
PtyAttach(context.Context, *connect.Request[gen.PtyAttachRequest]) (*connect.ServerStreamForClient[gen.PtyAttachResponse], error)
// PtySendInput sends raw bytes to a PTY process identified by tag.
PtySendInput(context.Context, *connect.Request[gen.PtySendInputRequest]) (*connect.Response[gen.PtySendInputResponse], error)
// PtyResize updates the terminal dimensions for a PTY process.
PtyResize(context.Context, *connect.Request[gen.PtyResizeRequest]) (*connect.Response[gen.PtyResizeResponse], error)
// PtyKill sends a signal to a PTY process.
PtyKill(context.Context, *connect.Request[gen.PtyKillRequest]) (*connect.Response[gen.PtyKillResponse], error)
}
// NewHostAgentServiceClient constructs a client for the hostagent.v1.HostAgentService service. By
@ -288,6 +311,30 @@ func NewHostAgentServiceClient(httpClient connect.HTTPClient, baseURL string, op
connect.WithSchema(hostAgentServiceMethods.ByName("FlattenRootfs")),
connect.WithClientOptions(opts...),
),
ptyAttach: connect.NewClient[gen.PtyAttachRequest, gen.PtyAttachResponse](
httpClient,
baseURL+HostAgentServicePtyAttachProcedure,
connect.WithSchema(hostAgentServiceMethods.ByName("PtyAttach")),
connect.WithClientOptions(opts...),
),
ptySendInput: connect.NewClient[gen.PtySendInputRequest, gen.PtySendInputResponse](
httpClient,
baseURL+HostAgentServicePtySendInputProcedure,
connect.WithSchema(hostAgentServiceMethods.ByName("PtySendInput")),
connect.WithClientOptions(opts...),
),
ptyResize: connect.NewClient[gen.PtyResizeRequest, gen.PtyResizeResponse](
httpClient,
baseURL+HostAgentServicePtyResizeProcedure,
connect.WithSchema(hostAgentServiceMethods.ByName("PtyResize")),
connect.WithClientOptions(opts...),
),
ptyKill: connect.NewClient[gen.PtyKillRequest, gen.PtyKillResponse](
httpClient,
baseURL+HostAgentServicePtyKillProcedure,
connect.WithSchema(hostAgentServiceMethods.ByName("PtyKill")),
connect.WithClientOptions(opts...),
),
}
}
@ -314,6 +361,10 @@ type hostAgentServiceClient struct {
getSandboxMetrics *connect.Client[gen.GetSandboxMetricsRequest, gen.GetSandboxMetricsResponse]
flushSandboxMetrics *connect.Client[gen.FlushSandboxMetricsRequest, gen.FlushSandboxMetricsResponse]
flattenRootfs *connect.Client[gen.FlattenRootfsRequest, gen.FlattenRootfsResponse]
ptyAttach *connect.Client[gen.PtyAttachRequest, gen.PtyAttachResponse]
ptySendInput *connect.Client[gen.PtySendInputRequest, gen.PtySendInputResponse]
ptyResize *connect.Client[gen.PtyResizeRequest, gen.PtyResizeResponse]
ptyKill *connect.Client[gen.PtyKillRequest, gen.PtyKillResponse]
}
// CreateSandbox calls hostagent.v1.HostAgentService.CreateSandbox.
@ -421,6 +472,26 @@ func (c *hostAgentServiceClient) FlattenRootfs(ctx context.Context, req *connect
return c.flattenRootfs.CallUnary(ctx, req)
}
// PtyAttach calls hostagent.v1.HostAgentService.PtyAttach.
func (c *hostAgentServiceClient) PtyAttach(ctx context.Context, req *connect.Request[gen.PtyAttachRequest]) (*connect.ServerStreamForClient[gen.PtyAttachResponse], error) {
return c.ptyAttach.CallServerStream(ctx, req)
}
// PtySendInput calls hostagent.v1.HostAgentService.PtySendInput.
func (c *hostAgentServiceClient) PtySendInput(ctx context.Context, req *connect.Request[gen.PtySendInputRequest]) (*connect.Response[gen.PtySendInputResponse], error) {
return c.ptySendInput.CallUnary(ctx, req)
}
// PtyResize calls hostagent.v1.HostAgentService.PtyResize.
func (c *hostAgentServiceClient) PtyResize(ctx context.Context, req *connect.Request[gen.PtyResizeRequest]) (*connect.Response[gen.PtyResizeResponse], error) {
return c.ptyResize.CallUnary(ctx, req)
}
// PtyKill calls hostagent.v1.HostAgentService.PtyKill.
func (c *hostAgentServiceClient) PtyKill(ctx context.Context, req *connect.Request[gen.PtyKillRequest]) (*connect.Response[gen.PtyKillResponse], error) {
return c.ptyKill.CallUnary(ctx, req)
}
// HostAgentServiceHandler is an implementation of the hostagent.v1.HostAgentService service.
type HostAgentServiceHandler interface {
// CreateSandbox boots a new microVM with the given configuration.
@ -473,6 +544,17 @@ type HostAgentServiceHandler interface {
// cleans up all sandbox resources. Used by the template build system to
// produce image-only templates (no memory/CPU state).
FlattenRootfs(context.Context, *connect.Request[gen.FlattenRootfsRequest]) (*connect.Response[gen.FlattenRootfsResponse], error)
// PtyAttach starts a new PTY process or reconnects to an existing one.
// If cmd is non-empty, starts a new process with the given PTY dimensions.
// If tag is set and cmd is empty, reconnects to the existing process with that tag.
// Returns a stream of output events (started, output data, exit).
PtyAttach(context.Context, *connect.Request[gen.PtyAttachRequest], *connect.ServerStream[gen.PtyAttachResponse]) error
// PtySendInput sends raw bytes to a PTY process identified by tag.
PtySendInput(context.Context, *connect.Request[gen.PtySendInputRequest]) (*connect.Response[gen.PtySendInputResponse], error)
// PtyResize updates the terminal dimensions for a PTY process.
PtyResize(context.Context, *connect.Request[gen.PtyResizeRequest]) (*connect.Response[gen.PtyResizeResponse], error)
// PtyKill sends a signal to a PTY process.
PtyKill(context.Context, *connect.Request[gen.PtyKillRequest]) (*connect.Response[gen.PtyKillResponse], error)
}
// NewHostAgentServiceHandler builds an HTTP handler from the service implementation. It returns the
@ -608,6 +690,30 @@ func NewHostAgentServiceHandler(svc HostAgentServiceHandler, opts ...connect.Han
connect.WithSchema(hostAgentServiceMethods.ByName("FlattenRootfs")),
connect.WithHandlerOptions(opts...),
)
hostAgentServicePtyAttachHandler := connect.NewServerStreamHandler(
HostAgentServicePtyAttachProcedure,
svc.PtyAttach,
connect.WithSchema(hostAgentServiceMethods.ByName("PtyAttach")),
connect.WithHandlerOptions(opts...),
)
hostAgentServicePtySendInputHandler := connect.NewUnaryHandler(
HostAgentServicePtySendInputProcedure,
svc.PtySendInput,
connect.WithSchema(hostAgentServiceMethods.ByName("PtySendInput")),
connect.WithHandlerOptions(opts...),
)
hostAgentServicePtyResizeHandler := connect.NewUnaryHandler(
HostAgentServicePtyResizeProcedure,
svc.PtyResize,
connect.WithSchema(hostAgentServiceMethods.ByName("PtyResize")),
connect.WithHandlerOptions(opts...),
)
hostAgentServicePtyKillHandler := connect.NewUnaryHandler(
HostAgentServicePtyKillProcedure,
svc.PtyKill,
connect.WithSchema(hostAgentServiceMethods.ByName("PtyKill")),
connect.WithHandlerOptions(opts...),
)
return "/hostagent.v1.HostAgentService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case HostAgentServiceCreateSandboxProcedure:
@ -652,6 +758,14 @@ func NewHostAgentServiceHandler(svc HostAgentServiceHandler, opts ...connect.Han
hostAgentServiceFlushSandboxMetricsHandler.ServeHTTP(w, r)
case HostAgentServiceFlattenRootfsProcedure:
hostAgentServiceFlattenRootfsHandler.ServeHTTP(w, r)
case HostAgentServicePtyAttachProcedure:
hostAgentServicePtyAttachHandler.ServeHTTP(w, r)
case HostAgentServicePtySendInputProcedure:
hostAgentServicePtySendInputHandler.ServeHTTP(w, r)
case HostAgentServicePtyResizeProcedure:
hostAgentServicePtyResizeHandler.ServeHTTP(w, r)
case HostAgentServicePtyKillProcedure:
hostAgentServicePtyKillHandler.ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
@ -744,3 +858,19 @@ func (UnimplementedHostAgentServiceHandler) FlushSandboxMetrics(context.Context,
func (UnimplementedHostAgentServiceHandler) FlattenRootfs(context.Context, *connect.Request[gen.FlattenRootfsRequest]) (*connect.Response[gen.FlattenRootfsResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.FlattenRootfs is not implemented"))
}
func (UnimplementedHostAgentServiceHandler) PtyAttach(context.Context, *connect.Request[gen.PtyAttachRequest], *connect.ServerStream[gen.PtyAttachResponse]) error {
return connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.PtyAttach is not implemented"))
}
func (UnimplementedHostAgentServiceHandler) PtySendInput(context.Context, *connect.Request[gen.PtySendInputRequest]) (*connect.Response[gen.PtySendInputResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.PtySendInput is not implemented"))
}
func (UnimplementedHostAgentServiceHandler) PtyResize(context.Context, *connect.Request[gen.PtyResizeRequest]) (*connect.Response[gen.PtyResizeResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.PtyResize is not implemented"))
}
func (UnimplementedHostAgentServiceHandler) PtyKill(context.Context, *connect.Request[gen.PtyKillRequest]) (*connect.Response[gen.PtyKillResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.PtyKill is not implemented"))
}

View File

@ -76,6 +76,21 @@ service HostAgentService {
// produce image-only templates (no memory/CPU state).
rpc FlattenRootfs(FlattenRootfsRequest) returns (FlattenRootfsResponse);
// PtyAttach starts a new PTY process or reconnects to an existing one.
// If cmd is non-empty, starts a new process with the given PTY dimensions.
// If tag is set and cmd is empty, reconnects to the existing process with that tag.
// Returns a stream of output events (started, output data, exit).
rpc PtyAttach(PtyAttachRequest) returns (stream PtyAttachResponse);
// PtySendInput sends raw bytes to a PTY process identified by tag.
rpc PtySendInput(PtySendInputRequest) returns (PtySendInputResponse);
// PtyResize updates the terminal dimensions for a PTY process.
rpc PtyResize(PtyResizeRequest) returns (PtyResizeResponse);
// PtyKill sends a signal to a PTY process.
rpc PtyKill(PtyKillRequest) returns (PtyKillResponse);
}
message CreateSandboxRequest {
@ -382,3 +397,70 @@ message FlattenRootfsRequest {
message FlattenRootfsResponse {
int64 size_bytes = 1;
}
// ── PTY ─────────────────────────────────────────────────────────────
message PtyAttachRequest {
string sandbox_id = 1;
// Tag is the stable identifier for this PTY session (e.g. "pty-abc123de").
// Chosen by the caller and used to reconnect later.
string tag = 2;
// If cmd is non-empty, a new process is started. If empty, reconnects to
// the existing process identified by tag.
string cmd = 3;
repeated string args = 4;
uint32 cols = 5;
uint32 rows = 6;
// Environment variables for the process.
map<string, string> envs = 7;
// Working directory. Empty means default.
string cwd = 8;
// User to run as. Empty means default (root).
string user = 9;
}
message PtyAttachResponse {
oneof event {
PtyStarted started = 1;
PtyOutput output = 2;
PtyExited exited = 3;
}
}
message PtyStarted {
uint32 pid = 1;
string tag = 2;
}
message PtyOutput {
bytes data = 1;
}
message PtyExited {
int32 exit_code = 1;
string error = 2;
}
message PtySendInputRequest {
string sandbox_id = 1;
string tag = 2;
bytes data = 3;
}
message PtySendInputResponse {}
message PtyResizeRequest {
string sandbox_id = 1;
string tag = 2;
uint32 cols = 3;
uint32 rows = 4;
}
message PtyResizeResponse {}
message PtyKillRequest {
string sandbox_id = 1;
string tag = 2;
}
message PtyKillResponse {}