Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,18 @@ release
# Locally downloaded oo binary (dev/build) — produced by scripts/download-oo.ts
.oo-bin/

# Locally downloaded Lark CLI binary (dev/build) — produced by scripts/download-lark-cli.ts
.lark-cli-bin/

# Bundled oo/opencode binaries staged by scripts/prepare-binaries.ts (not committed)
resources/bin

# Bundled oo skills exported by scripts/skills.ts (not committed)
resources/skills

# Lark CLI skills exported from the pinned binary (not committed)
resources/lark-skills

# Bundled self-contained OpenCode custom-tool runtime (not committed)
resources/agent-tool-runtime

Expand Down
15 changes: 14 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,19 @@ approval, while credential expansion, environment dumps, login/logout, and endpo
are rejected even in Full Access. OOMOL Skill registry maintenance remains tied to the OOMOL account
and does not follow the selected Link runtime.

Lark CLI is exposed in the same Connections catalog as a local `direct` provider, but it is not a
Link backend and does not send its credentials through the renderer or connector APIs.
`LarkCliManager` owns the isolated `<userData>/lark-cli/config` directory, drives the official
`config init --new` and `auth login --recommend` browser flows, and returns only redacted connection
state through `LinkRuntimeServiceImpl`. The shipped app contains a checksum-verified pinned CLI and
its matching embedded `lark-*` Skills. On Connect, Wanta first checks the official latest version;
an available update is downloaded to a versioned user-data runtime, verified against the release
checksum, and atomically activated. Update failure is non-fatal: authorization continues with the
already usable version. The active binary directory is prepended to the Agent sidecar `PATH`, its
config root is injected through `LARKSUITE_CLI_CONFIG_DIR`, and its matching Skills are copied into
the private Agent workspace. Thus chat uses the same local identity authorized from Connections,
independently of the selected OOMOL/OpenConnector Link runtime.

Vite (`vite-plugin-electron/simple` in `vite.config.ts`) bundles `electron/main.ts` and
`electron/preload.ts` into `dist-electron/main.js` + `preload.js`; the main-process build has a
**third** rollup input, `electron/chat/spreadsheet-preview-worker.ts` → `dist-electron/spreadsheet-preview-worker.js`,
Expand Down Expand Up @@ -608,7 +621,7 @@ electron/
chat/ common,node + ~45 modules by far the largest main-process domain: SSE event bridge; per-turn lifecycle & outputs (turn-lifecycle, turn-outputs); structured artifact registration/persistence (artifact-bundles, artifacts) + previews (spreadsheet-preview-worker[-client]); permission / local-access policy (permission-state, project-permission); project-* commands; attachments; stream buffering (stream-event-buffer, context-system). Also thin main-process facades openExternalUrl (shell external open) / setAgentTeam (agent team scope) for the renderer request layer (§4, §5)
git/ common,node,status,turn-diff(+test) GitService (serviceName("git-service")): project git status + per-turn diff review
knowledge/ common,node,store,runner,uri,thumbnail(+test) WikiGraph knowledge-base import, registration, query runtime & RPC service
link-runtime/ common,node(+test) selected Link runtime, origin-bound OpenConnector token, health/inventory facade
link-runtime/ common,node,lark-cli(+test) selected Link runtime, origin-bound OpenConnector token, health/inventory facade, and isolated direct Lark CLI lifecycle
teams/ common types only, no node.ts — team requests moved renderer-side (src/lib/teams-client.ts, §4)
connections/ common,summary,usage,executions,federated,domain,summary-model(+test) **pure functions + types, no node.ts** — connector requests moved renderer-side (src/lib/connections-client.ts, §4/§7); electron-free, imported straight into the renderer bundle
skills/ common,node,actions,scan,inventory,… skill service (install/scan/inventory); browse GET moved renderer-side (src/lib/skills-catalog-client.ts); actions.ts normalize* reused by the renderer (§4)
Expand Down
4 changes: 4 additions & 0 deletions electron-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ export default {
from: "resources/skills",
to: "skills",
},
{
from: "resources/lark-skills",
to: "lark-skills",
},
{
from: "resources/agent-tool-runtime",
to: "agent-tool-runtime",
Expand Down
16 changes: 16 additions & 0 deletions electron/agent/binaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,19 @@ export function ooBinaryName(platform: NodeJS.Platform = process.platform): stri
return platform === "win32" ? "oo.exe" : "oo"
}

export function larkCliBinaryName(platform: NodeJS.Platform = process.platform): string {
return platform === "win32" ? "lark-cli.exe" : "lark-cli"
}

/** dev:从项目本地 .oo-bin 解析 oo 二进制(postinstall 下载、prepare-binaries 同源;生产由 extraResources 解析)。 */
export function resolveDevOoBin(repoRoot: string, platform: NodeJS.Platform = process.platform): string {
return path.join(repoRoot, ".oo-bin", ooBinaryName(platform))
}

export function resolveDevLarkCliBin(repoRoot: string, platform: NodeJS.Platform = process.platform): string {
return path.join(repoRoot, ".lark-cli-bin", larkCliBinaryName(platform))
}

/** 生产:从打包的 Resources/bin 解析二进制(prepare-binaries 复制、extraResources 打入)。 */
export function resolveBundledBin(resourcesPath: string, binaryName: string): string {
return path.join(resourcesPath, "bin", binaryName)
Expand All @@ -42,6 +50,14 @@ export function resolveBundledSkillsDir(resourcesPath: string): string {
return path.join(resourcesPath, "skills")
}

export function resolveDevBundledLarkSkillsDir(repoRoot: string): string {
return path.join(repoRoot, "resources", "lark-skills")
}

export function resolveBundledLarkSkillsDir(resourcesPath: string): string {
return path.join(resourcesPath, "lark-skills")
}

/** dev:构建期合并的自定义工具 runtime(postinstall 生成)。 */
export function resolveDevBundledToolRuntimePath(repoRoot: string): string {
return path.join(repoRoot, "resources", "agent-tool-runtime", "tool.js")
Expand Down
24 changes: 23 additions & 1 deletion electron/agent/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ export interface AgentManagerOptions {
listOpenConnectorAuthorizedServices?: (signal?: AbortSignal) => Promise<string[]>
/** 内置 skill 源目录(resources/skills 或打包 Resources/skills);启动时拷进 .opencode/skill/。 */
bundledSkillsDir?: string
/** Official Lark CLI skills, available for the local direct connection. */
bundledLarkSkillsDir?: string
/** Active Wanta-managed Lark CLI direct-runtime binary. */
larkCliBinPath?: string
/** Isolated Lark CLI config directory; credentials remain owned by the CLI/keychain. */
larkCliConfigDir?: string
/** 构建期合并的自定义工具 runtime;启动时拷进 .opencode/runtime/tool.js。 */
bundledToolRuntimePath?: string
/** App 私有根目录(userData 下):workspace / oo-store / isolation 都在其下。 */
Expand Down Expand Up @@ -106,6 +112,8 @@ export interface AgentSidecarEnvOptions {
storeDir: string
teamName?: string
teamScopePath: string
larkCliBinPath?: string
larkCliConfigDir?: string
}

export function buildAgentSidecarEnv({
Expand All @@ -116,6 +124,8 @@ export function buildAgentSidecarEnv({
storeDir,
teamName,
teamScopePath,
larkCliBinPath,
larkCliConfigDir,
}: AgentSidecarEnvOptions): Record<string, string> {
const ooEnv = linkRuntime
? buildAgentLinkEnv({
Expand All @@ -132,6 +142,10 @@ export function buildAgentSidecarEnv({
PATH: commandPath,
WANTA_BROWSER_CONTROL_TOKEN: browserControl?.token ?? "",
WANTA_BROWSER_CONTROL_URL: browserControl?.url ?? "",
WANTA_LARK_CLI_BIN: larkCliBinPath ?? "",
LARKSUITE_CLI_CONFIG_DIR: larkCliConfigDir ?? "",
LARKSUITE_CLI_NO_SKILLS_NOTIFIER: "1",
LARKSUITE_CLI_NO_UPDATE_NOTIFIER: "1",
}
}

Expand Down Expand Up @@ -415,6 +429,7 @@ export class AgentManager {

await ensureAgentWorkspace(workspaceDir, bundledSkillsDir, bundledToolRuntimePath, {
bundledOoSkills: this.options.linkRuntime?.kind === "oomol",
bundledLarkSkillsDir: this.options.bundledLarkSkillsDir,
connectors: this.options.linkRuntime !== null,
})
this.teamScopePath = teamScopePath
Expand All @@ -433,6 +448,8 @@ export class AgentManager {
defaultModel,
wikiGraphCliPath,
wikiGraphStateDir,
larkCliBinPath,
larkCliConfigDir,
} = this.options
const workspaceDir = path.join(rootDir, "workspace")
const isolationDir = path.join(rootDir, "isolation")
Expand All @@ -441,7 +458,10 @@ export class AgentManager {

const config = buildOpencodeConfig({ customModels, defaultModel, linkRuntime, modelAccess })
const baseCommandPath = await resolveUserCommandPath({
preferredDirectories: linkRuntime && ooBinPath ? [path.dirname(ooBinPath)] : [],
preferredDirectories: [
...(larkCliBinPath ? [path.dirname(larkCliBinPath)] : []),
...(linkRuntime && ooBinPath ? [path.dirname(ooBinPath)] : []),
],
})
const wikiGraphBinDir =
wikiGraphCliPath && wikiGraphStateDir
Expand All @@ -462,6 +482,8 @@ export class AgentManager {
storeDir,
teamName: this.teamName,
teamScopePath,
larkCliBinPath,
larkCliConfigDir,
})

const sidecar = new OpencodeSidecar({
Expand Down
26 changes: 26 additions & 0 deletions electron/agent/workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,32 @@ test("ensureAgentWorkspace gives OpenConnector Browser and typed tools without O
}
})

test("ensureAgentWorkspace installs Lark direct-mode skills independently of the Link runtime", async () => {
const base = await mkdtemp(path.join(os.tmpdir(), "wanta-workspace-"))
try {
const workspaceDir = path.join(base, "workspace")
const bundledSkillsDir = path.join(base, "bundled-skills")
const bundledLarkSkillsDir = path.join(base, "lark-skills")
const bundledToolRuntimePath = await writeToolRuntime(base)
await writeSkill(bundledSkillsDir, "browser")
await writeSkill(bundledSkillsDir, "oo")
await writeSkill(bundledLarkSkillsDir, "lark-calendar")

await ensureAgentWorkspace(workspaceDir, bundledSkillsDir, bundledToolRuntimePath, {
bundledLarkSkillsDir,
bundledOoSkills: false,
connectors: false,
})

const skillRoot = path.join(workspaceDir, ".opencode", "skill")
assert.ok(await exists(path.join(skillRoot, "browser", "SKILL.md")))
assert.ok(await exists(path.join(skillRoot, "lark-calendar", "SKILL.md")))
assert.equal(await exists(path.join(skillRoot, "oo")), false)
} finally {
await rm(base, { force: true, recursive: true })
}
})

test("ensureAgentWorkspace works without a bundled skills directory", async () => {
const base = await mkdtemp(path.join(os.tmpdir(), "wanta-workspace-"))
try {
Expand Down
46 changes: 30 additions & 16 deletions electron/agent/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const alwaysAvailableBundledSkillIds = new Set(["browser"])

export interface AgentWorkspaceOptions {
bundledOoSkills: boolean
bundledLarkSkillsDir?: string
connectors: boolean
}

Expand Down Expand Up @@ -36,7 +37,7 @@ export async function ensureAgentWorkspace(
),
)
await syncToolRuntime(opencodeDir, bundledToolRuntimePath)
await syncBundledSkills(opencodeDir, bundledSkillsDir, options.bundledOoSkills)
await syncBundledSkills(opencodeDir, bundledSkillsDir, options.bundledLarkSkillsDir, options.bundledOoSkills)
return rootDir
}

Expand All @@ -62,37 +63,50 @@ async function syncToolRuntime(opencodeDir: string, bundledToolRuntimePath: stri
async function syncBundledSkills(
opencodeDir: string,
bundledSkillsDir: string | undefined,
bundledLarkSkillsDir: string | undefined,
includeOomolSkills: boolean,
): Promise<void> {
const skillDir = path.join(opencodeDir, "skill")

if (!bundledSkillsDir) {
if (!bundledSkillsDir && !bundledLarkSkillsDir) {
await rm(skillDir, { force: true, recursive: true })
return
}

let entries
try {
entries = await readdir(bundledSkillsDir, { withFileTypes: true })
} catch (error) {
// 源缺失/不可读(如 dev 跳过 postinstall):非致命——skills 全程 best-effort,不为 4 个可选 skill 阻断
// agent 启动。但显式告警(不再静默),避免发布包遗漏 Resources/skills 时问题被完全掩盖;保留已有副本不删。
console.warn(`[wanta] bundled skills source unavailable at ${bundledSkillsDir}; keeping existing skills:`, error)
return
const sources: Array<{ directory: string; names: string[] }> = []
for (const directory of [bundledSkillsDir, bundledLarkSkillsDir]) {
if (!directory) continue
try {
const entries = await readdir(directory, { withFileTypes: true })
sources.push({
directory,
names: entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name),
})
} catch (error) {
// 源缺失/不可读(如 dev 跳过 postinstall):非致命——skills 全程 best-effort,不为 4 个可选 skill 阻断
// agent 启动。但显式告警(不再静默),避免发布包遗漏 Resources/skills 时问题被完全掩盖;保留已有副本不删。
console.warn(`[wanta] bundled skills source unavailable at ${directory}; keeping other skill sources:`, error)
}
}

if (sources.length === 0) return

await rm(skillDir, { force: true, recursive: true })

const skillNames = entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.filter((name) => includeOomolSkills || alwaysAvailableBundledSkillIds.has(name))
if (skillNames.length === 0) {
const skillSources = sources.flatMap((source) =>
source.names
.filter(
(name) =>
source.directory === bundledLarkSkillsDir || includeOomolSkills || alwaysAvailableBundledSkillIds.has(name),
)
.map((name) => ({ name, source: source.directory })),
)
if (skillSources.length === 0) {
return
}

await mkdir(skillDir, { recursive: true })
await Promise.all(
skillNames.map((name) => cp(path.join(bundledSkillsDir, name), path.join(skillDir, name), { recursive: true })),
skillSources.map(({ name, source }) => cp(path.join(source, name), path.join(skillDir, name), { recursive: true })),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
}
3 changes: 3 additions & 0 deletions electron/connections/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,13 @@ export interface ConnectionProviderSummary {
categoryLabels: string[]
connectedUpdatedAt?: number
displayName: string
description?: string
executionMode?: "direct" | "remote"
iconUrl?: string
oauthClientConfig?: ConnectionProviderOAuthClientConfigSummary | null
service: string
status: ConnectionProviderStatus
runtimeVersion?: string
}

export type ConnectionProvider = ConnectionProviderSummary
Expand Down
26 changes: 26 additions & 0 deletions electron/link-runtime/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,32 @@ export interface LinkRuntimeState {
openConnector?: OpenConnectorSummary
}

export type LarkCliConnectionPhase =
| "idle"
| "checking"
| "updating"
| "configuring"
| "authorizing"
| "verifying"
| "disconnecting"

export interface LarkCliState {
accountLabel?: string
activeVersion: string | null
available: boolean
bundledVersion: string | null
connection: "connected" | "disconnected" | "expired"
error?: string
latestVersion?: string
phase: LarkCliConnectionPhase
updateStatus: "idle" | "checking" | "current" | "updating" | "updated" | "failed"
}

export type LinkRuntimeService = typeof LinkRuntimeService
export const LinkRuntimeService = serviceName("link-runtime-service") as ServiceName<{
ServerEvents: {
linkRuntimeChanged: LinkRuntimeState
larkCliChanged: LarkCliState
}
ClientInvokes: {
getState(): Promise<LinkRuntimeState>
Expand All @@ -60,5 +82,9 @@ export const LinkRuntimeService = serviceName("link-runtime-service") as Service
selectRuntime(kind: LinkRuntimeSelection): Promise<LinkRuntimeState>
clearOpenConnectorToken(): Promise<LinkRuntimeState>
removeOpenConnector(): Promise<LinkRuntimeState>
getLarkCliState(): Promise<LarkCliState>
connectLarkCli(): Promise<LarkCliState>
disconnectLarkCli(): Promise<LarkCliState>
cancelLarkCliConnection(): Promise<void>
}
}>
40 changes: 40 additions & 0 deletions electron/link-runtime/lark-cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import assert from "node:assert/strict"
import { test } from "vitest"
import { findOfficialAuthorizationUrl, isVersionNewer, redactCommandError } from "./lark-cli.ts"

test("Lark CLI update comparison handles stable and prerelease versions", () => {
assert.equal(isVersionNewer("1.0.82", "1.0.81"), true)
assert.equal(isVersionNewer("1.1.0", "1.0.99"), true)
assert.equal(isVersionNewer("1.0.81", "1.0.81"), false)
assert.equal(isVersionNewer("1.0.81-beta.2", "1.0.81-beta.1"), true)
assert.equal(isVersionNewer("1.0.81", "1.0.81-beta.2"), true)
assert.equal(isVersionNewer("invalid", "1.0.81"), false)
})

test("Lark CLI authorization URLs are recovered from JSON without widening the host allowlist", () => {
assert.equal(
findOfficialAuthorizationUrl('{"verification_url":"https://open.feishu.cn/device?a=1\\u0026b=2"}'),
"https://open.feishu.cn/device?a=1&b=2",
)
assert.equal(
findOfficialAuthorizationUrl("https://open.larksuite.com/device?id=1"),
"https://open.larksuite.com/device?id=1",
)
assert.equal(findOfficialAuthorizationUrl("https://open.feishu.cn.evil.example/device"), undefined)
assert.equal(findOfficialAuthorizationUrl("https://open.feishu.cn:8443/device"), undefined)
})

test("Lark CLI command errors redact authorization URLs and credentials", () => {
const redacted = redactCommandError(
'request failed device_code=dev-secret app_secret:app-secret "access_token":"access-secret", refresh_token=refresh-secret https://open.feishu.cn/device?id=secret',
)

assert.match(redacted, /request failed/u)
assert.equal(redacted.includes("dev-secret"), false)
assert.equal(redacted.includes("app-secret"), false)
assert.equal(redacted.includes("access-secret"), false)
assert.equal(redacted.includes("refresh-secret"), false)
assert.equal(redacted.includes("id=secret"), false)
assert.match(redacted, /\[redacted\]/u)
assert.match(redacted, /\[authorization-url\]/u)
})
Loading
Loading