@awak-app/simy-cli 0.7.23 → 0.7.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/package.json +2 -2
- package/src/agent.js +16 -1
- package/src/auto-update.js +3 -2
- package/src/browser-handoff.js +14 -3
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/consistency-handoff.mjs +45 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +43 -7
- package/src/codex-plugins/simy/hooks/loop-kernel/work-frontier.mjs +7 -5
- package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +39 -1
- package/src/connection-health.js +253 -0
- package/src/connection-overview.js +58 -0
- package/src/connection-presentation.js +85 -0
- package/src/connection-recovery.js +102 -0
- package/src/console/app.js +60 -6
- package/src/console/commands.js +3 -0
- package/src/console/index.js +1 -0
- package/src/delivery-hooks/legacy-hook-routing.js +24 -0
- package/src/delivery-hooks/supervisor.js +14 -14
- package/src/desktop-host.js +43 -8
- package/src/desktop-status.js +9 -0
- package/src/direct-executor.js +8 -2
- package/src/doctor.js +1 -0
- package/src/index.js +12 -2
- package/src/remote-connection-guide.js +222 -0
- package/src/session-supervision/analysis-manager.js +24 -4
- package/src/session-supervision/automatic-continuation.js +69 -18
- package/src/session-supervision/codex-app-server.js +52 -7
- package/src/session-supervision/codex-peer-opinion.js +27 -5
- package/src/session-supervision/consistency-auditor.js +1 -1
- package/src/session-supervision/continuation-analysis.js +7 -3
- package/src/session-supervision/decision-presentation.js +1 -1
- package/src/session-supervision/index.js +21 -13
- package/src/session-supervision/outcome-plan.js +76 -0
- package/src/session-supervision/purpose-instruction.js +28 -0
- package/src/session-supervision/recovery-frontier.js +14 -4
- package/src/session-supervision/remote-command-queue.js +15 -0
- package/src/session-supervision/remote-control.js +3 -0
- package/src/session-supervision/remote-hosts.js +70 -19
- package/src/session-supervision/remote-managed-delivery.js +94 -0
- package/src/session-supervision/remote-session-list.js +1 -1
- package/src/session-supervision/statistics.js +10 -3
- package/src/session-supervision/work-state.js +5 -0
- package/src/sqm/bundle-store.js +30 -2
- package/src/sqm/provider-plugin-status.js +21 -3
- package/src/web-origin.js +7 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { lookup } from 'node:dns/promises';
|
|
3
|
+
import { networkInterfaces, homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { readSession, isSessionValid } from './session-store.js';
|
|
7
|
+
import { readProviderLinks } from './provider-links.js';
|
|
8
|
+
import { inspectProviderStatus } from './desktop-status.js';
|
|
9
|
+
import { resolveWebOrigin } from './web-origin.js';
|
|
10
|
+
|
|
11
|
+
const execute = promisify(execFile);
|
|
12
|
+
const aliasPattern = /^[a-zA-Z0-9][a-zA-Z0-9._@-]{0,127}$/;
|
|
13
|
+
export const shellQuote = value => "'" + String(value).replaceAll("'", "'\\''") + "'";
|
|
14
|
+
const clean = value => String(value).replace(/[\x00-\x1f\x7f-\x9f]/g, '').slice(0, 180);
|
|
15
|
+
const check = (layer, state, code, message) => ({ layer, state, code, message });
|
|
16
|
+
|
|
17
|
+
// Fixed messages deliberately exclude response bodies, SSH output and credentials.
|
|
18
|
+
const remedies = {
|
|
19
|
+
dns: ['名前解決に失敗しました', '接続先名・SSHのHostName・DNS設定を確認してください。社内名やVPN内の名前なら、必要なVPNに接続して再確認してください。'],
|
|
20
|
+
proxy_dns: ['プロキシの名前解決に失敗しました', 'HTTP_PROXY・HTTPS_PROXY・ALL_PROXYの接続先名と、その名前を解決するVPN/DNSを確認してください。'],
|
|
21
|
+
proxy_auth: ['プロキシ認証が必要です', 'プロキシの認証期限・アカウントを確認し、組織指定の認証方法で更新してください。'],
|
|
22
|
+
proxy_config: ['プロキシ設定を解釈できません', 'プロキシ環境変数のURL形式・ポート・対応方式を確認してください。'],
|
|
23
|
+
proxy_connect: ['プロキシ経由の接続に失敗しました', 'プロキシへの到達性と転送先の許可、NO_PROXYの適用範囲を確認してください。'],
|
|
24
|
+
refused: ['接続先が接続を拒否しました', '接続先のサービスが起動しているか、ポートと待受アドレス、拒否するファイアウォール規則を確認してください。'],
|
|
25
|
+
route: ['接続先への経路がありません', 'ネットワーク・VPN・ルーティングと接続先の稼働状態を確認してください。'],
|
|
26
|
+
timeout: ['接続がタイムアウトしました', 'VPN・ファイアウォール・接続先停止が候補です。タイムアウトだけでは原因を特定できません。経路と接続先の受信規則を確認してください。'],
|
|
27
|
+
reset: ['接続が途中で切断されました', '接続先サービス・プロキシ・VPNの状態を確認し、診断を再実行してください。'],
|
|
28
|
+
tls_expired: ['証明書の期限または端末時刻に問題があります', '端末の日付と時刻を確認してください。期限切れのサーバー証明書は管理者が更新し、更新後に再確認してください。'],
|
|
29
|
+
tls_name: ['証明書と接続先名が一致しません', '証明書が対象とする正しいDNS名を使うか、管理者に証明書の修正を依頼してください。'],
|
|
30
|
+
tls_trust: ['証明書の信頼を検証できません', '証明書チェーンを管理者に確認してください。社内CAを使う場合は正しいCAをCURL_CA_BUNDLEまたはSSL_CERT_FILEに設定して再確認してください。'],
|
|
31
|
+
tls_client: ['クライアント証明書を利用できません', 'サービスが要求するクライアント証明書の有効期限・秘密鍵・読取権限を確認してください。'],
|
|
32
|
+
tls: ['TLS接続に失敗しました', '接続先のHTTPSポート・TLS対応・証明書と、TLSを中継するプロキシを確認してください。'],
|
|
33
|
+
key_changed: ['SSHホスト鍵が以前と異なります', '相手側でssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pubを実行し、正しい端末か管理者と照合してからknown_hostsを更新してください。'],
|
|
34
|
+
key_unknown: ['SSHホスト鍵が未確認です', '相手側のホスト鍵指紋を確認したうえで、通常のssh接続で初回登録してください。'],
|
|
35
|
+
key_permissions: ['SSH秘密鍵の権限が広すぎます', '自分が所有する秘密鍵のアクセス権を本人のみ読書き可能に修正してください。秘密鍵の内容を共有する必要はありません。'],
|
|
36
|
+
key_missing: ['SSH秘密鍵を読み取れません', 'SSH設定のIdentityFile、鍵ファイルの存在・所有者・読取権限を確認してください。'],
|
|
37
|
+
ssh_certificate: ['SSH証明書が無効または期限切れです', '端末時刻とSSH証明書の期限を確認し、発行元の手順で更新してください。'],
|
|
38
|
+
ssh_auth: ['SSH認証に失敗しました', 'SSHのUser・IdentityFile・相手側のauthorized_keysを確認してください。パスフレーズが必要な鍵はssh-agentへ読み込みます。'],
|
|
39
|
+
ssh_agent: ['SSHエージェントが署名できません', 'ssh-add -lでエージェントの鍵を確認し、鍵のロックやハードウェアキーの状態を確認してください。'],
|
|
40
|
+
ssh_config: ['SSH設定を読み取れません', 'SSHのHost・HostName・Port・ProxyJump・ProxyCommandの設定とIncludeファイルを確認してください。'],
|
|
41
|
+
ssh_forward: ['SSHのポート転送が許可されていません', '相手側のAllowTcpForwarding・PermitListen・鍵ごとの転送制限を管理者に確認してください。'],
|
|
42
|
+
ssh_missing: ['OpenSSHが見つかりません', 'この端末にOpenSSHクライアントをインストールして再確認してください。'],
|
|
43
|
+
cli_missing: ['相手のSIMY CLIが見つかりません', '相手側にSIMY CLIをインストールし、SSHのログインシェルのPATHでsimyが見つかることを確認してください。'],
|
|
44
|
+
auth: ['認証が必要、または期限切れです', 'サービスのログイン・OAuth再認証またはAPIキー更新を行ってください。APIキーは--token-envで環境変数名を指定できます。'],
|
|
45
|
+
permission: ['接続先がアクセスを拒否しました(403)', '対象アカウント・組織・APIスコープとサービス側のアクセス規則を確認してください。403だけでは権限不足とWAFによる拒否を区別できません。'],
|
|
46
|
+
rate_limit: ['サービスの利用制限に達しました(429)', 'サービスの利用制限と再試行時刻を確認し、待ってから再確認してください。'],
|
|
47
|
+
service: ['接続先サービスで障害が発生しています', 'サービスの稼働状況を確認し、復旧後に再確認してください。'],
|
|
48
|
+
endpoint: ['APIの接続先が見つかりません', 'APIのURL・バージョン・パスが正しいか確認してください。'],
|
|
49
|
+
method: ['この接続先はHEAD診断に対応していません', 'サービスが公開する読取専用のヘルスチェックURLで再確認してください。'],
|
|
50
|
+
redirect: ['別のページへ転送されました', 'ログイン画面または移転先の可能性があります。サービスの正しい接続先と認証方法を確認してください。'],
|
|
51
|
+
curl_missing: ['Web診断用のcurlが見つかりません', 'curlをインストールして再確認してください。'],
|
|
52
|
+
unknown: ['接続状態を確定できません', '対象の接続先と設定を確認し、診断を再実行してください。'],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export function issueFor(code, layer = 'connection', state = 'failed') {
|
|
56
|
+
const [message, instruction] = remedies[code] || remedies.unknown;
|
|
57
|
+
return { ...check(layer, state, code, message), instruction };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function classifySshFailure(error) {
|
|
61
|
+
const text = `${error?.stderr || ''}\n${error?.stdout || ''}`;
|
|
62
|
+
if (error?.code === 'ENOENT') return 'ssh_missing';
|
|
63
|
+
if (/REMOTE HOST IDENTIFICATION HAS CHANGED|Offending .* key/i.test(text)) return 'key_changed';
|
|
64
|
+
if (/Host key verification failed|No .* host key is known/i.test(text)) return 'key_unknown';
|
|
65
|
+
if (/UNPROTECTED PRIVATE KEY|bad permissions|Permissions .* too open/i.test(text)) return 'key_permissions';
|
|
66
|
+
if (/Certificate invalid|certificate has expired|certificate is not yet valid/i.test(text)) return 'ssh_certificate';
|
|
67
|
+
if (/sign_and_send_pubkey.*signing failed|agent refused operation/i.test(text)) return 'ssh_agent';
|
|
68
|
+
if (/Load key .* (?:Permission denied|invalid format)|no such identity|Identity file .* not accessible/i.test(text)) return 'key_missing';
|
|
69
|
+
if (/Permission denied|Too many authentication failures|Authentication failed/i.test(text)) return 'ssh_auth';
|
|
70
|
+
if (/Could not resolve hostname|Name or service not known|Temporary failure in name resolution/i.test(text)) return 'dns';
|
|
71
|
+
if (/Connection refused/i.test(text)) return 'refused';
|
|
72
|
+
if (/No route to host|Network is unreachable/i.test(text)) return 'route';
|
|
73
|
+
if (/timed out|timeout/i.test(text) || error?.killed) return 'timeout';
|
|
74
|
+
if (/administratively prohibited|remote port forwarding failed/i.test(text)) return 'ssh_forward';
|
|
75
|
+
if (/Bad configuration|Bad port|unsupported option|Bad owner or permissions on/i.test(text)) return 'ssh_config';
|
|
76
|
+
if (/Connection reset|Connection closed|kex_exchange_identification/i.test(text)) return 'reset';
|
|
77
|
+
if (text.includes('SIMY_CLI_MISSING')) return 'cli_missing';
|
|
78
|
+
return 'unknown';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function classifyWebFailure(result) {
|
|
82
|
+
const error = String(result.stderr || '');
|
|
83
|
+
if (result.missing) return 'curl_missing';
|
|
84
|
+
if (result.exitCode) {
|
|
85
|
+
if (/Unsupported proxy syntax|Unsupported proxy|Malformed.*proxy/i.test(error)) return 'proxy_config';
|
|
86
|
+
if (result.exitCode === 5) return 'proxy_dns';
|
|
87
|
+
if (result.exitCode === 6) return 'dns';
|
|
88
|
+
if (result.exitCode === 28) return 'timeout';
|
|
89
|
+
if (result.exitCode === 58) return 'tls_client';
|
|
90
|
+
if ([60, 77, 83, 90].includes(result.exitCode)) {
|
|
91
|
+
if (/expired|not yet valid/i.test(error)) return 'tls_expired';
|
|
92
|
+
if (/no alternative certificate subject|does not match|subject name/i.test(error)) return 'tls_name';
|
|
93
|
+
return 'tls_trust';
|
|
94
|
+
}
|
|
95
|
+
if (result.exitCode === 35) return 'tls';
|
|
96
|
+
if (result.exitCode === 97 || result.connectStatus >= 400) return result.connectStatus === 407 ? 'proxy_auth' : 'proxy_connect';
|
|
97
|
+
if (/Connection refused|Couldn't connect to server/i.test(error) && result.exitCode === 7) return /Connection refused/i.test(error) ? 'refused' : 'unknown';
|
|
98
|
+
if (/No route to host|Network is unreachable/i.test(error)) return 'route';
|
|
99
|
+
if ([52, 55, 56].includes(result.exitCode)) return 'reset';
|
|
100
|
+
return 'unknown';
|
|
101
|
+
}
|
|
102
|
+
const status = result.httpStatus;
|
|
103
|
+
if (status >= 200 && status < 300) return null;
|
|
104
|
+
if (status === 401) return 'auth';
|
|
105
|
+
if (status === 403) return 'permission';
|
|
106
|
+
if (status === 407) return 'proxy_auth';
|
|
107
|
+
if (status === 404) return 'endpoint';
|
|
108
|
+
if (status === 405 || status === 501) return 'method';
|
|
109
|
+
if (status === 429) return 'rate_limit';
|
|
110
|
+
if (status >= 500) return 'service';
|
|
111
|
+
if (status >= 300 && status < 400) return 'redirect';
|
|
112
|
+
return 'unknown';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function parseConnectionTarget(value = 'simy', webOrigin = resolveWebOrigin()) {
|
|
116
|
+
if (value === 'simy') return { kind: 'simy', url: new URL(webOrigin), display: 'SIMY' };
|
|
117
|
+
if (['provider:codex', 'provider:claude'].includes(value)) return { kind: 'provider', provider: value.slice(9), display: value };
|
|
118
|
+
if (/^https?:\/\//i.test(value)) {
|
|
119
|
+
const url = new URL(value);
|
|
120
|
+
if (url.username || url.password || /[\x00-\x20\x7f]/.test(value)) throw new Error('ログイン名やパスワードを含まないWebアドレスを指定してください。');
|
|
121
|
+
url.hash = '';
|
|
122
|
+
return { kind: 'web', url, display: clean(url.origin) };
|
|
123
|
+
}
|
|
124
|
+
const alias = value.replace(/^ssh:/, '');
|
|
125
|
+
if (!aliasPattern.test(alias)) throw new Error('接続先を確認してください。SIMYを調べるなら simy connections recover、別のパソコンを調べるなら simy connections で一覧を確認してください。');
|
|
126
|
+
return { kind: 'ssh', alias, display: alias };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function networkContext(env = process.env, interfaces = networkInterfaces()) {
|
|
130
|
+
return {
|
|
131
|
+
activeInterfaceCount: Object.values(interfaces).filter(addresses => addresses?.some(address => !address.internal)).length,
|
|
132
|
+
vpnInterfaces: Object.entries(interfaces).filter(([name, addresses]) => /^(tun|tap|utun|wg|tailscale|ppp)/i.test(name) && addresses?.some(a => !a.internal)).map(([name]) => clean(name)),
|
|
133
|
+
proxyConfigured: Boolean(env.https_proxy || env.HTTPS_PROXY || env.http_proxy || env.HTTP_PROXY || env.all_proxy || env.ALL_PROXY),
|
|
134
|
+
noProxyConfigured: Boolean(env.no_proxy || env.NO_PROXY),
|
|
135
|
+
customCaConfigured: Boolean(env.CURL_CA_BUNDLE || env.SSL_CERT_FILE || env.SSL_CERT_DIR),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// curl applies the user's proxy/NO_PROXY and CA configuration. Credentials and
|
|
140
|
+
// query strings travel over stdin, not child argv; redirects are never followed.
|
|
141
|
+
export function probeWeb(url, { token, env = process.env, spawnImpl = spawn, timeoutMs = 10_000 } = {}) {
|
|
142
|
+
if (token && /[\r\n\x00]/.test(token)) throw new Error('APIトークンに改行を含めることはできません。');
|
|
143
|
+
const configQuote = value => String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"');
|
|
144
|
+
const config = `url = "${configQuote(url.href)}"\n` + (token ? `header = "Authorization: Bearer ${configQuote(token)}"\n` : '');
|
|
145
|
+
return new Promise(resolve => {
|
|
146
|
+
let done = false, stdout = '', stderr = '', timer;
|
|
147
|
+
const finish = result => { if (done) return; done = true; clearTimeout(timer); resolve(result); };
|
|
148
|
+
const child = spawnImpl('curl', ['--disable', '--config', '-', '--head', '--silent', '--show-error',
|
|
149
|
+
'--proto', '=http,https', '--connect-timeout', '5', '--max-time', String(timeoutMs / 1000), '--max-redirs', '0',
|
|
150
|
+
'--output', process.platform === 'win32' ? 'NUL' : '/dev/null', '--write-out', '%{http_code}\n%{http_connect}\n%{ssl_verify_result}\n'],
|
|
151
|
+
{ stdio: ['pipe', 'pipe', 'pipe'], shell: false, env: { ...env, ...(env.HTTP_PROXY && !env.http_proxy ? { http_proxy: env.HTTP_PROXY } : {}) }, windowsHide: true });
|
|
152
|
+
timer = setTimeout(() => { child.kill(); finish({ exitCode: 28, httpStatus: 0, stderr: '' }); }, timeoutMs + 1000);
|
|
153
|
+
child.on('error', error => finish({ exitCode: 1, missing: error.code === 'ENOENT', httpStatus: 0, stderr: '' }));
|
|
154
|
+
child.stdin.on('error', () => {});
|
|
155
|
+
child.stdout.on('data', data => { stdout = (stdout + data).slice(-4096); });
|
|
156
|
+
child.stderr.on('data', data => { stderr = (stderr + data).slice(-8192); });
|
|
157
|
+
child.on('close', code => {
|
|
158
|
+
const [status, connect, verify] = stdout.trim().split(/\r?\n/).map(Number);
|
|
159
|
+
finish({ exitCode: Number.isInteger(code) ? code : 1, httpStatus: status || 0, connectStatus: connect || 0, tlsVerifyResult: verify, stderr });
|
|
160
|
+
});
|
|
161
|
+
child.stdin.end(config);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function dnsCheck(host, lookupImpl) {
|
|
166
|
+
let timer;
|
|
167
|
+
try {
|
|
168
|
+
await Promise.race([lookupImpl(host.replace(/^\[|\]$/g, '')), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timeout')), 3000); })]);
|
|
169
|
+
return check('dns', 'passed', 'dns_resolved', '接続先名を解決できました');
|
|
170
|
+
} catch { return issueFor('dns', 'dns'); }
|
|
171
|
+
finally { clearTimeout(timer); }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function diagnoseConnection(value, { webOrigin = resolveWebOrigin(), root = process.env.SIMY_HOME?.trim() || join(homedir(), '.simy'),
|
|
175
|
+
env = process.env, tokenEnv, executeImpl = execute, probeWebImpl = probeWeb, lookupImpl = lookup,
|
|
176
|
+
inspectProvider = inspectProviderStatus, readLinks = readProviderLinks, readSessionImpl = readSession,
|
|
177
|
+
interfaces = networkInterfaces(), now = Date.now() } = {}) {
|
|
178
|
+
const target = parseConnectionTarget(value, webOrigin);
|
|
179
|
+
if (tokenEnv && (target.kind !== 'web' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(tokenEnv) || !env[tokenEnv])) throw new Error('接続キーの指定を読み取れませんでした。--token-env は外部Webサービス用です。接続キーを保存した設定の名前を指定してください。分からない場合はサービスの担当者に確認してください。');
|
|
180
|
+
if (tokenEnv && target.url.protocol !== 'https:' && !['localhost', '127.0.0.1', '[::1]'].includes(target.url.hostname)) throw new Error('接続キーを送るときは、https:// で始まる安全なWebアドレス(HTTPS)を指定してください。');
|
|
181
|
+
const report = { schemaVersion: 1, target: { kind: target.kind, display: target.display }, status: 'unverified', checks: [], context: networkContext(env, interfaces), actions: [] };
|
|
182
|
+
const addAction = (id, title, instructions, command) => report.actions.push({ id, title, instructions, ...(command ? { command } : {}) });
|
|
183
|
+
if (target.kind === 'ssh') {
|
|
184
|
+
let config;
|
|
185
|
+
try {
|
|
186
|
+
const resolved = await executeImpl('ssh', ['-G', '--', target.alias], { timeout: 5000, maxBuffer: 256 * 1024, env });
|
|
187
|
+
const fields = new Map(resolved.stdout.split(/\r?\n/).map(line => { const at = line.indexOf(' '); return [line.slice(0, at), line.slice(at + 1)]; }));
|
|
188
|
+
config = { hostname: fields.get('hostname'), port: Number(fields.get('port')), jump: fields.get('proxyjump'), proxy: fields.get('proxycommand') };
|
|
189
|
+
report.checks.push(check('ssh_config', 'passed', 'ssh_config_resolved', '現在のSSH設定を読み取りました'));
|
|
190
|
+
if (config.hostname && ![config.jump, config.proxy].some(v => v && v !== 'none')) {
|
|
191
|
+
report.checks.push(await dnsCheck(config.hostname, lookupImpl));
|
|
192
|
+
} else report.checks.push(check('dns', 'unknown', 'ssh_proxy_dns', '踏み台・プロキシ経由の名前解決はSSH接続で確認します'));
|
|
193
|
+
const result = await executeImpl('ssh', ['-T', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=8', '--', target.alias,
|
|
194
|
+
"sh -lc 'if command -v simy >/dev/null 2>&1; then printf SIMY_CLI_PRESENT; else printf SIMY_CLI_MISSING; exit 127; fi'"],
|
|
195
|
+
{ timeout: 12_000, maxBuffer: 64 * 1024, env });
|
|
196
|
+
if (!result.stdout?.endsWith('SIMY_CLI_PRESENT')) throw new Error('unexpected probe');
|
|
197
|
+
// Successful SSH can resolve a name unavailable to the local DNS probe.
|
|
198
|
+
report.checks = report.checks.filter(c => !(c.layer === 'dns' && c.state === 'failed'));
|
|
199
|
+
report.checks.push(check('ssh', 'passed', 'ssh_ready', 'SSH認証と相手のSIMY CLIを確認しました'));
|
|
200
|
+
report.checks.push(check('cli_auth', 'unknown', 'cli_auth_unverified', '相手のSIMYログインはCLIを開いて確認してください'));
|
|
201
|
+
addAction('open_cli', '相手のCLIを開く', '相手側のログイン・組織を確認して接続します。', `simy connections connect ${target.alias}`);
|
|
202
|
+
} catch (error) {
|
|
203
|
+
const code = classifySshFailure(error);
|
|
204
|
+
report.checks.push(issueFor(code, 'ssh'));
|
|
205
|
+
if (['ssh_auth', 'ssh_agent', 'key_missing', 'key_permissions'].includes(code)) {
|
|
206
|
+
addAction('ssh_login', 'SSHの対話認証を試す', 'パスフレーズや本人の操作が必要な鍵を使って再認証し、その後に再確認します。', `ssh -T -o StrictHostKeyChecking=yes -- ${target.alias} true`);
|
|
207
|
+
}
|
|
208
|
+
if (['timeout', 'route', 'refused'].includes(code)) addAction('reverse', '逆向き経路を利用する', '接続を開始できる側で相手のSSHエイリアスを指定します。', 'simy connections tunnel <相手のSSHエイリアス>');
|
|
209
|
+
}
|
|
210
|
+
} else if (target.kind === 'provider') {
|
|
211
|
+
const state = await inspectProvider(target.provider).catch(() => ({ state: 'unknown' }));
|
|
212
|
+
const links = await readLinks(root).catch(() => ({}));
|
|
213
|
+
if (state.state === 'ready') report.checks.push(check('provider_auth', 'passed', 'provider_ready', 'CLIのインストールと保存されたログインを確認しました'));
|
|
214
|
+
else if (state.state === 'signed_out') {
|
|
215
|
+
report.checks.push(issueFor('auth', 'provider_auth'));
|
|
216
|
+
addAction('provider_login', 'プロバイダーにログインする', 'プロバイダーの公式ログインを起動し、完了後に再確認します。', target.provider === 'codex' ? 'codex login' : 'claude auth login');
|
|
217
|
+
} else {
|
|
218
|
+
report.checks.push(check('provider_auth', 'failed', 'provider_unavailable', 'CLIが未インストール・更新待ち、またはログイン状態を確認できません'));
|
|
219
|
+
addAction('provider_setup', '公式CLIのインストール・更新を確認', 'プロバイダー公式のインストール手順でCLIを導入・更新し、PATHとログイン状態を確認してください。');
|
|
220
|
+
}
|
|
221
|
+
if (links[target.provider]?.connected === true) report.checks.push(check('provider_link', 'passed', 'provider_linked', 'SIMYとの連携は有効です'));
|
|
222
|
+
else {
|
|
223
|
+
report.checks.push(check('provider_link', 'failed', 'provider_disconnected', 'SIMYとの連携が無効、または設定を読み取れません'));
|
|
224
|
+
if (links[target.provider]?.connected === false && !links[target.provider]?.settingsError) addAction('provider_link', 'SIMYとの連携を有効にする', 'このプロバイダーをSIMYに再接続します。会話の実行許可は別途設定します。');
|
|
225
|
+
}
|
|
226
|
+
report.checks.push(check('provider_service', 'unknown', 'provider_api_unverified', 'API接続・モデルの利用権限はサービスのURLを指定して診断してください'));
|
|
227
|
+
} else {
|
|
228
|
+
report.checks.push(await dnsCheck(target.url.hostname, lookupImpl));
|
|
229
|
+
const web = await Promise.resolve().then(() => probeWebImpl(target.url, { token: tokenEnv ? env[tokenEnv] : undefined, env })).catch(() => ({ exitCode: 1, httpStatus: 0 }));
|
|
230
|
+
const code = classifyWebFailure(web);
|
|
231
|
+
if (!web.exitCode && web.httpStatus > 0) report.checks = report.checks.filter(c => !(c.layer === 'dns' && c.state === 'failed'));
|
|
232
|
+
if (!web.exitCode && web.httpStatus > 0) report.checks.push(check('http_transport', 'passed', 'http_reached', `HTTP応答を受信しました(${web.httpStatus})`));
|
|
233
|
+
report.checks.push(code ? issueFor(code, 'web', ['method', 'redirect', 'unknown'].includes(code) ? 'unknown' : 'failed') : check('web', 'passed', 'web_ready', '指定URLへのHEADリクエストが成功しました'));
|
|
234
|
+
if (target.kind === 'simy') {
|
|
235
|
+
const session = await readSessionImpl(webOrigin, root).catch(() => null);
|
|
236
|
+
if (isSessionValid(session, now, webOrigin)) report.checks.push(check('simy_auth', 'passed', 'session_current', '保存されたSIMY認証の期限は有効です(サーバー側の失効は未確認)'));
|
|
237
|
+
else {
|
|
238
|
+
report.checks.push(issueFor('auth', 'simy_auth'));
|
|
239
|
+
addAction('simy_login', 'SIMYの接続・ログインを開く', '別のターミナルで下のコマンドを実行し、表示されたブラウザで認証を完了してから戻って再確認してください。', `simy --daemon --host ${shellQuote(webOrigin)}`);
|
|
240
|
+
}
|
|
241
|
+
} else if (code === 'auth') addAction('web_login', 'サービスのログインページを開く', 'サービス側でログイン・APIキー・OAuthを更新して再確認します。ブラウザのログインとAPIキーは別の認証です。');
|
|
242
|
+
}
|
|
243
|
+
for (const item of report.checks.filter(c => c.instruction)) addAction(`resolve_${item.code}`, item.message, item.instruction);
|
|
244
|
+
if (report.checks.some(c => ['timeout', 'route', 'refused', 'dns'].includes(c.code))) {
|
|
245
|
+
addAction('network_settings', 'VPN・経路・ファイアウォールを確認', report.context.vpnInterfaces.length
|
|
246
|
+
? 'VPN用と思われるネットワークインターフェースはあります。対象のVPNへのログインと到達先ルート、相手側の受信規則を確認してください。'
|
|
247
|
+
: 'VPN接続・OSの経路設定・相手の稼働状態・必要なポートの受信規則を確認してください。VPNインターフェースがないことだけでVPN障害とは断定できません。');
|
|
248
|
+
}
|
|
249
|
+
report.status = report.checks.some(c => c.state === 'failed') ? 'needs_action'
|
|
250
|
+
: report.checks.some(c => ['web_ready', 'ssh_ready', 'provider_ready'].includes(c.code)) ? 'reachable' : 'unverified';
|
|
251
|
+
addAction('retry', '修復後に再確認', '設定・認証の更新後、同じ接続先を再診断します。');
|
|
252
|
+
return report;
|
|
253
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { hostname } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { defaultSessionRoot } from './session-store.js';
|
|
6
|
+
|
|
7
|
+
const safeText = (value, limit = 160) => typeof value === 'string' ? value.replace(/[\x00-\x1f\x7f]/g, '').slice(0, limit) : null;
|
|
8
|
+
const scopeKey = ({ endpoint, accountId, organizationId }) => {
|
|
9
|
+
if (!endpoint || !accountId || !organizationId) return null;
|
|
10
|
+
try { const url = new URL(endpoint); url.username = ''; url.password = ''; url.search = ''; url.hash = '';
|
|
11
|
+
return createHash('sha256').update(JSON.stringify([url.href, accountId, organizationId])).digest('hex');
|
|
12
|
+
} catch { return null; }
|
|
13
|
+
};
|
|
14
|
+
const observationPath = (scope, root) => { const key = scopeKey(scope); return key ? join(root, 'connection-observations', `${key}.json`) : null; };
|
|
15
|
+
|
|
16
|
+
// Only metadata of an actual online bundle lookup; never credentials, rules,
|
|
17
|
+
// executable checks or inferred cross-organization registrations.
|
|
18
|
+
export async function recordSqmConnectionObservation(scope, result, { root = process.env.SIMY_SQM_CACHE_DIR || join(defaultSessionRoot(), 'sqm'), now = Date.now() } = {}) {
|
|
19
|
+
const target = observationPath(scope, root);
|
|
20
|
+
if (!target || !['cloud', 'cache_revalidated', 'unavailable'].includes(result?.source)) return;
|
|
21
|
+
const bundle = result.bundle;
|
|
22
|
+
const verified = bundle?.organization_id === scope.organizationId && bundle?.system_scope?.organization_id === scope.organizationId;
|
|
23
|
+
const repository = safeText(scope.repository, 200);
|
|
24
|
+
if (!repository || !/^[^/\s]+\/[^/\s]+$/.test(repository)) return;
|
|
25
|
+
const value = { version: 1, state: verified ? 'verified' : 'unavailable', repository,
|
|
26
|
+
checkedAt: new Date(now).toISOString(), expiresAt: verified ? bundle.expires_at : null };
|
|
27
|
+
const temp = `${target}.${randomUUID()}.tmp`;
|
|
28
|
+
try { await mkdir(join(root, 'connection-observations'), { recursive: true, mode: 0o700 });
|
|
29
|
+
await writeFile(temp, JSON.stringify(value), { mode: 0o600 }); await rename(temp, target);
|
|
30
|
+
} finally { await rm(temp, { force: true }); }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function readSqmConnectionObservation(scope, { root = process.env.SIMY_SQM_CACHE_DIR || join(defaultSessionRoot(), 'sqm'), now = Date.now() } = {}) {
|
|
34
|
+
const target = observationPath(scope, root);
|
|
35
|
+
if (!target) return { state: 'unknown' };
|
|
36
|
+
try {
|
|
37
|
+
const raw = await readFile(target, 'utf8'); if (raw.length > 4096) return { state: 'unknown' };
|
|
38
|
+
const v = JSON.parse(raw), at = Date.parse(v.checkedAt);
|
|
39
|
+
if (v.version !== 1 || !['verified', 'unavailable'].includes(v.state) || !Number.isFinite(at) || at > now ||
|
|
40
|
+
typeof v.repository !== 'string' || !/^[^/\s]+\/[^/\s]+$/.test(v.repository) || v.repository.length > 200) return { state: 'unknown' };
|
|
41
|
+
const current = now - at <= 5 * 60 * 1000 && (v.state !== 'verified' || Date.parse(v.expiresAt) > now);
|
|
42
|
+
return { state: current ? v.state : 'previous', repository: safeText(v.repository, 200), checkedAt: v.checkedAt };
|
|
43
|
+
} catch { return { state: 'unknown' }; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function connectionEnvironment(origin) {
|
|
47
|
+
try { const url = new URL(origin); if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || url.pathname !== '/') return { kind: 'unknown', origin: null };
|
|
48
|
+
return { kind: url.origin === 'https://app.simy.one' ? 'production' : url.origin === 'https://app-dev.simy.one' ? 'development' : 'custom', origin: url.origin };
|
|
49
|
+
} catch { return { kind: 'unknown', origin: null }; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function connectionOverview({ webOrigin, session, access, signedIn, switching, sqm, deviceName = hostname() }) {
|
|
53
|
+
const scopeReady = signedIn && !switching && session?.organization_id && access?.organizationId === session.organization_id && access?.state === 'ready';
|
|
54
|
+
return { device: { name: safeText(deviceName) || null }, environment: connectionEnvironment(webOrigin),
|
|
55
|
+
organization: { name: signedIn && access?.organizationId === session?.organization_id ? safeText(access?.organizationName) : null,
|
|
56
|
+
state: switching ? 'switching' : scopeReady ? 'ready' : 'unknown' },
|
|
57
|
+
sqm: scopeReady ? (sqm || { state: 'unknown' }) : { state: 'unknown' } };
|
|
58
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Everyday guidance stays separate from the technical diagnostic record.
|
|
2
|
+
// JSON and the explicit details view retain the original codes for support.
|
|
3
|
+
const networkSteps = [
|
|
4
|
+
'このパソコンで、いつも見るWebサイトが開くか確認してください。開けない場合はWi-Fiやケーブルを確認してください。',
|
|
5
|
+
'接続先が別のパソコンなら、そちらの電源を入れ、スリープを解除してください。',
|
|
6
|
+
'会社専用の接続アプリ(VPN)を普段使う場合は、そのアプリで接続してください。終わったら「もう一度確認する」を選びます。',
|
|
7
|
+
];
|
|
8
|
+
const adminStep = '自分で設定した覚えがない場合は、接続先を用意した人や会社の担当者に「詳しい情報」を見せてください。';
|
|
9
|
+
const advice = {
|
|
10
|
+
dns: ['接続先の場所が見つかりませんでした。', ['入力した接続先の名前やWebアドレスに間違いがないか確認してください。', ...networkSteps]],
|
|
11
|
+
proxy_dns: ['会社などの中継サーバーが見つかりませんでした。', [networkSteps[0], networkSteps[2], adminStep]],
|
|
12
|
+
proxy_auth: ['会社などの中継サーバーでログインが必要です。', ['会社指定の接続アプリやログイン画面で、ログインし直してください。', adminStep]],
|
|
13
|
+
proxy_config: ['会社などの中継サーバーの設定を読み取れませんでした。', [adminStep]],
|
|
14
|
+
proxy_connect: ['会社などの中継サーバーを通って接続できませんでした。', [networkSteps[2], adminStep]],
|
|
15
|
+
refused: ['接続先は、今この接続を受け付けていません。', ['相手のパソコンやサービスが起動しているか確認してください。', '相手がMacの場合は「システム設定」で「リモートログイン」を検索し、オンになっているか確認してください。', adminStep]],
|
|
16
|
+
route: ['接続先にたどり着けませんでした。', networkSteps],
|
|
17
|
+
timeout: ['しばらく待ちましたが、接続先から返事がありませんでした。', networkSteps],
|
|
18
|
+
reset: ['途中で接続が切れました。', [networkSteps[0], '少し待ってから「もう一度確認する」を選んでください。何度も切れる場合は、接続先の担当者に確認してください。']],
|
|
19
|
+
tls_expired: ['接続先の安全確認に使う証明書の期限を確認できませんでした。', ['このパソコンの日付と時刻が正しいか確認してください。', '時刻が正しければ、接続先の担当者に証明書の期限を確認してもらってください。']],
|
|
20
|
+
tls_name: ['開こうとしている接続先と、安全確認の情報が一致しません。', ['Webアドレスが正しいか確認してください。', '正しい場合は、接続先の担当者に確認してください。安全確認を無効にして接続しないでください。']],
|
|
21
|
+
tls_trust: ['接続先が安全か確認できませんでした。', ['接続先の担当者に、安全確認に使う証明書の設定を確認してもらってください。', '会社独自の設定が必要な場合もあります。安全確認を無効にせず、担当者の手順に従ってください。']],
|
|
22
|
+
tls_client: ['このパソコンの接続許可を示す証明書が使えません。', [adminStep]],
|
|
23
|
+
tls: ['安全な通信を始められませんでした。', ['Webアドレスが正しいか確認してください。', adminStep]],
|
|
24
|
+
key_changed: ['相手のパソコンの身元を確認する情報が以前と変わっています。', ['接続先を管理する人に、パソコンの交換や設定変更があったか確認してください。', '同じ名前の別のパソコンの可能性もあります。確認できるまで、以前の情報を削除したり、安全確認を無効にしたりしないでください。']],
|
|
25
|
+
key_unknown: ['このパソコンから接続してよい相手か、まだ確認できていません。', ['接続先を用意した人に、相手の身元確認と最初の接続設定を手伝ってもらってください。', '知らない相手をそのまま許可せず、設定が終わってから「もう一度確認する」を選んでください。']],
|
|
26
|
+
key_permissions: ['接続に使う鍵のファイルを、安全に使える設定にする必要があります。', ['このパソコンの接続設定をした人に、鍵のファイルを自分だけが使える設定にしてもらってください。', '鍵のファイルの中身を送ったり、貼り付けたりする必要はありません。']],
|
|
27
|
+
key_missing: ['接続に使う鍵のファイルが見つからないか、読み取れません。', [adminStep, '鍵のファイルの中身は共有しないでください。']],
|
|
28
|
+
ssh_certificate: ['このパソコンの接続許可を更新する必要があります。', ['このパソコンの日付と時刻を確認してください。', '接続先を管理する人に、接続許可の更新方法を確認してください。']],
|
|
29
|
+
ssh_auth: ['相手のパソコンへのログインができませんでした。', ['「相手のパソコンにログインし直す」を選んでください。', 'パスワードなどを求められたら、このターミナルの案内に従って入力してください。入力中に文字が表示されないことがあります。', '分からない場合は、接続先を用意した人に確認してください。']],
|
|
30
|
+
ssh_agent: ['接続用の鍵がロックされているか、使用できません。', ['普段使う鍵の管理アプリや、USBなどで接続する認証機器を確認してください。', '「相手のパソコンにログインし直す」を選び、表示される案内に従ってください。']],
|
|
31
|
+
ssh_config: ['保存されている接続設定を読み取れませんでした。', [adminStep]],
|
|
32
|
+
ssh_forward: ['相手のパソコンが、一時的な接続経路の作成を許可していません。', ['接続先の担当者に、一時的な接続経路を使ってよいか確認してください。担当者向けの情報は「詳しい情報」で確認できます。']],
|
|
33
|
+
ssh_missing: ['別のパソコンに接続するためのソフトが見つかりません。', ['このパソコンにOpenSSHという接続用ソフトが必要です。設定を手伝う人にインストールを依頼してください。']],
|
|
34
|
+
cli_missing: ['相手のパソコンでSIMYを見つけられませんでした。', ['相手のパソコンでSIMYが使えるか確認してください。', '相手側で使えるのにこの表示が出る場合は、接続設定をした人に「詳しい情報」を見せてください。']],
|
|
35
|
+
auth: ['ログインが必要か、ログインの有効期限が切れています。', ['下のログインの操作を選び、開いた画面の案内に従ってください。', '外部サービス用の接続キーを使っている場合は、そのサービスの担当者に更新方法を確認してください。']],
|
|
36
|
+
permission: ['接続先から、利用を許可されませんでした。', ['正しいアカウントでログインしているか確認してください。', '正しければ、接続先の担当者に利用許可を確認してください。接続を制限する設定が原因の場合もあります。']],
|
|
37
|
+
rate_limit: ['接続先の利用回数などの制限に達しています。', ['サービスの利用状況や、再び使える時刻を確認してください。時間を置いてから「もう一度確認する」を選んでください。']],
|
|
38
|
+
service: ['接続先のサービスで問題が起きています。', ['サービスのお知らせや障害情報を確認してください。復旧してから「もう一度確認する」を選んでください。']],
|
|
39
|
+
endpoint: ['指定したWebアドレスが見つかりませんでした。', ['接続先のWebアドレスが正しいか、サービスの担当者に確認してください。']],
|
|
40
|
+
method: ['このサービスは、今回の確認方法には対応していません。', ['これだけでは故障とは判断できません。サービスの担当者に、接続確認用のWebアドレスを教えてもらってください。']],
|
|
41
|
+
redirect: ['接続先から、別のページへ移動するよう案内されました。', ['ログイン画面や、移転したページかもしれません。サービスの正しいWebアドレスとログイン方法を確認してください。']],
|
|
42
|
+
curl_missing: ['Webサービスを確認するためのソフトが見つかりません。', ['このパソコンにcurlという確認用ソフトが必要です。設定を手伝う人にインストールを依頼してください。']],
|
|
43
|
+
unknown: ['今回の確認では、接続できるか判断できませんでした。', [networkSteps[0], adminStep]],
|
|
44
|
+
provider_unavailable: ['AIサービス用のソフトを使えるか確認できませんでした。', ['このパソコンで、対象のAIサービス用ソフトが使えるか確認してください。', '未インストール・更新待ち・ログイン状態の不明などが考えられます。公式の案内に従って設定してください。']],
|
|
45
|
+
provider_disconnected: ['SIMYとAIサービスの連携が無効か、設定を読み取れません。', ['「SIMYとの連携を有効にする」が表示されていれば、選んでください。', '表示されない場合は設定を読み取れない可能性があります。設定を手伝う人に「詳しい情報」を見せてください。']],
|
|
46
|
+
};
|
|
47
|
+
const observations = {
|
|
48
|
+
dns_resolved: '接続先の場所が見つかりました。',
|
|
49
|
+
ssh_config_resolved: '保存されている接続設定を読み取れました。',
|
|
50
|
+
ssh_proxy_dns: '別のサーバーを通る設定です。実際の接続で確認します。',
|
|
51
|
+
ssh_ready: '相手のパソコンにつながり、SIMYが見つかりました。',
|
|
52
|
+
cli_auth_unverified: '相手側のSIMYへのログインは、開いてから確認してください。',
|
|
53
|
+
provider_ready: 'AIサービス用のソフトと、保存されたログイン情報を確認できました。',
|
|
54
|
+
provider_linked: 'SIMYとの連携は有効です。',
|
|
55
|
+
provider_api_unverified: 'AIサービスで実際に作業できるかは、まだ確認していません。',
|
|
56
|
+
http_reached: 'Webサービスから返事がありました。',
|
|
57
|
+
web_ready: 'このWebアドレスにつながりました。',
|
|
58
|
+
session_current: '保存されているログイン情報の期限は有効です。サービス側で取り消されていないかは未確認です。',
|
|
59
|
+
};
|
|
60
|
+
export function friendlyCheck(item) {
|
|
61
|
+
const [message, steps] = advice[item.code] || [];
|
|
62
|
+
return { message: message || observations[item.code] || advice.unknown[0], steps: steps || [] };
|
|
63
|
+
}
|
|
64
|
+
export const targetName = target => target.display === 'provider:codex' ? 'Codex(AIサービス)'
|
|
65
|
+
: target.display === 'provider:claude' ? 'Claude(AIサービス)' : target.display;
|
|
66
|
+
|
|
67
|
+
export function friendlyAction(action, target) {
|
|
68
|
+
const name = targetName(target);
|
|
69
|
+
const known = {
|
|
70
|
+
retry: ['もう一度確認する', ['今と同じ接続先を確認します。設定を直したら、この操作を選んでください。']],
|
|
71
|
+
open_cli: [`${name} のSIMYを開く`, ['このパソコンから、相手のパソコンのSIMYを開きます。', '相手側のSIMY画面が表示されたら接続完了です。相手側のSIMYを終了すると、このパソコンに戻ります。']],
|
|
72
|
+
ssh_login: ['相手のパソコンにログインし直す', ['このターミナルで相手のパソコンへのログインを試します。表示される案内に従ってください。', 'パスワードの入力中に文字が表示されなくても、そのまま入力してEnterキーを押してください。', '終わったら、同じ接続先をもう一度確認します。']],
|
|
73
|
+
provider_login: [`${name} にログインし直す`, ['このパソコンでログイン操作を始めます。表示される案内や、開いたブラウザの指示に従ってください。', 'ログインが完了したら、このターミナルに戻ってください。']],
|
|
74
|
+
provider_link: ['SIMYとの連携を有効にする', ['このパソコンのSIMYで、対象のAIサービスとの連携を有効にします。', '作業の実行許可は別途選ぶ必要があります。停止中の作業は再開しません。']],
|
|
75
|
+
web_login: ['サービスのWebページを開く', ['このパソコンのブラウザで、サービスのトップページを開きます。そこからログインしてください。', '外部サービス用の接続キーを使う場合は、ブラウザのログインだけでは直らないことがあります。キーの更新方法はサービスの担当者に確認してください。', '手続きが終わったら、このターミナルに戻ってください。']],
|
|
76
|
+
simy_login: ['SIMYにログインし直す手順を見る', ['このパソコンで、ターミナルのウィンドウをもう1つ開いてください。', '下の1行をコピーして貼り付け、Enterキーを押してください。', '表示されたログイン用のページをブラウザで開き、ログインを完了してください。終わったら、このターミナルに戻ってください。']],
|
|
77
|
+
reverse: ['相手のパソコンからつなぐ方法を見る', ['今の向きでつながらないときは、相手のパソコンからこのパソコンへつなぐ方法があります。相手からこちらへ接続できる設定が必要です。', '相手のパソコンでターミナルを開き、simy connections を実行してください。', '一覧から「今使っているこのパソコン」の接続先名を確認し、下の「接続先名」を置き換えて実行してください。', '接続経路ができると次のコマンドが表示されます。そのコマンドをこのパソコンの別のターミナルで実行してください。相手側のターミナルは接続中ずっと開いておきます。', '一覧にない・どちらの名前か分からない場合は、接続設定をした人に確認してください。']],
|
|
78
|
+
network_settings: ['ネット接続や会社の接続アプリを確認する', networkSteps],
|
|
79
|
+
provider_setup: ['AIサービス用ソフトの準備方法を見る', advice.provider_unavailable[1]],
|
|
80
|
+
};
|
|
81
|
+
const remedy = action.id.startsWith('resolve_') ? advice[action.id.slice(8)] : null;
|
|
82
|
+
const [title, steps] = known[action.id] || (remedy ? ['対処方法を読む: ' + remedy[0], remedy[1]] : ['設定を確認する', [adminStep]]);
|
|
83
|
+
return { title, steps, command: action.id === 'simy_login' ? action.command
|
|
84
|
+
: action.id === 'reverse' ? 'simy connections tunnel 接続先名' : undefined };
|
|
85
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createInterface } from 'node:readline/promises';
|
|
3
|
+
import { diagnoseConnection, parseConnectionTarget } from './connection-health.js';
|
|
4
|
+
import { writeProviderLink } from './provider-links.js';
|
|
5
|
+
import { openExternalUrl } from './browser.js';
|
|
6
|
+
import { friendlyAction, friendlyCheck, targetName } from './connection-presentation.js';
|
|
7
|
+
|
|
8
|
+
export function connectionReportLines(report, { details = false } = {}) {
|
|
9
|
+
const status = { reachable: '今回の接続確認はできました', needs_action: '確認・操作が必要です', unverified: 'まだ接続を確認できていません' };
|
|
10
|
+
const lines = [
|
|
11
|
+
`接続先: ${targetName(report.target)}`,
|
|
12
|
+
`結果: ${status[report.status] || status.unverified}`,
|
|
13
|
+
...report.checks.flatMap(item => {
|
|
14
|
+
const plain = friendlyCheck(item);
|
|
15
|
+
return [`${item.state === 'passed' ? '✓' : item.state === 'failed' ? '!' : '?'} ${plain.message}`,
|
|
16
|
+
...plain.steps.map((step, i) => ` ${i + 1}. ${step}`)];
|
|
17
|
+
}),
|
|
18
|
+
'使いたい機能のすべてを確認したわけではありません。まだ確認できていない項目は上に表示しています。',
|
|
19
|
+
];
|
|
20
|
+
if (details) lines.push('', '詳しい情報(設定を手伝う人向け)',
|
|
21
|
+
...report.checks.map(c => `${c.code}: ${c.message}${c.instruction ? `\n ${c.instruction}` : ''}`),
|
|
22
|
+
`プロキシ設定: ${report.context.proxyConfigured ? 'あり' : 'なし'} / 独自CA設定: ${report.context.customCaConfigured ? 'あり' : 'なし'}`);
|
|
23
|
+
return lines;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function interactiveProcess(file, args) {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const child = spawn(file, args, { stdio: 'inherit', shell: false });
|
|
29
|
+
child.once('error', () => reject(new Error('操作を起動できませんでした。インストールと設定を確認してください。')));
|
|
30
|
+
child.once('exit', code => code === 0 ? resolve() : reject(new Error('操作が完了しませんでした。再診断で状態を確認してください。')));
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function performConnectionAction(action, value, options = {}) {
|
|
35
|
+
const target = parseConnectionTarget(value, options.webOrigin);
|
|
36
|
+
const run = options.runProcess || interactiveProcess;
|
|
37
|
+
if (action.id === 'ssh_login' && target.kind === 'ssh') {
|
|
38
|
+
await run('ssh', ['-T', '-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=8', '--', target.alias, 'true']);
|
|
39
|
+
} else if (action.id === 'open_cli' && target.kind === 'ssh') {
|
|
40
|
+
const { runRemoteConnectionsCommand } = await import('./remote-connection-guide.js');
|
|
41
|
+
const code = await runRemoteConnectionsCommand(['connect', target.alias]);
|
|
42
|
+
if (code !== 0) throw new Error('相手のSIMYを開けませんでした。');
|
|
43
|
+
} else if (action.id === 'provider_login' && target.kind === 'provider') {
|
|
44
|
+
await run(target.provider, target.provider === 'codex' ? ['login'] : ['auth', 'login']);
|
|
45
|
+
} else if (action.id === 'provider_link' && target.kind === 'provider') {
|
|
46
|
+
await writeProviderLink(options.root, target.provider, true);
|
|
47
|
+
} else if (action.id === 'web_login' && target.kind === 'web') {
|
|
48
|
+
await openExternalUrl(target.url.origin);
|
|
49
|
+
}
|
|
50
|
+
// Other actions are manual remedies. The caller displays the exact instructions
|
|
51
|
+
// and waits for the user to finish before making another read-only probe.
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function runConnectionRecovery(argv, options = {}) {
|
|
55
|
+
const { output = console.log, diagnose = diagnoseConnection, perform = performConnectionAction,
|
|
56
|
+
interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY), ask } = options;
|
|
57
|
+
const [mode, ...args] = argv;
|
|
58
|
+
let value = 'simy', hasTarget = false, json = false, details = false, tokenEnv;
|
|
59
|
+
for (let i = 0; i < args.length; i++) {
|
|
60
|
+
if (args[i] === '--json') json = true;
|
|
61
|
+
else if (args[i] === '--details') details = true;
|
|
62
|
+
else if (args[i] === '--token-env' && args[i + 1] && !tokenEnv) tokenEnv = args[++i];
|
|
63
|
+
else if (!args[i].startsWith('-') && !hasTarget) { value = args[i]; hasTarget = true; }
|
|
64
|
+
else throw new Error('入力内容を確認してください。SIMYを確認するには simy connections recover を実行します。使い方は simy connections --help で確認できます。');
|
|
65
|
+
}
|
|
66
|
+
const settings = { ...options, tokenEnv };
|
|
67
|
+
let reader;
|
|
68
|
+
const question = ask || (prompt => { reader ||= createInterface({ input: process.stdin, output: process.stdout }); return reader.question(prompt); });
|
|
69
|
+
try {
|
|
70
|
+
while (true) {
|
|
71
|
+
const report = await diagnose(value, settings);
|
|
72
|
+
if (json) output(JSON.stringify(report, null, 2));
|
|
73
|
+
else {
|
|
74
|
+
connectionReportLines(report, { details }).forEach(output);
|
|
75
|
+
output('');
|
|
76
|
+
output('次にできること:');
|
|
77
|
+
report.actions.forEach((a, i) => output(`${i + 1}. ${friendlyAction(a, report.target).title}`));
|
|
78
|
+
if (mode !== 'recover' || !interactive) {
|
|
79
|
+
output('操作しながら確認するには、同じ接続先で diagnose を recover に変えて実行してください。recover は文字を入力できるターミナルで使います。');
|
|
80
|
+
if (!details) output('詳しい情報は、実行したコマンドの末尾に --details を付けると表示できます。');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (mode !== 'recover' || json || !interactive) return report.status === 'reachable' ? 0 : 1;
|
|
84
|
+
const choice = (await question('次にすることの番号を入力してEnter(d: 詳しい情報 / 0: 終了): ')).trim();
|
|
85
|
+
if (choice.toLowerCase() === 'd') { details = !details; continue; }
|
|
86
|
+
if (choice === '0' || choice === '') return report.status === 'reachable' ? 0 : 1;
|
|
87
|
+
const action = /^\d+$/.test(choice) ? report.actions[Number(choice) - 1] : null;
|
|
88
|
+
if (!action) { output('表示された操作番号を選んでください。'); continue; }
|
|
89
|
+
if (action.id !== 'retry') {
|
|
90
|
+
const guidance = friendlyAction(action, report.target);
|
|
91
|
+
output(guidance.title);
|
|
92
|
+
guidance.steps.forEach((step, i) => output(`${i + 1}. ${step}`));
|
|
93
|
+
if (guidance.command) output(guidance.command);
|
|
94
|
+
// Release stdin while the selected login program owns the terminal.
|
|
95
|
+
reader?.close(); reader = undefined;
|
|
96
|
+
try { await perform(action, value, settings); }
|
|
97
|
+
catch { output('操作が完了しませんでした。画面の案内を確認し、分からない場合は設定を手伝う人に相談してください。'); }
|
|
98
|
+
await question('操作が終わったらEnterキーを押してください。同じ接続先をもう一度確認します: ');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} finally { reader?.close(); }
|
|
102
|
+
}
|