@anionex/dsh-computer-use 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +307 -0
- package/README.zh.md +307 -0
- package/assets/computer-use-fixture.png +0 -0
- package/assets/cursor.png +0 -0
- package/cordis.patch.yml +13 -0
- package/docs/interaction-policy.i18n.yaml +4 -0
- package/docs/interaction-policy.md +140 -0
- package/docs/interaction-policy.zh.md +140 -0
- package/lib/approval-policy.js +12 -0
- package/lib/approval-policy.js.map +1 -0
- package/lib/artifacts.js +79 -0
- package/lib/artifacts.js.map +1 -0
- package/lib/backend.js +3 -0
- package/lib/backend.js.map +1 -0
- package/lib/client.js +349 -0
- package/lib/client.js.map +1 -0
- package/lib/config.js +120 -0
- package/lib/config.js.map +1 -0
- package/lib/confirmations.js +99 -0
- package/lib/confirmations.js.map +1 -0
- package/lib/diff.js +58 -0
- package/lib/diff.js.map +1 -0
- package/lib/errors.js +22 -0
- package/lib/errors.js.map +1 -0
- package/lib/exposure.js +231 -0
- package/lib/exposure.js.map +1 -0
- package/lib/index.js +56 -0
- package/lib/index.js.map +1 -0
- package/lib/leases.js +259 -0
- package/lib/leases.js.map +1 -0
- package/lib/providers/macos.js +148 -0
- package/lib/providers/macos.js.map +1 -0
- package/lib/providers/native-helper.js +356 -0
- package/lib/providers/native-helper.js.map +1 -0
- package/lib/service.js +480 -0
- package/lib/service.js.map +1 -0
- package/lib/skill.js +119 -0
- package/lib/skill.js.map +1 -0
- package/lib/target-resolver.js +180 -0
- package/lib/target-resolver.js.map +1 -0
- package/lib/tools.js +493 -0
- package/lib/tools.js.map +1 -0
- package/lib/types/approval-policy.d.ts +13 -0
- package/lib/types/approval-policy.d.ts.map +1 -0
- package/lib/types/artifacts.d.ts +10 -0
- package/lib/types/artifacts.d.ts.map +1 -0
- package/lib/types/backend.d.ts +94 -0
- package/lib/types/backend.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +78 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/config.d.ts +75 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/confirmations.d.ts +23 -0
- package/lib/types/confirmations.d.ts.map +1 -0
- package/lib/types/diff.d.ts +5 -0
- package/lib/types/diff.d.ts.map +1 -0
- package/lib/types/errors.d.ts +15 -0
- package/lib/types/errors.d.ts.map +1 -0
- package/lib/types/exposure.d.ts +30 -0
- package/lib/types/exposure.d.ts.map +1 -0
- package/lib/types/index.d.ts +21 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/leases.d.ts +80 -0
- package/lib/types/leases.d.ts.map +1 -0
- package/lib/types/providers/macos.d.ts +15 -0
- package/lib/types/providers/macos.d.ts.map +1 -0
- package/lib/types/providers/native-helper.d.ts +36 -0
- package/lib/types/providers/native-helper.d.ts.map +1 -0
- package/lib/types/service.d.ts +52 -0
- package/lib/types/service.d.ts.map +1 -0
- package/lib/types/skill.d.ts +9 -0
- package/lib/types/skill.d.ts.map +1 -0
- package/lib/types/target-resolver.d.ts +38 -0
- package/lib/types/target-resolver.d.ts.map +1 -0
- package/lib/types/tools.d.ts +6 -0
- package/lib/types/tools.d.ts.map +1 -0
- package/lib/types/types.d.ts +246 -0
- package/lib/types/types.d.ts.map +1 -0
- package/lib/types/web.d.ts +32 -0
- package/lib/types/web.d.ts.map +1 -0
- package/lib/types.js +9 -0
- package/lib/types.js.map +1 -0
- package/lib/web.js +163 -0
- package/lib/web.js.map +1 -0
- package/native/macos/Sources/Fixture/main.swift +366 -0
- package/native/macos/Sources/Helper/CursorImage.swift +11 -0
- package/native/macos/Sources/Helper/CursorOverlay.swift +488 -0
- package/native/macos/Sources/Helper/TargetedPointer.swift +314 -0
- package/native/macos/Sources/Helper/main.swift +1140 -0
- package/native/macos/Sources/Monitor/main.swift +139 -0
- package/native/macos/bin/dsh-computer-use-helper +0 -0
- package/native/macos/manifest.json +14 -0
- package/package.json +128 -0
- package/scripts/build-client.mjs +24 -0
- package/scripts/build-native.mjs +158 -0
- package/scripts/check-native.mjs +92 -0
- package/scripts/model-e2e.mjs +291 -0
- package/scripts/session-transcript.mjs +117 -0
- package/scripts/validate.mjs +256 -0
- package/src/approval-policy.ts +16 -0
- package/src/artifacts.ts +93 -0
- package/src/backend.ts +103 -0
- package/src/client/index.tsx +539 -0
- package/src/config.ts +196 -0
- package/src/confirmations.ts +128 -0
- package/src/diff.ts +52 -0
- package/src/errors.ts +39 -0
- package/src/exposure.ts +236 -0
- package/src/index.ts +66 -0
- package/src/leases.ts +337 -0
- package/src/providers/macos.ts +185 -0
- package/src/providers/native-helper.ts +393 -0
- package/src/service.ts +583 -0
- package/src/skill.ts +123 -0
- package/src/target-resolver.ts +237 -0
- package/src/tools.ts +528 -0
- package/src/types.ts +299 -0
- package/src/web.ts +180 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** DSH Computer Use model-facing consumer and progressive Skill bundle. */
|
|
2
|
+
|
|
3
|
+
import { Service, type Context } from '@deepseek-ai/cordis'
|
|
4
|
+
import type {} from '@deepseek-ai/dsh-agent'
|
|
5
|
+
import type {} from '@deepseek-ai/dsh-settings'
|
|
6
|
+
import type {} from '@deepseek-ai/dsh-skill'
|
|
7
|
+
import type {} from '@deepseek-ai/dsh-tools'
|
|
8
|
+
import { Config, type ComputerUseConfig } from './config.ts'
|
|
9
|
+
import { ComputerUseExposure } from './exposure.ts'
|
|
10
|
+
import { MacOSComputerUseProvider } from './providers/macos.ts'
|
|
11
|
+
import { COMPUTER_USE_SKILL } from './skill.ts'
|
|
12
|
+
import { createComputerUseTools } from './tools.ts'
|
|
13
|
+
import { installComputerUseWeb } from './web.ts'
|
|
14
|
+
|
|
15
|
+
export { Config } from './config.ts'
|
|
16
|
+
export * from './errors.ts'
|
|
17
|
+
export * from './service.ts'
|
|
18
|
+
export * from './types.ts'
|
|
19
|
+
|
|
20
|
+
/** Register the portable Skill, bootstrap, Agent-scoped Tools, and optional Web diagnostics. */
|
|
21
|
+
export function installComputerUseConsumer(ctx: Context): () => void {
|
|
22
|
+
const exposure = new ComputerUseExposure(ctx, () => createComputerUseTools(ctx.computerUse))
|
|
23
|
+
let activation: (() => void) | undefined
|
|
24
|
+
let skill: (() => void) | undefined
|
|
25
|
+
let exposureDispose: (() => void) | undefined
|
|
26
|
+
try {
|
|
27
|
+
activation = ctx.tools.register(exposure.activationTool)
|
|
28
|
+
skill = ctx.skills.register(COMPUTER_USE_SKILL)
|
|
29
|
+
exposureDispose = exposure.install()
|
|
30
|
+
installComputerUseWeb(ctx)
|
|
31
|
+
} catch (error) {
|
|
32
|
+
exposureDispose?.()
|
|
33
|
+
skill?.()
|
|
34
|
+
activation?.()
|
|
35
|
+
throw error
|
|
36
|
+
}
|
|
37
|
+
return () => {
|
|
38
|
+
exposureDispose?.()
|
|
39
|
+
activation?.()
|
|
40
|
+
skill?.()
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** macOS provider plus the portable Skill, scoped Tools, and optional Web diagnostics. */
|
|
45
|
+
export class ComputerUseBundle extends MacOSComputerUseProvider {
|
|
46
|
+
static override inject = ['subprocess', 'approval', 'settings', 'sessions', 'agents', 'tools', 'skills']
|
|
47
|
+
static override Config = Config
|
|
48
|
+
|
|
49
|
+
private consumerDispose: (() => void) | undefined
|
|
50
|
+
|
|
51
|
+
constructor(ctx: Context, config: ComputerUseConfig = {}) {
|
|
52
|
+
super(ctx, config)
|
|
53
|
+
ctx.effect(() => () => {
|
|
54
|
+
this.consumerDispose?.()
|
|
55
|
+
this.consumerDispose = undefined
|
|
56
|
+
}, 'dsh-computer-use: consumer lifecycle')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Publish model-facing capabilities only after provider integrity and health pass. */
|
|
60
|
+
protected override async [Service.init](): Promise<void> {
|
|
61
|
+
await super[Service.init]()
|
|
62
|
+
this.consumerDispose = installComputerUseConsumer(this.ctx)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export default ComputerUseBundle
|
package/src/leases.ts
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/** Session-sidecar read leases, durable denials, and per-turn control leases. */
|
|
2
|
+
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
5
|
+
import type { CallId } from '@deepseek-ai/dsh-llm'
|
|
6
|
+
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
|
7
|
+
import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
|
|
8
|
+
import type { KvTable } from '@deepseek-ai/dsh-storage-domain'
|
|
9
|
+
import type { Context, Fiber } from '@deepseek-ai/cordis'
|
|
10
|
+
import { approvalPolicy } from './approval-policy.ts'
|
|
11
|
+
import type { ResolvedComputerUseConfig } from './config.ts'
|
|
12
|
+
import { ComputerUseError } from './errors.ts'
|
|
13
|
+
import type { ComputerAppIdentity } from './types.ts'
|
|
14
|
+
|
|
15
|
+
const nonNegativeSafeInteger = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
|
|
16
|
+
const leaseScopeSchema = z.union([z.literal('read'), z.literal('control')])
|
|
17
|
+
|
|
18
|
+
const sessionIdentitySchema = z.object({
|
|
19
|
+
createdAt: nonNegativeSafeInteger,
|
|
20
|
+
cwd: z.string().optional(),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
/** Session fields that fence one sidecar row to one exact Session lifecycle. */
|
|
24
|
+
export type ComputerUseSessionIdentity = z.infer<typeof sessionIdentitySchema>
|
|
25
|
+
|
|
26
|
+
const deniedLeaseSchema = z.object({
|
|
27
|
+
bundleId: z.string().min(1),
|
|
28
|
+
scope: leaseScopeSchema,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
/** One application/scope rejection that remains final for the Session lifecycle. */
|
|
32
|
+
export type ComputerUseDeniedLease = z.infer<typeof deniedLeaseSchema>
|
|
33
|
+
|
|
34
|
+
/** Runtime validation for the whole-Session Computer Use sidecar row. */
|
|
35
|
+
export const computerUseSessionStateSchema = z.object({
|
|
36
|
+
session: sessionIdentitySchema,
|
|
37
|
+
readGrants: z.array(z.string().min(1)),
|
|
38
|
+
denied: z.array(deniedLeaseSchema),
|
|
39
|
+
}).superRefine((row, ctx) => {
|
|
40
|
+
const readGrants = new Set<string>()
|
|
41
|
+
row.readGrants.forEach((bundleId, index) => {
|
|
42
|
+
if (readGrants.has(bundleId)) {
|
|
43
|
+
ctx.addIssue({
|
|
44
|
+
code: 'custom',
|
|
45
|
+
path: ['readGrants', index],
|
|
46
|
+
message: `duplicate Computer Use read grant '${bundleId}'`,
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
readGrants.add(bundleId)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const denials = new Set<string>()
|
|
53
|
+
row.denied.forEach((denial, index) => {
|
|
54
|
+
const key = `${denial.scope}\0${denial.bundleId}`
|
|
55
|
+
if (denials.has(key)) {
|
|
56
|
+
ctx.addIssue({
|
|
57
|
+
code: 'custom',
|
|
58
|
+
path: ['denied', index],
|
|
59
|
+
message: `duplicate Computer Use ${denial.scope} denial '${denial.bundleId}'`,
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
denials.add(key)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
/** Plugin-owned durable authorization state for one Session lifecycle. */
|
|
67
|
+
export type ComputerUseSessionState = z.infer<typeof computerUseSessionStateSchema>
|
|
68
|
+
|
|
69
|
+
/** One lifecycle-bound Computer Use sidecar record per Session id. */
|
|
70
|
+
export const computerUseStateDomainSpec = defineDomain({
|
|
71
|
+
name: 'computer_use_state',
|
|
72
|
+
version: 0,
|
|
73
|
+
tables: {
|
|
74
|
+
sessions: domainTable<SessionId, ComputerUseSessionState>(computerUseSessionStateSchema),
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
interface StorageBinding {
|
|
79
|
+
table: KvTable<SessionId, ComputerUseSessionState>
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function currentTurn(events: readonly SessionEvent[]): number | undefined {
|
|
83
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
84
|
+
const event = events[index]
|
|
85
|
+
if (event?.type === 'turn/end') return undefined
|
|
86
|
+
if (event?.type === 'turn/start') return event.data.turn
|
|
87
|
+
}
|
|
88
|
+
return undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function identityOf(header: SessionHeader): ComputerUseSessionIdentity {
|
|
92
|
+
return Object.freeze({
|
|
93
|
+
createdAt: header.createdAt,
|
|
94
|
+
...(header.cwd === undefined ? {} : { cwd: header.cwd }),
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function sameIdentity(row: ComputerUseSessionState, header: SessionHeader): boolean {
|
|
99
|
+
return row.session.createdAt === header.createdAt && row.session.cwd === header.cwd
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function stateSnapshot(
|
|
103
|
+
header: SessionHeader,
|
|
104
|
+
readGrants: Iterable<string>,
|
|
105
|
+
denied: Iterable<ComputerUseDeniedLease>,
|
|
106
|
+
): ComputerUseSessionState {
|
|
107
|
+
const readGrantSnapshot = Object.freeze([...readGrants]) as unknown as string[]
|
|
108
|
+
const deniedSnapshot = Object.freeze([...denied].map(item => Object.freeze({ ...item }))) as unknown as ComputerUseDeniedLease[]
|
|
109
|
+
return Object.freeze({
|
|
110
|
+
session: identityOf(header),
|
|
111
|
+
readGrants: readGrantSnapshot,
|
|
112
|
+
denied: deniedSnapshot,
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function configuredAccess(config: ResolvedComputerUseConfig, bundleId: string, scope: 'read' | 'control'): boolean {
|
|
117
|
+
if (config.allowAllApps) return true
|
|
118
|
+
return config.grants.find(grant => grant.bundleId === bundleId)?.[scope] === true
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function boundedFailure(error: unknown): string {
|
|
122
|
+
return (error instanceof Error ? error.message : String(error)).slice(0, 500)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Source of the technical application lease used by an operation. */
|
|
126
|
+
export type ComputerLeaseSource = 'configured' | 'approved'
|
|
127
|
+
|
|
128
|
+
/** Applies configured app policy and routes missing leases through DSH approval. */
|
|
129
|
+
export class ComputerLeaseManager {
|
|
130
|
+
private storage: StorageBinding | undefined
|
|
131
|
+
private readonly storageFiber: Fiber & PromiseLike<Fiber>
|
|
132
|
+
private readonly decisionTails = new Map<SessionId, Promise<void>>()
|
|
133
|
+
private readonly mutationTails = new Map<SessionId, Promise<void>>()
|
|
134
|
+
private readonly controlGrants = new WeakMap<Agent, Map<string, number>>()
|
|
135
|
+
|
|
136
|
+
constructor(
|
|
137
|
+
private readonly ctx: Context,
|
|
138
|
+
private readonly config: () => ResolvedComputerUseConfig,
|
|
139
|
+
) {
|
|
140
|
+
this.storageFiber = ctx.inject(['storageDomain'], async (storageCtx: Context) => {
|
|
141
|
+
const domain = await storageCtx.storageDomain.open(computerUseStateDomainSpec)
|
|
142
|
+
const binding: StorageBinding = { table: domain.table('sessions') }
|
|
143
|
+
this.storage = binding
|
|
144
|
+
return async () => {
|
|
145
|
+
if (this.storage === binding) this.storage = undefined
|
|
146
|
+
await Promise.all(this.mutationTails.values())
|
|
147
|
+
await domain.close()
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
ctx.effect(() => () => this.storageFiber.dispose(), 'dsh-computer-use: optional lease sidecar')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Wait for an already-composed storage-domain service to finish opening. */
|
|
154
|
+
async initialize(): Promise<void> {
|
|
155
|
+
await this.prepareStorage()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Ensure one Agent may read or control one exact running application. */
|
|
159
|
+
async ensure(
|
|
160
|
+
agent: Agent,
|
|
161
|
+
app: ComputerAppIdentity,
|
|
162
|
+
scope: 'read' | 'control',
|
|
163
|
+
toolName: string,
|
|
164
|
+
callId: CallId | undefined,
|
|
165
|
+
signal: AbortSignal,
|
|
166
|
+
): Promise<ComputerLeaseSource> {
|
|
167
|
+
if (configuredAccess(this.config(), app.bundleId, scope)) return 'configured'
|
|
168
|
+
return await this.enqueueDecision(agent.session.id, async () => {
|
|
169
|
+
if (configuredAccess(this.config(), app.bundleId, scope)) return 'configured'
|
|
170
|
+
return await this.ensureInteractive(agent, app, scope, toolName, callId, signal)
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Forget process-local control grants when their Agent is disposed. */
|
|
175
|
+
releaseAgent(agent: Agent): void {
|
|
176
|
+
this.controlGrants.delete(agent)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private async ensureInteractive(
|
|
180
|
+
agent: Agent,
|
|
181
|
+
app: ComputerAppIdentity,
|
|
182
|
+
scope: 'read' | 'control',
|
|
183
|
+
toolName: string,
|
|
184
|
+
callId: CallId | undefined,
|
|
185
|
+
signal: AbortSignal,
|
|
186
|
+
): Promise<ComputerLeaseSource> {
|
|
187
|
+
await this.prepareStorage()
|
|
188
|
+
const turn = currentTurn(agent.session.events)
|
|
189
|
+
if (turn === undefined) {
|
|
190
|
+
throw new ComputerUseError('COMPUTER_PERMISSION_REQUIRED', `${scope} access for ${app.name} must be requested inside an open Agent turn`)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (scope === 'control' && this.controlGrants.get(agent)?.get(app.bundleId) === turn) {
|
|
194
|
+
return 'approved'
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const stored = this.currentState(agent)
|
|
198
|
+
if (scope === 'read' && stored?.readGrants.includes(app.bundleId) === true) {
|
|
199
|
+
return 'approved'
|
|
200
|
+
}
|
|
201
|
+
if (stored?.denied.some(denial => denial.bundleId === app.bundleId && denial.scope === scope) === true) {
|
|
202
|
+
throw new ComputerUseError(
|
|
203
|
+
'COMPUTER_PERMISSION_REQUIRED',
|
|
204
|
+
`${scope} access for ${app.name} was rejected earlier in this Session; do not retry without new user instructions`,
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (approvalPolicy(this.ctx, agent) === 'never') {
|
|
209
|
+
throw new ComputerUseError(
|
|
210
|
+
'COMPUTER_PERMISSION_REQUIRED',
|
|
211
|
+
`${scope} access for ${app.name} is blocked because approval prompts are disabled in this Session (approval/policy: never, e.g. the danger-full-access preset); add "${app.bundleId}" to the computer-use grants in Settings, or switch the permission preset to one with approval ask`,
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (scope === 'read' && this.storage === undefined) {
|
|
216
|
+
throw this.storageRequired(app, scope, 'a Session-wide interactive read grant')
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const outcome = await this.ctx.approval.request({
|
|
220
|
+
agent,
|
|
221
|
+
toolName,
|
|
222
|
+
...(callId === undefined ? {} : { callId }),
|
|
223
|
+
reason: scope === 'read'
|
|
224
|
+
? `Allow this Agent to inspect the Accessibility state and requested screenshot of ${app.name} (${app.bundleId}) for this Session.`
|
|
225
|
+
: `Allow this Agent to send UI input to ${app.name} (${app.bundleId}) for the current turn.`,
|
|
226
|
+
signal,
|
|
227
|
+
})
|
|
228
|
+
if (outcome === 'cancelled') {
|
|
229
|
+
throw new ComputerUseError('COMPUTER_CANCELLED', `${scope} access request for ${app.name} was cancelled`)
|
|
230
|
+
}
|
|
231
|
+
if (outcome === 'rejected') {
|
|
232
|
+
if (this.storage === undefined) {
|
|
233
|
+
throw this.storageRequired(app, scope, 'the rejected interactive decision')
|
|
234
|
+
}
|
|
235
|
+
await this.persist(agent, app, { kind: 'denied', scope })
|
|
236
|
+
throw new ComputerUseError('COMPUTER_PERMISSION_REQUIRED', `${scope} access for ${app.name} was not granted (rejected); do not retry in this Session without new user instructions`)
|
|
237
|
+
}
|
|
238
|
+
if (outcome !== 'allowed-once') {
|
|
239
|
+
throw new ComputerUseError('COMPUTER_PERMISSION_REQUIRED', `${scope} access for ${app.name} was not granted (${outcome})`)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (scope === 'control') {
|
|
243
|
+
let grants = this.controlGrants.get(agent)
|
|
244
|
+
if (grants === undefined) {
|
|
245
|
+
grants = new Map()
|
|
246
|
+
this.controlGrants.set(agent, grants)
|
|
247
|
+
}
|
|
248
|
+
grants.set(app.bundleId, turn)
|
|
249
|
+
return 'approved'
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
await this.persist(agent, app, { kind: 'read-granted' })
|
|
253
|
+
return 'approved'
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private currentState(agent: Agent): ComputerUseSessionState | undefined {
|
|
257
|
+
const row = this.storage?.table.get(agent.session.id)
|
|
258
|
+
return row !== undefined && sameIdentity(row, agent.session.header) ? row : undefined
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private async prepareStorage(): Promise<StorageBinding | undefined> {
|
|
262
|
+
if (this.storage !== undefined) return this.storage
|
|
263
|
+
if (this.ctx.get('storageDomain') === undefined) return undefined
|
|
264
|
+
await this.storageFiber
|
|
265
|
+
return this.storage
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private async persist(
|
|
269
|
+
agent: Agent,
|
|
270
|
+
app: ComputerAppIdentity,
|
|
271
|
+
decision: { kind: 'read-granted' } | { kind: 'denied'; scope: 'read' | 'control' },
|
|
272
|
+
): Promise<void> {
|
|
273
|
+
const purpose = decision.kind === 'read-granted'
|
|
274
|
+
? 'the Session-wide read grant'
|
|
275
|
+
: `the Session-wide ${decision.scope} denial`
|
|
276
|
+
try {
|
|
277
|
+
const participated = await this.ctx.sessions.flush(agent.session)
|
|
278
|
+
if (!participated) {
|
|
279
|
+
throw new Error('no Session persistence listener participated in ctx.sessions.flush')
|
|
280
|
+
}
|
|
281
|
+
await this.enqueueMutation(agent.session.id, async () => {
|
|
282
|
+
const binding = this.storage
|
|
283
|
+
if (binding === undefined) throw this.storageRequired(app, decision.kind === 'read-granted' ? 'read' : decision.scope, purpose)
|
|
284
|
+
const current = binding.table.get(agent.session.id)
|
|
285
|
+
const row = current !== undefined && sameIdentity(current, agent.session.header)
|
|
286
|
+
? current
|
|
287
|
+
: stateSnapshot(agent.session.header, [], [])
|
|
288
|
+
const readGrants = new Set(row.readGrants)
|
|
289
|
+
const denied = [...row.denied]
|
|
290
|
+
if (decision.kind === 'read-granted') {
|
|
291
|
+
readGrants.add(app.bundleId)
|
|
292
|
+
} else if (!denied.some(item => item.bundleId === app.bundleId && item.scope === decision.scope)) {
|
|
293
|
+
denied.push({ bundleId: app.bundleId, scope: decision.scope })
|
|
294
|
+
}
|
|
295
|
+
await binding.table.put(agent.session.id, stateSnapshot(agent.session.header, readGrants, denied))
|
|
296
|
+
})
|
|
297
|
+
} catch (error) {
|
|
298
|
+
if (error instanceof ComputerUseError) throw error
|
|
299
|
+
throw new ComputerUseError(
|
|
300
|
+
'COMPUTER_PERMISSION_REQUIRED',
|
|
301
|
+
`${purpose} for ${app.name} could not be persisted after the approval audit: ${boundedFailure(error)}; configure working Session persistence and @deepseek-ai/dsh-storage-domain before retrying`,
|
|
302
|
+
{ cause: error },
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private storageRequired(
|
|
308
|
+
app: ComputerAppIdentity,
|
|
309
|
+
scope: 'read' | 'control',
|
|
310
|
+
purpose: string,
|
|
311
|
+
): ComputerUseError {
|
|
312
|
+
return new ComputerUseError(
|
|
313
|
+
'COMPUTER_PERMISSION_REQUIRED',
|
|
314
|
+
`${scope} access for ${app.name} requires ctx.storageDomain to persist ${purpose}; compose @deepseek-ai/dsh-storage-domain or add an exact static grant for "${app.bundleId}" before retrying`,
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private enqueueDecision<T>(sessionId: SessionId, operation: () => Promise<T>): Promise<T> {
|
|
319
|
+
const previous = this.decisionTails.get(sessionId) ?? Promise.resolve()
|
|
320
|
+
const result = previous.then(operation)
|
|
321
|
+
const tail = result.then(() => undefined, () => undefined)
|
|
322
|
+
this.decisionTails.set(sessionId, tail)
|
|
323
|
+
return result.finally(() => {
|
|
324
|
+
if (this.decisionTails.get(sessionId) === tail) this.decisionTails.delete(sessionId)
|
|
325
|
+
})
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private enqueueMutation<T>(sessionId: SessionId, operation: () => Promise<T>): Promise<T> {
|
|
329
|
+
const previous = this.mutationTails.get(sessionId) ?? Promise.resolve()
|
|
330
|
+
const result = previous.then(operation)
|
|
331
|
+
const tail = result.then(() => undefined, () => undefined)
|
|
332
|
+
this.mutationTails.set(sessionId, tail)
|
|
333
|
+
return result.finally(() => {
|
|
334
|
+
if (this.mutationTails.get(sessionId) === tail) this.mutationTails.delete(sessionId)
|
|
335
|
+
})
|
|
336
|
+
}
|
|
337
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/** macOS Accessibility/CoreGraphics/ScreenCaptureKit provider for `ctx.computerUse`. */
|
|
2
|
+
|
|
3
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
4
|
+
import { Service, type Context } from '@deepseek-ai/cordis'
|
|
5
|
+
import type {} from '@deepseek-ai/dsh-agent'
|
|
6
|
+
import type {} from '@deepseek-ai/dsh-settings'
|
|
7
|
+
import type {} from '@deepseek-ai/dsh-subprocess'
|
|
8
|
+
import type {} from '@deepseek-ai/dsh-user-approval'
|
|
9
|
+
import type {
|
|
10
|
+
BackendActionRequest,
|
|
11
|
+
BackendActionResult,
|
|
12
|
+
BackendCursorAction,
|
|
13
|
+
BackendHealth,
|
|
14
|
+
BackendObservation,
|
|
15
|
+
BackendObserveOptions,
|
|
16
|
+
ComputerUseBackend,
|
|
17
|
+
} from '../backend.ts'
|
|
18
|
+
import {
|
|
19
|
+
Config,
|
|
20
|
+
COMPUTER_USE_SETTINGS_NAMESPACE,
|
|
21
|
+
resolveConfig,
|
|
22
|
+
type ComputerUseConfig,
|
|
23
|
+
type ResolvedComputerUseConfig,
|
|
24
|
+
} from '../config.ts'
|
|
25
|
+
import { ComputerUseError } from '../errors.ts'
|
|
26
|
+
import { ComputerUseService } from '../service.ts'
|
|
27
|
+
import type { ComputerAppIdentity, ComputerAppSelector, ComputerAppSummary } from '../types.ts'
|
|
28
|
+
import { NativeHelperClient } from './native-helper.ts'
|
|
29
|
+
|
|
30
|
+
interface NativeHealth {
|
|
31
|
+
helperVersion: string
|
|
32
|
+
accessibility: BackendHealth['accessibility']
|
|
33
|
+
screenRecording: BackendHealth['screenRecording']
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface NativeObservation extends BackendObservation {}
|
|
37
|
+
|
|
38
|
+
/** Fixed-command native backend. */
|
|
39
|
+
class MacOSBackend implements ComputerUseBackend {
|
|
40
|
+
readonly name = 'macos-ax' as const
|
|
41
|
+
readonly client: NativeHelperClient
|
|
42
|
+
|
|
43
|
+
constructor(ctx: Context, private readonly config: ResolvedComputerUseConfig) {
|
|
44
|
+
this.client = new NativeHelperClient(ctx, config)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get helperPath(): string {
|
|
48
|
+
return this.client.helperPath
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async resolveApp(selector: ComputerAppSelector, signal: AbortSignal): Promise<ComputerAppIdentity> {
|
|
52
|
+
return await this.client.invoke<ComputerAppIdentity>({ command: 'resolve-app', selector }, signal)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async listApps(signal: AbortSignal): Promise<ComputerAppSummary[]> {
|
|
56
|
+
return await this.client.invoke<ComputerAppSummary[]>({ command: 'list-apps' }, signal)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async observe(app: ComputerAppIdentity, options: BackendObserveOptions, signal: AbortSignal): Promise<BackendObservation> {
|
|
60
|
+
return await this.client.invoke<NativeObservation>({
|
|
61
|
+
command: 'observe',
|
|
62
|
+
app,
|
|
63
|
+
options,
|
|
64
|
+
}, signal)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async act(request: BackendActionRequest, signal: AbortSignal): Promise<BackendActionResult> {
|
|
68
|
+
return await this.client.invoke<BackendActionResult>({
|
|
69
|
+
command: 'act',
|
|
70
|
+
request: {
|
|
71
|
+
...request,
|
|
72
|
+
actionTimeoutMs: this.config.actionTimeoutMs,
|
|
73
|
+
limits: {
|
|
74
|
+
maxNodes: this.config.maxNodes,
|
|
75
|
+
maxDepth: this.config.maxDepth,
|
|
76
|
+
maxTextBytes: this.config.maxTextBytes,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
}, signal)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async visualizeCursor(action: BackendCursorAction, phase: 'before' | 'after', signal: AbortSignal): Promise<void> {
|
|
83
|
+
if (this.config.interaction.cursorVisualization !== 'visible') return
|
|
84
|
+
const autoHideMs = this.config.interaction.cursorAutoHideMs
|
|
85
|
+
const move = async (point: { x: number; y: number }, durationMs: number): Promise<void> => {
|
|
86
|
+
await this.client.cursorCommand({
|
|
87
|
+
op: 'move',
|
|
88
|
+
x: point.x,
|
|
89
|
+
y: point.y,
|
|
90
|
+
durationMs,
|
|
91
|
+
autoHideMs,
|
|
92
|
+
targetPid: action.targetPid,
|
|
93
|
+
targetWindowNumber: action.targetWindowNumber,
|
|
94
|
+
targetWindowFrame: action.targetWindowFrame,
|
|
95
|
+
}, signal)
|
|
96
|
+
}
|
|
97
|
+
if (phase === 'after') {
|
|
98
|
+
if (action.kind === 'drag') await this.client.cursorCommand({
|
|
99
|
+
op: 'release',
|
|
100
|
+
autoHideMs,
|
|
101
|
+
targetPid: action.targetPid,
|
|
102
|
+
targetWindowNumber: action.targetWindowNumber,
|
|
103
|
+
targetWindowFrame: action.targetWindowFrame,
|
|
104
|
+
}, signal)
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
const start = action.kind === 'drag' ? action.from : action.to
|
|
108
|
+
if (start === undefined) return
|
|
109
|
+
await move(start, this.config.interaction.cursorMotionMs)
|
|
110
|
+
if (this.config.interaction.cursorMotionMs > 0) {
|
|
111
|
+
await delay(this.config.interaction.cursorMotionMs, undefined, { signal })
|
|
112
|
+
}
|
|
113
|
+
if (action.kind === 'scroll') return
|
|
114
|
+
await this.client.cursorCommand({
|
|
115
|
+
op: 'press',
|
|
116
|
+
autoHideMs,
|
|
117
|
+
targetPid: action.targetPid,
|
|
118
|
+
targetWindowNumber: action.targetWindowNumber,
|
|
119
|
+
targetWindowFrame: action.targetWindowFrame,
|
|
120
|
+
sustainedPress: action.kind === 'drag',
|
|
121
|
+
}, signal)
|
|
122
|
+
if (action.kind === 'drag') {
|
|
123
|
+
await move(action.to, Math.max(this.config.interaction.cursorMotionMs, 240))
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async dispose(): Promise<void> {
|
|
128
|
+
await this.client.dispose()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async health(signal: AbortSignal): Promise<BackendHealth> {
|
|
132
|
+
const prepared = await this.client.prepare(signal)
|
|
133
|
+
const health = await this.client.invoke<NativeHealth>({ command: 'health' }, signal)
|
|
134
|
+
return {
|
|
135
|
+
helperVersion: health.helperVersion || prepared.version,
|
|
136
|
+
helperSha256: prepared.sha256,
|
|
137
|
+
accessibility: health.accessibility,
|
|
138
|
+
screenRecording: health.screenRecording,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async openSettings(kind: 'accessibility' | 'screen-recording', signal: AbortSignal): Promise<void> {
|
|
143
|
+
await this.client.invoke<null>({ command: 'open-settings', kind }, signal)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Cordis Service provider loaded by the Bundle before the model-facing consumer. */
|
|
148
|
+
export class MacOSComputerUseProvider extends ComputerUseService {
|
|
149
|
+
static inject = ['subprocess', 'approval', 'settings', 'sessions', 'agents']
|
|
150
|
+
static Config = Config
|
|
151
|
+
|
|
152
|
+
private readonly settings
|
|
153
|
+
|
|
154
|
+
constructor(ctx: Context, config: ComputerUseConfig = {}) {
|
|
155
|
+
if (process.platform !== 'darwin') {
|
|
156
|
+
throw new ComputerUseError('COMPUTER_UNSUPPORTED_PLATFORM', `dsh-computer-use 0.1.0 supports macOS only; current platform is ${process.platform}`)
|
|
157
|
+
}
|
|
158
|
+
const settings = ctx.settings.register(COMPUTER_USE_SETTINGS_NAMESPACE, Config, {
|
|
159
|
+
base: config,
|
|
160
|
+
applies: 'live',
|
|
161
|
+
validate: (value) => { resolveConfig(value) },
|
|
162
|
+
})
|
|
163
|
+
const resolved = resolveConfig(settings.get())
|
|
164
|
+
super(ctx, new MacOSBackend(ctx, resolved), resolved)
|
|
165
|
+
this.settings = settings
|
|
166
|
+
ctx.effect(() => this.settings.watch(async (next) => {
|
|
167
|
+
const candidate = resolveConfig(next)
|
|
168
|
+
const backend = new MacOSBackend(ctx, candidate)
|
|
169
|
+
try {
|
|
170
|
+
await this.reconfigure(backend, candidate)
|
|
171
|
+
} catch (error) {
|
|
172
|
+
await backend.dispose()
|
|
173
|
+
throw error
|
|
174
|
+
}
|
|
175
|
+
}), 'dsh-computer-use: Settings watch')
|
|
176
|
+
ctx.effect(() => ctx.on('agent/disposed', ({ agent }) => { this.releaseAgent(agent) }), 'dsh-computer-use: Agent cleanup')
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Verify helper integrity and permissions before the service is injectable. */
|
|
180
|
+
protected async [Service.init](): Promise<void> {
|
|
181
|
+
await this.initialize()
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export default MacOSComputerUseProvider
|