openworkgraph 0.2.13 → 0.3.1

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.
Files changed (40) hide show
  1. package/dist/api.d.ts +1 -0
  2. package/dist/api.js +6 -0
  3. package/dist/backend/adapter.d.ts +1 -0
  4. package/dist/backend/adapter.js +161 -8
  5. package/dist/backend/stdio.d.ts +1 -1
  6. package/dist/backend/stdio.js +2 -2
  7. package/dist/backend/types.d.ts +12 -0
  8. package/dist/builtin-skills.d.ts +56 -0
  9. package/dist/builtin-skills.js +514 -0
  10. package/dist/graph-transfer.js +7 -2
  11. package/dist/inputs.d.ts +1 -0
  12. package/dist/inputs.js +1 -1
  13. package/dist/operations/daemon.js +8 -2
  14. package/dist/operations/update.d.ts +2 -0
  15. package/dist/operations/update.js +30 -11
  16. package/dist/plugins.d.ts +4 -0
  17. package/dist/plugins.js +33 -1
  18. package/dist/runtime-backend.d.ts +3 -1
  19. package/dist/runtime-backend.js +11 -2
  20. package/dist/runtime.d.ts +7 -0
  21. package/dist/runtime.js +17 -2
  22. package/dist/service.d.ts +1 -0
  23. package/dist/service.js +28 -10
  24. package/dist/skill-configs.d.ts +43 -0
  25. package/dist/skill-configs.js +554 -0
  26. package/dist/skill-schema.d.ts +10 -0
  27. package/dist/skill-schema.js +144 -0
  28. package/dist/skill-sessions.d.ts +24 -0
  29. package/dist/skill-sessions.js +165 -0
  30. package/dist/skill-source.d.ts +37 -0
  31. package/dist/skill-source.js +104 -0
  32. package/dist/skills-api.d.ts +8 -4
  33. package/dist/skills-api.js +48 -27
  34. package/dist/skills-management-api.d.ts +12 -0
  35. package/dist/skills-management-api.js +139 -0
  36. package/dist/vendor/protocol/index.d.ts +4 -2
  37. package/dist/vendor/protocol/index.js +2 -1
  38. package/dist/vendor/protocol/skills.d.ts +107 -0
  39. package/dist/vendor/protocol/skills.js +33 -0
  40. package/package.json +2 -2
@@ -4,20 +4,39 @@ import { join, relative, isAbsolute, sep } from 'node:path';
4
4
  import { SERVICE_VERSION } from "../vendor/protocol/index.js";
5
5
  import { readInstance, requestInstance, startBackground, waitUntilStopped } from './lifecycle.js';
6
6
  const PACKAGE_NAME = 'openworkgraph';
7
+ async function npmPath(args, spawnProcess) {
8
+ const invocation = process.platform === 'win32'
9
+ ? { command: process.env.ComSpec ?? process.env.COMSPEC ?? 'cmd.exe', args: ['/d', '/s', '/c', 'npm ' + args.join(' ')] }
10
+ : { command: 'npm', args };
11
+ return new Promise((resolve, reject) => {
12
+ const child = spawnProcess(invocation.command, invocation.args, { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 });
13
+ let output = '';
14
+ child.stdout?.on('data', chunk => { if (output.length < 8192)
15
+ output += chunk.toString('utf8'); });
16
+ child.once('error', reject);
17
+ child.once('close', code => code === 0 ? resolve(output.trim()) : reject(new Error('npm path query failed')));
18
+ });
19
+ }
20
+ /** Recognize the package actually running from npm exec/npx's cache, including custom cache roots. */
21
+ export async function isNpxInstallation(cliPath, spawnProcess = spawn) {
22
+ try {
23
+ const cache = await npmPath(['config', 'get', 'cache'], spawnProcess);
24
+ if (!cache)
25
+ return false;
26
+ const npxRoot = await realpath(join(cache, '_npx'));
27
+ const runningPath = await realpath(cliPath);
28
+ const inside = relative(npxRoot, runningPath).split(sep);
29
+ return inside.length === 5 && inside[0] !== '..' && inside[0] !== '' && !isAbsolute(inside[0])
30
+ && inside[1] === 'node_modules' && inside[2] === PACKAGE_NAME && inside[3] === 'dist' && inside[4] === 'cli.js';
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
7
36
  /** Compare the running package against npm's actual global package location. */
8
37
  export async function isGlobalNpmInstallation(cliPath, spawnProcess = spawn) {
9
- const invocation = process.platform === 'win32'
10
- ? { command: process.env.ComSpec ?? process.env.COMSPEC ?? 'cmd.exe', args: ['/d', '/s', '/c', 'npm root -g'] }
11
- : { command: 'npm', args: ['root', '-g'] };
12
38
  try {
13
- const root = await new Promise((resolve, reject) => {
14
- const child = spawnProcess(invocation.command, invocation.args, { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 });
15
- let output = '';
16
- child.stdout?.on('data', chunk => { if (output.length < 8192)
17
- output += chunk.toString('utf8'); });
18
- child.once('error', reject);
19
- child.once('close', code => code === 0 ? resolve(output.trim()) : reject(new Error('npm root failed')));
20
- });
39
+ const root = await npmPath(['root', '-g'], spawnProcess);
21
40
  if (!root)
22
41
  return false;
23
42
  const installedPath = join(root, PACKAGE_NAME);
package/dist/plugins.d.ts CHANGED
@@ -34,6 +34,10 @@ export interface PluginContract {
34
34
  /** Bounded pure JSON only: no getters, functions, cycles or exotic prototypes. */
35
35
  export declare function checkedJson(value: unknown, maxBytes?: number, maxDepth?: number): Json;
36
36
  export declare function readField(content: Json, field: string): Json | undefined;
37
+ /** Validate explicit references against the exact prompt occurrence, including boundaries. */
38
+ export declare function validSkillContent(content: Json): boolean;
39
+ /** Keep explicit Codex provenance while removing host paths; re-selection is required. */
40
+ export declare function portableSkillContent(content: Json): Json;
37
41
  export interface PluginInformation {
38
42
  state: 'available' | 'missing' | 'incompatible' | 'invalid';
39
43
  typeId: string;
package/dist/plugins.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isSkillName } from "./vendor/protocol/index.js";
1
2
  import { ServiceError } from './errors.js';
2
3
  const forbidden = new Set(['__proto__', 'prototype', 'constructor']);
3
4
  const builtins = new Set(['text', 'image', 'document', 'video', 'file', 'preview', 'execution', 'group']);
@@ -154,6 +155,37 @@ function validateContract(input) {
154
155
  }
155
156
  return c;
156
157
  }
158
+ /** Validate explicit references against the exact prompt occurrence, including boundaries. */
159
+ export function validSkillContent(content) {
160
+ if (!object(content) || content.skillReferences === undefined)
161
+ return true;
162
+ const refs = content.skillReferences;
163
+ if (!Array.isArray(refs) || refs.length > 1024 || typeof content.prompt !== 'string')
164
+ return false;
165
+ const prompt = content.prompt;
166
+ let end = 0;
167
+ for (const value of refs) {
168
+ if (!object(value) || Object.keys(value).some(key => !['skillId', 'source', 'name', 'start', 'end'].includes(key)))
169
+ return false;
170
+ const ref = value;
171
+ if (typeof ref.skillId !== 'string' || !ref.skillId.trim() || ref.skillId.length > 4096 || /[\x00-\x1f]/.test(ref.skillId) ||
172
+ !['openworkgraph', 'codex'].includes(ref.source) || !isSkillName(ref.name) ||
173
+ !Number.isSafeInteger(ref.start) || !Number.isSafeInteger(ref.end) || ref.start < end || ref.end > prompt.length ||
174
+ prompt.slice(ref.start, ref.end) !== '$' + ref.name ||
175
+ (ref.start > 0 && !/\s/.test(prompt[ref.start - 1])) || (ref.end < prompt.length && !/[\s.,!?;,。!?;]/.test(prompt[ref.end])))
176
+ return false;
177
+ end = ref.end;
178
+ }
179
+ return true;
180
+ }
181
+ /** Keep explicit Codex provenance while removing host paths; re-selection is required. */
182
+ export function portableSkillContent(content) {
183
+ if (!object(content) || content.skillReferences === undefined)
184
+ return content;
185
+ if (!validSkillContent(content))
186
+ invalid('Invalid skill references');
187
+ return { ...content, skillReferences: content.skillReferences.map(ref => object(ref) && ref.source === 'codex' ? { ...ref, skillId: 'codex-unresolved:' + ref.name } : ref) };
188
+ }
157
189
  export class PluginRegistry {
158
190
  db;
159
191
  constructor(db) {
@@ -209,7 +241,7 @@ export class PluginRegistry {
209
241
  }
210
242
  if (!contract)
211
243
  return { state: 'missing', typeId: type, schemaVersion, content, reason: 'Resource contract is unavailable', contract: null };
212
- const valid = matches(contract.contentSchema, content);
244
+ const valid = matches(contract.contentSchema, content) && (!builtins.has(type) || validSkillContent(content));
213
245
  return { state: valid ? 'available' : 'invalid', typeId: type, schemaVersion, content, reason: valid ? null : 'Content does not match schema', contract };
214
246
  }
215
247
  /** Pure copy-on-success migration. Caller persists new node version atomically; old version is never changed. */
@@ -5,6 +5,7 @@ import { Runs } from './runs.js';
5
5
  import { ProjectProbe } from './project-probe.js';
6
6
  import { type DataDirectories } from './directories.js';
7
7
  import { RunContexts } from './run-context.js';
8
+ import type { SkillSessions } from './skill-sessions.js';
8
9
  export declare function backendReply(interaction: Interaction, answer: Json): BackendReply;
9
10
  /** Trusted host bridge. Never accepts browser paths or creates isolation evidence. */
10
11
  export declare class RuntimeBackend implements SchedulerBackend {
@@ -12,9 +13,10 @@ export declare class RuntimeBackend implements SchedulerBackend {
12
13
  readonly dirs: DataDirectories;
13
14
  readonly adapter: BackendAdapter;
14
15
  readonly probe: ProjectProbe;
16
+ readonly skillSessions?: SkillSessions | undefined;
15
17
  private readonly contexts;
16
18
  readonly runContexts: RunContexts;
17
- constructor(runs: Runs, dirs: DataDirectories, adapter: BackendAdapter, probe?: ProjectProbe);
19
+ constructor(runs: Runs, dirs: DataDirectories, adapter: BackendAdapter, probe?: ProjectProbe, skillSessions?: SkillSessions | undefined);
18
20
  private snapshot;
19
21
  start(run: Run, snapshot: InputSnapshot, context: BackendContext): Promise<void>;
20
22
  queryHistory(runId: string, query: string): string;
@@ -29,13 +29,15 @@ export class RuntimeBackend {
29
29
  dirs;
30
30
  adapter;
31
31
  probe;
32
+ skillSessions;
32
33
  contexts = new Map();
33
34
  runContexts;
34
- constructor(runs, dirs, adapter, probe = new ProjectProbe()) {
35
+ constructor(runs, dirs, adapter, probe = new ProjectProbe(), skillSessions) {
35
36
  this.runs = runs;
36
37
  this.dirs = dirs;
37
38
  this.adapter = adapter;
38
39
  this.probe = probe;
40
+ this.skillSessions = skillSessions;
39
41
  this.runContexts = new RunContexts(runs, dirs);
40
42
  }
41
43
  snapshot(snapshot, context, expectedRunId) {
@@ -83,6 +85,11 @@ export class RuntimeBackend {
83
85
  let inputFiles;
84
86
  let upstreamContext;
85
87
  try {
88
+ if (snapshot.skills?.length) {
89
+ if (!this.skillSessions)
90
+ throw new ServiceError('INPUT_BLOCKED', '冻结技能上下文不可用。');
91
+ await this.skillSessions.environment(snapshot.skills, true);
92
+ }
86
93
  if (runtime.details.kind === 'execution')
87
94
  upstreamContext = this.runContexts.lineage(run.id);
88
95
  const inspect = await this.probe.inspect([path]);
@@ -144,7 +151,7 @@ export class RuntimeBackend {
144
151
  context.emit({ type: 'stopped' });
145
152
  return;
146
153
  }
147
- const result = await this.adapter.start({ runId: run.id, kind: runtime.details.kind, projectPath: path, inputPath: directories.input, outputPath: directories.output, serviceRoot: this.dirs.root, prompt: snapshot.prompt, resources: snapshot.resources, projectFiles: snapshot.projectFiles ?? [], files: inputFiles, model: snapshot.model, ...(upstreamContext ? { upstreamContext } : {}), ...(snapshot.imageRoute?.type === 'codex' && snapshot.imageRoute.options ? { imageOptions: snapshot.imageRoute.options } : {}), sandboxMode: sandboxMode(runtime.details.sandboxMode ?? 'read-only') }, {
154
+ const result = await this.adapter.start({ runId: run.id, kind: runtime.details.kind, projectPath: path, inputPath: directories.input, outputPath: directories.output, serviceRoot: this.dirs.root, prompt: snapshot.prompt, resources: snapshot.resources, projectFiles: snapshot.projectFiles ?? [], files: inputFiles, model: snapshot.model, skills: snapshot.skills ?? [], ...(upstreamContext ? { upstreamContext } : {}), ...(snapshot.imageRoute?.type === 'codex' && snapshot.imageRoute.options ? { imageOptions: snapshot.imageRoute.options } : {}), sandboxMode: sandboxMode(runtime.details.sandboxMode ?? 'read-only') }, {
148
155
  onSnapshot: value => this.snapshot(value, context, run.id),
149
156
  onInteraction: value => {
150
157
  if (!this.runs.db.prepare('SELECT 1 FROM runs WHERE id=?').get(run.id))
@@ -160,6 +167,8 @@ export class RuntimeBackend {
160
167
  },
161
168
  queryHistory: async (query) => this.queryHistory(run.id, query),
162
169
  readRunContext: async (request) => this.runContexts.read(run.id, request),
170
+ ...(this.skillSessions ? { skillEnvironment: async (skillId) => this.skillSessions.forSkill(snapshot.skills ?? [], skillId) } : {}),
171
+ ...(this.skillSessions ? { withSkillEnvironment: (skillId, dispatch) => this.skillSessions.withEnvironment(snapshot.skills ?? [], skillId, dispatch) } : {}),
163
172
  });
164
173
  this.snapshot(result, context, run.id);
165
174
  }
package/dist/runtime.d.ts CHANGED
@@ -17,12 +17,16 @@ import type { DataDirectories } from './directories.js';
17
17
  import { ExecutionSettingsStore } from './execution-settings.js';
18
18
  import { ImageProviders } from './image-providers.js';
19
19
  import { ImageCredentials } from './image-credentials.js';
20
+ import { BuiltinSkills, type BuiltinSkillsOptions } from './builtin-skills.js';
21
+ import { SkillConfigs } from './skill-configs.js';
22
+ import { SkillSessions } from './skill-sessions.js';
20
23
  export interface WorkflowRuntimeOptions {
21
24
  backend?: SchedulerBackend;
22
25
  models?: BackendModel[];
23
26
  /** Default: discover on model reads/submission. True also probes at startup; false disables execution and discovery. */
24
27
  enableBackend?: boolean;
25
28
  backendOptions?: AdapterOptions;
29
+ skillsOptions?: BuiltinSkillsOptions;
26
30
  }
27
31
  export declare class WorkflowRuntime {
28
32
  readonly db: DatabaseSync;
@@ -44,6 +48,9 @@ export declare class WorkflowRuntime {
44
48
  readonly executionSettings: ExecutionSettingsStore;
45
49
  readonly imageProviders: ImageProviders;
46
50
  readonly imageCredentials: ImageCredentials;
51
+ readonly skills: BuiltinSkills;
52
+ readonly skillConfigs: SkillConfigs;
53
+ readonly skillSessions: SkillSessions;
47
54
  private readonly probe;
48
55
  private readonly adapter;
49
56
  private availableModels;
package/dist/runtime.js CHANGED
@@ -29,6 +29,9 @@ import { ImageProviders } from './image-providers.js';
29
29
  import { ImageCredentials } from './image-credentials.js';
30
30
  import { ApiImageAgent, RoutedImageBackend } from './api-image-agent.js';
31
31
  import { planOpenAiImage, discoverOpenAiModels } from './openai-image-driver.js';
32
+ import { BuiltinSkills } from './builtin-skills.js';
33
+ import { SkillConfigs } from './skill-configs.js';
34
+ import { SkillSessions } from './skill-sessions.js';
32
35
  class RuntimeRuns extends Runs {
33
36
  available;
34
37
  constructor(db, serviceId, dependencies, available) {
@@ -62,6 +65,9 @@ export class WorkflowRuntime {
62
65
  executionSettings;
63
66
  imageProviders;
64
67
  imageCredentials;
68
+ skills;
69
+ skillConfigs;
70
+ skillSessions;
65
71
  probe = new ProjectProbe();
66
72
  adapter;
67
73
  availableModels;
@@ -88,6 +94,9 @@ export class WorkflowRuntime {
88
94
  this.executionSettings = new ExecutionSettingsStore(db);
89
95
  this.imageCredentials = new ImageCredentials(directories, serviceId);
90
96
  this.imageProviders = new ImageProviders(db, this.imageCredentials);
97
+ this.skills = new BuiltinSkills(directories, serviceId, options.skillsOptions);
98
+ this.skillConfigs = new SkillConfigs(directories, serviceId);
99
+ this.skillSessions = new SkillSessions(this.skills, this.skillConfigs, options.backendOptions);
91
100
  this.inputs = new InputPreparation(db, { plugins: this.plugins, readRepresentation: resource => this.resources.readPreparedRepresentation(resource) });
92
101
  this.models = new ModelDefaultsStore(db);
93
102
  this.runs = new RuntimeRuns(db, serviceId, { freeze: () => { throw new ServiceError('INPUT_BLOCKED', 'Use prepareSubmission outside the auth transaction'); }, ...(!options.backend ? { validateReply: (interaction, answer) => { backendReply(interaction, answer); } } : {}) }, (_run, snapshot) => snapshot.imageRoute?.type === 'api'
@@ -97,7 +106,7 @@ export class WorkflowRuntime {
97
106
  this.publication = new Publication(db, directories, this.resources, this.graphs, this.runs);
98
107
  this.executionChains = new ExecutionChains(this.runs, this.graphs, this.resources);
99
108
  this.adapter = options.backend ? undefined : new CodexBackendAdapter(options.backendOptions);
100
- const codexBackend = options.backend ?? new RuntimeBackend(this.runs, directories, this.adapter, this.probe);
109
+ const codexBackend = options.backend ?? new RuntimeBackend(this.runs, directories, this.adapter, this.probe, this.skillSessions);
101
110
  const backend = new RoutedImageBackend(codexBackend, new ApiImageAgent(this.runs, this.resources, this.imageProviders, this.imageCredentials, directories), this.runs);
102
111
  this.scheduler = new Scheduler(this.runs, backend, { publish: (run, token) => this.publication.publish(run, token), acquireProjectLock: (_run, path) => acquireProjectLock(path) });
103
112
  }
@@ -320,6 +329,11 @@ export class WorkflowRuntime {
320
329
  }
321
330
  const scopedInputs = new InputPreparation(this.db, { plugins: this.plugins, readRepresentation: resource => prepared.get(resourceKey(resource)) ?? null });
322
331
  const scopedPreview = scopedInputs.inputPreview(request.graphId, request.nodeId, !!apiRoute);
332
+ const targetContent = this.db.prepare('SELECT v.content FROM nodes n JOIN node_versions v ON v.node_id=n.id AND v.version=n.current_version WHERE n.id=? AND n.graph_id=?').get(request.nodeId, request.graphId);
333
+ const skillReferences = targetContent ? JSON.parse(String(targetContent.content)).skillReferences : undefined;
334
+ if (apiRoute && Array.isArray(skillReferences) && skillReferences.length)
335
+ throw new ServiceError('INPUT_BLOCKED', '独立图片 API 不支持技能引用,请切换到 Codex 或移除技能引用。');
336
+ const frozenSkills = apiRoute ? [] : await this.skillSessions.freeze(scopedPreview.prompt, skillReferences, path);
323
337
  if (apiRoute) {
324
338
  const references = [];
325
339
  for (const envelope of scopedPreview.resources) {
@@ -357,7 +371,7 @@ export class WorkflowRuntime {
357
371
  },
358
372
  freeze: () => scopedInputs.build(request.graphId, request.nodeId, apiRoute
359
373
  ? { expectedExecutionRevision: request.expectedExecutionRevision, imageRoute: apiRoute }
360
- : { expectedExecutionRevision: request.expectedExecutionRevision, model: this.models.freeze(request.modelOverride, this.availableModels), ...(request.kind === 'image_generation' ? { imageRoute: request.imageRoute?.type === 'codex' ? request.imageRoute : { type: 'codex' } } : {}) }),
374
+ : { expectedExecutionRevision: request.expectedExecutionRevision, model: this.models.freeze(request.modelOverride, this.availableModels), skills: frozenSkills, ...(request.kind === 'image_generation' ? { imageRoute: request.imageRoute?.type === 'codex' ? request.imageRoute : { type: 'codex' } } : {}) }),
361
375
  });
362
376
  return principal => scoped.submit(request, principal, request.kind === 'execution' && holdExecution);
363
377
  }
@@ -380,6 +394,7 @@ export class WorkflowRuntime {
380
394
  else {
381
395
  this.requireAvailable({ kind: token.details.kind });
382
396
  await this.refreshModels();
397
+ await this.skillSessions.environment(snapshot.skills ?? [], true);
383
398
  }
384
399
  const project = this.db.prepare('SELECT state,canonical_path FROM projects WHERE id=?').get(run.projectId);
385
400
  if (!project || project.state !== 'active')
package/dist/service.d.ts CHANGED
@@ -11,6 +11,7 @@ export declare function createService(dataDir?: string, options?: {
11
11
  projectProbe?: ProjectProbe;
12
12
  runtime?: WorkflowRuntimeOptions;
13
13
  npmInstallation?: () => Promise<boolean>;
14
+ npxInstallation?: () => Promise<boolean>;
14
15
  latestNpmVersion?: () => Promise<string>;
15
16
  updateRuntime?: (onExit: () => void) => Promise<void>;
16
17
  }): {
package/dist/service.js CHANGED
@@ -58,6 +58,7 @@ export function createService(dataDir, options = {}) {
58
58
  const events = new Events(db, serviceId, auth);
59
59
  const instanceId = randomUUID();
60
60
  const npmInstallation = options.npmInstallation ?? (async () => false);
61
+ const npxInstallation = options.npxInstallation ?? (async () => false);
61
62
  let updateQueued = false;
62
63
  const unavailable = {
63
64
  status: "unavailable",
@@ -136,14 +137,15 @@ export function createService(dataDir, options = {}) {
136
137
  const rawPath = request.url ?? "";
137
138
  const fileRoute = /^[/]v1[/]projects[/][a-zA-Z0-9_-]+[/]files(?:[/](?:search|content|link-content|media|thumbnail|stat))?(?:[?]|$)/.test(rawPath);
138
139
  const skillsRoute = /^[/]v1[/]projects[/][a-zA-Z0-9_-]+[/]skills[/]search(?:[?]|$)/.test(rawPath);
140
+ const skillsManagementRoute = /^[/]v1[/]skills(?:[/][^/?#]+(?:[/](?:install|update|uninstall|config|files))?)?(?:[?]|$)/.test(rawPath);
139
141
  const mediaRoute = /^[/]v1[/]projects[/][a-zA-Z0-9_-]+[/](?:assets[/][a-zA-Z0-9_-]+[/]versions[/][1-9][0-9]*|graphs[/][a-zA-Z0-9_-]+[/]resources[/][a-zA-Z0-9_-]+[/]versions[/][1-9][0-9]*)[/](?:thumbnail|preview|content)(?:[?]|$)/.test(rawPath);
140
- const queryRoute = fileRoute || skillsRoute || mediaRoute;
142
+ const queryRoute = fileRoute || skillsRoute || skillsManagementRoute || mediaRoute;
141
143
  const path = queryRoute ? rawPath.split('?')[0] : rawPath;
142
144
  if (!path.startsWith("/") ||
143
145
  path.startsWith("//") ||
144
146
  (!queryRoute && path.includes("?")) ||
145
147
  path.includes("#") ||
146
- path.includes("%") ||
148
+ (!skillsManagementRoute && path.includes("%")) ||
147
149
  path.includes("\\"))
148
150
  throw new ServiceError("INVALID_REQUEST", "请使用标准 API 路径;凭据只能放在 Authorization 请求头,不能放入 URL。");
149
151
  if (queryRoute && (rawPath.includes('#') || rawPath.length > 8192 || rawPath.split('?').length > 2))
@@ -170,11 +172,12 @@ export function createService(dataDir, options = {}) {
170
172
  .split(",")
171
173
  .map((value) => value.trim())
172
174
  .filter(Boolean);
175
+ const allowedMethods = /^[/]v1[/]skills[/][^/]+[/]config$/.test(path) ? ["GET", "POST", "PUT", "DELETE"] : ["GET", "POST", "DELETE"];
173
176
  if (typeof method !== "string" ||
174
- !["GET", "POST", "DELETE"].includes(method) ||
177
+ !allowedMethods.includes(method) ||
175
178
  headers.some((header) => !CORS_HEADERS.includes(header)))
176
179
  throw new ServiceError("ORIGIN_DENIED", "不允许的跨来源方法或请求头。");
177
- response.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE");
180
+ response.setHeader("Access-Control-Allow-Methods", allowedMethods.join(", "));
178
181
  response.setHeader("Access-Control-Allow-Headers", CORS_HEADERS.join(", "));
179
182
  response.setHeader("Access-Control-Max-Age", "300");
180
183
  response.writeHead(204);
@@ -200,7 +203,7 @@ export function createService(dataDir, options = {}) {
200
203
  const runtimeInfo = runtime.serviceInfo;
201
204
  send({
202
205
  ...info,
203
- installation: await npmInstallation() ? 'npm-global' : 'other',
206
+ installation: await npmInstallation() ? 'npm-global' : await npxInstallation() ? 'npx' : 'other',
204
207
  imageRoutes: runtimeInfo.imageRoutes,
205
208
  capabilities: {
206
209
  ...info.capabilities,
@@ -288,7 +291,7 @@ export function createService(dataDir, options = {}) {
288
291
  events.stream(request, response, token, origin);
289
292
  return;
290
293
  }
291
- const business = await api.handle(request, fileRoute || skillsRoute ? rawPath : path, token, origin);
294
+ const business = await api.handle(request, fileRoute || skillsRoute || skillsManagementRoute ? rawPath : path, token, origin);
292
295
  if (business.handled) {
293
296
  const body = auth.withSession(token, origin, (current) => business.binary ?? (business.binaryParts ? undefined : events.decorate(business.body, current.id)));
294
297
  if (business.status)
@@ -468,13 +471,29 @@ export function createService(dataDir, options = {}) {
468
471
  db.prepare("INSERT INTO settings(key,value) VALUES('listener',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(JSON.stringify(listenerInfo(address, serviceId, instanceId)));
469
472
  });
470
473
  let closing;
474
+ async function closeHttpServer() {
475
+ if (!server.listening)
476
+ return;
477
+ await new Promise((resolve, reject) => {
478
+ // Runs and event streams have already drained. A browser's unfinished
479
+ // request must not hold the service's lifetime locks indefinitely.
480
+ const timeout = setTimeout(() => server.closeAllConnections(), 1000);
481
+ timeout.unref();
482
+ server.close(error => {
483
+ clearTimeout(timeout);
484
+ if (error)
485
+ reject(error);
486
+ else
487
+ resolve();
488
+ });
489
+ });
490
+ }
471
491
  async function abortStartup() {
472
492
  runtime.disposeUnstarted();
473
493
  projectProbe.close();
474
494
  events.close();
475
495
  try {
476
- if (server.listening)
477
- await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
496
+ await closeHttpServer();
478
497
  }
479
498
  finally {
480
499
  db.close();
@@ -487,8 +506,7 @@ export function createService(dataDir, options = {}) {
487
506
  projectProbe.close();
488
507
  events.close();
489
508
  try {
490
- if (server.listening)
491
- await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
509
+ await closeHttpServer();
492
510
  db.prepare("DELETE FROM settings WHERE key='listener' AND json_extract(value,'$.instanceId')=?").run(instanceId);
493
511
  }
494
512
  finally {
@@ -0,0 +1,43 @@
1
+ import type { SkillConfigSchema, SkillConfiguration, SkillConfigurationWrite } from "./vendor/protocol/index.js";
2
+ import type { DataDirectories } from './directories.js';
3
+ /** Omit schema to validate an automatic skill without loading its env/defaults. */
4
+ export interface SkillConfigRevisionRequest {
5
+ skillId: string;
6
+ revision: number;
7
+ schema?: SkillConfigSchema;
8
+ }
9
+ /** Workspace-private, immutable revisions. Uninstall deliberately does not call clear. */
10
+ export declare class SkillConfigs {
11
+ private readonly directories;
12
+ private readonly serviceId;
13
+ private sid;
14
+ constructor(directories: DataDirectories, serviceId: string);
15
+ private windowsSid;
16
+ private windowsAcl;
17
+ private directory;
18
+ private locked;
19
+ private claim;
20
+ private owner;
21
+ private dead;
22
+ private recover;
23
+ private load;
24
+ private manifest;
25
+ private removeRevoked;
26
+ private snapshot;
27
+ private atomic;
28
+ private effective;
29
+ private view;
30
+ read(skillId: string, schema: SkillConfigSchema): Promise<SkillConfiguration>;
31
+ write(skillId: string, schema: SkillConfigSchema, body: SkillConfigurationWrite): Promise<SkillConfiguration>;
32
+ clear(skillId: string, expectedRevision: number): Promise<SkillConfiguration>;
33
+ revision(skillId: string): Promise<number>;
34
+ hasRevision(skillId: string, revision: number): Promise<boolean>;
35
+ environment(skillId: string, schema: SkillConfigSchema, revision: number): Promise<Record<string, string>>;
36
+ private snapshotEnvironment;
37
+ /** Deterministic multi-skill locking prevents clear between the final revision
38
+ * check, secret read, and synchronous spawn/request. No nested public store
39
+ * methods may be awaited inside dispatch. Never await a returned command. */
40
+ withRevisions<T>(requests: readonly SkillConfigRevisionRequest[], dispatch: (environments: ReadonlyMap<string, Record<string, string>>) => T): Promise<{
41
+ value: T;
42
+ }>;
43
+ }