From bf51fb7a105b2f34a59ed4e83d2588e790cfb086 Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 26 Jun 2026 18:38:24 +0800 Subject: [PATCH] fix(server): skip Unix-only permission check on Windows for server token (#1135) --- .changeset/fix-windows-token-permission.md | 6 ++++++ .../server/src/services/auth/privateFiles.ts | 6 +++++- packages/server/src/services/auth/tokenStore.ts | 5 ++++- packages/server/test/services-auth.test.ts | 17 +++++++++++------ 4 files changed, 26 insertions(+), 8 deletions(-) create mode 100644 .changeset/fix-windows-token-permission.md diff --git a/.changeset/fix-windows-token-permission.md b/.changeset/fix-windows-token-permission.md new file mode 100644 index 000000000..cda5f007f --- /dev/null +++ b/.changeset/fix-windows-token-permission.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/server": patch +"@moonshot-ai/kimi-code": patch +--- + +Fix the local server failing to start on Windows after the first run because the persistent token file's synthesized mode was rejected as too permissive. diff --git a/packages/server/src/services/auth/privateFiles.ts b/packages/server/src/services/auth/privateFiles.ts index 2197ef72f..1b9c06c6d 100644 --- a/packages/server/src/services/auth/privateFiles.ts +++ b/packages/server/src/services/auth/privateFiles.ts @@ -54,7 +54,11 @@ export async function writePrivateFile( export async function readPrivateFile(filePath: string): Promise { const info = await stat(filePath); - if ((info.mode & 0o077) !== 0) { + // Windows does not have Unix-style permission bits; libuv synthesises the + // mode from the read-only attribute, so a private writable file is reported + // as 0o666 and a read-only one as 0o444. The ACL-based security model is + // different, so this check only makes sense on POSIX systems. + if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) { throw new PrivateFileTooPermissiveError(filePath, info.mode & 0o777); } return readFile(filePath); diff --git a/packages/server/src/services/auth/tokenStore.ts b/packages/server/src/services/auth/tokenStore.ts index e12d860b0..459c463ef 100644 --- a/packages/server/src/services/auth/tokenStore.ts +++ b/packages/server/src/services/auth/tokenStore.ts @@ -48,7 +48,10 @@ export async function createTokenStore(homeDir: string): Promise { } // Changed: re-read, but refuse a too-permissive file and never let an // empty/partial read clobber the last good token. - if ((st.mode & 0o077) !== 0) { + // Skip the check on Windows: fs.stat mode is synthesised from the + // read-only attribute and does not reflect real ACLs, so it would always + // appear too permissive and prevent legitimate token reloads. + if (process.platform !== 'win32' && (st.mode & 0o077) !== 0) { return cache.token; } try { diff --git a/packages/server/test/services-auth.test.ts b/packages/server/test/services-auth.test.ts index dea045920..0aac27be0 100644 --- a/packages/server/test/services-auth.test.ts +++ b/packages/server/test/services-auth.test.ts @@ -44,14 +44,14 @@ describe('privateFiles', () => { expect(statSync(join(tmpDir, 'nested', 'dir')).mode & 0o777).toBe(0o700); }); - it.skipIf(process.platform === 'win32')('round-trips string content through readPrivateFile', async () => { + it('round-trips string content through readPrivateFile', async () => { const p = join(tmpDir, 'secret'); await writePrivateFile(p, 's3cr3t-value'); const buf = await readPrivateFile(p); expect(buf.toString('utf8')).toBe('s3cr3t-value'); }); - it.skipIf(process.platform === 'win32')('round-trips Buffer content through readPrivateFile', async () => { + it('round-trips Buffer content through readPrivateFile', async () => { const p = join(tmpDir, 'bin'); const data = Buffer.from([0, 1, 2, 254, 255]); await writePrivateFile(p, data); @@ -59,7 +59,7 @@ describe('privateFiles', () => { expect(buf.equals(data)).toBe(true); }); - it('readPrivateFile throws on a 0644 file', async () => { + it.skipIf(process.platform === 'win32')('readPrivateFile throws on a 0644 file', async () => { const p = join(tmpDir, 'leaky'); writeFileSync(p, 'x', { mode: 0o644 }); chmodSync(p, 0o644); @@ -84,7 +84,7 @@ describe('tokenStore', () => { await b.dispose(); }); - it.skipIf(process.platform === 'win32')('reuses the same persistent token across stores in one home dir', async () => { + it('reuses the same persistent token across stores in one home dir', async () => { const home = join(tmpDir, 'home'); const a = await createTokenStore(home); const token = a.getToken(); @@ -123,7 +123,7 @@ describe('tokenStore', () => { expect(existsSync(store.tokenPath)).toBe(true); }); - it.skipIf(process.platform === 'win32')('re-reads the token after the file is rewritten (live rotation)', async () => { + it('re-reads the token after the file is rewritten (live rotation)', async () => { const home = join(tmpDir, 'home'); const store = await createTokenStore(home); const original = store.getToken(); @@ -142,11 +142,16 @@ describe('tokenStore', () => { }); describe('persistentToken', () => { - it.skipIf(process.platform === 'win32')('loadOrCreateServerToken generates once and reuses thereafter', async () => { + it('loadOrCreateServerToken generates once and reuses thereafter', async () => { const home = join(tmpDir, 'home'); const a = await loadOrCreateServerToken(home); const b = await loadOrCreateServerToken(home); expect(a).toBe(b); + }); + + it.skipIf(process.platform === 'win32')('writes server.token with mode 0600', async () => { + const home = join(tmpDir, 'home'); + await loadOrCreateServerToken(home); expect(statSync(join(home, 'server.token')).mode & 0o777).toBe(0o600); });