mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(cli): strip terminal control sequences from Linear text in CLI output (#31677)
Linear-provided titles, states, assignees, projects, labels, workspace names and error messages were printed verbatim, so remote text could emit OSC/CSI/C1 sequences or forge extra output lines. Move terminalSafe into src/shared/terminal-safe-text.ts, add terminalSafeLine (collapses CR/LF), and apply it to every Linear text field in the human-readable formatters. --json output is unchanged. Fixes #20478 Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
@@ -16,7 +16,7 @@ export async function loadAnsiConsumers(root = process.cwd()) {
|
||||
['parseCodexLoginAuthUrl', 'main/codex-accounts/codex-login-auth-url'],
|
||||
['TerminalCommandPaint', 'main/runtime/terminal-command-paint'],
|
||||
['claudePermissionSubject', 'main/claude/claude-permission-presentation'],
|
||||
['terminalSafe', 'cli/agent-session-search-format'],
|
||||
['terminalSafe', 'shared/terminal-safe-text'],
|
||||
[
|
||||
'createAutomationRunOutputSnapshotBuffer',
|
||||
'renderer/src/components/automations/automation-run-output-snapshot'
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
formatSessionSearchResponse,
|
||||
formatSessionSearchStatus,
|
||||
terminalSafe
|
||||
formatSessionSearchStatus
|
||||
} from './agent-session-search-format'
|
||||
import type {
|
||||
AiVaultSearchHit,
|
||||
@@ -174,11 +173,6 @@ describe('formatSessionSearchResponse: non-result answers', () => {
|
||||
})
|
||||
|
||||
describe('terminal safety', () => {
|
||||
it('strips escape sequences a transcript could carry into the reader terminal', () => {
|
||||
expect(terminalSafe('before\u001b]52;c;cGF5bG9hZA==\u0007after')).toBe('beforeafter')
|
||||
expect(terminalSafe('red \u001b[31mtext\u001b[0m')).toBe('red text')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'updatedAt',
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import {
|
||||
stripAnsiEscapeSequences,
|
||||
TERMINAL_CONTROL_CHARACTER_PATTERN
|
||||
} from '../shared/ansi-escape-sequences'
|
||||
import { aiVaultAgentLabel } from '../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultSearchHit,
|
||||
AiVaultSearchResponse,
|
||||
AiVaultSearchStatus
|
||||
} from '../shared/ai-vault-search-types'
|
||||
import { terminalSafeLine } from '../shared/terminal-safe-text'
|
||||
|
||||
type SessionSearchResults = Extract<AiVaultSearchResponse, { kind: 'results' }>
|
||||
/** Malformed cursors are a caller mistake and leave through the CLI error channel. */
|
||||
@@ -16,15 +13,8 @@ export type PrintableSessionSearchResponse = Exclude<
|
||||
{ kind: 'malformed-cursor' }
|
||||
>
|
||||
|
||||
// Strip terminal controls from untrusted transcripts, including OSC 52 clipboard writes.
|
||||
export function terminalSafe(value: string): string {
|
||||
return stripAnsiEscapeSequences(value).replace(TERMINAL_CONTROL_CHARACTER_PATTERN, '')
|
||||
}
|
||||
|
||||
function oneLine(value: string): string {
|
||||
return terminalSafe(value)
|
||||
.replaceAll(/[\r\n]+/g, ' ')
|
||||
.trim()
|
||||
return terminalSafeLine(value).trim()
|
||||
}
|
||||
|
||||
function formatHit(hit: AiVaultSearchHit): string {
|
||||
|
||||
@@ -191,4 +191,116 @@ describe('linear-format', () => {
|
||||
|
||||
expect(formatLinearCreate(result)).toBe('Created ENG-123 in Launch: Follow up.')
|
||||
})
|
||||
|
||||
const OSC_52 = '\u001b]52;c;cGF5bG9hZA==\u0007'
|
||||
const CLEAR_SCREEN = '\u001b[2J'
|
||||
|
||||
it('strips escape sequences, C1 bytes and line breaks from issue fields', () => {
|
||||
const result: LinearIssueContextResult = {
|
||||
issue: {
|
||||
id: 'issue-1',
|
||||
identifier: 'ENG-123',
|
||||
title: `Fix${OSC_52} auth\r\nState: Done`,
|
||||
url: 'https://linear.app/acme/issue/ENG-123',
|
||||
state: { name: `In Progress${CLEAR_SCREEN}` },
|
||||
assignee: { displayName: 'Ada\u009b31m' },
|
||||
project: { id: 'project-1', name: 'Launch' },
|
||||
labels: [{ id: 'label-1', name: `bug${OSC_52}` }],
|
||||
priority: 2,
|
||||
estimate: 5
|
||||
},
|
||||
meta: {
|
||||
requested: {
|
||||
current: false,
|
||||
include: {
|
||||
comments: false,
|
||||
children: false,
|
||||
attachments: false,
|
||||
relations: false,
|
||||
activity: false
|
||||
},
|
||||
depth: 0
|
||||
},
|
||||
resolved: {
|
||||
id: 'issue-1',
|
||||
identifier: 'ENG-123',
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceName: 'Acme'
|
||||
},
|
||||
partial: false,
|
||||
includeErrors: [],
|
||||
sections: {}
|
||||
}
|
||||
}
|
||||
|
||||
expect(formatLinearIssue(result)).toBe(
|
||||
[
|
||||
'ENG-123 Fix auth State: Done',
|
||||
'URL: https://linear.app/acme/issue/ENG-123',
|
||||
'State: In Progress',
|
||||
'Assignee: Ada',
|
||||
'Project: Launch',
|
||||
'Priority: high',
|
||||
'Estimate: 5',
|
||||
'Labels: bug'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps one aligned row per issue when a title carries a line break', () => {
|
||||
const output = formatLinearMcpIssueList({
|
||||
issues: [
|
||||
{
|
||||
id: 'issue-1',
|
||||
identifier: 'ENG-1',
|
||||
title: `Fix auth\r\nENG-2 Done${CLEAR_SCREEN}`,
|
||||
url: 'https://linear.app/acme/issue/ENG-1',
|
||||
labels: [],
|
||||
state: { name: `In${OSC_52} Progress` },
|
||||
assignee: { displayName: 'Ada\u009d0;pwned\u009c' },
|
||||
workspace: { id: 'workspace-1', name: 'Acme' }
|
||||
}
|
||||
],
|
||||
truncated: false,
|
||||
meta: {
|
||||
limit: 50,
|
||||
returned: 1,
|
||||
hasMore: false,
|
||||
orderBy: 'updatedAt',
|
||||
workspaceId: 'workspace-1',
|
||||
partial: false,
|
||||
workspaceErrors: []
|
||||
}
|
||||
})
|
||||
|
||||
expect(output).toBe(
|
||||
`${'ENG-1'.padEnd(10)} ${'In Progress'.padEnd(14)} ${'Ada'.padEnd(18)} Fix auth ENG-2 Done`
|
||||
)
|
||||
})
|
||||
|
||||
it('strips control sequences from Linear-provided warning text', () => {
|
||||
printLinearMcpIssueListWarnings({
|
||||
issues: [],
|
||||
truncated: false,
|
||||
meta: {
|
||||
limit: 20,
|
||||
returned: 0,
|
||||
hasMore: false,
|
||||
orderBy: 'updatedAt',
|
||||
workspaceId: 'all',
|
||||
partial: true,
|
||||
workspaceErrors: [
|
||||
{
|
||||
workspace: { id: 'workspace-1', name: `Acme${OSC_52}` },
|
||||
code: 'linear_rate_limited',
|
||||
message: `rate limited\r\nwarning: fake${CLEAR_SCREEN}`
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'warning: Acme unavailable for Linear: rate limited warning: fake'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
+43
-35
@@ -25,28 +25,29 @@ import {
|
||||
formatLinearProjectListRows,
|
||||
linearProjectListWarningLines
|
||||
} from '../shared/linear/project-list-format'
|
||||
import { terminalSafeLine as safe } from '../shared/terminal-safe-text'
|
||||
|
||||
export function formatLinearIssue(result: LinearIssueContextResult): string {
|
||||
const issue = result.issue
|
||||
const lines = [
|
||||
`${issue.identifier} ${issue.title}`,
|
||||
`URL: ${issue.url}`,
|
||||
`State: ${issue.state?.name ?? 'unknown'}`,
|
||||
`Assignee: ${issue.assignee?.displayName ?? 'unassigned'}`,
|
||||
`Project: ${issue.project?.name ?? 'none'}`
|
||||
`${safe(issue.identifier)} ${safe(issue.title)}`,
|
||||
`URL: ${safe(issue.url)}`,
|
||||
`State: ${safe(issue.state?.name ?? 'unknown')}`,
|
||||
`Assignee: ${safe(issue.assignee?.displayName ?? 'unassigned')}`,
|
||||
`Project: ${safe(issue.project?.name ?? 'none')}`
|
||||
]
|
||||
lines.push(`Priority: ${formatPriority(issue.priority)}`)
|
||||
lines.push(`Estimate: ${issue.estimate ?? 'none'}`)
|
||||
if (issue.labels.length > 0) {
|
||||
lines.push(
|
||||
`Labels: ${issue.labels
|
||||
.map((label) => label.name)
|
||||
.map((label) => safe(label.name ?? ''))
|
||||
.filter(Boolean)
|
||||
.join(', ')}`
|
||||
)
|
||||
}
|
||||
if (issue.dueDate) {
|
||||
lines.push(`Due: ${issue.dueDate}`)
|
||||
lines.push(`Due: ${safe(issue.dueDate)}`)
|
||||
}
|
||||
const sections = result.meta.sections
|
||||
if (sections.comments) {
|
||||
@@ -87,35 +88,38 @@ export function formatLinearTeamList(result: LinearTeamListResult): string {
|
||||
}
|
||||
return result.teams
|
||||
.map((team) => {
|
||||
const workspace = team.workspace ? ` ${team.workspace.name}` : ''
|
||||
return `${team.key.padEnd(10)} ${team.name}${workspace}`
|
||||
const workspace = team.workspace ? ` ${safe(team.workspace.name)}` : ''
|
||||
return `${safe(team.key).padEnd(10)} ${safe(team.name)}${workspace}`
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export function formatLinearTeamMembers(result: LinearTeamMembersResult): string {
|
||||
if (result.members.length === 0) {
|
||||
return `No Linear members found for ${result.team.key}.`
|
||||
return `No Linear members found for ${safe(result.team.key)}.`
|
||||
}
|
||||
return result.members
|
||||
.map((member) => `${(member.displayName ?? 'unknown').padEnd(24)} ${member.id ?? ''}`)
|
||||
.map((member) => `${safe(member.displayName ?? 'unknown').padEnd(24)} ${safe(member.id ?? '')}`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export function formatLinearTeamStates(result: LinearTeamStatesResult): string {
|
||||
if (result.states.length === 0) {
|
||||
return `No Linear workflow states found for ${result.team.key}.`
|
||||
return `No Linear workflow states found for ${safe(result.team.key)}.`
|
||||
}
|
||||
return result.states
|
||||
.map((state) => `${state.name.padEnd(24)} ${(state.type ?? '').padEnd(12)} ${state.id}`)
|
||||
.map(
|
||||
(state) =>
|
||||
`${safe(state.name).padEnd(24)} ${safe(state.type ?? '').padEnd(12)} ${safe(state.id)}`
|
||||
)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export function formatLinearTeamLabels(result: LinearTeamLabelsResult): string {
|
||||
if (result.labels.length === 0) {
|
||||
return `No Linear labels found for ${result.team.key}.`
|
||||
return `No Linear labels found for ${safe(result.team.key)}.`
|
||||
}
|
||||
return result.labels.map((label) => `${label.name.padEnd(24)} ${label.id}`).join('\n')
|
||||
return result.labels.map((label) => `${safe(label.name).padEnd(24)} ${safe(label.id)}`).join('\n')
|
||||
}
|
||||
|
||||
export function formatLinearIssueList(result: LinearIssueListResult): string {
|
||||
@@ -144,14 +148,16 @@ export function printLinearMcpIssueListWarnings(result: LinearMcpIssueListResult
|
||||
if (result.meta.hasMore) {
|
||||
const workspaceHint =
|
||||
result.meta.nextCursor && result.meta.workspaceId !== 'all' && result.meta.workspaceId
|
||||
? `; continue with --workspace ${result.meta.workspaceId}`
|
||||
? `; continue with --workspace ${safe(result.meta.workspaceId)}`
|
||||
: ''
|
||||
console.error(
|
||||
`warning: more results available; next cursor: ${result.meta.nextCursor ?? 'n/a'}${workspaceHint}`
|
||||
`warning: more results available; next cursor: ${safe(result.meta.nextCursor ?? 'n/a')}${workspaceHint}`
|
||||
)
|
||||
}
|
||||
for (const error of result.meta.workspaceErrors) {
|
||||
console.error(`warning: ${error.workspace.name} unavailable for Linear: ${error.message}`)
|
||||
console.error(
|
||||
`warning: ${safe(error.workspace.name)} unavailable for Linear: ${safe(error.message)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,44 +167,44 @@ export function formatLinearProjectList(result: LinearProjectListResult): string
|
||||
|
||||
export function formatLinearStatusSet(result: LinearStatusSetResult): string {
|
||||
const suffix = result.meta.alreadyInState ? ' (already set)' : ''
|
||||
return `Set ${result.issue.identifier} to ${result.state.name}${suffix}.`
|
||||
return `Set ${safe(result.issue.identifier)} to ${safe(result.state.name)}${suffix}.`
|
||||
}
|
||||
|
||||
export function formatLinearCommentAdd(result: LinearCommentAddResult): string {
|
||||
const suffix = result.meta.deduplicated ? ' (already posted)' : ''
|
||||
return `Added comment ${result.comment.id} to ${result.issue.identifier}${suffix}.`
|
||||
return `Added comment ${safe(result.comment.id)} to ${safe(result.issue.identifier)}${suffix}.`
|
||||
}
|
||||
|
||||
export function formatLinearCommentEdit(result: LinearCommentEditResult): string {
|
||||
return `Edited comment ${result.comment.id} on ${result.issue.identifier}.`
|
||||
return `Edited comment ${safe(result.comment.id)} on ${safe(result.issue.identifier)}.`
|
||||
}
|
||||
|
||||
export function formatLinearCommentDelete(result: LinearCommentDeleteResult): string {
|
||||
return `Deleted comment ${result.comment.id} from ${result.issue.identifier}.`
|
||||
return `Deleted comment ${safe(result.comment.id)} from ${safe(result.issue.identifier)}.`
|
||||
}
|
||||
|
||||
export function formatLinearAttach(result: LinearAttachResult): string {
|
||||
const suffix = result.meta.deduplicated ? ' (already attached)' : ''
|
||||
return `Attached ${result.attachment.title} to ${result.issue.identifier}${suffix}.`
|
||||
return `Attached ${safe(result.attachment.title)} to ${safe(result.issue.identifier)}${suffix}.`
|
||||
}
|
||||
|
||||
export function formatLinearCreate(result: LinearCreateResult | LinearSaveIssueResult): string {
|
||||
const parent = result.issue.parent ? ` under ${result.issue.parent.identifier}` : ''
|
||||
const project = result.issue.project?.name ? ` in ${result.issue.project.name}` : ''
|
||||
const parent = result.issue.parent ? ` under ${safe(result.issue.parent.identifier)}` : ''
|
||||
const project = result.issue.project?.name ? ` in ${safe(result.issue.project.name)}` : ''
|
||||
const suffix = result.meta.deduplicated ? ' (already created)' : ''
|
||||
return `Created ${result.issue.identifier}${parent}${project}: ${result.issue.title}${suffix}.`
|
||||
return `Created ${safe(result.issue.identifier)}${parent}${project}: ${safe(result.issue.title)}${suffix}.`
|
||||
}
|
||||
|
||||
export function formatLinearSaveIssue(result: LinearSaveIssueResult): string {
|
||||
if (result.meta.created) {
|
||||
return formatLinearCreate(result)
|
||||
}
|
||||
return `Saved ${result.issue.identifier}: ${result.issue.title}.`
|
||||
return `Saved ${safe(result.issue.identifier)}: ${safe(result.issue.title)}.`
|
||||
}
|
||||
|
||||
export function formatLinearTaskUpdate(result: LinearIssueTaskUpdateResult): string {
|
||||
const suffix = result.meta.alreadySet ? ' (already set)' : ''
|
||||
return `Updated ${result.issue.identifier} ${taskOperationLabel(result.operation)}${suffix}.`
|
||||
return `Updated ${safe(result.issue.identifier)} ${taskOperationLabel(result.operation)}${suffix}.`
|
||||
}
|
||||
|
||||
export function formatLinearRelationWrite(result: LinearIssueRelationWriteResult): string {
|
||||
@@ -208,12 +214,12 @@ export function formatLinearRelationWrite(result: LinearIssueRelationWriteResult
|
||||
? ' (already present)'
|
||||
: ' (already absent)'
|
||||
: ''
|
||||
return `${verb} ${result.issue.identifier} ${result.relation.relationship} ${result.relatedIssue.identifier}${suffix}.`
|
||||
return `${verb} ${safe(result.issue.identifier)} ${result.relation.relationship} ${safe(result.relatedIssue.identifier)}${suffix}.`
|
||||
}
|
||||
|
||||
export function printLinearIssueWarnings(result: LinearIssueContextResult): void {
|
||||
for (const error of result.meta.includeErrors) {
|
||||
console.error(`warning: ${error.include} unavailable: ${error.message}`)
|
||||
console.error(`warning: ${error.include} unavailable: ${safe(error.message)}`)
|
||||
}
|
||||
for (const [name, meta] of Object.entries(result.meta.sections)) {
|
||||
if (meta?.capReached) {
|
||||
@@ -228,7 +234,7 @@ export function printLinearSearchWarnings(result: LinearSearchResult): void {
|
||||
}
|
||||
for (const error of result.meta.workspaceErrors ?? []) {
|
||||
console.error(
|
||||
`warning: ${error.workspace.name} unavailable for Linear search: ${error.message}`
|
||||
`warning: ${safe(error.workspace.name)} unavailable for Linear search: ${safe(error.message)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -244,7 +250,9 @@ export function printLinearListWarnings(
|
||||
console.error(`warning: showing first ${meta.returned} Linear issues`)
|
||||
}
|
||||
for (const error of meta.workspaceErrors ?? []) {
|
||||
console.error(`warning: ${error.workspace.name} unavailable for Linear: ${error.message}`)
|
||||
console.error(
|
||||
`warning: ${safe(error.workspace.name)} unavailable for Linear: ${safe(error.message)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,9 +263,9 @@ export function printLinearProjectListWarnings(result: LinearProjectListResult):
|
||||
}
|
||||
|
||||
function formatSearchRow(issue: LinearSearchIssueSummary): string {
|
||||
const state = issue.state?.name ?? 'unknown'
|
||||
const assignee = issue.assignee?.displayName ?? 'unassigned'
|
||||
return `${issue.identifier.padEnd(10)} ${state.padEnd(14)} ${assignee.padEnd(18)} ${issue.title}`
|
||||
const state = safe(issue.state?.name ?? 'unknown')
|
||||
const assignee = safe(issue.assignee?.displayName ?? 'unassigned')
|
||||
return `${safe(issue.identifier).padEnd(10)} ${state.padEnd(14)} ${assignee.padEnd(18)} ${safe(issue.title)}`
|
||||
}
|
||||
|
||||
function formatPriority(priority: number | null | undefined): string {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { LinearProjectListResult } from './agent-result-types'
|
||||
import { appendLinearListTruncation } from './list-truncation-format'
|
||||
import { terminalSafeLine as safe } from '../terminal-safe-text'
|
||||
|
||||
// Why: non-JSON project output aligns ids with the existing compact Linear tables.
|
||||
const LINEAR_PROJECT_NAME_COLUMN_WIDTH = 28
|
||||
@@ -14,12 +15,12 @@ export function formatLinearProjectListRows(result: LinearProjectListResult): st
|
||||
project.teams
|
||||
?.map((team) => {
|
||||
const key = team.key?.trim()
|
||||
return key ? key : team.name
|
||||
return safe(key ? key : team.name)
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(',') || 'no-teams'
|
||||
const workspace = project.workspaceName ? ` ${project.workspaceName}` : ''
|
||||
return `${project.name.padEnd(LINEAR_PROJECT_NAME_COLUMN_WIDTH)} ${project.id} ${teams}${workspace}`
|
||||
const workspace = project.workspaceName ? ` ${safe(project.workspaceName)}` : ''
|
||||
return `${safe(project.name).padEnd(LINEAR_PROJECT_NAME_COLUMN_WIDTH)} ${safe(project.id)} ${teams}${workspace}`
|
||||
})
|
||||
.join('\n')
|
||||
return appendLinearListTruncation(
|
||||
@@ -35,7 +36,9 @@ export function linearProjectListWarningLines(result: LinearProjectListResult):
|
||||
warnings.push(`warning: showing first ${result.meta.returned} Linear projects`)
|
||||
}
|
||||
for (const error of result.meta.workspaceErrors ?? []) {
|
||||
warnings.push(`warning: ${error.workspace.name} unavailable for Linear: ${error.message}`)
|
||||
warnings.push(
|
||||
`warning: ${safe(error.workspace.name)} unavailable for Linear: ${safe(error.message)}`
|
||||
)
|
||||
}
|
||||
return warnings
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import {
|
||||
stripAnsiEscapeSequences,
|
||||
TERMINAL_CONTROL_CHARACTER_PATTERN
|
||||
} from './ansi-escape-sequences'
|
||||
|
||||
// Strip terminal controls from untrusted remote text, including OSC 52 clipboard writes.
|
||||
export function terminalSafe(value: string): string {
|
||||
return stripAnsiEscapeSequences(value).replace(TERMINAL_CONTROL_CHARACTER_PATTERN, '')
|
||||
}
|
||||
|
||||
/** Why: a line break inside a single-line field could spoof a separate output line. */
|
||||
export function terminalSafeLine(value: string): string {
|
||||
return terminalSafe(value).replaceAll(/[\r\n]+/g, ' ')
|
||||
}
|
||||
Reference in New Issue
Block a user