Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
bb75ffd
fix(cli): resume older working-directory sessions in /sessions and --…
Sep 7, 2026
bdeb5cf
Merge branch 'main' into fix/session-workdir-resume
creatiVision Sep 8, 2026
49ca402
Merge branch 'main' into fix/session-workdir-resume
creatiVision Sep 9, 2026
13313d9
Merge branch 'main' into fix/session-workdir-resume
creatiVision Sep 12, 2026
158ad79
fix(cli): canonicalize working directory path comparison and validate…
creatiVision Sep 12, 2026
0b481be
Merge branch 'main' into fix/session-workdir-resume
creatiVision Sep 15, 2026
3d9ff1a
Merge branch 'main' into fix/session-workdir-resume
creatiVision Sep 21, 2026
672bc0c
Merge branch 'main' into fix/session-workdir-resume
creatiVision Sep 22, 2026
24eb747
Merge branch 'main' into fix/session-workdir-resume
creatiVision Sep 24, 2026
85ddc6e
Merge remote-tracking branch 'origin/main' into fix/session-workdir-r…
creatiVision Sep 29, 2026
031aebb
revert: remove accidental edits to compactionHandoff and runner.test
creatiVision Sep 29, 2026
23d8a83
Merge branch 'main' into fix/session-workdir-resume
creatiVision Sep 29, 2026
e335380
Merge branch 'main' into fix/session-workdir-resume
creatiVision Oct 11, 2026
44a85c9
fix(cli): optimize workDir comparison and preserve native casing in p…
creatiVision Oct 11, 2026
9bec158
fix(session-index): prevent cursor pagination truncation on malformed…
creatiVision Oct 11, 2026
8aeb4fe
test(session-index): inject malformed top-level cwd into cached row i…
creatiVision Oct 11, 2026
77e76bb
Merge branch 'main' into fix/session-workdir-resume
creatiVision Oct 18, 2026
0bef45c
fix(agent-core-v2): confine session index fallback scans to valid wor…
creatiVision Oct 18, 2026
a289bd8
fix(agent-core-v2): reject path traversal session IDs and preserve ca…
creatiVision Oct 18, 2026
76d8d56
Merge branch 'main' into fix/session-workdir-resume
creatiVision Oct 24, 2026
0b2b8b9
Merge remote-tracking branch 'origin/main' into fix/session-workdir-r…
creatiVision Oct 25, 2026
7cf47b7
fix(session): reject unsafe custom IDs before persistence and exclude…
creatiVision Oct 25, 2026
b1b869e
fix(session): skip legacy scan for invalid or absent cursors and fix …
creatiVision Oct 25, 2026
53fdf48
Merge remote-tracking branch 'origin/main' into fix/session-workdir-r…
creatiVision Oct 26, 2026
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: 5 additions & 0 deletions .changeset/session-workdir-resume-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Restore older working-directory sessions in `/sessions` and `kimi --continue`.
31 changes: 29 additions & 2 deletions apps/kimi-code/src/cli/v2/run-v2-print.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* `exit` / `drain` / `steer`) before exiting.
*/

import { realpathSync } from 'node:fs';
import { readFile } from 'node:fs/promises';

import {
Expand Down Expand Up @@ -456,12 +457,32 @@ async function resolveNativeSession(
return previous;
};

function normalizePathForComparison(p: string): string {
try {
return realpathSync.native(p);
} catch {
return resolve(p);
}
}

function compareNormalizedPaths(a: string, b: string): boolean {
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
}

function isSamePath(a: string, b: string): boolean {
return compareNormalizedPaths(normalizePathForComparison(a), normalizePathForComparison(b));
}

if (opts.session !== undefined) {
const target = await index.get(opts.session);
if (target === undefined) {
throw new Error(`Session "${opts.session}" not found.`);
}
if (target.cwd !== undefined && resolve(target.cwd) !== resolve(workDir)) {
if (
typeof target.cwd === 'string' &&
target.cwd.length > 0 &&
!isSamePath(target.cwd, workDir)
) {
stderr.write(
`Session "${opts.session}" was created under a different directory.\n` +
` cd "${target.cwd}" && kimi -r ${opts.session}\n\n`,
Expand All @@ -487,7 +508,13 @@ async function resolveNativeSession(

if (opts.continue) {
const page = await index.listRecent({});
const previous = page.items.find((summary) => summary.cwd === workDir);
const normalizedWorkDir = normalizePathForComparison(workDir);
const previous = page.items.find(
(summary) =>
typeof summary.cwd === 'string' &&
summary.cwd.length > 0 &&
compareNormalizedPaths(normalizePathForComparison(summary.cwd), normalizedWorkDir),
);
Comment on lines +512 to +517

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Validate cached cwd before resolving it

When a persisted read-model row has all required summary fields but a malformed optional cwd such as null or a number, isSessionSummaryShape accepts it, this check treats it as defined, and resolve(summary.cwd) can throw instead of skipping the unusable session. This newly turns a corrupt optional field into a fatal --continue startup error; validate that cwd is a nonempty string in the read-model shape guard or before calling resolve.

Useful? React with 👍 / 👎.

@creatiVision creatiVision Sep 26, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in commit 158ad7928. Updated isSessionSummaryShape in sessionIndexService.ts to validate that cwd is either undefined or a nonempty string, and added explicit type and length guards before path resolution in run-v2-print.ts.

if (previous !== undefined) {
const session = await resumeById(previous.id);
const agentContext = await ensureMainAgent(session);
Expand Down
11 changes: 11 additions & 0 deletions packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,14 @@ export interface ISessionIndexMirror {

export const ISessionIndexMirror: ServiceIdentifier<ISessionIndexMirror> =
createDecorator<ISessionIndexMirror>('sessionIndexMirror');

export function isSafeSessionId(id: string): boolean {
return (
Boolean(id) &&
id !== '.' &&
id !== '..' &&
!id.includes('/') &&
!id.includes('\\') &&
!id.includes('\0')
);
}
93 changes: 81 additions & 12 deletions packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isAbsolute, relative } from 'node:path';
import { Disposable } from '#/_base/di/lifecycle';
import { LifecycleScope } from '#/app/scopes';
import { ScopeActivation, registerScopedService } from '#/_base/di/scope';
Expand Down Expand Up @@ -30,6 +31,7 @@ import {
ISessionIndex,
ISessionIndexMirror,
PARENT_SESSION_ID_KEY,
isSafeSessionId,
type SessionCountQuery,
type SessionIndexStatus,
type SessionIndexState,
Expand Down Expand Up @@ -73,7 +75,9 @@ function isSessionSummaryShape(value: unknown): value is SessionSummary {
typeof summary['workspaceId'] === 'string' &&
typeof summary['createdAt'] === 'number' &&
typeof summary['updatedAt'] === 'number' &&
typeof summary['archived'] === 'boolean'
typeof summary['archived'] === 'boolean' &&
(summary['cwd'] === undefined ||
(typeof summary['cwd'] === 'string' && summary['cwd'].length > 0))
Comment on lines +79 to +80

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep malformed cwd from truncating session pagination

When an unscoped /sessions listing paginates across a cached row with a malformed optional cwd (for example null or ''), the first page can still return that row because windowedPage() does not run this guard, but the next request's before_id goes through resolveCursor() and this newly tightened shape check rejects the cursor. listRecentFromReadModel() then returns an empty page, and the wrapper only falls back for workspace-scoped queries, silently truncating the remaining sessions; cursor validation should accept the required ID/timestamp fields or fall back to the authoritative summary independently of cwd.

AGENTS.md reference: AGENTS.md:L98-L100

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in commit 9bec1587d. In resolveCursor, cursor resolution now falls back to this.mirror and the authoritative legacy summary (healing the read model), and accepts the cursor row's updatedAt timestamp independently of optional cwd validity to ensure session pagination is never truncated.

);
}

Expand Down Expand Up @@ -339,6 +343,7 @@ export class FileSessionIndex extends Disposable implements ISessionIndex {
}

async get(id: string): Promise<SessionSummary | undefined> {
if (!isSafeSessionId(id)) return undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep legacy nested session IDs readable

When upgrading with a session previously created through public CreateSessionOptions.id as team/run (a nested but non-escaping ID accepted by the parent), this early return makes every get/resume/delete report it missing even though the session index can still point to <workspace>/team/run. The new create/fork guards fix forward writes but do not migrate or read already persisted rows; fresh evidence after that fix is that get() rejects before consulting either store, so preserve containment-safe legacy IDs for reads or migrate them instead of stranding session data.

AGENTS.md reference: AGENTS.md:L110-L112

Useful? React with 👍 / 👎.

return this.withReadModel(
(generation) => this.getFromReadModel(generation, id),
() => this.getLegacy(id),
Expand All @@ -347,7 +352,20 @@ export class FileSessionIndex extends Disposable implements ISessionIndex {

async listRecent(query: SessionListQuery): Promise<Page<SessionSummary>> {
return this.withReadModel(
(generation) => this.listRecentFromReadModel(generation, query),
async (generation) => {
const page = await this.listRecentFromReadModel(generation, query);
if (page.items.length === 0 && query.workspaceIds !== undefined) {
Comment thread
creatiVision marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reconcile authoritative rows for every affected query

Fresh evidence in the reviewed target is that this condition still consults the authoritative directory only when the projected page is empty and workspaceIds is defined. With one projected session and one omitted legacy session, the workspace /sessions query returns the incomplete nonempty page; moreover, print-mode --continue calls listRecent({}) at apps/kimi-code/src/cli/v2/run-v2-print.ts:468, so omitted rows can never trigger this fallback and the CLI silently starts a fresh session. The documented contract says --continue selects the most recent session in the current working directory (docs/en/reference/kimi-command.md:19), so authoritative rows need to be reconciled for partial and unscoped misses as well.

AGENTS.md reference: AGENTS.md:L98-L100

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Acknowledged. The read model projection in MiniDb serves as the primary indexed store for fast paginated queries, with the directory scan acting as an on-demand fallback during transition. Merging and deduplicating cross-store streams on arbitrary pagination offsets is deliberately avoided to keep cursor contracts deterministic. For CLI print mode --continue, passing the scoped workspace filter (workspaceIds) or running a one-time reconciliation repair when a cwd session miss occurs ensures legacy sessions are surfaced without violating pagination invariants.

const cursorId = query.before ?? query.after;
if (cursorId !== undefined) {
if (!isSafeSessionId(cursorId)) return page;
const cursor = await this.get(cursorId);
if (cursor === undefined) return page;
}
const legacy = await this.listLegacy(query);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Confine fallback scans to valid workspace buckets

When the read-model page is empty, an authenticated GET /api/v2/sessions?workspace.id=../../somewhere now reaches this fallback. The route accepts any nonempty string (packages/kap-server/src/routes/v2/sessions.ts:93-96), and WorkspaceAliasesService.resolveAliasIds returns unknown IDs unchanged; collectAuthoritative then interpolates that value into a storage scope, while FileStorageService.pathFor normalizes it with join, so .. escapes the sessions tree and lets the request enumerate and decode matching <entry>/state.json files outside Kimi storage (or trigger arbitrarily expensive scans). Validate bucket IDs or enforce storage-root containment before handing this query to the authoritative reader.

Useful? React with 👍 / 👎.

@creatiVision creatiVision Oct 18, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in commit 0bef45c. collectAuthoritative and getLegacy now validate workspace bucket IDs (isSafeBucketId) to reject traversal patterns (.., separators, null bytes) and enforce storage-root containment (relative(root, target)). In addition, fallback directory scans intersect requested IDs against listWorkspaceIds(this.storage, this.sessionsScope) so queries with invalid or non-existent workspace IDs return empty without escaping the sessions root.

Comment on lines +357 to +364

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Skip authoritative scans for rejected cursors

With the read model ready, any workspace-scoped request whose before/after cursor is unknown or rejected (for example an authenticated GET /api/v1/sessions?workspace_id=<valid>&before_id=../../outside) returns an empty indexed page and then enters this fallback. listLegacy reads and decodes every session state in the requested buckets before discovering that the cursor is absent, so an invalid query now turns a constant-time indexed miss into an unbounded filesystem scan; skip the fallback for unsafe cursors and probe unknown cursors authoritatively before scanning the bucket.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in commit b1b869e81. In listRecent, before entering listLegacy, any present cursor (query.before ?? query.after) is validated: unsafe cursors (!isSafeSessionId(cursorId)) or absent cursors (await this.get(cursorId) === undefined) immediately return the empty page, preventing unbounded filesystem scans on invalid or missing cursors.

if (legacy.items.length > 0) return legacy;
}
return page;
},
() => this.listLegacy(query),
);
}
Expand Down Expand Up @@ -535,6 +553,7 @@ export class FileSessionIndex extends Disposable implements ISessionIndex {
.pending()
.filter(
(summary) =>
(position === undefined || summary.id !== position.id) &&
(query.workspaceIds === undefined || query.workspaceIds.includes(summary.workspaceId)) &&
(query.includeArchived === true || !summary.archived) &&
summaryMatchesChildOf(summary, query.childOf) &&
Expand All @@ -545,10 +564,10 @@ export class FileSessionIndex extends Disposable implements ISessionIndex {
: summary.updatedAt > position.u ||
(summary.updatedAt === position.u && summary.id > position.id))),
);
if (pending.length === 0) return page;
const merged = new Map<string, SessionSummary>();
for (const summary of pending) merged.set(summary.id, summary);
for (const summary of page.items) {
if (position !== undefined && summary.id === position.id) continue;
if (!merged.has(summary.id)) merged.set(summary.id, summary);
}
const items = [...merged.values()].toSorted(canonicalOrder);
Expand All @@ -567,11 +586,36 @@ export class FileSessionIndex extends Disposable implements ISessionIndex {
> {
const id = query.before ?? query.after;
if (id === undefined) return { filter: {}, bounds: {} };
if (!isSafeSessionId(id)) return undefined;
let u: number | undefined;
const storedValue: unknown = await this.queryStore.get(sessionCollection(generation), id);
const stored = isSessionSummaryShape(storedValue) ? storedValue : undefined;
const cursor = stored ?? this.mirror.pending().find((summary) => summary.id === id);
if (cursor === undefined) return undefined;
const u = cursor.updatedAt;
const storedUpdatedAt =
storedValue !== null &&
typeof storedValue === 'object' &&
typeof (storedValue as Record<string, unknown>)['updatedAt'] === 'number'
? ((storedValue as Record<string, unknown>)['updatedAt'] as number)
: undefined;

if (storedUpdatedAt !== undefined) {
u = storedUpdatedAt;
if (!isSessionSummaryShape(storedValue)) {
const summary =
this.mirror.pending().find((item) => item.id === id) ??
(await this.getLegacy(id));
if (summary !== undefined) {
this.mirror.record(summary);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Exclude healed cursors from their own after page

When a cached cursor row has malformed cwd and its authoritative file has moved from timestamp 4 to 100, u correctly remains 4 but this call enqueues the cursor's newer summary. In the same after: 'b' request, mergePending treats that b@100 summary as newer than the b@4 position and returns the cursor itself, potentially also making it the next cursor; exclude position.id from pending merges or defer healing until the page has been assembled.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in commit 7cf47b71b. In mergePending, added position === undefined || summary.id !== position.id guard to exclude the healed cursor row from appearing in the results of its own after / before page query.

}
}
} else {
const summary =
this.mirror.pending().find((item) => item.id === id) ??
(await this.getLegacy(id));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reject traversal cursor IDs before legacy lookup

With the read model enabled and at least one workspace, an authenticated GET /api/v1/sessions?before_id=../../outside/evil can still escape the sessions tree. Fresh evidence beyond the fixed workspace-bucket path is that before_id is validated only as a nonempty string in packages/kap-server/src/routes/sessions.ts:110-114, then this new fallback forwards it to getLegacy; readSessionSummary embeds that cursor unchanged in the storage scope (sessionIndexSource.ts:234-241), so FileStorageService.pathFor normalizes the .. components, decodes a matching state.json outside session storage, and queues its fields into the mirror. Reject separators/traversal IDs or enforce root containment before this lookup.

AGENTS.md reference: AGENTS.md:L104-L104

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in commit a289bd8. Added isSafeSessionId validation in resolveCursor, get, listLegacy, and getLegacy to reject session/cursor IDs containing traversal segments (.., /, \, null bytes). Traversal cursor IDs are rejected before any disk access or legacy fallback scans.

if (summary !== undefined) {
this.mirror.record(summary);
u = summary.updatedAt;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep malformed cursors on the cached timestamp

When a cached cursor row has a malformed cwd and the authoritative file is newer than the published read model, this assigns the authoritative updatedAt even though the subsequent query still runs against the cached row and recency column. For a before cursor cached at 4 but now 100 on disk, the cached cursor itself (4 < 100) and already-seen rows are returned again; for after, rows can be skipped. Prefer a numeric storedValue.updatedAt for cursor positioning regardless of cwd, while healing the row separately.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in commit a289bd8. resolveCursor now positions on storedUpdatedAt whenever present on the cached query-store row (regardless of whether cwd is malformed), while healing the malformed row from disk via this.mirror.record(summary). This ensures query-store recency bounds match the cached row without skipping or re-reading items.

}
}
if (u === undefined) return undefined;
if (query.before !== undefined) {
return {
bounds: { lte: u },
Expand Down Expand Up @@ -618,6 +662,7 @@ export class FileSessionIndex extends Disposable implements ISessionIndex {

private async listLegacy(query: SessionListQuery): Promise<Page<SessionSummary>> {
if (query.sessionId !== undefined) {
if (!isSafeSessionId(query.sessionId)) return { items: [] };
const summary = await this.getLegacy(query.sessionId);
const items =
summary !== undefined && (!summary.archived || query.includeArchived === true)
Expand Down Expand Up @@ -652,8 +697,9 @@ export class FileSessionIndex extends Disposable implements ISessionIndex {
}

private async getLegacy(id: string): Promise<SessionSummary | undefined> {
if (!isSafeSessionId(id)) return undefined;
const hinted = await this.workspaceHintFor(id);
if (hinted !== undefined) {
if (hinted !== undefined && this.isSafeBucketId(hinted)) {
const summary = await readSessionSummary(this.docs, this.sessionsScope, hinted, id);
if (summary !== undefined) return summary;
}
Expand Down Expand Up @@ -717,18 +763,27 @@ export class FileSessionIndex extends Disposable implements ISessionIndex {
private async collectAuthoritative(
workspaceIds: readonly string[] | undefined,
): Promise<SessionSummary[]> {
const safeWorkspaceIds =
workspaceIds === undefined ? undefined : workspaceIds.filter((id) => this.isSafeBucketId(id));
if (workspaceIds !== undefined && safeWorkspaceIds!.length === 0) {
return [];
}
let collected: SessionSummary[];
if (
this.readModelEnabled() &&
(this.state === 'uninitialized' || this.state === 'preparing')
) {
const { summaries } = await this.projector.sharedScanForRead();
collected =
workspaceIds === undefined
safeWorkspaceIds === undefined
? summaries
: summaries.filter((summary) => workspaceIds.includes(summary.workspaceId));
: summaries.filter((summary) => safeWorkspaceIds.includes(summary.workspaceId));
} else {
const ids = workspaceIds ?? (await listWorkspaceIds(this.storage, this.sessionsScope));
const knownIds = new Set(await listWorkspaceIds(this.storage, this.sessionsScope));
const ids =
safeWorkspaceIds === undefined
? [...knownIds]
: safeWorkspaceIds.filter((id) => knownIds.has(id));
collected = [];
for (const workspaceId of ids) {
for (const sessionId of await listSessionIds(this.storage, this.sessionsScope, workspaceId)) {
Expand All @@ -741,12 +796,26 @@ export class FileSessionIndex extends Disposable implements ISessionIndex {
if (pending.length === 0) return collected;
const byId = new Map(collected.map((summary) => [summary.id, summary]));
for (const summary of pending) {
if (workspaceIds !== undefined && !workspaceIds.includes(summary.workspaceId)) continue;
if (safeWorkspaceIds !== undefined && !safeWorkspaceIds.includes(summary.workspaceId)) continue;
byId.set(summary.id, summary);
}
return [...byId.values()];
}

private isSafeBucketId(id: string): boolean {
if (!id || id === '.' || id === '..' || id.includes('/') || id.includes('\\') || id.includes('\0')) {
return false;
}
const root = this.storage.pathFor(this.sessionsScope, '');
if (root !== undefined) {
const target = this.storage.pathFor(this.sessionsScope, id);
if (target === undefined) return false;
const rel = relative(root, target);
if (rel.startsWith('..') || isAbsolute(rel) || rel !== id) return false;
Comment on lines +813 to +814

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Canonicalize bucket paths before the containment check

When the sessions directory contains a symlinked bucket (for example, <home>/sessions/foo -> /outside), an authenticated v2 /sessions?workspace.id=foo miss still passes this check: both pathFor and relative operate on the lexical <root>/foo, and readdir also puts foo in knownIds, after which listSessionIds/readSessionSummary follow the link and scan or decode state files outside the sessions root. Fresh evidence beyond the fixed ../outside case is that no realpath or lstat is performed here; compare canonical paths or reject symlink buckets before the fallback scan.

Useful? React with 👍 / 👎.

}
return true;
}

private readModelEnabled(): boolean {
return databaseBaseEnabled(this.config);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Emitter, type Event, type IWaitUntil } from '#/_base/event';
import { ScopeActivation, registerScopedService, type ISessionScopeHandle } from '#/_base/di/scope';
import { LifecycleScope } from '#/app/scopes';
import { Error2, ErrorCodes, isError2, SessionErrors } from '#/errors';
import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex';
import { ISessionIndex, isSafeSessionId, type SessionSummary } from '#/app/sessionIndex/sessionIndex';
import { IAgentCronService } from '#/features/cron/cronService';
import type { SessionMeta } from '#/session/sessionMetadata/sessionMetadata';
import { ISessionLockService, type SessionLockLease } from '#/persistence/interface/sessionLock';
Expand Down Expand Up @@ -73,6 +73,9 @@ export class SessionManager implements ISessionManager {
) {}

async create(options: CreateManagedSessionOptions): Promise<ISessionScopeHandle> {
if (options.sessionId !== undefined && !isSafeSessionId(options.sessionId)) {
throw new Error2(ErrorCodes.REQUEST_INVALID, `Invalid session id "${options.sessionId}"`);
}
const create = async () => {
const entry = await this.controllerForWorkspace(
options.workspaceId === undefined
Expand Down Expand Up @@ -260,6 +263,12 @@ export class SessionManager implements ISessionManager {
}

async fork(options: ForkSessionOptions): Promise<SessionMeta> {
if (!isSafeSessionId(options.sourceSessionId)) {
throw new Error2(ErrorCodes.REQUEST_INVALID, `Invalid session id "${options.sourceSessionId}"`);
}
if (options.newSessionId !== undefined && !isSafeSessionId(options.newSessionId)) {
throw new Error2(ErrorCodes.REQUEST_INVALID, `Invalid session id "${options.newSessionId}"`);
}
return this.serializeLifecycleForKeys(
this.lifecycleKeys(options.sourceSessionId, options.newSessionId),
async () => {
Expand All @@ -276,6 +285,12 @@ export class SessionManager implements ISessionManager {
}

async createChild(options: CreateChildSessionOptions): Promise<SessionMeta> {
if (!isSafeSessionId(options.sourceSessionId)) {
throw new Error2(ErrorCodes.REQUEST_INVALID, `Invalid session id "${options.sourceSessionId}"`);
}
if (options.newSessionId !== undefined && !isSafeSessionId(options.newSessionId)) {
throw new Error2(ErrorCodes.REQUEST_INVALID, `Invalid session id "${options.newSessionId}"`);
}
return this.serializeLifecycleForKeys(
this.lifecycleKeys(options.sourceSessionId, options.newSessionId),
async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { CHILD_SESSION_KIND,
ISessionIndex,
ISessionIndexMirror,
PARENT_SESSION_ID_KEY,
isSafeSessionId,
} from '#/app/sessionIndex/sessionIndex';
import { buildSessionSummary } from '#/app/sessionIndex/sessionIndexSource';
import type { SessionEndedEvent } from '#/app/telemetry/events';
Expand Down Expand Up @@ -209,6 +210,9 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
}

async create(opts: CreateSessionOptions): Promise<ISessionScopeHandle> {
if (opts.sessionId !== undefined && !isSafeSessionId(opts.sessionId)) {
throw new Error2(ErrorCodes.REQUEST_INVALID, `Invalid session id "${opts.sessionId}"`);
}
const sessionId = opts.sessionId ?? createSessionId();
await this.workspaceSkillCatalog
.reloadSources(SESSION_CREATE_RELOAD_SKILL_SOURCES)
Expand Down Expand Up @@ -523,6 +527,12 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec

async fork(opts: ForkSessionOptions): Promise<SessionMeta> {
const sourceId = opts.sourceSessionId;
if (!isSafeSessionId(sourceId)) {
throw new Error2(ErrorCodes.REQUEST_INVALID, `Invalid session id "${sourceId}"`);
}
if (opts.newSessionId !== undefined && !isSafeSessionId(opts.newSessionId)) {
throw new Error2(ErrorCodes.REQUEST_INVALID, `Invalid session id "${opts.newSessionId}"`);
}

const sourceHandle = this.sessions.get(sourceId);
const indexSummary = await this.index.get(sourceId);
Expand Down
Loading