mirror of https://github.com/garrytan/gstack.git
feat: pair-agent CLI command + instruction block generator
One command to pair a remote agent: $B pair-agent. Creates a setup key via POST /pair, prints a copy-pasteable instruction block with curl commands. Smart tunnel fallback (tunnel URL > auto-start > localhost). Flags: --for HOST, --local HOST, --admin, --client NAME. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
eb6f57239b
commit
fafe68b44c
|
|
@ -447,6 +447,188 @@ async function sendCommand(state: ServerState, command: string, args: string[],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Pair-Agent DX ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface InstructionBlockOptions {
|
||||||
|
setupKey: string;
|
||||||
|
serverUrl: string;
|
||||||
|
scopes: string[];
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure function: generate a copy-pasteable instruction block for a remote agent. */
|
||||||
|
export function generateInstructionBlock(opts: InstructionBlockOptions): string {
|
||||||
|
const { setupKey, serverUrl, scopes, expiresAt } = opts;
|
||||||
|
const scopeDesc = scopes.includes('admin')
|
||||||
|
? 'read + write + admin access (can execute JS, read cookies, access storage)'
|
||||||
|
: 'read + write access (cannot execute JS, read cookies, or access storage)';
|
||||||
|
|
||||||
|
return `\
|
||||||
|
${'='.repeat(59)}
|
||||||
|
REMOTE BROWSER ACCESS — paste this into your other agent
|
||||||
|
${'='.repeat(59)}
|
||||||
|
|
||||||
|
You have access to a remote browser controlled via HTTP API.
|
||||||
|
This setup key expires in 5 minutes.
|
||||||
|
|
||||||
|
STEP 1 — Exchange the setup key for a session token:
|
||||||
|
|
||||||
|
curl -s -X POST \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{"setup_key": "${setupKey}"}' \\
|
||||||
|
${serverUrl}/connect
|
||||||
|
|
||||||
|
You'll get back: {"token": "gsk_sess_...", "expires": "...", "scopes": [...]}
|
||||||
|
Save that token. Use it for all subsequent requests.
|
||||||
|
|
||||||
|
STEP 2 — Create your own tab:
|
||||||
|
|
||||||
|
curl -s -X POST \\
|
||||||
|
-H "Authorization: Bearer <your-session-token>" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{"command": "newtab", "args": ["https://example.com"]}' \\
|
||||||
|
${serverUrl}/command
|
||||||
|
|
||||||
|
You'll get back: {"tabId": N, ...}
|
||||||
|
Include "tabId": N in all subsequent commands.
|
||||||
|
|
||||||
|
STEP 3 — Use the browser. Send commands as POST /command:
|
||||||
|
|
||||||
|
curl -s -X POST \\
|
||||||
|
-H "Authorization: Bearer <your-session-token>" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{"command": "snapshot", "args": ["-i"], "tabId": <your-tab-id>}' \\
|
||||||
|
${serverUrl}/command
|
||||||
|
|
||||||
|
AVAILABLE COMMANDS:
|
||||||
|
Navigate: {"command": "goto", "args": ["URL"], "tabId": N}
|
||||||
|
Read page: {"command": "snapshot", "args": ["-i"], "tabId": N}
|
||||||
|
Full text: {"command": "text", "args": [], "tabId": N}
|
||||||
|
Screenshot: {"command": "screenshot", "args": ["/tmp/screen.png"], "tabId": N}
|
||||||
|
Click: {"command": "click", "args": ["@e3"], "tabId": N}
|
||||||
|
Fill form: {"command": "fill", "args": ["@e5", "value"], "tabId": N}
|
||||||
|
Go back: {"command": "back", "args": [], "tabId": N}
|
||||||
|
List tabs: {"command": "tabs", "args": []}
|
||||||
|
|
||||||
|
SCOPES: This token has ${scopeDesc}.
|
||||||
|
${scopes.includes('admin') ? '' : `To request admin access, ask the user to re-run pair-agent with --admin.\n`}
|
||||||
|
SESSION: Token expires ${expiresAt}. The user can revoke it
|
||||||
|
anytime with: $B tunnel revoke <your-agent-name>
|
||||||
|
|
||||||
|
IF SOMETHING GOES WRONG:
|
||||||
|
401 Unauthorized → Token expired or revoked. Ask the user
|
||||||
|
to run pair-agent again.
|
||||||
|
403 Forbidden → Command not in your scope, or tab not owned
|
||||||
|
by you. Use newtab first.
|
||||||
|
429 Too Many Requests → Sending > 10 requests/second.
|
||||||
|
Wait for the Retry-After header.
|
||||||
|
|
||||||
|
${'='.repeat(59)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFlag(args: string[], flag: string): string | null {
|
||||||
|
const idx = args.indexOf(flag);
|
||||||
|
if (idx === -1 || idx + 1 >= args.length) return null;
|
||||||
|
return args[idx + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasFlag(args: string[], flag: string): boolean {
|
||||||
|
return args.includes(flag);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePairAgent(state: ServerState, args: string[]): Promise<void> {
|
||||||
|
const clientName = parseFlag(args, '--client') || `remote-${Date.now()}`;
|
||||||
|
const admin = hasFlag(args, '--admin');
|
||||||
|
const localHost = parseFlag(args, '--local');
|
||||||
|
|
||||||
|
// Call POST /pair to create a setup key
|
||||||
|
const pairResp = await fetch(`http://127.0.0.1:${state.port}/pair`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${state.token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
clientId: clientName,
|
||||||
|
admin,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!pairResp.ok) {
|
||||||
|
const err = await pairResp.text();
|
||||||
|
console.error(`[browse] Failed to create setup key: ${err}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pairData = await pairResp.json() as {
|
||||||
|
setup_key: string;
|
||||||
|
expires_at: string;
|
||||||
|
scopes: string[];
|
||||||
|
tunnel_url: string | null;
|
||||||
|
server_url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine the URL to use
|
||||||
|
let serverUrl: string;
|
||||||
|
if (pairData.tunnel_url) {
|
||||||
|
serverUrl = pairData.tunnel_url;
|
||||||
|
} else {
|
||||||
|
// Check if ngrok is configured but tunnel isn't running
|
||||||
|
const ngrokEnvPath = path.join(process.env.HOME || '/tmp', '.gstack', 'ngrok.env');
|
||||||
|
if (fs.existsSync(ngrokEnvPath) && !localHost) {
|
||||||
|
console.warn('[browse] ngrok is configured but tunnel is not running.');
|
||||||
|
console.warn('[browse] Start the tunnel: BROWSE_TUNNEL=1 $B restart');
|
||||||
|
console.warn('[browse] Using localhost for now (same-machine only).\n');
|
||||||
|
} else if (!localHost) {
|
||||||
|
console.warn('[browse] No tunnel active. Instructions use localhost (same-machine only).\n');
|
||||||
|
}
|
||||||
|
serverUrl = pairData.server_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --local HOST: write config file directly, skip instruction block
|
||||||
|
if (localHost) {
|
||||||
|
try {
|
||||||
|
// Resolve host config for the globalRoot path
|
||||||
|
const hostsPath = path.resolve(__dirname, '..', '..', 'hosts', 'index.ts');
|
||||||
|
let globalRoot = `.${localHost}/skills/gstack`;
|
||||||
|
try {
|
||||||
|
const { getHostConfig } = await import(hostsPath);
|
||||||
|
const hostConfig = getHostConfig(localHost);
|
||||||
|
globalRoot = hostConfig.globalRoot;
|
||||||
|
} catch {
|
||||||
|
// Fallback to convention-based path
|
||||||
|
}
|
||||||
|
|
||||||
|
const configDir = path.join(process.env.HOME || '/tmp', globalRoot);
|
||||||
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
|
const configFile = path.join(configDir, 'browse-remote.json');
|
||||||
|
const configData = {
|
||||||
|
url: serverUrl,
|
||||||
|
setup_key: pairData.setup_key,
|
||||||
|
scopes: pairData.scopes,
|
||||||
|
expires_at: pairData.expires_at,
|
||||||
|
};
|
||||||
|
fs.writeFileSync(configFile, JSON.stringify(configData, null, 2), { mode: 0o600 });
|
||||||
|
console.log(`Connected. ${localHost} can now use the browser.`);
|
||||||
|
console.log(`Config written to: ${configFile}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[browse] Failed to write config for ${localHost}: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print the instruction block
|
||||||
|
const block = generateInstructionBlock({
|
||||||
|
setupKey: pairData.setup_key,
|
||||||
|
serverUrl,
|
||||||
|
scopes: pairData.scopes,
|
||||||
|
expiresAt: pairData.expires_at || 'in 24 hours',
|
||||||
|
});
|
||||||
|
console.log(block);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Main ──────────────────────────────────────────────────────
|
// ─── Main ──────────────────────────────────────────────────────
|
||||||
async function main() {
|
async function main() {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
|
|
@ -678,6 +860,13 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = await ensureServer();
|
const state = await ensureServer();
|
||||||
|
|
||||||
|
// ─── Pair-Agent (post-server, pre-dispatch) ──────────────
|
||||||
|
if (command === 'pair-agent') {
|
||||||
|
await handlePairAgent(state, commandArgs);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
await sendCommand(state, command, commandArgs);
|
await sendCommand(state, command, commandArgs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue