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:
@ -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)
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
24
frontend/pnpm-lock.yaml
generated
24
frontend/pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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">
|
||||
<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 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>
|
||||
</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"
|
||||
>
|
||||
<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 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}
|
||||
|
||||
595
frontend/src/lib/components/TerminalTab.svelte
Normal file
595
frontend/src/lib/components/TerminalTab.svelte
Normal 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>
|
||||
@ -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'} />
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
405
internal/api/handlers_pty.go
Normal file
405
internal/api/handlers_pty.go
Normal 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)
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
220
internal/envdclient/pty.go
Normal 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
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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"))
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user