@piagent/platform 1.1.6 → 1.1.8
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/CHANGELOG.md +22 -0
- package/README.md +1 -1
- package/docs/capability-packs.md +22 -1
- package/docs/command-reference-vietnamese.md +3 -2
- package/docs/distribution-standard.md +7 -7
- package/docs/operator-manual-vietnamese.md +7 -7
- package/docs/project-adapters.md +23 -0
- package/docs/publishing-for-teams.md +2 -0
- package/docs/release-install-policy.md +6 -6
- package/docs/security-threat-model.md +11 -1
- package/docs/team-onboarding.md +11 -3
- package/docs-site/index.html +2 -2
- package/package.json +2 -2
- package/packages/piagent-core/capabilities/capability-core.js +110 -8
- package/packages/piagent-core/capabilities/project-profile.js +83 -0
- package/packages/piagent-core/extensions/piagent-guard.ts +119 -26
- package/packages/piagent-core/extensions/update-check.js +170 -0
- package/packages/piagent-core/package.json +1 -1
- package/schemas/project-profile.schema.json +25 -7
- package/scripts/capability-catalog.mjs +28 -7
- package/scripts/init-project.sh +7 -0
- package/scripts/install-global.sh +3 -3
- package/scripts/profile-doctor.sh +13 -3
- package/scripts/runtime-policy-smoke.sh +12 -3
- package/scripts/team-doctor.sh +12 -3
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
// A project profile can either state its whole policy or name the adapter it
|
|
5
|
+
// takes that policy from.
|
|
6
|
+
//
|
|
7
|
+
// Stating the whole policy freezes it. The adapters ship with the platform and
|
|
8
|
+
// are corrected by it, but a project that copied one at onboarding time keeps
|
|
9
|
+
// the copy forever, so a global update leaves every existing project on the old
|
|
10
|
+
// rules. `extends` makes the policy a reference instead: the project keeps the
|
|
11
|
+
// parts that are about the project, and the installed platform supplies the
|
|
12
|
+
// rest.
|
|
13
|
+
//
|
|
14
|
+
// Profiles written before this existed carry no `extends` and keep working
|
|
15
|
+
// exactly as they did, self-contained.
|
|
16
|
+
|
|
17
|
+
const ADAPTER_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
18
|
+
|
|
19
|
+
export class ProjectProfileError extends Error {}
|
|
20
|
+
|
|
21
|
+
export function adapterProfileFile(platformRoot, name) {
|
|
22
|
+
if (typeof name !== "string") return undefined;
|
|
23
|
+
const trimmed = name.trim();
|
|
24
|
+
// The name indexes a directory inside the installed platform, so it is
|
|
25
|
+
// restricted to a plain slug rather than sanitised after the fact.
|
|
26
|
+
if (!ADAPTER_NAME.test(trimmed)) return undefined;
|
|
27
|
+
const candidate = path.join(platformRoot, "adapters", trimmed, "profile.json");
|
|
28
|
+
return fs.existsSync(candidate) ? candidate : undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readJson(file) {
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve a stored project profile document against the installed adapters.
|
|
41
|
+
* Returns the document to enforce, plus the adapter it came from when one was
|
|
42
|
+
* named. Any key the project states replaces the adapter's key whole — there is
|
|
43
|
+
* no per-entry merging of lists, because a half-merged protectedPaths is harder
|
|
44
|
+
* to reason about than a stated one.
|
|
45
|
+
*/
|
|
46
|
+
export function resolveProjectProfileDocument(platformRoot, stored) {
|
|
47
|
+
if (!stored || typeof stored !== "object" || Array.isArray(stored)) {
|
|
48
|
+
return { profile: stored, base: undefined };
|
|
49
|
+
}
|
|
50
|
+
if (stored.extends === undefined) return { profile: stored, base: undefined };
|
|
51
|
+
|
|
52
|
+
const file = adapterProfileFile(platformRoot, stored.extends);
|
|
53
|
+
if (!file) {
|
|
54
|
+
throw new ProjectProfileError(
|
|
55
|
+
`project profile extends "${String(stored.extends)}", which is not an adapter in the installed platform`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
const adapter = readJson(file);
|
|
59
|
+
if (!adapter) throw new ProjectProfileError(`adapter profile is unreadable: ${file}`);
|
|
60
|
+
|
|
61
|
+
const { extends: base, ...overrides } = stored;
|
|
62
|
+
return { profile: { ...adapter, ...overrides }, base };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function readProjectProfileDocument(platformRoot, profilePath) {
|
|
66
|
+
const stored = readJson(profilePath);
|
|
67
|
+
if (stored === undefined) return { stored: undefined, profile: undefined, base: undefined };
|
|
68
|
+
const { profile, base } = resolveProjectProfileDocument(platformRoot, stored);
|
|
69
|
+
return { stored, profile, base };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The document a project stores when it takes its policy from an adapter: what
|
|
74
|
+
* identifies this project, and nothing the platform already knows.
|
|
75
|
+
*/
|
|
76
|
+
export function buildExtendingProfile(base, projectId, displayName) {
|
|
77
|
+
return {
|
|
78
|
+
schemaVersion: 1,
|
|
79
|
+
extends: base,
|
|
80
|
+
projectId,
|
|
81
|
+
displayName
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -40,9 +40,12 @@ import {
|
|
|
40
40
|
redactSensitiveText
|
|
41
41
|
} from "./redaction-core.js";
|
|
42
42
|
import { detectProfileName } from "./project-shape.js";
|
|
43
|
+
import { evaluateUpdateCheck, readUpdateCache, startUpdateProbe } from "./update-check.js";
|
|
44
|
+
import { buildExtendingProfile, resolveProjectProfileDocument } from "../capabilities/project-profile.js";
|
|
43
45
|
import {
|
|
44
46
|
resolveCapabilityProfileDocument,
|
|
45
47
|
verifyCapabilityLock,
|
|
48
|
+
writeJsonAtomic,
|
|
46
49
|
writeProfileLockAtomic
|
|
47
50
|
} from "../capabilities/capability-core.js";
|
|
48
51
|
import { resolveCapabilitySourceRoots } from "../capabilities/capability-sources.js";
|
|
@@ -417,6 +420,28 @@ const DEFAULT_POLICY: BasePolicy = {
|
|
|
417
420
|
orchestrationPolicy: DEFAULT_ORCHESTRATION_POLICY
|
|
418
421
|
};
|
|
419
422
|
|
|
423
|
+
// Which platform supplies the adapters is fixed by where this file is installed,
|
|
424
|
+
// so it is resolved once here rather than threaded through every profile load.
|
|
425
|
+
const PLATFORM_ROOT = findPlatformRoot(path.dirname(fileURLToPath(import.meta.url)));
|
|
426
|
+
const UPDATE_CHECK_MODULE = fileURLToPath(new URL("./update-check.js", import.meta.url));
|
|
427
|
+
|
|
428
|
+
// The installed version, read from the package this file ships in. A maintainer
|
|
429
|
+
// working in the repository is not running a release and has nothing to update
|
|
430
|
+
// to, so the checkout is recognised by the one thing a published copy never
|
|
431
|
+
// carries and told nothing.
|
|
432
|
+
function installedPlatformVersion(): string | undefined {
|
|
433
|
+
if (fs.existsSync(path.join(PLATFORM_ROOT, ".git"))) return undefined;
|
|
434
|
+
return readJsonFile<{ version?: string }>(path.join(PLATFORM_ROOT, "package.json"))?.version;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function updateAvailabilityNotice(): string | undefined {
|
|
438
|
+
const installed = installedPlatformVersion();
|
|
439
|
+
if (!installed) return undefined;
|
|
440
|
+
const decision = evaluateUpdateCheck({ installed, cache: readUpdateCache(), now: Date.now() });
|
|
441
|
+
if (decision.probe) startUpdateProbe(UPDATE_CHECK_MODULE);
|
|
442
|
+
return decision.notice;
|
|
443
|
+
}
|
|
444
|
+
|
|
420
445
|
function loadPolicy(extensionDir: string): BasePolicy {
|
|
421
446
|
const root = findPackageRoot(extensionDir);
|
|
422
447
|
return readJsonFile<BasePolicy>(path.join(root, "policies", "base-policy.json")) ?? DEFAULT_POLICY;
|
|
@@ -433,17 +458,38 @@ function fallbackProfile(cwd: string, mode = "unprofiled"): ProjectProfile {
|
|
|
433
458
|
};
|
|
434
459
|
}
|
|
435
460
|
|
|
461
|
+
// A profile that names an adapter this platform does not have cannot be
|
|
462
|
+
// enforced, and the unprofiled shape carries no readOnlyPaths — falling back to
|
|
463
|
+
// it would quietly hand write access to whatever the profile was protecting. The
|
|
464
|
+
// session stays readable and says why instead.
|
|
465
|
+
function unresolvedProfile(cwd: string, reason: string): ProjectProfile {
|
|
466
|
+
return {
|
|
467
|
+
...fallbackProfile(cwd, "unresolved-profile-base"),
|
|
468
|
+
permissionProfile: "read-only",
|
|
469
|
+
unresolvedReason: reason
|
|
470
|
+
} as ProjectProfile;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function resolveStoredProfile(stored: ProjectProfile | undefined, cwd: string, missingMode: string): ProjectProfile {
|
|
474
|
+
if (!stored) return fallbackProfile(cwd, missingMode);
|
|
475
|
+
try {
|
|
476
|
+
return resolveProjectProfileDocument(PLATFORM_ROOT, stored).profile as ProjectProfile;
|
|
477
|
+
} catch (error) {
|
|
478
|
+
return unresolvedProfile(cwd, error instanceof Error ? error.message : String(error));
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
436
482
|
function loadProfile(cwd: string, projectTrusted = false): ProjectProfile {
|
|
437
483
|
const explicit = process.env.PIAGENT_PROFILE;
|
|
438
484
|
if (explicit && explicit.trim().length > 0) {
|
|
439
|
-
return readJsonFile<ProjectProfile>(explicit)
|
|
485
|
+
return resolveStoredProfile(readJsonFile<ProjectProfile>(explicit), cwd, "explicit-profile-unreadable");
|
|
440
486
|
}
|
|
441
487
|
|
|
442
488
|
if (!projectTrusted) {
|
|
443
489
|
return fallbackProfile(cwd, "unprofiled-global-package");
|
|
444
490
|
}
|
|
445
491
|
|
|
446
|
-
return readJsonFile<ProjectProfile>(path.join(cwd, ".pi", "piagent-profile.json"))
|
|
492
|
+
return resolveStoredProfile(readJsonFile<ProjectProfile>(path.join(cwd, ".pi", "piagent-profile.json")), cwd, "unprofiled");
|
|
447
493
|
}
|
|
448
494
|
|
|
449
495
|
function loadProfileFromContext(ctx: ExtensionContext): ProjectProfile {
|
|
@@ -530,6 +576,7 @@ function capabilitySourceRoots(
|
|
|
530
576
|
type ProjectCapabilityState = {
|
|
531
577
|
ok: boolean;
|
|
532
578
|
reason?: string;
|
|
579
|
+
repinned?: string;
|
|
533
580
|
filesystemRead?: string[];
|
|
534
581
|
filesystemWrite?: string[];
|
|
535
582
|
};
|
|
@@ -539,8 +586,18 @@ function verifyProjectCapabilityState(extensionDir: string, cwd: string, project
|
|
|
539
586
|
if (!projectTrusted) return { ok: true };
|
|
540
587
|
const profilePath = projectProfilePath(cwd);
|
|
541
588
|
if (!fs.existsSync(profilePath)) return { ok: true };
|
|
542
|
-
|
|
543
|
-
|
|
589
|
+
// Which packs a project selects can come from the adapter it extends, so the
|
|
590
|
+
// question has to be asked of the resolved document. Asking the stored one
|
|
591
|
+
// would skip the lock entirely for every project that references an adapter.
|
|
592
|
+
const stored = readJsonFile<ProjectProfile>(profilePath);
|
|
593
|
+
if (!stored) return { ok: true };
|
|
594
|
+
let profile: ProjectProfile;
|
|
595
|
+
try {
|
|
596
|
+
profile = resolveProjectProfileDocument(PLATFORM_ROOT, stored).profile as ProjectProfile;
|
|
597
|
+
} catch (error) {
|
|
598
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
599
|
+
}
|
|
600
|
+
if (!Array.isArray(profile.capabilityPacks)) return { ok: true };
|
|
544
601
|
const lockPath = path.join(cwd, ".pi", "piagent-profile.lock.json");
|
|
545
602
|
if (!fs.existsSync(lockPath)) return { ok: false, reason: "Capability lock is missing. Reapply the project profile." };
|
|
546
603
|
const lock = readJsonFile<Record<string, unknown>>(lockPath);
|
|
@@ -550,13 +607,30 @@ function verifyProjectCapabilityState(extensionDir: string, cwd: string, project
|
|
|
550
607
|
packageSource: projectPackageSource(cwd),
|
|
551
608
|
extraRoots: capabilitySourceRoots(cwd, profile)
|
|
552
609
|
});
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
610
|
+
if (verification.status === "blocked") {
|
|
611
|
+
return {
|
|
612
|
+
ok: false,
|
|
613
|
+
reason: `Capability lock does not match what this project agreed to: ${verification.reasons.join("; ")}. Reapply the project profile.`
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
const granted = {
|
|
617
|
+
filesystemRead: verification.expected.permissions.filesystemRead,
|
|
618
|
+
filesystemWrite: verification.expected.permissions.filesystemWrite
|
|
619
|
+
};
|
|
620
|
+
if (verification.status !== "repin") return { ok: true, ...granted };
|
|
621
|
+
|
|
622
|
+
// The grant is unchanged and only the platform build moved. Record the new
|
|
623
|
+
// build instead of stopping the session, which is what turned every release
|
|
624
|
+
// into a chore in every project.
|
|
625
|
+
try {
|
|
626
|
+
writeJsonAtomic(lockPath, verification.expected);
|
|
627
|
+
} catch (error) {
|
|
628
|
+
return {
|
|
629
|
+
ok: false,
|
|
630
|
+
reason: `Capability lock is behind the installed platform and could not be rewritten: ${error instanceof Error ? error.message : String(error)}`
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
return { ok: true, repinned: verification.reasons.join("; "), ...granted };
|
|
560
634
|
} catch (error) {
|
|
561
635
|
return { ok: false, reason: `Capability validation failed: ${error instanceof Error ? error.message : String(error)}` };
|
|
562
636
|
}
|
|
@@ -2854,12 +2928,18 @@ function buildTechStackManifest(profileName: string, selectedOptions: TechOption
|
|
|
2854
2928
|
};
|
|
2855
2929
|
}
|
|
2856
2930
|
|
|
2931
|
+
// `profile` is the document the project stores. When it names an adapter, the
|
|
2932
|
+
// lock is resolved against that adapter but still records the stored document,
|
|
2933
|
+
// so a later platform correction is not a lock the project has to re-agree to.
|
|
2857
2934
|
function writeProfileDocumentWithLock(extensionDir: string, cwd: string, profile: ProjectProfile): ProjectProfile {
|
|
2858
2935
|
const target = projectProfilePath(cwd);
|
|
2859
|
-
const
|
|
2936
|
+
const platformRoot = findPlatformRoot(extensionDir);
|
|
2937
|
+
const resolved = resolveProjectProfileDocument(platformRoot, profile).profile as ProjectProfile;
|
|
2938
|
+
const capabilityLock = resolveCapabilityProfileDocument(platformRoot, resolved, {
|
|
2860
2939
|
profileFile: "piagent-profile.json",
|
|
2940
|
+
storedProfile: profile,
|
|
2861
2941
|
packageSource: projectPackageSource(cwd),
|
|
2862
|
-
extraRoots: capabilitySourceRoots(cwd,
|
|
2942
|
+
extraRoots: capabilitySourceRoots(cwd, resolved)
|
|
2863
2943
|
});
|
|
2864
2944
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
2865
2945
|
writeProfileLockAtomic(target, profile, path.join(cwd, ".pi", "piagent-profile.lock.json"), capabilityLock);
|
|
@@ -2909,7 +2989,7 @@ function writeTechStackSelection(
|
|
|
2909
2989
|
};
|
|
2910
2990
|
writeProfileDocumentWithLock(extensionDir, cwd, profile);
|
|
2911
2991
|
ensureProjectContextPlaceholder(cwd);
|
|
2912
|
-
return { profile, manifest };
|
|
2992
|
+
return { profile: resolveProjectProfileDocument(findPlatformRoot(extensionDir), profile).profile as ProjectProfile, manifest };
|
|
2913
2993
|
}
|
|
2914
2994
|
|
|
2915
2995
|
function normalizeTechSelections(cwd: string, profileName: string, selections: Record<string, unknown> = {}, fillRecommended = true): {
|
|
@@ -3033,20 +3113,24 @@ function buildProfileFromAdapter(extensionDir: string, cwd: string, profileName:
|
|
|
3033
3113
|
const profile = readJsonFile<ProjectProfile>(source);
|
|
3034
3114
|
if (!profile) throw new Error(`Profile unreadable: ${profileName}`);
|
|
3035
3115
|
const projectName = path.basename(cwd);
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3116
|
+
// The project records which adapter it follows, not a copy of that adapter's
|
|
3117
|
+
// rules. A copy stops receiving corrections the moment it is written.
|
|
3118
|
+
return buildExtendingProfile(
|
|
3119
|
+
profileName,
|
|
3120
|
+
projectId ? slugify(projectId) : slugify(projectName),
|
|
3121
|
+
displayName?.trim() || titleize(projectName)
|
|
3122
|
+
) as ProjectProfile;
|
|
3041
3123
|
}
|
|
3042
3124
|
|
|
3043
3125
|
function writeProfileFromAdapter(extensionDir: string, cwd: string, profileName: string, overwrite = false, projectId?: string, displayName?: string): ProjectProfile {
|
|
3044
3126
|
const target = projectProfilePath(cwd);
|
|
3045
3127
|
if (fs.existsSync(target) && !overwrite) throw new Error(".pi/piagent-profile.json already exists. Pass overwrite=true to replace it.");
|
|
3046
|
-
const
|
|
3047
|
-
writeProfileDocumentWithLock(extensionDir, cwd,
|
|
3128
|
+
const stored = buildProfileFromAdapter(extensionDir, cwd, profileName, projectId, displayName);
|
|
3129
|
+
writeProfileDocumentWithLock(extensionDir, cwd, stored);
|
|
3048
3130
|
ensureProjectContextPlaceholder(cwd);
|
|
3049
|
-
|
|
3131
|
+
// Store the reference, hand back the document it resolves to: callers report
|
|
3132
|
+
// and trace what is now in force, not the four lines that point at it.
|
|
3133
|
+
return resolveProjectProfileDocument(findPlatformRoot(extensionDir), stored).profile as ProjectProfile;
|
|
3050
3134
|
}
|
|
3051
3135
|
|
|
3052
3136
|
function readTask(cwd: string, taskId: string): TaskContract | undefined {
|
|
@@ -3659,12 +3743,19 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
3659
3743
|
const permissionHint = ` permission=${permissionProfile.mode}`;
|
|
3660
3744
|
ctx.ui.notify(`Piagent Pi guard loaded: ${name}${profileHint}${permissionHint}${contextHint}`, preflight.recommendation === "fresh-session" ? "warning" : "info");
|
|
3661
3745
|
if (!capabilityState.ok) ctx.ui.notify(capabilityState.reason ?? "Capability validation failed.", "warning");
|
|
3746
|
+
if (capabilityState.repinned) {
|
|
3747
|
+
ctx.ui.notify(`Capability lock re-pinned: ${capabilityState.repinned}. The capabilities this project grants are unchanged.`, "info");
|
|
3748
|
+
}
|
|
3662
3749
|
const legacyWarning = legacyProjectStateWarning(ctx.cwd);
|
|
3663
3750
|
if (legacyWarning) ctx.ui.notify(legacyWarning, "warning");
|
|
3664
3751
|
if (permissionProfile.warning) ctx.ui.notify(permissionProfile.warning, "warning");
|
|
3665
3752
|
if (permissionProfile.mode === "trusted-full-access") {
|
|
3666
3753
|
ctx.ui.notify("Piagent permission profile trusted-full-access is active; protected paths, secret redaction, and destructive/external confirmations remain enforced.", "warning");
|
|
3667
3754
|
}
|
|
3755
|
+
// Last, so a release announcement never pushes a security warning out of
|
|
3756
|
+
// the first thing the operator reads.
|
|
3757
|
+
const updateNotice = updateAvailabilityNotice();
|
|
3758
|
+
if (updateNotice) ctx.ui.notify(updateNotice, "info");
|
|
3668
3759
|
});
|
|
3669
3760
|
|
|
3670
3761
|
pi.on("input", async (event, ctx) => {
|
|
@@ -4675,10 +4766,12 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4675
4766
|
entry.context7.digest = snapshot.digest;
|
|
4676
4767
|
manifest.updatedAt = nowIso();
|
|
4677
4768
|
fs.writeFileSync(techStackPath(ctx.cwd), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
4678
|
-
|
|
4679
|
-
|
|
4680
|
-
|
|
4681
|
-
|
|
4769
|
+
// Write back the document the project stores, not the resolved one: saving
|
|
4770
|
+
// the resolved copy would inline the adapter and stop it following.
|
|
4771
|
+
const stored = readJsonFile<ProjectProfile>(projectProfilePath(ctx.cwd));
|
|
4772
|
+
if (stored?.techStack) {
|
|
4773
|
+
stored.techStack.updatedAt = manifest.updatedAt;
|
|
4774
|
+
writeProfileDocumentWithLock(extensionDir, ctx.cwd, stored);
|
|
4682
4775
|
}
|
|
4683
4776
|
appendTrace(ctx.cwd, { event: "profile_tech_context_record", techId, role: entry.role, libraryId: snapshot.resolvedLibraryId });
|
|
4684
4777
|
appendSessionTrace(pi, { event: "profile_tech_context_record", techId, role: entry.role, libraryId: snapshot.resolvedLibraryId });
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
// Telling someone a new release exists is only useful if it costs them nothing.
|
|
8
|
+
// A session start that waits on the registry pays a network round trip every
|
|
9
|
+
// time Pi opens, for information that changes a few times a month, so the check
|
|
10
|
+
// never runs in the session's path: the session reads a cache file and, when
|
|
11
|
+
// that cache is old, leaves a detached process behind to refresh it. The notice
|
|
12
|
+
// lands on this session if the cache already knew, and on the next one if it did
|
|
13
|
+
// not.
|
|
14
|
+
//
|
|
15
|
+
// Everything read here comes from outside the process — a registry response and
|
|
16
|
+
// a file any local process can write — so the only value that ever reaches the
|
|
17
|
+
// terminal is a version string that matched RELEASE_VERSION. A registry that
|
|
18
|
+
// answers with a sentence, or a cache someone edited to carry one, produces no
|
|
19
|
+
// notice rather than a line of attacker-chosen text in the operator's terminal.
|
|
20
|
+
|
|
21
|
+
const HELPER_PACKAGE = "@piagent/platform";
|
|
22
|
+
const CACHE_FILE = "piagent-update-check.json";
|
|
23
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
24
|
+
const PROBE_TIMEOUT_MS = 10_000;
|
|
25
|
+
const MAX_CACHE_BYTES = 4096;
|
|
26
|
+
const RELEASE_VERSION = /^(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})$/;
|
|
27
|
+
|
|
28
|
+
/** @param {string} [home] @returns {string|undefined} */
|
|
29
|
+
export function updateCacheFile(home) {
|
|
30
|
+
const base = home ?? os.homedir();
|
|
31
|
+
if (typeof base !== "string" || base.length === 0) return undefined;
|
|
32
|
+
return path.join(base, ".pi", CACHE_FILE);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A release version and nothing else. Prereleases and build metadata are
|
|
37
|
+
* rejected rather than parsed: this decides what to tell someone to install, and
|
|
38
|
+
* nudging an operator onto a prerelease is not what the notice is for.
|
|
39
|
+
* @param {unknown} value @returns {string|undefined}
|
|
40
|
+
*/
|
|
41
|
+
export function releaseVersion(value) {
|
|
42
|
+
if (typeof value !== "string") return undefined;
|
|
43
|
+
const trimmed = value.trim();
|
|
44
|
+
return RELEASE_VERSION.test(trimmed) ? trimmed : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Compare two release versions numerically. `1.1.10` is after `1.1.9`, which
|
|
49
|
+
* string ordering gets backwards.
|
|
50
|
+
* @param {string} left @param {string} right @returns {number}
|
|
51
|
+
*/
|
|
52
|
+
export function compareReleaseVersions(left, right) {
|
|
53
|
+
const leftParts = left.split(".").map(Number);
|
|
54
|
+
const rightParts = right.split(".").map(Number);
|
|
55
|
+
for (let index = 0; index < 3; index += 1) {
|
|
56
|
+
if (leftParts[index] !== rightParts[index]) return leftParts[index] < rightParts[index] ? -1 : 1;
|
|
57
|
+
}
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** @param {string} [home] @returns {{latest?: string, checkedAt?: number}} */
|
|
62
|
+
export function readUpdateCache(home) {
|
|
63
|
+
const file = updateCacheFile(home);
|
|
64
|
+
if (!file) return {};
|
|
65
|
+
try {
|
|
66
|
+
// A cache larger than this is not something this code wrote.
|
|
67
|
+
if (fs.statSync(file).size > MAX_CACHE_BYTES) return {};
|
|
68
|
+
const document = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
69
|
+
const checkedAt = Date.parse(document?.checkedAt);
|
|
70
|
+
return {
|
|
71
|
+
latest: releaseVersion(document?.latest),
|
|
72
|
+
checkedAt: Number.isFinite(checkedAt) ? checkedAt : undefined
|
|
73
|
+
};
|
|
74
|
+
} catch {
|
|
75
|
+
return {};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** @param {string|undefined} home @param {string} latest @param {number} now @returns {boolean} */
|
|
80
|
+
export function writeUpdateCache(home, latest, now) {
|
|
81
|
+
const file = updateCacheFile(home);
|
|
82
|
+
const version = releaseVersion(latest);
|
|
83
|
+
if (!file || !version) return false;
|
|
84
|
+
const document = {
|
|
85
|
+
schemaVersion: 1,
|
|
86
|
+
package: HELPER_PACKAGE,
|
|
87
|
+
latest: version,
|
|
88
|
+
checkedAt: new Date(now).toISOString()
|
|
89
|
+
};
|
|
90
|
+
try {
|
|
91
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
92
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
93
|
+
fs.writeFileSync(temporary, `${JSON.stringify(document, null, 2)}\n`);
|
|
94
|
+
fs.renameSync(temporary, file);
|
|
95
|
+
return true;
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* What the session should do about updates, decided from state alone so it can
|
|
103
|
+
* be tested without a registry or a clock.
|
|
104
|
+
* @param {{installed?: string, cache?: {latest?: string, checkedAt?: number}, now: number, env?: Record<string, string|undefined>}} options
|
|
105
|
+
* @returns {{notice?: string, probe: boolean}}
|
|
106
|
+
*/
|
|
107
|
+
export function evaluateUpdateCheck(options) {
|
|
108
|
+
const env = options.env ?? process.env;
|
|
109
|
+
if (env.PIAGENT_NO_UPDATE_CHECK?.trim()) return { probe: false };
|
|
110
|
+
|
|
111
|
+
const installed = releaseVersion(options.installed);
|
|
112
|
+
const cache = options.cache ?? {};
|
|
113
|
+
const latest = releaseVersion(cache.latest);
|
|
114
|
+
const probe = !(typeof cache.checkedAt === "number" && options.now - cache.checkedAt < CACHE_TTL_MS);
|
|
115
|
+
|
|
116
|
+
if (!installed || !latest || compareReleaseVersions(latest, installed) <= 0) return { probe };
|
|
117
|
+
return {
|
|
118
|
+
probe,
|
|
119
|
+
notice: `Piagent update available: ${installed} -> ${latest}. Run \`piagent-update\` to move this machine to it.`
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Leave the refresh behind and return. The child is detached with no inherited
|
|
125
|
+
* stdio, so nothing it does can hold the session open or write into the
|
|
126
|
+
* terminal.
|
|
127
|
+
* @param {string} moduleFile @param {{spawn?: typeof spawn}} [options]
|
|
128
|
+
* @returns {boolean}
|
|
129
|
+
*/
|
|
130
|
+
export function startUpdateProbe(moduleFile, options = {}) {
|
|
131
|
+
try {
|
|
132
|
+
const child = (options.spawn ?? spawn)(process.execPath, [moduleFile, "--probe"], {
|
|
133
|
+
detached: true,
|
|
134
|
+
stdio: "ignore",
|
|
135
|
+
windowsHide: true
|
|
136
|
+
});
|
|
137
|
+
child.unref();
|
|
138
|
+
return true;
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** @returns {string|undefined} */
|
|
145
|
+
function fetchLatestVersion() {
|
|
146
|
+
const result = spawnSync("npm", ["view", HELPER_PACKAGE, "version", "--json"], {
|
|
147
|
+
encoding: "utf8",
|
|
148
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
149
|
+
windowsHide: true
|
|
150
|
+
});
|
|
151
|
+
if (result.status !== 0 || typeof result.stdout !== "string") return undefined;
|
|
152
|
+
try {
|
|
153
|
+
return releaseVersion(JSON.parse(result.stdout));
|
|
154
|
+
} catch {
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** @param {{now?: number, home?: string, fetch?: () => string|undefined}} [options] @returns {boolean} */
|
|
160
|
+
export function runUpdateProbe(options = {}) {
|
|
161
|
+
const latest = (options.fetch ?? fetchLatestVersion)();
|
|
162
|
+
// A registry that could not be reached leaves the cache alone, so the next
|
|
163
|
+
// session retries instead of recording "no update" as if it had been checked.
|
|
164
|
+
if (!latest) return false;
|
|
165
|
+
return writeUpdateCache(options.home, latest, options.now ?? Date.now());
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (process.argv[2] === "--probe" && process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
169
|
+
runUpdateProbe();
|
|
170
|
+
}
|
|
@@ -6,19 +6,37 @@
|
|
|
6
6
|
"required": [
|
|
7
7
|
"schemaVersion",
|
|
8
8
|
"projectId",
|
|
9
|
-
"displayName"
|
|
10
|
-
"mode",
|
|
11
|
-
"rootMarkers",
|
|
12
|
-
"protectedPaths",
|
|
13
|
-
"requiredContext",
|
|
14
|
-
"verifyCommands",
|
|
15
|
-
"mcpCapabilities"
|
|
9
|
+
"displayName"
|
|
16
10
|
],
|
|
11
|
+
"if": {
|
|
12
|
+
"required": [
|
|
13
|
+
"extends"
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
"then": {
|
|
17
|
+
"description": "A profile that names an adapter stores identity plus overrides. Every policy field it omits comes from the installed adapter at runtime, so none of them are required here."
|
|
18
|
+
},
|
|
19
|
+
"else": {
|
|
20
|
+
"required": [
|
|
21
|
+
"mode",
|
|
22
|
+
"rootMarkers",
|
|
23
|
+
"protectedPaths",
|
|
24
|
+
"requiredContext",
|
|
25
|
+
"verifyCommands",
|
|
26
|
+
"mcpCapabilities"
|
|
27
|
+
]
|
|
28
|
+
},
|
|
17
29
|
"properties": {
|
|
18
30
|
"schemaVersion": {
|
|
19
31
|
"type": "integer",
|
|
20
32
|
"const": 1
|
|
21
33
|
},
|
|
34
|
+
"extends": {
|
|
35
|
+
"description": "Name of a built-in adapter under adapters/ in the installed platform. Its profile is the base; every other key in this file replaces the adapter's key of the same name.",
|
|
36
|
+
"type": "string",
|
|
37
|
+
"minLength": 1,
|
|
38
|
+
"pattern": "^[a-z0-9][a-z0-9-]*$"
|
|
39
|
+
},
|
|
22
40
|
"projectId": {
|
|
23
41
|
"type": "string",
|
|
24
42
|
"minLength": 1,
|
|
@@ -20,6 +20,10 @@ import {
|
|
|
20
20
|
validateCapabilitySources,
|
|
21
21
|
vendorRemoteSource
|
|
22
22
|
} from "../packages/piagent-core/capabilities/capability-sources.js";
|
|
23
|
+
import {
|
|
24
|
+
buildExtendingProfile,
|
|
25
|
+
resolveProjectProfileDocument
|
|
26
|
+
} from "../packages/piagent-core/capabilities/project-profile.js";
|
|
23
27
|
|
|
24
28
|
const platformRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
25
29
|
const catalogPath = path.join(platformRoot, "catalog", "capabilities.json");
|
|
@@ -28,7 +32,7 @@ function usage() {
|
|
|
28
32
|
process.stdout.write(`Usage:
|
|
29
33
|
piagent-capabilities catalog [--check | --write]
|
|
30
34
|
piagent-capabilities resolve --profile <profile.json> [--output <lock.json>] [--package-source <source>]
|
|
31
|
-
piagent-capabilities apply-profile --profile <source.json> --target <piagent-profile.json> [--package-source <source>] [--force]
|
|
35
|
+
piagent-capabilities apply-profile --profile <source.json> --target <piagent-profile.json> [--extends <adapter>] [--package-source <source>] [--force]
|
|
32
36
|
piagent-capabilities doctor [--profile <profile.json>] [--lock <lock.json>] [--package-source <source>]
|
|
33
37
|
piagent-capabilities vendor --profile <profile.json> [--source <name>]
|
|
34
38
|
piagent-capabilities validate-source --package-source <source>
|
|
@@ -58,7 +62,7 @@ function parseArguments(values) {
|
|
|
58
62
|
output.flags.set(value, true);
|
|
59
63
|
continue;
|
|
60
64
|
}
|
|
61
|
-
if (!["--profile", "--output", "--target", "--lock", "--file", "--package-source", "--source"].includes(value)) throw new CapabilityValidationError(`unknown option ${value}`);
|
|
65
|
+
if (!["--profile", "--extends", "--output", "--target", "--lock", "--file", "--package-source", "--source"].includes(value)) throw new CapabilityValidationError(`unknown option ${value}`);
|
|
62
66
|
if (output.flags.has(value)) throw new CapabilityValidationError(`duplicate option ${value}`);
|
|
63
67
|
const next = values[index + 1];
|
|
64
68
|
if (!next || next.startsWith("--")) throw new CapabilityValidationError(`${value} requires a value`);
|
|
@@ -157,7 +161,7 @@ function runResolve(flags) {
|
|
|
157
161
|
}
|
|
158
162
|
|
|
159
163
|
function runApplyProfile(flags) {
|
|
160
|
-
assertAllowedFlags(flags, new Set(["--profile", "--target", "--package-source", "--force"]));
|
|
164
|
+
assertAllowedFlags(flags, new Set(["--profile", "--extends", "--target", "--package-source", "--force"]));
|
|
161
165
|
const source = requireExistingFile(flags.get("--profile"), "--profile");
|
|
162
166
|
const targetValue = flags.get("--target");
|
|
163
167
|
if (!targetValue) throw new CapabilityValidationError("--target is required");
|
|
@@ -166,17 +170,27 @@ function runApplyProfile(flags) {
|
|
|
166
170
|
const parent = fs.realpathSync(path.dirname(target));
|
|
167
171
|
const normalizedTarget = path.join(parent, path.basename(target));
|
|
168
172
|
if (fs.existsSync(normalizedTarget) && !flags.has("--force")) throw new CapabilityValidationError("target profile already exists; pass --force to replace it");
|
|
169
|
-
const
|
|
173
|
+
const adapter = readJson(source);
|
|
174
|
+
const base = flags.get("--extends");
|
|
175
|
+
// With --extends the project records which adapter it follows and keeps only
|
|
176
|
+
// its own identity, so a later platform correction reaches it. Without it the
|
|
177
|
+
// source document is copied whole, which is what a caller supplying its own
|
|
178
|
+
// profile file wants.
|
|
179
|
+
const stored = base === undefined
|
|
180
|
+
? adapter
|
|
181
|
+
: buildExtendingProfile(base, adapter.projectId, adapter.displayName);
|
|
182
|
+
const profile = resolveProjectProfileDocument(platformRoot, stored).profile;
|
|
170
183
|
const packageSource = flags.get("--package-source") ?? "workspace";
|
|
171
184
|
const lock = resolveCapabilityProfileDocument(platformRoot, profile, {
|
|
172
185
|
profileFile: path.basename(normalizedTarget),
|
|
186
|
+
storedProfile: stored,
|
|
173
187
|
packageSource,
|
|
174
188
|
extraRoots: profile.capabilitySources === undefined
|
|
175
189
|
? undefined
|
|
176
190
|
: resolveCapabilitySourceRoots(projectRootForProfile(normalizedTarget), profile.capabilitySources)
|
|
177
191
|
});
|
|
178
192
|
const lockTarget = path.join(parent, "piagent-profile.lock.json");
|
|
179
|
-
writeProfileLockAtomic(normalizedTarget,
|
|
193
|
+
writeProfileLockAtomic(normalizedTarget, stored, lockTarget, lock);
|
|
180
194
|
process.stdout.write(`${JSON.stringify({ ok: true, profile: normalizedTarget, lock: lockTarget, packs: lock.packs.length })}\n`);
|
|
181
195
|
}
|
|
182
196
|
|
|
@@ -210,11 +224,18 @@ function runDoctor(flags) {
|
|
|
210
224
|
const verification = verifyCapabilityLock(platformRoot, profile, readJson(lockFile), { packageSource, extraRoots });
|
|
211
225
|
report.lock = {
|
|
212
226
|
path: lockFile,
|
|
213
|
-
current: verification.
|
|
227
|
+
current: verification.status === "current",
|
|
228
|
+
status: verification.status,
|
|
229
|
+
reasons: verification.reasons,
|
|
214
230
|
expectedDigest: verification.expectedDigest,
|
|
215
231
|
actualDigest: verification.actualDigest
|
|
216
232
|
};
|
|
217
|
-
|
|
233
|
+
// A re-pinnable lock is behind the installed build but grants exactly what
|
|
234
|
+
// it agreed to, so it is reported rather than treated as a failure. The
|
|
235
|
+
// guard rewrites it on the next session.
|
|
236
|
+
if (verification.status === "blocked") {
|
|
237
|
+
throw new CapabilityValidationError(`capability lock no longer matches what the project agreed to: ${verification.reasons.join("; ")}`);
|
|
238
|
+
}
|
|
218
239
|
}
|
|
219
240
|
} else if (flags.has("--lock")) {
|
|
220
241
|
throw new CapabilityValidationError("--lock requires --profile");
|
package/scripts/init-project.sh
CHANGED
|
@@ -219,6 +219,13 @@ if [[ "$FORCE_PROFILE" == true || ! -f "$PROFILE_TARGET" ]]; then
|
|
|
219
219
|
--target "$PROFILE_TARGET"
|
|
220
220
|
--package-source "$PACKAGE_SOURCE"
|
|
221
221
|
)
|
|
222
|
+
# A built-in adapter is referenced, not copied, so a later platform correction
|
|
223
|
+
# reaches this project. A caller supplying its own profile file still gets that
|
|
224
|
+
# file written whole.
|
|
225
|
+
if [[ "$PROFILE_PATH" == "$PLATFORM_ROOT/adapters/"*"/profile.json" ]]; then
|
|
226
|
+
adapter_name="${PROFILE_PATH#"$PLATFORM_ROOT/adapters/"}"
|
|
227
|
+
apply_profile_args+=(--extends "${adapter_name%/profile.json}")
|
|
228
|
+
fi
|
|
222
229
|
if [[ "$FORCE_PROFILE" == true ]]; then
|
|
223
230
|
apply_profile_args+=(--force)
|
|
224
231
|
fi
|