sync: update from private repo (7d64ee2)
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
c526adddc2
commit
02c7dfdd83
@ -1,4 +1,4 @@
|
||||
# X / Twitter ツール(XSearch / XUserPosts / XPostDetail / XFetchCardMedia)
|
||||
# X / Twitter ツール(XSearch / XUserPosts / XPostDetail / XTimeline / XFetchCardMedia)
|
||||
|
||||
twitter-cli を内部で呼び出して X (旧 Twitter) のデータを取得する read-only ツール群。
|
||||
|
||||
@ -37,6 +37,23 @@ XUserPosts({
|
||||
})
|
||||
```
|
||||
|
||||
## XTimeline — ホームタイムライン(ログイン中アカウント)
|
||||
|
||||
```js
|
||||
XTimeline({
|
||||
tab: "for_you", // for_you (デフォルト) / following
|
||||
limit: 20, // デフォルト 20, 最大 50
|
||||
full_text: true,
|
||||
compact: false,
|
||||
output_path: "x/timeline.txt" // 任意
|
||||
})
|
||||
```
|
||||
|
||||
設定済みの `auth_token` / `ct0` cookie に紐づくアカウントの**ホームタイムライン**を返す。
|
||||
`tab: "for_you"`(おすすめ)と `tab: "following"`(フォロー中)を切り替えられる。
|
||||
内部的には `twitter feed [-t following]` を呼ぶ。検索ではなく「ログイン中ユーザーが
|
||||
普段見るフィード」を取得したいときに使う。出力・メディア DL の扱いは XSearch と共通。
|
||||
|
||||
## XPostDetail — 投稿の詳細+リプライ
|
||||
|
||||
```js
|
||||
|
||||
@ -66,7 +66,7 @@ movements:
|
||||
- `result` がそのままユーザーに表示される最終出力。途中のメモや作業ログは入れない
|
||||
- **ユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "確認したい内容", why_no_default: "デフォルトで進められない理由"})`
|
||||
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "失敗の理由"})`
|
||||
allowed_tools: [Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, ReadExcel, ReadDocx, ReadPPTX, SQLite, Bash, XSearch, XUserPosts, XPostDetail, XFetchCardMedia, BrowseWeb, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, ListPieces, GetPiece, CreatePiece, UpdatePiece, SearchKnowledge, ListNamespaces, ListDocuments, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, ReadToolDoc, UpdateDashboardWidget, 'mcp__*']
|
||||
allowed_tools: [Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, ReadExcel, ReadDocx, ReadPPTX, SQLite, Bash, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, BrowseWeb, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, ListPieces, GetPiece, CreatePiece, UpdatePiece, SearchKnowledge, ListNamespaces, ListDocuments, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, ReadToolDoc, UpdateDashboardWidget, 'mcp__*']
|
||||
# default_next is the engine-internal fallback for context overflow / ASK
|
||||
# limit reached / SpawnSubTask unavailable. It is NOT exposed to the LLM.
|
||||
default_next: COMPLETE
|
||||
|
||||
@ -45,7 +45,7 @@ movements:
|
||||
- **追加調査のため同じ dig を続行**: `transition({next_step: "dig"})`
|
||||
- **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
||||
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
|
||||
allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, XSearch, XUserPosts, XPostDetail, XFetchCardMedia, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*']
|
||||
allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*']
|
||||
default_next: analyze
|
||||
rules:
|
||||
- condition: output/ に情報を書き出した
|
||||
@ -72,7 +72,7 @@ movements:
|
||||
|
||||
output/images/ に画像がある場合は、必ずレポートの該当箇所に埋め込む:
|
||||
``
|
||||
allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, XSearch, XUserPosts, XPostDetail, XFetchCardMedia, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*']
|
||||
allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*']
|
||||
default_next: verify
|
||||
rules:
|
||||
- condition: output/ にレポートを書き出した
|
||||
|
||||
@ -113,7 +113,7 @@ movements:
|
||||
- **追加調査のため同じ dig を続行**: `transition({next_step: "dig"})`
|
||||
- **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
||||
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
|
||||
allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, XSearch, XUserPosts, XPostDetail, XFetchCardMedia, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*']
|
||||
allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*']
|
||||
default_next: analyze
|
||||
rules:
|
||||
- condition: 2つ以上の独立した調査テーマがあり、並列分解が効率的と判断した
|
||||
@ -146,7 +146,7 @@ movements:
|
||||
画像があるのにテキストだけのレポートにしないこと。
|
||||
レポート作成中に追加で必要な図・グラフを見つけた場合も DownloadFile で収集して埋め込む。
|
||||
|
||||
allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, XSearch, XUserPosts, XPostDetail, XFetchCardMedia, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*']
|
||||
allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*']
|
||||
default_next: verify
|
||||
rules:
|
||||
- condition: output/ にレポートを書き出した
|
||||
|
||||
@ -28,6 +28,7 @@ movements:
|
||||
- キーワードで広く拾う → XSearch
|
||||
- 特定アカウントの発言を追う → XUserPosts
|
||||
- 議論の流れ・リプライツリーまで欲しい → XPostDetail
|
||||
- ログイン中アカウントのホームタイムライン(おすすめ / フォロー中)を見る → XTimeline
|
||||
|
||||
### Reddit
|
||||
BrowseWeb で必ず **old.reddit.com** を使う(軽量でテキスト抽出しやすい)。
|
||||
@ -56,7 +57,7 @@ movements:
|
||||
- **追加収集のため同じ gather を続行**: `transition({next_step: "gather"})`
|
||||
- **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
||||
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
|
||||
allowed_tools: [XSearch, XUserPosts, XPostDetail, XFetchCardMedia, BrowseWeb, WebFetch, WebSearch, Read, Write, Edit, Glob, Grep, DownloadFile, SearchKnowledge, ListNamespaces, ListDocuments, SearchNotes, ReadNote, 'mcp__*']
|
||||
allowed_tools: [XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, BrowseWeb, WebFetch, WebSearch, Read, Write, Edit, Glob, Grep, DownloadFile, SearchKnowledge, ListNamespaces, ListDocuments, SearchNotes, ReadNote, 'mcp__*']
|
||||
default_next: analyze
|
||||
rules:
|
||||
- condition: 追加収集が必要(別のSNS、追加クエリ等)
|
||||
|
||||
@ -61,6 +61,7 @@ movements:
|
||||
- XSearch
|
||||
- XUserPosts
|
||||
- XPostDetail
|
||||
- XTimeline
|
||||
- XFetchCardMedia
|
||||
- BrowseWeb
|
||||
- WebFetch
|
||||
|
||||
@ -41,6 +41,7 @@ const TOOL_DOC_ALIASES: Record<string, string> = {
|
||||
xuserposts: 'xsearch',
|
||||
xpostdetail: 'xsearch',
|
||||
xfetchcardmedia: 'xsearch',
|
||||
xtimeline: 'xsearch',
|
||||
// youtube.ts をまとめる
|
||||
searchyoutube: 'getyoutubetranscript',
|
||||
// maps.ts をまとめる
|
||||
|
||||
@ -9,6 +9,7 @@ export const RAW_SAVE_TOOLS = new Set([
|
||||
'XSearch',
|
||||
'XUserPosts',
|
||||
'XPostDetail',
|
||||
'XTimeline',
|
||||
'BrowseWeb',
|
||||
'GetYouTubeTranscript',
|
||||
'SearchYouTube',
|
||||
|
||||
@ -126,7 +126,7 @@ function mkStubSubsystem() {
|
||||
setWindow: vi.fn(),
|
||||
on: vi.fn(),
|
||||
};
|
||||
const client = { end: vi.fn() };
|
||||
const client = { end: vi.fn(), on: vi.fn() };
|
||||
const openShellChannel = vi.fn().mockResolvedValue({
|
||||
channel,
|
||||
client,
|
||||
|
||||
@ -246,6 +246,7 @@ async function ensureSessionInternal(
|
||||
|
||||
// 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({
|
||||
@ -266,6 +267,7 @@ async function ensureSessionInternal(
|
||||
timeoutMs: sub.config.callTimeoutSeconds * 1000,
|
||||
});
|
||||
channel = shellResult.channel;
|
||||
client = shellResult.client;
|
||||
hostFingerprint = shellResult.hostFingerprint;
|
||||
} catch (e) {
|
||||
clearBuffer(pemBuf);
|
||||
@ -292,8 +294,10 @@ async function ensureSessionInternal(
|
||||
return err(`SshConsoleEnsure: failed to open shell channel: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
// Build the session and register it. From here on the channel + PEM
|
||||
// belong to the session; we don't clear them on the happy path.
|
||||
// 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,
|
||||
@ -303,6 +307,7 @@ async function ensureSessionInternal(
|
||||
rows,
|
||||
scrollbackCap: sub.config.console.scrollbackBytes,
|
||||
channel,
|
||||
client,
|
||||
auditRepo: sub.auditRepo,
|
||||
});
|
||||
sub.sessionRegistry.register(session);
|
||||
|
||||
@ -129,11 +129,30 @@ const XFETCHCARDMEDIA_DEF: ToolDef = {
|
||||
},
|
||||
};
|
||||
|
||||
const XTIMELINE_DEF: ToolDef = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'XTimeline',
|
||||
description: 'ログイン中アカウントのホームタイムライン(For You / フォロー中)を取得する(twitter-cli 経由、認証 Cookie 設定が必要)。詳細は ReadToolDoc({ name: "XTimeline" })。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tab: { type: 'string', enum: ['for_you', 'following'], description: 'for_you (デフォルト) / following' },
|
||||
limit: { type: 'number', description: '件数 (デフォルト: 20, 最大: 50)' },
|
||||
full_text: { type: 'boolean', description: '長文の省略を避ける' },
|
||||
compact: { type: 'boolean', description: 'token 節約向けの compact 出力' },
|
||||
output_path: { type: 'string', description: '任意: output/x/ 配下に保存する相対パス' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TOOL_DEFS: Record<string, ToolDef> = {
|
||||
XSearch: XSEARCH_DEF,
|
||||
XUserPosts: XUSERPOSTS_DEF,
|
||||
XPostDetail: XPOSTDETAIL_DEF,
|
||||
XFetchCardMedia: XFETCHCARDMEDIA_DEF,
|
||||
XTimeline: XTIMELINE_DEF,
|
||||
};
|
||||
|
||||
type XHistoryRecord = {
|
||||
@ -608,6 +627,36 @@ async function executeXUserPosts(input: Record<string, unknown>, ctx: ToolContex
|
||||
return runTwitterCli('XUserPosts', args, ctx, outputPath);
|
||||
}
|
||||
|
||||
async function executeXTimeline(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||
const tab = String(input['tab'] ?? 'for_you');
|
||||
const following = tab === 'following';
|
||||
const args = ['feed', '--max', String(clampLimit(input['limit'], 20)), '--yaml'];
|
||||
if (following) args.push('-t', 'following');
|
||||
maybePushFlag(args, input['full_text'], '--full-text');
|
||||
maybePushFlag(args, input['compact'], '--compact');
|
||||
let outputPath: string | null;
|
||||
try {
|
||||
outputPath = resolveOptionalOutputPath(ctx, input['output_path']);
|
||||
} catch (err) {
|
||||
return { output: `XTimeline error: ${(err as Error).message}`, isError: true };
|
||||
}
|
||||
const result = await runTwitterCli('XTimeline', args, ctx, outputPath);
|
||||
if (result.isError) return result;
|
||||
|
||||
const posts = parseXPostsFromYaml(result.output);
|
||||
if (posts.length > 0) {
|
||||
const refId = `xposts-${Date.now()}`;
|
||||
const structuredBlocks: StructuredBlock[] = [{
|
||||
refId,
|
||||
type: 'x_posts',
|
||||
title: following ? 'X タイムライン(フォロー中)' : 'X タイムライン(For You)',
|
||||
data: { query: following ? 'following' : 'for_you', posts },
|
||||
}];
|
||||
return { output: `${result.output}\n\n[[embed:${refId}]]`, isError: false, structuredBlocks };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function executeXPostDetail(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||
const tweet = String(input['tweet'] ?? '').trim();
|
||||
if (!tweet) return { output: 'XPostDetail error: tweet is required', isError: true };
|
||||
@ -862,6 +911,8 @@ export async function executeTool(
|
||||
return executeXSearch(input, ctx);
|
||||
case 'XUserPosts':
|
||||
return executeXUserPosts(input, ctx);
|
||||
case 'XTimeline':
|
||||
return executeXTimeline(input, ctx);
|
||||
case 'XPostDetail':
|
||||
return executeXPostDetail(input, ctx);
|
||||
case 'XFetchCardMedia':
|
||||
|
||||
@ -57,7 +57,7 @@ const BUILTIN_TOOL_NAMES_LIST: ReadonlyArray<string> = [
|
||||
// orchestration.ts
|
||||
'SpawnSubTask',
|
||||
// x.ts
|
||||
'XPostDetail', 'XSearch', 'XUserPosts',
|
||||
'XPostDetail', 'XSearch', 'XTimeline', 'XUserPosts',
|
||||
// maps.ts
|
||||
'GetDirections', 'ReverseGeocode', 'SearchPlaces',
|
||||
// youtube.ts
|
||||
|
||||
@ -13,6 +13,11 @@ class StubChannel extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
class StubClient extends EventEmitter {
|
||||
ended = false;
|
||||
end(): void { this.ended = true; this.emit('close'); }
|
||||
}
|
||||
|
||||
function mkAudit() {
|
||||
return {
|
||||
beginAndComplete: vi.fn(),
|
||||
@ -21,7 +26,7 @@ function mkAudit() {
|
||||
};
|
||||
}
|
||||
|
||||
function mkSession(channel: StubChannel) {
|
||||
function mkSession(channel: StubChannel, client: StubClient = new StubClient()) {
|
||||
const audit = mkAudit();
|
||||
const session = new ConsoleSession({
|
||||
localTaskId: 't1',
|
||||
@ -32,9 +37,10 @@ function mkSession(channel: StubChannel) {
|
||||
rows: 24,
|
||||
scrollbackCap: 1024,
|
||||
channel: channel as any,
|
||||
client: client as any,
|
||||
auditRepo: audit as any,
|
||||
});
|
||||
return { session, audit };
|
||||
return { session, audit, client };
|
||||
}
|
||||
|
||||
describe('ConsoleSession', () => {
|
||||
@ -131,18 +137,43 @@ describe('ConsoleSession', () => {
|
||||
expect(ch.windowChanges).toEqual([{ rows: 40, cols: 100 }]);
|
||||
});
|
||||
|
||||
it('close() is idempotent and records audit', async () => {
|
||||
it('close() is idempotent, ends the client, and records audit', async () => {
|
||||
const ch = new StubChannel();
|
||||
const { session, audit } = mkSession(ch);
|
||||
const { session, audit, client } = mkSession(ch);
|
||||
await session.close('idle_timeout');
|
||||
await session.close('idle_timeout');
|
||||
expect(ch.ended).toBe(true);
|
||||
expect(client.ended).toBe(true);
|
||||
expect(audit.beginAndComplete).toHaveBeenCalledTimes(1);
|
||||
const call = audit.beginAndComplete.mock.calls[0]![0];
|
||||
expect(call.action).toBe('ssh.console.close');
|
||||
expect(call.detail.reason).toBe('idle_timeout');
|
||||
});
|
||||
|
||||
it('client "error" (ECONNRESET) tears the session down without throwing', async () => {
|
||||
const ch = new StubChannel();
|
||||
const { session, audit, client } = mkSession(ch);
|
||||
// Regression for #407: an unhandled ssh2 Client 'error' event crashes
|
||||
// the whole Node process. ConsoleSession must own a listener so a
|
||||
// dropped transport closes only this session.
|
||||
expect(() => client.emit('error', new Error('read ECONNRESET'))).not.toThrow();
|
||||
// close() runs on the next tick via the catch-less promise chain.
|
||||
await Promise.resolve();
|
||||
expect(session.isClosed).toBe(true);
|
||||
expect(audit.beginAndComplete).toHaveBeenCalledTimes(1);
|
||||
expect(audit.beginAndComplete.mock.calls[0]![0].detail.reason).toBe('host_disconnect');
|
||||
});
|
||||
|
||||
it('client "close" tears the session down once', async () => {
|
||||
const ch = new StubChannel();
|
||||
const { session, audit, client } = mkSession(ch);
|
||||
client.emit('close');
|
||||
await Promise.resolve();
|
||||
expect(session.isClosed).toBe(true);
|
||||
expect(audit.beginAndComplete).toHaveBeenCalledTimes(1);
|
||||
expect(audit.beginAndComplete.mock.calls[0]![0].detail.reason).toBe('host_disconnect');
|
||||
});
|
||||
|
||||
it('scrollback caps at scrollbackCap', () => {
|
||||
const ch = new StubChannel();
|
||||
const { session } = mkSession(ch);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import type { Terminal as HeadlessTerminalType } from '@xterm/headless';
|
||||
import type { ClientChannel } from 'ssh2';
|
||||
import type { Client, ClientChannel } from 'ssh2';
|
||||
import { ByteRingBuffer } from './ring-buffer.js';
|
||||
/** Where an input chunk came from — drives audit `source` + back-pressure label. */
|
||||
export type InputSource = 'human' | 'ai';
|
||||
@ -26,6 +26,13 @@ export interface ConsoleSessionArgs {
|
||||
rows: number;
|
||||
scrollbackCap: number;
|
||||
channel: ClientChannel;
|
||||
/**
|
||||
* The ssh2 Client backing `channel`. The session owns it: it ends the
|
||||
* client on close() (so the underlying connection/socket doesn't leak)
|
||||
* and reacts to client-level 'error'/'close' so a dropped transport tears
|
||||
* the session down instead of leaving a half-dead session registered.
|
||||
*/
|
||||
client: Client;
|
||||
auditRepo: SshAuditRepo;
|
||||
}
|
||||
|
||||
@ -108,6 +115,7 @@ export class ConsoleSession {
|
||||
rows: number;
|
||||
|
||||
private readonly channel: ClientChannel;
|
||||
private readonly client: Client;
|
||||
private readonly headless: HeadlessTerminal;
|
||||
private readonly scrollback: ByteRingBuffer;
|
||||
private readonly auditRepo: SshAuditRepo;
|
||||
@ -131,6 +139,7 @@ export class ConsoleSession {
|
||||
this.cols = args.cols;
|
||||
this.rows = args.rows;
|
||||
this.channel = args.channel;
|
||||
this.client = args.client;
|
||||
this.scrollback = new ByteRingBuffer(args.scrollbackCap);
|
||||
this.auditRepo = args.auditRepo;
|
||||
this.headless = new HeadlessTerminal({
|
||||
@ -144,13 +153,29 @@ export class ConsoleSession {
|
||||
});
|
||||
|
||||
this.channel.on('data', (data: Buffer) => this.handleOutput(data));
|
||||
this.channel.on('close', () => {
|
||||
if (!this.closing) {
|
||||
this.close('host_disconnect').catch((e) =>
|
||||
logger.warn(`[console-session] close error: ${(e as Error).message}`),
|
||||
);
|
||||
}
|
||||
this.channel.on('close', () => this.onTransportDown());
|
||||
|
||||
// The Client (connection) can fail independently of the channel:
|
||||
// ssh2 re-emits transport-level socket errors (ECONNRESET on idle
|
||||
// timeout, network drop) as an 'error' on the Client. Without a
|
||||
// listener Node treats an 'error' event as fatal and kills the whole
|
||||
// process — this handler keeps a dropped connection to a single
|
||||
// session-scoped teardown. 'close' covers a graceful peer disconnect.
|
||||
this.client.on('error', (e: Error) => {
|
||||
logger.warn(
|
||||
`[console-session] client error task=${this.localTaskId}: ${(e as Error).message}`,
|
||||
);
|
||||
this.onTransportDown();
|
||||
});
|
||||
this.client.on('close', () => this.onTransportDown());
|
||||
}
|
||||
|
||||
/** Tear the session down once when the underlying SSH transport drops. */
|
||||
private onTransportDown(): void {
|
||||
if (this.closing) return;
|
||||
this.close('host_disconnect').catch((e) =>
|
||||
logger.warn(`[console-session] close error: ${(e as Error).message}`),
|
||||
);
|
||||
}
|
||||
|
||||
get lastActivityAt(): number {
|
||||
@ -275,6 +300,15 @@ export class ConsoleSession {
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
try {
|
||||
// End the owning Client too — channel.end() only closes the shell
|
||||
// channel, the TCP connection stays up until the client is ended.
|
||||
// Guarded by `closing` above so the resulting 'close' event is a
|
||||
// no-op in onTransportDown.
|
||||
this.client.end();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
try {
|
||||
this.headless.dispose();
|
||||
} catch {
|
||||
|
||||
@ -219,15 +219,42 @@ async function openClient(
|
||||
const settle = (err: Error | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
client.removeAllListeners('ready');
|
||||
client.removeAllListeners('error');
|
||||
client.removeAllListeners('close');
|
||||
if (err) reject(err);
|
||||
else resolve(client);
|
||||
// Detach the handshake-only listeners with the exact references
|
||||
// (removeAllListeners would also strip the permanent error handler).
|
||||
client.removeListener('ready', onReady);
|
||||
client.removeListener('close', onClose);
|
||||
if (err) {
|
||||
// Failed handshake: the caller destroys the socket and never uses
|
||||
// this client, so drop the error handler too.
|
||||
client.removeListener('error', onError);
|
||||
reject(err);
|
||||
} else {
|
||||
// Success: keep `onError` attached. The client can outlive this
|
||||
// call (an interactive console keeps it alive for the whole
|
||||
// session), and ssh2 re-emits transport-level failures
|
||||
// (ECONNRESET on idle timeout, network drop) as an 'error' event
|
||||
// on the Client. A Node 'error' event with no listener is fatal to
|
||||
// the entire process — this permanent handler is the safety net
|
||||
// that turns a dropped connection into a log line instead of a
|
||||
// crash. The live-client owner (ConsoleSession / exec finally)
|
||||
// drives the actual teardown.
|
||||
resolve(client);
|
||||
}
|
||||
};
|
||||
client.once('ready', () => settle(null));
|
||||
client.once('error', (err: Error) => settle(err));
|
||||
client.once('close', () => settle(new Error('connection_closed_during_handshake')));
|
||||
const onReady = () => settle(null);
|
||||
const onClose = () => settle(new Error('connection_closed_during_handshake'));
|
||||
const onError = (err: Error) => {
|
||||
if (!settled) {
|
||||
settle(err);
|
||||
} else {
|
||||
logger.warn(
|
||||
`[ssh:session] client error after handshake conn=${connection.id}: ${sanitizeError(err).message}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
client.on('error', onError);
|
||||
client.once('ready', onReady);
|
||||
client.once('close', onClose);
|
||||
try {
|
||||
client.connect(config);
|
||||
} catch (e) {
|
||||
|
||||
@ -702,7 +702,7 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC
|
||||
src={imageSrc}
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
className="w-full rounded-lg border-0"
|
||||
style={{ height: '72vh' }}
|
||||
style={{ height: '80vh' }}
|
||||
title={name}
|
||||
/>
|
||||
);
|
||||
@ -713,13 +713,13 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC
|
||||
src={imageSrc}
|
||||
type="application/pdf"
|
||||
className="w-full rounded-lg"
|
||||
style={{ height: '72vh' }}
|
||||
style={{ height: '80vh' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<img src={imageSrc} alt={name} className="max-w-full max-h-[72vh] object-contain rounded-lg" />
|
||||
<img src={imageSrc} alt={name} className="max-w-full max-h-[80vh] object-contain rounded-lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -729,8 +729,9 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC
|
||||
return <pre className="text-xs whitespace-pre-wrap break-all">{currentContent.slice(0, 100000)}</pre>;
|
||||
})();
|
||||
|
||||
const isMarkdown = /\.(md|markdown)$/i.test(name);
|
||||
const modalWidth = isMarkdown ? 'min(1400px, 96vw)' : 'min(1000px, 94vw)';
|
||||
// All preview types share the wide modal so HTML / PDF / images / tables get
|
||||
// the same generous viewport as Markdown (previously non-MD was ~40% narrower).
|
||||
const modalWidth = 'min(1400px, 96vw)';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-[env(safe-area-inset-top)_env(safe-area-inset-right)_env(safe-area-inset-bottom)_env(safe-area-inset-left)]" onClick={onClose}>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user