feat(agent): add auth token config. (#649)

* feat(agent): add auth token config.

* fix: fix agent loading auth token.

* fix: update retain old config filepath.

* fix: update retain old config filepath.

* fix: lint.

* fix: remove auto migrate, update config template.
release-notes-05
Zhiming Ma 2023-10-30 12:09:18 +08:00 committed by GitHub
parent c51e00ee45
commit e88097320b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 91 additions and 85 deletions

View File

@ -10,6 +10,6 @@
"devDependencies": { "devDependencies": {
"cpy-cli": "^4.2.0", "cpy-cli": "^4.2.0",
"rimraf": "^5.0.1", "rimraf": "^5.0.1",
"tabby-agent": "1.0.0" "tabby-agent": "1.1.0-dev"
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "tabby-agent", "name": "tabby-agent",
"version": "1.0.0", "version": "1.1.0-dev",
"description": "Generic client agent for Tabby AI coding assistant IDE extensions.", "description": "Generic client agent for Tabby AI coding assistant IDE extensions.",
"repository": "https://github.com/TabbyML/tabby", "repository": "https://github.com/TabbyML/tabby",
"main": "./dist/index.js", "main": "./dist/index.js",

View File

@ -3,6 +3,7 @@ import { isBrowser } from "./env";
export type AgentConfig = { export type AgentConfig = {
server: { server: {
endpoint: string; endpoint: string;
token: string;
requestHeaders: Record<string, string | number | boolean | null | undefined>; requestHeaders: Record<string, string | number | boolean | null | undefined>;
requestTimeout: number; requestTimeout: number;
}; };
@ -50,6 +51,7 @@ export type PartialAgentConfig = RecursivePartial<AgentConfig>;
export const defaultAgentConfig: AgentConfig = { export const defaultAgentConfig: AgentConfig = {
server: { server: {
endpoint: "http://localhost:8080", endpoint: "http://localhost:8080",
token: "",
requestHeaders: {}, requestHeaders: {},
requestTimeout: 30000, // 30s requestTimeout: 30000, // 30s
}, },
@ -82,67 +84,27 @@ export const defaultAgentConfig: AgentConfig = {
}, },
}; };
const oldConfigTomlTemplate = `## Tabby agent configuration file
## You can uncomment any block to enable settings.
## Configurations in this file has lower priority than in IDE settings.
## Server
## You can set the server endpoint and request timeout here.
# [server]
# endpoint = "http://localhost:8080" # http or https URL
# requestTimeout = 30000 # ms
## You can add custom request headers, e.g. for authentication.
# [server.requestHeaders]
# Authorization = "Bearer eyJhbGciOiJ..........."
## Completion
## You can set the prompt context to send to the server for completion.
# [completion.prompt]
# maxPrefixLines = 20
# maxSuffixLines = 20
## You can set the debounce mode for auto completion requests when typing.
# [completion.debounce]
# mode = "adaptive" # or "fixed"
# interval = 250 # ms, only used when mode is "fixed"
## You can set the timeout for completion requests.
# [completion.timeout]
# auto = 5000 # ms, for auto completion when typing
# manually = 30000 # ms, for manually triggered completion
## Logs
## You can set the log level here. The log file is located at ~/.tabby-client/agent/logs/.
# [logs]
# level = "silent" # or "error" or "debug"
## Anonymous usage tracking
## You can disable anonymous usage tracking here.
# [anonymousUsageTracking]
# disable = false # set to true to disable
`;
const configTomlTemplate = `## Tabby agent configuration file const configTomlTemplate = `## Tabby agent configuration file
## You can uncomment any block to enable settings. ## You can uncomment any block to enable settings.
## Configurations in this file has lower priority than in IDE settings. ## Configurations in this file has lower priority than in IDE settings.
## Server ## Server
## You can set the server endpoint here. ## You can set the server endpoint and authentication token here.
# [server] # [server]
# endpoint = "http://localhost:8080" # http or https URL # endpoint = "http://localhost:8080" # http or https URL
# token = "your-token-here" # if server requires authentication
## You can add custom request headers, e.g. for authentication. ## You can add custom request headers.
# [server.requestHeaders] # [server.requestHeaders]
# Authorization = "Bearer eyJhbGciOiJ..........." # Header1 = "Value1" # list your custom headers here
# Header2 = "Value2" # value can be string, number or boolean
# Authorization = "Bearer your-token-here" # if Authorization header is set, server.token will be ignored
## Logs ## Logs
## You can set the log level here. The log file is located at ~/.tabby-client/agent/logs/. ## You can set the log level here. The log file is located at ~/.tabby-client/agent/logs/.
# [logs] # [logs]
# level = "silent" # or "error" or "debug" # level = "silent" # "silent" or "error" or "debug"
## Anonymous usage tracking ## Anonymous usage tracking
## You can disable anonymous usage tracking here. ## You can disable anonymous usage tracking here.
@ -158,6 +120,7 @@ export const userAgentConfig = isBrowser
const fs = require("fs-extra"); const fs = require("fs-extra");
const toml = require("toml"); const toml = require("toml");
const chokidar = require("chokidar"); const chokidar = require("chokidar");
const deepEqual = require("deep-equal");
class ConfigFile extends EventEmitter { class ConfigFile extends EventEmitter {
filepath: string; filepath: string;
@ -177,14 +140,13 @@ export const userAgentConfig = isBrowser
async load() { async load() {
try { try {
const fileContent = await fs.readFile(this.filepath, "utf8"); const fileContent = await fs.readFile(this.filepath, "utf8");
// If the config file is the old template, and user has not modified it, const data = toml.parse(fileContent);
// Overwrite it with the new template. // If the config file contains no value, overwrite it with the new template.
if (fileContent.trim() === oldConfigTomlTemplate.trim()) { if (Object.keys(data).length === 0 && fileContent.trim() !== configTomlTemplate.trim()) {
await this.createTemplate(); await this.createTemplate();
return await this.load(); return;
} }
this.data = toml.parse(fileContent); this.data = data;
super.emit("updated", this.data);
} catch (error) { } catch (error) {
if (error.code === "ENOENT") { if (error.code === "ENOENT") {
await this.createTemplate(); await this.createTemplate();
@ -206,8 +168,15 @@ export const userAgentConfig = isBrowser
this.watcher = chokidar.watch(this.filepath, { this.watcher = chokidar.watch(this.filepath, {
interval: 1000, interval: 1000,
}); });
this.watcher.on("add", this.load.bind(this)); const onChanged = async () => {
this.watcher.on("change", this.load.bind(this)); const oldData = this.data;
await this.load();
if (!deepEqual(oldData, this.data)) {
super.emit("updated", this.data);
}
};
this.watcher.on("add", onChanged);
this.watcher.on("change", onChanged);
} }
} }

View File

@ -54,7 +54,16 @@ export class Auth extends EventEmitter {
constructor(options: { endpoint: string; dataStore?: DataStore }) { constructor(options: { endpoint: string; dataStore?: DataStore }) {
super(); super();
this.endpoint = options.endpoint; this.endpoint = options.endpoint;
this.dataStore = options.dataStore || dataStore; if (options.dataStore) {
this.dataStore = options.dataStore;
} else {
this.dataStore = dataStore;
dataStore.on("updated", async () => {
await this.load();
super.emit("updated", this.jwt);
});
dataStore.watch();
}
this.authApi = createClient<CloudApi>({ baseUrl: "https://app.tabbyml.com/api" }); this.authApi = createClient<CloudApi>({ baseUrl: "https://app.tabbyml.com/api" });
this.scheduleRefreshToken(); this.scheduleRefreshToken();
} }

View File

@ -95,13 +95,13 @@ export class TabbyAgent extends EventEmitter implements Agent {
this.config = deepmerge(defaultAgentConfig, this.userConfig, this.clientConfig); this.config = deepmerge(defaultAgentConfig, this.userConfig, this.clientConfig);
allLoggers.forEach((logger) => (logger.level = this.config.logs.level)); allLoggers.forEach((logger) => (logger.level = this.config.logs.level));
this.anonymousUsageLogger.disabled = this.config.anonymousUsageTracking.disable; this.anonymousUsageLogger.disabled = this.config.anonymousUsageTracking.disable;
if (this.config.server.requestHeaders["Authorization"] === undefined) { if (isBlank(this.config.server.token) && this.config.server.requestHeaders["Authorization"] === undefined) {
if (this.config.server.endpoint !== this.auth?.endpoint) { if (this.config.server.endpoint !== this.auth?.endpoint) {
this.auth = await Auth.create({ endpoint: this.config.server.endpoint, dataStore: this.dataStore }); this.auth = await Auth.create({ endpoint: this.config.server.endpoint, dataStore: this.dataStore });
this.auth.on("updated", this.setupApi.bind(this)); this.auth.on("updated", this.setupApi.bind(this));
} }
} else { } else {
// If `Authorization` request header is provided, use it directly. // If auth token is provided, use it directly.
this.auth = null; this.auth = null;
} }
await this.setupApi(); await this.setupApi();
@ -126,10 +126,15 @@ export class TabbyAgent extends EventEmitter implements Agent {
} }
private async setupApi() { private async setupApi() {
const auth = !isBlank(this.config.server.token)
? `Bearer ${this.config.server.token}`
: this.auth?.token
? `Bearer ${this.auth.token}`
: undefined;
this.api = createClient<TabbyApi>({ this.api = createClient<TabbyApi>({
baseUrl: this.config.server.endpoint.replace(/\/+$/, ""), // remove trailing slash baseUrl: this.config.server.endpoint.replace(/\/+$/, ""), // remove trailing slash
headers: { headers: {
Authorization: this.auth?.token ? `Bearer ${this.auth.token}` : undefined, Authorization: auth,
...this.config.server.requestHeaders, ...this.config.server.requestHeaders,
}, },
}); });
@ -225,6 +230,7 @@ export class TabbyAgent extends EventEmitter implements Agent {
error instanceof HttpError && error instanceof HttpError &&
[401, 403, 405].indexOf(error.status) !== -1 && [401, 403, 405].indexOf(error.status) !== -1 &&
new URL(this.config.server.endpoint).hostname.endsWith("app.tabbyml.com") && new URL(this.config.server.endpoint).hostname.endsWith("app.tabbyml.com") &&
isBlank(this.config.server.token) &&
this.config.server.requestHeaders["Authorization"] === undefined this.config.server.requestHeaders["Authorization"] === undefined
) { ) {
this.logger.debug({ requestId, path, error }, "API unauthorized"); this.logger.debug({ requestId, path, error }, "API unauthorized");
@ -254,8 +260,10 @@ export class TabbyAgent extends EventEmitter implements Agent {
} }
} }
} catch (_) { } catch (_) {
this.changeStatus("disconnected"); if (this.status === "ready" || this.status === "notInitialized") {
this.serverHealthState = null; this.changeStatus("disconnected");
this.serverHealthState = null;
}
} }
} }

View File

@ -11,28 +11,48 @@ export interface DataStore {
save(): PromiseLike<void>; save(): PromiseLike<void>;
} }
export const dataStore: DataStore = isBrowser export const dataStore = isBrowser
? null ? null
: (() => { : (() => {
const dataFile = require("path").join(require("os").homedir(), ".tabby-client", "agent", "data.json"); const EventEmitter = require("events");
const fs = require("fs-extra"); const fs = require("fs-extra");
return { const deepEqual = require("deep-equal");
data: {}, const chokidar = require("chokidar");
load: async function () {
await this.migrateFrom_0_3_0(); class FileDataStore extends EventEmitter implements FileDataStore {
filepath: string;
data: Partial<StoredData> = {};
watcher: ReturnType<typeof chokidar.watch> | null = null;
constructor(filepath: string) {
super();
this.filepath = filepath;
}
async load() {
this.data = (await fs.readJson(dataFile, { throws: false })) || {}; this.data = (await fs.readJson(dataFile, { throws: false })) || {};
}, }
save: async function () {
async save() {
await fs.outputJson(dataFile, this.data); await fs.outputJson(dataFile, this.data);
}, }
migrateFrom_0_3_0: async function () {
const dataFile_0_3_0 = require("path").join(require("os").homedir(), ".tabby", "agent", "data.json"); watch() {
const migratedFlag = require("path").join(require("os").homedir(), ".tabby", "agent", ".data_json_migrated"); this.watcher = chokidar.watch(this.filepath, {
if ((await fs.pathExists(dataFile_0_3_0)) && !(await fs.pathExists(migratedFlag))) { interval: 1000,
const data = await fs.readJson(dataFile_0_3_0); });
await fs.outputJson(dataFile, data); const onChanged = async () => {
await fs.outputFile(migratedFlag, ""); const oldData = this.data;
} await this.load();
}, if (!deepEqual(oldData, this.data)) {
}; super.emit("updated", this.data);
}
};
this.watcher.on("add", onChanged);
this.watcher.on("change", onChanged);
}
}
const dataFile = require("path").join(require("os").homedir(), ".tabby-client", "agent", "data.json");
return new FileDataStore(dataFile);
})(); })();

View File

@ -10,6 +10,6 @@
"devDependencies": { "devDependencies": {
"cpy-cli": "^4.2.0", "cpy-cli": "^4.2.0",
"rimraf": "^5.0.1", "rimraf": "^5.0.1",
"tabby-agent": "1.0.0" "tabby-agent": "1.1.0-dev"
} }
} }

View File

@ -7,7 +7,7 @@
"repository": "https://github.com/TabbyML/tabby", "repository": "https://github.com/TabbyML/tabby",
"bugs": "https://github.com/TabbyML/tabby/issues", "bugs": "https://github.com/TabbyML/tabby/issues",
"license": "Apache-2.0", "license": "Apache-2.0",
"version": "1.0.0", "version": "1.1.0-dev",
"keywords": [ "keywords": [
"ai", "ai",
"autocomplete", "autocomplete",
@ -217,6 +217,6 @@
}, },
"dependencies": { "dependencies": {
"@xstate/fsm": "^2.0.1", "@xstate/fsm": "^2.0.1",
"tabby-agent": "1.0.0" "tabby-agent": "1.1.0-dev"
} }
} }