From 38a9b14466a5bbcc16fd5821d6e6bc9215429bd9 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 3 Jul 2026 11:53:37 +0800 Subject: [PATCH 1/7] feat(agent-core): strengthen the language-matching rule in the default system prompt --- .changeset/language-matching-prompt.md | 5 +++++ packages/agent-core/src/profile/default/system.md | 11 ++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 .changeset/language-matching-prompt.md diff --git a/.changeset/language-matching-prompt.md b/.changeset/language-matching-prompt.md new file mode 100644 index 000000000..66f23045f --- /dev/null +++ b/.changeset/language-matching-prompt.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Strengthen the system prompt's language rule so replies and reasoning follow the user's language even after long tool-output stretches, while repository artifacts keep project conventions. diff --git a/packages/agent-core/src/profile/default/system.md b/packages/agent-core/src/profile/default/system.md index 122a29960..022e0699a 100644 --- a/packages/agent-core/src/profile/default/system.md +++ b/packages/agent-core/src/profile/default/system.md @@ -4,11 +4,17 @@ Your primary goal is to help users with software engineering tasks by taking act {{ ROLE_ADDITIONAL }} +# Language + +Write in the user's language unless they explicitly ask for a different one. Determine it from their most recent messages — if they switch languages mid-session, switch with them. This applies to everything user-visible: your replies, your reasoning and thinking, progress notes before and between tool calls, and questions you ask. Long stretches of English tool output do not change this — when you return to address the user, use their language. + +Keep code, commands, identifiers, file paths, and technical terms in their original form. Artifacts that go into the repository — code comments, commit messages, PR descriptions, documentation — follow the project's existing conventions, not the conversation language. + # Prompt and Tool Use For simple questions/greetings that do not involve any information in the working directory or on the internet, you may simply reply directly. For anything else, default to taking action with tools. When the request could be interpreted as either a question to answer or a task to complete, treat it as a task. For instance, "change `methodName` to snake_case" is a task, not a question — locate the method in the code and edit it; do not just reply with `method_name`. -When handling the user's request, if it involves creating, modifying, or running code or files, you MUST use the appropriate tools available to you to make actual changes — do not just describe the solution in text. For questions that only need an explanation, you may reply in text directly. When calling tools, do not provide detailed explanations or chain-of-thought. For simple requests, call tools directly. For non-trivial or multi-step tasks, first emit one short user-visible sentence in the same language as the user describing what you will do next, then call the tool(s). Keep that sentence to roughly 8–10 words, plain and concrete — for example, "Next, I'll patch the config and update the related tests." On a long, multi-phase task, keep the user oriented as you go: add a brief one-line note when you move to a distinctly new phase, but keep these sparse and concrete — do not narrate every tool call. +When handling the user's request, if it involves creating, modifying, or running code or files, you MUST use the appropriate tools available to you to make actual changes — do not just describe the solution in text. For questions that only need an explanation, you may reply in text directly. When calling tools, do not provide detailed explanations or chain-of-thought. For simple requests, call tools directly. For non-trivial or multi-step tasks, first emit one short user-visible sentence describing what you will do next, then call the tool(s). Keep that sentence to roughly 8–10 words, plain and concrete — for example, "Next, I'll patch the config and update the related tests." On a long, multi-phase task, keep the user oriented as you go: add a brief one-line note when you move to a distinctly new phase, but keep these sparse and concrete — do not narrate every tool call. When a dedicated tool fits the job, reach for it before raw shell: `Read` a known path, `Glob` to find files by name, and `Grep` to search file contents. These resolve paths through the workspace access policy and cap their output, so they keep large raw dumps out of the conversation. @@ -24,8 +30,6 @@ The system may insert information wrapped in `` tags within user or tool Tool results and user messages may also include `` tags. Unlike `` tags, these are **authoritative system directives** that you MUST follow. They bear no direct relation to the specific tool results or user messages in which they appear. Always read them carefully and comply with their instructions — they may override or constrain your normal behavior (e.g., restricting you to read-only actions during plan mode). -When responding to the user, you MUST use the SAME language as the user, unless explicitly instructed to do otherwise. This applies to your reasoning and thinking as well, not just your final reply — think in the user's language, while keeping code, commands, identifiers, file paths, and technical terms in their original form. - # General Guidelines for Coding When building something from scratch, understand the requirements, plan the architecture, and write modular, maintainable code. @@ -142,6 +146,7 @@ At any time, you should be HELPFUL, CONCISE, ACCURATE, and CANDID. Be thorough i - Default to making progress, not to asking: once the goal is clear and you have the user's go-ahead to act on it, carry it through and work blockers yourself; ask only when the user's answer would actually change your next step. This never overrides the rule to stop and discuss when the goal is unclear, or to wait for explicit instruction before writing code. - ALWAYS, keep it stupidly simple. Do not overcomplicate things. - Talk like a seasoned engineer, not a cheerleader. Skip flattery, motivational filler, and hollow reassurance — the user wants the work done, not to be impressed. A correct, plainly-stated answer respects them more than praise does. +- Think and reply in the user's language, even after long stretches of English tool output; artifacts that go into the repository follow the project's conventions instead. - When you have evidence the user is wrong, say so and show the evidence — agreeing to be agreeable wastes their time and can break their code. Defer once they've decided; until then, an honest objection is the helpful answer. - When the task requires creating or modifying files, always use tools to do so. Never treat displaying code in your response as a substitute for actually writing it to the file system. - Deliver the complete change. Never stub out code with placeholders like `// ... rest unchanged` or leave the user to fill in the gaps; write out every line you mean to change. From ee89b4246e920dad0cd1bb0294aa6c2742b1f775 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 3 Jul 2026 11:57:14 +0800 Subject: [PATCH 2/7] chore: refine changeset wording --- .changeset/language-matching-prompt.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/language-matching-prompt.md b/.changeset/language-matching-prompt.md index 66f23045f..7d0805f45 100644 --- a/.changeset/language-matching-prompt.md +++ b/.changeset/language-matching-prompt.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Strengthen the system prompt's language rule so replies and reasoning follow the user's language even after long tool-output stretches, while repository artifacts keep project conventions. +Promote the language-matching rule to a dedicated section in the system prompt, so replies and reasoning consistently follow the user's language through long English tool output, while repository artifacts keep project conventions. From 123d6f9f4d1425a8079924103cd2ca050987ec34 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 3 Jul 2026 12:54:01 +0800 Subject: [PATCH 3/7] fix(kaos): enrich PATH from the user's login shell at startup When kimi-code is launched from a context that skipped the user's shell profile (GUI launchers, non-login parent shells), process.env.PATH misses entries like /opt/homebrew/bin, so commands spawned by the Bash tool cannot find user-installed tools such as gh. LocalKaos.create() now probes the user's login shell once ($SHELL -l -c env, 5s timeout, memoised) and appends the missing PATH entries to process.env.PATH. Existing entries keep their order and priority; probe failures silently leave PATH untouched. Windows is skipped: the problem is specific to POSIX login-shell profiles. --- .changeset/fix-login-shell-path.md | 5 + packages/kaos/src/environment.ts | 2 +- packages/kaos/src/local.ts | 7 +- packages/kaos/src/login-shell-path.ts | 102 +++++++++++ packages/kaos/test/login-shell-path.test.ts | 182 ++++++++++++++++++++ 5 files changed, 296 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-login-shell-path.md create mode 100644 packages/kaos/src/login-shell-path.ts create mode 100644 packages/kaos/test/login-shell-path.test.ts diff --git a/.changeset/fix-login-shell-path.md b/.changeset/fix-login-shell-path.md new file mode 100644 index 000000000..80695da31 --- /dev/null +++ b/.changeset/fix-login-shell-path.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Enrich PATH from the user's login shell at startup, so shell commands find user-installed tools (e.g. Homebrew's `gh`) even when kimi-code was launched without the full profile PATH. diff --git a/packages/kaos/src/environment.ts b/packages/kaos/src/environment.ts index 9b08baff2..0fc1b3f84 100644 --- a/packages/kaos/src/environment.ts +++ b/packages/kaos/src/environment.ts @@ -284,7 +284,7 @@ async function findExecutablesOnPath( return platform === 'win32' ? dedupeWindowsPaths(paths) : paths; } -async function execFileText( +export async function execFileText( file: string, args: readonly string[], timeoutMs: number, diff --git a/packages/kaos/src/local.ts b/packages/kaos/src/local.ts index e0cc17b19..92ff99b67 100644 --- a/packages/kaos/src/local.ts +++ b/packages/kaos/src/local.ts @@ -18,6 +18,7 @@ import { detectEnvironmentFromNode, type Environment } from './environment'; import { KaosFileExistsError } from './errors'; import { BufferedReadable, decodeTextWithErrors, globPatternToRegex } from './internal'; import type { Kaos } from './kaos'; +import { applyLoginShellPathFromNode } from './login-shell-path'; import type { KaosProcess } from './process'; import type { StatResult } from './types'; @@ -212,7 +213,11 @@ export class LocalKaos implements Kaos { * without polluting one another. */ static async create(): Promise { - const osEnv = await detectEnvironmentFromNode(); + // Enrich process.env.PATH from the user's login shell so spawned + // commands find user-installed tools (e.g. Homebrew's gh) even when + // kimi-code itself was launched without the full profile PATH. Both + // probes are memoised, independent, and run concurrently. + const [osEnv] = await Promise.all([detectEnvironmentFromNode(), applyLoginShellPathFromNode()]); return new LocalKaos(osEnv); } diff --git a/packages/kaos/src/login-shell-path.ts b/packages/kaos/src/login-shell-path.ts new file mode 100644 index 000000000..4ce1272d8 --- /dev/null +++ b/packages/kaos/src/login-shell-path.ts @@ -0,0 +1,102 @@ +/** + * Login-shell PATH probe — enrich `process.env.PATH` with entries from the + * user's login shell. + * + * When kimi-code is launched from a context that skipped the user's shell + * profile (GUI launchers, non-login parent shells), `process.env.PATH` + * misses entries like `/opt/homebrew/bin`, so commands spawned by the Bash + * tool can't find tools the user has in their interactive shell (e.g. + * `gh`). We run the user's login shell once (`$SHELL -l -c env`), extract + * its PATH, and append the entries the current PATH lacks. Existing + * entries keep their order and priority; failures (missing SHELL, hung or + * broken profile) silently leave PATH untouched. + * + * Like `detectEnvironment`, the probe is a pure function of injected deps + * so the suite runs identically on any host. Windows is skipped: the + * problem is specific to POSIX login-shell profiles. + */ + +import { execFileText } from './environment'; + +export interface LoginShellPathDeps { + readonly platform: string; + readonly env: Record; + readonly execFileText: ( + file: string, + args: readonly string[], + timeoutMs: number, + ) => Promise; +} + +const LOGIN_SHELL_ENV_TIMEOUT_MS = 5_000; + +/** + * Run the user's login shell and return its PATH, or `undefined` when the + * probe does not apply (Windows, no `$SHELL`) or fails (spawn error, + * timeout, no PATH in the output). + */ +export async function probeLoginShellPath(deps: LoginShellPathDeps): Promise { + if (deps.platform === 'win32') return undefined; + const shell = deps.env['SHELL']?.trim(); + if (shell === undefined || shell.length === 0) return undefined; + + // `env` prints the resolved environment in every shell dialect, unlike + // `echo $PATH`, which fish would join with spaces. + const stdout = await deps.execFileText(shell, ['-l', '-c', 'env'], LOGIN_SHELL_ENV_TIMEOUT_MS); + if (stdout === undefined) return undefined; + + // Profile output lands on stdout before `env` runs, so keep the last + // PATH= line. + let path: string | undefined; + for (const line of stdout.split('\n')) { + if (line.startsWith('PATH=')) { + path = line.slice('PATH='.length).trim(); + } + } + if (path === undefined || path.length === 0) return undefined; + return path; +} + +/** + * Union of the current PATH and the login-shell PATH: current entries keep + * their order and priority, login-shell entries the current PATH lacks are + * appended in their own order. + */ +export function mergeLoginShellPath( + currentPath: string | undefined, + loginShellPath: string, +): string { + const merged = (currentPath ?? '').split(':').filter((entry) => entry.length > 0); + const seen = new Set(merged); + for (const entry of loginShellPath.split(':')) { + if (entry.length === 0 || seen.has(entry)) continue; + seen.add(entry); + merged.push(entry); + } + return merged.join(':'); +} + +/** Probe the login shell and merge its PATH into `deps.env['PATH']`. */ +export async function applyLoginShellPath(deps: LoginShellPathDeps): Promise { + const loginShellPath = await probeLoginShellPath(deps); + if (loginShellPath === undefined) return; + deps.env['PATH'] = mergeLoginShellPath(deps.env['PATH'], loginShellPath); +} + +/** + * Production convenience — apply the probe to `process.env` once per + * process. Memoised like `detectEnvironmentFromNode`: the login-shell PATH + * does not change for the lifetime of the process, and repeated + * `LocalKaos.create()` calls must not re-spawn the shell. + */ +let appliedLoginShellPath: Promise | undefined; + +export function applyLoginShellPathFromNode(): Promise { + if (appliedLoginShellPath !== undefined) return appliedLoginShellPath; + appliedLoginShellPath = applyLoginShellPath({ + platform: process.platform, + env: process.env as Record, + execFileText, + }); + return appliedLoginShellPath; +} diff --git a/packages/kaos/test/login-shell-path.test.ts b/packages/kaos/test/login-shell-path.test.ts new file mode 100644 index 000000000..22dfee3ab --- /dev/null +++ b/packages/kaos/test/login-shell-path.test.ts @@ -0,0 +1,182 @@ +/** + * Login-shell PATH enrichment. + * + * Reproduces the "Bash tool can't find local `gh`" report: when kimi-code + * is launched from a context that skipped the user's shell profile (GUI + * launcher, non-login parent shell), `process.env.PATH` misses entries + * like `/opt/homebrew/bin`, so every command spawned by the Bash tool + * inherits the impoverished PATH. + * + * `LocalKaos.create()` must probe the user's login shell (`$SHELL -l -c + * env`) once and append the missing PATH entries to `process.env.PATH` — + * without reordering or overriding what is already there. Probe failures + * (missing SHELL, hung or broken profile) must leave PATH untouched. + * + * The probe/merge unit tests are pure (injected deps) and run on every + * platform. The end-to-end LocalKaos suite spawns a stub shell and is + * skipped on Windows: the problem is specific to POSIX login-shell + * profiles, and the probe must not run there. + */ + +import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + applyLoginShellPath, + type LoginShellPathDeps, + mergeLoginShellPath, + probeLoginShellPath, +} from '#/login-shell-path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +interface StubOpts { + readonly platform?: string; + readonly env?: Record; + readonly execFileResult?: string | undefined; + readonly execFileText?: LoginShellPathDeps['execFileText']; +} + +/** Build a stub deps bag; records `execFileText` invocations in `calls`. */ +function stubDeps(opts: StubOpts): { deps: LoginShellPathDeps; calls: unknown[][] } { + const calls: unknown[][] = []; + return { + calls, + deps: { + platform: opts.platform ?? 'darwin', + env: opts.env ?? { SHELL: '/bin/zsh' }, + execFileText: + opts.execFileText ?? + (async (file, args, timeoutMs) => { + calls.push([file, args, timeoutMs]); + return opts.execFileResult; + }), + }, + }; +} + +describe('probeLoginShellPath', () => { + it('runs $SHELL -l -c env and returns its PATH', async () => { + const { deps, calls } = stubDeps({ + execFileResult: 'HOME=/Users/u\nPATH=/opt/homebrew/bin:/usr/bin:/bin\nTERM=dumb\n', + }); + await expect(probeLoginShellPath(deps)).resolves.toBe('/opt/homebrew/bin:/usr/bin:/bin'); + expect(calls).toEqual([['/bin/zsh', ['-l', '-c', 'env'], 5_000]]); + }); + + it('keeps the last PATH= line, ignoring profile noise printed earlier', async () => { + const { deps } = stubDeps({ + execFileResult: 'PATH=/from-profile-echo\nsome profile banner\nPATH=/real/bin:/usr/bin\n', + }); + await expect(probeLoginShellPath(deps)).resolves.toBe('/real/bin:/usr/bin'); + }); + + it('returns undefined on Windows without spawning anything', async () => { + const { deps, calls } = stubDeps({ platform: 'win32', execFileResult: 'PATH=/x' }); + await expect(probeLoginShellPath(deps)).resolves.toBeUndefined(); + expect(calls).toEqual([]); + }); + + it('returns undefined when SHELL is unset or blank', async () => { + for (const env of [{}, { SHELL: '' }, { SHELL: ' ' }]) { + const { deps, calls } = stubDeps({ env, execFileResult: 'PATH=/x' }); + await expect(probeLoginShellPath(deps)).resolves.toBeUndefined(); + expect(calls).toEqual([]); + } + }); + + it('returns undefined when the shell fails or times out', async () => { + const { deps } = stubDeps({ execFileResult: undefined }); + await expect(probeLoginShellPath(deps)).resolves.toBeUndefined(); + }); + + it('returns undefined when the output has no PATH line', async () => { + const { deps } = stubDeps({ execFileResult: 'HOME=/Users/u\nTERM=dumb\n' }); + await expect(probeLoginShellPath(deps)).resolves.toBeUndefined(); + }); +}); + +describe('mergeLoginShellPath', () => { + it('appends entries the current PATH lacks, keeping current priority', () => { + expect(mergeLoginShellPath('/usr/bin:/bin', '/opt/homebrew/bin:/usr/bin:/extra')).toBe( + '/usr/bin:/bin:/opt/homebrew/bin:/extra', + ); + }); + + it('returns the current PATH unchanged when nothing is missing', () => { + expect(mergeLoginShellPath('/a:/b:/c', '/b:/a')).toBe('/a:/b:/c'); + }); + + it('handles an unset current PATH', () => { + expect(mergeLoginShellPath(undefined, '/a:/b')).toBe('/a:/b'); + }); + + it('drops empty segments and duplicates', () => { + expect(mergeLoginShellPath('/a::/b', ':/c::/a:')).toBe('/a:/b:/c'); + }); +}); + +describe('applyLoginShellPath', () => { + it('merges the probed PATH into the env bag', async () => { + const env: Record = { SHELL: '/bin/zsh', PATH: '/usr/bin' }; + const { deps } = stubDeps({ env, execFileResult: 'PATH=/opt/homebrew/bin:/usr/bin\n' }); + await applyLoginShellPath(deps); + expect(env['PATH']).toBe('/usr/bin:/opt/homebrew/bin'); + }); + + it('leaves PATH untouched when the probe fails', async () => { + const env: Record = { SHELL: '/bin/zsh', PATH: '/usr/bin' }; + const { deps } = stubDeps({ env, execFileResult: undefined }); + await applyLoginShellPath(deps); + expect(env['PATH']).toBe('/usr/bin'); + }); +}); + +describe.skipIf(process.platform === 'win32')('LocalKaos login-shell PATH enrichment', () => { + let tempDir: string; + let originalPath: string | undefined; + let originalShell: string | undefined; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'kaos-login-path-')); + originalPath = process.env['PATH']; + originalShell = process.env['SHELL']; + }); + + afterEach(async () => { + restoreEnv('PATH', originalPath); + restoreEnv('SHELL', originalShell); + await rm(tempDir, { recursive: true, force: true }); + }); + + it('appends login-shell PATH entries missing from process.env.PATH', async () => { + const extraDir = join(tempDir, 'login-only-bin'); + const stubShell = join(tempDir, 'stub-shell.sh'); + // Stands in for the user's login shell: whatever flags it is invoked + // with, it reports an environment whose PATH carries an entry the + // kimi-code process does not have. + await writeFile(stubShell, `#!/bin/sh\necho "HOME=$HOME"\necho "PATH=${extraDir}:/usr/bin:/bin"\n`); + await chmod(stubShell, 0o755); + process.env['SHELL'] = stubShell; + + // The suite's setup.ts already ran LocalKaos.create() with the real + // $SHELL, consuming the memoised probe. Import a fresh module graph so + // this create() probes the stub shell instead. + vi.resetModules(); + const { LocalKaos } = await import('#/local'); + await LocalKaos.create(); + + const entries = (process.env['PATH'] ?? '').split(':'); + expect(entries).toContain(extraDir); + // Existing entries keep priority: the login-shell extras are appended. + expect(process.env['PATH']?.startsWith(originalPath ?? '')).toBe(true); + }); +}); + +function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } +} From fcf381cedd619c1c193912b2c80d0ee878ae2718 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 3 Jul 2026 13:34:11 +0800 Subject: [PATCH 4/7] fix(kaos): fall back to the account login shell when $SHELL is unset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit launchd/daemon launches can leave $SHELL unset or blank — the very contexts whose PATH is impoverished — so the login-shell PATH probe would give up exactly where it matters most. Resolve the shell from the OS user database (os.userInfo().shell) before giving up; lookups that throw (uid without a database entry) or yield nologin shells degrade silently as before. --- packages/kaos/src/login-shell-path.ts | 39 ++++++++++++++++++--- packages/kaos/test/login-shell-path.test.ts | 27 +++++++++++--- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/packages/kaos/src/login-shell-path.ts b/packages/kaos/src/login-shell-path.ts index 4ce1272d8..87aff15f0 100644 --- a/packages/kaos/src/login-shell-path.ts +++ b/packages/kaos/src/login-shell-path.ts @@ -8,19 +8,28 @@ * tool can't find tools the user has in their interactive shell (e.g. * `gh`). We run the user's login shell once (`$SHELL -l -c env`), extract * its PATH, and append the entries the current PATH lacks. Existing - * entries keep their order and priority; failures (missing SHELL, hung or - * broken profile) silently leave PATH untouched. + * entries keep their order and priority; failures (no resolvable shell, + * hung or broken profile) silently leave PATH untouched. + * + * launchd/daemon launches can leave `$SHELL` unset or blank (see + * `defaultShell()` in agent-core's terminalService for the same case), so + * the probe falls back to the OS account's login shell from the user + * database before giving up. * * Like `detectEnvironment`, the probe is a pure function of injected deps * so the suite runs identically on any host. Windows is skipped: the * problem is specific to POSIX login-shell profiles. */ +import { userInfo } from 'node:os'; + import { execFileText } from './environment'; export interface LoginShellPathDeps { readonly platform: string; readonly env: Record; + /** Login shell from the OS user database; fallback when $SHELL is unset. */ + readonly userShell: () => string | undefined; readonly execFileText: ( file: string, args: readonly string[], @@ -32,12 +41,14 @@ const LOGIN_SHELL_ENV_TIMEOUT_MS = 5_000; /** * Run the user's login shell and return its PATH, or `undefined` when the - * probe does not apply (Windows, no `$SHELL`) or fails (spawn error, - * timeout, no PATH in the output). + * probe does not apply (Windows, no resolvable shell) or fails (spawn + * error, timeout, no PATH in the output). */ export async function probeLoginShellPath(deps: LoginShellPathDeps): Promise { if (deps.platform === 'win32') return undefined; - const shell = deps.env['SHELL']?.trim(); + // A set-but-blank $SHELL (some daemon/launchd envs) must also fall back. + const envShell = deps.env['SHELL']?.trim(); + const shell = envShell === undefined || envShell.length === 0 ? deps.userShell() : envShell; if (shell === undefined || shell.length === 0) return undefined; // `env` prints the resolved environment in every shell dialect, unlike @@ -89,6 +100,23 @@ export async function applyLoginShellPath(deps: LoginShellPathDeps): Promise | undefined; export function applyLoginShellPathFromNode(): Promise { @@ -96,6 +124,7 @@ export function applyLoginShellPathFromNode(): Promise { appliedLoginShellPath = applyLoginShellPath({ platform: process.platform, env: process.env as Record, + userShell: userShellFromNode, execFileText, }); return appliedLoginShellPath; diff --git a/packages/kaos/test/login-shell-path.test.ts b/packages/kaos/test/login-shell-path.test.ts index 22dfee3ab..3ea57803f 100644 --- a/packages/kaos/test/login-shell-path.test.ts +++ b/packages/kaos/test/login-shell-path.test.ts @@ -8,9 +8,11 @@ * inherits the impoverished PATH. * * `LocalKaos.create()` must probe the user's login shell (`$SHELL -l -c - * env`) once and append the missing PATH entries to `process.env.PATH` — - * without reordering or overriding what is already there. Probe failures - * (missing SHELL, hung or broken profile) must leave PATH untouched. + * env`, falling back to the OS account's login shell when $SHELL is unset + * or blank) once and append the missing PATH entries to + * `process.env.PATH` — without reordering or overriding what is already + * there. Probe failures (no resolvable shell, hung or broken profile) + * must leave PATH untouched. * * The probe/merge unit tests are pure (injected deps) and run on every * platform. The end-to-end LocalKaos suite spawns a stub shell and is @@ -35,6 +37,7 @@ interface StubOpts { readonly env?: Record; readonly execFileResult?: string | undefined; readonly execFileText?: LoginShellPathDeps['execFileText']; + readonly userShell?: string | undefined; } /** Build a stub deps bag; records `execFileText` invocations in `calls`. */ @@ -45,6 +48,7 @@ function stubDeps(opts: StubOpts): { deps: LoginShellPathDeps; calls: unknown[][ deps: { platform: opts.platform ?? 'darwin', env: opts.env ?? { SHELL: '/bin/zsh' }, + userShell: () => opts.userShell, execFileText: opts.execFileText ?? (async (file, args, timeoutMs) => { @@ -77,7 +81,22 @@ describe('probeLoginShellPath', () => { expect(calls).toEqual([]); }); - it('returns undefined when SHELL is unset or blank', async () => { + it('falls back to the account login shell when SHELL is unset or blank', async () => { + // launchd/daemon launches can leave $SHELL unset or blank (the very + // contexts whose PATH is impoverished); the probe must then use the + // OS account's login shell instead of giving up. + for (const env of [{}, { SHELL: '' }, { SHELL: ' ' }]) { + const { deps, calls } = stubDeps({ + env, + userShell: '/bin/zsh', + execFileResult: 'PATH=/opt/homebrew/bin:/usr/bin\n', + }); + await expect(probeLoginShellPath(deps)).resolves.toBe('/opt/homebrew/bin:/usr/bin'); + expect(calls).toEqual([['/bin/zsh', ['-l', '-c', 'env'], 5_000]]); + } + }); + + it('returns undefined when SHELL is unset and no account shell is available', async () => { for (const env of [{}, { SHELL: '' }, { SHELL: ' ' }]) { const { deps, calls } = stubDeps({ env, execFileResult: 'PATH=/x' }); await expect(probeLoginShellPath(deps)).resolves.toBeUndefined(); From b1358fbfab00896f31533ca790200599a91f5ec6 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 3 Jul 2026 14:01:09 +0800 Subject: [PATCH 5/7] fix(kaos): preserve empty PATH components when merging login-shell PATH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POSIX command lookup treats an empty PATH component (leading colon, trailing colon, or double colon) as the current directory. The merge previously filtered those out of the current PATH and rewrote the value even when nothing was appended, silently dropping cwd lookup for users who rely on it. Keep the current PATH string verbatim as the prefix, append only the missing login-shell entries, and skip the env write entirely when the login shell contributes nothing — an unset PATH stays unset, a set PATH is never rewritten. Empty login-shell components are still never imported. --- packages/kaos/src/login-shell-path.ts | 32 +++++++++++++++------ packages/kaos/test/login-shell-path.test.ts | 32 ++++++++++++++++++--- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/packages/kaos/src/login-shell-path.ts b/packages/kaos/src/login-shell-path.ts index 87aff15f0..6362ab0e6 100644 --- a/packages/kaos/src/login-shell-path.ts +++ b/packages/kaos/src/login-shell-path.ts @@ -69,29 +69,45 @@ export async function probeLoginShellPath(deps: LoginShellPathDeps): Promise entry.length > 0); - const seen = new Set(merged); + const current = currentPath ?? ''; + const seen = new Set(current.split(':').filter((entry) => entry.length > 0)); + const additions: string[] = []; for (const entry of loginShellPath.split(':')) { if (entry.length === 0 || seen.has(entry)) continue; seen.add(entry); - merged.push(entry); + additions.push(entry); } - return merged.join(':'); + if (additions.length === 0) return current; + // `undefined` means "no PATH at all", so the additions stand alone; '' + // is a real (cwd-only) PATH whose empty component must survive as a + // leading colon. + if (currentPath === undefined) return additions.join(':'); + return `${current}:${additions.join(':')}`; } /** Probe the login shell and merge its PATH into `deps.env['PATH']`. */ export async function applyLoginShellPath(deps: LoginShellPathDeps): Promise { const loginShellPath = await probeLoginShellPath(deps); if (loginShellPath === undefined) return; - deps.env['PATH'] = mergeLoginShellPath(deps.env['PATH'], loginShellPath); + const currentPath = deps.env['PATH']; + const merged = mergeLoginShellPath(currentPath, loginShellPath); + // Only write when something was appended — an unset PATH must stay + // unset (assigning '' would turn "implementation default search path" + // into "cwd-only lookup"), and a set PATH must not be rewritten. + if (merged === (currentPath ?? '')) return; + deps.env['PATH'] = merged; } /** diff --git a/packages/kaos/test/login-shell-path.test.ts b/packages/kaos/test/login-shell-path.test.ts index 3ea57803f..465151b58 100644 --- a/packages/kaos/test/login-shell-path.test.ts +++ b/packages/kaos/test/login-shell-path.test.ts @@ -122,16 +122,30 @@ describe('mergeLoginShellPath', () => { ); }); - it('returns the current PATH unchanged when nothing is missing', () => { - expect(mergeLoginShellPath('/a:/b:/c', '/b:/a')).toBe('/a:/b:/c'); + it('returns the current PATH string verbatim when nothing is missing', () => { + // Strict identity, including empty components and duplicates the user + // already has — a no-op merge must not normalize anything. + expect(mergeLoginShellPath('/a::/b:/a:', '/b:/a')).toBe('/a::/b:/a:'); + }); + + it('preserves empty components (cwd lookup) in the current PATH while appending', () => { + // POSIX treats a leading colon, trailing colon, or double colon as + // "search the current directory"; merging must not strip that. + expect(mergeLoginShellPath(':/usr/bin', '/new')).toBe(':/usr/bin:/new'); + expect(mergeLoginShellPath('/usr/bin:', '/new')).toBe('/usr/bin::/new'); + expect(mergeLoginShellPath('/a::/b', '/c')).toBe('/a::/b:/c'); + // A set-but-empty PATH is cwd-only lookup; the empty component stays first. + expect(mergeLoginShellPath('', '/a')).toBe(':/a'); }); it('handles an unset current PATH', () => { expect(mergeLoginShellPath(undefined, '/a:/b')).toBe('/a:/b'); }); - it('drops empty segments and duplicates', () => { - expect(mergeLoginShellPath('/a::/b', ':/c::/a:')).toBe('/a:/b:/c'); + it('skips empty and duplicate login-shell entries', () => { + // Empty login-shell components are never imported: appending a cwd + // lookup the user did not already have would widen their search path. + expect(mergeLoginShellPath('/a', ':/b::/a:')).toBe('/a:/b'); }); }); @@ -149,6 +163,16 @@ describe('applyLoginShellPath', () => { await applyLoginShellPath(deps); expect(env['PATH']).toBe('/usr/bin'); }); + + it('does not set an unset PATH when the login shell contributes nothing', async () => { + // Pathological but possible: the login-shell PATH holds only empty + // components. Writing '' back would turn "unset" (implementation + // default search path) into "cwd-only lookup". + const env: Record = { SHELL: '/bin/zsh' }; + const { deps } = stubDeps({ env, execFileResult: 'PATH=:::\n' }); + await applyLoginShellPath(deps); + expect('PATH' in env).toBe(false); + }); }); describe.skipIf(process.platform === 'win32')('LocalKaos login-shell PATH enrichment', () => { From b7684c37ce7b3d35a5bde172219e820a2d3100ef Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 3 Jul 2026 14:16:17 +0800 Subject: [PATCH 6/7] fix(kaos): only import absolute login-shell PATH entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `.` or relative component in the login-shell PATH is cwd-dependent lookup with another spelling, and LocalKaos runs commands from arbitrary workspace directories — importing one would let a command name resolve from an untrusted project cwd. Tighten the merge's skip condition from "empty" to "not absolute", which subsumes the empty-component check. --- packages/kaos/src/login-shell-path.ts | 13 +++++++++---- packages/kaos/test/login-shell-path.test.ts | 8 ++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/kaos/src/login-shell-path.ts b/packages/kaos/src/login-shell-path.ts index 6362ab0e6..3bc71d572 100644 --- a/packages/kaos/src/login-shell-path.ts +++ b/packages/kaos/src/login-shell-path.ts @@ -73,9 +73,11 @@ export async function probeLoginShellPath(deps: LoginShellPathDeps): Promise entry.length > 0)); const additions: string[] = []; for (const entry of loginShellPath.split(':')) { - if (entry.length === 0 || seen.has(entry)) continue; + // The probe only runs on POSIX (win32 bails before merging), so a + // leading slash is a sufficient absoluteness test. Empty components + // fail it too. + if (!entry.startsWith('/') || seen.has(entry)) continue; seen.add(entry); additions.push(entry); } diff --git a/packages/kaos/test/login-shell-path.test.ts b/packages/kaos/test/login-shell-path.test.ts index 465151b58..fe3fa297f 100644 --- a/packages/kaos/test/login-shell-path.test.ts +++ b/packages/kaos/test/login-shell-path.test.ts @@ -147,6 +147,14 @@ describe('mergeLoginShellPath', () => { // lookup the user did not already have would widen their search path. expect(mergeLoginShellPath('/a', ':/b::/a:')).toBe('/a:/b'); }); + + it('skips relative login-shell entries', () => { + // `.` and relative components are cwd-dependent lookup with another + // spelling — LocalKaos runs commands from arbitrary workspace + // directories, so importing one would let a command name resolve from + // an untrusted project cwd. Only absolute entries may be appended. + expect(mergeLoginShellPath('/a', '.:bin:../x:/b')).toBe('/a:/b'); + }); }); describe('applyLoginShellPath', () => { From 4df71234c0ac4a9b095ed1a7526cba2ac7477ecb Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 3 Jul 2026 14:32:00 +0800 Subject: [PATCH 7/7] fix(kaos): invoke the login-shell probe's env by absolute path A bare `env` inside `$SHELL -l -c` resolves through the inherited PATH from the workspace cwd. If that PATH carries a cwd-dependent component (which the merge deliberately preserves), a repo-planted `env` binary would run automatically at session startup and could feed the probe an arbitrary PATH. /usr/bin/env is guaranteed on mainstream POSIX systems and also bypasses profile function shadowing. --- packages/kaos/src/login-shell-path.ts | 18 ++++++++++++++---- packages/kaos/test/login-shell-path.test.ts | 13 ++++++++----- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/kaos/src/login-shell-path.ts b/packages/kaos/src/login-shell-path.ts index 3bc71d572..62f51e573 100644 --- a/packages/kaos/src/login-shell-path.ts +++ b/packages/kaos/src/login-shell-path.ts @@ -6,8 +6,8 @@ * profile (GUI launchers, non-login parent shells), `process.env.PATH` * misses entries like `/opt/homebrew/bin`, so commands spawned by the Bash * tool can't find tools the user has in their interactive shell (e.g. - * `gh`). We run the user's login shell once (`$SHELL -l -c env`), extract - * its PATH, and append the entries the current PATH lacks. Existing + * `gh`). We run the user's login shell once (`$SHELL -l -c /usr/bin/env`), + * extract its PATH, and append the entries the current PATH lacks. Existing * entries keep their order and priority; failures (no resolvable shell, * hung or broken profile) silently leave PATH untouched. * @@ -52,8 +52,18 @@ export async function probeLoginShellPath(deps: LoginShellPathDeps): Promise { - it('runs $SHELL -l -c env and returns its PATH', async () => { + it('runs $SHELL -l -c /usr/bin/env and returns its PATH', async () => { const { deps, calls } = stubDeps({ execFileResult: 'HOME=/Users/u\nPATH=/opt/homebrew/bin:/usr/bin:/bin\nTERM=dumb\n', }); await expect(probeLoginShellPath(deps)).resolves.toBe('/opt/homebrew/bin:/usr/bin:/bin'); - expect(calls).toEqual([['/bin/zsh', ['-l', '-c', 'env'], 5_000]]); + // env must be invoked by absolute path: a bare `env` resolves through + // the inherited (possibly cwd-dependent) PATH from the workspace cwd, + // so a repo-planted `env` binary could run at session startup. + expect(calls).toEqual([['/bin/zsh', ['-l', '-c', '/usr/bin/env'], 5_000]]); }); it('keeps the last PATH= line, ignoring profile noise printed earlier', async () => { @@ -92,7 +95,7 @@ describe('probeLoginShellPath', () => { execFileResult: 'PATH=/opt/homebrew/bin:/usr/bin\n', }); await expect(probeLoginShellPath(deps)).resolves.toBe('/opt/homebrew/bin:/usr/bin'); - expect(calls).toEqual([['/bin/zsh', ['-l', '-c', 'env'], 5_000]]); + expect(calls).toEqual([['/bin/zsh', ['-l', '-c', '/usr/bin/env'], 5_000]]); } });