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: 4 additions & 2 deletions reference/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ def reconstruct_request(doc,validated,context_id):
for part in item['parts']:
if part['kind'] in ('resource','opaque'):resource_ids.add(part['resource_id'])
if part['kind'] in ('opaque','structured'):raise Unsupported('opaque/structured content requires a declared request encoder')
for id in resource_ids:validated['resources'].bytes(id)
tools=[copy.deepcopy(t) for id in context['tool_ids'] for t in doc['tools'] if t['id']==id]
for tool in tools:resource_ids.update(tool.get('resource_ids',[]))
for id in sorted(resource_ids):validated['resources'].bytes(id)
# The normalized form does not guess a provider's wire encoding or tokenize it.
return {'request_version':'0.1','source':{'session_id':doc['session']['id'],'capture_id':doc['capture']['id'],'context_id':context_id},'fidelity':context['fidelity'],'inputs':copy.deepcopy(context['inputs']),'tools':[copy.deepcopy(t) for id in context['tool_ids'] for t in doc['tools'] if t['id']==id],'model':copy.deepcopy(context.get('model')),'request_parameters':copy.deepcopy(context.get('request_parameters',{})),'configuration_id':context.get('configuration_id'),'resources':[copy.deepcopy(validated['resources'].records[id]) for id in sorted(resource_ids)],'provider_encoding':'not_selected','token_budget':'unmeasured','losses':[]}
return {'request_version':'0.1','source':{'session_id':doc['session']['id'],'capture_id':doc['capture']['id'],'context_id':context_id},'fidelity':context['fidelity'],'inputs':copy.deepcopy(context['inputs']),'tools':tools,'model':copy.deepcopy(context.get('model')),'request_parameters':copy.deepcopy(context.get('request_parameters',{})),'configuration_id':context.get('configuration_id'),'resources':[copy.deepcopy(validated['resources'].records[id]) for id in sorted(resource_ids)],'provider_encoding':'not_selected','token_budget':'unmeasured','losses':[]}
11 changes: 3 additions & 8 deletions reference/continuation.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@
import hashlib
import json
from pathlib import Path
import unicodedata
from jsonschema import Draft202012Validator
from .common import Invalid, need, unique, relative, acyclic, pointer
from .core import validate_document, Validator, SUPPORTED
from .requirements import plan_requirements
from .capabilities import inspect_capabilities
from .workspace import workspace_states, check_git
from .workspace import workspace_states, check_git, validate_destination_paths

ROOT=Path(__file__).resolve().parents[1]
SESSION=Draft202012Validator(json.loads((ROOT/'schemas/session.schema.json').read_text()))
Expand Down Expand Up @@ -239,12 +238,8 @@ def blocker(kind,id,owner=None):need(assessed(kind,id,owner)['status'] in ('unre
flattened=workspace_states(p)
for w in p['workspaces']:
if w['id'] not in plan['workspace_ids'] or w['root_id'] not in mappings:continue
mapping=mappings[w['root_id']];paths=[]
for e in flattened[w['id']].values():
value=e['path'];norm=mapping['unicode_normalization']
if norm!='none':value=unicodedata.normalize(norm,value)
if not mapping['case_sensitive']:value=value.casefold()
need(value not in paths,'destination path collision');paths.append(value)
mapping=mappings[w['root_id']]
validate_destination_paths(flattened[w['id']],case_sensitive=mapping['case_sensitive'],normalization=mapping['unicode_normalization'])
model=report['model_assessment'];need(model['source']==plan['model_requirements']['source_model'],'model source mismatch')
if model['fit']=='fits':
need(model['tokenizer'] is not None and model['input_tokens'] is not None and model['input_limit'] is not None,'unmeasured context fit')
Expand Down
11 changes: 7 additions & 4 deletions reference/typescript/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,12 @@ export function reconstructRequest(doc: Obj, v: Validated, id: string): Obj {
);
}
}
for (const id of ids) v.resources.bytes(id);
const tools = ctx.tool_ids.flatMap((id: string) =>
doc.tools.filter((t: Obj) => t.id === id),
);
for (const tool of tools)
for (const id of tool.resource_ids ?? []) ids.add(id);
for (const id of [...ids].sort()) v.resources.bytes(id);
return structuredClone({
request_version: "0.1",
source: {
Expand All @@ -174,9 +179,7 @@ export function reconstructRequest(doc: Obj, v: Validated, id: string): Obj {
},
fidelity: ctx.fidelity,
inputs: ctx.inputs,
tools: ctx.tool_ids.flatMap((id: string) =>
doc.tools.filter((t: Obj) => t.id === id),
),
tools,
model: ctx.model ?? null,
request_parameters: ctx.request_parameters ?? {},
configuration_id: ctx.configuration_id ?? null,
Expand Down
23 changes: 11 additions & 12 deletions reference/typescript/continuation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,15 @@ import {
setEqual,
equal,
hash,
casefold,
} from "./common.ts";
import { validateDocument, shape, SUPPORTED, type Validated } from "./core.ts";
import { inspectCapabilities } from "./capabilities.ts";
import { planRequirements } from "./requirements.ts";
import { workspaceStates, checkGit } from "./workspace.ts";
import {
workspaceStates,
checkGit,
validateDestinationPaths,
} from "./workspace.ts";
const FEATURE = "asif.portable-continuation/0.2";
export const subject = (kind: string, id: string, owner?: string): Obj =>
owner === undefined ? { kind, id } : { kind, id, owner_id: owner };
Expand Down Expand Up @@ -622,16 +625,12 @@ export function inspectReport(
const flattened = workspaceStates(p);
for (const w of p.workspaces)
if (plan.workspace_ids.includes(w.id) && own(mappings, w.root_id)) {
const mapping = mappings[w.root_id],
paths = new Set<string>();
for (const e of Object.values<Obj>(flattened[w.id])) {
let value = e.path;
if (mapping.unicode_normalization !== "none")
value = value.normalize(mapping.unicode_normalization);
if (!mapping.case_sensitive) value = casefold(value);
need(!paths.has(value), "destination path collision");
paths.add(value);
}
const mapping = mappings[w.root_id];
validateDestinationPaths(
flattened[w.id],
mapping.case_sensitive,
mapping.unicode_normalization,
);
}
const model = report.model_assessment;
need(
Expand Down
59 changes: 37 additions & 22 deletions reference/typescript/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,18 +57,20 @@ export function workspaceStates(profile: Obj): Obj {
);
for (const deletion of w.deletions) {
relative(deletion);
const targets = Object.keys(entries).filter(
(p) => p === deletion || p.startsWith(deletion + "/"),
);
need(
!w.selection.exclude.some(
(p: string) =>
matches(deletion, p) ||
deletion === p.replace(/\/+$/, "") ||
deletion.startsWith(p.replace(/\/+$/, "") + "/"),
![deletion, ...targets].some((target) =>
w.selection.exclude.some(
(p: string) =>
matches(target, p) ||
target === p.replace(/\/+$/, "") ||
target.startsWith(p.replace(/\/+$/, "") + "/"),
),
),
"deleting excluded path",
);
const targets = Object.keys(entries).filter(
(p) => p === deletion || p.startsWith(deletion + "/"),
);
need(targets.length, "deletion absent from base");
for (const p of targets) delete entries[p];
}
Expand All @@ -91,6 +93,32 @@ export function workspaceStates(profile: Obj): Obj {
}
return result;
}
export function validateDestinationPaths(
entries: Obj,
caseSensitive = true,
normalization = "none",
): void {
need(
["none", "NFC", "NFD"].includes(normalization),
"unsupported normalization",
);
const seen = new Map<string, string>();
for (const [p, e] of Object.entries<Obj>(entries)) {
let folded = normalization === "none" ? p : p.normalize(normalization);
if (!caseSensitive) folded = casefold(folded);
need(!seen.has(folded), "destination path collision");
seen.set(folded, e.kind);
}
for (const p of seen.keys()) {
const parts = p.split("/");
for (let i = 1; i < parts.length; i++)
need(
(seen.get(parts.slice(0, i).join("/")) ?? "directory") === "directory",
"destination prefix collision",
);
}
}

export function checkGit(
w: Obj,
resources: Resources,
Expand Down Expand Up @@ -192,8 +220,7 @@ export function restore(
"incomplete selected-tree snapshot",
);
const norm = options.normalization ?? "none";
need(["none", "NFC", "NFD"].includes(norm), "unsupported normalization");
const seen = new Map<string, string>();
validateDestinationPaths(entries, options.case_sensitive ?? true, norm);
for (const [p, e] of Object.entries<Obj>(entries)) {
if (e.kind === "symlink")
throw new Unsupported(
Expand All @@ -204,20 +231,8 @@ export function restore(
!p.split("/").some((c) => casefold(c) === ".git"),
"workspace cannot inject Git administrative files",
);
let folded = norm === "none" ? p : p.normalize(norm);
if (options.case_sensitive === false) folded = casefold(folded);
need(!seen.has(folded), "destination path collision");
seen.set(folded, e.kind);
if (e.kind === "file") v.resources.bytes(e.resource_id);
}
for (const p of seen.keys()) {
const parts = p.split("/");
for (let i = 1; i < parts.length; i++)
need(
(seen.get(parts.slice(0, i).join("/")) ?? "directory") === "directory",
"destination prefix collision",
);
}
need(!lexists(destination), "destination already exists");
const parent = path.dirname(destination);
need(
Expand Down
24 changes: 15 additions & 9 deletions reference/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ def workspace_states(profile):
if base:need(records[base]['root_id']==w['root_id'],'workspace base root mismatch')
for deletion in w['deletions']:
relative(deletion)
need(not any(fnmatch.fnmatchcase(deletion,p) or deletion==p.rstrip('/') or deletion.startswith(p.rstrip('/')+'/') for p in w['selection']['exclude']),'deleting excluded path')
targets=[p for p in entries if p==deletion or p.startswith(deletion+'/')]
need(not any(fnmatch.fnmatchcase(target,p) or target==p.rstrip('/') or target.startswith(p.rstrip('/')+'/') for target in [deletion,*targets] for p in w['selection']['exclude']),'deleting excluded path')
need(targets,'deletion absent from base')
for p in targets:del entries[p]
for entry in w['entries']:
Expand Down Expand Up @@ -60,6 +60,19 @@ def oid(value):
need(set(g['lfs_dependency_ids'])<=dependencies.keys(),'missing LFS dependency')


def validate_destination_paths(entries,*,case_sensitive=True,normalization='none'):
"""Check the selected tree under the destination's path equivalence rules."""
need(normalization in ('none','NFC','NFD'),'unsupported normalization')
seen={}
for path,entry in entries.items():
folded=unicodedata.normalize(normalization,path) if normalization!='none' else path
if not case_sensitive:folded=folded.casefold()
need(folded not in seen,'destination path collision');seen[folded]=entry['kind']
for path in seen:
pieces=path.split('/')
for i in range(1,len(pieces)):need(seen.get('/'.join(pieces[:i]),'directory')=='directory','destination prefix collision')


def restore(document,validated,workspace_id,destination,*,case_sensitive=True,normalization='none',fail_after=None):
profile=document['continuation'];records=unique(profile['workspaces']);need(workspace_id in records,'unknown workspace')
w=records[workspace_id];states=workspace_states(profile);entries=states[workspace_id];resources=validated['resources']
Expand All @@ -71,18 +84,11 @@ def restore(document,validated,workspace_id,destination,*,case_sensitive=True,no
current=records[current['base_snapshot_id']]
if any(x['mode']=='refs' or 'git' in x for x in chain):raise Unsupported('Git administration/index restoration is not implemented; use a verified Git adapter')
need(all(x['selection']['complete_for_selection'] for x in chain),'incomplete selected-tree snapshot')
need(normalization in ('none','NFC','NFD'),'unsupported normalization')
seen={}
validate_destination_paths(entries,case_sensitive=case_sensitive,normalization=normalization)
for path,entry in entries.items():
if entry['kind']=='symlink':raise Unsupported('workspace symlink restoration requires an explicit link policy')
relative(path);need('.git' not in [p.casefold() for p in path.split('/')],'workspace cannot inject Git administrative files')
folded=unicodedata.normalize(normalization,path) if normalization!='none' else path
if not case_sensitive:folded=folded.casefold()
need(folded not in seen,'destination path collision');seen[folded]=entry['kind']
if entry['kind']=='file':resources.bytes(entry['resource_id'])
for path in seen:
pieces=path.split('/')
for i in range(1,len(pieces)):need(seen.get('/'.join(pieces[:i]),'directory')=='directory','destination prefix collision')
destination=Path(destination);need(not os.path.lexists(destination),'destination already exists')
parent=destination.parent;need(parent.is_dir() and not parent.is_symlink(),'destination parent must be a controlled directory')
# This lock coordinates this implementation's writers. The parent must not be
Expand Down
17 changes: 17 additions & 0 deletions tests/check_continuation.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,23 @@ def supply(r,raw):
def snapshot_change(r,mutate):
value=json.loads(supplied(r));mutate(value);supply(r,(json.dumps(value,ensure_ascii=False)+'\n').encode())
def component(s,kind):return next(c for c in s['components'] if c['kind']==kind)
# Keep path bindings and their hashed snapshot evidence consistent so failures
# exercise destination path validation rather than stale evidence checks.
for case in json.loads((ROOT/'tests/validation-regression-cases.json').read_text())['destination_paths']:
doc,source,receipt=copy.deepcopy(fixtures['another-computer'])
w=doc['continuation']['workspaces'][0]
w['entries']=[{'path':p,'kind':'directory','mode':493} if p==case.get('directory') else {**w['entries'][0],'path':p} for p in case['paths']]
w['selection']['include']=['**']
source=(json.dumps(doc,ensure_ascii=False)+'\n').encode();receipt['source']['document_sha256']=hashlib.sha256(source).hexdigest()
mapping={'case_sensitive':case['case_sensitive'],'unicode_normalization':case['normalization']}
receipt['path_bindings'][0].update(mapping)
snapshot_change(receipt,lambda s:component(s,'workspace').update(mapping))
if 'error' in case:
try:inspect_report(doc,source,receipt,FOLDER,now=NOW)
except Invalid as exc:assert case['error'] in str(exc),(case['name'],exc)
else:raise AssertionError('destination collision accepted: '+case['name'])
else:assert inspect_report(doc,source,receipt,FOLDER,now=NOW)['outcome']=='ready'
results.append({'case':'destination-'+case['name'],'passed':True,'outcome':'rejected' if 'error' in case else 'ready'})
negative_report('snapshot-required',lambda r:r['destination'].pop('capabilities_snapshot'),None)
negative_report('snapshot-digest-mismatch',lambda r:r['destination'].update(capabilities_sha256='0'*64),'capability snapshot digest mismatch')
negative_report('snapshot-noncanonical-base64',lambda r:r['destination']['capabilities_snapshot'].update(data='e31=',bytes=2),'noncanonical snapshot base64')
Expand Down
50 changes: 50 additions & 0 deletions tests/check_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,56 @@ def preserved_partial_capture():
for path in ['../a','/tmp/a','a\\b','C:a','NUL','a/../b','a.']:
check('path-'+path,lambda p=path:rejects(lambda:relative(p),'path'))
check('request-typed-inputs',lambda:equal(reconstruct_request(doc,validated,doc['contexts'][0]['id'])['inputs'],doc['contexts'][0]['inputs']))
regressions=json.loads((ROOT/'tests/validation-regression-cases.json').read_text())
def request_resource_case(case):
d=copy.deepcopy(doc);tool=d['tools'][0]
if case.get('unselected'):
tool=copy.deepcopy(tool);tool['id']='unused-tool';d['tools'].append(tool)
tool['resource_ids']=case['tool_resources']
if case.get('lookalikes'):tool['input_schema'].update(resource_id='missing',resource_ids=['missing'])
if 'availability' in case:
resource={'id':'tool-asset','media_type':'text/plain','purpose':'input','availability':case['availability']}
resource['explanation']='Not captured.'
if case['availability']=='external':resource['locator']='https://example.invalid/tool-asset'
d['resources'].append(resource)
def project():return reconstruct_request(d,validate_document(d,folder),d['contexts'][0]['id'])
if 'error' in case:rejects(project,case['error'])
else:equal([r['id'] for r in project()['resources']],case['expected'])
for case in regressions['request_resources']:
check('request-'+case['name'],lambda c=case:request_resource_case(c))

def deletion_case(case):
d=copy.deepcopy(doc);w=d['continuation']['workspaces'][0]
w['entries']=[{**w['entries'][0],'path':p} for p in ['folder/public','folder/private','folder/secret/child']]
w['selection']['include']=['folder/**']
# A no-op intermediate delta exercises deletion through a base chain.
middle=copy.deepcopy(w);middle.update(id='middle',mode='delta',base_snapshot_id=w['id'],entries=[],deletions=[])
delta=copy.deepcopy(middle);delta.update(id='delta',base_snapshot_id='middle',deletions=[case['deletion']])
delta['selection']['exclude']=case['exclude'];d['continuation']['workspaces']+=[middle,delta]
d['continuation']['plans'][0]['workspace_ids']=['delta'];before=copy.deepcopy(d)
if 'error' in case:rejects(lambda:inspect_session(d,folder),case['error'])
else:
inspect_session(d,folder);states=workspace_states(d['continuation'])
equal(states['delta'],{});equal(states[w['id']],states['middle'])
equal(d,before)
for case in regressions['workspace_deletions']:
check('delta-'+case['name'],lambda c=case:deletion_case(c))

def destination_case(case):
d=copy.deepcopy(doc);w=d['continuation']['workspaces'][0]
w['entries']=[{'path':p,'kind':'directory','mode':493} if p==case.get('directory') else {**w['entries'][0],'path':p} for p in case['paths']]
with tempfile.TemporaryDirectory() as temp:
destination=Path(temp)/'restored'
def run():return restore(d,validated,w['id'],destination,case_sensitive=case['case_sensitive'],normalization=case['normalization'])
if 'error' in case:
rejects(run,case['error']);equal(list(Path(temp).iterdir()),[])
elif case['name'].startswith(('case-sensitive','unnormalized')):
# The host filesystem may not support these declared destination semantics.
from reference.workspace import validate_destination_paths
validate_destination_paths(workspace_states(d['continuation'])[w['id']],case_sensitive=case['case_sensitive'],normalization=case['normalization'])
else:run();assert (destination/'dir/child').is_file()
for case in regressions['destination_paths']:
check('restore-'+case['name'],lambda c=case:destination_case(c))
check('configuration-order',lambda:equal(resolve_configuration(doc,doc['configurations'][0]['id'],{'root_id':doc['continuation']['workspaces'][0]['root_id'],'relative_path':''})['instruction_ids'],doc['continuation']['configuration_bindings'][0]['effective_order']))
check('predicate-component-prefix',lambda:equal(predicate({'path_prefix':'src'},{'relative_path':'src-other/file'}),False))
check('predicate-combination',lambda:equal(predicate({'all':[{'event_kind_in':['tool_call']},{'not':{'root_is':'other'}}]},{'event_kind':'tool_call','root_id':'work'}),True))
Expand Down
Loading
Loading