pi-codex-marketplace 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.md +134 -0
- package/extensions/pi/git-registration.ts +138 -0
- package/extensions/pi/index.ts +293 -0
- package/extensions/pi/installation.ts +90 -0
- package/extensions/pi/journal.ts +80 -0
- package/extensions/pi/lifecycle.ts +285 -0
- package/extensions/pi/registration.ts +143 -0
- package/extensions/pi/scope-overrides.ts +170 -0
- package/package.json +60 -0
- package/src/barrier/global-barrier.ts +105 -0
- package/src/bridge-state/atomic.ts +237 -0
- package/src/bridge-state/index.ts +5 -0
- package/src/bridge-state/migrate.ts +261 -0
- package/src/bridge-state/paths.ts +75 -0
- package/src/bridge-state/repair.ts +185 -0
- package/src/bridge-state/schema.ts +70 -0
- package/src/bridge-state/store.ts +489 -0
- package/src/bridge-state/types.ts +170 -0
- package/src/cache/index.ts +2 -0
- package/src/cache/paths.ts +42 -0
- package/src/cache/source-cache.ts +365 -0
- package/src/compatibility/index.ts +1 -0
- package/src/compatibility/profile.ts +328 -0
- package/src/installation/flow.ts +443 -0
- package/src/installation/index.ts +1 -0
- package/src/installation/inspection.ts +129 -0
- package/src/journal/active-chains.ts +99 -0
- package/src/journal/index.ts +3 -0
- package/src/journal/journal.ts +215 -0
- package/src/journal/types.ts +49 -0
- package/src/lifecycle/index.ts +5 -0
- package/src/lifecycle/rebind.ts +290 -0
- package/src/lifecycle/refresh.ts +407 -0
- package/src/lifecycle/removal.ts +457 -0
- package/src/lifecycle/update-plan.ts +222 -0
- package/src/lifecycle/update.ts +303 -0
- package/src/projection/collision.ts +120 -0
- package/src/projection/effective-state.ts +182 -0
- package/src/projection/index.ts +4 -0
- package/src/projection/overrides.ts +230 -0
- package/src/projection/project.ts +359 -0
- package/src/reconciliation/startup.ts +144 -0
- package/src/registration/budget.ts +28 -0
- package/src/registration/catalog.ts +224 -0
- package/src/registration/contained.ts +140 -0
- package/src/registration/fence.ts +86 -0
- package/src/registration/findings.ts +188 -0
- package/src/registration/flow.ts +619 -0
- package/src/registration/git-acquisition.ts +481 -0
- package/src/registration/git-flow.ts +654 -0
- package/src/registration/git-locator.ts +380 -0
- package/src/registration/git-selector.ts +279 -0
- package/src/registration/index.ts +16 -0
- package/src/registration/receipt.ts +305 -0
- package/src/registration/registration.ts +102 -0
- package/src/registration/snapshot.ts +382 -0
- package/src/registration/source-key.ts +111 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Startup Reconciliation — global-first verification and reconciliation pass on session start.
|
|
3
|
+
* See CONTEXT.md: Startup reconciliation, Global Pending Barrier, Pending Application.
|
|
4
|
+
*
|
|
5
|
+
* Rules:
|
|
6
|
+
* - At most one reconciliation pass per startup.
|
|
7
|
+
* - Global-first: Global scope is reconciled first.
|
|
8
|
+
* - Only scopes with enabled contributions, active recovery, or journal repair execute and produce a receipt.
|
|
9
|
+
* - If no work needed (clean/empty), no-op and does NOT generate empty receipts.
|
|
10
|
+
* - If Global recovery cannot reach a clean terminal state, Global Pending Barrier blocks Project reconciliation.
|
|
11
|
+
* - Reconciles Pending Application without implicit activation or auto-rollback.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readBridgeStateSync } from '../bridge-state/store.js';
|
|
15
|
+
import type { Scope } from '../bridge-state/types.js';
|
|
16
|
+
import { checkGlobalPendingBarrier, globalBarrierFinding } from '../barrier/global-barrier.js';
|
|
17
|
+
import { appendReceipt, readReceiptJournal } from '../journal/journal.js';
|
|
18
|
+
import { createReceipt, type AttemptReceipt } from '../registration/receipt.js';
|
|
19
|
+
|
|
20
|
+
export interface StartupReconciliationOptions {
|
|
21
|
+
cwd?: string;
|
|
22
|
+
agentDir?: string;
|
|
23
|
+
projectTrusted?: boolean;
|
|
24
|
+
verifyReload?: (scope: Scope) => Promise<boolean> | boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface StartupReconciliationResult {
|
|
28
|
+
globalReconciled: boolean;
|
|
29
|
+
globalReceipt?: AttemptReceipt;
|
|
30
|
+
projectReconciled: boolean;
|
|
31
|
+
projectReceipt?: AttemptReceipt;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function runStartupReconciliation(
|
|
35
|
+
opts: StartupReconciliationOptions = {},
|
|
36
|
+
): Promise<StartupReconciliationResult> {
|
|
37
|
+
const result: StartupReconciliationResult = {
|
|
38
|
+
globalReconciled: false,
|
|
39
|
+
projectReconciled: false,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const defaultVerify = async (_s: Scope) => true;
|
|
43
|
+
const verify = opts.verifyReload ?? defaultVerify;
|
|
44
|
+
|
|
45
|
+
// 1. Global Scope Pass
|
|
46
|
+
const globalState = readBridgeStateSync('global', opts);
|
|
47
|
+
const globalJournal = await readReceiptJournal('global', opts);
|
|
48
|
+
|
|
49
|
+
const globalPendingChain = globalJournal.activeChains.find(
|
|
50
|
+
(c) => c.condition === 'pending-application',
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const globalHasEnabledInstallations =
|
|
54
|
+
globalState.status === 'ok' &&
|
|
55
|
+
globalState.state!.installations.some((i) => i.installationState === 'enabled');
|
|
56
|
+
|
|
57
|
+
if (globalPendingChain || (globalHasEnabledInstallations && globalJournal.receipts.length > 0)) {
|
|
58
|
+
const rev = globalState.status === 'ok' ? globalState.state!.stateRevision : '0';
|
|
59
|
+
const applied = await verify('global');
|
|
60
|
+
const summary = applied ? 'Completed' : 'Pending Application';
|
|
61
|
+
|
|
62
|
+
const receipt = createReceipt({
|
|
63
|
+
kind: 'Reconciliation',
|
|
64
|
+
operation: 'Startup Reconciliation',
|
|
65
|
+
scope: 'global',
|
|
66
|
+
trigger: 'startup reconciliation global',
|
|
67
|
+
expectedStateRevision: rev,
|
|
68
|
+
observedStateRevision: applied ? rev : undefined,
|
|
69
|
+
durableOutcome: 'unchanged',
|
|
70
|
+
runtimeOutcome: applied ? 'applied' : 'pending-application',
|
|
71
|
+
summary,
|
|
72
|
+
recoversReceiptId: globalPendingChain?.rootReceiptId,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await appendReceipt('global', receipt, opts);
|
|
76
|
+
result.globalReconciled = true;
|
|
77
|
+
result.globalReceipt = receipt;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 2. Check Global Barrier before attempting Project Scope
|
|
81
|
+
const barrier = await checkGlobalPendingBarrier(opts);
|
|
82
|
+
|
|
83
|
+
// 3. Project Scope Pass
|
|
84
|
+
const projectState = readBridgeStateSync('project', opts);
|
|
85
|
+
const projectJournal = await readReceiptJournal('project', opts);
|
|
86
|
+
|
|
87
|
+
const projectPendingChain = projectJournal.activeChains.find(
|
|
88
|
+
(c) => c.condition === 'pending-application',
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const projectHasEnabledInstallations =
|
|
92
|
+
projectState.status === 'ok' &&
|
|
93
|
+
projectState.state!.installations.some((i) => i.installationState === 'enabled');
|
|
94
|
+
|
|
95
|
+
const projectNeedsWork =
|
|
96
|
+
Boolean(projectPendingChain) ||
|
|
97
|
+
(projectHasEnabledInstallations && projectJournal.receipts.length > 0);
|
|
98
|
+
|
|
99
|
+
if (projectNeedsWork) {
|
|
100
|
+
const rev = projectState.status === 'ok' ? projectState.state!.stateRevision : '0';
|
|
101
|
+
|
|
102
|
+
if (barrier.active) {
|
|
103
|
+
// Blocked by Global Pending Barrier
|
|
104
|
+
const receipt = createReceipt({
|
|
105
|
+
kind: 'Reconciliation',
|
|
106
|
+
operation: 'Startup Reconciliation',
|
|
107
|
+
scope: 'project',
|
|
108
|
+
trigger: 'startup reconciliation project',
|
|
109
|
+
expectedStateRevision: rev,
|
|
110
|
+
durableOutcome: 'unchanged',
|
|
111
|
+
runtimeOutcome: 'none',
|
|
112
|
+
summary: 'Blocked',
|
|
113
|
+
findings: [barrier.finding ?? globalBarrierFinding(barrier.reason ?? 'Global Barrier active')],
|
|
114
|
+
recoversReceiptId: projectPendingChain?.rootReceiptId,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await appendReceipt('project', receipt, opts);
|
|
118
|
+
result.projectReconciled = true;
|
|
119
|
+
result.projectReceipt = receipt;
|
|
120
|
+
} else if (opts.projectTrusted === true) {
|
|
121
|
+
const applied = await verify('project');
|
|
122
|
+
const summary = applied ? 'Completed' : 'Pending Application';
|
|
123
|
+
|
|
124
|
+
const receipt = createReceipt({
|
|
125
|
+
kind: 'Reconciliation',
|
|
126
|
+
operation: 'Startup Reconciliation',
|
|
127
|
+
scope: 'project',
|
|
128
|
+
trigger: 'startup reconciliation project',
|
|
129
|
+
expectedStateRevision: rev,
|
|
130
|
+
observedStateRevision: applied ? rev : undefined,
|
|
131
|
+
durableOutcome: 'unchanged',
|
|
132
|
+
runtimeOutcome: applied ? 'applied' : 'pending-application',
|
|
133
|
+
summary,
|
|
134
|
+
recoversReceiptId: projectPendingChain?.rootReceiptId,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await appendReceipt('project', receipt, opts);
|
|
138
|
+
result.projectReconciled = true;
|
|
139
|
+
result.projectReceipt = receipt;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation Ruleset / Validation Budget — versioned Bridge contracts applied during validation.
|
|
3
|
+
* See CONTEXT.md: Validation Ruleset, Validation Budget.
|
|
4
|
+
*
|
|
5
|
+
* A changed ruleset or budget requires revalidation even when source bytes are unchanged.
|
|
6
|
+
* Exceeding a budget produces a Blocking Finding at the owning boundary (source/catalog/entry),
|
|
7
|
+
* never partial or best-effort validation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const VALIDATION_RULESET = 'ruleset:v1';
|
|
11
|
+
export const VALIDATION_BUDGET = 'budget:v1';
|
|
12
|
+
/** Compatibility Profile reference bound into every snapshot (full profile contract is #19). */
|
|
13
|
+
export const COMPATIBILITY_PROFILE = 'profile:v1';
|
|
14
|
+
|
|
15
|
+
export const BUDGET = {
|
|
16
|
+
/** Maximum tree depth under the Marketplace Root (1 = immediate children). */
|
|
17
|
+
maxTreeDepth: 32,
|
|
18
|
+
/** Maximum inspected files (regular files) under the root. */
|
|
19
|
+
maxFiles: 10_000,
|
|
20
|
+
/** Maximum total bytes of inspected file content. */
|
|
21
|
+
maxTotalBytes: 512 * 1024 * 1024,
|
|
22
|
+
/** Maximum Marketplace Catalog file bytes. */
|
|
23
|
+
maxCatalogBytes: 1 * 1024 * 1024,
|
|
24
|
+
/** Maximum plugins entries in a catalog. */
|
|
25
|
+
maxEntries: 1024,
|
|
26
|
+
/** Maximum declared marketplace name length. */
|
|
27
|
+
maxNameLength: 64,
|
|
28
|
+
} as const;
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marketplace Catalog — canonical `.agents/plugins/marketplace.json` object within a Marketplace Root.
|
|
3
|
+
* See CONTEXT.md: Marketplace Catalog, Marketplace Entry, Marketplace Entry ID.
|
|
4
|
+
*
|
|
5
|
+
* Only the canonical object participates in Bridge ingestion; legacy/antigravity shapes are ignored
|
|
6
|
+
* (detected as catalog missing). Each entry is enumerated with a snapshot-scoped Marketplace Entry ID
|
|
7
|
+
* `/plugins/<zero-based ordinal>`. Non-local entry source kinds are recognized only as Unavailable
|
|
8
|
+
* Entries rather than recursively acquired.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { CODE, RULE, blocking, type ValidationFinding } from './findings.js';
|
|
12
|
+
import { BUDGET } from './budget.js';
|
|
13
|
+
|
|
14
|
+
export type EntryType = 'local' | 'git' | 'unsupported';
|
|
15
|
+
|
|
16
|
+
const LOCAL_KINDS = new Set(['local', 'directory', 'dir', 'file', 'path', 'src']);
|
|
17
|
+
const NONLOCAL_KINDS = new Set(['git', 'github', 'repo', 'url', 'remote', 'http', 'https']);
|
|
18
|
+
|
|
19
|
+
/** Matches lowercase kebab-case names (Codex marketplace declared name). */
|
|
20
|
+
export const KEBAB_NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
21
|
+
|
|
22
|
+
export interface MarketplaceEntry {
|
|
23
|
+
/** Snapshot-scoped identity: `/plugins/<ordinal>`. */
|
|
24
|
+
entryId: string;
|
|
25
|
+
/** Zero-based ordinal in the plugins[] array. */
|
|
26
|
+
ordinal: number;
|
|
27
|
+
/** Declared entry name if present. */
|
|
28
|
+
name?: string;
|
|
29
|
+
/** Recognized source kind. */
|
|
30
|
+
type: EntryType;
|
|
31
|
+
/** Declared `./`-relative Contained Path for local entries. */
|
|
32
|
+
path?: string;
|
|
33
|
+
/** Whether this entry can supply an activatable plugin (else Unavailable). */
|
|
34
|
+
available: boolean;
|
|
35
|
+
/** Human reason when unavailable. */
|
|
36
|
+
unavailableReason?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Catalog {
|
|
40
|
+
/** Declared validated lowercase kebab-case name. */
|
|
41
|
+
name: string;
|
|
42
|
+
/** Enumerated entries (snapshot-scoped). */
|
|
43
|
+
entries: MarketplaceEntry[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface CatalogResult {
|
|
47
|
+
ok: boolean;
|
|
48
|
+
catalog?: Catalog;
|
|
49
|
+
findings: ValidationFinding[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function classifyKind(raw: unknown): { type: EntryType; reason?: string } {
|
|
53
|
+
if (raw === undefined || raw === null || raw === '') return { type: 'local' };
|
|
54
|
+
const s = String(raw).toLowerCase();
|
|
55
|
+
if (LOCAL_KINDS.has(s)) return { type: 'local' };
|
|
56
|
+
if (NONLOCAL_KINDS.has(s)) return { type: 'git' };
|
|
57
|
+
return { type: 'unsupported' };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Parse a parsed `.agents/plugins/marketplace.json` object structurally.
|
|
62
|
+
* Structural/catalog-identity failures are Blocking (deny Registration); per-entry inability to
|
|
63
|
+
* resolve to a plugin is an Unavailable Entry (disclosed, non-blocking).
|
|
64
|
+
*/
|
|
65
|
+
export function parseCatalog(obj: unknown, opts: { scope: 'global' | 'project' }): CatalogResult {
|
|
66
|
+
const findings: ValidationFinding[] = [];
|
|
67
|
+
|
|
68
|
+
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
findings: [
|
|
72
|
+
blocking({
|
|
73
|
+
code: CODE.CATALOG_MALFORMED,
|
|
74
|
+
phase: 'validation',
|
|
75
|
+
target: 'catalog',
|
|
76
|
+
scope: opts.scope,
|
|
77
|
+
pointer: '/',
|
|
78
|
+
rule: RULE.CATALOG_MALFORMED,
|
|
79
|
+
outcome: 'marketplace.json is not an object',
|
|
80
|
+
}),
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const o = obj as Record<string, unknown>;
|
|
85
|
+
|
|
86
|
+
if (typeof o.name !== 'string' || o.name.trim().length === 0) {
|
|
87
|
+
findings.push(
|
|
88
|
+
blocking({
|
|
89
|
+
code: CODE.CATALOG_NAME_INVALID,
|
|
90
|
+
phase: 'validation',
|
|
91
|
+
target: 'catalog',
|
|
92
|
+
scope: opts.scope,
|
|
93
|
+
pointer: '/name',
|
|
94
|
+
rule: RULE.CATALOG_NAME_INVALID,
|
|
95
|
+
outcome: 'declared marketplace name is missing',
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
} else if (!KEBAB_NAME_RE.test(o.name.trim())) {
|
|
99
|
+
findings.push(
|
|
100
|
+
blocking({
|
|
101
|
+
code: CODE.CATALOG_NAME_INVALID,
|
|
102
|
+
phase: 'validation',
|
|
103
|
+
target: 'catalog',
|
|
104
|
+
scope: opts.scope,
|
|
105
|
+
pointer: '/name',
|
|
106
|
+
rule: RULE.CATALOG_NAME_INVALID,
|
|
107
|
+
outcome: `declared marketplace name '${o.name}' is not lowercase kebab-case`,
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!Array.isArray(o.plugins)) {
|
|
113
|
+
findings.push(
|
|
114
|
+
blocking({
|
|
115
|
+
code: CODE.CATALOG_MALFORMED,
|
|
116
|
+
phase: 'validation',
|
|
117
|
+
target: 'catalog',
|
|
118
|
+
scope: opts.scope,
|
|
119
|
+
pointer: '/plugins',
|
|
120
|
+
rule: RULE.CATALOG_MALFORMED,
|
|
121
|
+
outcome: 'plugins is not an array',
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
findings,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (o.plugins.length > BUDGET.maxEntries) {
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
findings: [blocking({
|
|
134
|
+
code: CODE.BUDGET_EXCEEDED,
|
|
135
|
+
phase: 'validation',
|
|
136
|
+
target: 'catalog',
|
|
137
|
+
scope: opts.scope,
|
|
138
|
+
pointer: '/plugins',
|
|
139
|
+
rule: RULE.BUDGET_EXCEEDED,
|
|
140
|
+
outcome: `Validation Budget exceeded: ${o.plugins.length} entries > ${BUDGET.maxEntries}`,
|
|
141
|
+
})],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const entries: MarketplaceEntry[] = [];
|
|
146
|
+
o.plugins.forEach((entryRaw, index) => {
|
|
147
|
+
const entryId = `/plugins/${index}`;
|
|
148
|
+
if (typeof entryRaw !== 'object' || entryRaw === null || Array.isArray(entryRaw)) {
|
|
149
|
+
findings.push(
|
|
150
|
+
blocking({
|
|
151
|
+
code: CODE.CATALOG_ENTRY_MALFORMED,
|
|
152
|
+
phase: 'validation',
|
|
153
|
+
target: 'entry',
|
|
154
|
+
scope: opts.scope,
|
|
155
|
+
pointer: entryId,
|
|
156
|
+
rule: RULE.CATALOG_ENTRY_MALFORMED,
|
|
157
|
+
outcome: 'marketplace entry is not an object',
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
entries.push({ entryId, ordinal: index, type: 'unsupported', available: false, unavailableReason: 'malformed entry' });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const e = entryRaw as Record<string, unknown>;
|
|
164
|
+
// Codex Marketplace v1 uses `source: { source: "local", path: "./…" }`.
|
|
165
|
+
// Retain the earlier flat shape as a backward-compatible input, but derive the canonical
|
|
166
|
+
// source kind/path from the nested object when present.
|
|
167
|
+
const nestedSource = typeof e.source === 'object' && e.source !== null && !Array.isArray(e.source)
|
|
168
|
+
? e.source as Record<string, unknown>
|
|
169
|
+
: undefined;
|
|
170
|
+
const nestedKind = nestedSource ? classifyKind(nestedSource.source ?? nestedSource.type) : undefined;
|
|
171
|
+
const flatKind = classifyKind(e.type ?? e.kind);
|
|
172
|
+
const nestedPath = typeof nestedSource?.path === 'string' ? nestedSource.path : undefined;
|
|
173
|
+
const flatPath = typeof e.path === 'string' ? e.path : undefined;
|
|
174
|
+
// The canonical nested v1 source is an indivisible declaration. Never mix a flat kind/path
|
|
175
|
+
// with it: a conflicting flat `type: local` must not disguise nested `source: git`.
|
|
176
|
+
if (nestedSource && ((e.type !== undefined || e.kind !== undefined) && flatKind.type !== nestedKind!.type || (flatPath !== undefined && flatPath !== nestedPath))) {
|
|
177
|
+
entries.push({ entryId, ordinal: index, type: 'unsupported', available: false, unavailableReason: 'conflicting nested and flat source declaration' });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const kind = nestedKind ?? flatKind;
|
|
181
|
+
const name = typeof e.name === 'string' ? e.name : undefined;
|
|
182
|
+
const path = nestedSource ? nestedPath : flatPath;
|
|
183
|
+
|
|
184
|
+
if (kind.type !== 'local') {
|
|
185
|
+
// Recognized only as an Unavailable Entry (never recursively acquired). Disclosed, not a finding.
|
|
186
|
+
entries.push({
|
|
187
|
+
entryId,
|
|
188
|
+
ordinal: index,
|
|
189
|
+
name,
|
|
190
|
+
type: kind.type,
|
|
191
|
+
path,
|
|
192
|
+
available: false,
|
|
193
|
+
unavailableReason: 'unsupported source kind',
|
|
194
|
+
});
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// local entry
|
|
199
|
+
if (!path) {
|
|
200
|
+
entries.push({
|
|
201
|
+
entryId,
|
|
202
|
+
ordinal: index,
|
|
203
|
+
name,
|
|
204
|
+
type: 'local',
|
|
205
|
+
path,
|
|
206
|
+
available: false,
|
|
207
|
+
unavailableReason: 'cannot resolve to a Plugin: no local path declared',
|
|
208
|
+
});
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
entries.push({ entryId, ordinal: index, name, type: 'local', path, available: true });
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// Duplicate entry names are distinguished by Entry ID; not a Blocking here (identity is ordinal-based).
|
|
215
|
+
const blocked = findings.some((f) => f.classification === 'blocking');
|
|
216
|
+
if (!blocked) {
|
|
217
|
+
return {
|
|
218
|
+
ok: true,
|
|
219
|
+
catalog: { name: String(o.name).trim(), entries },
|
|
220
|
+
findings,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
return { ok: false, catalog: { name: typeof o.name === 'string' ? o.name.trim() : '', entries }, findings };
|
|
224
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contained Path / Contained Symlink — strict containment within an owning root.
|
|
3
|
+
* See CONTEXT.md: Contained Path, Contained Symlink.
|
|
4
|
+
*
|
|
5
|
+
* Contained Path: a declared `./`-relative path with no absolute, backslash, NUL, dot, or parent
|
|
6
|
+
* segment whose canonical target remains within its owning root. Existence without containment is
|
|
7
|
+
* insufficient.
|
|
8
|
+
*
|
|
9
|
+
* Contained Symlink: a symlink whose canonical target is a regular file or directory within the same
|
|
10
|
+
* owning root. Broken, looping, special-file, or root-external symlinks are Blocking Findings.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { realpathSync, readlinkSync, lstatSync, statSync } from 'node:fs';
|
|
14
|
+
import { isAbsolute, sep } from 'node:path';
|
|
15
|
+
|
|
16
|
+
export interface PathSyntax {
|
|
17
|
+
ok: boolean;
|
|
18
|
+
reason?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Validate `./`-relative path syntax (no absolute, backslash, NUL, dot, or parent segments). */
|
|
22
|
+
export function containedPathSyntax(p: string): PathSyntax {
|
|
23
|
+
if (typeof p !== 'string' || p.length === 0) return { ok: false, reason: 'empty path' };
|
|
24
|
+
if (p.includes('\0')) return { ok: false, reason: 'NUL byte' };
|
|
25
|
+
if (p.includes('\\')) return { ok: false, reason: 'backslash' };
|
|
26
|
+
if (isAbsolute(p)) return { ok: false, reason: 'absolute path' };
|
|
27
|
+
if (!p.startsWith('./')) return { ok: false, reason: 'not ./ relative' };
|
|
28
|
+
const segments = p.split('/');
|
|
29
|
+
// "./x" -> segments ['', '.', 'x']; a bare "." or ".." component anywhere is rejected
|
|
30
|
+
const rest = segments.slice(1);
|
|
31
|
+
for (const seg of rest) {
|
|
32
|
+
if (seg === '' || seg === '.' || seg === '..') {
|
|
33
|
+
return { ok: false, reason: `dot or parent segment '${seg}'` };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { ok: true };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type ContainmentBlockClass = 'path' | 'symlink';
|
|
40
|
+
|
|
41
|
+
export type ContainmentOutcome =
|
|
42
|
+
| { kind: 'ok'; canonicalPath: string }
|
|
43
|
+
| { kind: 'blocking'; reason: string; blockClass: ContainmentBlockClass } // safety violation
|
|
44
|
+
| { kind: 'missing' } // cannot resolve (Unavailable, not blocking)
|
|
45
|
+
|
|
46
|
+
export interface ContainmentResult {
|
|
47
|
+
outcome: ContainmentOutcome;
|
|
48
|
+
/** Symlink target when the resolved target is a symlink, else undefined. */
|
|
49
|
+
symlinkTarget?: string;
|
|
50
|
+
type: 'file' | 'directory' | 'symlink' | 'special';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isWithin(root: string, target: string): boolean {
|
|
54
|
+
if (target === root) return true;
|
|
55
|
+
const prefix = root.endsWith(sep) ? root : root + sep;
|
|
56
|
+
return target.startsWith(prefix);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve a `./`-relative declared path against a canonical owning root and verify strict
|
|
61
|
+
* containment (including symlink targets) within the root.
|
|
62
|
+
*/
|
|
63
|
+
export function resolveContained(root: string, relPath: string, checkType: 'any' | 'file' | 'directory' = 'any'): ContainmentResult {
|
|
64
|
+
const syntax = containedPathSyntax(relPath);
|
|
65
|
+
if (!syntax.ok) {
|
|
66
|
+
return {
|
|
67
|
+
outcome: { kind: 'blocking', blockClass: 'path', reason: `path containment syntax violation: ${syntax.reason}` },
|
|
68
|
+
type: 'special',
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const abs = root + sep + relPath.slice(2);
|
|
73
|
+
let canonical: string;
|
|
74
|
+
try {
|
|
75
|
+
canonical = realpathSync.native(abs);
|
|
76
|
+
} catch (e) {
|
|
77
|
+
const err = e as NodeJS.ErrnoException;
|
|
78
|
+
if (err.code === 'ENOENT') return { outcome: { kind: 'missing' }, type: 'special' };
|
|
79
|
+
return { outcome: { kind: 'blocking', blockClass: 'symlink', reason: `unable to resolve: ${err.message}` }, type: 'special' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// containment check
|
|
83
|
+
if (!isWithin(root, canonical)) {
|
|
84
|
+
return {
|
|
85
|
+
outcome: { kind: 'blocking', blockClass: 'path', reason: `canonical target '${canonical}' escapes owning root '${root}'` },
|
|
86
|
+
type: 'special',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let type: ContainmentResult['type'];
|
|
91
|
+
let symlinkTarget: string | undefined;
|
|
92
|
+
try {
|
|
93
|
+
const lst = lstatSync(abs);
|
|
94
|
+
if (lst.isSymbolicLink()) {
|
|
95
|
+
type = 'symlink';
|
|
96
|
+
symlinkTarget = readlinkSync(abs);
|
|
97
|
+
// canonical already resolved; for symlink, target file must be a regular file or directory
|
|
98
|
+
try {
|
|
99
|
+
const st = statSync(abs);
|
|
100
|
+
if (!st.isFile() && !st.isDirectory()) {
|
|
101
|
+
return {
|
|
102
|
+
outcome: { kind: 'blocking', blockClass: 'symlink', reason: 'contained symlink resolves to a special file' },
|
|
103
|
+
type: 'symlink',
|
|
104
|
+
symlinkTarget,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
} catch (e) {
|
|
108
|
+
const err = e as NodeJS.ErrnoException;
|
|
109
|
+
if (err.code === 'ENOENT') {
|
|
110
|
+
return {
|
|
111
|
+
outcome: { kind: 'blocking', blockClass: 'symlink', reason: 'broken symlink (target does not exist)' },
|
|
112
|
+
type: 'symlink',
|
|
113
|
+
symlinkTarget,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
outcome: { kind: 'blocking', blockClass: 'symlink', reason: `symlink error: ${err.message}` },
|
|
118
|
+
type: 'symlink',
|
|
119
|
+
symlinkTarget,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
} else if (lst.isFile()) {
|
|
123
|
+
type = 'file';
|
|
124
|
+
} else if (lst.isDirectory()) {
|
|
125
|
+
type = 'directory';
|
|
126
|
+
} else {
|
|
127
|
+
return { outcome: { kind: 'blocking', blockClass: 'path', reason: 'not a regular file or directory' }, type: 'special' };
|
|
128
|
+
}
|
|
129
|
+
} catch (e) {
|
|
130
|
+
const err = e as NodeJS.ErrnoException;
|
|
131
|
+
if (err.code === 'ENOENT') return { outcome: { kind: 'missing' }, type: 'special' };
|
|
132
|
+
return { outcome: { kind: 'blocking', blockClass: 'path', reason: `lstat error: ${err.message}` }, type: 'special' };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (checkType !== 'any' && type !== checkType && type !== 'symlink') {
|
|
136
|
+
return { outcome: { kind: 'blocking', blockClass: 'path', reason: `expected ${checkType} but got ${type}` }, type };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { outcome: { kind: 'ok', canonicalPath: canonical }, type, symlinkTarget };
|
|
140
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attempt Fence — per-scope exclusivity boundary for Lifecycle Operations.
|
|
3
|
+
* See CONTEXT.md: Attempt Fence, Global Pending Barrier, Blocking Finding.
|
|
4
|
+
*
|
|
5
|
+
* Admits only one attempt at a time per scope. The fence is acquired before preflight and held
|
|
6
|
+
* until the attempt reaches a terminal outcome (committed / declined / blocked / stale). A second
|
|
7
|
+
* concurrent attempt on the same scope is denied with an ATTEMPT_IN_PROGRESS Blocking Finding.
|
|
8
|
+
*
|
|
9
|
+
* For Project Scope: Global Pending Barrier is checked prior to fence acquisition. If active,
|
|
10
|
+
* the attempt is blocked with GLOBAL_PENDING_BARRIER.
|
|
11
|
+
*
|
|
12
|
+
* Cross-process: a lock file sibling to the scope's state document is used (O_EXCL advisory lock).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { acquireLock, releaseLock } from '../bridge-state/atomic.js';
|
|
16
|
+
import { getFencePath, getStatePath } from '../bridge-state/paths.js';
|
|
17
|
+
import type { Scope } from '../bridge-state/types.js';
|
|
18
|
+
import { checkGlobalPendingBarrier } from '../barrier/global-barrier.js';
|
|
19
|
+
import { CODE, RULE, blocking, type ValidationFinding } from './findings.js';
|
|
20
|
+
|
|
21
|
+
export interface AttemptFenceHandle {
|
|
22
|
+
scope: Scope;
|
|
23
|
+
release(): void;
|
|
24
|
+
released: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface AttemptFenceResult {
|
|
28
|
+
ok: boolean;
|
|
29
|
+
handle?: AttemptFenceHandle;
|
|
30
|
+
finding?: ValidationFinding;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const FENCE_LOCK_TIMEOUT_MS = 300;
|
|
34
|
+
|
|
35
|
+
/** Acquire the per-scope Attempt Fence (lock file). Denied when another attempt holds it or Global Barrier active. */
|
|
36
|
+
export async function acquireAttemptFence(
|
|
37
|
+
scope: Scope,
|
|
38
|
+
opts: { cwd?: string; agentDir?: string; fenceTimeoutMs?: number; projectTrusted?: boolean } = {},
|
|
39
|
+
): Promise<AttemptFenceResult> {
|
|
40
|
+
// Global Pending Barrier: blocks Project Scope attempts
|
|
41
|
+
if (scope === 'project') {
|
|
42
|
+
const barrier = await checkGlobalPendingBarrier(opts);
|
|
43
|
+
if (barrier.active) {
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
finding: barrier.finding,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const statePath = getStatePath(scope, opts);
|
|
52
|
+
const fencePath = getFencePath(statePath);
|
|
53
|
+
const timeout = opts.fenceTimeoutMs ?? FENCE_LOCK_TIMEOUT_MS;
|
|
54
|
+
|
|
55
|
+
let fd: number;
|
|
56
|
+
try {
|
|
57
|
+
fd = await acquireLock(fencePath, timeout);
|
|
58
|
+
} catch {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
finding: blocking({
|
|
62
|
+
code: CODE.ATTEMPT_IN_PROGRESS,
|
|
63
|
+
phase: 'admission',
|
|
64
|
+
target: 'attempt',
|
|
65
|
+
scope,
|
|
66
|
+
pointer: '',
|
|
67
|
+
rule: RULE.ATTEMPT_IN_PROGRESS,
|
|
68
|
+
outcome: `another ${scope} attempt is in progress; only one attempt at a time per scope (no queue)`,
|
|
69
|
+
}),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let released = false;
|
|
74
|
+
const handle: AttemptFenceHandle = {
|
|
75
|
+
scope,
|
|
76
|
+
get released(): boolean {
|
|
77
|
+
return released;
|
|
78
|
+
},
|
|
79
|
+
release(): void {
|
|
80
|
+
if (released) return;
|
|
81
|
+
released = true;
|
|
82
|
+
releaseLock(fd, fencePath);
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
return { ok: true, handle };
|
|
86
|
+
}
|