newmark-agent 0.4.3 → 0.4.5
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/dist/conversation-utility-host.bundle.cjs +902 -698
- package/dist/conversation-utility-host.js +3 -0
- package/dist/core/agent.d.ts +19 -0
- package/dist/core/agent.js +88 -12
- package/dist/core/agentKernelRunner.js +1 -1
- package/dist/core/conversationKernel.d.ts +8 -0
- package/dist/core/conversationKernel.js +44 -5
- package/dist/core/electronUtilityAgentClient.d.ts +1 -0
- package/dist/core/electronUtilityAgentClient.js +4 -0
- package/dist/core/electronUtilityRuntimePool.d.ts +2 -0
- package/dist/core/electronUtilityRuntimePool.js +11 -0
- package/dist/core/toolPolicy.js +28 -13
- package/dist/core/utilityAgentProtocol.d.ts +7 -0
- package/dist/core/workspace.d.ts +6 -0
- package/dist/core/workspace.js +14 -0
- package/dist/core/wslAgentClient.d.ts +1 -0
- package/dist/core/wslAgentClient.js +4 -0
- package/dist/core/wslAgentProtocol.d.ts +7 -0
- package/dist/core/wslAgentRuntimePool.d.ts +2 -0
- package/dist/core/wslAgentRuntimePool.js +12 -0
- package/dist/main.js +36 -7
- package/dist/tools/index.d.ts +14 -0
- package/dist/tools/index.js +144 -18
- package/dist/tui/src/app.js +51 -6
- package/dist/tui/src/data.js +28 -0
- package/dist/tui/src/render.js +67 -30
- package/dist/ui/index.html +11 -0
- package/dist/wsl-agent-host.bundle.cjs +902 -698
- package/dist/wsl-agent-host.js +3 -0
- package/package.json +6 -3
package/dist/main.js
CHANGED
|
@@ -735,12 +735,21 @@ function setWindowsConsoleMode(mode) {
|
|
|
735
735
|
const setMode = Number.isInteger(mode);
|
|
736
736
|
const script = [
|
|
737
737
|
'Add-Type -TypeDefinition \'using System; using System.Runtime.InteropServices; public static class NewmarkConsoleMode { [DllImport("kernel32.dll")] public static extern IntPtr GetStdHandle(int n); [DllImport("kernel32.dll")] public static extern bool GetConsoleMode(IntPtr h, out uint mode); [DllImport("kernel32.dll")] public static extern bool SetConsoleMode(IntPtr h, uint mode); }\'',
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
738
|
+
// 输出句柄(STD_OUTPUT_HANDLE = -11)必须启用 ENABLE_VIRTUAL_TERMINAL_PROCESSING(0x4),
|
|
739
|
+
// 否则 TUI 的备用屏 ?1049h / 清屏 2J 等 ANSI 序列在 ConHost/传统控制台下不被解析,
|
|
740
|
+
// 主屏保留滚动历史(滚轮能滚回旧帧),光标追踪也随之错位。
|
|
741
|
+
'$out = [NewmarkConsoleMode]::GetStdHandle(-11)',
|
|
742
|
+
'$outMode = [uint32]0',
|
|
743
|
+
'if (-not [NewmarkConsoleMode]::GetConsoleMode($out, [ref]$outMode)) { exit 2 }',
|
|
741
744
|
setMode
|
|
742
|
-
? `$target = [uint32]${mode}; if (-not [NewmarkConsoleMode]::SetConsoleMode($
|
|
743
|
-
: '
|
|
745
|
+
? `$target = [uint32]${mode}; if (-not [NewmarkConsoleMode]::SetConsoleMode($out, $target)) { exit 3 }`
|
|
746
|
+
: 'if (-not [NewmarkConsoleMode]::SetConsoleMode($out, ($outMode -bor 4))) { exit 3 }',
|
|
747
|
+
// 输入句柄(STD_INPUT_HANDLE = -10)启用 ENABLE_VIRTUAL_TERMINAL_INPUT(0x200),
|
|
748
|
+
// 清除 line/echo 让方向键等原始序列可读。
|
|
749
|
+
'$inp = [NewmarkConsoleMode]::GetStdHandle(-10)',
|
|
750
|
+
'$inpMode = [uint32]0',
|
|
751
|
+
'if ([NewmarkConsoleMode]::GetConsoleMode($inp, [ref]$inpMode)) { [NewmarkConsoleMode]::SetConsoleMode($inp, (($inpMode -band (-bnot 7)) -bor 512)) | Out-Null }',
|
|
752
|
+
'Write-Output $outMode',
|
|
744
753
|
].join('; ');
|
|
745
754
|
const result = (0, child_process_1.spawnSync)('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], {
|
|
746
755
|
stdio: ['inherit', 'pipe', 'inherit'],
|
|
@@ -1206,6 +1215,11 @@ else if (isTuiArg) {
|
|
|
1206
1215
|
|| path.basename(process.execPath).toLowerCase() === 'newmark console runtime.exe'
|
|
1207
1216
|
|| process.env.NEWMARK_CONSOLE_WRAPPER === '1');
|
|
1208
1217
|
if (isConsoleLauncher && process.env.NEWMARK_TUI_SIDECAR !== '1') {
|
|
1218
|
+
// Console mode is shared by every process attached to the same console:
|
|
1219
|
+
// enable output VT processing (and input VT) before spawning the sidecar so
|
|
1220
|
+
// the TUI's ?1049h alternate-screen sequence is actually parsed by ConHost /
|
|
1221
|
+
// traditional consoles. The sidecar also re-applies it defensively below.
|
|
1222
|
+
setWindowsConsoleMode();
|
|
1209
1223
|
const tuiProcess = (0, child_process_1.spawnSync)(process.execPath, [path.join(__dirname, 'launcher.js'), ...args], {
|
|
1210
1224
|
cwd: process.cwd(),
|
|
1211
1225
|
env: {
|
|
@@ -3162,16 +3176,31 @@ else {
|
|
|
3162
3176
|
});
|
|
3163
3177
|
electron_1.ipcMain.handle('agent:setModel', async (_event, model) => {
|
|
3164
3178
|
if (agent) {
|
|
3165
|
-
|
|
3179
|
+
// Compare the resolved selection (qualified deployment or 'auto')
|
|
3180
|
+
// rather than the bare model name, so switching between two
|
|
3181
|
+
// same-named models on different providers is still recognized as a
|
|
3182
|
+
// real change.
|
|
3183
|
+
const before = agent.modelSelectionValue();
|
|
3166
3184
|
agent.setModel(model, true);
|
|
3185
|
+
const after = agent.modelSelectionValue();
|
|
3167
3186
|
// Compression and kernel reset only make sense when the model actually
|
|
3168
3187
|
// changed. The renderer sends this on every prompt, so an unchanged
|
|
3169
3188
|
// selection must not trigger a context compression round (which can
|
|
3170
3189
|
// run an extra model call on large histories).
|
|
3171
|
-
if (
|
|
3190
|
+
if (after !== before) {
|
|
3172
3191
|
await agent.compressForModelSwitch();
|
|
3173
3192
|
resetConversationKernel();
|
|
3174
3193
|
}
|
|
3194
|
+
// Propagate the selection to the target-bound runtime. A running Build
|
|
3195
|
+
// block keeps its current model until the next Guide/Next re-enters it;
|
|
3196
|
+
// the runtime records the new selection as pending so the context
|
|
3197
|
+
// window and the next dequeue both follow the newly selected model.
|
|
3198
|
+
const target = conversationRuntimeTarget(agent.activeConversationId || 'default');
|
|
3199
|
+
ensureConversationKernel(root)?.setModel(target, model);
|
|
3200
|
+
if (wslBackendEnabled())
|
|
3201
|
+
await ensureWslConversationPool()?.setModel(target, model);
|
|
3202
|
+
else
|
|
3203
|
+
await ensureElectronUtilityPool()?.setModel(target, model);
|
|
3175
3204
|
}
|
|
3176
3205
|
return agent?.model;
|
|
3177
3206
|
});
|
package/dist/tools/index.d.ts
CHANGED
|
@@ -91,13 +91,27 @@ export declare class ToolExecutor {
|
|
|
91
91
|
private parseGitHubRepo;
|
|
92
92
|
private githubFileAudit;
|
|
93
93
|
private repoSecurityAudit;
|
|
94
|
+
private collectRepositoryFiles;
|
|
94
95
|
private scanRepositorySecrets;
|
|
96
|
+
/**
|
|
97
|
+
* 扫描隐私地址类高危信息:带凭据的 URL(user:pass@)、私网 IP、本地用户目录
|
|
98
|
+
* 绝对路径(C:\Users\<user>、/home/<user>、/Users/<user>)。这些内容泄露个人
|
|
99
|
+
* 账号、内网拓扑或本地机器结构,进入公开 remote 即构成隐私暴露,需与密钥同级
|
|
100
|
+
* 硬性阻挡并在 Agent 二轮审查确认后放行。
|
|
101
|
+
*/
|
|
102
|
+
private scanRepositoryPrivacyLeaks;
|
|
95
103
|
private releaseExcludedPathFindings;
|
|
96
104
|
private ghJson;
|
|
97
105
|
private gbranch;
|
|
98
106
|
private ghFork;
|
|
99
107
|
private ghPrCreate;
|
|
100
108
|
private withRemoteSecurityPreamble;
|
|
109
|
+
/**
|
|
110
|
+
* 硬性阻挡远程写:当密钥或隐私地址类高危信息存在且 Agent 尚未二轮审查确认时,
|
|
111
|
+
* 返回明确的阻挡结果(脱敏 findings),要求处理/确认后再以
|
|
112
|
+
* security_review_confirmed=true 重试放行。
|
|
113
|
+
*/
|
|
114
|
+
private formatRemoteSecurityBlock;
|
|
101
115
|
private gstat;
|
|
102
116
|
private gpull;
|
|
103
117
|
private gpush;
|
package/dist/tools/index.js
CHANGED
|
@@ -49,6 +49,7 @@ const terminalTakeover_1 = require("./terminalTakeover");
|
|
|
49
49
|
const computerUse_1 = require("./computerUse");
|
|
50
50
|
const nativeTools_1 = require("./nativeTools");
|
|
51
51
|
const ssh_1 = require("../core/ssh");
|
|
52
|
+
const workspace_1 = require("../core/workspace");
|
|
52
53
|
const wslHostToolBridge_1 = require("../core/wslHostToolBridge");
|
|
53
54
|
const utilityHostToolBridge_1 = require("../core/utilityHostToolBridge");
|
|
54
55
|
const toolPolicy_1 = require("../core/toolPolicy");
|
|
@@ -61,6 +62,41 @@ const computerUseSession_1 = require("../core/computerUseSession");
|
|
|
61
62
|
function normalizeComputerUseAction(action) {
|
|
62
63
|
return String(action || '').trim().toLowerCase();
|
|
63
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* 统一跨环境路径归一。WSL 运行时里,Agent 可能用 Windows 盘符路径(`C:\...` /
|
|
67
|
+
* `C:/...`)引用 Windows 工作区;Linux 的 `path.isAbsolute` 不认识盘符,会把
|
|
68
|
+
* 它误当相对路径拼到 `/mnt/...` 工作区下。这里先把盘符路径转换为 `/mnt/<drive>/...`,
|
|
69
|
+
* 再做常规的绝对/相对判断。非 WSL 运行时行为与旧的 `resolve` 完全一致。
|
|
70
|
+
*/
|
|
71
|
+
function normalizeCrossEnvPath(value, wsPath) {
|
|
72
|
+
const raw = String(value || '').trim();
|
|
73
|
+
if (!raw)
|
|
74
|
+
return wsPath;
|
|
75
|
+
if (process.env.NEWMARK_WSL_DISTRO) {
|
|
76
|
+
const posix = (0, workspace_1.windowsDrivePathToPosix)(raw);
|
|
77
|
+
if (posix)
|
|
78
|
+
return posix;
|
|
79
|
+
}
|
|
80
|
+
if (path.isAbsolute(raw))
|
|
81
|
+
return raw;
|
|
82
|
+
return path.join(wsPath, raw);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* WSL 运行时:把 bash 命令里出现的 Windows 盘符路径保守翻译为 `/mnt/<drive>/...`。
|
|
86
|
+
* 仅替换「以盘符开头、后跟路径字符(不含空白、引号、反引号与 shell 元字符)」的
|
|
87
|
+
* token,避免误伤 `C:` 标签、环境变量与字符串字面量。非 WSL 原样返回。
|
|
88
|
+
*/
|
|
89
|
+
function translateWindowsPathsForWslBash(script) {
|
|
90
|
+
if (!process.env.NEWMARK_WSL_DISTRO)
|
|
91
|
+
return script;
|
|
92
|
+
const value = String(script || '');
|
|
93
|
+
if (!/[A-Za-z]:[\\/]/.test(value))
|
|
94
|
+
return value;
|
|
95
|
+
return value.replace(/(?<![A-Za-z0-9_])([A-Za-z]):[\\/]([^\s"'`;|&<>()]+)/g, (_match, drive, rest) => {
|
|
96
|
+
const posix = rest.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
97
|
+
return `/mnt/${drive.toLowerCase()}/${posix}`;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
64
100
|
function computerUseOwner(context, wsPath) {
|
|
65
101
|
const conversationId = String(context.conversationId || '').trim();
|
|
66
102
|
if (conversationId)
|
|
@@ -350,7 +386,7 @@ class ToolExecutor {
|
|
|
350
386
|
t('subagent_result', 'Return the persisted transcript, mailbox summary, status, and latest result for a peer agent. Target by exact id (preferred) or name.', { id: { type: 'string', description: 'Exact peer id from subagent_list.' }, name: { type: 'string', description: 'Convenience peer name.' } }, []),
|
|
351
387
|
t('subagent_close', 'Close a same-conversation peer. Root can close any peer; a peer can close only itself. Target by exact id (preferred) or name.', { id: { type: 'string', description: 'Exact peer id from subagent_list.' }, name: { type: 'string', description: 'Convenience peer name.' } }, []),
|
|
352
388
|
t('linked_plan', 'Read or update the current conversation linked Markdown plan. Update requires the current expected_revision.', { action: { type: 'string', enum: ['get', 'update'] }, markdown: { type: 'string' }, expected_revision: { type: 'number' } }, ['action']),
|
|
353
|
-
t('build_history_query', 'Read the concrete public work details of one historical Build Block.
|
|
389
|
+
t('build_history_query', 'Read the concrete public work details (tool calls, results, file changes, guides) of one historical Build Block. Call it proactively when the current task continues, fixes, verifies, or depends on earlier work: reuse the returned activity instead of re-investigating from scratch. Do not call it merely to answer completion status already exposed by the prompt. Select by newest-to-oldest history_index, or by run_id returned from an earlier query. Every activity/guide content is bounded to max_chars (default 2000) to keep the read lean and cache-friendly.', { history_index: { type: 'number', minimum: 1, description: '1-based historical Build Block index from the request ledger; 1 is the newest previous task.' }, run_id: { type: 'string', description: 'Exact run id returned by an earlier build_history_query result.' }, max_events: { type: 'number', minimum: 1, maximum: 200, description: 'Maximum trailing public work events; defaults to 80.' }, max_chars: { type: 'number', minimum: 100, maximum: 4000, description: 'Per-event/per-guide content character bound; defaults to 2000.' } }, []),
|
|
354
390
|
t('context_compress', 'Actively compress the LLM context history for this conversation. This collapses older history entries into a concise summary while preserving the recent tail, which reduces context tokens and cost. IMPORTANT: it affects only the LLM context (what the model sees); the displayed conversation history shown to the user is never altered. Call this when the conversation is long, token pressure is high, or you judge that older turns are no longer needed in full. Idempotent and safe: repeated calls produce incremental summaries.', { keep_recent: { type: 'number', minimum: 2, maximum: 60, description: 'Recent message count to keep uncompressed at the tail. Defaults to the configured keep_recent_messages.' }, force: { type: 'boolean', description: 'Compress even if the context is not yet over the automatic threshold. Defaults to false.' } }, []),
|
|
355
391
|
t('context_history_manage', 'Manage the LLM context history for this conversation without affecting the displayed conversation history. This is the active context-management surface. The hot cache stays bounded; evicted folded segments remain in a conversation-isolated append-only cold archive and are loaded only by explicit search/read/restore calls. Actions: list returns a bounded index of current context entries; remove declares one long-term entry for unload (see below); summarize folds a contiguous current range; restore reinserts a folded segment when its summary marker is still present; search finds matching hot or archived segments; read returns one bounded segment without injecting the whole archive; status reports budgets, hot cache, cold archive, the protected recent zone, and pending removals. The recent context tail and last user message are protected from remove/summarize unless dangerous is true. For cache-optimization, remove ONLY targets long-term history (never the protected recent tail or last user message) and does NOT unload immediately: the declared entry stays in context for the rest of the current Build Block so the provider prefix cache stays stable, then is physically removed when the Block ends — applying to subsequent Blocks only.', {
|
|
356
392
|
action: { type: 'string', enum: ['list', 'remove', 'summarize', 'restore', 'search', 'read', 'status'], description: 'list current entries; remove one; summarize a range; restore by restore_id; search hot/cold folded segments; read one bounded folded segment; status report context budgets and storage.' },
|
|
@@ -422,12 +458,15 @@ class ToolExecutor {
|
|
|
422
458
|
include_remote: { type: 'boolean' },
|
|
423
459
|
base_ref: { type: 'string' },
|
|
424
460
|
}, []),
|
|
425
|
-
t('repo_security_audit', 'Review a local or remote-backed repository for release/privacy risk before remote actions. Reports GitHub/private/public state, dirty files, ignored local-only files, likely secret material, release-excluded paths, and recommended next checks. Read-only.', {
|
|
461
|
+
t('repo_security_audit', 'Review a local or remote-backed repository for release/privacy risk before remote actions. Reports GitHub/private/public state, dirty files, ignored local-only files, likely secret material, privacy addresses (credential URLs, private network addresses, local user paths), release-excluded paths, and recommended next checks. Read-only.', {
|
|
426
462
|
path: { type: 'string' },
|
|
427
463
|
base_ref: { type: 'string' },
|
|
428
464
|
}, []),
|
|
429
465
|
t('git_pull', 'Pull from remote', {}, []),
|
|
430
|
-
t('git_push', 'Stage, commit, push
|
|
466
|
+
t('git_push', 'Stage, commit, and push changes. Before a remote push, an automatic repository security review runs: if it finds high-risk content (personal keys/tokens or privacy addresses such as credential URLs, private network addresses, or local user paths), the push is BLOCKED. Complete a second review of every reported finding (remove/ignore it or confirm it is safe), then call git_push again with security_review_confirmed=true to proceed.', {
|
|
467
|
+
message: { type: 'string' },
|
|
468
|
+
security_review_confirmed: { type: 'boolean', description: 'Set true only after a second review resolved or explicitly confirmed every reported high-risk finding.' },
|
|
469
|
+
}, ['message']),
|
|
431
470
|
t('git_clone', 'Clone a git repo', { url: { type: 'string' }, path: { type: 'string' } }, ['url', 'path']),
|
|
432
471
|
t('git_branch', 'Inspect or manage local git branches. Actions: current, list, create, switch.', {
|
|
433
472
|
action: { type: 'string', enum: ['current', 'list', 'create', 'switch'] },
|
|
@@ -445,12 +484,13 @@ class ToolExecutor {
|
|
|
445
484
|
remote: { type: 'boolean' },
|
|
446
485
|
remote_name: { type: 'string' },
|
|
447
486
|
}, []),
|
|
448
|
-
t('gh_pr_create', 'Create a GitHub pull request for the current branch through GitHub CLI. Requires explicit title and body.', {
|
|
487
|
+
t('gh_pr_create', 'Create a GitHub pull request for the current branch through GitHub CLI. Requires explicit title and body. Before creation, the same automatic repository security review as git_push runs: high-risk findings (personal keys/tokens or privacy addresses) BLOCK creation until a second review resolves them and you call again with security_review_confirmed=true.', {
|
|
449
488
|
title: { type: 'string' },
|
|
450
489
|
body: { type: 'string' },
|
|
451
490
|
base: { type: 'string' },
|
|
452
491
|
head: { type: 'string' },
|
|
453
492
|
draft: { type: 'boolean' },
|
|
493
|
+
security_review_confirmed: { type: 'boolean', description: 'Set true only after a second review resolved or explicitly confirmed every reported high-risk finding.' },
|
|
454
494
|
}, ['title', 'body']),
|
|
455
495
|
];
|
|
456
496
|
let visibleTools = tools.filter((tool) => (0, nativeTools_1.isNativeToolEnabled)(tool.function?.name || '', this.config.nativeToolEnabled()));
|
|
@@ -571,11 +611,7 @@ class ToolExecutor {
|
|
|
571
611
|
const value = args[k];
|
|
572
612
|
return value === undefined || value === null ? '' : String(value);
|
|
573
613
|
};
|
|
574
|
-
const resolve = (relPath) =>
|
|
575
|
-
if (path.isAbsolute(relPath))
|
|
576
|
-
return relPath;
|
|
577
|
-
return path.join(wsPath, relPath);
|
|
578
|
-
};
|
|
614
|
+
const resolve = (relPath) => normalizeCrossEnvPath(relPath, wsPath);
|
|
579
615
|
const targetForTool = () => {
|
|
580
616
|
switch (tool) {
|
|
581
617
|
case 'read':
|
|
@@ -911,7 +947,7 @@ class ToolExecutor {
|
|
|
911
947
|
case 'file_audit': return await this.fileAudit(resolve(g('path') || '.'), wsPath, args.include_remote !== false, g('base_ref'), context.signal);
|
|
912
948
|
case 'repo_security_audit': return await this.repoSecurityAudit(resolve(g('path') || '.'), wsPath, g('base_ref'), context.signal);
|
|
913
949
|
case 'git_pull': return await this.gpull(wsPath, context.signal);
|
|
914
|
-
case 'git_push': return await this.withRemoteSecurityPreamble(wsPath, () => this.gpush(g('message'), wsPath, context.signal), context.signal);
|
|
950
|
+
case 'git_push': return await this.withRemoteSecurityPreamble(wsPath, () => this.gpush(g('message'), wsPath, context.signal), context.signal, args.security_review_confirmed === true);
|
|
915
951
|
case 'git_clone': return await this.gclone(g('url'), resolve(g('path')), context.signal);
|
|
916
952
|
case 'git_branch': return await this.gbranch(g('action'), g('name'), g('start_point'), wsPath, context.signal);
|
|
917
953
|
case 'gh_auth_status': return await this.gh(['auth', 'status'], wsPath, context.signal);
|
|
@@ -919,7 +955,7 @@ class ToolExecutor {
|
|
|
919
955
|
case 'gh_issue_list': return await this.ghList('issue', g('repo'), Number(args.limit || 20), wsPath, context.signal);
|
|
920
956
|
case 'gh_pr_list': return await this.ghList('pr', g('repo'), Number(args.limit || 20), wsPath, context.signal);
|
|
921
957
|
case 'gh_fork': return await this.ghFork(g('action'), g('repo'), args.clone === true, args.remote === true, g('remote_name'), wsPath, context.signal);
|
|
922
|
-
case 'gh_pr_create': return await this.withRemoteSecurityPreamble(wsPath, () => this.ghPrCreate(g('title'), g('body'), g('base'), g('head'), args.draft === true, wsPath, context.signal), context.signal);
|
|
958
|
+
case 'gh_pr_create': return await this.withRemoteSecurityPreamble(wsPath, () => this.ghPrCreate(g('title'), g('body'), g('base'), g('head'), args.draft === true, wsPath, context.signal), context.signal, args.security_review_confirmed === true);
|
|
923
959
|
default: return `[?] Unknown tool: ${tool}`;
|
|
924
960
|
}
|
|
925
961
|
}
|
|
@@ -1064,7 +1100,7 @@ class ToolExecutor {
|
|
|
1064
1100
|
if (!this.looksLikePath(token))
|
|
1065
1101
|
continue;
|
|
1066
1102
|
const withoutWildcard = token.replace(/[\\/][*?][^\\/]*$/g, '');
|
|
1067
|
-
refs.push(path.
|
|
1103
|
+
refs.push(path.resolve(normalizeCrossEnvPath(withoutWildcard, wsPath)));
|
|
1068
1104
|
}
|
|
1069
1105
|
return Array.from(new Set(refs));
|
|
1070
1106
|
}
|
|
@@ -1100,9 +1136,13 @@ class ToolExecutor {
|
|
|
1100
1136
|
if (!cmd.trim())
|
|
1101
1137
|
return '[bash] No command.';
|
|
1102
1138
|
const timeout = this.resolveBashTimeout(timeoutMs);
|
|
1139
|
+
// WSL 运行时跨环境归一:工作目录若仍是 Windows 盘符路径则转 /mnt/<drive>/...,
|
|
1140
|
+
// 命令内的 Windows 盘符路径保守翻译为 WSL 挂载路径,让 bash 能直接操作 Windows 工作区。
|
|
1141
|
+
const workspaceCwd = process.env.NEWMARK_WSL_DISTRO ? ((0, workspace_1.windowsDrivePathToPosix)(ws) || ws) : ws;
|
|
1142
|
+
const translatedCmd = translateWindowsPathsForWslBash(cmd);
|
|
1103
1143
|
try {
|
|
1104
|
-
const result = await (0, nativeBash_1.executeWorkspaceBash)(
|
|
1105
|
-
cwd:
|
|
1144
|
+
const result = await (0, nativeBash_1.executeWorkspaceBash)(translatedCmd, workspaceCwd, {
|
|
1145
|
+
cwd: workspaceCwd,
|
|
1106
1146
|
timeoutMs: timeout,
|
|
1107
1147
|
signal,
|
|
1108
1148
|
allowHostFallback: true,
|
|
@@ -1739,6 +1779,7 @@ class ToolExecutor {
|
|
|
1739
1779
|
const ignoredFiles = await this.gitExecAt(repoRoot, ['ls-files', '--others', '--ignored', '--exclude-standard'], signal);
|
|
1740
1780
|
const changedAgainstBase = chosenBase ? await this.gitExecAt(repoRoot, ['diff', '--name-status', chosenBase], signal) : '';
|
|
1741
1781
|
const secretFindings = this.scanRepositorySecrets(repoRoot, trackedFiles, statusShort);
|
|
1782
|
+
const privacyFindings = this.scanRepositoryPrivacyLeaks(repoRoot, trackedFiles, statusShort);
|
|
1742
1783
|
const localOnlyFindings = this.releaseExcludedPathFindings(repoRoot, ignoredFiles);
|
|
1743
1784
|
const repoInfo = ghRemote
|
|
1744
1785
|
? await this.ghJson(['api', `repos/${ghRemote.owner}/${ghRemote.name}`, '--jq', '{name: .full_name, private: .private, visibility: .visibility, fork: .fork, archived: .archived, default_branch: .default_branch, html_url: .html_url}'], repoRoot, signal)
|
|
@@ -1751,6 +1792,8 @@ class ToolExecutor {
|
|
|
1751
1792
|
risks.push('Remote GitHub repository is public; treat all tracked content and PR metadata as publicly visible.');
|
|
1752
1793
|
if (ghRemote && secretFindings.length)
|
|
1753
1794
|
risks.push('Potential secret-like material appears in tracked or changed files.');
|
|
1795
|
+
if (ghRemote && privacyFindings.length)
|
|
1796
|
+
risks.push('Privacy-address content (credential URLs, private network addresses, or local user paths) appears in tracked or changed files.');
|
|
1754
1797
|
if (ghRemote && localOnlyFindings.length)
|
|
1755
1798
|
risks.push('Workspace contains release-excluded/local-only files that must stay out of remote commits and public reports.');
|
|
1756
1799
|
if (ghRemote && String(statusShort || '').trim())
|
|
@@ -1791,12 +1834,14 @@ class ToolExecutor {
|
|
|
1791
1834
|
verdict: risks.length ? 'review-required' : 'no-obvious-risk',
|
|
1792
1835
|
risks,
|
|
1793
1836
|
secret_findings: secretFindings,
|
|
1837
|
+
privacy_findings: privacyFindings,
|
|
1838
|
+
high_risk_findings: [...secretFindings, ...privacyFindings],
|
|
1794
1839
|
release_excluded_local_files: localOnlyFindings,
|
|
1795
1840
|
recommendations,
|
|
1796
1841
|
},
|
|
1797
1842
|
}, null, 2);
|
|
1798
1843
|
}
|
|
1799
|
-
|
|
1844
|
+
collectRepositoryFiles(trackedFilesRaw, statusRaw) {
|
|
1800
1845
|
const files = new Set();
|
|
1801
1846
|
for (const line of String(trackedFilesRaw || '').split(/\r?\n/)) {
|
|
1802
1847
|
const rel = line.trim();
|
|
@@ -1808,6 +1853,10 @@ class ToolExecutor {
|
|
|
1808
1853
|
if (rel)
|
|
1809
1854
|
files.add(rel.replace(/\\/g, '/'));
|
|
1810
1855
|
}
|
|
1856
|
+
return files;
|
|
1857
|
+
}
|
|
1858
|
+
scanRepositorySecrets(repoRoot, trackedFilesRaw, statusRaw) {
|
|
1859
|
+
const files = this.collectRepositoryFiles(trackedFilesRaw, statusRaw);
|
|
1811
1860
|
const patterns = [
|
|
1812
1861
|
{ id: 'openai_or_generic_sk_key', re: /\bsk-[A-Za-z0-9._-]{16,}\b/ },
|
|
1813
1862
|
{ id: 'github_token', re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}\b/ },
|
|
@@ -1834,7 +1883,50 @@ class ToolExecutor {
|
|
|
1834
1883
|
for (const [idx, line] of text.split(/\r?\n/).entries()) {
|
|
1835
1884
|
const matched = patterns.find(p => p.re.test(line));
|
|
1836
1885
|
if (matched) {
|
|
1837
|
-
|
|
1886
|
+
// 只汇报「类型 + 位置」,绝不把密钥值(含脱敏样本)暴露给 Agent,
|
|
1887
|
+
// 避免经 API 中转被拦截窃取。
|
|
1888
|
+
findings.push({ path: rel, line: idx + 1, type: matched.id });
|
|
1889
|
+
if (findings.length >= 40)
|
|
1890
|
+
break;
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
return findings;
|
|
1895
|
+
}
|
|
1896
|
+
/**
|
|
1897
|
+
* 扫描隐私地址类高危信息:带凭据的 URL(user:pass@)、私网 IP、本地用户目录
|
|
1898
|
+
* 绝对路径(C:\Users\<user>、/home/<user>、/Users/<user>)。这些内容泄露个人
|
|
1899
|
+
* 账号、内网拓扑或本地机器结构,进入公开 remote 即构成隐私暴露,需与密钥同级
|
|
1900
|
+
* 硬性阻挡并在 Agent 二轮审查确认后放行。
|
|
1901
|
+
*/
|
|
1902
|
+
scanRepositoryPrivacyLeaks(repoRoot, trackedFilesRaw, statusRaw) {
|
|
1903
|
+
const files = this.collectRepositoryFiles(trackedFilesRaw, statusRaw);
|
|
1904
|
+
const patterns = [
|
|
1905
|
+
{ id: 'credential_url', re: /(?:https?|git|ssh|ftp):\/\/[^\s/@:]+:[^\s/@]+@/i },
|
|
1906
|
+
{ id: 'private_network_address', re: /\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})\b/ },
|
|
1907
|
+
{ id: 'local_user_path', re: /(?:^|[\s"'`])(?:C:\\(?:Users|Documents and Settings)\\[^\\\s"']+|~\/(?:[^/\s"']+\/){1,3}|\/(?:home|Users)\/[^/\s"']+\/[^\s"']*)/ },
|
|
1908
|
+
];
|
|
1909
|
+
const findings = [];
|
|
1910
|
+
for (const rel of Array.from(files).sort()) {
|
|
1911
|
+
if (findings.length >= 40)
|
|
1912
|
+
break;
|
|
1913
|
+
const full = path.join(repoRoot, rel);
|
|
1914
|
+
if (!fs.existsSync(full) || !fs.statSync(full).isFile())
|
|
1915
|
+
continue;
|
|
1916
|
+
if (fs.statSync(full).size > 512 * 1024)
|
|
1917
|
+
continue;
|
|
1918
|
+
let text = '';
|
|
1919
|
+
try {
|
|
1920
|
+
text = fs.readFileSync(full, 'utf-8');
|
|
1921
|
+
}
|
|
1922
|
+
catch {
|
|
1923
|
+
continue;
|
|
1924
|
+
}
|
|
1925
|
+
for (const [idx, line] of text.split(/\r?\n/).entries()) {
|
|
1926
|
+
const matched = patterns.find(p => p.re.test(line));
|
|
1927
|
+
if (matched) {
|
|
1928
|
+
// 只汇报「类型 + 位置」,绝不把隐私地址值暴露给 Agent。
|
|
1929
|
+
findings.push({ path: rel, line: idx + 1, type: matched.id });
|
|
1838
1930
|
if (findings.length >= 40)
|
|
1839
1931
|
break;
|
|
1840
1932
|
}
|
|
@@ -1940,7 +2032,7 @@ class ToolExecutor {
|
|
|
1940
2032
|
args.push('--draft');
|
|
1941
2033
|
return this.gh(args, ws, signal);
|
|
1942
2034
|
}
|
|
1943
|
-
async withRemoteSecurityPreamble(ws, action, signal) {
|
|
2035
|
+
async withRemoteSecurityPreamble(ws, action, signal, securityReviewConfirmed = false) {
|
|
1944
2036
|
const repoRoot = await this.findGitRoot(ws, ws, signal);
|
|
1945
2037
|
if (!repoRoot)
|
|
1946
2038
|
return await action();
|
|
@@ -1954,6 +2046,11 @@ class ToolExecutor {
|
|
|
1954
2046
|
const remote = audit.remote || {};
|
|
1955
2047
|
const risks = Array.isArray(review.risks) ? review.risks : [];
|
|
1956
2048
|
const findings = Array.isArray(review.secret_findings) ? review.secret_findings : [];
|
|
2049
|
+
const privacy = Array.isArray(review.privacy_findings) ? review.privacy_findings : [];
|
|
2050
|
+
const highRisk = [...findings, ...privacy];
|
|
2051
|
+
if (highRisk.length && !securityReviewConfirmed) {
|
|
2052
|
+
return this.formatRemoteSecurityBlock(remote, findings, privacy);
|
|
2053
|
+
}
|
|
1957
2054
|
const localOnly = Array.isArray(review.release_excluded_local_files) ? review.release_excluded_local_files : [];
|
|
1958
2055
|
summary = [
|
|
1959
2056
|
'[repo_security_audit]',
|
|
@@ -1961,8 +2058,10 @@ class ToolExecutor {
|
|
|
1961
2058
|
`verdict=${review.verdict || 'unknown'}`,
|
|
1962
2059
|
risks.length ? `risks=${risks.length}` : 'risks=0',
|
|
1963
2060
|
findings.length ? `secret_findings=${findings.length}` : 'secret_findings=0',
|
|
2061
|
+
privacy.length ? `privacy_findings=${privacy.length}` : 'privacy_findings=0',
|
|
1964
2062
|
localOnly.length ? `release_excluded_local_files=${localOnly.length}` : 'release_excluded_local_files=0',
|
|
1965
|
-
|
|
2063
|
+
securityReviewConfirmed ? 'security_review_confirmed=true' : '',
|
|
2064
|
+
].filter(Boolean).join(' ');
|
|
1966
2065
|
}
|
|
1967
2066
|
catch {
|
|
1968
2067
|
// Keep the remote action result visible even if the preflight summary cannot be parsed.
|
|
@@ -1970,6 +2069,33 @@ class ToolExecutor {
|
|
|
1970
2069
|
const actionOutput = await action();
|
|
1971
2070
|
return `${summary}\n${actionOutput}`;
|
|
1972
2071
|
}
|
|
2072
|
+
/**
|
|
2073
|
+
* 硬性阻挡远程写:当密钥或隐私地址类高危信息存在且 Agent 尚未二轮审查确认时,
|
|
2074
|
+
* 返回明确的阻挡结果(脱敏 findings),要求处理/确认后再以
|
|
2075
|
+
* security_review_confirmed=true 重试放行。
|
|
2076
|
+
*/
|
|
2077
|
+
formatRemoteSecurityBlock(remote, secretFindings, privacyFindings) {
|
|
2078
|
+
// 只汇报「类型 + 位置」,绝不把密钥值 / 隐私地址值(含脱敏样本)暴露给 Agent。
|
|
2079
|
+
const detail = (finding) => `${String(finding.path || '')}:${String(finding.line || '')} [${String(finding.type || '')}]`.trim();
|
|
2080
|
+
const lines = [
|
|
2081
|
+
'[repo_security_audit] BLOCKED: remote write refused pending a second review.',
|
|
2082
|
+
'High-risk content was detected in tracked or changed files.',
|
|
2083
|
+
`Remote: ${remote.provider || 'git'}${remote.repository ? ` ${remote.repository}` : ''}.`,
|
|
2084
|
+
`Secret-like findings: ${secretFindings.length}; privacy-address findings: ${privacyFindings.length}.`,
|
|
2085
|
+
];
|
|
2086
|
+
if (secretFindings.length) {
|
|
2087
|
+
lines.push('Secret-like findings:');
|
|
2088
|
+
for (const finding of secretFindings.slice(0, 12))
|
|
2089
|
+
lines.push(` - ${detail(finding)}`);
|
|
2090
|
+
}
|
|
2091
|
+
if (privacyFindings.length) {
|
|
2092
|
+
lines.push('Privacy-address findings:');
|
|
2093
|
+
for (const finding of privacyFindings.slice(0, 12))
|
|
2094
|
+
lines.push(` - ${detail(finding)}`);
|
|
2095
|
+
}
|
|
2096
|
+
lines.push('Second review required: remove, .gitignore, or explicitly confirm each finding is safe.', 'Then retry this action with security_review_confirmed=true.');
|
|
2097
|
+
return lines.join('\n');
|
|
2098
|
+
}
|
|
1973
2099
|
async gstat(ws, signal) {
|
|
1974
2100
|
try {
|
|
1975
2101
|
const r = await this.gitExec('git status --short', ws, signal);
|
package/dist/tui/src/app.js
CHANGED
|
@@ -51,8 +51,10 @@ const ESC = "\u001b[";
|
|
|
51
51
|
function createPaintScheduler(state, output = process.stdout, renderFrame = render) {
|
|
52
52
|
let pending = false;
|
|
53
53
|
let lastFrame = "";
|
|
54
|
+
let cancelled = false;
|
|
54
55
|
const flush = () => {
|
|
55
56
|
pending = false;
|
|
57
|
+
if (cancelled) return;
|
|
56
58
|
const frame = renderFrame(state);
|
|
57
59
|
if (frame === lastFrame) return false;
|
|
58
60
|
lastFrame = frame;
|
|
@@ -65,6 +67,8 @@ function createPaintScheduler(state, output = process.stdout, renderFrame = rend
|
|
|
65
67
|
setImmediate(flush);
|
|
66
68
|
};
|
|
67
69
|
paint.flush = flush;
|
|
70
|
+
// 退出前调用:丢弃任何排队中的重绘,避免恢复主屏后又被画上一帧 TUI 画面。
|
|
71
|
+
paint.cancel = () => { cancelled = true; pending = false; };
|
|
68
72
|
return paint;
|
|
69
73
|
}
|
|
70
74
|
|
|
@@ -108,6 +112,28 @@ function resolveTuiWorkspacePath(args, options = {}) {
|
|
|
108
112
|
return explicitRoot || process.cwd();
|
|
109
113
|
}
|
|
110
114
|
|
|
115
|
+
// 在 Windows 传统控制台(ConHost)下,输出句柄默认不启用
|
|
116
|
+
// ENABLE_VIRTUAL_TERMINAL_PROCESSING,导致备用屏 ?1049h / 清屏 2J 等 ANSI
|
|
117
|
+
// 序列不被解析,主屏保留滚动历史(滚轮能滚回旧帧)。Electron-as-node sidecar
|
|
118
|
+
// 也不会像纯 Node 那样由 libuv 自动启用 VT。这里主动给输出句柄启用 VT 处理,
|
|
119
|
+
// 给输入句柄启用原始输入,作为 main.ts 之外入口的保险。
|
|
120
|
+
function enableWindowsVirtualTerminal() {
|
|
121
|
+
if (process.platform !== 'win32') return;
|
|
122
|
+
try {
|
|
123
|
+
const { spawnSync } = require('node:child_process');
|
|
124
|
+
const script = [
|
|
125
|
+
'Add-Type -TypeDefinition \'using System; using System.Runtime.InteropServices; public static class NewmarkTuiVT { [DllImport("kernel32.dll")] public static extern IntPtr GetStdHandle(int n); [DllImport("kernel32.dll")] public static extern bool GetConsoleMode(IntPtr h, out uint m); [DllImport("kernel32.dll")] public static extern bool SetConsoleMode(IntPtr h, uint m); }\'',
|
|
126
|
+
'$o = [NewmarkTuiVT]::GetStdHandle(-11); $om = [uint32]0; if ([NewmarkTuiVT]::GetConsoleMode($o, [ref]$om)) { [NewmarkTuiVT]::SetConsoleMode($o, ($om -bor 4)) | Out-Null }',
|
|
127
|
+
'$i = [NewmarkTuiVT]::GetStdHandle(-10); $im = [uint32]0; if ([NewmarkTuiVT]::GetConsoleMode($i, [ref]$im)) { [NewmarkTuiVT]::SetConsoleMode($i, (($im -band (-bnot 7)) -bor 512)) | Out-Null }',
|
|
128
|
+
].join('; ');
|
|
129
|
+
spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], {
|
|
130
|
+
stdio: 'ignore',
|
|
131
|
+
windowsHide: true,
|
|
132
|
+
timeout: 3000,
|
|
133
|
+
});
|
|
134
|
+
} catch {}
|
|
135
|
+
}
|
|
136
|
+
|
|
111
137
|
function start(options = {}) {
|
|
112
138
|
const forcedTerminal = process.env.NEWMARK_FORCE_TTY === "1";
|
|
113
139
|
if ((!process.stdin.isTTY || !process.stdout.isTTY) && !forcedTerminal) {
|
|
@@ -115,6 +141,9 @@ function start(options = {}) {
|
|
|
115
141
|
process.exitCode = 1;
|
|
116
142
|
return;
|
|
117
143
|
}
|
|
144
|
+
// 备用屏依赖输出句柄的 VT 处理;纯 Node 的 libuv 会自动启用,但
|
|
145
|
+
// Electron-as-node sidecar 或强制 TTY 路径不会,这里补上。
|
|
146
|
+
if (forcedTerminal || !process.stdout.isTTY) enableWindowsVirtualTerminal();
|
|
118
147
|
|
|
119
148
|
const args = process.argv.slice(2);
|
|
120
149
|
let adapter;
|
|
@@ -140,24 +169,38 @@ function start(options = {}) {
|
|
|
140
169
|
let timer = null;
|
|
141
170
|
let animationTimer = null;
|
|
142
171
|
let closing = false;
|
|
172
|
+
// 标准 TUI 屏幕模型:进入备用屏幕缓冲(alternate screen buffer)。
|
|
173
|
+
// 备用屏没有主屏的滚动历史:每次刷新用 2J 全量重绘而不是滚动新页面,
|
|
174
|
+
// 鼠标滚轮无法滚回上一次刷新,终端滚动也不会让画面错位(否则高亮行
|
|
175
|
+
// 会随终端滚动"离开屏幕")。
|
|
176
|
+
const enterAltScreen = () => process.stdout.write(`${ESC}?1049h${ESC}?25l${ESC}2J${ESC}H`);
|
|
177
|
+
const leaveAltScreen = () => `${ESC}?25h${ESC}?1049l${ESC}0m${ESC}2J${ESC}H`;
|
|
143
178
|
const paint = createPaintScheduler(state);
|
|
144
179
|
const cleanup = () => {
|
|
145
180
|
if (timer) clearInterval(timer);
|
|
146
181
|
if (animationTimer) clearInterval(animationTimer);
|
|
147
182
|
if (typeof process.stdin.setRawMode === "function") process.stdin.setRawMode(false);
|
|
148
183
|
process.stdin.pause();
|
|
149
|
-
process.stdout.write(`${ESC}?25h${ESC}0m${ESC}2J${ESC}H`);
|
|
150
184
|
};
|
|
151
185
|
const quit = () => {
|
|
152
186
|
if (closing) return;
|
|
153
187
|
closing = true;
|
|
188
|
+
paint.cancel();
|
|
154
189
|
cleanup();
|
|
155
190
|
if (typeof state.adapter.close === "function") state.adapter.close();
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
191
|
+
// 退出序列与关闭消息用 fs.writeSync 同步写入 fd:直接进入内核管道,
|
|
192
|
+
// 不经过异步流缓冲,process.exit() 不会丢弃任何字节(此前异步 write
|
|
193
|
+
// 在退出瞬间被 ConPTY/管道丢弃,导致恢复主屏后看不到退出消息)。
|
|
194
|
+
try {
|
|
195
|
+
const fs = require("node:fs");
|
|
196
|
+
const fd = process.stdout.fd || 1;
|
|
197
|
+
fs.writeSync(fd, `${leaveAltScreen()}\n`);
|
|
198
|
+
fs.writeSync(fd, state.adapterKind === "mock"
|
|
199
|
+
? "Newmark TUI demo closed. No state was saved.\n"
|
|
200
|
+
: "Newmark TUI closed. Conversation and settings state are persisted by Newmark.\n");
|
|
201
|
+
} catch {}
|
|
159
202
|
process.exitCode = 0;
|
|
160
|
-
|
|
203
|
+
process.exit(0);
|
|
161
204
|
};
|
|
162
205
|
|
|
163
206
|
function simulateReply(text) {
|
|
@@ -554,8 +597,10 @@ function start(options = {}) {
|
|
|
554
597
|
process.stdin.resume();
|
|
555
598
|
process.stdin.on("keypress", handleKey);
|
|
556
599
|
process.stdout.on("resize", paint);
|
|
557
|
-
|
|
600
|
+
// 兜底:任何退出路径(包括未捕获异常后的 exit 事件)都恢复主屏缓冲与光标。
|
|
601
|
+
process.on("exit", () => process.stdout.write(`${ESC}?25h${ESC}?1049l${ESC}0m`));
|
|
558
602
|
process.on("SIGTERM", quit);
|
|
603
|
+
enterAltScreen();
|
|
559
604
|
paint();
|
|
560
605
|
}
|
|
561
606
|
|
package/dist/tui/src/data.js
CHANGED
|
@@ -204,6 +204,34 @@ const providers = [
|
|
|
204
204
|
}
|
|
205
205
|
];
|
|
206
206
|
|
|
207
|
+
// Demo-only long-list injection: NEWMARK_TUI_DEMO_MODELS=<count> appends a
|
|
208
|
+
// dedicated stress provider with <count> models so the featureless --demo TUI
|
|
209
|
+
// can exercise long model-selection cursor-follow behavior without any runtime.
|
|
210
|
+
const DEMO_MODEL_COUNT = Number(process.env.NEWMARK_TUI_DEMO_MODELS || 0);
|
|
211
|
+
if (Number.isFinite(DEMO_MODEL_COUNT) && DEMO_MODEL_COUNT > 0) {
|
|
212
|
+
providers.push({
|
|
213
|
+
id: "provider-demo-stress",
|
|
214
|
+
name: "Demo Stress",
|
|
215
|
+
base_url: "https://demo.invalid/v1",
|
|
216
|
+
api_key: "",
|
|
217
|
+
has_api_key: false,
|
|
218
|
+
protocol: "openai",
|
|
219
|
+
enabled: true,
|
|
220
|
+
models: Array.from({ length: DEMO_MODEL_COUNT }, (_, index) => ({
|
|
221
|
+
name: `demo-stress-model-${String(index).padStart(3, "0")}`,
|
|
222
|
+
display: `Stress Model ${String(index).padStart(3, "0")}`,
|
|
223
|
+
description: `Long-list cursor-follow stress model ${index} description`,
|
|
224
|
+
max_tokens: 128000,
|
|
225
|
+
vision: false,
|
|
226
|
+
thinking: false,
|
|
227
|
+
enabled: true,
|
|
228
|
+
speed_rating: "unknown",
|
|
229
|
+
capability_rating: "unknown",
|
|
230
|
+
validation: { status: "unavailable", level: "discovered", checked_at: "" }
|
|
231
|
+
}))
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
207
235
|
const flows = [
|
|
208
236
|
{
|
|
209
237
|
name: "release-readiness",
|