kimi-code/packages/node-sdk/src/auth.ts
7Sageer e736349a7c
feat(feedback): support attaching logs and codebase (#1120)
* feat(feedback): support attaching logs and codebase

Add an attachment picker to /feedback (none / logs / logs + codebase).
Codebase uploads scan the working directory with sensitive files excluded
and are sent through a new multipart upload API on the oauth/node-sdk layers.

* fix(feedback): fall back to logs when codebase scan fails

* tiny fix

* fix(feedback): make diagnostic uploads partial-safe

* refactor(feedback): reuse harness session export and normalize upload url types

* docs(slash-commands): note optional feedback attachments

* refactor(feedback): reorganize feedback upload modules

Move the attachment orchestration out of tui/commands/info.ts into a
dedicated feedback/feedback-attachments.ts, and split the former
codebase-upload/attach.ts into a generic multipart uploader
(feedback/upload.ts) and an archive lifecycle module
(feedback/archive.ts). Both session and codebase archives now flow
through a single upload lifecycle, which also removes the temp-dir
leak that occurred when codebase packaging failed.

Rename FeedbackCodebaseArchive to FeedbackArchive and the
codebase-upload/ directory to codebase/ so module boundaries match
their actual responsibilities (scan + package only).
2026-06-26 16:15:08 +08:00

319 lines
9.6 KiB
TypeScript

import {
loadRuntimeConfigSafe,
readConfigFile,
readConfigFileForUpdate,
writeConfigFile,
type KimiConfig,
type OAuthRef,
} from '@moonshot-ai/agent-core';
import {
applyManagedKimiCodeConfig,
applyManagedKimiCodeLogoutConfig,
KIMI_CODE_PROVIDER_NAME,
KimiOAuthToolkit,
resolveKimiCodeLoginAuth,
resolveKimiCodeRuntimeAuth,
type AuthManagedUsageResult,
type AuthStatus,
type BearerTokenProvider,
type FetchCompleteFeedbackUploadResult,
type FetchFeedbackUploadError,
type FetchSubmitFeedbackResult,
type KimiHostIdentity,
type KimiOAuthLoginOptions,
type ManagedKimiConfigShape,
type OAuthRefreshOutcome,
} from '@moonshot-ai/kimi-code-oauth';
import { mapOAuthTokenError } from '#/oauth-error';
export interface KimiAuthSubmitFeedbackInput {
readonly content: string;
readonly sessionId: string;
readonly version: string;
readonly os: string;
readonly model: string | null;
readonly contact?: string;
readonly info?: Record<string, unknown>;
}
export interface KimiAuthCreateFeedbackUploadUrlInput {
readonly feedbackId: number;
readonly filename: string;
readonly size: number;
readonly sha256: string;
}
export interface KimiAuthCompleteFeedbackUploadPart {
readonly partNumber: number;
readonly etag: string;
}
export interface KimiAuthCompleteFeedbackUploadInput {
readonly uploadId: number;
readonly parts: readonly KimiAuthCompleteFeedbackUploadPart[];
}
export interface KimiAuthFeedbackUploadPart {
readonly partNumber: number;
readonly url: string;
readonly method: string;
readonly size: number;
}
export interface KimiAuthCreateFeedbackUploadUrlOk {
readonly kind: 'ok';
readonly uploadId: number;
readonly parts: readonly KimiAuthFeedbackUploadPart[];
}
export type KimiAuthCreateFeedbackUploadUrlResult =
| KimiAuthCreateFeedbackUploadUrlOk
| FetchFeedbackUploadError;
export type KimiAuthLoginOptions = Omit<KimiOAuthLoginOptions, 'provisionConfig'>;
export interface KimiAuthLoginResult {
readonly providerName: string;
readonly ok: true;
readonly defaultModel: string;
readonly defaultThinking: boolean;
readonly configPath?: string | undefined;
}
export interface KimiAuthLogoutResult {
readonly providerName: string;
readonly ok: true;
}
export interface KimiAuthFacadeOptions {
readonly homeDir: string;
readonly configPath: string;
readonly identity?: KimiHostIdentity | undefined;
readonly onConfigUpdated?: ((config: KimiConfig) => void) | undefined;
readonly onRefresh?: ((outcome: OAuthRefreshOutcome) => void) | undefined;
}
type SDKManagedConfig = KimiConfig & ManagedKimiConfigShape;
export class KimiAuthFacade {
private readonly toolkit: KimiOAuthToolkit<SDKManagedConfig>;
constructor(private readonly options: KimiAuthFacadeOptions) {
this.toolkit = new KimiOAuthToolkit<SDKManagedConfig>({
homeDir: options.homeDir,
identity: options.identity,
onRefresh: options.onRefresh,
configAdapter: {
configPath: options.configPath,
// Write-path base read: strict (a salvaged base would drop the user's
// broken-but-fixable sections on rewrite) with an actionable message.
read: () => readConfigFileForUpdate(options.configPath) as SDKManagedConfig,
write: async (config) => {
await writeConfigFile(options.configPath, config);
},
apply: applyManagedKimiCodeConfig,
remove: applyManagedKimiCodeLogoutConfig,
},
});
}
async status(providerName?: string | undefined): Promise<AuthStatus> {
return this.toolkit.status(providerName, this.resolveRuntimeManagedAuth(providerName).oauthRef);
}
async login(
providerName: string | undefined = KIMI_CODE_PROVIDER_NAME,
options: KimiAuthLoginOptions = {},
): Promise<KimiAuthLoginResult> {
const auth = this.resolveManagedAuth(providerName);
const loginAuth = resolveKimiCodeLoginAuth({
configuredBaseUrl: auth.baseUrl,
configuredOAuthRef: auth.oauthRef,
requestedBaseUrl: options.baseUrl,
requestedOAuthHost: options.oauthHost,
});
const result = await this.toolkit.login(providerName, {
...options,
baseUrl: loginAuth.baseUrl,
oauthHost: loginAuth.oauthHost,
oauthRef: options.oauthRef ?? loginAuth.oauthRef,
provisionConfig: true,
});
if (result.provision === undefined) {
throw new Error('Kimi auth login did not provision model config.');
}
const updated = readConfigFile(this.options.configPath);
this.options.onConfigUpdated?.(updated);
return {
providerName: result.providerName,
ok: true,
defaultModel: result.provision.defaultModel,
defaultThinking: result.provision.defaultThinking,
configPath: result.provision.configPath,
};
}
async logout(providerName?: string | undefined): Promise<KimiAuthLogoutResult> {
const result = await this.toolkit.logout(
providerName,
this.resolveRuntimeManagedAuth(providerName).oauthRef,
);
const updated = readConfigFile(this.options.configPath);
this.options.onConfigUpdated?.(updated);
return {
providerName: result.providerName,
ok: result.ok,
};
}
async getManagedUsage(providerName?: string | undefined): Promise<AuthManagedUsageResult> {
const auth = this.resolveRuntimeManagedAuth(providerName);
return this.toolkit.getManagedUsage(providerName, {
oauthRef: auth.oauthRef,
baseUrl: auth.baseUrl,
});
}
async submitFeedback(
input: KimiAuthSubmitFeedbackInput,
providerName?: string | undefined,
): Promise<FetchSubmitFeedbackResult> {
const auth = this.resolveRuntimeManagedAuth(providerName);
return this.toolkit.submitFeedback(
{
session_id: input.sessionId,
content: input.content,
version: input.version,
os: input.os,
model: input.model,
contact: input.contact,
info: input.info,
},
providerName,
{
oauthRef: auth.oauthRef,
baseUrl: auth.baseUrl,
},
);
}
async createFeedbackUploadUrl(
input: KimiAuthCreateFeedbackUploadUrlInput,
providerName?: string | undefined,
): Promise<KimiAuthCreateFeedbackUploadUrlResult> {
const auth = this.resolveRuntimeManagedAuth(providerName);
const result = await this.toolkit.createFeedbackUploadUrl(
{
file_hash: input.sha256,
file_name: input.filename,
file_size: input.size,
feedback_id: input.feedbackId,
},
providerName,
{
oauthRef: auth.oauthRef,
baseUrl: auth.baseUrl,
},
);
if (result.kind !== 'ok') return result;
return {
kind: 'ok',
uploadId: result.upload_id,
parts: result.parts.map((part) => ({
partNumber: part.part_number,
url: part.url,
method: part.method,
size: part.size,
})),
};
}
async completeFeedbackUpload(
input: KimiAuthCompleteFeedbackUploadInput,
providerName?: string | undefined,
): Promise<FetchCompleteFeedbackUploadResult> {
const auth = this.resolveRuntimeManagedAuth(providerName);
return this.toolkit.completeFeedbackUpload(
{
upload_id: input.uploadId,
parts: input.parts.map((part) => ({ part_number: part.partNumber, etag: part.etag })),
},
providerName,
{
oauthRef: auth.oauthRef,
baseUrl: auth.baseUrl,
},
);
}
async getCachedAccessToken(
providerName?: string,
oauthRef?: OAuthRef | undefined,
): Promise<string | undefined> {
return this.toolkit.getCachedAccessToken(
providerName,
this.runtimeOAuthRef(providerName, oauthRef),
);
}
readonly resolveOAuthTokenProvider = (
providerName: string,
oauthRef?: OAuthRef | undefined,
): BearerTokenProvider => {
const provider = this.toolkit.tokenProvider(
providerName,
this.runtimeOAuthRef(providerName, oauthRef),
);
return {
getAccessToken: async (options) => {
try {
return await provider.getAccessToken(options);
} catch (error) {
// Classify OAuth token failures into the public KimiError protocol;
// unrecognized errors are rethrown raw (see mapOAuthTokenError).
throw mapOAuthTokenError(error, providerName) ?? error;
}
},
};
};
private resolveManagedAuth(providerName?: string | undefined): {
readonly oauthRef?: OAuthRef | undefined;
readonly baseUrl?: string | undefined;
} {
const name = providerName ?? KIMI_CODE_PROVIDER_NAME;
// Read path: token/status resolution must work off a degraded config
// instead of failing the session when an unrelated section is broken.
// Write paths (the toolkit's configAdapter.read) stay strict.
const config = loadRuntimeConfigSafe(this.options.configPath).config;
const provider = config.providers[name];
return {
oauthRef: provider?.oauth,
baseUrl: provider?.baseUrl,
};
}
private resolveRuntimeManagedAuth(providerName?: string | undefined): {
readonly oauthRef: OAuthRef;
readonly baseUrl?: string | undefined;
} {
const auth = this.resolveManagedAuth(providerName);
return resolveKimiCodeRuntimeAuth({
configuredBaseUrl: auth.baseUrl,
configuredOAuthRef: auth.oauthRef,
});
}
private runtimeOAuthRef(
providerName: string | undefined,
oauthRef?: OAuthRef | undefined,
): OAuthRef | undefined {
if ((providerName ?? KIMI_CODE_PROVIDER_NAME) !== KIMI_CODE_PROVIDER_NAME) return oauthRef;
const auth = this.resolveManagedAuth(providerName);
return resolveKimiCodeRuntimeAuth({
configuredBaseUrl: auth.baseUrl,
configuredOAuthRef: oauthRef ?? auth.oauthRef,
}).oauthRef;
}
}