Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/api/src/services/terminal-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
appendTerminalOutput,
createTerminalImagePastePlan,
emptyTerminalOutputBuffer,
projectTerminalLabel,
renderTerminalOutputBuffer,
terminalImagePasteDirectory,
type TerminalImagePastePayload,
Expand Down Expand Up @@ -1399,7 +1400,7 @@ export const createTerminalSession = (
const session = yield* _(registerRecord(
resolvedProjectId,
project.projectKey,
project.displayName,
projectTerminalLabel(project),
prepared,
projectItem.containerName,
projectItem.targetDir,
Expand All @@ -1421,7 +1422,7 @@ export const createTerminalSession = (
const session = yield* _(registerRecord(
resolvedProjectId,
startedProject.projectKey,
startedProject.displayName,
projectTerminalLabel(startedProject),
prepared,
reachableProjectItem.containerName,
reachableProjectItem.targetDir,
Expand Down
2 changes: 1 addition & 1 deletion packages/api/tests/terminal-sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@ describe("terminal sessions service", () => {
status: "ready"
})
await expect(runTestEffect(lookupTerminalSessionById(second.session.id))).resolves.toMatchObject({
projectDisplayName: displayName,
projectDisplayName: "https://github.com/org/repo/issues/7 | container dg-repo-issue-7",
projectKey,
session: {
id: second.session.id,
Expand Down
9 changes: 5 additions & 4 deletions packages/app/src/docker-git/open-project-ssh.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { PlatformError } from "@effect/platform/Error"
import * as FileSystem from "@effect/platform/FileSystem"
import * as Path from "@effect/platform/Path"
import { projectTerminalLabel } from "@prover-coder-ai/docker-git-terminal/core"
import { Duration, Effect } from "effect"

import { createProjectTerminalSession, upProject } from "./api-client.js"
Expand Down Expand Up @@ -156,7 +157,7 @@ const resolveHostSshLaunchSpec = (

const writeProjectSshHeader = (item: ProjectItem): Effect.Effect<void> =>
Effect.sync(() => {
writeToTerminal(`\n[docker-git] SSH terminal: ${item.displayName}\n`)
writeToTerminal(`\n[docker-git] SSH terminal: ${projectTerminalLabel(item)}\n`)
writeToTerminal(`[docker-git] ${item.sshCommand}\n\n`)
})

Expand Down Expand Up @@ -203,9 +204,9 @@ export const openResolvedProjectSshWithUpEffect = <E, R>(
) =>
Effect.gen(function*(_) {
const writeProgress = deps.writeProgress ?? writeProjectOpenProgress
yield* _(writeProgress(`Starting project before SSH: ${item.displayName}`))
yield* _(writeProgress(`Starting project before SSH: ${projectTerminalLabel(item)}`))
const refreshedItem = yield* _(deps.upProject(item.projectDir))
yield* _(writeProgress(`Opening SSH terminal: ${(refreshedItem ?? item).displayName}`))
yield* _(writeProgress(`Opening SSH terminal: ${projectTerminalLabel(refreshedItem ?? item)}`))
yield* _(deps.openProjectSsh(refreshedItem ?? item))
})

Expand Down Expand Up @@ -241,7 +242,7 @@ export const openResolvedProjectSshViaController = (item: ProjectItem) =>
createSession: (projectId) => createProjectTerminalSession(projectId),
attach: (project, session) =>
attachTerminalSession({
header: `SSH terminal: ${project.displayName}`,
header: `SSH terminal: ${projectTerminalLabel(project)}`,
session,
websocketPath: `/projects/${encodeURIComponent(project.projectDir)}/terminal-sessions/${
encodeURIComponent(session.id)
Expand Down
4 changes: 3 additions & 1 deletion packages/app/src/web/app-ready-controller-context.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { projectTerminalLabel } from "@prover-coder-ai/docker-git-terminal/core"

import type { DashboardData } from "./api.js"
import { createActionContext } from "./app-ready-actions.js"
import type { ReadyState } from "./app-ready-hooks.js"
Expand All @@ -23,7 +25,7 @@ export const createReadyActionContext = (
refreshDashboard,
selectedProjectId: state.selectedProjectId,
selectedProjectKey: selectedProjectSummary?.projectKey ?? null,
selectedProjectName: selectedProjectSummary?.displayName ?? null,
selectedProjectName: selectedProjectSummary === undefined ? null : projectTerminalLabel(selectedProjectSummary),
setActionPrompt: state.setActionPrompt,
setActiveScreen: state.setActiveScreen,
setAuthSnapshot: state.setAuthSnapshot,
Expand Down
4 changes: 3 additions & 1 deletion packages/app/src/web/app-ready-ssh-link-terminal.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { projectTerminalLabel } from "@prover-coder-ai/docker-git-terminal/core"

import type { BrowserActionContext } from "./actions-shared.js"
import type { TerminalSession } from "./api-types.js"
import type { DashboardProject } from "./app-ready-ssh-link-core.js"
Expand Down Expand Up @@ -135,7 +137,7 @@ const buildProjectTerminalSession = (
buildProjectActiveTerminalSession({
onExit: args.actionContext.reloadDashboard,
onReady: args.actionContext.reloadDashboard,
projectDisplayName: project.displayName,
projectDisplayName: projectTerminalLabel(project),
projectId: project.id,
projectKey: project.projectKey,
session
Expand Down
4 changes: 2 additions & 2 deletions packages/app/tests/docker-git/open-project-ssh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,9 @@ describe("openResolvedProjectSshWithUpEffect", () => {
})
const events = yield* _(captureOpenResolvedProjectSshWithUpEvents(item))
expect(events).toEqual([
"progress:Starting project before SSH: org/repo",
"progress:Starting project before SSH: https://github.com/org/repo.git | container dg-repo",
"up:/controller/org/repo/issue-9",
"progress:Opening SSH terminal: org/repo",
"progress:Opening SSH terminal: https://github.com/org/repo.git | container dg-repo",
"open:ssh -p 2299 dev@127.0.0.1"
])
}))
Expand Down
1 change: 1 addition & 0 deletions packages/terminal/src/core/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./image-paste.js"
export * from "./output-buffer.js"
export * from "./project-terminal-label.js"
139 changes: 139 additions & 0 deletions packages/terminal/src/core/project-terminal-label.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
export type ProjectTerminalLabelInput = {
readonly containerName?: string | undefined
readonly displayName: string
readonly repoRef: string
readonly repoUrl: string
}

const decimalDigitsPattern = /^\d+$/u

const stripGitSuffix = (value: string): string => value.endsWith(".git") ? value.slice(0, -4) : value

const readPathPart = (value: string | undefined): string | null => {
const trimmed = value?.trim() ?? ""
return trimmed.length > 0 ? trimmed : null
}

const splitGitHubRemotePath = (repoUrl: string): ReadonlyArray<string> | null => {
const trimmed = repoUrl.trim()
const httpsPrefix = "https://github.com/"
const sshUrlPrefix = "ssh://git@github.com/"
const sshScpPrefix = "git@github.com:"
if (trimmed.startsWith(httpsPrefix)) {
return trimmed.slice(httpsPrefix.length).split("/").filter((part) => part.length > 0)
}
if (trimmed.startsWith(sshUrlPrefix)) {
return trimmed.slice(sshUrlPrefix.length).split("/").filter((part) => part.length > 0)
}
if (trimmed.startsWith(sshScpPrefix)) {
return trimmed.slice(sshScpPrefix.length).split("/").filter((part) => part.length > 0)
}
return null
}

const githubRepositoryPath = (repoUrl: string): string | null => {
const parts = splitGitHubRemotePath(repoUrl)
const owner = readPathPart(parts?.[0])
const repoRaw = readPathPart(parts?.[1])
if (owner === null || repoRaw === null) {
return null
}
return `${owner}/${stripGitSuffix(repoRaw)}`
}

const sourceUrlForContext = (repoUrl: string, path: string): string | null => {
const repoPath = githubRepositoryPath(repoUrl)
return repoPath === null ? null : `https://github.com/${repoPath}/${path}`
}

const renderIssueContext = (repoUrl: string, issueId: string): string => {
const issueUrl = sourceUrlForContext(repoUrl, `issues/${issueId}`)
return issueUrl === null ? `issue #${issueId}` : issueUrl
}

const renderPullRequestContext = (repoUrl: string, pullRequestId: string): string => {
const pullRequestUrl = sourceUrlForContext(repoUrl, `pull/${pullRequestId}`)
return pullRequestUrl === null ? `PR #${pullRequestId}` : pullRequestUrl
}

const renderMergeRequestContext = (mergeRequestId: string): string => `MR #${mergeRequestId}`

const renderSourceContext = (repoUrl: string, repoRef: string): string => {
const trimmedUrl = repoUrl.trim()
const trimmedRef = repoRef.trim()
if (trimmedUrl.length === 0) {
return trimmedRef.length === 0 || trimmedRef === "main" ? "" : trimmedRef
}
return trimmedRef.length === 0 || trimmedRef === "main"
? trimmedUrl
: `${trimmedUrl} (${trimmedRef})`
}
Comment on lines +62 to +70
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Не выводите сырой repoUrl в терминальный лейбл — возможна утечка токенов.

На Line 62-70 и Line 137-138 в label попадает repoUrl без санитизации. Если URL содержит userinfo/query (например PAT), секрет окажется в UI/прогрессе/персистентных записях сессий.

💡 Минимальный фикс
+const sanitizeRepoUrlForLabel = (value: string): string =>
+  value
+    .trim()
+    .replace(/^(https?:\/\/)[^/@\s]+@/u, "$1")
+    .split(/[?#]/u, 1)[0] ?? ""

 const renderSourceContext = (repoUrl: string, repoRef: string): string => {
-  const trimmedUrl = repoUrl.trim()
+  const trimmedUrl = sanitizeRepoUrlForLabel(repoUrl)
   const trimmedRef = repoRef.trim()
   if (trimmedUrl.length === 0) {
     return trimmedRef.length === 0 || trimmedRef === "main" ? "" : trimmedRef
   }
   return trimmedRef.length === 0 || trimmedRef === "main"
     ? trimmedUrl
     : `${trimmedUrl} (${trimmedRef})`
 }
 ...
 export const projectTerminalLabel = (project: ProjectTerminalLabelInput): string => {
 ...
   const displayName = project.displayName.trim()
-  return displayName.length === 0 ? project.repoUrl.trim() : displayName
+  return displayName.length === 0 ? sanitizeRepoUrlForLabel(project.repoUrl) : displayName
 }

As per coding guidelines: Fail if changed files expose credentials, tokens, private-keys, or PII in source, generated config, logs, or CI output.

Also applies to: 137-138

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/terminal/src/core/project-terminal-label.ts` around lines 62 - 70,
The label currently returns raw repoUrl (via trimmedUrl/repoRef) which can leak
tokens; update the code that builds the terminal label (the block using
repoUrl/trimmedUrl and repoRef/trimmedRef) to sanitize repoUrl first by parsing
and removing userinfo and query string—e.g., use the URL constructor to set
username/password='' and search='' and then use the sanitized URL in the
existing return logic; if URL parsing fails, fallback to redacting anything
before/after an '@' or strip query string so no credentials or query params are
included (also apply the same sanitization where repoUrl is used at the other
site mentioned around lines 137-138).


const parseWrappedNumericRef = (value: string, prefix: string, suffix: string): string | null => {
if (!value.startsWith(prefix) || !value.endsWith(suffix)) {
return null
}
const id = value.slice(prefix.length, value.length - suffix.length)
return decimalDigitsPattern.test(id) ? id : null
}

const renderWorkspaceContext = (
repoUrl: string,
repoRef: string
): string => {
const issueId = parseWrappedNumericRef(repoRef, "issue-", "")
if (issueId !== null) {
return renderIssueContext(repoUrl, issueId)
}
const pullRequestId = parseWrappedNumericRef(repoRef, "refs/pull/", "/head")
if (pullRequestId !== null) {
return renderPullRequestContext(repoUrl, pullRequestId)
}
const mergeRequestId = parseWrappedNumericRef(repoRef, "refs/merge-requests/", "/head")
if (mergeRequestId !== null) {
return renderMergeRequestContext(mergeRequestId)
}
return renderSourceContext(repoUrl, repoRef)
}

const appendNonEmpty = (parts: ReadonlyArray<string>, value: string): ReadonlyArray<string> => {
const trimmed = value.trim()
return trimmed.length === 0 ? parts : [...parts, trimmed]
}

/**
* Builds the terminal-facing project label with source link and container identity.
*
* @param project - Project identity returned by the docker-git API.
* @returns A deterministic label for SSH terminal headers and ready messages.
*
* @pure true
* @effect none
* @invariant GitHub issue/PR refs prefer canonical source URLs; labels preserve non-empty containerName.
* @precondition project.displayName identifies the repository or fallback project label.
* @postcondition result contains workspace source link/context and non-empty containerName when present.
* @complexity O(n) where n = |repoUrl| + |repoRef|
* @throws Never
*/
// CHANGE: keep SSH terminal labels to source link/context plus container identity
// WHY: verbose repository + issue text duplicates the source URL and crowds the terminal header
// QUOTE(ТЗ): "ссылки и название контейнера будет предостаточно"
// REF: issue-370
// SOURCE: n/a
// FORMAT THEOREM: forall p: label(p) contains context(repoUrl(p), repoRef(p)) or containerName(p)
// PURITY: CORE
// EFFECT: none
// INVARIANT: issue-* -> issue context; refs/pull/*/head -> PR context; containerName is preserved when non-empty
// COMPLEXITY: O(n)
export const projectTerminalLabel = (project: ProjectTerminalLabelInput): string => {
const withContext = appendNonEmpty([], renderWorkspaceContext(project.repoUrl, project.repoRef))
const containerName = project.containerName?.trim() ?? ""
const withContainer = containerName.length === 0
? withContext
: appendNonEmpty(withContext, `container ${containerName}`)
if (withContainer.length > 0) {
return withContainer.join(" | ")
}
const displayName = project.displayName.trim()
return displayName.length === 0 ? project.repoUrl.trim() : displayName
}
Loading
Loading