sync: update from private repo (ea88916)
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
This commit is contained in:
parent
0f75bdfbab
commit
38bd874366
@ -4,6 +4,8 @@ AI と人間が共有する SSH PTY セッションを操作する 3 ツール
|
|||||||
|
|
||||||
単発コマンドだけなら **`SshExec`** (ssh-ops piece) のほうが軽い。本ツール群は対話的シェル + AI が画面を見続ける用途に最適化されている。
|
単発コマンドだけなら **`SshExec`** (ssh-ops piece) のほうが軽い。本ツール群は対話的シェル + AI が画面を見続ける用途に最適化されている。
|
||||||
|
|
||||||
|
> **ユーザーが先にセッションを開いている場合がある**: タスク詳細の **Console タブ**から、ユーザーが接続を選んで自分でセッションを起動できる。その場合 `SshConsoleEnsure` は既存セッションをそのまま再利用する (`connection_id` を省略すれば active session が採用される)。「まず console を開く」操作を AI 側でやり直す必要はない。
|
||||||
|
|
||||||
## 典型的な flow (まずこれを真似る)
|
## 典型的な flow (まずこれを真似る)
|
||||||
|
|
||||||
```js
|
```js
|
||||||
|
|||||||
@ -29,6 +29,10 @@ movements:
|
|||||||
でも同じ doc が返る。SshListConnections は ReadToolDoc({name: "SshListConnections"})。
|
でも同じ doc が返る。SshListConnections は ReadToolDoc({name: "SshListConnections"})。
|
||||||
|
|
||||||
## 標準 flow
|
## 標準 flow
|
||||||
|
0. ユーザーが Console タブから既にセッションを開いていることがある。その場合は
|
||||||
|
connection_id を省略して SshConsoleSnapshot({}) で現在の画面を確認し、そのまま
|
||||||
|
SshConsoleSend で続ける (改めて Ensure し直す必要はない)。"no live session" が
|
||||||
|
返ってきたら下の手順で自分で開く
|
||||||
1. タスク本文を読み、どのリモートホストでどんな作業をするか把握する
|
1. タスク本文を読み、どのリモートホストでどんな作業をするか把握する
|
||||||
2. connection_id (UUID) がタスク本文に無ければ SshListConnections({}) で発見する
|
2. connection_id (UUID) がタスク本文に無ければ SshListConnections({}) で発見する
|
||||||
- **必ず id フィールドを使う**。label ("terminal" など) や host ("192.168.1.x" など) を connection_id として渡してはいけない
|
- **必ず id フィールドを使う**。label ("terminal" など) や host ("192.168.1.x" など) を connection_id として渡してはいけない
|
||||||
@ -60,6 +64,13 @@ movements:
|
|||||||
output/ または input/ 配下を使う。詳細・エラーコードは ReadToolDoc({name: "SshUpload"})
|
output/ または input/ 配下を使う。詳細・エラーコードは ReadToolDoc({name: "SshUpload"})
|
||||||
/ ReadToolDoc({name: "SshDownload"})
|
/ ReadToolDoc({name: "SshDownload"})
|
||||||
|
|
||||||
|
## 調べ物 (Web 検索)
|
||||||
|
- 不明なコマンド・オプション、エラーメッセージ、設定手順が出てきたら WebSearch で調べてよい。
|
||||||
|
ヒットしたページや公式 docs の本文は WebFetch で読む。JS レンダリングや操作が要るページは BrowseWeb。
|
||||||
|
- インストーラ / 設定テンプレート / tarball を URL から取得して配信したいときは DownloadFile で
|
||||||
|
workspace の output/ または input/ に落とし、SshUpload でリモートへ送る
|
||||||
|
- 検索結果を鵜呑みにしてリモートで破壊的コマンドを実行しない。出典を確認し、不可逆操作は user に確認する
|
||||||
|
|
||||||
## 注意
|
## 注意
|
||||||
- shell 状態 (cd / env / foreground プロセス) はタスク内で維持される。毎ターン cd し直す必要なし
|
- shell 状態 (cd / env / foreground プロセス) はタスク内で維持される。毎ターン cd し直す必要なし
|
||||||
- 機密値はコマンド文字列に直接書かない (audit log に hash で残る)
|
- 機密値はコマンド文字列に直接書かない (audit log に hash で残る)
|
||||||
@ -70,7 +81,7 @@ movements:
|
|||||||
- 完了: complete({status: "success", result: "..."})
|
- 完了: complete({status: "success", result: "..."})
|
||||||
- 中断: complete({status: "aborted", abort_reason: "..."})
|
- 中断: complete({status: "aborted", abort_reason: "..."})
|
||||||
- 確認待ち: complete({status: "needs_user_input", missing_info: "..."})
|
- 確認待ち: complete({status: "needs_user_input", missing_info: "..."})
|
||||||
allowed_tools: [SshConsoleEnsure, SshConsoleSend, SshConsoleRun, SshConsoleSnapshot, SshUpload, SshDownload, SshListConnections, Read, Write, Bash, Glob, Grep]
|
allowed_tools: [SshConsoleEnsure, SshConsoleSend, SshConsoleRun, SshConsoleSnapshot, SshUpload, SshDownload, SshListConnections, WebSearch, WebFetch, DownloadFile, BrowseWeb, Read, Write, Bash, Glob, Grep]
|
||||||
allowed_ssh_connections: ['*']
|
allowed_ssh_connections: ['*']
|
||||||
default_next: COMPLETE
|
default_next: COMPLETE
|
||||||
rules: []
|
rules: []
|
||||||
|
|||||||
@ -63,6 +63,14 @@ movements:
|
|||||||
- `connect_timeout` / `auth_failed` 等の一時失敗: 同じ command を最大 2 回まで再試行。
|
- `connect_timeout` / `auth_failed` 等の一時失敗: 同じ command を最大 2 回まで再試行。
|
||||||
それ以上は `complete({status: "aborted", abort_reason: "..."})`
|
それ以上は `complete({status: "aborted", abort_reason: "..."})`
|
||||||
|
|
||||||
|
## 調べ物 (Web 検索)
|
||||||
|
|
||||||
|
- 不明なコマンド・オプション、エラーメッセージ、設定手順が出てきたら WebSearch で調べてよい。
|
||||||
|
ヒットしたページや公式 docs の本文は WebFetch、JS レンダリングや操作が要るページは BrowseWeb で読む
|
||||||
|
- 設定テンプレート / インストーラ / tarball を URL から取得して配信したい場合は DownloadFile で
|
||||||
|
output/ または input/ に落とし、SshUpload でリモートへ送る
|
||||||
|
- 検索結果を鵜呑みにしてリモートで破壊的・不可逆なコマンドを実行しない。出典を確認し、不安なら停止して user に確認する
|
||||||
|
|
||||||
## 成果物
|
## 成果物
|
||||||
|
|
||||||
ops の結果は output/report.md にまとめる。**機密値は記録しない**:
|
ops の結果は output/report.md にまとめる。**機密値は記録しない**:
|
||||||
@ -77,7 +85,7 @@ movements:
|
|||||||
- **次の verify へ**: `transition({next_step: "verify"})`
|
- **次の verify へ**: `transition({next_step: "verify"})`
|
||||||
- **必要情報不足で停止**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
- **必要情報不足で停止**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
||||||
- **致命的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
|
- **致命的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
|
||||||
allowed_tools: [SshExec, SshUpload, SshDownload, SshListConnections, Read, Write, Bash, Glob, Grep]
|
allowed_tools: [SshExec, SshUpload, SshDownload, SshListConnections, WebSearch, WebFetch, DownloadFile, BrowseWeb, Read, Write, Bash, Glob, Grep]
|
||||||
allowed_ssh_connections: ['*']
|
allowed_ssh_connections: ['*']
|
||||||
default_next: verify
|
default_next: verify
|
||||||
rules:
|
rules:
|
||||||
|
|||||||
313
src/bridge/console-session-api.test.ts
Normal file
313
src/bridge/console-session-api.test.ts
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
/**
|
||||||
|
* Tests for POST /api/local/tasks/:taskId/console/session — the
|
||||||
|
* user-initiated SSH console session-open endpoint (Task 2).
|
||||||
|
*
|
||||||
|
* Strategy: mirror the hermetic stub-subsystem approach from
|
||||||
|
* ssh-console.test.ts. We do NOT dial real SSH. The shell-open collaborator
|
||||||
|
* (`openShellChannel`) is faked to return a channel/client/fingerprint, and a
|
||||||
|
* real `SessionRegistry` is used so we can assert `registry.get(taskId)`
|
||||||
|
* actually returns a session after a successful open. The access gate
|
||||||
|
* (preflight + accessResolver + host-key check) runs for real inside the
|
||||||
|
* shared `openConsoleSession` core the endpoint calls — the test only fakes
|
||||||
|
* the network dial, not the gate.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import express from 'express';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { createConsoleSessionRouter } from './console-ws-api.js';
|
||||||
|
import { preflight, type SshSubsystem } from '../engine/tools/ssh.js';
|
||||||
|
import { SessionRegistry } from '../ssh/console-registry.js';
|
||||||
|
import type { SimpleTask, SimpleUser } from './console-ws-api.js';
|
||||||
|
|
||||||
|
function mkConn(overrides: Partial<{ hostKeyVerifiedAt: string | null; enabled: boolean }> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'conn-1',
|
||||||
|
ownerId: 'owner-1',
|
||||||
|
label: 'test',
|
||||||
|
host: 'localhost',
|
||||||
|
port: 22,
|
||||||
|
username: 'me',
|
||||||
|
privateKeyEnc: Buffer.alloc(0),
|
||||||
|
passphraseEnc: null,
|
||||||
|
keyVersion: 1,
|
||||||
|
keyFingerprint: 'fp-key',
|
||||||
|
hostKeyType: 'ssh-ed25519',
|
||||||
|
hostKeyB64: 'aaa',
|
||||||
|
hostKeyFingerprint: 'fp',
|
||||||
|
hostKeyRecordedAt: '2026-01-01',
|
||||||
|
hostKeyVerifiedAt: overrides.hostKeyVerifiedAt === undefined ? '2026-01-01' : overrides.hostKeyVerifiedAt,
|
||||||
|
hostKeyPending: false,
|
||||||
|
hostKeyPendingB64: null,
|
||||||
|
hostKeyPendingFingerprint: null,
|
||||||
|
hostKeyPendingToken: null,
|
||||||
|
hostKeyPendingSource: null,
|
||||||
|
commandDenyPatterns: null,
|
||||||
|
commandAllowPatterns: null,
|
||||||
|
remotePathPrefix: '/',
|
||||||
|
allowRemoteUnrestricted: true,
|
||||||
|
allowPrivateAddresses: true,
|
||||||
|
enabled: overrides.enabled === undefined ? true : overrides.enabled,
|
||||||
|
disabledByAdmin: false,
|
||||||
|
disabledByAdminReason: null,
|
||||||
|
disabledByAdminAt: null,
|
||||||
|
disabledByAdminUserId: null,
|
||||||
|
createdAt: '2026-01-01',
|
||||||
|
updatedAt: '2026-01-01',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Harness {
|
||||||
|
sub: SshSubsystem;
|
||||||
|
registry: SessionRegistry;
|
||||||
|
openShellChannel: ReturnType<typeof vi.fn>;
|
||||||
|
closeForTaskSpy: ReturnType<typeof vi.fn>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mkSub(opts: {
|
||||||
|
conn?: ReturnType<typeof mkConn>;
|
||||||
|
accessAllowed?: boolean;
|
||||||
|
accessReason?: string;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
} = {}): Harness {
|
||||||
|
const conn = opts.conn ?? mkConn();
|
||||||
|
const audit = {
|
||||||
|
beginAndComplete: vi.fn().mockReturnValue(1),
|
||||||
|
begin: vi.fn().mockReturnValue(1),
|
||||||
|
complete: vi.fn(),
|
||||||
|
listAuditRows: vi.fn(),
|
||||||
|
pruneOlderThan: vi.fn(),
|
||||||
|
promotePendingToAborted: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const registry = new SessionRegistry({
|
||||||
|
idleTimeoutMs: 60_000,
|
||||||
|
maxSessionDurationMs: 600_000,
|
||||||
|
maxSessionsPerConnection: 3,
|
||||||
|
});
|
||||||
|
// Wrap closeForTask so we can assert force-replace behavior without
|
||||||
|
// depending on a real channel teardown.
|
||||||
|
const closeForTaskSpy = vi.fn(registry.closeForTask.bind(registry));
|
||||||
|
(registry as any).closeForTask = closeForTaskSpy;
|
||||||
|
|
||||||
|
const connectionRepo = {
|
||||||
|
resolveConnection: vi.fn().mockReturnValue(conn),
|
||||||
|
};
|
||||||
|
const abuseRepo = {
|
||||||
|
isLocked: vi.fn().mockReturnValue({ locked: false }),
|
||||||
|
checkAndRecordFailure: vi.fn(),
|
||||||
|
recordSuccess: vi.fn(),
|
||||||
|
};
|
||||||
|
const accessResolver = {
|
||||||
|
resolveAccess: vi.fn().mockReturnValue(
|
||||||
|
opts.accessAllowed === false
|
||||||
|
? { allowed: false, reason: opts.accessReason ?? 'no_grant' }
|
||||||
|
: { allowed: true },
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const channel = { write: vi.fn(), end: vi.fn(), setWindow: vi.fn(), on: vi.fn() };
|
||||||
|
const client = { end: vi.fn(), on: vi.fn() };
|
||||||
|
const openShellChannel = vi.fn().mockResolvedValue({
|
||||||
|
channel,
|
||||||
|
client,
|
||||||
|
hostFingerprint: 'SHA256:fake',
|
||||||
|
});
|
||||||
|
|
||||||
|
const sub = {
|
||||||
|
connectionRepo,
|
||||||
|
auditRepo: audit,
|
||||||
|
abuseRepo,
|
||||||
|
accessResolver,
|
||||||
|
sessionRegistry: registry,
|
||||||
|
openShellChannel,
|
||||||
|
getUserAccess: () => ({ isAdmin: opts.isAdmin ?? false, orgIds: [] }),
|
||||||
|
decryptKeyMaterial: () => Buffer.alloc(0),
|
||||||
|
decryptPassphrase: () => null,
|
||||||
|
sshExec: vi.fn(),
|
||||||
|
sshUpload: vi.fn(),
|
||||||
|
sshDownload: vi.fn(),
|
||||||
|
maintenance: { isActive: () => false, snapshot: () => ({ active: false }), enter: () => {}, exit: () => {} } as SshSubsystem['maintenance'],
|
||||||
|
config: {
|
||||||
|
enabled: true,
|
||||||
|
allowPrivateAddresses: true,
|
||||||
|
callTimeoutSeconds: 30,
|
||||||
|
maxOutputBytes: 1024,
|
||||||
|
maxUploadSizeMb: 10,
|
||||||
|
maxDownloadSizeMb: 10,
|
||||||
|
auditRetentionDays: 90,
|
||||||
|
adminBypassesGrants: true,
|
||||||
|
abuseWindowMinutes: 10,
|
||||||
|
abuseFailureThreshold: 5,
|
||||||
|
abuseLockMinutes: 30,
|
||||||
|
console: {
|
||||||
|
enabled: true,
|
||||||
|
idleTimeoutSeconds: 60,
|
||||||
|
maxSessionDurationSeconds: 600,
|
||||||
|
scrollbackBytes: 4096,
|
||||||
|
maxSessionsPerConnection: 3,
|
||||||
|
maxInputBytesPerSend: 1024,
|
||||||
|
autoInjectScreenLines: 24,
|
||||||
|
defaultCols: 80,
|
||||||
|
defaultRows: 24,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as SshSubsystem;
|
||||||
|
|
||||||
|
return { sub, registry, openShellChannel, closeForTaskSpy };
|
||||||
|
}
|
||||||
|
|
||||||
|
const OWNER: SimpleUser = { id: 'owner-1', role: 'user' };
|
||||||
|
|
||||||
|
function mkTask(overrides: Partial<SimpleTask> = {}): SimpleTask {
|
||||||
|
return {
|
||||||
|
id: '1',
|
||||||
|
ownerId: 'owner-1',
|
||||||
|
visibility: 'private',
|
||||||
|
pieceName: 'ssh-console',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApp(opts: {
|
||||||
|
sub: SshSubsystem;
|
||||||
|
user?: SimpleUser | null;
|
||||||
|
resolveTask?: (id: string, user: SimpleUser) => Promise<SimpleTask | null>;
|
||||||
|
}) {
|
||||||
|
const app = express();
|
||||||
|
const user = opts.user === undefined ? OWNER : opts.user;
|
||||||
|
if (user) {
|
||||||
|
app.use((req, _res, next) => { (req as any).user = user; next(); });
|
||||||
|
}
|
||||||
|
app.use(
|
||||||
|
'/api',
|
||||||
|
express.json(),
|
||||||
|
createConsoleSessionRouter({
|
||||||
|
sub: opts.sub,
|
||||||
|
preflight,
|
||||||
|
requireAuth: (_req: any, _res: any, next: any) => next(),
|
||||||
|
resolveTask: opts.resolveTask ?? (async () => mkTask()),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /api/local/tasks/:taskId/console/session', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it('connection owner → 200 and a session is registered for the task', async () => {
|
||||||
|
const h = mkSub();
|
||||||
|
const app = buildApp({ sub: h.sub });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/local/tasks/1/console/session')
|
||||||
|
.send({ connection_id: 'conn-1' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.ok).toBe(true);
|
||||||
|
expect(res.body.connection_id).toBe('conn-1');
|
||||||
|
expect(h.openShellChannel).toHaveBeenCalledTimes(1);
|
||||||
|
// The real registry now holds a live session keyed by the task id.
|
||||||
|
expect(h.registry.get('1')).toBeTruthy();
|
||||||
|
expect(h.registry.get('1')!.connectionId).toBe('conn-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non-owner without grant → 403 no_grant', async () => {
|
||||||
|
const h = mkSub({ accessAllowed: false, accessReason: 'no_grant' });
|
||||||
|
const app = buildApp({
|
||||||
|
sub: h.sub,
|
||||||
|
user: { id: 'someone-else', role: 'user' },
|
||||||
|
});
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/local/tasks/1/console/session')
|
||||||
|
.send({ connection_id: 'conn-1' });
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error).toBe('no_grant');
|
||||||
|
expect(h.openShellChannel).not.toHaveBeenCalled();
|
||||||
|
expect(h.registry.get('1')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('host key not verified → 409 host_key_not_verified', async () => {
|
||||||
|
const h = mkSub({ conn: mkConn({ hostKeyVerifiedAt: null }) });
|
||||||
|
const app = buildApp({ sub: h.sub });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/local/tasks/1/console/session')
|
||||||
|
.send({ connection_id: 'conn-1' });
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(res.body.error).toBe('host_key_not_verified');
|
||||||
|
expect(h.openShellChannel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('task not visible to the user → 404 task_not_found', async () => {
|
||||||
|
const h = mkSub();
|
||||||
|
const app = buildApp({ sub: h.sub, resolveTask: async () => null });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/local/tasks/999/console/session')
|
||||||
|
.send({ connection_id: 'conn-1' });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.error).toBe('task_not_found');
|
||||||
|
expect(h.openShellChannel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('already-active same connection → 200 already_active:true (no re-dial)', async () => {
|
||||||
|
const h = mkSub();
|
||||||
|
const app = buildApp({ sub: h.sub });
|
||||||
|
// First open establishes the session.
|
||||||
|
const first = await request(app)
|
||||||
|
.post('/api/local/tasks/1/console/session')
|
||||||
|
.send({ connection_id: 'conn-1' });
|
||||||
|
expect(first.status).toBe(200);
|
||||||
|
expect(h.openShellChannel).toHaveBeenCalledTimes(1);
|
||||||
|
// Second open on the same connection reuses it.
|
||||||
|
const second = await request(app)
|
||||||
|
.post('/api/local/tasks/1/console/session')
|
||||||
|
.send({ connection_id: 'conn-1' });
|
||||||
|
expect(second.status).toBe(200);
|
||||||
|
expect(second.body.ok).toBe(true);
|
||||||
|
expect(second.body.already_active).toBe(true);
|
||||||
|
// Still only one dial total.
|
||||||
|
expect(h.openShellChannel).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('already-active different connection without force_replace → 409, with it → 200', async () => {
|
||||||
|
const h = mkSub();
|
||||||
|
// conn-1 resolves first; for the swap, return conn-2.
|
||||||
|
const conn2 = mkConn();
|
||||||
|
(conn2 as any).id = 'conn-2';
|
||||||
|
const app = buildApp({ sub: h.sub });
|
||||||
|
|
||||||
|
// Open conn-1.
|
||||||
|
const first = await request(app)
|
||||||
|
.post('/api/local/tasks/1/console/session')
|
||||||
|
.send({ connection_id: 'conn-1' });
|
||||||
|
expect(first.status).toBe(200);
|
||||||
|
expect(h.openShellChannel).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// conn-2 now resolves for the swap attempts.
|
||||||
|
(h.sub.connectionRepo.resolveConnection as any).mockReturnValue(conn2);
|
||||||
|
|
||||||
|
// Swap without force → 409 connection_change_requires_force.
|
||||||
|
const noForce = await request(app)
|
||||||
|
.post('/api/local/tasks/1/console/session')
|
||||||
|
.send({ connection_id: 'conn-2' });
|
||||||
|
expect(noForce.status).toBe(409);
|
||||||
|
expect(noForce.body.error).toBe('connection_change_requires_force');
|
||||||
|
expect(h.openShellChannel).toHaveBeenCalledTimes(1); // no new dial
|
||||||
|
|
||||||
|
// Swap with force → 200, old closed, new dialed.
|
||||||
|
const force = await request(app)
|
||||||
|
.post('/api/local/tasks/1/console/session')
|
||||||
|
.send({ connection_id: 'conn-2', force_replace: true });
|
||||||
|
expect(force.status).toBe(200);
|
||||||
|
expect(force.body.ok).toBe(true);
|
||||||
|
expect(h.closeForTaskSpy).toHaveBeenCalledWith('1', 'connection_change');
|
||||||
|
expect(h.openShellChannel).toHaveBeenCalledTimes(2);
|
||||||
|
expect(h.registry.get('1')!.connectionId).toBe('conn-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('missing connection_id → 400', async () => {
|
||||||
|
const h = mkSub();
|
||||||
|
const app = buildApp({ sub: h.sub });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/local/tasks/1/console/session')
|
||||||
|
.send({});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toBe('missing_connection_id');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -7,6 +7,8 @@ import type { SessionRegistry } from '../ssh/console-registry.js';
|
|||||||
import type { ConsoleSession } from '../ssh/console-session.js';
|
import type { ConsoleSession } from '../ssh/console-session.js';
|
||||||
import type { AttachMessage, ServerTextMessage } from '../ssh/console-protocol.js';
|
import type { AttachMessage, ServerTextMessage } from '../ssh/console-protocol.js';
|
||||||
import { checkConsoleInput } from '../ssh/console-deny-check.js';
|
import { checkConsoleInput } from '../ssh/console-deny-check.js';
|
||||||
|
import type { OpenConsoleDeps, OpenConsoleResult } from '../engine/tools/ssh-console.js';
|
||||||
|
import { openConsoleSession } from '../engine/tools/ssh-console.js';
|
||||||
|
|
||||||
export interface SimpleUser { id: string; role: 'admin' | 'user' | string }
|
export interface SimpleUser { id: string; role: 'admin' | 'user' | string }
|
||||||
export interface SimpleTask { id: string; ownerId: string; visibility: string; pieceName: string }
|
export interface SimpleTask { id: string; ownerId: string; visibility: string; pieceName: string }
|
||||||
@ -289,3 +291,148 @@ export function createConsoleStatusRouter(deps: {
|
|||||||
);
|
);
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map an `OpenConsoleResult.error` code to an HTTP status. The endpoint
|
||||||
|
* surfaces the same structured `error` code string in the JSON body so the
|
||||||
|
* UI can map it to a localized message. Unknown codes default to 400 (caller
|
||||||
|
* error) except the internal failure codes which default to 500.
|
||||||
|
*/
|
||||||
|
function statusForOpenError(error: string | undefined): number {
|
||||||
|
switch (error) {
|
||||||
|
case 'connection_not_found':
|
||||||
|
return 404;
|
||||||
|
case 'no_grant':
|
||||||
|
return 403;
|
||||||
|
case 'host_key_not_verified':
|
||||||
|
case 'host_key_mismatch':
|
||||||
|
case 'connection_change_requires_force':
|
||||||
|
case 'abuse_locked':
|
||||||
|
case 'connection_disabled':
|
||||||
|
return 409;
|
||||||
|
case 'decrypt_failed':
|
||||||
|
case 'open_shell_failed':
|
||||||
|
return 500;
|
||||||
|
// missing_connection_id / missing_task_context / no_user_context /
|
||||||
|
// piece_not_configured / piece_not_allowed / preflight_denied and any
|
||||||
|
// other unexpected code → 400 (the request was malformed or denied at a
|
||||||
|
// layer that maps cleanly onto a bad-request response).
|
||||||
|
default:
|
||||||
|
return 400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The preflight helper (`preflight` from engine/tools/ssh.ts) returns a flat
|
||||||
|
* `preflight_denied` for several distinct denials (no grant, abuse lock,
|
||||||
|
* disabled connection, connection not found). The REST contract wants the
|
||||||
|
* specific codes, so the handler re-derives them from the human-readable
|
||||||
|
* message that `openConsoleSession` mirrors into `result.message`. This is a
|
||||||
|
* best-effort refinement layered ON TOP of the real gate — the gate itself
|
||||||
|
* (inside openConsoleSession/preflight) is authoritative and unchanged; we
|
||||||
|
* never widen access here, only narrow a generic 400 into a more specific
|
||||||
|
* 4xx for the UI.
|
||||||
|
*/
|
||||||
|
function refineErrorCode(result: OpenConsoleResult): string {
|
||||||
|
const code = result.error ?? 'unknown';
|
||||||
|
if (code !== 'preflight_denied') return code;
|
||||||
|
// Match the exact human-readable strings `preflight` (engine/tools/ssh.ts)
|
||||||
|
// produces for each denial reason. Order matters: 'disabled' and 'locked'
|
||||||
|
// are checked before the generic access-denied so a disabled/locked
|
||||||
|
// connection isn't mislabeled as no_grant.
|
||||||
|
const msg = (result.message ?? '').toLowerCase();
|
||||||
|
if (msg.includes('does not exist')) return 'connection_not_found';
|
||||||
|
if (msg.includes('is disabled')) return 'connection_disabled';
|
||||||
|
if (msg.includes('temporarily locked')) return 'abuse_locked';
|
||||||
|
if (msg.includes('access denied')) return 'no_grant';
|
||||||
|
return 'preflight_denied';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST router exposing POST /local/tasks/:taskId/console/session.
|
||||||
|
*
|
||||||
|
* Lets a user open an SSH console PTY session themselves from a task's
|
||||||
|
* Console tab. The session is keyed by localTaskId, so the WS/xterm and the
|
||||||
|
* AI console tools share it automatically. The handler runs the SAME gate as
|
||||||
|
* the agent-facing SshConsoleEnsure tool: it calls the shared
|
||||||
|
* `openConsoleSession` core, which runs the full preflight (piece membership
|
||||||
|
* / access decision / enabled / abuse / host-key) against the task's piece
|
||||||
|
* name. `allowedConnections: ['*']` is passed because the per-piece
|
||||||
|
* allowed-list is an agent-prompt concept; the authoritative gate is the
|
||||||
|
* access resolver against `task.pieceName`, which still runs inside the core.
|
||||||
|
*/
|
||||||
|
export function createConsoleSessionRouter(deps: {
|
||||||
|
sub: OpenConsoleDeps['sub'];
|
||||||
|
preflight: OpenConsoleDeps['preflight'];
|
||||||
|
requireAuth: any;
|
||||||
|
resolveTask: (taskId: string, user: SimpleUser) => Promise<SimpleTask | null>;
|
||||||
|
}): Router {
|
||||||
|
const r = Router();
|
||||||
|
r.post(
|
||||||
|
'/local/tasks/:taskId/console/session',
|
||||||
|
deps.requireAuth,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const taskId = req.params.taskId!;
|
||||||
|
const user = (req.user as SimpleUser | undefined) ?? null;
|
||||||
|
if (!user) {
|
||||||
|
res.status(401).json({ error: 'unauthenticated' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = await deps.resolveTask(taskId, user);
|
||||||
|
if (!task) {
|
||||||
|
// Missing OR not visible to this user → opaque 404 (don't leak
|
||||||
|
// task existence across the visibility boundary).
|
||||||
|
res.status(404).json({ error: 'task_not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (req.body ?? {}) as {
|
||||||
|
connection_id?: unknown;
|
||||||
|
cols?: unknown;
|
||||||
|
rows?: unknown;
|
||||||
|
force_replace?: unknown;
|
||||||
|
};
|
||||||
|
const connectionId = typeof body.connection_id === 'string' ? body.connection_id : '';
|
||||||
|
if (!connectionId) {
|
||||||
|
res.status(400).json({ error: 'missing_connection_id' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cols = typeof body.cols === 'number' ? body.cols : undefined;
|
||||||
|
const rows = typeof body.rows === 'number' ? body.rows : undefined;
|
||||||
|
|
||||||
|
const result = await openConsoleSession(
|
||||||
|
{ sub: deps.sub, preflight: deps.preflight },
|
||||||
|
{
|
||||||
|
taskId,
|
||||||
|
connectionId,
|
||||||
|
ownerId: task.ownerId || 'local',
|
||||||
|
userId: user.id,
|
||||||
|
pieceName: task.pieceName,
|
||||||
|
// Per-piece allowed-list is an agent-prompt concept; the real gate
|
||||||
|
// is the access resolver against task.pieceName inside the core.
|
||||||
|
allowedConnections: ['*'],
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
forceReplace: body.force_replace === true,
|
||||||
|
initiator: 'user',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
res.status(200).json({
|
||||||
|
ok: true,
|
||||||
|
connection_id: result.connectionId,
|
||||||
|
cols: result.cols,
|
||||||
|
rows: result.rows,
|
||||||
|
...(result.alreadyActive ? { already_active: true } : {}),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = refineErrorCode(result);
|
||||||
|
res.status(statusForOpenError(code)).json({ error: code });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|||||||
@ -88,11 +88,12 @@ import {
|
|||||||
} from '../ssh/session.js';
|
} from '../ssh/session.js';
|
||||||
import { SessionRegistry } from '../ssh/console-registry.js';
|
import { SessionRegistry } from '../ssh/console-registry.js';
|
||||||
import { createSshUserRouter, createSshAdminRouter, type SshApiDeps } from './ssh-api.js';
|
import { createSshUserRouter, createSshAdminRouter, type SshApiDeps } from './ssh-api.js';
|
||||||
import { setSshSubsystem } from '../engine/tools/ssh.js';
|
import { setSshSubsystem, preflight as sshPreflight, type SshSubsystem } from '../engine/tools/ssh.js';
|
||||||
import { __setActiveSessionLookup } from '../engine/agent-loop.js';
|
import { __setActiveSessionLookup } from '../engine/agent-loop.js';
|
||||||
import {
|
import {
|
||||||
attachConsoleWs,
|
attachConsoleWs,
|
||||||
createConsoleStatusRouter,
|
createConsoleStatusRouter,
|
||||||
|
createConsoleSessionRouter,
|
||||||
type SimpleTask,
|
type SimpleTask,
|
||||||
type SimpleUser,
|
type SimpleUser,
|
||||||
} from './console-ws-api.js';
|
} from './console-ws-api.js';
|
||||||
@ -629,7 +630,11 @@ export function createCoreServer(opts: CoreServerOptions): {
|
|||||||
// SshDownload tools can access the same repos / session primitives /
|
// SshDownload tools can access the same repos / session primitives /
|
||||||
// crypto wrappers that the HTTP layer uses. sessionRegistry is
|
// crypto wrappers that the HTTP layer uses. sessionRegistry is
|
||||||
// constructed above (hoisted so sshDeps.onAccessRevoked can use it).
|
// constructed above (hoisted so sshDeps.onAccessRevoked can use it).
|
||||||
setSshSubsystem({
|
// Captured in a local const (not just passed to setSshSubsystem)
|
||||||
|
// so the user-initiated console-session REST endpoint can call the
|
||||||
|
// shared openConsoleSession core with the EXACT same `sub` the
|
||||||
|
// agent-facing console tools use — no second SshSubsystem.
|
||||||
|
const sshSubsystem: SshSubsystem = {
|
||||||
connectionRepo,
|
connectionRepo,
|
||||||
auditRepo,
|
auditRepo,
|
||||||
abuseRepo,
|
abuseRepo,
|
||||||
@ -651,7 +656,8 @@ export function createCoreServer(opts: CoreServerOptions): {
|
|||||||
config: sshConfig,
|
config: sshConfig,
|
||||||
sessionRegistry,
|
sessionRegistry,
|
||||||
openShellChannel,
|
openShellChannel,
|
||||||
});
|
};
|
||||||
|
setSshSubsystem(sshSubsystem);
|
||||||
|
|
||||||
// Phase 4 (SSH Console): wire the registry into agent-loop so
|
// Phase 4 (SSH Console): wire the registry into agent-loop so
|
||||||
// buildSystemPrompt can auto-inject the live screen tail into
|
// buildSystemPrompt can auto-inject the live screen tail into
|
||||||
@ -751,6 +757,21 @@ export function createCoreServer(opts: CoreServerOptions): {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// REST user-initiated session-open endpoint:
|
||||||
|
// POST /api/local/tasks/:taskId/console/session. Reuses the same
|
||||||
|
// SshSubsystem + preflight the console tools use; the access gate
|
||||||
|
// runs inside openConsoleSession against task.pieceName.
|
||||||
|
app.use(
|
||||||
|
'/api',
|
||||||
|
express.json(),
|
||||||
|
createConsoleSessionRouter({
|
||||||
|
sub: sshSubsystem,
|
||||||
|
preflight: sshPreflight,
|
||||||
|
requireAuth: authActive ? requireAuth : (_req: Request, _res: Response, next: NextFunction) => next(),
|
||||||
|
resolveTask: consoleDeps.resolveTask,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Phase 6 (SSH Console): admin list + kill endpoints. The
|
// Phase 6 (SSH Console): admin list + kill endpoints. The
|
||||||
// `/api/admin` prefix already has `express.json()` mounted above
|
// `/api/admin` prefix already has `express.json()` mounted above
|
||||||
// (see Admin user management API), so POST bodies parse correctly.
|
// (see Admin user management API), so POST bodies parse correctly.
|
||||||
|
|||||||
@ -32,6 +32,319 @@ import { logger } from '../../logger.js';
|
|||||||
import { getSshSubsystem, preflight, type SshSubsystem } from './ssh.js';
|
import { getSshSubsystem, preflight, type SshSubsystem } from './ssh.js';
|
||||||
import { makeNonce, makeMarkerCommand, parseMarker, extractOutput, detectWaitingForInput, shouldGuardInterrupt } from './console-run-lib.js';
|
import { makeNonce, makeMarkerCommand, parseMarker, extractOutput, detectWaitingForInput, shouldGuardInterrupt } from './console-run-lib.js';
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// openConsoleSession — the shared find-or-open core
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Extracted from the SshConsoleEnsure tool so a future HTTP endpoint can
|
||||||
|
// create a session WITHOUT fabricating a ToolContext. The tool wrapper
|
||||||
|
// (ensureSessionInternal) builds OpenConsoleParams from its ToolContext
|
||||||
|
// (initiator: 'agent') and maps the structured result back to the tool's
|
||||||
|
// existing return/error shape. Every preflight gate, host-key check,
|
||||||
|
// disabled/abuse check, key decryption, shell-channel open, session
|
||||||
|
// register, per-connection cap, and the `ssh.console.open` audit are
|
||||||
|
// preserved verbatim — in the same order, with the same semantics.
|
||||||
|
|
||||||
|
/** Explicit collaborators the find-or-open core needs. `sub` carries the
|
||||||
|
* sessionRegistry, connectionRepo, access resolver, key decryptors, audit
|
||||||
|
* repo, abuse repo, config (cols/rows defaults + caps) and openShellChannel.
|
||||||
|
* `preflight` is threaded explicitly (it is the shared access/state gate). */
|
||||||
|
export interface OpenConsoleDeps {
|
||||||
|
sub: SshSubsystem;
|
||||||
|
preflight: typeof preflight;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenConsoleParams {
|
||||||
|
taskId: string;
|
||||||
|
connectionId: string;
|
||||||
|
/** Connection-resolution / audit owner (job.ownerId ?? 'local' upstream). */
|
||||||
|
ownerId: string | null;
|
||||||
|
/** The acting principal (startedByUserId). */
|
||||||
|
userId: string;
|
||||||
|
/** For the grant check (access resolver matches against this piece). */
|
||||||
|
pieceName: string;
|
||||||
|
/** Piece allowed_ssh_connections (['*'] for user-initiated). */
|
||||||
|
allowedConnections: string[];
|
||||||
|
cols?: number;
|
||||||
|
rows?: number;
|
||||||
|
forceReplace?: boolean;
|
||||||
|
/** Audit marker — distinguishes human-opened from agent-opened sessions. */
|
||||||
|
initiator: 'agent' | 'user';
|
||||||
|
/** Optional job id for the audit rows (agent path passes ctx.jobId). */
|
||||||
|
jobId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenConsoleResult {
|
||||||
|
ok: boolean;
|
||||||
|
/** True if a live session already existed and was reused (no open). */
|
||||||
|
alreadyActive?: boolean;
|
||||||
|
connectionId?: string;
|
||||||
|
cols?: number;
|
||||||
|
rows?: number;
|
||||||
|
/** Structured error code on failure. */
|
||||||
|
error?:
|
||||||
|
| 'no_grant'
|
||||||
|
| 'host_key_not_verified'
|
||||||
|
| 'host_key_mismatch'
|
||||||
|
| 'connection_change_requires_force'
|
||||||
|
| 'abuse_locked'
|
||||||
|
| 'connection_disabled'
|
||||||
|
| 'connection_not_found'
|
||||||
|
| 'missing_connection_id'
|
||||||
|
| 'missing_task_context'
|
||||||
|
| 'no_user_context'
|
||||||
|
| 'piece_not_configured'
|
||||||
|
| 'piece_not_allowed'
|
||||||
|
| 'preflight_denied'
|
||||||
|
| 'decrypt_failed'
|
||||||
|
| 'open_shell_failed';
|
||||||
|
/** Human/LLM-readable message (mirrors the tool's existing error strings). */
|
||||||
|
message?: string;
|
||||||
|
/** The live session on success (consumed by the tool wrapper; not serialized). */
|
||||||
|
session?: ConsoleSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find-or-open a ConsoleSession bound to (taskId, connectionId). Runs the
|
||||||
|
* full preflight (piece membership, access decision, enabled / abuse /
|
||||||
|
* host-key state), decrypts key material, opens the shell channel, builds +
|
||||||
|
* registers the ConsoleSession, enforces the per-connection cap, and writes
|
||||||
|
* the `ssh.console.open` audit (with `initiator` in the detail).
|
||||||
|
*
|
||||||
|
* Returns a structured OpenConsoleResult. On the happy path / reuse,
|
||||||
|
* `ok: true` with `session` set; on any gate failure, `ok: false` with a
|
||||||
|
* structured `error` code + `message`.
|
||||||
|
*/
|
||||||
|
export async function openConsoleSession(
|
||||||
|
deps: OpenConsoleDeps,
|
||||||
|
params: OpenConsoleParams,
|
||||||
|
): Promise<OpenConsoleResult> {
|
||||||
|
const { sub } = deps;
|
||||||
|
const connectionId = params.connectionId;
|
||||||
|
if (!connectionId) {
|
||||||
|
return { ok: false, error: 'missing_connection_id', message: 'SshConsoleEnsure: connection_id is required.' };
|
||||||
|
}
|
||||||
|
const localTaskId = params.taskId ?? '';
|
||||||
|
if (!localTaskId) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'missing_task_context',
|
||||||
|
message: 'SshConsoleEnsure: this tool requires a local task context (ctx.taskId).',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a session already exists for this task, branch on whether it's the
|
||||||
|
// same connection. Same → reuse. Different → reject by default (so a
|
||||||
|
// single LLM connection_id slip can't kill the user's live shell), opt
|
||||||
|
// into the swap with force_replace=true.
|
||||||
|
const existing = sub.sessionRegistry.get(localTaskId);
|
||||||
|
if (existing) {
|
||||||
|
if (existing.connectionId === connectionId) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
alreadyActive: true,
|
||||||
|
connectionId: existing.connectionId,
|
||||||
|
cols: existing.cols,
|
||||||
|
rows: existing.rows,
|
||||||
|
session: existing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const forceReplace = params.forceReplace === true;
|
||||||
|
if (!forceReplace) {
|
||||||
|
const ageSec = Math.max(0, Math.floor((Date.now() - existing.startedAt) / 1000));
|
||||||
|
const idleSec = Math.max(0, Math.floor((Date.now() - existing.lastActivityAt) / 1000));
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'connection_change_requires_force',
|
||||||
|
connectionId: existing.connectionId,
|
||||||
|
message:
|
||||||
|
`SshConsoleEnsure: this task already has an active session on connection ${existing.connectionId} ` +
|
||||||
|
`(age=${ageSec}s, last_activity=${idleSec}s ago). ` +
|
||||||
|
`Use connection_id="${existing.connectionId}" to continue working in the existing shell, ` +
|
||||||
|
`or pass force_replace=true to close it and open a new session on ${connectionId}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await sub.sessionRegistry.closeForTask(localTaskId, 'connection_change');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the minimal ToolContext-shaped object the shared `preflight`
|
||||||
|
// helper reads (userId, ownerId, pieceName, allowedSshConnections, jobId).
|
||||||
|
// The HTTP caller never constructs a ToolContext — params are explicit and
|
||||||
|
// this synthesis is internal to the shared core.
|
||||||
|
const preCtx = {
|
||||||
|
workspacePath: '',
|
||||||
|
editAllowed: false,
|
||||||
|
taskId: localTaskId,
|
||||||
|
userId: params.userId,
|
||||||
|
ownerId: params.ownerId,
|
||||||
|
jobId: params.jobId ?? undefined,
|
||||||
|
pieceName: params.pieceName,
|
||||||
|
allowedSshConnections: params.allowedConnections,
|
||||||
|
} as ToolContext;
|
||||||
|
|
||||||
|
// Full 12-step preflight (same path as SshExec).
|
||||||
|
const pre = deps.preflight({
|
||||||
|
toolName: 'SshExec',
|
||||||
|
connectionId,
|
||||||
|
ctx: preCtx,
|
||||||
|
sub,
|
||||||
|
auditAction: 'ssh.console.open',
|
||||||
|
});
|
||||||
|
if (!pre.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'preflight_denied',
|
||||||
|
message: pre.error.output,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { connection, actingUserId, pieceName } = pre;
|
||||||
|
|
||||||
|
// Console requires a verified host key — there is no LLM-actionable
|
||||||
|
// recovery from first_observe / mismatch on a long-lived shell.
|
||||||
|
if (connection.hostKeyVerifiedAt === null) {
|
||||||
|
sub.auditRepo.beginAndComplete(
|
||||||
|
{
|
||||||
|
action: 'ssh.console.open',
|
||||||
|
connectionId,
|
||||||
|
ownerId: connection.ownerId,
|
||||||
|
actingUserId,
|
||||||
|
pieceName,
|
||||||
|
jobId: preCtx.jobId ?? undefined,
|
||||||
|
detail: { reason: 'host_key_not_verified', initiator: params.initiator },
|
||||||
|
},
|
||||||
|
'denied',
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'host_key_not_verified',
|
||||||
|
message: `SshConsoleEnsure: host key for connection ${connectionId} is not user-verified. Run SshExec first to surface the verify prompt.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cols = typeof params.cols === 'number' && params.cols > 0 ? Math.floor(params.cols) : sub.config.console.defaultCols;
|
||||||
|
const rows = typeof params.rows === 'number' && params.rows > 0 ? Math.floor(params.rows) : sub.config.console.defaultRows;
|
||||||
|
|
||||||
|
// Decrypt key material — same flow as SshExec; we clear on failure but
|
||||||
|
// keep alive past this call because the ssh2 Client needs the PEM through
|
||||||
|
// the entire session. ConsoleSession.close() does NOT clear these
|
||||||
|
// buffers (it can't see them) — we accept that the PEM stays in memory
|
||||||
|
// for the lifetime of the session, which already holds the decrypted
|
||||||
|
// channel and host connection state.
|
||||||
|
let pemBuf: Buffer | null = null;
|
||||||
|
let passBuf: Buffer | null = null;
|
||||||
|
try {
|
||||||
|
pemBuf = sub.decryptKeyMaterial(connection.ownerId, connection.privateKeyEnc);
|
||||||
|
passBuf = sub.decryptPassphrase(connection.ownerId, connection.passphraseEnc);
|
||||||
|
} catch (e) {
|
||||||
|
if (pemBuf) clearBuffer(pemBuf);
|
||||||
|
if (passBuf) clearBuffer(passBuf);
|
||||||
|
sub.auditRepo.beginAndComplete(
|
||||||
|
{
|
||||||
|
action: 'ssh.console.open',
|
||||||
|
connectionId,
|
||||||
|
ownerId: connection.ownerId,
|
||||||
|
actingUserId,
|
||||||
|
pieceName,
|
||||||
|
jobId: preCtx.jobId ?? undefined,
|
||||||
|
detail: { reason: 'decrypt_failed', msg: (e as Error).message, initiator: params.initiator },
|
||||||
|
},
|
||||||
|
'failed',
|
||||||
|
);
|
||||||
|
return { ok: false, error: 'decrypt_failed', message: 'SshConsoleEnsure: failed to decrypt stored key material.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the channel. On failure clear the PEM and bail.
|
||||||
|
let channel: import('ssh2').ClientChannel;
|
||||||
|
let client: import('ssh2').Client;
|
||||||
|
let hostFingerprint: string;
|
||||||
|
try {
|
||||||
|
const shellResult = await sub.openShellChannel({
|
||||||
|
connection: {
|
||||||
|
id: connection.id,
|
||||||
|
ownerId: connection.ownerId,
|
||||||
|
host: connection.host,
|
||||||
|
port: connection.port,
|
||||||
|
username: connection.username,
|
||||||
|
privateKeyPem: pemBuf,
|
||||||
|
passphrase: passBuf ?? undefined,
|
||||||
|
hostKeyB64: connection.hostKeyB64,
|
||||||
|
hostKeyVerified: true,
|
||||||
|
allowPrivate: sub.config.allowPrivateAddresses || connection.allowPrivateAddresses,
|
||||||
|
},
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
timeoutMs: sub.config.callTimeoutSeconds * 1000,
|
||||||
|
});
|
||||||
|
channel = shellResult.channel;
|
||||||
|
client = shellResult.client;
|
||||||
|
hostFingerprint = shellResult.hostFingerprint;
|
||||||
|
} catch (e) {
|
||||||
|
clearBuffer(pemBuf);
|
||||||
|
clearBuffer(passBuf);
|
||||||
|
sub.abuseRepo.checkAndRecordFailure({
|
||||||
|
connectionId,
|
||||||
|
ownerId: connection.ownerId,
|
||||||
|
userId: actingUserId,
|
||||||
|
host: connection.host,
|
||||||
|
username: connection.username,
|
||||||
|
});
|
||||||
|
sub.auditRepo.beginAndComplete(
|
||||||
|
{
|
||||||
|
action: 'ssh.console.open',
|
||||||
|
connectionId,
|
||||||
|
ownerId: connection.ownerId,
|
||||||
|
actingUserId,
|
||||||
|
pieceName,
|
||||||
|
jobId: preCtx.jobId ?? undefined,
|
||||||
|
detail: { reason: 'open_shell_failed', msg: (e as Error).message, initiator: params.initiator },
|
||||||
|
},
|
||||||
|
'failed',
|
||||||
|
);
|
||||||
|
return { ok: false, error: 'open_shell_failed', message: `SshConsoleEnsure: failed to open shell channel: ${(e as Error).message}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the session and register it. From here on the channel + client
|
||||||
|
// + PEM belong to the session; we don't clear them on the happy path.
|
||||||
|
// The session ends the client (and thus releases the PEM-bound
|
||||||
|
// connection) when it closes.
|
||||||
|
const session = new ConsoleSession({
|
||||||
|
localTaskId,
|
||||||
|
connectionId,
|
||||||
|
ownerId: connection.ownerId,
|
||||||
|
startedByUserId: actingUserId,
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
scrollbackCap: sub.config.console.scrollbackBytes,
|
||||||
|
channel,
|
||||||
|
client,
|
||||||
|
auditRepo: sub.auditRepo,
|
||||||
|
});
|
||||||
|
sub.sessionRegistry.register(session);
|
||||||
|
sub.abuseRepo.recordSuccess(connectionId);
|
||||||
|
sub.auditRepo.beginAndComplete(
|
||||||
|
{
|
||||||
|
action: 'ssh.console.open',
|
||||||
|
connectionId,
|
||||||
|
ownerId: connection.ownerId,
|
||||||
|
actingUserId,
|
||||||
|
pieceName,
|
||||||
|
jobId: preCtx.jobId ?? undefined,
|
||||||
|
detail: { cols, rows, host_fingerprint: hostFingerprint, initiator: params.initiator },
|
||||||
|
},
|
||||||
|
'success',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enforce the per-connection session cap (evict oldest).
|
||||||
|
const evict = sub.sessionRegistry.enforceCap(connectionId);
|
||||||
|
for (const e of evict) {
|
||||||
|
sub.sessionRegistry.closeForTask(e.localTaskId, 'session_cap_evict').catch((err) =>
|
||||||
|
logger.warn(`[ssh-console] evict close error: ${(err as Error).message}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, alreadyActive: false, connectionId, cols, rows, session };
|
||||||
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
// Tool definitions
|
// Tool definitions
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
@ -167,6 +480,11 @@ interface EnsureResult {
|
|||||||
* Internal find-or-open helper. Returns a live ConsoleSession bound to
|
* Internal find-or-open helper. Returns a live ConsoleSession bound to
|
||||||
* (ctx.taskId, connectionId). Used by SshConsoleEnsure directly and by
|
* (ctx.taskId, connectionId). Used by SshConsoleEnsure directly and by
|
||||||
* SshConsoleSend / SshConsoleSnapshot when no session is attached yet.
|
* SshConsoleSend / SshConsoleSnapshot when no session is attached yet.
|
||||||
|
*
|
||||||
|
* Thin wrapper over the shared `openConsoleSession` core: it builds
|
||||||
|
* OpenConsoleParams from the ToolContext (initiator: 'agent') and maps the
|
||||||
|
* structured result back to the tool's existing return/error shape so the
|
||||||
|
* tool's external behavior is byte-for-byte unchanged.
|
||||||
*/
|
*/
|
||||||
async function ensureSessionInternal(
|
async function ensureSessionInternal(
|
||||||
input: Record<string, unknown>,
|
input: Record<string, unknown>,
|
||||||
@ -174,190 +492,30 @@ async function ensureSessionInternal(
|
|||||||
sub: SshSubsystem,
|
sub: SshSubsystem,
|
||||||
): Promise<EnsureResult | ToolResult> {
|
): Promise<EnsureResult | ToolResult> {
|
||||||
const connectionId = typeof input.connection_id === 'string' ? input.connection_id : '';
|
const connectionId = typeof input.connection_id === 'string' ? input.connection_id : '';
|
||||||
if (!connectionId) {
|
const cols = typeof input.cols === 'number' ? input.cols : undefined;
|
||||||
return err('SshConsoleEnsure: connection_id is required.');
|
const rows = typeof input.rows === 'number' ? input.rows : undefined;
|
||||||
}
|
|
||||||
const localTaskId = ctx.taskId ?? '';
|
|
||||||
if (!localTaskId) {
|
|
||||||
return err('SshConsoleEnsure: this tool requires a local task context (ctx.taskId).');
|
|
||||||
}
|
|
||||||
|
|
||||||
// If a session already exists for this task, branch on whether it's the
|
const result = await openConsoleSession(
|
||||||
// same connection. Same → reuse. Different → reject by default (so a
|
{ sub, preflight },
|
||||||
// single LLM connection_id slip can't kill the user's live shell), opt
|
|
||||||
// into the swap with force_replace=true.
|
|
||||||
const existing = sub.sessionRegistry.get(localTaskId);
|
|
||||||
if (existing) {
|
|
||||||
if (existing.connectionId === connectionId) {
|
|
||||||
return { opened: false, session: existing };
|
|
||||||
}
|
|
||||||
const forceReplace = input.force_replace === true;
|
|
||||||
if (!forceReplace) {
|
|
||||||
const ageSec = Math.max(0, Math.floor((Date.now() - existing.startedAt) / 1000));
|
|
||||||
const idleSec = Math.max(0, Math.floor((Date.now() - existing.lastActivityAt) / 1000));
|
|
||||||
return err(
|
|
||||||
`SshConsoleEnsure: this task already has an active session on connection ${existing.connectionId} ` +
|
|
||||||
`(age=${ageSec}s, last_activity=${idleSec}s ago). ` +
|
|
||||||
`Use connection_id="${existing.connectionId}" to continue working in the existing shell, ` +
|
|
||||||
`or pass force_replace=true to close it and open a new session on ${connectionId}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await sub.sessionRegistry.closeForTask(localTaskId, 'connection_change');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Full 12-step preflight (same path as SshExec).
|
|
||||||
const pre = preflight({
|
|
||||||
toolName: 'SshExec',
|
|
||||||
connectionId,
|
|
||||||
ctx,
|
|
||||||
sub,
|
|
||||||
auditAction: 'ssh.console.open',
|
|
||||||
});
|
|
||||||
if (!pre.ok) return pre.error;
|
|
||||||
const { connection, actingUserId, pieceName } = pre;
|
|
||||||
|
|
||||||
// Console requires a verified host key — there is no LLM-actionable
|
|
||||||
// recovery from first_observe / mismatch on a long-lived shell.
|
|
||||||
if (connection.hostKeyVerifiedAt === null) {
|
|
||||||
sub.auditRepo.beginAndComplete(
|
|
||||||
{
|
{
|
||||||
action: 'ssh.console.open',
|
taskId: ctx.taskId ?? '',
|
||||||
connectionId,
|
connectionId,
|
||||||
ownerId: connection.ownerId,
|
ownerId: ctx.ownerId ?? null,
|
||||||
actingUserId,
|
userId: (ctx.userId ?? ctx.ownerId ?? '').toString(),
|
||||||
pieceName,
|
pieceName: ctx.pieceName ?? '',
|
||||||
jobId: ctx.jobId ?? undefined,
|
allowedConnections: ctx.allowedSshConnections ?? [],
|
||||||
detail: { reason: 'host_key_not_verified' },
|
|
||||||
},
|
|
||||||
'denied',
|
|
||||||
);
|
|
||||||
return err(
|
|
||||||
`SshConsoleEnsure: host key for connection ${connectionId} is not user-verified. Run SshExec first to surface the verify prompt.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cols = typeof input.cols === 'number' && input.cols > 0 ? Math.floor(input.cols) : sub.config.console.defaultCols;
|
|
||||||
const rows = typeof input.rows === 'number' && input.rows > 0 ? Math.floor(input.rows) : sub.config.console.defaultRows;
|
|
||||||
|
|
||||||
// Decrypt key material — same flow as SshExec; we clear on failure but
|
|
||||||
// keep alive past this call because the ssh2 Client needs the PEM through
|
|
||||||
// the entire session. ConsoleSession.close() does NOT clear these
|
|
||||||
// buffers (it can't see them) — we accept that the PEM stays in memory
|
|
||||||
// for the lifetime of the session, which already holds the decrypted
|
|
||||||
// channel and host connection state.
|
|
||||||
let pemBuf: Buffer | null = null;
|
|
||||||
let passBuf: Buffer | null = null;
|
|
||||||
try {
|
|
||||||
pemBuf = sub.decryptKeyMaterial(connection.ownerId, connection.privateKeyEnc);
|
|
||||||
passBuf = sub.decryptPassphrase(connection.ownerId, connection.passphraseEnc);
|
|
||||||
} catch (e) {
|
|
||||||
if (pemBuf) clearBuffer(pemBuf);
|
|
||||||
if (passBuf) clearBuffer(passBuf);
|
|
||||||
sub.auditRepo.beginAndComplete(
|
|
||||||
{
|
|
||||||
action: 'ssh.console.open',
|
|
||||||
connectionId,
|
|
||||||
ownerId: connection.ownerId,
|
|
||||||
actingUserId,
|
|
||||||
pieceName,
|
|
||||||
jobId: ctx.jobId ?? undefined,
|
|
||||||
detail: { reason: 'decrypt_failed', msg: (e as Error).message },
|
|
||||||
},
|
|
||||||
'failed',
|
|
||||||
);
|
|
||||||
return err('SshConsoleEnsure: failed to decrypt stored key material.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open the channel. On failure clear the PEM and bail.
|
|
||||||
let channel: import('ssh2').ClientChannel;
|
|
||||||
let client: import('ssh2').Client;
|
|
||||||
let hostFingerprint: string;
|
|
||||||
try {
|
|
||||||
const shellResult = await sub.openShellChannel({
|
|
||||||
connection: {
|
|
||||||
id: connection.id,
|
|
||||||
ownerId: connection.ownerId,
|
|
||||||
host: connection.host,
|
|
||||||
port: connection.port,
|
|
||||||
username: connection.username,
|
|
||||||
privateKeyPem: pemBuf,
|
|
||||||
passphrase: passBuf ?? undefined,
|
|
||||||
hostKeyB64: connection.hostKeyB64,
|
|
||||||
hostKeyVerified: true,
|
|
||||||
allowPrivate: sub.config.allowPrivateAddresses || connection.allowPrivateAddresses,
|
|
||||||
},
|
|
||||||
cols,
|
cols,
|
||||||
rows,
|
rows,
|
||||||
timeoutMs: sub.config.callTimeoutSeconds * 1000,
|
forceReplace: input.force_replace === true,
|
||||||
});
|
initiator: 'agent',
|
||||||
channel = shellResult.channel;
|
|
||||||
client = shellResult.client;
|
|
||||||
hostFingerprint = shellResult.hostFingerprint;
|
|
||||||
} catch (e) {
|
|
||||||
clearBuffer(pemBuf);
|
|
||||||
clearBuffer(passBuf);
|
|
||||||
sub.abuseRepo.checkAndRecordFailure({
|
|
||||||
connectionId,
|
|
||||||
ownerId: connection.ownerId,
|
|
||||||
userId: actingUserId,
|
|
||||||
host: connection.host,
|
|
||||||
username: connection.username,
|
|
||||||
});
|
|
||||||
sub.auditRepo.beginAndComplete(
|
|
||||||
{
|
|
||||||
action: 'ssh.console.open',
|
|
||||||
connectionId,
|
|
||||||
ownerId: connection.ownerId,
|
|
||||||
actingUserId,
|
|
||||||
pieceName,
|
|
||||||
jobId: ctx.jobId ?? undefined,
|
jobId: ctx.jobId ?? undefined,
|
||||||
detail: { reason: 'open_shell_failed', msg: (e as Error).message },
|
|
||||||
},
|
},
|
||||||
'failed',
|
|
||||||
);
|
);
|
||||||
return err(`SshConsoleEnsure: failed to open shell channel: ${(e as Error).message}`);
|
|
||||||
|
if (!result.ok || !result.session) {
|
||||||
|
return err(result.message ?? 'SshConsoleEnsure: failed to open session.');
|
||||||
}
|
}
|
||||||
|
return { opened: !result.alreadyActive, session: result.session };
|
||||||
// Build the session and register it. From here on the channel + client
|
|
||||||
// + PEM belong to the session; we don't clear them on the happy path.
|
|
||||||
// The session ends the client (and thus releases the PEM-bound
|
|
||||||
// connection) when it closes.
|
|
||||||
const session = new ConsoleSession({
|
|
||||||
localTaskId,
|
|
||||||
connectionId,
|
|
||||||
ownerId: connection.ownerId,
|
|
||||||
startedByUserId: actingUserId,
|
|
||||||
cols,
|
|
||||||
rows,
|
|
||||||
scrollbackCap: sub.config.console.scrollbackBytes,
|
|
||||||
channel,
|
|
||||||
client,
|
|
||||||
auditRepo: sub.auditRepo,
|
|
||||||
});
|
|
||||||
sub.sessionRegistry.register(session);
|
|
||||||
sub.abuseRepo.recordSuccess(connectionId);
|
|
||||||
sub.auditRepo.beginAndComplete(
|
|
||||||
{
|
|
||||||
action: 'ssh.console.open',
|
|
||||||
connectionId,
|
|
||||||
ownerId: connection.ownerId,
|
|
||||||
actingUserId,
|
|
||||||
pieceName,
|
|
||||||
jobId: ctx.jobId ?? undefined,
|
|
||||||
detail: { cols, rows, host_fingerprint: hostFingerprint },
|
|
||||||
},
|
|
||||||
'success',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Enforce the per-connection session cap (evict oldest).
|
|
||||||
const evict = sub.sessionRegistry.enforceCap(connectionId);
|
|
||||||
for (const e of evict) {
|
|
||||||
sub.sessionRegistry.closeForTask(e.localTaskId, 'session_cap_evict').catch((err) =>
|
|
||||||
logger.warn(`[ssh-console] evict close error: ${(err as Error).message}`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { opened: true, session };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureTool(
|
async function ensureTool(
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { useLocalTaskList } from './hooks/useTaskList';
|
|||||||
import { useLocalTask, useLocalTaskComments } from './hooks/useTaskDetail';
|
import { useLocalTask, useLocalTaskComments } from './hooks/useTaskDetail';
|
||||||
import { useSubtaskActivities } from './hooks/useSubtaskActivities';
|
import { useSubtaskActivities } from './hooks/useSubtaskActivities';
|
||||||
import { useBranding } from './hooks/useBranding';
|
import { useBranding } from './hooks/useBranding';
|
||||||
import { useSwipeNav } from './hooks/useSwipeNav';
|
import { SwipeableTabs } from './components/mobile/SwipeableTabs';
|
||||||
import { useLocalStorageState } from './hooks/useLocalStorageState';
|
import { useLocalStorageState } from './hooks/useLocalStorageState';
|
||||||
import { useTaskNotifications } from './hooks/useTaskNotifications';
|
import { useTaskNotifications } from './hooks/useTaskNotifications';
|
||||||
import { DEFAULT_NOTIFY_EVENTS, type NotifyEventSettings } from './lib/notifications';
|
import { DEFAULT_NOTIFY_EVENTS, type NotifyEventSettings } from './lib/notifications';
|
||||||
@ -477,7 +477,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<MobileDetailFlow mobileTab={mobileTab} onTabChange={(id) => setUrlState(prev => ({ ...prev, mobileTab: id }))} onSwipeRightFromEdge={() => setUrlState(prev => ({ ...prev, taskId: null, mobileTab: 'chat' as MobileTabId }))} visibleTabs={mobileVisibleTabIds}>
|
<MobileDetailFlow>
|
||||||
<div className="flex-shrink-0 flex border-b border-hairline bg-canvas px-2 pt-[env(safe-area-inset-top)]">
|
<div className="flex-shrink-0 flex border-b border-hairline bg-canvas px-2 pt-[env(safe-area-inset-top)]">
|
||||||
{mobileVisibleTabs.map(({ id, label }) => (
|
{mobileVisibleTabs.map(({ id, label }) => (
|
||||||
<button
|
<button
|
||||||
@ -502,29 +502,38 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div key={mobileTab} className="flex-1 min-h-0 overflow-hidden animate-mobile-tab-swap">
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
{mobileTab === 'chat' && (
|
<SwipeableTabs
|
||||||
chatReady ? (
|
tabs={mobileVisibleTabIds}
|
||||||
<ChatPane task={localTask!} comments={localComments} onSubmit={handleComment} onCancel={handleCancel} />
|
activeTab={mobileTab}
|
||||||
) : (
|
onTabChange={(id) => setUrlState(prev => ({ ...prev, mobileTab: id }))}
|
||||||
<SkeletonChatPane />
|
onSwipeBackFromFirst={() => setUrlState(prev => ({ ...prev, taskId: null, mobileTab: 'chat' as MobileTabId }))}
|
||||||
)
|
renderTab={(id, preview) => (
|
||||||
)}
|
// While dragging, browser (noVNC iframe) / ssh (WebSocket)
|
||||||
{mobileTab !== 'chat' && localTaskId && (
|
// peek as a light placeholder; the real panel mounts on commit.
|
||||||
<LocalDetailPanel
|
preview && (id === 'browser' || id === 'ssh')
|
||||||
|
? <div className="h-full w-full flex items-center justify-center bg-canvas text-slate-400 text-sm font-medium">{id === 'browser' ? 'ブラウザ' : 'SSH'}</div>
|
||||||
|
: id === 'chat'
|
||||||
|
? (chatReady
|
||||||
|
? <ChatPane task={localTask!} comments={localComments} onSubmit={handleComment} onCancel={handleCancel} />
|
||||||
|
: <SkeletonChatPane />)
|
||||||
|
: (localTaskId
|
||||||
|
? <LocalDetailPanel
|
||||||
{...detailPanelProps({
|
{...detailPanelProps({
|
||||||
detailTab: mobileTab === 'overview' ? 'overview'
|
detailTab: id === 'overview' ? 'overview'
|
||||||
: mobileTab === 'activity' ? 'activity'
|
: id === 'activity' ? 'activity'
|
||||||
: mobileTab === 'trace' ? 'trace'
|
: id === 'trace' ? 'trace'
|
||||||
: mobileTab === 'browser' ? 'browser'
|
: id === 'browser' ? 'browser'
|
||||||
: mobileTab === 'ssh' ? 'ssh'
|
: id === 'ssh' ? 'ssh'
|
||||||
: 'files',
|
: 'files',
|
||||||
showWidthToggle: false,
|
showWidthToggle: false,
|
||||||
onTabChange: t => setUrlState(prev => ({ ...prev, mobileTab: t as MobileTabId })),
|
onTabChange: t => setUrlState(prev => ({ ...prev, mobileTab: t as MobileTabId })),
|
||||||
onClose: () => setUrlState(prev => ({ ...prev, taskId: null, mobileTab: 'chat' as MobileTabId })),
|
onClose: () => setUrlState(prev => ({ ...prev, taskId: null, mobileTab: 'chat' as MobileTabId })),
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
: null)
|
||||||
)}
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Mobile-only pet overlay. Anchored to the MobileDetailFlow
|
{/* Mobile-only pet overlay. Anchored to the MobileDetailFlow
|
||||||
wrapper (which has `relative`) so the pet stays visible
|
wrapper (which has `relative`) so the pet stays visible
|
||||||
@ -684,39 +693,12 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
|||||||
* via the tab bar still works (the swipe handler ignores touches that
|
* via the tab bar still works (the swipe handler ignores touches that
|
||||||
* start on form controls / buttons / anchors).
|
* start on form controls / buttons / anchors).
|
||||||
*/
|
*/
|
||||||
function MobileDetailFlow({
|
function MobileDetailFlow({ children }: { children: ReactNode }) {
|
||||||
mobileTab,
|
// The horizontal tab swipe now lives in <SwipeableTabs> (the content area)
|
||||||
onTabChange,
|
// so it can yield to native scroll inside wide content and follow the finger.
|
||||||
onSwipeRightFromEdge,
|
// This wrapper only provides `relative` for the app-level mobile pet overlay.
|
||||||
visibleTabs,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
mobileTab: MobileTabId;
|
|
||||||
onTabChange: (tab: MobileTabId) => void;
|
|
||||||
onSwipeRightFromEdge?: () => void;
|
|
||||||
visibleTabs: MobileTabId[];
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
const swipe = useSwipeNav({
|
|
||||||
onSwipeLeft: () => {
|
|
||||||
const idx = visibleTabs.indexOf(mobileTab);
|
|
||||||
if (idx >= 0 && idx < visibleTabs.length - 1) {
|
|
||||||
onTabChange(visibleTabs[idx + 1]);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSwipeRight: () => {
|
|
||||||
const idx = visibleTabs.indexOf(mobileTab);
|
|
||||||
if (idx > 0) {
|
|
||||||
onTabChange(visibleTabs[idx - 1]);
|
|
||||||
} else if (idx === 0) {
|
|
||||||
onSwipeRightFromEdge?.();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// `relative` is required so the app-level mobile pet overlay (rendered
|
|
||||||
// inside this wrapper) can anchor with position: absolute.
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-col h-full" {...swipe}>
|
<div className="relative flex flex-col h-full">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,14 +1,53 @@
|
|||||||
import { useRef } from 'react';
|
import { useMemo, useRef, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useConsoleSession } from '../../../hooks/useConsoleSession';
|
import { useConsoleSession } from '../../../hooks/useConsoleSession';
|
||||||
import type { ConsoleStatus } from '../../../lib/ssh-console-types';
|
import type { ConsoleStatus } from '../../../lib/ssh-console-types';
|
||||||
|
import type { SshConnection } from '../../../lib/ssh-types';
|
||||||
import { TerminalView, type TerminalViewHandle } from './console/TerminalView';
|
import { TerminalView, type TerminalViewHandle } from './console/TerminalView';
|
||||||
import { ConsoleHeader } from './console/ConsoleHeader';
|
import { ConsoleHeader } from './console/ConsoleHeader';
|
||||||
import { MobileKeyboardBar } from './console/MobileKeyboardBar';
|
import { MobileKeyboardBar } from './console/MobileKeyboardBar';
|
||||||
import { ScrollToBottomButton } from './console/ScrollToBottomButton';
|
import { ScrollToBottomButton } from './console/ScrollToBottomButton';
|
||||||
import { useViewportNarrow } from '../../layout/TopBar';
|
import { useViewportNarrow } from '../../layout/TopBar';
|
||||||
|
|
||||||
|
async function fetchConnections(): Promise<{ list: SshConnection[]; sshDisabled: boolean }> {
|
||||||
|
const res = await fetch('/api/ssh/connections', { credentials: 'include' });
|
||||||
|
if (res.status === 404) return { list: [], sshDisabled: true };
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = (await res.json()) as { connections: SshConnection[] };
|
||||||
|
return { list: data.connections ?? [], sshDisabled: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a structured `{ error }` code from the session endpoint to a user-facing message. */
|
||||||
|
function describeSessionError(code: string): { msg: string; hardStop: boolean } {
|
||||||
|
switch (code) {
|
||||||
|
case 'host_key_not_verified':
|
||||||
|
return {
|
||||||
|
msg: '接続の host key を検証してください(Settings → SSH Connections → Test)',
|
||||||
|
hardStop: false,
|
||||||
|
};
|
||||||
|
case 'no_grant':
|
||||||
|
return {
|
||||||
|
msg: 'この接続への権限がありません(admin に grant を依頼してください)',
|
||||||
|
hardStop: false,
|
||||||
|
};
|
||||||
|
case 'host_key_mismatch':
|
||||||
|
return {
|
||||||
|
msg: 'host key 不一致(MITM の可能性)。admin に連絡してください',
|
||||||
|
hardStop: true,
|
||||||
|
};
|
||||||
|
case 'connection_disabled':
|
||||||
|
return { msg: 'この接続は無効化されています', hardStop: false };
|
||||||
|
case 'abuse_locked':
|
||||||
|
return { msg: 'この接続は一時的にロックされています(abuse 検知)', hardStop: false };
|
||||||
|
case 'connection_not_found':
|
||||||
|
return { msg: '接続が見つかりません', hardStop: false };
|
||||||
|
default:
|
||||||
|
return { msg: `セッションを開始できませんでした: ${code}`, hardStop: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function ConsoleTab({ taskId }: { taskId: number }) {
|
export function ConsoleTab({ taskId }: { taskId: number }) {
|
||||||
|
const qc = useQueryClient();
|
||||||
const { data: status } = useQuery<ConsoleStatus>({
|
const { data: status } = useQuery<ConsoleStatus>({
|
||||||
queryKey: ['console-status', taskId],
|
queryKey: ['console-status', taskId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@ -24,14 +63,181 @@ export function ConsoleTab({ taskId }: { taskId: number }) {
|
|||||||
// and scroll-to-bottom FAB become useful.
|
// and scroll-to-bottom FAB become useful.
|
||||||
const compactMode = useViewportNarrow(768);
|
const compactMode = useViewportNarrow(768);
|
||||||
|
|
||||||
|
const showPicker = !status?.active;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 min-h-0">
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
<ConsoleHeader state={session.state} status={status ?? null} />
|
<ConsoleHeader state={session.state} status={status ?? null} />
|
||||||
<div className="flex-1 min-h-0 relative">
|
<div className="flex-1 min-h-0 relative">
|
||||||
|
{showPicker ? (
|
||||||
|
<ConnectionPicker
|
||||||
|
taskId={taskId}
|
||||||
|
onStarted={() => {
|
||||||
|
// The session now exists server-side; refresh status and force
|
||||||
|
// an immediate WS attach instead of waiting for the 5s poll.
|
||||||
|
qc.invalidateQueries({ queryKey: ['console-status', taskId] });
|
||||||
|
session.reconnectNow();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<TerminalView ref={terminalRef} session={session} />
|
<TerminalView ref={terminalRef} session={session} />
|
||||||
{compactMode && <ScrollToBottomButton terminalRef={terminalRef} />}
|
{compactMode && <ScrollToBottomButton terminalRef={terminalRef} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{compactMode && !showPicker && <MobileKeyboardBar session={session} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConnectionPicker({
|
||||||
|
taskId,
|
||||||
|
onStarted,
|
||||||
|
}: {
|
||||||
|
taskId: number;
|
||||||
|
onStarted: () => void;
|
||||||
|
}) {
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['ssh', 'connections'],
|
||||||
|
queryFn: fetchConnections,
|
||||||
|
staleTime: 15_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const connections = useMemo(
|
||||||
|
() => (data?.list ?? []).filter((c) => c.enabled && !c.disabledByAdmin),
|
||||||
|
[data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [selectedId, setSelectedId] = useState<string>('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [errMsg, setErrMsg] = useState<{ msg: string; hardStop: boolean } | null>(null);
|
||||||
|
// Set when the server reports an existing session on a different connection;
|
||||||
|
// lets the user re-POST with force_replace to take over the session.
|
||||||
|
const [replaceCandidate, setReplaceCandidate] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Default the select to the first connection once loaded.
|
||||||
|
const effectiveId = selectedId || connections[0]?.id || '';
|
||||||
|
|
||||||
|
async function start(forceReplace: boolean) {
|
||||||
|
if (!effectiveId) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setErrMsg(null);
|
||||||
|
setReplaceCandidate(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/local/tasks/${taskId}/console/session`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
connection_id: effectiveId,
|
||||||
|
...(forceReplace ? { force_replace: true } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
onStarted();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let code = `HTTP ${res.status}`;
|
||||||
|
try {
|
||||||
|
const j = (await res.json()) as { error?: string };
|
||||||
|
if (j?.error) code = j.error;
|
||||||
|
} catch {
|
||||||
|
// non-JSON body; keep HTTP status as the code
|
||||||
|
}
|
||||||
|
if (code === 'connection_change_requires_force') {
|
||||||
|
setReplaceCandidate(effectiveId);
|
||||||
|
setErrMsg({
|
||||||
|
msg: '別の接続のセッションが既に存在します。置き換えて開始できます。',
|
||||||
|
hardStop: false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setErrMsg(describeSessionError(code));
|
||||||
|
} catch (e) {
|
||||||
|
setErrMsg({ msg: e instanceof Error ? e.message : String(e), hardStop: false });
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data?.sshDisabled) {
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center p-6 bg-[#0b1020]">
|
||||||
|
<div className="max-w-md text-xs text-slate-300 bg-surface/10 border border-hairline rounded-md p-4 leading-relaxed">
|
||||||
|
SSH サブシステムは無効です。<code className="font-mono">config.yaml</code> の{' '}
|
||||||
|
<code className="font-mono">ssh.enabled: true</code> を設定後にサーバーを再起動してください。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center p-6 bg-[#0b1020]">
|
||||||
|
<div className="w-full max-w-md space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-slate-100">SSH コンソールを開始</h3>
|
||||||
|
<p className="text-2xs text-slate-400 mt-0.5">
|
||||||
|
接続を選んでセッションを開始すると、ターミナルが開き AI とこのタスクで共有されます。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && <div className="text-xs text-slate-400">Loading…</div>}
|
||||||
|
{error && <div className="text-xs text-red-400">読み込みに失敗しました: {String(error)}</div>}
|
||||||
|
|
||||||
|
{!isLoading && connections.length === 0 ? (
|
||||||
|
<div className="text-xs text-slate-300 bg-surface/10 border border-hairline rounded-md p-3 leading-relaxed">
|
||||||
|
利用できる SSH 接続がありません — Settings → SSH Connections で登録/grant してください。
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
value={effectiveId}
|
||||||
|
onChange={(e) => { setSelectedId(e.target.value); setErrMsg(null); setReplaceCandidate(null); }}
|
||||||
|
disabled={submitting}
|
||||||
|
className="w-full text-xs px-2 py-1.5 bg-surface border border-hairline rounded text-slate-100 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{connections.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.label} — {c.username}@{c.host}:{c.port}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => start(false)}
|
||||||
|
disabled={submitting || !effectiveId}
|
||||||
|
className="w-full px-3 h-8 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? '開始中…' : 'セッション開始'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{replaceCandidate && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => start(true)}
|
||||||
|
disabled={submitting}
|
||||||
|
className="w-full px-3 h-8 text-xs font-semibold border border-amber-400/50 text-amber-300 rounded-md hover:bg-amber-500/15 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
現在のセッションを置き換えて開始
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{errMsg && (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
errMsg.hardStop
|
||||||
|
? 'text-xs text-red-200 bg-red-500/20 border border-red-500/50 rounded-md p-3 leading-relaxed'
|
||||||
|
: 'text-xs text-amber-200 bg-amber-500/10 border border-amber-500/30 rounded-md p-3 leading-relaxed'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{errMsg.msg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{compactMode && <MobileKeyboardBar session={session} />}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
250
ui/src/components/mobile/SwipeableTabs.tsx
Normal file
250
ui/src/components/mobile/SwipeableTabs.tsx
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||||
|
import { type Axis, lockAxis, canScrollFurther, resist, shouldCommit, neighborIndex } from '../../lib/tab-swipe';
|
||||||
|
|
||||||
|
interface SwipeableTabsProps<T extends string> {
|
||||||
|
/** Visible tab ids, in order. */
|
||||||
|
tabs: T[];
|
||||||
|
activeTab: T;
|
||||||
|
onTabChange: (tab: T) => void;
|
||||||
|
/** Swiping right past the threshold while on the FIRST tab (e.g. close). */
|
||||||
|
onSwipeBackFromFirst?: () => void;
|
||||||
|
/**
|
||||||
|
* Render a tab's content. `preview` is true for the neighbor mounted DURING a
|
||||||
|
* drag — return a lightweight placeholder for side-effecting tabs (e.g. a
|
||||||
|
* noVNC iframe or SSH WebSocket) so a partial/cancelled swipe doesn't open
|
||||||
|
* connections; the real content mounts once the swipe commits (preview=false).
|
||||||
|
*/
|
||||||
|
renderTab: (tab: T, preview: boolean) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mode = 'idle' | 'pending' | 'vert' | 'scroll' | 'drag';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Horizontal swipe-between-tabs with finger-following content and a peeking
|
||||||
|
* neighbor (iOS-style). Native horizontal scrolling INSIDE the content (e.g. a
|
||||||
|
* wide <pre>) wins until it reaches its edge, so scrolling a code block no
|
||||||
|
* longer steals the tab gesture. The active layer's transform is mutated
|
||||||
|
* imperatively during the drag so the (heavy) tab panels don't re-render per
|
||||||
|
* frame; the neighbor is mounted only while a drag is in progress.
|
||||||
|
*/
|
||||||
|
export function SwipeableTabs<T extends string>({
|
||||||
|
tabs,
|
||||||
|
activeTab,
|
||||||
|
onTabChange,
|
||||||
|
onSwipeBackFromFirst,
|
||||||
|
renderTab,
|
||||||
|
}: SwipeableTabsProps<T>) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const activeRef = useRef<HTMLDivElement>(null);
|
||||||
|
const peekRef = useRef<HTMLDivElement>(null);
|
||||||
|
// The neighbor tab to mount while dragging ('left' = next on the right edge,
|
||||||
|
// 'right' = prev on the left edge). null = no drag in progress.
|
||||||
|
const [peek, setPeek] = useState<{ dir: 'left' | 'right'; tab: T } | null>(null);
|
||||||
|
|
||||||
|
const g = useRef({
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
axis: null as Axis | null,
|
||||||
|
mode: 'idle' as Mode,
|
||||||
|
scroller: null as Element | null,
|
||||||
|
dir: null as 'left' | 'right' | null,
|
||||||
|
width: 0,
|
||||||
|
dragX: 0,
|
||||||
|
rawDx: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Latest props for the native (re-attached) handlers.
|
||||||
|
const propsRef = useRef({ tabs, activeTab, onTabChange, onSwipeBackFromFirst });
|
||||||
|
propsRef.current = { tabs, activeTab, onTabChange, onSwipeBackFromFirst };
|
||||||
|
|
||||||
|
const baseOffset = (dir: 'left' | 'right') => (dir === 'left' ? '100%' : '-100%');
|
||||||
|
|
||||||
|
const paint = (dx: number, animate: boolean) => {
|
||||||
|
const a = activeRef.current;
|
||||||
|
const p = peekRef.current;
|
||||||
|
const t = animate ? 'transform 0.24s cubic-bezier(0.22,0.61,0.36,1)' : 'none';
|
||||||
|
if (a) {
|
||||||
|
a.style.transition = t;
|
||||||
|
a.style.transform = `translate3d(${dx}px,0,0)`;
|
||||||
|
}
|
||||||
|
if (p && g.current.dir) {
|
||||||
|
p.style.transition = t;
|
||||||
|
p.style.transform = `translate3d(calc(${baseOffset(g.current.dir)} + ${dx}px),0,0)`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find the nearest horizontally-scrollable ancestor between `el` and the
|
||||||
|
// container (inclusive of el, exclusive of container).
|
||||||
|
const findScroller = (el: Element | null): Element | null => {
|
||||||
|
let node = el;
|
||||||
|
const stop = containerRef.current;
|
||||||
|
while (node && node !== stop) {
|
||||||
|
if (node instanceof HTMLElement) {
|
||||||
|
const ox = getComputedStyle(node).overflowX;
|
||||||
|
if ((ox === 'auto' || ox === 'scroll') && node.scrollWidth - node.clientWidth > 1) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node = node.parentElement;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
g.current.mode = 'idle';
|
||||||
|
g.current.axis = null;
|
||||||
|
g.current.scroller = null;
|
||||||
|
g.current.dir = null;
|
||||||
|
g.current.dragX = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onStart = (e: TouchEvent) => {
|
||||||
|
if (e.touches.length !== 1) return;
|
||||||
|
g.current.mode = 'idle';
|
||||||
|
// Don't fight cursor placement, button presses, or link taps: ignore
|
||||||
|
// gestures that begin on a form control / button / anchor / editable.
|
||||||
|
const target = e.target as Element | null;
|
||||||
|
if (target?.closest('input, textarea, select, button, a, [contenteditable="true"], [data-no-swipe]')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = e.touches[0];
|
||||||
|
g.current.startX = t.clientX;
|
||||||
|
g.current.startY = t.clientY;
|
||||||
|
g.current.axis = null;
|
||||||
|
g.current.mode = 'pending';
|
||||||
|
g.current.scroller = findScroller(e.target as Element | null);
|
||||||
|
g.current.width = el.clientWidth;
|
||||||
|
g.current.dir = null;
|
||||||
|
g.current.dragX = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMove = (e: TouchEvent) => {
|
||||||
|
if (g.current.mode === 'idle' || g.current.mode === 'vert' || g.current.mode === 'scroll') return;
|
||||||
|
const t = e.touches[0];
|
||||||
|
if (!t) return;
|
||||||
|
const dx = t.clientX - g.current.startX;
|
||||||
|
const dy = t.clientY - g.current.startY;
|
||||||
|
|
||||||
|
if (g.current.axis === null) {
|
||||||
|
const a = lockAxis(dx, dy);
|
||||||
|
if (!a) return;
|
||||||
|
g.current.axis = a;
|
||||||
|
if (a === 'v') {
|
||||||
|
g.current.mode = 'vert';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (g.current.axis !== 'h') return;
|
||||||
|
|
||||||
|
// Only decide to yield to native scroll BEFORE a drag has started. Once
|
||||||
|
// dragging, reversing direction must not hand the gesture back to the
|
||||||
|
// scroller mid-drag (that would leave the panels painted off-center).
|
||||||
|
const sc = g.current.scroller;
|
||||||
|
if (g.current.mode !== 'drag' && sc && canScrollFurther(sc as HTMLElement, dx)) {
|
||||||
|
g.current.mode = 'scroll';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault(); // own the horizontal gesture
|
||||||
|
const { tabs: ts, activeTab: at } = propsRef.current;
|
||||||
|
const idx = ts.indexOf(at);
|
||||||
|
const ni = neighborIndex(idx, dx, ts.length);
|
||||||
|
const hasNeighbor = ni !== null;
|
||||||
|
const dir: 'left' | 'right' = dx < 0 ? 'left' : 'right';
|
||||||
|
|
||||||
|
if (g.current.mode !== 'drag') {
|
||||||
|
g.current.mode = 'drag';
|
||||||
|
}
|
||||||
|
if (g.current.dir !== dir) {
|
||||||
|
g.current.dir = dir;
|
||||||
|
// Mount the neighbor (or null at an edge so the layer just rubber-bands).
|
||||||
|
setPeek(hasNeighbor ? { dir, tab: ts[ni] } : null);
|
||||||
|
}
|
||||||
|
g.current.rawDx = dx;
|
||||||
|
g.current.dragX = resist(dx, hasNeighbor, g.current.width);
|
||||||
|
paint(g.current.dragX, false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const finish = () => {
|
||||||
|
if (g.current.mode !== 'drag') {
|
||||||
|
reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { tabs: ts, activeTab: at, onTabChange: change, onSwipeBackFromFirst: back } = propsRef.current;
|
||||||
|
const idx = ts.indexOf(at);
|
||||||
|
const dragX = g.current.dragX;
|
||||||
|
const width = g.current.width;
|
||||||
|
const ni = neighborIndex(idx, dragX, ts.length);
|
||||||
|
const dir = g.current.dir;
|
||||||
|
|
||||||
|
// First tab + rightward swipe past threshold → "back" (close), no neighbor.
|
||||||
|
// Use the RAW (un-resisted) movement: dragX is rubber-banded to ~12% width
|
||||||
|
// at an edge, so it could never reach the threshold otherwise.
|
||||||
|
if (ni === null && dir === 'right' && idx === 0 && back && Math.abs(g.current.rawDx) >= Math.max(60, width * 0.22)) {
|
||||||
|
paint(0, true);
|
||||||
|
window.setTimeout(() => { reset(); setPeek(null); }, 240);
|
||||||
|
back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ni !== null && shouldCommit(dragX, width, true)) {
|
||||||
|
const target = ts[ni];
|
||||||
|
// Slide fully, then commit the tab change and snap back to center.
|
||||||
|
paint(dir === 'left' ? -width : width, true);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
change(target);
|
||||||
|
setPeek(null);
|
||||||
|
g.current.dir = null;
|
||||||
|
requestAnimationFrame(() => paint(0, false));
|
||||||
|
reset();
|
||||||
|
}, 240);
|
||||||
|
} else {
|
||||||
|
paint(0, true);
|
||||||
|
window.setTimeout(() => { setPeek(null); reset(); }, 240);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// touchcancel = the OS/browser aborted the gesture (notification, multi-touch,
|
||||||
|
// interruption) — NOT a user commit. Snap back without changing tabs.
|
||||||
|
const cancel = () => {
|
||||||
|
if (g.current.mode === 'drag') {
|
||||||
|
paint(0, true);
|
||||||
|
window.setTimeout(() => { setPeek(null); reset(); }, 240);
|
||||||
|
} else {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
el.addEventListener('touchstart', onStart, { passive: true });
|
||||||
|
el.addEventListener('touchmove', onMove, { passive: false });
|
||||||
|
el.addEventListener('touchend', finish, { passive: true });
|
||||||
|
el.addEventListener('touchcancel', cancel, { passive: true });
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('touchstart', onStart);
|
||||||
|
el.removeEventListener('touchmove', onMove);
|
||||||
|
el.removeEventListener('touchend', finish);
|
||||||
|
el.removeEventListener('touchcancel', cancel);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative h-full w-full overflow-hidden">
|
||||||
|
<div ref={activeRef} className="absolute inset-0 will-change-transform">
|
||||||
|
{renderTab(activeTab, false)}
|
||||||
|
</div>
|
||||||
|
{peek && (
|
||||||
|
<div
|
||||||
|
ref={peekRef}
|
||||||
|
className="absolute inset-0 will-change-transform"
|
||||||
|
style={{ transform: `translate3d(${baseOffset(peek.dir)},0,0)` }}
|
||||||
|
>
|
||||||
|
{renderTab(peek.tab, true)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -17,6 +17,13 @@ export interface ConsoleSessionApi {
|
|||||||
send(input: string): void;
|
send(input: string): void;
|
||||||
sendResize(cols: number, rows: number): void;
|
sendResize(cols: number, rows: number): void;
|
||||||
close(): void;
|
close(): void;
|
||||||
|
/**
|
||||||
|
* Force an immediate (re)connect attempt, resetting the backoff timer.
|
||||||
|
* Used after the user opens a session via REST so the terminal attaches
|
||||||
|
* without waiting for the next scheduled retry / 5s status poll. Safe to
|
||||||
|
* call at any time; the normal auto-reconnect keeps running afterwards.
|
||||||
|
*/
|
||||||
|
reconnectNow(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -35,6 +42,10 @@ export function useConsoleSession(taskId: string | number): ConsoleSessionApi {
|
|||||||
const outputListeners = useRef(new Set<(d: Uint8Array) => void>());
|
const outputListeners = useRef(new Set<(d: Uint8Array) => void>());
|
||||||
const noticeListeners = useRef(new Set<(m: any) => void>());
|
const noticeListeners = useRef(new Set<(m: any) => void>());
|
||||||
const lastAttachRef = useRef<{ canWrite: boolean; cols: number; rows: number } | null>(null);
|
const lastAttachRef = useRef<{ canWrite: boolean; cols: number; rows: number } | null>(null);
|
||||||
|
// Populated by the connection effect with a callback that forces an
|
||||||
|
// immediate reconnect (resetting backoff). Held in a ref so the stable
|
||||||
|
// `reconnectNow` returned below can delegate to the live closure.
|
||||||
|
const reconnectNowRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@ -100,10 +111,26 @@ export function useConsoleSession(taskId: string | number): ConsoleSessionApi {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Expose an on-demand reconnect: cancel any pending backoff retry, reset
|
||||||
|
// the delay, drop the current socket and reconnect immediately. The
|
||||||
|
// existing ws.onclose auto-reconnect still fires for organic disconnects.
|
||||||
|
reconnectNowRef.current = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
|
||||||
|
retryDelayMs = 1000;
|
||||||
|
const cur = wsRef.current;
|
||||||
|
if (cur && (cur.readyState === cur.OPEN || cur.readyState === cur.CONNECTING)) {
|
||||||
|
// Already (re)connecting/connected — nothing to force.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connect();
|
||||||
|
};
|
||||||
|
|
||||||
connect();
|
connect();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
reconnectNowRef.current = null;
|
||||||
if (retryTimer) clearTimeout(retryTimer);
|
if (retryTimer) clearTimeout(retryTimer);
|
||||||
try { wsRef.current?.close(); } catch {}
|
try { wsRef.current?.close(); } catch {}
|
||||||
};
|
};
|
||||||
@ -134,5 +161,8 @@ export function useConsoleSession(taskId: string | number): ConsoleSessionApi {
|
|||||||
close() {
|
close() {
|
||||||
try { wsRef.current?.close(); } catch {}
|
try { wsRef.current?.close(); } catch {}
|
||||||
},
|
},
|
||||||
|
reconnectNow() {
|
||||||
|
reconnectNowRef.current?.();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,60 +0,0 @@
|
|||||||
import { useRef, type TouchEvent } from 'react';
|
|
||||||
|
|
||||||
interface UseSwipeNavOptions {
|
|
||||||
onSwipeLeft?: () => void;
|
|
||||||
onSwipeRight?: () => void;
|
|
||||||
/** Minimum horizontal distance (px) to count as a swipe. Default 60. */
|
|
||||||
threshold?: number;
|
|
||||||
/** Maximum vertical drift (px) before the gesture is treated as a scroll. Default 40. */
|
|
||||||
verticalTolerance?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lightweight horizontal swipe handler. Pure DOM touch events, no deps.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* const swipe = useSwipeNav({ onSwipeLeft: next, onSwipeRight: prev });
|
|
||||||
* <div {...swipe}>...</div>
|
|
||||||
*
|
|
||||||
* Behavior:
|
|
||||||
* - Touches starting on form controls (input / textarea / select / button /
|
|
||||||
* anchor / contenteditable) are ignored so we don't fight cursor placement
|
|
||||||
* or button presses.
|
|
||||||
* - Gestures with vertical drift > verticalTolerance are treated as
|
|
||||||
* scrolls and ignored, so vertical scroll inside panels still works.
|
|
||||||
* - Horizontal distance must exceed threshold to trigger a callback.
|
|
||||||
*/
|
|
||||||
export function useSwipeNav({
|
|
||||||
onSwipeLeft,
|
|
||||||
onSwipeRight,
|
|
||||||
threshold = 60,
|
|
||||||
verticalTolerance = 40,
|
|
||||||
}: UseSwipeNavOptions) {
|
|
||||||
const start = useRef<{ x: number; y: number; ignored: boolean } | null>(null);
|
|
||||||
|
|
||||||
const onTouchStart = (e: TouchEvent<HTMLElement>) => {
|
|
||||||
const touch = e.touches[0];
|
|
||||||
if (!touch) return;
|
|
||||||
const target = e.target as HTMLElement | null;
|
|
||||||
const ignored = !!target?.closest(
|
|
||||||
'input, textarea, select, button, a, [contenteditable="true"], [data-no-swipe]',
|
|
||||||
);
|
|
||||||
start.current = { x: touch.clientX, y: touch.clientY, ignored };
|
|
||||||
};
|
|
||||||
|
|
||||||
const onTouchEnd = (e: TouchEvent<HTMLElement>) => {
|
|
||||||
const s = start.current;
|
|
||||||
start.current = null;
|
|
||||||
if (!s || s.ignored) return;
|
|
||||||
const touch = e.changedTouches[0];
|
|
||||||
if (!touch) return;
|
|
||||||
const dx = touch.clientX - s.x;
|
|
||||||
const dy = touch.clientY - s.y;
|
|
||||||
if (Math.abs(dy) > verticalTolerance) return;
|
|
||||||
if (Math.abs(dx) < threshold) return;
|
|
||||||
if (dx < 0) onSwipeLeft?.();
|
|
||||||
else onSwipeRight?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
return { onTouchStart, onTouchEnd };
|
|
||||||
}
|
|
||||||
63
ui/src/lib/tab-swipe.test.ts
Normal file
63
ui/src/lib/tab-swipe.test.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { lockAxis, canScrollFurther, resist, shouldCommit, neighborIndex } from './tab-swipe';
|
||||||
|
|
||||||
|
describe('lockAxis', () => {
|
||||||
|
it('stays null until past the threshold', () => {
|
||||||
|
expect(lockAxis(3, 2)).toBe(null);
|
||||||
|
expect(lockAxis(7, 7)).toBe(null);
|
||||||
|
});
|
||||||
|
it('locks to the dominant axis past threshold', () => {
|
||||||
|
expect(lockAxis(20, 4)).toBe('h');
|
||||||
|
expect(lockAxis(4, 20)).toBe('v');
|
||||||
|
expect(lockAxis(12, 12)).toBe('h'); // tie → horizontal
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('canScrollFurther', () => {
|
||||||
|
const el = { scrollLeft: 50, scrollWidth: 300, clientWidth: 100 }; // max=200
|
||||||
|
it('true when there is room in the finger direction', () => {
|
||||||
|
expect(canScrollFurther(el, +1)).toBe(true); // moving right, scrollLeft>0
|
||||||
|
expect(canScrollFurther(el, -1)).toBe(true); // moving left, room on right
|
||||||
|
});
|
||||||
|
it('false at the respective edge', () => {
|
||||||
|
expect(canScrollFurther({ ...el, scrollLeft: 0 }, +1)).toBe(false);
|
||||||
|
expect(canScrollFurther({ ...el, scrollLeft: 200 }, -1)).toBe(false);
|
||||||
|
});
|
||||||
|
it('false when not scrollable', () => {
|
||||||
|
expect(canScrollFurther({ scrollLeft: 0, scrollWidth: 100, clientWidth: 100 }, -1)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resist', () => {
|
||||||
|
it('is 1:1 when a neighbor exists', () => {
|
||||||
|
expect(resist(80, true, 360)).toBe(80);
|
||||||
|
});
|
||||||
|
it('rubber-bands (smaller, same sign) with no neighbor', () => {
|
||||||
|
const r = resist(120, false, 360);
|
||||||
|
expect(Math.sign(r)).toBe(1);
|
||||||
|
expect(Math.abs(r)).toBeLessThan(120);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shouldCommit', () => {
|
||||||
|
it('commits past max(60, 22% width) with a neighbor', () => {
|
||||||
|
expect(shouldCommit(-100, 360, true)).toBe(true); // 22%*360=79.2, 100>79
|
||||||
|
expect(shouldCommit(-70, 360, true)).toBe(false); // 70<79
|
||||||
|
expect(shouldCommit(-70, 200, true)).toBe(true); // max(60,44)=60, 70>60
|
||||||
|
});
|
||||||
|
it('never commits without a neighbor', () => {
|
||||||
|
expect(shouldCommit(-300, 360, false)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('neighborIndex', () => {
|
||||||
|
it('drag left → next, drag right → prev', () => {
|
||||||
|
expect(neighborIndex(1, -10, 4)).toBe(2);
|
||||||
|
expect(neighborIndex(1, 10, 4)).toBe(0);
|
||||||
|
});
|
||||||
|
it('null at the edges', () => {
|
||||||
|
expect(neighborIndex(3, -10, 4)).toBe(null);
|
||||||
|
expect(neighborIndex(0, 10, 4)).toBe(null);
|
||||||
|
expect(neighborIndex(1, 0, 4)).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
66
ui/src/lib/tab-swipe.ts
Normal file
66
ui/src/lib/tab-swipe.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
// Pure decision helpers for the mobile swipe-between-tabs gesture. The DOM
|
||||||
|
// wiring lives in components/mobile/SwipeableTabs.tsx; this module is the
|
||||||
|
// testable core (axis lock, native-scroll conflict, rubber-band, commit).
|
||||||
|
|
||||||
|
export type Axis = 'h' | 'v';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lock the gesture axis once the finger has moved past `threshold` on either
|
||||||
|
* axis. Returns null while the movement is still too small to classify.
|
||||||
|
*/
|
||||||
|
export function lockAxis(dx: number, dy: number, threshold = 8): Axis | null {
|
||||||
|
const ax = Math.abs(dx);
|
||||||
|
const ay = Math.abs(dy);
|
||||||
|
if (ax < threshold && ay < threshold) return null;
|
||||||
|
return ax >= ay ? 'h' : 'v';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a horizontally-scrollable element can still scroll in the direction
|
||||||
|
* the FINGER is moving (`dirX`): a finger moving right (dirX > 0) reveals the
|
||||||
|
* left side, i.e. needs scrollLeft > 0; moving left needs room on the right.
|
||||||
|
* When it can, the native scroll should win over the tab swipe.
|
||||||
|
*/
|
||||||
|
export function canScrollFurther(
|
||||||
|
el: { scrollLeft: number; scrollWidth: number; clientWidth: number },
|
||||||
|
dirX: number,
|
||||||
|
): boolean {
|
||||||
|
if (dirX === 0) return false;
|
||||||
|
const maxScroll = el.scrollWidth - el.clientWidth;
|
||||||
|
if (maxScroll <= 1) return false; // not actually scrollable
|
||||||
|
return dirX > 0 ? el.scrollLeft > 1 : el.scrollLeft < maxScroll - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rubber-band the drag offset. When there IS a neighbor in the drag direction
|
||||||
|
* the offset is 1:1; when there is none (first/last tab) it resists so the edge
|
||||||
|
* feels bounded instead of dragging into empty space.
|
||||||
|
*/
|
||||||
|
export function resist(dx: number, hasNeighbor: boolean, width: number): number {
|
||||||
|
if (hasNeighbor) return dx;
|
||||||
|
const w = Math.max(1, width);
|
||||||
|
const sign = Math.sign(dx);
|
||||||
|
// Asymptotic resistance capped at ~12% of width.
|
||||||
|
return sign * w * 0.12 * (1 - Math.exp(-Math.abs(dx) / w));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commit to the neighbor tab when the drag passed the threshold (the larger of
|
||||||
|
* 60px or 22% of the container width) AND a neighbor exists in that direction.
|
||||||
|
*/
|
||||||
|
export function shouldCommit(dragX: number, width: number, hasNeighbor: boolean): boolean {
|
||||||
|
if (!hasNeighbor) return false;
|
||||||
|
const threshold = Math.max(60, width * 0.22);
|
||||||
|
return Math.abs(dragX) >= threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the neighbor index for a drag. dragX < 0 (finger moved left) goes to
|
||||||
|
* the NEXT tab; dragX > 0 goes to the PREVIOUS. Returns null when there is no
|
||||||
|
* neighbor in that direction.
|
||||||
|
*/
|
||||||
|
export function neighborIndex(active: number, dragX: number, count: number): number | null {
|
||||||
|
if (dragX < 0) return active < count - 1 ? active + 1 : null;
|
||||||
|
if (dragX > 0) return active > 0 ? active - 1 : null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user