/keys.json
* DSH_RELAY_DATA 状态目录,默认 <本目录>/data
* DSH_RELAY_ADMIN_KEY 管理接口密钥;设置后管理接口需要 x-admin-key
* DSH_RELAY_POLL_MS 单次长轮询上限毫秒,默认 cli-server 400 / 其他 15000
* DSH_RELAY_EVENT_LIMIT 每台机器保留的最近事件条数,默认 300
*
* 说明:PHP 每次请求都是独立进程,没有跨请求内存,所以中转状态写在
* data/state.json 里(仅"最近事件环 + 待发帧 + 待回请求",不含会话正文以外的
* 任何东西),并用 flock 保证并发安全。这与 Node 版参考实现的"纯内存"等价,
* 只是把内存换成了本地临时文件;真正的生产后端应把状态放在内存/Redis 里。
*/
// ─────────────────────────────── 配置 ───────────────────────────────
const PROTOCOL_VERSION = 1;
$IS_CLI_SERVER = (PHP_SAPI === 'cli-server');
$BASE_PATH = rtrim((string)(getenv('DSH_RELAY_BASE') ?: '/dsh-api'), '/');
$DATA_DIR = (string)(getenv('DSH_RELAY_DATA') ?: (__DIR__ . DIRECTORY_SEPARATOR . 'data'));
$KEYS_FILE = (string)(getenv('DSH_RELAY_KEYS') ?: (__DIR__ . DIRECTORY_SEPARATOR . 'keys.json'));
$ADMIN_KEY = (string)(getenv('DSH_RELAY_ADMIN_KEY') ?: '');
$POLL_MAX_MS = (int)(getenv('DSH_RELAY_POLL_MS') ?: ($IS_CLI_SERVER ? 400 : 15000));
$EVENT_LIMIT = (int)(getenv('DSH_RELAY_EVENT_LIMIT') ?: 300);
$STATE_FILE = $DATA_DIR . DIRECTORY_SEPARATOR . 'state.json';
$LOCK_FILE = $DATA_DIR . DIRECTORY_SEPARATOR . 'state.lock';
@mkdir($DATA_DIR, 0700, true);
set_time_limit(0);
ignore_user_abort(false);
// ─────────────────────────── 基础工具 ───────────────────────────
/** 输出 JSON 并结束请求。 */
function jsonOut(int $status, $payload): void
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
/** 读取并解析 JSON 请求体(空体返回空数组)。 */
function readJsonBody(): array
{
$raw = file_get_contents('php://input');
if ($raw === false || $raw === '') {
return [];
}
$parsed = json_decode($raw, true);
return is_array($parsed) ? $parsed : [];
}
/** key 的非敏感展示形式,与插件端保持同一规则。 */
function fingerprint(string $key): string
{
if ($key === '') {
return '';
}
if (mb_strlen($key) <= 16) {
return mb_substr($key, 0, 4) . '…';
}
return mb_substr($key, 0, 12) . '…' . mb_substr($key, -4);
}
/** 恒定时间比较,避免用 === 比较机密。 */
function secretEquals(string $a, string $b): bool
{
if ($a === '' || $b === '') {
return false;
}
return hash_equals($a, $b);
}
/** 从各种可能的来源取出实例 key。 */
function extractKey(?array $helloFrame, ?array $body): string
{
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (is_string($header) && stripos($header, 'Bearer ') === 0) {
return substr($header, 7);
}
$query = $_GET['key'] ?? '';
if (is_string($query) && $query !== '') {
return $query;
}
if (is_array($body) && isset($body['key']) && is_string($body['key'])) {
return $body['key'];
}
if (is_array($helloFrame) && isset($helloFrame['auth']['key']) && is_string($helloFrame['auth']['key'])) {
return $helloFrame['auth']['key'];
}
return '';
}
/** 管理接口鉴权:配置了 DSH_RELAY_ADMIN_KEY 就必须带 x-admin-key。 */
function requireAdmin(): void
{
global $ADMIN_KEY;
if ($ADMIN_KEY === '') {
return; // 本地测试默认不设防;生产部署务必设置
}
$provided = $_SERVER['HTTP_X_ADMIN_KEY'] ?? ($_GET['adminKey'] ?? '');
if (!is_string($provided) || !secretEquals($ADMIN_KEY, $provided)) {
jsonOut(401, ['error' => ['code' => 'unauthorized', 'message' => '管理接口需要 x-admin-key']]);
}
}
// ─────────────────────────── key 白名单 ───────────────────────────
function loadKeys(): array
{
global $KEYS_FILE;
if (!is_file($KEYS_FILE)) {
return [];
}
$raw = file_get_contents($KEYS_FILE);
$parsed = $raw === false ? null : json_decode($raw, true);
$list = is_array($parsed) ? ($parsed['keys'] ?? $parsed) : [];
$out = [];
foreach ((is_array($list) ? $list : []) as $entry) {
if (is_string($entry)) {
$entry = ['key' => $entry];
}
if (is_array($entry) && isset($entry['key']) && is_string($entry['key']) && $entry['key'] !== '') {
$out[] = $entry;
}
}
return $out;
}
function saveKeys(array $keys): void
{
global $KEYS_FILE;
$payload = json_encode(['keys' => array_values($keys)], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
file_put_contents($KEYS_FILE, $payload . "\n", LOCK_EX);
@chmod($KEYS_FILE, 0600);
}
function authorizeKey(string $key): ?array
{
if ($key === '') {
return null;
}
foreach (loadKeys() as $entry) {
if (secretEquals((string)$entry['key'], $key)) {
return $entry;
}
}
return null;
}
// ─────────────────────────── 状态存取 ───────────────────────────
function defaultState(): array
{
return [
'instances' => [],
'pendingPairings' => [],
'autoAccept' => false,
'updatedAt' => time(),
];
}
function readStateUnlocked(): array
{
global $STATE_FILE;
if (!is_file($STATE_FILE)) {
return defaultState();
}
$raw = file_get_contents($STATE_FILE);
$parsed = $raw === false ? null : json_decode($raw, true);
if (!is_array($parsed)) {
return defaultState();
}
return array_merge(defaultState(), $parsed);
}
function writeStateUnlocked(array $state): void
{
global $STATE_FILE;
$state['updatedAt'] = time();
$tmp = $STATE_FILE . '.' . getmypid() . '.tmp';
file_put_contents($tmp, json_encode($state, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
@rename($tmp, $STATE_FILE);
}
/**
* 在文件锁保护下读改写状态。
*
* @param callable $fn 形如 function (array &$state) { ... return $x; }
* @param bool $write 是否写回
*/
function withState(callable $fn, bool $write = true)
{
global $LOCK_FILE;
$fp = fopen($LOCK_FILE, 'c+b');
if ($fp === false) {
jsonOut(500, ['error' => ['code' => 'internal', 'message' => '无法打开状态锁文件']]);
}
flock($fp, $write ? LOCK_EX : LOCK_SH);
try {
$state = readStateUnlocked();
$result = $fn($state);
if ($write) {
writeStateUnlocked($state);
}
return $result;
} finally {
flock($fp, LOCK_UN);
fclose($fp);
}
}
/**
* 确保某台机器在状态里有槽位(不存在则创建),并可选刷新 key 信息。
*
* 注意:返回 void —— PHP 不允许对函数调用结果取引用,所以调用方拿到的是
* `$state['instances'][$instanceId]` 这个真实数组元素。
*/
function ensureInstance(array &$state, string $instanceId, ?array $keyEntry = null): void
{
if (!isset($state['instances'][$instanceId])) {
$state['instances'][$instanceId] = [
'instanceId' => $instanceId,
'keyFingerprint' => $keyEntry ? fingerprint((string)$keyEntry['key']) : null,
'label' => $keyEntry['label'] ?? null,
'transport' => 'http',
'tls' => false,
'connectedAt' => time(),
'lastSeenAt' => time(),
'hello' => null,
'capabilities' => [],
'subscriptions' => ['topics' => [], 'sessions' => [], 'assistantStreams' => []],
'events' => [],
'inbox' => [],
'responses' => [],
'pendingRequests' => [],
'lastSeq' => 0,
'cursor' => 0,
'rejectedAt' => null,
'disconnectedAt' => null,
];
}
if ($keyEntry) {
$state['instances'][$instanceId]['keyFingerprint'] = fingerprint((string)$keyEntry['key']);
$state['instances'][$instanceId]['label'] = $keyEntry['label'] ?? null;
}
}
/** 把一帧放进某台机器的待发队列。 */
function queueFrame(array &$state, string $instanceId, array $frame): void
{
ensureInstance($state, $instanceId);
$instance = &$state['instances'][$instanceId];
$instance['inbox'][] = $frame;
// 队列上限,避免对端长期不在时无限增长
if (count($instance['inbox']) > 500) {
$instance['inbox'] = array_slice($instance['inbox'], -500);
}
$instance['cursor'] = (int)($frame['seq'] ?? $instance['cursor']);
}
/** 记录一次被拒绝的连接,供 UI 一键登记。 */
function recordPendingPairing(string $key, string $instanceId, string $reason): void
{
withState(function (array &$state) use ($key, $instanceId, $reason) {
if ($state['autoAccept']) {
$keys = loadKeys();
$keys[] = ['key' => $key, 'label' => $instanceId !== '' ? $instanceId : 'auto-accepted', 'addedAt' => date('c')];
saveKeys($keys);
return;
}
foreach ($state['pendingPairings'] as $pairing) {
if (($pairing['key'] ?? '') === $key) {
return;
}
}
$state['pendingPairings'][] = [
'key' => $key,
'fingerprint' => fingerprint($key),
'instanceId' => $instanceId,
'reason' => $reason,
'at' => time(),
];
if (count($state['pendingPairings']) > 20) {
$state['pendingPairings'] = array_slice($state['pendingPairings'], -20);
}
});
}
// ─────────────────────────── 路由 ───────────────────────────
$requestPath = (string)(parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/');
$requestMethod = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
$route = null;
if ($BASE_PATH !== '' && str_starts_with($requestPath, $BASE_PATH)) {
$route = substr($requestPath, strlen($BASE_PATH));
if ($route === false) {
$route = '';
}
if ($route !== '' && $route[0] !== '/') {
$route = null; // 例如 /dsh-apixxx 不应被当成 API
}
}
$route = $route === null ? null : (rtrim($route, '/') === '' ? '/' : rtrim($route, '/'));
// 非 API 路径 → 提供测试 UI
if ($route === null) {
if ($requestMethod !== 'GET') {
jsonOut(405, ['error' => ['code' => 'bad_request', 'message' => '只支持 GET']]);
}
renderUi($BASE_PATH, $POLL_MAX_MS, $IS_CLI_SERVER, $ADMIN_KEY !== '');
exit;
}
// ─────────────────────────── API 路由 ───────────────────────────
// GET {base}/ —— 协议信息
if ($route === '/' || $route === '') {
jsonOut(200, [
'name' => 'dsh2server PHP relay',
'protocol' => PROTOCOL_VERSION,
'basePath' => $BASE_PATH,
'carriers' => ['http-long-poll' => true, 'websocket' => false],
'webSocketNote' => 'PHP 无法把 HTTP 请求升级为 WebSocket;插件会自动回退到 HTTP 长轮询。',
'endpoints' => [
'events' => $BASE_PATH . '/events',
'inbox' => $BASE_PATH . '/inbox',
'instances' => $BASE_PATH . '/instances',
'keys' => $BASE_PATH . '/keys',
],
'pollMaxMs' => $POLL_MAX_MS,
'cliServer' => $IS_CLI_SERVER,
'keys' => count(loadKeys()),
'instances' => count(withState(fn (array &$s) => array_keys($s['instances']), false)),
]);
}
// POST {base}/events —— 插件上行
if ($route === '/events' && $requestMethod === 'POST') {
$body = readJsonBody();
$frames = is_array($body['frames'] ?? null) ? $body['frames'] : [];
$hello = null;
foreach ($frames as $frame) {
if (is_array($frame) && ($frame['type'] ?? '') === 'hello') {
$hello = $frame;
break;
}
}
$key = extractKey($hello, $body);
$entry = authorizeKey($key);
if ($entry === null) {
recordPendingPairing($key, (string)($hello['instanceId'] ?? ($body['instanceId'] ?? '')), 'unknown instance key');
jsonOut(401, ['error' => [
'code' => 'unauthorized',
'message' => '未登记的实例 key:请在测试 UI 的「配对」面板里登记这台机器',
]]);
}
$instanceId = (string)($body['instanceId'] ?? ($hello['instanceId'] ?? ($entry['instanceId'] ?? '')));
if ($instanceId === '') {
jsonOut(400, ['error' => ['code' => 'bad_request', 'message' => '缺少 instanceId']]);
}
$accepted = withState(function (array &$state) use ($frames, $instanceId, $entry, $hello) {
ensureInstance($state, $instanceId, $entry);
$instance = &$state['instances'][$instanceId];
$instance['transport'] = 'http';
$instance['tls'] = (($_SERVER['HTTPS'] ?? '') === 'on');
$instance['lastSeenAt'] = time();
$instance['rejectedAt'] = null;
$instance['disconnectedAt'] = null;
if ($hello !== null) {
$instance['hello'] = [
'v' => $hello['v'] ?? null,
'instance' => $hello['instance'] ?? null,
'lastSeq' => $hello['lastSeq'] ?? 0,
'resumeFromSeq' => $hello['resumeFromSeq'] ?? 0,
'subscriptions' => $hello['subscriptions'] ?? null,
];
$instance['capabilities'] = $hello['capabilities'] ?? [];
if (isset($hello['instance']['displayName'])) {
$instance['label'] = $instance['label'] ?: $hello['instance']['displayName'];
}
}
$maxSeq = (int)$instance['lastSeq'];
foreach ($frames as $frame) {
if (!is_array($frame)) {
continue;
}
switch ($frame['type'] ?? '') {
case 'hello':
$resumeFrom = 0;
foreach ($instance['events'] as $event) {
$resumeFrom = max($resumeFrom, (int)($event['seq'] ?? 0));
}
queueFrame($state, $instanceId, [
'v' => PROTOCOL_VERSION,
'type' => 'hello.ack',
'instanceId' => $instanceId,
'serverTime' => (int)(microtime(true) * 1000),
'heartbeatMs' => 30000,
'resumeFromSeq' => $resumeFrom,
'serverSeq' => (int)$instance['cursor'],
'pollWaitMs' => $GLOBALS['POLL_MAX_MS'],
]);
// 默认订阅:全局主题;逐会话订阅由 UI 或后端按需发起
queueFrame($state, $instanceId, [
'v' => PROTOCOL_VERSION,
'type' => 'subscribe',
'id' => 'sub-init',
'topics' => ['instance', 'sessions', 'jobs', 'approvals'],
'snapshot' => true,
]);
break;
case 'event':
$seq = (int)($frame['seq'] ?? 0);
$duplicate = false;
foreach ($instance['events'] as $existing) {
if ((int)($existing['seq'] ?? 0) === $seq) {
$duplicate = true;
break;
}
}
if (!$duplicate) {
$instance['events'][] = $frame;
if (count($instance['events']) > $GLOBALS['EVENT_LIMIT']) {
$instance['events'] = array_slice($instance['events'], -$GLOBALS['EVENT_LIMIT']);
}
}
$instance['lastSeq'] = max((int)$instance['lastSeq'], $seq);
$maxSeq = max($maxSeq, $seq);
break;
case 'response':
$id = (string)($frame['id'] ?? '');
if ($id !== '') {
$instance['responses'][$id] = $frame;
unset($instance['pendingRequests'][$id]);
}
// 订阅响应顺带刷新订阅视图
if ($id === 'sub-init' || str_starts_with($id, 'sub-')) {
$result = $frame['result'] ?? null;
if (is_array($result)) {
$instance['subscriptions'] = [
'topics' => $result['topics'] ?? [],
'sessions' => $result['sessions'] ?? [],
'assistantStreams' => $result['assistantStreams'] ?? [],
];
}
}
break;
case 'ping':
queueFrame($state, $instanceId, [
'v' => PROTOCOL_VERSION,
'type' => 'pong',
'ts' => (int)(microtime(true) * 1000),
]);
break;
case 'ack':
$instance['cursor'] = max((int)$instance['cursor'], (int)($frame['seq'] ?? 0));
break;
case 'bye':
// HTTP 载体没有"连接关闭"事件,卸载信号只能来自这一帧。
// 标记断开而不是删除,避免与紧接其后的重连抢跑。
$instance['disconnectedAt'] = time();
$instance['lastSeenAt'] = time();
break;
}
}
return $maxSeq;
});
jsonOut(200, ['accepted' => $accepted]);
}
// GET {base}/inbox —— 插件下行(长轮询)
if ($route === '/inbox' && $requestMethod === 'GET') {
$key = extractKey(null, null);
$entry = authorizeKey($key);
if ($entry === null) {
jsonOut(401, ['error' => ['code' => 'unauthorized', 'message' => '未登记的实例 key']]);
}
$instanceId = (string)($_GET['instanceId'] ?? ($entry['instanceId'] ?? ''));
if ($instanceId === '') {
jsonOut(400, ['error' => ['code' => 'bad_request', 'message' => '缺少 instanceId']]);
}
$waitMs = (int)($_GET['waitMs'] ?? $POLL_MAX_MS);
$waitMs = max(0, min($waitMs, $POLL_MAX_MS));
$deadline = microtime(true) + ($waitMs / 1000);
do {
$frames = withState(function (array &$state) use ($instanceId) {
if (!isset($state['instances'][$instanceId])) {
return null;
}
$instance = &$state['instances'][$instanceId];
$instance['lastSeenAt'] = time();
$frames = $instance['inbox'];
$instance['inbox'] = [];
return $frames;
});
if ($frames === null) {
jsonOut(404, ['error' => ['code' => 'not_found', 'message' => '未知实例:请先完成 hello']]);
}
if (count($frames) > 0) {
$cursor = withState(fn (array &$s) => (int)($s['instances'][$instanceId]['cursor'] ?? 0), false);
jsonOut(200, ['frames' => $frames, 'cursor' => $cursor, 'waitMs' => $POLL_MAX_MS]);
}
if (microtime(true) >= $deadline) {
break;
}
usleep(120000); // 120ms:兼顾响应速度与 CPU
} while (true);
$cursor = withState(fn (array &$s) => (int)($s['instances'][$instanceId]['cursor'] ?? 0), false);
jsonOut(200, ['frames' => [], 'cursor' => $cursor, 'waitMs' => $POLL_MAX_MS]);
}
// GET {base}/ws —— 明确告知:PHP 无法升级 WebSocket
if ($route === '/ws') {
jsonOut(426, [
'error' => [
'code' => 'websocket_unsupported',
'message' => '本 PHP 中转不支持 WebSocket。插件会(在 transport=auto 时)自动回退到 HTTP 长轮询;'
. '也可以把插件配置里的 transport 直接设为 http。',
],
]);
}
// ── 管理接口 ──────────────────────────────────────────────────────
// GET {base}/keys
if ($route === '/keys' && $requestMethod === 'GET') {
requireAdmin();
$rows = [];
foreach (loadKeys() as $entry) {
$rows[] = [
'fingerprint' => fingerprint((string)$entry['key']),
'label' => $entry['label'] ?? null,
'instanceId' => $entry['instanceId'] ?? null,
'addedAt' => $entry['addedAt'] ?? null,
];
}
jsonOut(200, ['keys' => $rows, 'keysFile' => $KEYS_FILE]);
}
// POST {base}/keys { key, label? }
if ($route === '/keys' && $requestMethod === 'POST') {
requireAdmin();
$body = readJsonBody();
$key = trim((string)($body['key'] ?? ''));
if (strlen($key) < 16) {
jsonOut(400, ['error' => ['code' => 'invalid', 'message' => 'key 至少 16 个字符']]);
}
if (authorizeKey($key) !== null) {
jsonOut(409, ['error' => ['code' => 'exists', 'message' => '这个 key 已经登记过了']]);
}
$keys = loadKeys();
$entry = ['key' => $key, 'label' => $body['label'] ?? null, 'addedAt' => date('c')];
$keys[] = $entry;
saveKeys($keys);
// 从待配对列表里移除
withState(function (array &$state) use ($key) {
$state['pendingPairings'] = array_values(array_filter(
$state['pendingPairings'],
fn ($p) => ($p['key'] ?? '') !== $key
));
});
jsonOut(200, ['added' => ['fingerprint' => fingerprint($key), 'label' => $entry['label']]]);
}
// POST {base}/keys/remove { key | fingerprint }
if ($route === '/keys/remove' && $requestMethod === 'POST') {
requireAdmin();
$body = readJsonBody();
$selector = (string)($body['key'] ?? ($body['fingerprint'] ?? ''));
$keys = loadKeys();
$remaining = [];
$removed = false;
foreach ($keys as $entry) {
if ((string)$entry['key'] === $selector || fingerprint((string)$entry['key']) === $selector) {
$removed = true;
continue;
}
$remaining[] = $entry;
}
if ($removed) {
saveKeys($remaining);
}
jsonOut($removed ? 200 : 404, ['removed' => $removed]);
}
// GET {base}/pending
if ($route === '/pending' && $requestMethod === 'GET') {
requireAdmin();
$data = withState(function (array &$state) {
$rows = [];
foreach ($state['pendingPairings'] as $pairing) {
$rows[] = [
'fingerprint' => $pairing['fingerprint'] ?? fingerprint((string)($pairing['key'] ?? '')),
'instanceId' => $pairing['instanceId'] ?? '',
'reason' => $pairing['reason'] ?? '',
'at' => $pairing['at'] ?? time(),
'key' => $pairing['key'] ?? '',
];
}
return ['pending' => $rows, 'autoAccept' => (bool)$state['autoAccept']];
}, false);
jsonOut(200, $data);
}
// POST {base}/pending/allow { key } —— 一键登记
if ($route === '/pending/allow' && $requestMethod === 'POST') {
requireAdmin();
$body = readJsonBody();
$key = (string)($body['key'] ?? '');
if ($key === '') {
jsonOut(400, ['error' => ['code' => 'invalid', 'message' => '缺少 key']]);
}
if (authorizeKey($key) === null) {
$keys = loadKeys();
$keys[] = ['key' => $key, 'label' => $body['label'] ?? null, 'addedAt' => date('c')];
saveKeys($keys);
}
withState(function (array &$state) use ($key) {
$state['pendingPairings'] = array_values(array_filter(
$state['pendingPairings'],
fn ($p) => ($p['key'] ?? '') !== $key
));
});
jsonOut(200, ['allowed' => fingerprint($key)]);
}
// POST {base}/pending/auto { enabled }
if ($route === '/pending/auto' && $requestMethod === 'POST') {
requireAdmin();
$body = readJsonBody();
$enabled = (bool)($body['enabled'] ?? false);
withState(function (array &$state) use ($enabled) {
$state['autoAccept'] = $enabled;
});
jsonOut(200, ['autoAccept' => $enabled]);
}
// GET {base}/instances
if ($route === '/instances' && $requestMethod === 'GET') {
requireAdmin();
$rows = withState(function (array &$state) {
$rows = [];
foreach ($state['instances'] as $id => $instance) {
$rows[] = [
'instanceId' => $id,
'label' => $instance['label'] ?? null,
'keyFingerprint' => $instance['keyFingerprint'] ?? null,
'transport' => $instance['transport'] ?? 'http',
'tls' => (bool)($instance['tls'] ?? false),
'connectedAt' => $instance['connectedAt'] ?? null,
'lastSeenAt' => $instance['lastSeenAt'] ?? null,
'disconnectedAt' => $instance['disconnectedAt'] ?? null,
'lastSeq' => $instance['lastSeq'] ?? 0,
'eventCount' => count($instance['events'] ?? []),
'capabilities' => $instance['capabilities'] ?? [],
'subscriptions' => $instance['subscriptions'] ?? [],
'pendingRequests' => count($instance['pendingRequests'] ?? []),
'lastEventKind' => empty($instance['events']) ? null : ($instance['events'][count($instance['events']) - 1]['kind'] ?? null),
'pluginVersion' => $instance['hello']['instance']['pluginVersion'] ?? null,
'hostname' => $instance['hello']['instance']['hostname'] ?? null,
'platform' => $instance['hello']['instance']['platform'] ?? null,
];
}
return $rows;
}, false);
jsonOut(200, ['instances' => $rows]);
}
// GET {base}/instances/{id}/events?since=&limit=
if ($requestMethod === 'GET' && preg_match('#^/instances/([^/]+)/events$#', (string)$route, $m)) {
requireAdmin();
$instanceId = urldecode($m[1]);
$since = (int)($_GET['since'] ?? 0);
$limit = max(1, min((int)($_GET['limit'] ?? 200), 1000));
$data = withState(function (array &$state) use ($instanceId, $since, $limit) {
if (!isset($state['instances'][$instanceId])) {
return null;
}
$events = [];
foreach ($state['instances'][$instanceId]['events'] as $event) {
if ((int)($event['seq'] ?? 0) > $since) {
$events[] = $event;
}
}
$events = array_slice($events, -$limit);
return ['events' => $events, 'lastSeq' => (int)$state['instances'][$instanceId]['lastSeq']];
}, false);
if ($data === null) {
jsonOut(404, ['error' => ['code' => 'not_found', 'message' => '未知实例']]);
}
jsonOut(200, $data);
}
// POST {base}/instances/{id}/request { method, params }
if ($requestMethod === 'POST' && preg_match('#^/instances/([^/]+)/request$#', (string)$route, $m)) {
requireAdmin();
$instanceId = urldecode($m[1]);
$body = readJsonBody();
$method = (string)($body['method'] ?? '');
if ($method === '') {
jsonOut(400, ['error' => ['code' => 'invalid', 'message' => '缺少 method']]);
}
$id = 'req-' . bin2hex(random_bytes(6));
$frame = [
'v' => PROTOCOL_VERSION,
'type' => 'request',
'id' => $id,
'method' => $method,
'params' => is_array($body['params'] ?? null) ? $body['params'] : new stdClass(),
];
$ok = withState(function (array &$state) use ($instanceId, $id, $frame, $method) {
if (!isset($state['instances'][$instanceId])) {
return false;
}
queueFrame($state, $instanceId, $frame);
$state['instances'][$instanceId]['pendingRequests'][$id] = ['method' => $method, 'at' => time()];
return true;
});
if (!$ok) {
jsonOut(404, ['error' => ['code' => 'not_found', 'message' => '未知实例(该机器还没连上或已离线)']]);
}
// 立即返回 id:UI 用 /response?id= 轮询结果。
// (不能在这里阻塞等待:php -S 是单进程,阻塞会卡住插件拉取队列,形成死锁。)
jsonOut(200, ['queued' => true, 'id' => $id, 'method' => $method]);
}
// GET {base}/instances/{id}/response?id=
if ($requestMethod === 'GET' && preg_match('#^/instances/([^/]+)/response$#', (string)$route, $m)) {
requireAdmin();
$instanceId = urldecode($m[1]);
$id = (string)($_GET['id'] ?? '');
if ($id === '') {
jsonOut(400, ['error' => ['code' => 'invalid', 'message' => '缺少 id']]);
}
$frame = withState(function (array &$state) use ($instanceId, $id) {
return $state['instances'][$instanceId]['responses'][$id] ?? null;
}, false);
if ($frame === null) {
jsonOut(200, ['ready' => false]);
}
jsonOut(200, ['ready' => true, 'frame' => $frame]);
}
// POST {base}/instances/{id}/forget —— 忘记这台机器(清掉它的内存状态)
if ($requestMethod === 'POST' && preg_match('#^/instances/([^/]+)/forget$#', (string)$route, $m)) {
requireAdmin();
$instanceId = urldecode($m[1]);
$removed = withState(function (array &$state) use ($instanceId) {
if (!isset($state['instances'][$instanceId])) {
return false;
}
unset($state['instances'][$instanceId]);
return true;
});
jsonOut($removed ? 200 : 404, ['forgotten' => $removed]);
}
// POST {base}/admin/clear —— 清空全部中转状态(不动 key 白名单)
if ($route === '/admin/clear' && $requestMethod === 'POST') {
requireAdmin();
withState(function (array &$state) {
$state['instances'] = [];
$state['pendingPairings'] = [];
});
jsonOut(200, ['cleared' => true]);
}
jsonOut(404, ['error' => ['code' => 'not_found', 'message' => '未知路由:' . $requestMethod . ' ' . $requestPath]]);
// ─────────────────────────── 测试 UI ───────────────────────────
/**
* 输出内置 HTML 调试台。
*
* 页面本身不依赖任何外部资源(离线可用),所有数据都来自上面的 API。
*/
function renderUi(string $basePath, int $pollMaxMs, bool $cliServer, bool $adminRequired): void
{
$html = <<<'HTML'
dsh2server 测试台
dsh2server 测试台
连接中…
② 在线机器
还没有机器连上来。启动 dsh 后稍等片刻(最多 60 秒重连一次)。
④ 操作面板 未选择机器
—
点「session.list」拉取会话。
工作目录结果
—
—
⑤ 审批 / 提问 0
需要插件配置 forwardApprovals: true(可选 forwardQuestions: true)才会把本机的审批/提问转发到这里。
暂无待决请求。
⑥ 自检结果
点右上角「一键自检」依次验证:握手 → 能力 → 会话列表 → 工作目录 → 命令下发 → 中断。
HTML;
$html = str_replace(
['__BASE__', '__POLL__', '__CLI__', '__ADMIN__'],
[
json_encode($basePath, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
(string)$pollMaxMs,
$cliServer ? 'true' : 'false',
$adminRequired ? 'true' : 'false',
],
$html
);
header('Content-Type: text/html; charset=utf-8');
header('Cache-Control: no-store');
echo $html;
}