fix(sandbox): support real development workspaces
- raise finite development defaults/ceilings and queue capacities - migrate untouched v1 default policy - exclude generated output and shipped runtime paths from snapshots - report snapshot limit errors with the offending path
This commit is contained in:
1 parent
c7d6c558e6
commit
b2d37472f0
9 files changed
+115
-28
No files matched your search
@@ -18,7 +18,7 @@ export async function authorizeSandboxWrite(policy: SandboxEffectivePolicy, desc
|
||||
assertActive(context.signal)
|
||||
await assertPolicyCurrent(policy)
|
||||
if (writebackRequired && policy.writeback === 'deny') throw new Error('沙盒策略禁止写回工作区')
|
||||
if (pending >= 64) throw new Error('沙盒待审批请求过多')
|
||||
if (pending >= 128) throw new Error('沙盒待审批请求过多')
|
||||
const expiresAt = Date.now() + duration
|
||||
const started = performance.now()
|
||||
const expired = (): boolean => Date.now() >= expiresAt || performance.now() - started >= duration
|
||||
|
||||
@@ -3,7 +3,7 @@ import { assertActive } from './policy'
|
||||
|
||||
interface Waiting { workspace: string; start(): void; cancel(): void }
|
||||
const queue: Waiting[] = [], busy = new Set<string>()
|
||||
const maximumActive = 4, maximumQueued = 32
|
||||
const maximumActive = 4, maximumQueued = 64
|
||||
function drain(): void {
|
||||
for (let index = 0; index < queue.length && busy.size < maximumActive;) {
|
||||
const item = queue[index]
|
||||
@@ -15,7 +15,7 @@ function drain(): void {
|
||||
export async function withSandboxExecution<T>(root: string, signal: AbortSignal | undefined, operation: (canonicalRoot: string) => Promise<T>): Promise<T> {
|
||||
assertActive(signal)
|
||||
const real = await fs.realpath(root), workspace = process.platform === 'win32' ? real.toLowerCase() : real
|
||||
if (queue.length >= maximumQueued || queue.filter(item => item.workspace === workspace).length >= 8) throw new Error('沙盒执行队列已满,请稍后重试')
|
||||
if (queue.length >= maximumQueued || queue.filter(item => item.workspace === workspace).length >= 12) throw new Error('沙盒执行队列已满,请稍后重试')
|
||||
return new Promise<T>((done, reject) => {
|
||||
const item: Waiting = {
|
||||
workspace,
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { SandboxEffectivePolicy } from '@shared/sandbox'
|
||||
import { sandboxConfiguration } from './configuration'
|
||||
import { assertActive, within } from './policy'
|
||||
import { assertPolicyCurrent, sandboxPathDenied } from './policy-store'
|
||||
import { SANDBOX_EXCLUDED_DIRECTORIES, SANDBOX_EXCLUDED_ROOT_PATHS } from './workspace-files'
|
||||
|
||||
export class SandboxReadError extends Error {
|
||||
constructor(readonly code: string, message: string) { super(message) }
|
||||
@@ -13,7 +14,13 @@ export interface NativeReadResult {
|
||||
data?: string; entries?: Array<{ name: string; kind: 'file' | 'directory' | 'link' }>
|
||||
}
|
||||
const identityPattern = /^[a-f0-9]{16}:[a-f0-9]{32}$/
|
||||
const excluded = new Set(['.tmp', 'node_modules', '.venv', 'venv', '__pycache__'])
|
||||
function excluded(path: string): boolean {
|
||||
const normalized = path.replaceAll('\\', '/').toLowerCase(),
|
||||
parts = normalized.split('/'),
|
||||
inExcludedDirectory = parts.some(part => SANDBOX_EXCLUDED_DIRECTORIES.has(part)),
|
||||
inExcludedRoot = [...SANDBOX_EXCLUDED_ROOT_PATHS].some(root => normalized === root || normalized.startsWith(`${root}/`))
|
||||
return inExcludedDirectory || inExcludedRoot
|
||||
}
|
||||
|
||||
/** Every read reopens and pins the root/ancestors/target in native code; root identity is fixed for this Job. */
|
||||
export class NativeWorkspaceReader {
|
||||
@@ -27,7 +34,7 @@ export class NativeWorkspaceReader {
|
||||
private allowed(path: string): boolean {
|
||||
const absolute = resolve(this.policy.workspaceRoot, path)
|
||||
return within(this.policy.workspaceRoot, absolute) && !sandboxPathDenied(this.policy, absolute)
|
||||
&& !relative(this.policy.workspaceRoot, absolute).split(/[\\/]/).some(part => excluded.has(part.toLowerCase()))
|
||||
&& !excluded(relative(this.policy.workspaceRoot, absolute))
|
||||
&& !within(resolve(sandboxConfiguration().stateRoot), absolute)
|
||||
}
|
||||
async read(operation: 'read' | 'stat' | 'list', input: unknown, signal?: AbortSignal): Promise<NativeReadResult> {
|
||||
|
||||
@@ -5,8 +5,16 @@ import type { SandboxEffectivePolicy, SandboxLimits, SandboxPolicyDocument, Sand
|
||||
import { sandboxConfiguration } from './configuration'
|
||||
import { protectedPath } from './policy'
|
||||
|
||||
export const SANDBOX_LIMITS: Readonly<SandboxLimits> = Object.freeze({ files: 10_000, bytes: 128 * 1024 * 1024, fileBytes: 8 * 1024 * 1024, timeoutMs: 120_000 })
|
||||
const defaultRevision = 'sandbox-policy-v1-default'
|
||||
/**
|
||||
* Development defaults are intended for real source repositories, while the
|
||||
* ceiling below remains finite so a malformed or hostile workspace cannot
|
||||
* consume unbounded disk or CPU. A policy may lower either budget, but never
|
||||
* raise it above the ceiling.
|
||||
*/
|
||||
export const SANDBOX_LIMITS: Readonly<SandboxLimits> = Object.freeze({ files: 50_000, bytes: 2 * 1024 * 1024 * 1024, fileBytes: 256 * 1024 * 1024, timeoutMs: 10 * 60_000 })
|
||||
export const SANDBOX_LIMIT_CEILINGS: Readonly<SandboxLimits> = Object.freeze({ files: 200_000, bytes: 8 * 1024 * 1024 * 1024, fileBytes: 512 * 1024 * 1024, timeoutMs: 10 * 60_000 })
|
||||
const defaultRevision = 'sandbox-policy-v2-development'
|
||||
const legacyDefaultRevision = 'sandbox-policy-v1-default'
|
||||
const maximumBytes = 256 * 1024
|
||||
const keys = ['commandsEnabled', 'writeback', 'limits', 'deniedPaths', 'maxChanges']
|
||||
const limitsKeys = ['files', 'bytes', 'fileBytes', 'timeoutMs'] as const
|
||||
@@ -40,7 +48,7 @@ export function validatePolicyOverride(input: unknown): SandboxPolicyOverride {
|
||||
result.limits = {}
|
||||
for (const key of limitsKeys) if (Object.hasOwn(limits, key)) {
|
||||
const limit = limits[key]
|
||||
const ceiling = key === 'timeoutMs' ? 600_000 : SANDBOX_LIMITS[key]
|
||||
const ceiling = SANDBOX_LIMIT_CEILINGS[key]
|
||||
if (!Number.isInteger(limit) || Number(limit) < (key === 'timeoutMs' ? 100 : 1) || Number(limit) > ceiling) throw new Error(`沙盒资源上限无效:${key}`)
|
||||
result.limits[key] = Number(limit)
|
||||
}
|
||||
@@ -50,7 +58,7 @@ export function validatePolicyOverride(input: unknown): SandboxPolicyOverride {
|
||||
result.deniedPaths = [...new Set(value.deniedPaths.map(deniedPath))]
|
||||
}
|
||||
if (Object.hasOwn(value, 'maxChanges')) {
|
||||
if (!Number.isInteger(value.maxChanges) || Number(value.maxChanges) < 1 || Number(value.maxChanges) > SANDBOX_LIMITS.files) throw new Error('沙盒变更项数上限无效')
|
||||
if (!Number.isInteger(value.maxChanges) || Number(value.maxChanges) < 1 || Number(value.maxChanges) > SANDBOX_LIMIT_CEILINGS.files) throw new Error('沙盒变更项数上限无效')
|
||||
result.maxChanges = Number(value.maxChanges)
|
||||
}
|
||||
return result
|
||||
@@ -96,6 +104,50 @@ export async function readSandboxPolicy(): Promise<SandboxPolicyDocument> {
|
||||
}
|
||||
return result
|
||||
}
|
||||
/**
|
||||
* v1 stored the old low defaults as an explicit complete document after any
|
||||
* security preference was toggled. Promote only an untouched complete legacy
|
||||
* default. A smaller limit, disabled commands, denied paths, or writeback
|
||||
* restriction remains an intentional user choice.
|
||||
*/
|
||||
export async function migrateLegacyDefaultPolicy(): Promise<boolean> {
|
||||
const path = join(sandboxConfiguration().stateRoot, 'policy.json')
|
||||
const bytes = await regularControlFile(path)
|
||||
if (!bytes) return false
|
||||
const value = object(JSON.parse(bytes.toString('utf8')))
|
||||
if (value.version !== 1 || value.revision !== legacyDefaultRevision || !Number.isSafeInteger(value.updatedAt)) return false
|
||||
const legacyDefaults = object(value.defaults)
|
||||
const legacy = { files: 10_000, bytes: 128 * 1024 * 1024, fileBytes: 8 * 1024 * 1024, timeoutMs: 120_000 }
|
||||
const limits = object(legacyDefaults.limits)
|
||||
const workspaces = object(value.workspaces)
|
||||
const untouched = legacyDefaults.commandsEnabled === true && legacyDefaults.writeback === 'ask'
|
||||
&& Array.isArray(legacyDefaults.deniedPaths) && legacyDefaults.deniedPaths.length === 0
|
||||
&& legacyDefaults.maxChanges === legacy.files
|
||||
&& limitsKeys.every(key => limits[key] === legacy[key])
|
||||
&& Object.keys(workspaces).length === 0
|
||||
if (!untouched) return false
|
||||
const state = resolve(sandboxConfiguration().stateRoot)
|
||||
const lockPath = join(state, 'policy-migration.lock'), temporary = join(state, `policy-${randomUUID()}.migrated.tmp`)
|
||||
await fs.mkdir(state, { recursive: true, mode: 0o700 })
|
||||
const lock = await fs.open(lockPath, 'wx', 0o600).catch(error => {
|
||||
if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw new Error('沙盒策略正在被其他维护进程迁移;请稍后重试')
|
||||
throw error
|
||||
})
|
||||
try {
|
||||
if (!(await regularControlFile(path))?.equals(bytes)) return false
|
||||
const migrated = defaults()
|
||||
migrated.revision = randomUUID()
|
||||
migrated.updatedAt = Date.now()
|
||||
const output = await fs.open(temporary, 'wx', 0o600)
|
||||
try { await output.writeFile(JSON.stringify(migrated, null, 2) + '\n'); await output.sync() } finally { await output.close() }
|
||||
await fs.rename(temporary, path)
|
||||
return true
|
||||
} finally {
|
||||
await lock.close()
|
||||
await fs.unlink(temporary).catch(() => undefined)
|
||||
await fs.unlink(lockPath).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
export async function effectiveSandboxPolicy(rootInput: string): Promise<SandboxEffectivePolicy> {
|
||||
const root = await fs.realpath(rootInput), doc = await readSandboxPolicy()
|
||||
const override = doc.workspaces[workspaceKey(root)]?.rules ?? {}
|
||||
|
||||
@@ -162,7 +162,8 @@ async function executeIsolatedCommand(command: string, context: SandboxCommandCo
|
||||
draft = await PersistentWorkspace.open(policy, context.sessionId!, workspaceIdentity)
|
||||
await draft.synchronize()
|
||||
}
|
||||
const initial = draft ? await draft.checkoutInto(stage, id) : await snapshot(workspace, join(stage, 'workspace'), policy, context.signal)
|
||||
const initial = draft ? await draft.checkoutInto(stage, id)
|
||||
: await snapshot(workspace, join(stage, 'workspace'), policy, context.signal)
|
||||
const toolchains = await stageSandboxToolchains(stage, context.runtimes ?? [], context.signal)
|
||||
const dependencies = await stageSandboxDependencies(workspace, stage, context.dependencies ?? [], toolchains, context.signal)
|
||||
if (toolchains.some(tool => tool.kind === 'git')) {
|
||||
@@ -198,7 +199,7 @@ async function executeIsolatedCommand(command: string, context: SandboxCommandCo
|
||||
await fs.writeFile(join(stage, 'git-arguments'), context.gitArguments.map(arg => Buffer.from(arg).toString('hex')).join('\n') + '\n', { flag: 'wx' })
|
||||
}
|
||||
const timeout = Math.min(context.timeoutMs ?? policy.limits.timeoutMs, policy.limits.timeoutMs)
|
||||
if (!Number.isInteger(timeout) || timeout < 100 || timeout > 600_000) throw new Error('沙盒超时参数无效')
|
||||
if (!Number.isInteger(timeout) || timeout < 100 || timeout > 10 * 60_000) throw new Error('沙盒超时参数无效')
|
||||
await assertPolicyCurrent(policy)
|
||||
const native = await runNative(binary, profile, stage, join(directory, 'result.json'), timeout, policy, context.signal, context.stdioProbe, context.readBridge ? { owner: { ...identity, sandboxRunId: id } } : undefined)
|
||||
await assertPolicyCurrent(policy)
|
||||
|
||||
@@ -8,10 +8,26 @@ import { assertActive, resolveSandboxPath, within } from './policy'
|
||||
import { sandboxPathDenied } from './policy-store'
|
||||
|
||||
export type Snapshot = Map<string, { hash: string; size: number }>
|
||||
const excludedDirectories = new Set(['.git', '.ycode', '.tmp', 'node_modules', '.venv', 'venv', '__pycache__', 'out', 'dist', 'release'])
|
||||
/** Generated output and app-shipped runtimes never belong to a project draft. */
|
||||
export const SANDBOX_EXCLUDED_DIRECTORIES = new Set([
|
||||
'.git', '.ycode', '.workbuddy', '.tmp', '.tmp-profile', 'node_modules', '.venv', 'venv', '__pycache__',
|
||||
'out', 'dist', 'release', 'coverage', '.next', '.nuxt', '.turbo'
|
||||
])
|
||||
/** These are application resources when YCode develops its own checkout. */
|
||||
export const SANDBOX_EXCLUDED_ROOT_PATHS = new Set([
|
||||
'resources/omp', 'resources/sandbox/win32-x64', 'resources/model-metadata'
|
||||
])
|
||||
function isExcluded(file: string): boolean {
|
||||
const normalized = file.replaceAll('\\', '/').toLowerCase()
|
||||
if (SANDBOX_EXCLUDED_DIRECTORIES.has(normalized)) return true
|
||||
return [...SANDBOX_EXCLUDED_ROOT_PATHS].some(path => normalized === path || normalized.startsWith(`${path}/`))
|
||||
}
|
||||
export async function regularBytes(file: string, fileLimit: number): Promise<Buffer> {
|
||||
const before = await fs.lstat(file)
|
||||
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size > fileLimit) throw new Error('快照包含链接、特殊文件或超过策略上限的文件')
|
||||
if (!before.isFile()) throw new Error(`快照包含特殊文件:${file}`)
|
||||
if (before.isSymbolicLink()) throw new Error(`快照包含符号链接:${file}`)
|
||||
if (before.nlink !== 1) throw new Error(`快照包含多重硬链接:${file}`)
|
||||
if (before.size > fileLimit) throw new Error(`文件超过沙盒单文件上限:${file}(${before.size} 字节,上限 ${fileLimit} 字节)`)
|
||||
const handle = await fs.open(file, constants.O_RDONLY)
|
||||
try {
|
||||
const opened = await handle.stat()
|
||||
@@ -30,22 +46,22 @@ export async function snapshot(source: string, target: string | undefined, polic
|
||||
async function walk(directory: string): Promise<void> {
|
||||
for await (const entry of await fs.opendir(directory)) {
|
||||
assertActive(signal)
|
||||
if (++entries > policy.limits.files) throw new Error('工作区超过沙盒快照的总项数上限')
|
||||
const sourcePath = join(directory, entry.name), file = relative(source, sourcePath)
|
||||
if (within(stateRoot, sourcePath) || sandboxPathDenied(policy, sourcePath) || entry.isSymbolicLink() || entry.isDirectory() && excludedDirectories.has(entry.name.toLowerCase())) { excluded++; continue }
|
||||
if (within(stateRoot, sourcePath) || sandboxPathDenied(policy, sourcePath) || entry.isSymbolicLink() || isExcluded(file)) { excluded++; continue }
|
||||
if (++entries > policy.limits.files) throw new Error(`工作区超过沙盒快照的总项数上限(${policy.limits.files})`)
|
||||
await resolveSandboxPath(source, file)
|
||||
if (entry.isDirectory()) { directories.add(file); if (target) await fs.mkdir(join(target, file), { recursive: true }); await walk(sourcePath) }
|
||||
else if (entry.isFile()) {
|
||||
if (files.size >= policy.limits.files) throw new Error('工作区超过沙盒快照的文件数量上限')
|
||||
if (files.size >= policy.limits.files) throw new Error(`工作区超过沙盒快照的文件数量上限(${policy.limits.files})`)
|
||||
const bytes = await regularBytes(sourcePath, policy.limits.fileBytes)
|
||||
totalBytes += bytes.length
|
||||
if (totalBytes > policy.limits.bytes) throw new Error('工作区超过沙盒快照的容量上限')
|
||||
if (totalBytes > policy.limits.bytes) throw new Error(`工作区超过沙盒快照的容量上限:${file} 使快照达到 ${totalBytes} 字节,上限 ${policy.limits.bytes} 字节`)
|
||||
if (target) {
|
||||
await fs.mkdir(dirname(join(target, file)), { recursive: true })
|
||||
await fs.writeFile(join(target, file), bytes, { flag: 'wx' })
|
||||
}
|
||||
files.set(file, { hash: digest(bytes), size: bytes.length })
|
||||
} else throw new Error('工作区包含不支持的特殊文件')
|
||||
} else throw new Error(`工作区包含不支持的特殊文件:${file}`)
|
||||
}
|
||||
}
|
||||
await walk(source)
|
||||
@@ -58,19 +74,19 @@ export async function stageChanges(stageRoot: string, before: Snapshot, beforeDi
|
||||
async function walk(directory: string): Promise<void> {
|
||||
for await (const entry of await fs.opendir(directory)) {
|
||||
assertActive(signal)
|
||||
if (++entries > policy.limits.files) throw new Error('沙盒产物超过总项数上限')
|
||||
if (++entries > policy.limits.files) throw new Error(`沙盒产物超过总项数上限(${policy.limits.files})`)
|
||||
const absolute = join(directory, entry.name), file = relative(stageRoot, absolute)
|
||||
if (nodeDependencies && file === 'node_modules') continue
|
||||
if (sandboxPathDenied(policy, join(policy.workspaceRoot, file)) || entry.isSymbolicLink()) throw new Error('沙盒产生了受保护路径或链接;未写回工作区')
|
||||
await resolveSandboxPath(stageRoot, file)
|
||||
if (entry.isDirectory()) { directories.add(file); await walk(absolute) }
|
||||
else if (entry.isFile()) {
|
||||
if (after.size >= policy.limits.files) throw new Error('沙盒产物超过文件数量上限')
|
||||
if (after.size >= policy.limits.files) throw new Error(`沙盒产物超过文件数量上限(${policy.limits.files})`)
|
||||
const bytes = await regularBytes(absolute, policy.limits.fileBytes)
|
||||
totalBytes += bytes.length
|
||||
if (totalBytes > policy.limits.bytes) throw new Error('沙盒产物超过容量上限')
|
||||
if (totalBytes > policy.limits.bytes) throw new Error(`沙盒产物超过容量上限:${file} 使产物达到 ${totalBytes} 字节,上限 ${policy.limits.bytes} 字节`)
|
||||
after.set(file, bytes)
|
||||
} else throw new Error('沙盒产生了不支持的特殊文件')
|
||||
} else throw new Error(`沙盒产生了不支持的特殊文件:${file}`)
|
||||
}
|
||||
}
|
||||
await walk(stageRoot)
|
||||
|
||||
@@ -7,7 +7,7 @@ import { sandboxConfiguration } from './configuration'
|
||||
import { assertActive, protectedPath, within } from './policy'
|
||||
import { assertPolicyCurrent, sandboxPathDenied } from './policy-store'
|
||||
import { digest, type DirectoryChange, type FileChange } from './transactions'
|
||||
import { regularBytes, snapshot, stageChanges, type Snapshot } from './workspace-files'
|
||||
import { regularBytes, SANDBOX_EXCLUDED_DIRECTORIES, SANDBOX_EXCLUDED_ROOT_PATHS, snapshot, stageChanges, type Snapshot } from './workspace-files'
|
||||
import { verifiedBinary } from './native-component'
|
||||
import { NativeWorkspaceReader } from './filesystem-reader'
|
||||
import { releaseRunLease } from './maintenance'
|
||||
@@ -30,7 +30,12 @@ const same = (a?: WorkspaceEntry, b?: WorkspaceEntry): boolean => a?.kind === b?
|
||||
const order = (tree: WorkspaceEntry[]): WorkspaceEntry[] => tree.sort((a, b) => a.path.localeCompare(b.path, 'en'))
|
||||
const index = (tree: WorkspaceEntry[]): Map<string, WorkspaceEntry> => new Map(tree.map(entry => [key(entry.path), entry]))
|
||||
const depth = (path: string): number => path.split('/').length
|
||||
const excluded = new Set(['node_modules', '.tmp', '.venv', 'venv', '__pycache__', 'out', 'dist', 'release'])
|
||||
const excluded = SANDBOX_EXCLUDED_DIRECTORIES
|
||||
function excludedEntry(path: string): boolean {
|
||||
const normalized = path.replaceAll('\\', '/').toLowerCase()
|
||||
return normalized.split('/').some(part => excluded.has(part))
|
||||
|| [...SANDBOX_EXCLUDED_ROOT_PATHS].some(root => normalized === root || normalized.startsWith(`${root}/`))
|
||||
}
|
||||
|
||||
export function workspaceKey(workspace: string, sessionId: string): string {
|
||||
if (typeof sessionId !== 'string' || !sessionId || sessionId.length > 200 || /[\x00-\x1f]/.test(sessionId)) throw new Error('长期工作区需要有效的宿主会话身份')
|
||||
@@ -81,7 +86,7 @@ function validateTree(tree: WorkspaceEntry[], policy: SandboxEffectivePolicy): v
|
||||
let bytes = 0; const seen = new Map<string, WorkspaceEntry>()
|
||||
for (const entry of tree) {
|
||||
if (!entry || typeof entry.path !== 'string' || !entry.path || entry.path.length > 1024 || isAbsolute(entry.path) || entry.path.includes('\\')
|
||||
|| entry.path.split('/').some(part => !part || part === '.' || part === '..' || excluded.has(part.toLowerCase())) || protectedPath(entry.path)
|
||||
|| entry.path.split('/').some(part => !part || part === '.' || part === '..') || excludedEntry(entry.path) || protectedPath(entry.path)
|
||||
|| sandboxPathDenied(policy, join(policy.workspaceRoot, entry.path)) || seen.has(key(entry.path)) || !['file', 'directory'].includes(entry.kind)) throw new Error('长期工作区包含无效、重复或被策略拒绝的路径')
|
||||
if (entry.kind === 'file') {
|
||||
if (!hash.test(entry.hash ?? '') || !Number.isSafeInteger(entry.size) || entry.size! < 0 || entry.size! > policy.limits.fileBytes) throw new Error('长期工作区文件身份或容量无效')
|
||||
@@ -190,7 +195,7 @@ export class PersistentWorkspace {
|
||||
const value = JSON.parse(bytes.toString('utf8')) as WorkspaceRevision
|
||||
if (value.version !== 1 || value.id !== identity.id || key(value.workspaceRoot) !== key(this.policy.workspaceRoot) || value.sessionId !== this.sessionId || !fileId.test(value.workspaceIdentity)
|
||||
|| !fileId.test(value.physicalIdentity) || !hash.test(value.policyFingerprint) || !Number.isSafeInteger(value.createdAt)
|
||||
|| !Array.isArray(value.base) || !Array.isArray(value.tree) || value.base.length > 10000 || value.tree.length > 10000 || !Number.isSafeInteger(value.excluded) || value.excluded < 0) throw new Error('长期工作区版本归属无效')
|
||||
|| !Array.isArray(value.base) || !Array.isArray(value.tree) || value.base.length > this.policy.limits.files || value.tree.length > this.policy.limits.files || !Number.isSafeInteger(value.excluded) || value.excluded < 0) throw new Error('长期工作区版本归属无效')
|
||||
this.revision = value
|
||||
}
|
||||
assertUsable(): void {
|
||||
|
||||
Reference in new issue
Block a user